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)