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