1 3
import inspect
2 3
import json
3 3
import traceback
4

5 3
import pykka
6

7

8 3
class JsonRpcWrapper:
9

10
    """
11
    Wrap objects and make them accessible through JSON-RPC 2.0 messaging.
12

13
    This class takes responsibility of communicating with the objects and
14
    processing of JSON-RPC 2.0 messages. The transport of the messages over
15
    HTTP, WebSocket, TCP, or whatever is of no concern to this class.
16

17
    The wrapper supports exporting the methods of one or more objects. Either
18
    way, the objects must be exported with method name prefixes, called
19
    "mounts".
20

21
    To expose objects, add them all to the objects mapping. The key in the
22
    mapping is used as the object's mounting point in the exposed API::
23

24
       jrw = JsonRpcWrapper(objects={
25
           'foo': foo,
26
           'hello': lambda: 'Hello, world!',
27
       })
28

29
    This will export the Python callables on the left as the JSON-RPC 2.0
30
    method names on the right::
31

32
        foo.bar() -> foo.bar
33
        foo.baz() -> foo.baz
34
        lambda    -> hello
35

36
    Only the public methods of the mounted objects, or functions/methods
37
    included directly in the mapping, will be exposed.
38

39
    If a method returns a :class:`pykka.Future`, the future will be completed
40
    and its value unwrapped before the JSON-RPC wrapper returns the response.
41

42
    For further details on the JSON-RPC 2.0 spec, see
43
    http://www.jsonrpc.org/specification
44

45
    :param objects: mapping between mounting points and exposed functions or
46
        class instances
47
    :type objects: dict
48
    :param decoders: object builders to be used by :func`json.loads`
49
    :type decoders: list of functions taking a dict and returning a dict
50
    :param encoders: object serializers to be used by :func:`json.dumps`
51
    :type encoders: list of :class:`json.JSONEncoder` subclasses with the
52
        method :meth:`default` implemented
53
    """
54

55 3
    def __init__(self, objects, decoders=None, encoders=None):
56 3
        if "" in objects.keys():
57 3
            raise AttributeError(
58
                "The empty string is not allowed as an object mount"
59
            )
60 3
        self.objects = objects
61 3
        self.decoder = get_combined_json_decoder(decoders or [])
62 3
        self.encoder = get_combined_json_encoder(encoders or [])
63

64 3
    def handle_json(self, request):
65
        """
66
        Handles an incoming request encoded as a JSON string.
67

68
        Returns a response as a JSON string for commands, and :class:`None` for
69
        notifications.
70

71
        :param request: the serialized JSON-RPC request
72
        :type request: string
73
        :rtype: string or :class:`None`
74
        """
75 3
        try:
76 3
            request = json.loads(request, object_hook=self.decoder)
77 3
        except ValueError:
78 3
            response = JsonRpcParseError().get_response()
79
        else:
80 3
            response = self.handle_data(request)
81 3
        if response is None:
82 3
            return None
83 3
        return json.dumps(response, cls=self.encoder)
84

85 3
    def handle_data(self, request):
86
        """
87
        Handles an incoming request in the form of a Python data structure.
88

89
        Returns a Python data structure for commands, or a :class:`None` for
90
        notifications.
91

92
        :param request: the unserialized JSON-RPC request
93
        :type request: dict
94
        :rtype: dict, list, or :class:`None`
95
        """
96 3
        if isinstance(request, list):
97 3
            return self._handle_batch(request)
98
        else:
99 3
            return self._handle_single_request(request)
100

101 3
    def _handle_batch(self, requests):
102 3
        if not requests:
103 3
            return JsonRpcInvalidRequestError(
104
                data="Batch list cannot be empty"
105
            ).get_response()
106

107 3
        responses = []
108 3
        for request in requests:
109 3
            response = self._handle_single_request(request)
110 3
            if response:
111 3
                responses.append(response)
112

113 3
        return responses or None
114

115 3
    def _handle_single_request(self, request):
116 3
        try:
117 3
            self._validate_request(request)
118 3
            args, kwargs = self._get_params(request)
119 3
        except JsonRpcInvalidRequestError as error:
120 3
            return error.get_response()
121

122 3
        try:
123 3
            method = self._get_method(request["method"])
124

125 3
            try:
126 3
                result = method(*args, **kwargs)
