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