1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653
|
JSON-RPC Example
================
.. contents::
: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 <http://json-rpc.org/>`_
endpoint using WebOb and the `simplejson
<http://www.undefined.org/python/#simplejson>`_ JSON library. This
also shows how to use WebOb as a client library using `WSGIProxy
<http://pythonpaste.org/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
<http://www.xmlrpc.com/>`_.
Code
----
The finished code for this is available in
`docs/json-example-code/jsonrpc.py
<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-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
<http://json-rpc.org/wd/JSON-RPC-1-1-WD-20060807.html>`_ 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
<http://python.org/doc/current/lib/module-wsgiref.simpleserver.html>`_)
and serve up our application (note that *creating* the application is
left out to start with):
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
if not req.method == 'POST':
raise exc.HTTPMethodNotAllowed(
"Only POST allowed",
allowed='POST')
And maybe the request body doesn't contain valid JSON:
.. code-block:: python
try:
json = loads(req.body)
except ValueError, e:
raise ValueError('Bad JSON: %s' % e)
And maybe all the keys aren't in the dictionary:
.. code-block:: python
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``.
.. code-block:: python
if method.startswith('_'):
raise exc.HTTPForbidden(
"Bad method name %s: must not start with _" % method)
And maybe ``json['params']`` isn't a list:
.. code-block:: python
if not isinstance(params, list):
raise ValueError(
"Bad params %r: must be a list" % params)
And maybe the method doesn't exist:
.. code-block:: python
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.
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
>>> from webob import Request
>>> req = Request.blank('http://python.org')
Then you can send that request to an application:
.. code-block:: python
>>> 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:
.. code-block:: python
>>> 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):
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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
<http://python.org/doc/current/lib/module-doctest.html>`_ for our
tests. The test is in `docs/json-example-code/test_jsonrpc.txt
<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-example-code/test_jsonrpc.txt>`_
and you can run it with `docs/json-example-code/test_jsonrpc.py
<https://github.com/Pylons/webob/tree/master/docs/jsonrpc-example-code/test_jsonrpc.py>`_,
which looks like:
.. code-block:: python
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:
.. code-block:: python
>>> class Divider(object):
... def divide(self, a, b):
... return a / b
Then we'll get the app setup:
.. code-block:: python
>>> from jsonrpc import *
>>> app = JsonRpcApp(Divider())
And attach the client *directly* to it:
.. code-block:: python
>>> 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:
.. code-block:: python
>>> proxy.divide(10, 4)
2
>>> proxy.divide(10, 4.0)
2.5
Lastly, we'll test a couple error conditions. First a method error:
.. code-block:: python
>>> proxy.divide(10, 0) # doctest: +ELLIPSIS
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
<BLANKLINE>
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:
.. code-block:: python
>>> 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
<http://en.wikipedia.org/wiki/Representational_State_Transfer>`_
service, so do think about that direction if you are implementing your
own protocol.
|