127

128 3
                if self._is_notification(request):
129 3
                    return None
130

131 3
                result = self._unwrap_result(result)
132

133 3
                return {
134
                    "jsonrpc": "2.0",
135
                    "id": request["id"],
136
                    "result": result,
137
                }
138 3
            except TypeError as error:
139 3
                raise JsonRpcInvalidParamsError(
140
                    data={
141
                        "type": error.__class__.__name__,
142
                        "message": str(error),
143
                        "traceback": traceback.format_exc(),
144
                    }
145
                )
146 3
            except Exception as error:
147 3
                raise JsonRpcApplicationError(
148
                    data={
149
                        "type": error.__class__.__name__,
150
                        "message": str(error),
151
                        "traceback": traceback.format_exc(),
152
                    }
153
                )
154 3
        except JsonRpcError as error:
155 3
            if self._is_notification(request):
156 3
                return None
157 3
            return error.get_response(request["id"])
158

159 3
    def _validate_request(self, request):
160 3
        if not isinstance(request, dict):
161 3
            raise JsonRpcInvalidRequestError(data="Request must be an object")
162 3
        if "jsonrpc" not in request:
163 3
            raise JsonRpcInvalidRequestError(
164
                data="'jsonrpc' member must be included"
165
            )
166 3
        if request["jsonrpc"] != "2.0":
167 3
            raise JsonRpcInvalidRequestError(
168
                data="'jsonrpc' value must be '2.0'"
169
            )
170 3
        if "method" not in request:
171 3
            raise JsonRpcInvalidRequestError(
172
                data="'method' member must be included"
173
            )
174 3
        if not isinstance(request["method"], str):
175 3
            raise JsonRpcInvalidRequestError(data="'method' must be a string")
176

177 3
    def _get_params(self, request):
178 3
        if "params" not in request:
179 3
            return [], {}
180 3
        params = request["params"]
181 3
        if isinstance(params, list):
182 3
            return params, {}
183 3
        elif isinstance(params, dict):
184 3
            return [], params
185
        else:
186 3
            raise JsonRpcInvalidRequestError(
187
                data="'params', if given, must be an array or an object"
188
            )
189

190 3
    def _get_method(self, method_path):
191 3
        if callable(self.objects.get(method_path, None)):
192
            # The mounted object is the callable
193 3
            return self.objects[method_path]
194

195
        # The mounted object contains the callable
196

197 3
        if "." not in method_path:
198 3
            raise JsonRpcMethodNotFoundError(
199
                data=f"Could not find object mount in method name {method_path!r}"
200
            )
201

202 3
        mount, method_name = method_path.rsplit(".", 1)
203

204 3
        if method_name.startswith("_"):
205 3
            raise JsonRpcMethodNotFoundError(
206
                data="Private methods are not exported"
207
            )
208

209 3
        try:
210 3
            obj = self.objects[mount]
211 3
        except KeyError:
212 3
            raise JsonRpcMethodNotFoundError(
213
                data=f"No object found at {mount!r}"
214
            )
215

216 3
        try:
217 3
            return getattr(obj, method_name)
218 3
        except AttributeError:
219 3
            raise JsonRpcMethodNotFoundError(
220
                data=f"Object mounted at {mount!r} has no member {method_name!r}"
221
            )
222

223 3
    def _is_notification(self, request):
224 3
        return "id" not in request
225

226 3
    def _unwrap_result(self, result):
227 3
        if isinstance(result, pykka.Future):
228 3
            result = result.get()
229 3
        return result
230

231

232 3
class JsonRpcError(Exception):
233 3
    code = -32000
234 3
    message = "Unspecified server error"
235

236 3
    def __init__(self, data=None):
237 3
        self.data = data
238

239 3
    def get_response(self, request_id=None):
240 3
        response = {
241
            "jsonrpc": "2.0",
242
            "id": request_id,
243
            "error": {"code": self.code, "message": self.message},
244
        }
245 3
        if self.data:
246 3
            response["error"]["data"] = self.data
247 3
        return response
248

249

250 3
class JsonRpcParseError(JsonRpcError):
251 3
    code = -32700
252 3
    message = "Parse error"
253

254

255 3
class JsonRpcInvalidRequestError(JsonRpcError):
256 3
    code = -32600
