JSON-RPC Example — WebOb 1.8.11 documentation

# JSON-RPC Example

author:

  Ian Bicking

## Introduction

This is an example of how to write a web service using WebOb. The example shows how to create a JSON-RPC endpoint using WebOb and the simplejson JSON library. This also shows how to use WebOb as a client library using WSGIProxy.

While this example presents JSON-RPC, this is not an endorsement of JSON-RPC. In fact I don't like JSON-RPC. It's unnecessarily un-RESTful, and modelled too closely on XML-RPC.

## Code

The finished code for this is available in docs/json-example-code/jsonrpc.py -- you can run that file as a script to try it out, or import it.

## Concepts

JSON-RPC wraps an object, allowing you to call methods on that object and get the return values. It also provides a way to get error responses.

The specification goes into the details (though in a vague sort of way). Here's the basics:

- All access goes through a POST to a single URL.

- The POST contains a JSON body that looks like:

      {"method": "methodName",
       "id": "arbitrary-something",
       "params": [arg1, arg2, ...]}

- The `id` parameter is just a convenience for the client to keep track of which response goes with which request. This makes asynchronous calls (like an XMLHttpRequest) easier. We just send the exact same id back as we get, we never look at it.

- The response is JSON. A successful response looks like:

      {"result": the_result,
       "error": null,
       "id": "arbitrary-something"}

- The error response looks like:

      {"result": null,
       "error": {"name": "JSONRPCError",
                 "code": (number 100-999),
                 "message": "Some Error Occurred",
                 "error": "whatever you want\n(a traceback?)"},
       "id": "arbitrary-something"}

- It doesn't seem to indicate if an error response should have a 200 response or a 500 response. So as not to be completely stupid about HTTP, we choose a 500 resonse, as giving an error with a 200 response is irresponsible.

## Infrastructure

To make this easier to test, we'll set up a bit of infrastructure. This will open up a server (using `wsgiref.simple_server`) and serve up our application (note that creating the application is left out to start with):

    import sys

    def main(args=None):
        import optparse
        from wsgiref import simple_server
        parser = optparse.OptionParser(
            usage="%prog [OPTIONS] MODULE:EXPRESSION")
        parser.add_option(
            '-p', '--port', default='8080',
            help='Port to serve on (default 8080)')
        parser.add_option(
            '-H', '--host', default='127.0.0.1',
            help='Host to serve on (default localhost; 0.0.0.0 to make public)')
        if args is None:
            args = sys.argv[1:]
        options, args = parser.parse_args()
        if not args or len(args) > 1:
            print 'You must give a single object reference'
            parser.print_help()
            sys.exit(2)
        app = make_app(args[0])
        server = simple_server.make_server(
            options.host, int(options.port),
            app)
        print 'Serving on http://%s:%s' % (options.host, options.port)
        server.serve_forever()

    if __name__ == '__main__':
        main()

I won't describe this much. It starts a server, serving up just the app created by `make_app(args[0])`. `make_app` will have to load up the object and wrap it in our WSGI/WebOb wrapper. We'll be calling that wrapper `JSONRPC(obj)`, so here's how it'll go:

    def make_app(expr):
        module, expression = expr.split(':', 1)
        __import__(module)
        module = sys.modules[module]
        obj = eval(expression, module.__dict__)
        return JsonRpcApp(obj)

We use `__import__(module)` to import the module, but its return value is wonky. We can find the thing it imported in `sys.modules` (a dictionary of all the loaded modules). Then we evaluate the second part of the expression in the namespace of the module. This lets you do something like `smtplib:SMTP('localhost')` to get a fully instantiated SMTP object.

That's all the infrastructure we'll need for the server side. Now we just have to implement `JsonRpcApp`.

## The Application Wrapper

Note that I'm calling this an "application" because that's the terminology WSGI uses. Everything that gets called is an "application", and anything that calls an application is called a "server".

The instantiation of the server is already figured out:

    class JsonRpcApp(object):

        def __init__(self, obj):
            self.obj = obj

        def __call__(self, environ, start_response):
            ... the WSGI interface ...

