authproxy.py raw

   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 limenkad.
  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("LimenkaRPC")
  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 _json_dumps(self, obj):
 109          return json.dumps(obj, default=serialization_fallback, ensure_ascii=self.ensure_ascii)
 110  
 111      def get_request(self, *args, **argsn):
 112          AuthServiceProxy.__id_count += 1
 113  
 114          log.debug("-{}-> {} {} {}".format(
 115              AuthServiceProxy.__id_count,
 116              self._service_name,
 117              self._json_dumps(args),
 118              self._json_dumps(argsn),
 119          ))
 120  
 121          if args and argsn:
 122              params = dict(args=args, **argsn)
 123          else:
 124              params = args or argsn
 125          return {'jsonrpc': '2.0',
 126                  'method': self._service_name,
 127                  'params': params,
 128                  'id': AuthServiceProxy.__id_count}
 129  
 130      def __call__(self, *args, **argsn):
 131          postdata = self._json_dumps(self.get_request(*args, **argsn))
 132          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
 133          # For backwards compatibility tests, accept JSON RPC 1.1 responses
 134          if 'jsonrpc' not in response:
 135              if response['error'] is not None:
 136                  raise JSONRPCException(response['error'], status)
 137              elif 'result' not in response:
 138                  raise JSONRPCException({
 139                      'code': -343, 'message': 'missing JSON-RPC result'}, status)
 140              elif status != HTTPStatus.OK:
 141                  raise JSONRPCException({
 142                      'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
 143              else:
 144                  return response['result']
 145          else:
 146              assert response['jsonrpc'] == '2.0'
 147              if status != HTTPStatus.OK:
 148                  raise JSONRPCException({
 149                      'code': -342, 'message': 'non-200 HTTP status code'}, status)
 150              if 'error' in response:
 151                  raise JSONRPCException(response['error'], status)
 152              elif 'result' not in response:
 153                  raise JSONRPCException({
 154                      'code': -343, 'message': 'missing JSON-RPC 2.0 result and error'}, status)
 155              return response['result']
 156  
 157      def batch(self, rpc_call_list):
 158          postdata = self._json_dumps(list(rpc_call_list))
 159          log.debug("--> " + postdata)
 160          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
 161          if status != HTTPStatus.OK:
 162              raise JSONRPCException({
 163                  'code': -342, 'message': 'non-200 HTTP status code'}, status)
 164          return response
 165  
 166      def _get_response(self):
 167          req_start_time = time.time()
 168          try:
 169              http_response = self.__conn.getresponse()
 170          except socket.timeout:
 171              raise JSONRPCException({
 172                  'code': -344,
 173                  'message': '%r RPC took longer than %f seconds. Consider '
 174                             'using larger timeout for calls that take '
 175                             'longer to return.' % (self._service_name,
 176                                                    self.__conn.timeout)})
 177          if http_response is None:
 178              raise JSONRPCException({
 179                  'code': -342, 'message': 'missing HTTP response from server'})
 180  
 181          # Check for no-content HTTP status code, which can be returned when an
 182          # RPC client requests a JSON-RPC 2.0 "notification" with no response.
 183          # Currently this is only possible if clients call the _request() method
 184          # directly to send a raw request.
 185          if http_response.status == HTTPStatus.NO_CONTENT:
 186              if len(http_response.read()) != 0:
 187                  raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
 188              return None, http_response.status
 189  
 190          content_type = http_response.getheader('Content-Type')
 191          if content_type != 'application/json':
 192              raise JSONRPCException(
 193                  {'code': -342, 'message': 'non-JSON HTTP response with \'%i %s\' from server' % (http_response.status, http_response.reason)},
 194                  http_response.status)
 195  
 196          data = http_response.read()
 197          try:
 198              responsedata = data.decode('utf8')
 199          except UnicodeDecodeError as e:
 200              raise JSONRPCException({
 201                  'code': -342, 'message': f'Cannot decode response in utf8 format, content: {data}, exception: {e}'})
 202          response = json.loads(responsedata, parse_float=decimal.Decimal)
 203          elapsed = time.time() - req_start_time
 204          if "error" in response and response["error"] is None:
 205              log.debug("<-%s- [%.6f] %s" % (response.get("id"), elapsed, self._json_dumps(response["result"])))
 206          else:
 207              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
 208          return response, http_response.status
 209  
 210      def __truediv__(self, relative_uri):
 211          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
 212  
 213      def _set_conn(self, connection=None):
 214          port = 80 if self.__url.port is None else self.__url.port
 215          if connection:
 216              self.__conn = connection
 217              self.timeout = connection.timeout
 218          elif self.__url.scheme == 'https':
 219              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
 220          else:
 221              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)
 222