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