So the server is an instance bound to the particular object being exposed, and `__call__` implements the WSGI interface.

We'll start with a simple outline of the WSGI interface, using a kind of standard WebOb setup:

    from webob import Request, Response
    from webob import exc

    class JsonRpcApp(object):
        ...
        def __call__(self, environ, start_response):
            req = Request(environ)
            try:
                resp = self.process(req)
            except ValueError, e:
                resp = exc.HTTPBadRequest(str(e))
            except exc.HTTPException, e:
                resp = e
            return resp(environ, start_response)

We first create a request object. The request object just wraps the WSGI environment. Then we create the response object in the `process` method (which we still have to write). We also do some exception catching. We'll turn any `ValueError` into a `400 Bad Request` response. We'll also let `process` raise any `web.exc.HTTPException` exception. There's an exception defined in that module for all the HTTP error responses, like `405 Method Not Allowed`. These exceptions are themselves WSGI applications (as is `webob.Response`), and so we call them like WSGI applications and return the result.

## The `process` method

The `process` method of course is where all the fancy stuff happens. We'll start with just the most minimal implementation, with no error checking or handling:

    from simplejson import loads, dumps

    class JsonRpcApp(object):
        ...
        def process(self, req):
            json = loads(req.body)
            method = json['method']
            params = json['params']
            id = json['id']
            method = getattr(self.obj, method)
            result = method(*params)
            resp = Response(
                content_type='application/json',
                body=dumps(dict(result=result,
                                error=None,
                                id=id)))
            return resp

As long as the request is properly formed and the method doesn't raise any exceptions, you are pretty much set. But of course that's not a reasonable expectation. There's a whole bunch of things that can go wrong. For instance, it has to be a POST method:

    if not req.method == 'POST':
        raise exc.HTTPMethodNotAllowed(
            "Only POST allowed",
            allowed='POST')

And maybe the request body doesn't contain valid JSON:

    try:
        json = loads(req.body)
    except ValueError, e:
        raise ValueError('Bad JSON: %s' % e)

And maybe all the keys aren't in the dictionary:

    try:
        method = json['method']
        params = json['params']
        id = json['id']
    except KeyError, e:
        raise ValueError(
            "JSON body missing parameter: %s" % e)

And maybe it's trying to acces a private method (a method that starts with `_`) -- that's not just a bad request, we'll call that case `403 Forbidden`.

    if method.startswith('_'):
        raise exc.HTTPForbidden(
            "Bad method name %s: must not start with _" % method)

And maybe `json['params']` isn't a list:

    if not isinstance(params, list):
        raise ValueError(
            "Bad params %r: must be a list" % params)

And maybe the method doesn't exist:

    try:
        method = getattr(self.obj, method)
    except AttributeError:
        raise ValueError(
            "No such method %s" % method)

The last case is the error we actually can expect: that the method raises some exception.

    try:
        result = method(*params)
    except:
        tb = traceback.format_exc()
        exc_value = sys.exc_info()[1]
        error_value = dict(
            name='JSONRPCError',
            code=100,
            message=str(exc_value),
            error=tb)
        return Response(
            status=500,
            content_type='application/json',
            body=dumps(dict(result=None,
                            error=error_value,
                            id=id)))

That's a complete server.

## The Complete Code