257 3
    message = "Invalid Request"
258

259

260 3
class JsonRpcMethodNotFoundError(JsonRpcError):
261 3
    code = -32601
262 3
    message = "Method not found"
263

264

265 3
class JsonRpcInvalidParamsError(JsonRpcError):
266 3
    code = -32602
267 3
    message = "Invalid params"
268

269

270 3
class JsonRpcApplicationError(JsonRpcError):
271 3
    code = 0
272 3
    message = "Application error"
273

274

275 3
def get_combined_json_decoder(decoders):
276 3
    def decode(dct):
277 3
        for decoder in decoders:
278 3
            dct = decoder(dct)
279 3
        return dct
280

281 3
    return decode
282

283

284 3
def get_combined_json_encoder(encoders):
285 3
    class JsonRpcEncoder(json.JSONEncoder):
286 3
        def default(self, obj):
287 3
            for encoder in encoders:
288 3
                try:
289 3
                    return encoder().default(obj)
290 0
                except TypeError:
291 0
                    pass  # Try next encoder
292 0
            return json.JSONEncoder.default(self, obj)
293

294 3
    return JsonRpcEncoder
295

296

297 3
class JsonRpcInspector:
298

299
    """
300
    Inspects a group of classes and functions to create a description of what
301
    methods they can expose over JSON-RPC 2.0.
302

303
    To inspect one or more classes, add them all to the objects mapping. The
304
    key in the mapping is used as the classes' mounting point in the exposed
305
    API::
306

307
        jri = JsonRpcInspector(objects={
308
            'foo': Foo,
309
            'hello': lambda: 'Hello, world!',
310
        })
311

312
    Since the inspector is based on inspecting classes and not instances, it
313
    will not include methods added dynamically. The wrapper works with
314
    instances, and it will thus export dynamically added methods as well.
315

316
    :param objects: mapping between mounts and exposed functions or classes
317
    :type objects: dict
318
    """
319

320 3
    def __init__(self, objects):
321 3
        if "" in objects.keys():
322 3
            raise AttributeError(
323
                "The empty string is not allowed as an object mount"
324
            )
325 3
        self.objects = objects
326

327 3
    def describe(self):
328
        """
329
        Inspects the object and returns a data structure which describes the
330
        available properties and methods.
331
        """
332 3
        methods = {}
333 3
        for mount, obj in self.objects.items():
334 3
            if inspect.isroutine(obj):
335 3
                methods[mount] = self._describe_method(obj)
336
            else:
337 3
                obj_methods = self._get_methods(obj)
338 3
                for name, description in obj_methods.items():
339 3
                    if mount:
340 3
                        name = f"{mount}.{name}"
341 3
                    methods[name] = description
342 3
        return methods
343

344 3
    def _get_methods(self, obj):
345 3
        methods = {}
346 3
        for name, value in inspect.getmembers(obj):
347 3
            if name.startswith("_"):
348 3
                continue
349 3
            if not inspect.isroutine(value):
350 0
                continue
351 3
            method = self._describe_method(value)
352 3
            if method:
353 3
                methods[name] = method
354 3
        return methods
355

356 3
    def _describe_method(self, method):
357 3
        return {
358
            "description": inspect.getdoc(method),
359
            "params": self._describe_params(method),
360
        }
361

362 3
    def _describe_params(self, method):
363 3
        argspec = inspect.getfullargspec(method)
364

365 3
        defaults = argspec.defaults and list(argspec.defaults) or []
366 3
        num_args_without_default = len(argspec.args) - len(defaults)
367 3
        no_defaults = [None] * num_args_without_default
368 3
        defaults = no_defaults + defaults
369

370 3
        params = []
371

372 3
        for arg, _default in zip(argspec.args, defaults):
373 3
            if arg == "self":
374 3
                continue
375 3
            params.append({"name": arg})
376

377 3
        if argspec.defaults:
378 3
            for i, default in enumerate(reversed(argspec.defaults)):
379 3
                params[len(params) - i - 1]["default"] = default
380

381 3
        if argspec.varargs:
382 3
            params.append({"name": argspec.varargs, "varargs": True})
383

384 3
        if argspec.varkw:
385 3
            params.append({"name": argspec.varkw, "kwargs": True})
386

387 3
        return params

Read our documentation on viewing source code .

Loading