Pylons JSON-RPC Controller

Posted on June 22, 2010

Good evening folks. An update here on the Pylons + JSON-RPC + Pyjamas saga. I bring you a sneak-peek at the JSONRPCController class. It’s modelled after the XMLRPCController class that already ships with Pylons 0.9.7 and will be more first forays into contributing to the Pylons community. I hope to get some feedback from the Pylons developers as my intent is to submit this class for inclusion in Pylons.

So please feel free to leave constructive comments and suggestions.

First the controller class you’ll need to implement your own JSON-RPC service:

"""The base WSGI JSONRPCController"""
import inspect
import json
import logging
import types
import urllib

from paste.response import replace_header
from pylons.controllers import WSGIController
from pylons.controllers.util import abort, Response

__all__ = ['JSONRPCController']

log = logging.getLogger(__name__)


class JSONRPCController(WSGIController):
    """
    A WSGI-speaking JSON-RPC controller class

    See the specification:
    `.

    Many parts of this controller are modelled after XMLRPCController
    from Pylons 0.9.7

    Parts of the specification not supported (yet):
     - Notifications
    """

    def _get_method_args(self):
        """Return `self._rpc_args` to dispatched controller method
        chosen by __call__"""
        return self._rpc_args

    def __call__(self, environ, start_response):
        """Parse the request body as JSON, look up the method on the
        controller and if it exists, dispatch to it.
        """
        length = 0
        if not environ.has_key('CONTENT_LENGTH'):
            log.debug("No Content-Length")
            abort(411)
        else:
            length = int(environ['CONTENT_LENGTH'])
            log.debug('Content-Length: %s', length)
        if length == 0:
            log.debug("Content-Length is 0")
            abort(413)

        raw_body = environ['wsgi.input'].read(length)[0:-1]
        json_body = json.loads(urllib.unquote_plus(raw_body))

        self._req_id = json_body['id']
        self._req_method = json_body['method']
        self._req_params = json_body['params']
        log.debug('id: %s, method: %s, params: %s',
                  self._req_id,
                  self._req_method,
                  self._req_params)

        self._func = self._find_method()
        self._error = None
        log.debug('found method: %s', self._func.func_name)

        # now that we have a method, dispatch control to WSGIController
        # store argument parameters as dict for _get_method_args
        arglist = inspect.getargspec(self._func)[0][1:]
        kargs = dict(zip(arglist, self._req_params))
        kargs['action'], kargs['environ'] = self._req_method, environ
        kargs['start_response'] = start_response
        self._rpc_args = kargs

        status = []
        headers = []
        exc_info = []
        def change_content(new_status, new_headers, new_exc_info=None):
            status.append(new_status)
            headers.extend(new_headers)
            exc_info.append(new_exc_info)

        output = WSGIController.__call__(self, environ, change_content)
        output = list(output)
        headers.append(('Content-Length', str(len(output[0]))))
        replace_header(headers, 'Content-Type', 'application/json')
        start_response(status[0], headers, exc_info[0])

        return output

    def _dispatch_call(self):
        """Implement dispatch interface specified by WSGIController"""
        raw_response = self._inspect_call(self._func)
        response = dict(
            id=self._req_id,
            result=raw_response,
            error=self._error)

        return json.dumps(response)

    def _find_method(self):
        """Return method named by `self._req_method` in controller if able"""
        log.debug('Trying to find JSON-RPC method: %s', self._req_method)
        if self._req_method.startswith('_'):
            self._error = "No such method"

        try:
            func = getattr(self, self._req_method, None)
        except UnicodeEncodeError:
            return # should handle this better

        func = func if isinstance(func, types.MethodType) else None
        return func

There are some potential rough corners to handle. This class needs a good set of offensive tests to try and find the corner cases it should protect against. It also doesn’t implement the notification response from the protocol specification. It also needs error handling. If you find any other holes I should plug before submitting it to the Pylons project, please leave a comment or send me a mail.

Anyhow, once you have this class you’ll need to subclass it as usual like a normal Pylons controller:

import logging

from pylons import request, response, session, url
from pylons.controllers.util import abort, redirect

from jsontest.lib.jsonrpc import JSONRPCController

log = logging.getLogger(__name__)

class EchoController(JSONRPCController):

    def echo(self, message):
        return 'Got message: %s' % message

Make sure to add a route to your controller. The default mappings don’t really work AFAIK.

map.connect('/echoservice', controller='echo')

To test it, I prefer the python json-rpc client. Fire up the paster local development server and at a python interpreter try:

>>> from jsonrpc import ServiceProxy
>>> s = ServiceProxy('http://localhost:5000/echoservice')
>>> s.echo("Hello, world!")
'Got message: Hello, world!'
>>>

And voila!

Happy hacking.