Since we showed all the error handling in pieces, here's the complete code:

    from webob import Request, Response
    from webob import exc
    from simplejson import loads, dumps
    import traceback
    import sys

    class JsonRpcApp(object):
        """
        Serve the given object via json-rpc (http://json-rpc.org/)
        """

        def __init__(self, obj):
            self.obj = obj

        def __call__(self, environ, start_response):
            req = Request(environ)
            try:
                resp = self.process(req)
            except ValueError, e:
                resp = exc.HTTPBadRequest(str(e))
            except exc.HTTPException, e:
                resp = e
            return resp(environ, start_response)

        def process(self, req):
            if not req.method == 'POST':
                raise exc.HTTPMethodNotAllowed(
                    "Only POST allowed",
                    allowed='POST')
            try:
                json = loads(req.body)
            except ValueError, e:
                raise ValueError('Bad JSON: %s' % e)
            try:
                method = json['method']
                params = json['params']
                id = json['id']
            except KeyError, e:
                raise ValueError(
                    "JSON body missing parameter: %s" % e)
            if method.startswith('_'):
                raise exc.HTTPForbidden(
                    "Bad method name %s: must not start with _" % method)
            if not isinstance(params, list):
                raise ValueError(
                    "Bad params %r: must be a list" % params)
            try:
                method = getattr(self.obj, method)
            except AttributeError:
                raise ValueError(
                    "No such method %s" % method)
            try:
                result = method(*params)
            except:
                text = traceback.format_exc()
                exc_value = sys.exc_info()[1]
                error_value = dict(
                    name='JSONRPCError',
                    code=100,
                    message=str(exc_value),
                    error=text)
                return Response(
                    status=500,
                    content_type='application/json',
                    body=dumps(dict(result=None,
                                    error=error_value,
                                    id=id)))
            return Response(
                content_type='application/json',
                body=dumps(dict(result=result,
                                error=None,
                                id=id)))

## The Client

It would be nice to have a client to test out our server. Using WSGIProxy we can use WebOb Request and Response to do actual HTTP connections.

The basic idea is that you can create a blank Request:

    >>> from webob import Request
    >>> req = Request.blank('http://python.org')

Then you can send that request to an application:

    >>> from wsgiproxy.exactproxy import proxy_exact_request
    >>> resp = req.get_response(proxy_exact_request)

This particular application (`proxy_exact_request`) sends the request over HTTP:

    >>> resp.content_type
    'text/html'
    >>> resp.body[:10]
    '<!DOCTYPE '

So we're going to create a proxy object that constructs WebOb-based jsonrpc requests, and sends those using `proxy_exact_request`.

## The Proxy Client

The proxy client is instantiated with its base URL. We'll also let you pass in a proxy application, in case you want to do local requests (e.g., to do direct tests against a `JsonRpcApp` instance):

    class ServerProxy(object):

        def __init__(self, url, proxy=None):
            self._url = url
            if proxy is None:
                from wsgiproxy.exactproxy import proxy_exact_request
                proxy = proxy_exact_request
            self.proxy = proxy

This ServerProxy object itself doesn't do much, but you can call methods on it. We can intercept any access `ServerProxy(...).method` with the magic function `__getattr__`. Whenever you get an attribute that doesn't exist in an instance, Python will call `inst.__getattr__(attr_name)` and return that. When you call a method, you are calling the object that `.method` returns. So we'll create a helper object that is callable, and our `__getattr__` will just return that:

    class ServerProxy(object):
        ...
        def __getattr__(self, name):
            # Note, even attributes like __contains__ can get routed
            # through __getattr__
            if name.startswith('_'):
                raise AttributeError(name)
            return _Method(self, name)

    class _Method(object):
        def __init__(self, parent, name):
            self.parent = parent
            self.name = name

Now when we call the method we'll be calling `_Method.__call__`, and the HTTP endpoint will be `self.parent._url`, and the method name will be `self.name`.

Here's the code to do the call:

    class _Method(object):
        ...

        def __call__(self, *args):
            json = dict(method=self.name,
                        id=None,
                        params=list(args))
            req = Request.blank(self.parent._url)
            req.method = 'POST'
            req.content_type = 'application/json'
            req.body = dumps(json)
            resp = req.get_response(self.parent.proxy)
            if resp.status_code != 200 and not (
                resp.status_code == 500
                and resp.content_type == 'application/json'):
                raise ProxyError(
                    "Error from JSON-RPC client %s: %s"
                    % (self._url, resp.status),
                    resp)
            json = loads(resp.body)
            if json.get('error') is not None:
                e = Fault(
                    json['error'].get('message'),
                    json['error'].get('code'),
                    json['error'].get('error'),
                    resp)
                raise e
            return json['result']

