/ 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 "jsonrpc":"2.0", per JSON-RPC 2.0
 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.reuse_http_connections = True
 79          self.__url = urllib.parse.urlparse(service_url)
 80          user = None if self.__url.username is None else self.__url.username.encode('utf8')
 81          passwd = None if self.__url.password is None else self.__url.password.encode('utf8')
 82          authpair = user + b':' + passwd
 83          self.__auth_header = b'Basic ' + base64.b64encode(authpair)
 84          # clamp the socket timeout, since larger values can cause an
 85          # "Invalid argument" exception in Python's HTTP(S) client
 86          # library on some operating systems (e.g. OpenBSD, FreeBSD)
 87          self.timeout = min(timeout, 2147483)
 88          self._set_conn(connection)
 89  
 90      def __getattr__(self, name):
 91          if name.startswith('__') and name.endswith('__'):
 92              # Python internal stuff
 93              raise AttributeError
 94          if self._service_name is not None:
 95              name = "%s.%s" % (self._service_name, name)
 96          if not self.reuse_http_connections:
 97              self._set_conn()
 98          return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
 99  
100      def _request(self, method, path, postdata):
101          '''
102          Do a HTTP request.
103          '''
104          headers = {'Host': self.__url.hostname,
105                     'User-Agent': USER_AGENT,
106                     'Authorization': self.__auth_header,
107                     'Content-type': 'application/json'}
108          if not self.reuse_http_connections:
109              self._set_conn()
110          self.__conn.request(method, path, postdata, headers)
111          return self._get_response()
112  
113      def _json_dumps(self, obj):
114          return json.dumps(obj, default=serialization_fallback, ensure_ascii=self.ensure_ascii)
115  
116      def get_request(self, *args, **argsn):
117          AuthServiceProxy.__id_count += 1
118  
119          log.debug("-{}-> {} {} {}".format(
120              AuthServiceProxy.__id_count,
121              self._service_name,
122              self._json_dumps(args),
123              self._json_dumps(argsn),
124          ))
125  
126          if args and argsn:
127              params = dict(args=args, **argsn)
128          else:
129              params = args or argsn
130          return {'jsonrpc': '2.0',
131                  'method': self._service_name,
132                  'params': params,
133                  'id': AuthServiceProxy.__id_count}
134  
135      def __call__(self, *args, **argsn):
136          postdata = self._json_dumps(self.get_request(*args, **argsn))
137          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
138          # For backwards compatibility tests, accept JSON RPC 1.1 responses
139          if 'jsonrpc' not in response:
140              if response['error'] is not None:
141                  raise JSONRPCException(response['error'], status)
142              elif 'result' not in response:
143                  raise JSONRPCException({
144                      'code': -343, 'message': 'missing JSON-RPC result'}, status)
145              elif status != HTTPStatus.OK:
146                  raise JSONRPCException({
147                      'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
148              else:
149                  return response['result']
150          else:
151              assert response['jsonrpc'] == '2.0'
152              if status != HTTPStatus.OK:
153                  raise JSONRPCException({
154                      'code': -342, 'message': 'non-200 HTTP status code'}, status)
155              if 'error' in response:
156                  raise JSONRPCException(response['error'], status)
157              elif 'result' not in response:
158                  raise JSONRPCException({
159                      'code': -343, 'message': 'missing JSON-RPC 2.0 result and error'}, status)
160              return response['result']
161  
162      def batch(self, rpc_call_list):
163          postdata = self._json_dumps(list(rpc_call_list))
164          log.debug("--> " + postdata)
165          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
166          if status != HTTPStatus.OK:
167              raise JSONRPCException({
168                  'code': -342, 'message': 'non-200 HTTP status code'}, status)
169          return response
170  
171      def _get_response(self):
172          req_start_time = time.time()
173          try:
174              http_response = self.__conn.getresponse()
175          except socket.timeout:
176              raise JSONRPCException({
177                  'code': -344,
178                  'message': '%r RPC took longer than %f seconds. Consider '
179                             'using larger timeout for calls that take '
180                             'longer to return.' % (self._service_name,
181                                                    self.__conn.timeout)})
182          if http_response is None:
183              raise JSONRPCException({
184                  'code': -342, 'message': 'missing HTTP response from server'})
185  
186          # Check for no-content HTTP status code, which can be returned when an
187          # RPC client requests a JSON-RPC 2.0 "notification" with no response.
188          # Currently this is only possible if clients call the _request() method
189          # directly to send a raw request.
190          if http_response.status == HTTPStatus.NO_CONTENT:
191              if len(http_response.read()) != 0:
192                  raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
193              return None, http_response.status
194  
195          content_type = http_response.getheader('Content-Type')
196          if content_type != 'application/json':
197              raise JSONRPCException(
198                  {'code': -342, 'message': 'non-JSON HTTP response with \'%i %s\' from server' % (http_response.status, http_response.reason)},
199                  http_response.status)
200  
201          data = http_response.read()
202          try:
203              responsedata = data.decode('utf8')
204          except UnicodeDecodeError as e:
205              raise JSONRPCException({
206                  'code': -342, 'message': f'Cannot decode response in utf8 format, content: {data}, exception: {e}'})
207          response = json.loads(responsedata, parse_float=decimal.Decimal)
208          elapsed = time.time() - req_start_time
209          if "error" in response and response["error"] is None:
210              log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, self._json_dumps(response["result"])))
211          else:
212              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
213          return response, http_response.status
214  
215      def __truediv__(self, relative_uri):
216          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
217  
218      def _set_conn(self, connection=None):
219          port = 80 if self.__url.port is None else self.__url.port
220          if connection:
221              self.__conn = connection
222              self.timeout = connection.timeout
223          elif self.__url.scheme == 'https':
224              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
225          else:
226              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)