Home | History | Annotate | Download | only in docs
      1 JSON-RPC Example
      2 ================
      3 
      4 .. contents::
      5 
      6 :author: Ian Bicking
      7 
      8 Introduction
      9 ------------
     10 
     11 This is an example of how to write a web service using WebOb.  The
     12 example shows how to create a `JSON-RPC <http://json-rpc.org/>`_
     13 endpoint using WebOb and the `simplejson
     14 <http://www.undefined.org/python/#simplejson>`_ JSON library.  This
     15 also shows how to use WebOb as a client library using `WSGIProxy
     16 <http://pythonpaste.org/wsgiproxy/>`_.
     17 
     18 While this example presents JSON-RPC, this is not an endorsement of
     19 JSON-RPC.  In fact I don't like JSON-RPC.  It's unnecessarily
     20 un-RESTful, and modelled too closely on `XML-RPC
     21 <http://www.xmlrpc.com/>`_.
     22 
     23 Code
     24 ----
     25 
     26 The 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 
     31 Concepts
     32 --------
     33 
     34 JSON-RPC wraps an object, allowing you to call methods on that object
     35 and get the return values.  It also provides a way to get error
     36 responses.
     37 
     38 The `specification
     39 <http://json-rpc.org/wd/JSON-RPC-1-1-WD-20060807.html>`_ goes into the
     40 details (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 
     75 Infrastructure
     76 --------------
     77 
     78 To make this easier to test, we'll set up a bit of infrastructure.
     79 This will open up a server (using `wsgiref
     80 <http://python.org/doc/current/lib/module-wsgiref.simpleserver.html>`_)
     81 and serve up our application (note that *creating* the application is
     82 left 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 
    116 I won't describe this much.  It starts a server, serving up just the
    117 app created by ``make_app(args[0])``.  ``make_app`` will have to load
    118 up the object and wrap it in our WSGI/WebOb wrapper.  We'll be calling
    119 that 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 
    130 We use ``__import__(module)`` to import the module, but its return
    131 value 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
    133 part of the expression in the namespace of the module.  This lets you
    134 do something like ``smtplib:SMTP('localhost')`` to get a fully
    135 instantiated SMTP object.
    136 
    137 That's all the infrastructure we'll need for the server side.  Now we
    138 just have to implement ``JsonRpcApp``.
    139 
    140 The Application Wrapper
    141 -----------------------
    142 
    143 Note that I'm calling this an "application" because that's the
    144 terminology WSGI uses.  Everything that gets *called* is an
    145 "application", and anything that calls an application is called a
    146 "server".
    147 
    148 The 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 
    160 So the server is an instance bound to the particular object being
    161 exposed, and ``__call__`` implements the WSGI interface.
    162 
    163 We'll start with a simple outline of the WSGI interface, using a kind
    164 of 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 
    183 We first create a request object.  The request object just wraps the
    184 WSGI environment.  Then we create the response object in the
    185 ``process`` method (which we still have to write).  We also do some
    186 exception catching.  We'll turn any ``ValueError`` into a ``400 Bad
    187 Request`` response.  We'll also let ``process`` raise any
    188 ``web.exc.HTTPException`` exception.  There's an exception defined in
    189 that module for all the HTTP error responses, like ``405 Method Not
    190 Allowed``.  These exceptions are themselves WSGI applications (as is
    191 ``webob.Response``), and so we call them like WSGI applications and
    192 return the result.
    193 
    194 The ``process`` method
    195 ----------------------
    196 
    197 The ``process`` method of course is where all the fancy stuff
    198 happens.  We'll start with just the most minimal implementation, with
    199 no 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 
    221 As long as the request is properly formed and the method doesn't raise
    222 any exceptions, you are pretty much set.  But of course that's not a
    223 reasonable expectation.  There's a whole bunch of things that can go
    224 wrong.  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 
    233 And 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 
    242 And 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 
    254 And maybe it's trying to acces a private method (a method that starts
    255 with ``_``) -- 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 
    264 And 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 
    272 And 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 
    282 The last case is the error we actually can expect: that the method
    283 raises 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 
    304 That's a complete server.
    305 
    306 The Complete Code
    307 -----------------
    308 
    309 Since we showed all the error handling in pieces, here's the complete
    310 code:
    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 
    387 The Client
    388 ----------
    389 
    390 It would be nice to have a client to test out our server.  Using
    391 `WSGIProxy`_ we can use WebOb
    392 Request and Response to do actual HTTP connections.
    393 
    394 The 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 
    401 Then 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 
    408 This particular application (``proxy_exact_request``) sends the
    409 request over HTTP:
    410 
    411 .. code-block:: python
    412 
    413     >>> resp.content_type
    414     'text/html'
    415     >>> resp.body[:10]
    416     '<!DOCTYPE '
    417 
    418 So we're going to create a proxy object that constructs WebOb-based
    419 jsonrpc requests, and sends those using ``proxy_exact_request``.
    420 
    421 The Proxy Client
    422 ----------------
    423 
    424 The proxy client is instantiated with its base URL.  We'll also let
    425 you 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 
    439 This ServerProxy object itself doesn't do much, but you can call methods on
    440 it.  We can intercept any access ``ServerProxy(...).method`` with the
    441 magic function ``__getattr__``.  Whenever you get an attribute that
    442 doesn't exist in an instance, Python will call
    443 ``inst.__getattr__(attr_name)`` and return that.  When you *call* a
    444 method, you are calling the object that ``.method`` returns.  So we'll
    445 create a helper object that is callable, and our ``__getattr__`` will
    446 just 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 
    464 Now when we call the method we'll be calling ``_Method.__call__``, and
    465 the HTTP endpoint will be ``self.parent._url``, and the method name
    466 will be ``self.name``.
    467 
    468 Here'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 
    501 We raise two kinds of exceptions here.  ``ProxyError`` is when
    502 something unexpected happens, like a ``404 Not Found``.  ``Fault`` is
    503 when a more expected exception occurs, i.e., the underlying method
    504 raised an exception.
    505 
    506 In both cases we'll keep the response object around, as that can be
    507 interesting.  Note that you can make exceptions have any methods or
    508 signature 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 
    535 Using Them Together
    536 -------------------
    537 
    538 Good programmers start with tests.  But at least we'll end with a
    539 test.  We'll use `doctest
    540 <http://python.org/doc/current/lib/module-doctest.html>`_ for our
    541 tests.  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>`_
    543 and 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>`_,
    545 which looks like:
    546 
    547 .. code-block:: python
    548 
    549     if __name__ == '__main__':
    550         import doctest
    551         doctest.testfile('test_jsonrpc.txt')
    552 
    553 As you can see, it's just a stub to run the doctest.  We'll need a
    554 simple 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 
    562 Then we'll get the app setup:
    563 
    564 .. code-block:: python
    565 
    566     >>> from jsonrpc import *
    567     >>> app = JsonRpcApp(Divider())
    568 
    569 And attach the client *directly* to it:
    570 
    571 .. code-block:: python
    572 
    573     >>> proxy = ServerProxy('http://localhost:8080', proxy=app)
    574 
    575 Because we gave the app itself as the proxy, the URL doesn't actually
    576 matter.
    577 
    578 Now, if you are used to testing you might ask: is this kosher?  That
    579 is, we are shortcircuiting HTTP entirely.  Is this a realistic test?
    580 
    581 One thing you might be worried about in this case is that there are
    582 more shared objects than you'd have with HTTP.  That is, everything
    583 over HTTP is serialized to headers and bodies.  Without HTTP, we can
    584 send stuff around that can't go over HTTP.  This *could* happen, but
    585 we're mostly protected because the only thing the application's share
    586 is the WSGI ``environ``.  Even though we use a ``webob.Request``
    587 object on both side, it's not the *same* request object, and all the
    588 state is studiously kept in the environment.  We *could* share things
    589 in the environment that couldn't go over HTTP.  For instance, we could
    590 set ``environ['jsonrpc.request_value'] = dict(...)``, and avoid
    591 ``simplejson.dumps`` and ``simplejson.loads``.  We *could* do that,
    592 and if we did then it is possible our test would work even though the
    593 libraries were broken over HTTP.  But of course inspection shows we
    594 *don't* do that.  A little discipline is required to resist playing clever
    595 tricks (or else you can play those tricks and do more testing).
    596 Generally it works well.
    597 
    598 So, 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 
    607 Lastly, 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 
    623 It's hard to actually predict this exception, because the test of the
    624 exception itself contains the traceback from the underlying call, with
    625 filenames 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
    628 visual inspection to make sure it looks sensible.
    629 
    630 The 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 
    639 Here the exception isn't a JSON-RPC method exception, but a more basic
    640 ProxyError exception.
    641 
    642 Conclusion
    643 ----------
    644 
    645 Hopefully this will give you ideas about how to implement web services
    646 of different kinds using WebOb.  I hope you also can appreciate the
    647 elegance of the symmetry of the request and response objects, and the
    648 client and server for the protocol.
    649 
    650 Many of these techniques would be better used with a `RESTful
    651 <http://en.wikipedia.org/wiki/Representational_State_Transfer>`_
    652 service, so do think about that direction if you are implementing your
    653 own protocol.
    654