/ test / functional / test_framework / authproxy.py
authproxy.py
  1  # Copyright (c) 2011 Jeff Garzik
  2  #
  3  # Previous copyright, from python-jsonrpc/jsonrpc/proxy.py:
  4  #
  5  # Copyright (c) 2007 Jan-Klaas Kollhof
  6  #
  7  # This file is part of jsonrpc.
  8  #
  9  # jsonrpc is free software; you can redistribute it and/or modify
 10  # it under the terms of the GNU Lesser General Public License as published by
 11  # the Free Software Foundation; either version 2.1 of the License, or
 12  # (at your option) any later version.
 13  #
 14  # This software is distributed in the hope that it will be useful,
 15  # but WITHOUT ANY WARRANTY; without even the implied warranty of
 16  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 17  # GNU Lesser General Public License for more details.
 18  #
 19  # You should have received a copy of the GNU Lesser General Public License
 20  # along with this software; if not, write to the Free Software
 21  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 22  """HTTP proxy for opening RPC connection to bitcoind.
 23  
 24  AuthServiceProxy has the following improvements over python-jsonrpc's
 25  ServiceProxy class:
 26  
 27  - HTTP connections persist for the life of the AuthServiceProxy object
 28    (if server supports HTTP/1.1)
 29  - sends protocol 'version', per JSON-RPC 1.1
 30  - sends proper, incrementing 'id'
 31  - sends Basic HTTP authentication headers
 32  - parses all JSON numbers that look like floats as Decimal
 33  - uses standard Python json lib
 34  """
 35  
 36  import base64
 37  import decimal
 38  from http import HTTPStatus
 39  import http.client
 40  import json
 41  import logging
 42  import pathlib
 43  import socket
 44  import time
 45  import urllib.parse
 46  
 47  HTTP_TIMEOUT = 30
 48  USER_AGENT = "AuthServiceProxy/0.1"
 49  
 50  log = logging.getLogger("BitcoinRPC")
 51  
 52  class JSONRPCException(Exception):
 53      def __init__(self, rpc_error, http_status=None):
 54          try:
 55              errmsg = '%(message)s (%(code)i)' % rpc_error
 56          except (KeyError, TypeError):
 57              errmsg = ''
 58          super().__init__(errmsg)
 59          self.error = rpc_error
 60          self.http_status = http_status
 61  
 62  
 63  def serialization_fallback(o):
 64      if isinstance(o, decimal.Decimal):
 65          return str(o)
 66      if isinstance(o, pathlib.Path):
 67          return str(o)
 68      raise TypeError(repr(o) + " is not JSON serializable")
 69  
 70  class AuthServiceProxy():
 71      __id_count = 0
 72  
 73      # ensure_ascii: escape unicode as \uXXXX, passed to json.dumps
 74      def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None, ensure_ascii=True):
 75          self.__service_url = service_url
 76          self._service_name = service_name
 77          self.ensure_ascii = ensure_ascii  # can be toggled on the fly by tests
 78          self.__url = urllib.parse.urlparse(service_url)
 79          user = None if self.__url.username is None else self.__url.username.encode('utf8')
 80          passwd = None if self.__url.password is None else self.__url.password.encode('utf8')
 81          authpair = user + b':' + passwd
 82          self.__auth_header = b'Basic ' + base64.b64encode(authpair)
 83          # clamp the socket timeout, since larger values can cause an
 84          # "Invalid argument" exception in Python's HTTP(S) client
 85          # library on some operating systems (e.g. OpenBSD, FreeBSD)
 86          self.timeout = min(timeout, 2147483)
 87          self._set_conn(connection)
 88  
 89      def __getattr__(self, name):
 90          if name.startswith('__') and name.endswith('__'):
 91              # Python internal stuff
 92              raise AttributeError
 93          if self._service_name is not None:
 94              name = "%s.%s" % (self._service_name, name)
 95          return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
 96  
 97      def _request(self, method, path, postdata):
 98          '''
 99          Do a HTTP request.
100          '''
101          headers = {'Host': self.__url.hostname,
102                     'User-Agent': USER_AGENT,
103                     'Authorization': self.__auth_header,
104                     'Content-type': 'application/json'}
105          self.__conn.request(method, path, postdata, headers)
106          return self._get_response()
107  
108      def get_request(self, *args, **argsn):
109          AuthServiceProxy.__id_count += 1
110  
111          log.debug("-{}-> {} {}".format(
112              AuthServiceProxy.__id_count,
113              self._service_name,
114              json.dumps(args or argsn, default=serialization_fallback, ensure_ascii=self.ensure_ascii),
115          ))
116          if args and argsn:
117              params = dict(args=args, **argsn)
118          else:
119              params = args or argsn
120          return {'version': '1.1',
121                  'method': self._service_name,
122                  'params': params,
123                  'id': AuthServiceProxy.__id_count}
124  
125      def __call__(self, *args, **argsn):
126          postdata = json.dumps(self.get_request(*args, **argsn), default=serialization_fallback, ensure_ascii=self.ensure_ascii)
127          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
128          if response['error'] is not None:
129              raise JSONRPCException(response['error'], status)
130          elif 'result' not in response:
131              raise JSONRPCException({
132                  'code': -343, 'message': 'missing JSON-RPC result'}, status)
133          elif status != HTTPStatus.OK:
134              raise JSONRPCException({
135                  'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
136          else:
137              return response['result']
138  
139      def batch(self, rpc_call_list):
140          postdata = json.dumps(list(rpc_call_list), default=serialization_fallback, ensure_ascii=self.ensure_ascii)
141          log.debug("--> " + postdata)
142          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
143          if status != HTTPStatus.OK:
144              raise JSONRPCException({
145                  'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
146          return response
147  
148      def _get_response(self):
149          req_start_time = time.time()
150          try:
151              http_response = self.__conn.getresponse()
152          except socket.timeout:
153              raise JSONRPCException({
154                  'code': -344,
155                  'message': '%r RPC took longer than %f seconds. Consider '
156                             'using larger timeout for calls that take '
157                             'longer to return.' % (self._service_name,
158                                                    self.__conn.timeout)})
159          if http_response is None:
160              raise JSONRPCException({
161                  'code': -342, 'message': 'missing HTTP response from server'})
162  
163          content_type = http_response.getheader('Content-Type')
164          if content_type != 'application/json':
165              raise JSONRPCException(
166                  {'code': -342, 'message': 'non-JSON HTTP response with \'%i %s\' from server' % (http_response.status, http_response.reason)},
167                  http_response.status)
168  
169          responsedata = http_response.read().decode('utf8')
170          response = json.loads(responsedata, parse_float=decimal.Decimal)
171          elapsed = time.time() - req_start_time
172          if "error" in response and response["error"] is None:
173              log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, json.dumps(response["result"], default=serialization_fallback, ensure_ascii=self.ensure_ascii)))
174          else:
175              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
176          return response, http_response.status
177  
178      def __truediv__(self, relative_uri):
179          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
180  
181      def _set_conn(self, connection=None):
182          port = 80 if self.__url.port is None else self.__url.port
183          if connection:
184              self.__conn = connection
185              self.timeout = connection.timeout
186          elif self.__url.scheme == 'https':
187              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
188          else:
189              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)