• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1JSON-RPC Example
2================
3
4.. contents::
5
6:author: Ian Bicking
7
8Introduction
9------------
10
11This is an example of how to write a web service using WebOb.  The
12example shows how to create a `JSON-RPC <http://json-rpc.org/>`_
13endpoint using WebOb and the `simplejson
14<http://www.undefined.org/python/#simplejson>`_ JSON library.  This
15also shows how to use WebOb as a client library using `WSGIProxy
16<http://pythonpaste.org/wsgiproxy/>`_.
17
18While this example presents JSON-RPC, this is not an endorsement of
19JSON-RPC.  In fact I don't like JSON-RPC.  It's unnecessarily
20un-RESTful, and modelled too closely on `XML-RPC
21<http://www.xmlrpc.com/>`_.
22
23Code
24----
25
26The finished code for this is available in
27`docs/json-example-code/jsonrpc.py
28<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-example-code/jsonrpc.py>`_
29-- you can run that file as a script to try it out, or import it.
30
31Concepts
32--------
33
34JSON-RPC wraps an object, allowing you to call methods on that object
35and get the return values.  It also provides a way to get error
36responses.
37
38The `specification
39<http://json-rpc.org/wd/JSON-RPC-1-1-WD-20060807.html>`_ goes into the
40details (though in a vague sort of way).  Here's the basics:
41
42* All access goes through a POST to a single URL.
43
44* The POST contains a JSON body that looks like::
45
46   {"method": "methodName",
47    "id": "arbitrary-something",
48    "params": [arg1, arg2, ...]}
49
50* The ``id`` parameter is just a convenience for the client to keep
51  track of which response goes with which request.  This makes
52  asynchronous calls (like an XMLHttpRequest) easier.  We just send
53  the exact same id back as we get, we never look at it.
54
55* The response is JSON.  A successful response looks like::
56
57    {"result": the_result,
58     "error": null,
59     "id": "arbitrary-something"}
60
61* The error response looks like::
62
63    {"result": null,
64     "error": {"name": "JSONRPCError",
65               "code": (number 100-999),
66               "message": "Some Error Occurred",
67               "error": "whatever you want\n(a traceback?)"},
68     "id": "arbitrary-something"}
69
70* It doesn't seem to indicate if an error response should have a 200
71  response or a 500 response.  So as not to be completely stupid about
72  HTTP, we choose a 500 resonse, as giving an error with a 200
73  response is irresponsible.
74
75Infrastructure
76--------------
77
78To make this easier to test, we'll set up a bit of infrastructure.
79This will open up a server (using `wsgiref
80<http://python.org/doc/current/lib/module-wsgiref.simpleserver.html>`_)
81and serve up our application (note that *creating* the application is
82left out to start with):
83
84.. code-block:: python
85
86    import sys
87
88    def main(args=None):
89        import optparse
90        from wsgiref import simple_server
91        parser = optparse.OptionParser(
92            usage="%prog [OPTIONS] MODULE:EXPRESSION")
93        parser.add_option(
94            '-p', '--port', default='8080',
95            help='Port to serve on (default 8080)')
96        parser.add_option(
97            '-H', '--host', default='127.0.0.1',
98            help='Host to serve on (default localhost; 0.0.0.0 to make public)')
99        if args is None:
100            args = sys.argv[1:]
101        options, args = parser.parse_args()
102        if not args or len(args) > 1:
103            print 'You must give a single object reference'
104            parser.print_help()
105            sys.exit(2)
106        app = make_app(args[0])
107        server = simple_server.make_server(
108            options.host, int(options.port),
109            app)
110        print 'Serving on http://%s:%s' % (options.host, options.port)
111        server.serve_forever()
112
113    if __name__ == '__main__':
114        main()
115
116I won't describe this much.  It starts a server, serving up just the
117app created by ``make_app(args[0])``.  ``make_app`` will have to load
118up the object and wrap it in our WSGI/WebOb wrapper.  We'll be calling
119that wrapper ``JSONRPC(obj)``, so here's how it'll go:
120
121.. code-block:: python
122
123    def make_app(expr):
124        module, expression = expr.split(':', 1)
125        __import__(module)
126        module = sys.modules[module]
127        obj = eval(expression, module.__dict__)
128        return JsonRpcApp(obj)
129
130We use ``__import__(module)`` to import the module, but its return
131value is wonky.  We can find the thing it imported in ``sys.modules``
132(a dictionary of all the loaded modules).  Then we evaluate the second
133part of the expression in the namespace of the module.  This lets you
134do something like ``smtplib:SMTP('localhost')`` to get a fully
135instantiated SMTP object.
136
137That's all the infrastructure we'll need for the server side.  Now we
138just have to implement ``JsonRpcApp``.
139
140The Application Wrapper
141-----------------------
142
143Note that I'm calling this an "application" because that's the
144terminology WSGI uses.  Everything that gets *called* is an
145"application", and anything that calls an application is called a
146"server".
147
148The instantiation of the server is already figured out:
149
150.. code-block:: python
151
152    class JsonRpcApp(object):
153
154        def __init__(self, obj):
155            self.obj = obj
156
157        def __call__(self, environ, start_response):
158            ... the WSGI interface ...
159
160So the server is an instance bound to the particular object being
161exposed, and ``__call__`` implements the WSGI interface.
162
163We'll start with a simple outline of the WSGI interface, using a kind
164of standard WebOb setup:
165
166.. code-block:: python
167
168    from webob import Request, Response
169    from webob import exc
170
171    class JsonRpcApp(object):
172        ...
173        def __call__(self, environ, start_response):
174            req = Request(environ)
175            try:
176                resp = self.process(req)
177            except ValueError, e:
178                resp = exc.HTTPBadRequest(str(e))
179            except exc.HTTPException, e:
180                resp = e
181            return resp(environ, start_response)
182
183We first create a request object.  The request object just wraps the
184WSGI environment.  Then we create the response object in the
185``process`` method (which we still have to write).  We also do some
186exception catching.  We'll turn any ``ValueError`` into a ``400 Bad
187Request`` response.  We'll also let ``process`` raise any
188``web.exc.HTTPException`` exception.  There's an exception defined in
189that module for all the HTTP error responses, like ``405 Method Not
190Allowed``.  These exceptions are themselves WSGI applications (as is
191``webob.Response``), and so we call them like WSGI applications and
192return the result.
193
194The ``process`` method
195----------------------
196
197The ``process`` method of course is where all the fancy stuff
198happens.  We'll start with just the most minimal implementation, with
199no error checking or handling:
200
201.. code-block:: python
202
203    from simplejson import loads, dumps
204
205    class JsonRpcApp(object):
206        ...
207        def process(self, req):
208            json = loads(req.body)
209            method = json['method']
210            params = json['params']
211            id = json['id']
212            method = getattr(self.obj, method)
213            result = method(*params)
214            resp = Response(
215                content_type='application/json',
216                body=dumps(dict(result=result,
217                                error=None,
218                                id=id)))
219            return resp
220
221As long as the request is properly formed and the method doesn't raise
222any exceptions, you are pretty much set.  But of course that's not a
223reasonable expectation.  There's a whole bunch of things that can go
224wrong.  For instance, it has to be a POST method:
225
226.. code-block:: python
227
228    if not req.method == 'POST':
229        raise exc.HTTPMethodNotAllowed(
230            "Only POST allowed",
231            allowed='POST')
232
233And maybe the request body doesn't contain valid JSON:
234
235.. code-block:: python
236
237    try:
238        json = loads(req.body)
239    except ValueError, e:
240        raise ValueError('Bad JSON: %s' % e)
241
242And maybe all the keys aren't in the dictionary:
243
244.. code-block:: python
245
246    try:
247        method = json['method']
248        params = json['params']
249        id = json['id']
250    except KeyError, e:
251        raise ValueError(
252            "JSON body missing parameter: %s" % e)
253
254And maybe it's trying to acces a private method (a method that starts
255with ``_``) -- that's not just a bad request, we'll call that case
256``403 Forbidden``.
257
258.. code-block:: python
259
260    if method.startswith('_'):
261        raise exc.HTTPForbidden(
262            "Bad method name %s: must not start with _" % method)
263
264And maybe ``json['params']`` isn't a list:
265
266.. code-block:: python
267
268    if not isinstance(params, list):
269        raise ValueError(
270            "Bad params %r: must be a list" % params)
271
272And maybe the method doesn't exist:
273
274.. code-block:: python
275
276    try:
277        method = getattr(self.obj, method)
278    except AttributeError:
279        raise ValueError(
280            "No such method %s" % method)
281
282The last case is the error we actually can expect: that the method
283raises some exception.
284
285.. code-block:: python
286
287    try:
288        result = method(*params)
289    except:
290        tb = traceback.format_exc()
291        exc_value = sys.exc_info()[1]
292        error_value = dict(
293            name='JSONRPCError',
294            code=100,
295            message=str(exc_value),
296            error=tb)
297        return Response(
298            status=500,
299            content_type='application/json',
300            body=dumps(dict(result=None,
301                            error=error_value,
302                            id=id)))
303
304That's a complete server.
305
306The Complete Code
307-----------------
308
309Since we showed all the error handling in pieces, here's the complete
310code:
311
312.. code-block:: python
313
314    from webob import Request, Response
315    from webob import exc
316    from simplejson import loads, dumps
317    import traceback
318    import sys
319
320    class JsonRpcApp(object):
321        """
322        Serve the given object via json-rpc (http://json-rpc.org/)
323        """
324
325        def __init__(self, obj):
326            self.obj = obj
327
328        def __call__(self, environ, start_response):
329            req = Request(environ)
330            try:
331                resp = self.process(req)
332            except ValueError, e:
333                resp = exc.HTTPBadRequest(str(e))
334            except exc.HTTPException, e:
335                resp = e
336            return resp(environ, start_response)
337
338        def process(self, req):
339            if not req.method == 'POST':
340                raise exc.HTTPMethodNotAllowed(
341                    "Only POST allowed",
342                    allowed='POST')
343            try:
344                json = loads(req.body)
345            except ValueError, e:
346                raise ValueError('Bad JSON: %s' % e)
347            try:
348                method = json['method']
349                params = json['params']
350                id = json['id']
351            except KeyError, e:
352                raise ValueError(
353                    "JSON body missing parameter: %s" % e)
354            if method.startswith('_'):
355                raise exc.HTTPForbidden(
356                    "Bad method name %s: must not start with _" % method)
357            if not isinstance(params, list):
358                raise ValueError(
359                    "Bad params %r: must be a list" % params)
360            try:
361                method = getattr(self.obj, method)
362            except AttributeError:
363                raise ValueError(
364                    "No such method %s" % method)
365            try:
366                result = method(*params)
367            except:
368                text = traceback.format_exc()
369                exc_value = sys.exc_info()[1]
370                error_value = dict(
371                    name='JSONRPCError',
372                    code=100,
373                    message=str(exc_value),
374                    error=text)
375                return Response(
376                    status=500,
377                    content_type='application/json',
378                    body=dumps(dict(result=None,
379                                    error=error_value,
380                                    id=id)))
381            return Response(
382                content_type='application/json',
383                body=dumps(dict(result=result,
384                                error=None,
385                                id=id)))
386
387The Client
388----------
389
390It would be nice to have a client to test out our server.  Using
391`WSGIProxy`_ we can use WebOb
392Request and Response to do actual HTTP connections.
393
394The basic idea is that you can create a blank Request:
395
396.. code-block:: python
397
398    >>> from webob import Request
399    >>> req = Request.blank('http://python.org')
400
401Then you can send that request to an application:
402
403.. code-block:: python
404
405    >>> from wsgiproxy.exactproxy import proxy_exact_request
406    >>> resp = req.get_response(proxy_exact_request)
407
408This particular application (``proxy_exact_request``) sends the
409request over HTTP:
410
411.. code-block:: python
412
413    >>> resp.content_type
414    'text/html'
415    >>> resp.body[:10]
416    '<!DOCTYPE '
417
418So we're going to create a proxy object that constructs WebOb-based
419jsonrpc requests, and sends those using ``proxy_exact_request``.
420
421The Proxy Client
422----------------
423
424The proxy client is instantiated with its base URL.  We'll also let
425you pass in a proxy application, in case you want to do local requests
426(e.g., to do direct tests against a ``JsonRpcApp`` instance):
427
428.. code-block:: python
429
430    class ServerProxy(object):
431
432        def __init__(self, url, proxy=None):
433            self._url = url
434            if proxy is None:
435                from wsgiproxy.exactproxy import proxy_exact_request
436                proxy = proxy_exact_request
437            self.proxy = proxy
438
439This ServerProxy object itself doesn't do much, but you can call methods on
440it.  We can intercept any access ``ServerProxy(...).method`` with the
441magic function ``__getattr__``.  Whenever you get an attribute that
442doesn't exist in an instance, Python will call
443``inst.__getattr__(attr_name)`` and return that.  When you *call* a
444method, you are calling the object that ``.method`` returns.  So we'll
445create a helper object that is callable, and our ``__getattr__`` will
446just return that:
447
448.. code-block:: python
449
450    class ServerProxy(object):
451        ...
452        def __getattr__(self, name):
453            # Note, even attributes like __contains__ can get routed
454            # through __getattr__
455            if name.startswith('_'):
456                raise AttributeError(name)
457            return _Method(self, name)
458
459    class _Method(object):
460        def __init__(self, parent, name):
461            self.parent = parent
462            self.name = name
463
464Now when we call the method we'll be calling ``_Method.__call__``, and
465the HTTP endpoint will be ``self.parent._url``, and the method name
466will be ``self.name``.
467
468Here's the code to do the call:
469
470.. code-block:: python
471
472    class _Method(object):
473        ...
474
475        def __call__(self, *args):
476            json = dict(method=self.name,
477                        id=None,
478                        params=list(args))
479            req = Request.blank(self.parent._url)
480            req.method = 'POST'
481            req.content_type = 'application/json'
482            req.body = dumps(json)
483            resp = req.get_response(self.parent.proxy)
484            if resp.status_code != 200 and not (
485                resp.status_code == 500
486                and resp.content_type == 'application/json'):
487                raise ProxyError(
488                    "Error from JSON-RPC client %s: %s"
489                    % (self._url, resp.status),
490                    resp)
491            json = loads(resp.body)
492            if json.get('error') is not None:
493                e = Fault(
494                    json['error'].get('message'),
495                    json['error'].get('code'),
496                    json['error'].get('error'),
497                    resp)
498                raise e
499            return json['result']
500
501We raise two kinds of exceptions here.  ``ProxyError`` is when
502something unexpected happens, like a ``404 Not Found``.  ``Fault`` is
503when a more expected exception occurs, i.e., the underlying method
504raised an exception.
505
506In both cases we'll keep the response object around, as that can be
507interesting.  Note that you can make exceptions have any methods or
508signature you want, which we'll do:
509
510.. code-block:: python
511
512    class ProxyError(Exception):
513        """
514        Raised when a request via ServerProxy breaks
515        """
516        def __init__(self, message, response):
517            Exception.__init__(self, message)
518            self.response = response
519
520    class Fault(Exception):
521        """
522        Raised when there is a remote error
523        """
524        def __init__(self, message, code, error, response):
525            Exception.__init__(self, message)
526            self.code = code
527            self.error = error
528            self.response = response
529        def __str__(self):
530            return 'Method error calling %s: %s\n%s' % (
531                self.response.request.url,
532                self.args[0],
533                self.error)
534
535Using Them Together
536-------------------
537
538Good programmers start with tests.  But at least we'll end with a
539test.  We'll use `doctest
540<http://python.org/doc/current/lib/module-doctest.html>`_ for our
541tests.  The test is in `docs/json-example-code/test_jsonrpc.txt
542<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-example-code/test_jsonrpc.txt>`_
543and you can run it with `docs/json-example-code/test_jsonrpc.py
544<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-example-code/test_jsonrpc.py>`_,
545which looks like:
546
547.. code-block:: python
548
549    if __name__ == '__main__':
550        import doctest
551        doctest.testfile('test_jsonrpc.txt')
552
553As you can see, it's just a stub to run the doctest.  We'll need a
554simple object to expose.  We'll make it real simple:
555
556.. code-block:: python
557
558    >>> class Divider(object):
559    ...     def divide(self, a, b):
560    ...        return a / b
561
562Then we'll get the app setup:
563
564.. code-block:: python
565
566    >>> from jsonrpc import *
567    >>> app = JsonRpcApp(Divider())
568
569And attach the client *directly* to it:
570
571.. code-block:: python
572
573    >>> proxy = ServerProxy('http://localhost:8080', proxy=app)
574
575Because we gave the app itself as the proxy, the URL doesn't actually
576matter.
577
578Now, if you are used to testing you might ask: is this kosher?  That
579is, we are shortcircuiting HTTP entirely.  Is this a realistic test?
580
581One thing you might be worried about in this case is that there are
582more shared objects than you'd have with HTTP.  That is, everything
583over HTTP is serialized to headers and bodies.  Without HTTP, we can
584send stuff around that can't go over HTTP.  This *could* happen, but
585we're mostly protected because the only thing the application's share
586is the WSGI ``environ``.  Even though we use a ``webob.Request``
587object on both side, it's not the *same* request object, and all the
588state is studiously kept in the environment.  We *could* share things
589in the environment that couldn't go over HTTP.  For instance, we could
590set ``environ['jsonrpc.request_value'] = dict(...)``, and avoid
591``simplejson.dumps`` and ``simplejson.loads``.  We *could* do that,
592and if we did then it is possible our test would work even though the
593libraries were broken over HTTP.  But of course inspection shows we
594*don't* do that.  A little discipline is required to resist playing clever
595tricks (or else you can play those tricks and do more testing).
596Generally it works well.
597
598So, now we have a proxy, lets use it:
599
600.. code-block:: python
601
602    >>> proxy.divide(10, 4)
603    2
604    >>> proxy.divide(10, 4.0)
605    2.5
606
607Lastly, we'll test a couple error conditions.  First a method error:
608
609.. code-block:: python
610
611    >>> proxy.divide(10, 0) # doctest: +ELLIPSIS
612    Traceback (most recent call last):
613       ...
614    Fault: Method error calling http://localhost:8080: integer division or modulo by zero
615    Traceback (most recent call last):
616      File ...
617        result = method(*params)
618      File ...
619        return a / b
620    ZeroDivisionError: integer division or modulo by zero
621    <BLANKLINE>
622
623It's hard to actually predict this exception, because the test of the
624exception itself contains the traceback from the underlying call, with
625filenames and line numbers that aren't stable.  We use ``# doctest:
626+ELLIPSIS`` so that we can replace text we don't care about with
627``...``.  This is actually figured out through copy-and-paste, and
628visual inspection to make sure it looks sensible.
629
630The other exception can be:
631
632.. code-block:: python
633
634    >>> proxy.add(1, 1)
635    Traceback (most recent call last):
636        ...
637    ProxyError: Error from JSON-RPC client http://localhost:8080: 400 Bad Request
638
639Here the exception isn't a JSON-RPC method exception, but a more basic
640ProxyError exception.
641
642Conclusion
643----------
644
645Hopefully this will give you ideas about how to implement web services
646of different kinds using WebOb.  I hope you also can appreciate the
647elegance of the symmetry of the request and response objects, and the
648client and server for the protocol.
649
650Many of these techniques would be better used with a `RESTful
651<http://en.wikipedia.org/wiki/Representational_State_Transfer>`_
652service, so do think about that direction if you are implementing your
653own protocol.
654