We raise two kinds of exceptions here. `ProxyError` is when something unexpected happens, like a `404 Not Found`. `Fault` is when a more expected exception occurs, i.e., the underlying method raised an exception.

In both cases we'll keep the response object around, as that can be interesting. Note that you can make exceptions have any methods or signature you want, which we'll do:

    class ProxyError(Exception):
        """
        Raised when a request via ServerProxy breaks
        """
        def __init__(self, message, response):
            Exception.__init__(self, message)
            self.response = response

    class Fault(Exception):
        """
        Raised when there is a remote error
        """
        def __init__(self, message, code, error, response):
            Exception.__init__(self, message)
            self.code = code
            self.error = error
            self.response = response
        def __str__(self):
            return 'Method error calling %s: %s\n%s' % (
                self.response.request.url,
                self.args[0],
                self.error)

## Using Them Together

Good programmers start with tests. But at least we'll end with a test. We'll use `doctest` for our tests. The test is in docs/json-example-code/test_jsonrpc.txt and you can run it with docs/json-example-code/test_jsonrpc.py, which looks like:

    if __name__ == '__main__':
        import doctest
        doctest.testfile('test_jsonrpc.txt')

As you can see, it's just a stub to run the doctest. We'll need a simple object to expose. We'll make it real simple:

    >>> class Divider(object):
    ...     def divide(self, a, b):
    ...        return a / b

Then we'll get the app setup:

    >>> from jsonrpc import *
    >>> app = JsonRpcApp(Divider())

And attach the client directly to it:

    >>> proxy = ServerProxy('http://localhost:8080', proxy=app)

Because we gave the app itself as the proxy, the URL doesn't actually matter.

Now, if you are used to testing you might ask: is this kosher? That is, we are shortcircuiting HTTP entirely. Is this a realistic test?

One thing you might be worried about in this case is that there are more shared objects than you'd have with HTTP. That is, everything over HTTP is serialized to headers and bodies. Without HTTP, we can send stuff around that can't go over HTTP. This could happen, but we're mostly protected because the only thing the application's share is the WSGI `environ`. Even though we use a `webob.Request` object on both side, it's not the same request object, and all the state is studiously kept in the environment. We could share things in the environment that couldn't go over HTTP. For instance, we could set `environ['jsonrpc.request_value'] = dict(...)`, and avoid `simplejson.dumps` and `simplejson.loads`. We could do that, and if we did then it is possible our test would work even though the libraries were broken over HTTP. But of course inspection shows we don't do that. A little discipline is required to resist playing clever tricks (or else you can play those tricks and do more testing). Generally it works well.

So, now we have a proxy, lets use it:

    >>> proxy.divide(10, 4)
    2
    >>> proxy.divide(10, 4.0)
    2.5

Lastly, we'll test a couple error conditions. First a method error:

    >>> proxy.divide(10, 0)
    Traceback (most recent call last):
       ...
    Fault: Method error calling http://localhost:8080: integer division or modulo by zero
    Traceback (most recent call last):
      File ...
        result = method(*params)
      File ...
        return a / b
    ZeroDivisionError: integer division or modulo by zero

It's hard to actually predict this exception, because the test of the exception itself contains the traceback from the underlying call, with filenames and line numbers that aren't stable. We use `# doctest: +ELLIPSIS` so that we can replace text we don't care about with `...`. This is actually figured out through copy-and-paste, and visual inspection to make sure it looks sensible.

The other exception can be:

    >>> proxy.add(1, 1)
    Traceback (most recent call last):
        ...
    ProxyError: Error from JSON-RPC client http://localhost:8080: 400 Bad Request

Here the exception isn't a JSON-RPC method exception, but a more basic ProxyError exception.

## Conclusion

Hopefully this will give you ideas about how to implement web services of different kinds using WebOb. I hope you also can appreciate the elegance of the symmetry of the request and response objects, and the client and server for the protocol.

Many of these techniques would be better used with a RESTful service, so do think about that direction if you are implementing your own protocol.