Pylons JSON-RPC Controller Update

Posted on June 29, 2010

Alright, after a little hacking this week I managed to add some error handling to the JSONRPCController class I created and rounded out some corners. I just want to post the updated code one more time and try to get some last minute critiques before trying to submit this to the Pylons project through more official channels.

"""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', 'JSONRPCError']

log = logging.getLogger(__name__)


class JSONRPCError(BaseException):

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

    def __str__(self):
        return str(self.message)


def jsonrpc_error(req_id, message):
    """Generate a Response object with a JSON-RPC error body"""
    jrpc_err = JSONRPCError(message)
    return Response(body=json.dumps(dict(
                id=req_id,
                result=None,
                error=str(jrpc_err))))

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

    Valid controller return values should be json-serializable objects.

    Sub-classes should catch their exceptions and raise JSONRPCError
    if they want to pass meaningful errors to the client.

    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._error = None
        try:
            self._func = self._find_method()
        except AttributeError, e:
            return jsonrpc_error(self._req_id, str(e))

        # now that we have a method, add self._req_params to
        # self.kargs and dispatch control to WGIController
        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"""
        try:
            raw_response = self._inspect_call(self._func)
        except JSONRPCError as e:
            self._error = str(e)
        except Exception as e:
            log.debug('Encountered unhandled exception: %s', repr(e))
            json_exc = JSONRPCError('Internal server error')
            self._error = str(json_exc)

        if self._error is not None:
            raw_response = None

        response = dict(
            id=self._req_id,
            result=raw_response,
            error=self._error)

        try:
            return json.dumps(response)
        except TypeError, e:
            log.debug('Error encoding response: %s', e)
            return json.dumps(dict(
                    id=self._req_id,
                    result=None,
                    error="Error encoding 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('_'):
            raise AttributeError, "Method not allowed"

        try:
            func = getattr(self, self._req_method, None)
        except UnicodeEncodeError:
            # XMLRPCController catches this, not sure why.
            raise AttributeError, ("Problem decoding unicode in requested "
                                   "method name.")

        if isinstance(func, types.MethodType):
            return func
        else:
            raise AttributeError, "No such method: %s" % self._req_method

So let me know what you think in the comments or send me an email.