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