Add txrestapi

This commit is contained in:
Kload 2013-06-28 17:59:04 +00:00
parent ad7fa4e40b
commit d5725fd243
9 changed files with 472 additions and 0 deletions

3
txrestapi/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
_trial_temp
txrestapi.egg-info
txrestapi/_trial_temp

146
txrestapi/README.rst Normal file
View file

@ -0,0 +1,146 @@
============
Introduction
============
``txrestapi`` makes it easier to create Twisted REST API services. Normally, one
would create ``Resource`` subclasses defining each segment of a path; this is
cubersome to implement and results in output that isn't very readable.
``txrestapi`` provides an ``APIResource`` class allowing complex mapping of path to
callback (a la Django) with a readable decorator.
===============================
Basic URL callback registration
===============================
First, let's create a bare API service::
>>> from txrestapi.resource import APIResource
>>> api = APIResource()
and a web server to serve it::
>>> from twisted.web.server import Site
>>> from twisted.internet import reactor
>>> site = Site(api, timeout=None)
and a function to make it easy for us to make requests (only for doctest
purposes; normally you would of course use ``reactor.listenTCP(8080, site)``)::
>>> from twisted.web.server import Request
>>> class FakeChannel(object):
... transport = None
>>> def makeRequest(method, path):
... req = Request(FakeChannel(), None)
... req.prepath = req.postpath = None
... req.method = method; req.path = path
... resource = site.getChildWithDefault(path, req)
... return resource.render(req)
We can now register callbacks for paths we care about. We can provide different
callbacks for different methods; they must accept ``request`` as the first
argument::
>>> def get_callback(request): return 'GET callback'
>>> api.register('GET', '^/path/to/method', get_callback)
>>> def post_callback(request): return 'POST callback'
>>> api.register('POST', '^/path/to/method', post_callback)
Then, when we make a call, the request is routed to the proper callback::
>>> print makeRequest('GET', '/path/to/method')
GET callback
>>> print makeRequest('POST', '/path/to/method')
POST callback
We can register multiple callbacks for different requests; the first one that
matches wins::
>>> def default_callback(request):
... return 'Default callback'
>>> api.register('GET', '^/.*$', default_callback) # Matches everything
>>> print makeRequest('GET', '/path/to/method')
GET callback
>>> print makeRequest('GET', '/path/to/different/method')
Default callback
Our default callback, however, will only match GET requests. For a true default
callback, we can either register callbacks for each method individually, or we
can use ALL::
>>> api.register('ALL', '^/.*$', default_callback)
>>> print makeRequest('PUT', '/path/to/method')
Default callback
>>> print makeRequest('DELETE', '/path/to/method')
Default callback
>>> print makeRequest('GET', '/path/to/method')
GET callback
Let's unregister all references to the default callback so it doesn't interfere
with later tests (default callbacks should, of course, always be registered
last, so they don't get called before other callbacks)::
>>> api.unregister(callback=default_callback)
=============
URL Arguments
=============
Since callbacks accept ``request``, they have access to POST data or query
arguments, but we can also pull arguments out of the URL by using named groups
in the regular expression (similar to Django). These will be passed into the
callback as keyword arguments::
>>> def get_info(request, id):
... return 'Information for id %s' % id
>>> api.register('GET', '/(?P<id>[^/]+)/info$', get_info)
>>> print makeRequest('GET', '/someid/info')
Information for id someid
Bear in mind all arguments will come in as strings, so code should be
accordingly defensive.
================
Decorator syntax
================
Registration via the ``register()`` method is somewhat awkward, so decorators
are provided making it much more straightforward. ::
>>> from txrestapi.methods import GET, POST, PUT, ALL
>>> class MyResource(APIResource):
...
... @GET('^/(?P<id>[^/]+)/info')
... def get_info(self, request, id):
... return 'Info for id %s' % id
...
... @PUT('^/(?P<id>[^/]+)/update')
... @POST('^/(?P<id>[^/]+)/update')
... def set_info(self, request, id):
... return "Setting info for id %s" % id
...
... @ALL('^/')
... def default_view(self, request):
... return "I match any URL"
Again, registrations occur top to bottom, so methods should be written from
most specific to least. Also notice that one can use the decorator syntax as
one would expect to register a method as the target for two URLs ::
>>> site = Site(MyResource(), timeout=None)
>>> print makeRequest('GET', '/anid/info')
Info for id anid
>>> print makeRequest('PUT', '/anid/update')
Setting info for id anid
>>> print makeRequest('POST', '/anid/update')
Setting info for id anid
>>> print makeRequest('DELETE', '/anid/delete')
I match any URL
======================
Callback return values
======================
You can return Resource objects from a callback if you wish, allowing you to
have APIs that send you to other kinds of resources, or even other APIs.
Normally, however, you'll most likely want to return strings, which will be
wrapped in a Resource object for convenience.

1
txrestapi/setup.cfg Normal file
View file

@ -0,0 +1 @@
[egg_info]

26
txrestapi/setup.py Normal file
View file

@ -0,0 +1,26 @@
from setuptools import setup, find_packages
import sys, os
version = '0.1'
setup(name='txrestapi',
version=version,
description="Easing the creation of REST API services in Python",
long_description="""\
""",
classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
keywords='',
author='Ian McCracken',
author_email='ian.mccracken@gmail.com',
url='http://github.com/iancmcc/txrestapi',
license='MIT',
packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
include_package_data=True,
zip_safe=False,
install_requires=[
# -*- Extra requirements: -*-
],
entry_points="""
# -*- Entry points: -*-
""",
)

View file

@ -0,0 +1 @@
#

View file

@ -0,0 +1,29 @@
from zope.interface.advice import addClassAdvisor
def method_factory_factory(method):
def factory(regex):
_f = {}
def decorator(f):
_f[f.__name__] = f
return f
def advisor(cls):
def wrapped(f):
def __init__(self, *args, **kwargs):
f(self, *args, **kwargs)
for func_name in _f:
orig = _f[func_name]
func = getattr(self, func_name)
if func.im_func==orig:
self.register(method, regex, func)
return __init__
cls.__init__ = wrapped(cls.__init__)
return cls
addClassAdvisor(advisor)
return decorator
return factory
ALL = method_factory_factory('ALL')
GET = method_factory_factory('GET')
POST = method_factory_factory('POST')
PUT = method_factory_factory('PUT')
DELETE = method_factory_factory('DELETE')

View file

@ -0,0 +1,65 @@
import re
from itertools import ifilter
from functools import wraps
from twisted.web.resource import Resource, NoResource
class _FakeResource(Resource):
_result = ''
isLeaf = True
def __init__(self, result):
Resource.__init__(self)
self._result = result
def render(self, request):
return self._result
def maybeResource(f):
@wraps(f)
def inner(*args, **kwargs):
result = f(*args, **kwargs)
if not isinstance(result, Resource):
result = _FakeResource(result)
return result
return inner
class APIResource(Resource):
_registry = None
def __init__(self, *args, **kwargs):
Resource.__init__(self, *args, **kwargs)
self._registry = []
def _get_callback(self, request):
filterf = lambda t:t[0] in (request.method, 'ALL')
path_to_check = getattr(request, '_remaining_path', request.path)
for m, r, cb in ifilter(filterf, self._registry):
result = r.search(path_to_check)
if result:
request._remaining_path = path_to_check[result.span()[1]:]
return cb, result.groupdict()
return None, None
def register(self, method, regex, callback):
self._registry.append((method, re.compile(regex), callback))
def unregister(self, method=None, regex=None, callback=None):
if regex is not None: regex = re.compile(regex)
for m, r, cb in self._registry[:]:
if not method or (method and m==method):
if not regex or (regex and r==regex):
if not callback or (callback and cb==callback):
self._registry.remove((m, r, cb))
def getChild(self, name, request):
r = self.children.get(name, None)
if r is None:
# Go into the thing
callback, args = self._get_callback(request)
if callback is None:
return NoResource()
else:
return maybeResource(callback)(request, **args)
else:
return r

View file

@ -0,0 +1,7 @@
from twisted.web.server import Site
from .resource import APIResource
class RESTfulService(Site):
def __init__(self, port=8080):
self.root = APIResource()

View file

@ -0,0 +1,194 @@
import txrestapi
__package__="txrestapi"
import re
import os.path
from twisted.internet import reactor
from twisted.internet.defer import inlineCallbacks
from twisted.web.resource import Resource, NoResource
from twisted.web.server import Request, Site
from twisted.web.client import getPage
from twisted.trial import unittest
from .resource import APIResource
from .methods import GET, PUT
class FakeChannel(object):
transport = None
def getRequest(method, url):
req = Request(FakeChannel(), None)
req.method = method
req.path = url
return req
class APIResourceTest(unittest.TestCase):
def test_returns_normal_resources(self):
r = APIResource()
a = Resource()
r.putChild('a', a)
req = Request(FakeChannel(), None)
a_ = r.getChild('a', req)
self.assertEqual(a, a_)
def test_registry(self):
compiled = re.compile('regex')
r = APIResource()
r.register('GET', 'regex', None)
self.assertEqual([x[0] for x in r._registry], ['GET'])
self.assertEqual(r._registry[0], ('GET', compiled, None))
def test_method_matching(self):
r = APIResource()
r.register('GET', 'regex', 1)
r.register('PUT', 'regex', 2)
r.register('GET', 'another', 3)
req = getRequest('GET', 'regex')
result = r._get_callback(req)
self.assert_(result)
self.assertEqual(result[0], 1)
req = getRequest('PUT', 'regex')
result = r._get_callback(req)
self.assert_(result)
self.assertEqual(result[0], 2)
req = getRequest('GET', 'another')
result = r._get_callback(req)
self.assert_(result)
self.assertEqual(result[0], 3)
req = getRequest('PUT', 'another')
result = r._get_callback(req)
self.assertEqual(result, (None, None))
def test_callback(self):
marker = object()
def cb(request):
return marker
r = APIResource()
r.register('GET', 'regex', cb)
req = getRequest('GET', 'regex')
result = r.getChild('regex', req)
self.assertEqual(result.render(req), marker)
def test_longerpath(self):
marker = object()
r = APIResource()
def cb(request):
return marker
r.register('GET', '/regex/a/b/c', cb)
req = getRequest('GET', '/regex/a/b/c')
result = r.getChild('regex', req)
self.assertEqual(result.render(req), marker)
def test_args(self):
r = APIResource()
def cb(request, **kwargs):
return kwargs
r.register('GET', '/(?P<a>[^/]*)/a/(?P<b>[^/]*)/c', cb)
req = getRequest('GET', '/regex/a/b/c')
result = r.getChild('regex', req)
self.assertEqual(sorted(result.render(req).keys()), ['a', 'b'])
def test_order(self):
r = APIResource()
def cb1(request, **kwargs):
kwargs.update({'cb1':True})
return kwargs
def cb(request, **kwargs):
return kwargs
# Register two regexes that will match
r.register('GET', '/(?P<a>[^/]*)/a/(?P<b>[^/]*)/c', cb1)
r.register('GET', '/(?P<a>[^/]*)/a/(?P<b>[^/]*)', cb)
req = getRequest('GET', '/regex/a/b/c')
result = r.getChild('regex', req)
# Make sure the first one got it
self.assert_('cb1' in result.render(req))
def test_no_resource(self):
r = APIResource()
r.register('GET', '^/(?P<a>[^/]*)/a/(?P<b>[^/]*)$', None)
req = getRequest('GET', '/definitely/not/a/match')
result = r.getChild('regex', req)
self.assert_(isinstance(result, NoResource))
def test_all(self):
r = APIResource()
def get_cb(r): return 'GET'
def put_cb(r): return 'PUT'
def all_cb(r): return 'ALL'
r.register('GET', '^path', get_cb)
r.register('ALL', '^path', all_cb)
r.register('PUT', '^path', put_cb)
# Test that the ALL registration picks it up before the PUT one
for method in ('GET', 'PUT', 'ALL'):
req = getRequest(method, 'path')
result = r.getChild('path', req)
self.assertEqual(result.render(req), 'ALL' if method=='PUT' else method)
class TestResource(Resource):
isLeaf = True
def render(self, request):
return 'aresource'
class TestAPI(APIResource):
@GET('^/(?P<a>test[^/]*)/?')
def _on_test_get(self, request, a):
return 'GET %s' % a
@PUT('^/(?P<a>test[^/]*)/?')
def _on_test_put(self, request, a):
return 'PUT %s' % a
@GET('^/gettest')
def _on_gettest(self, request):
return TestResource()
class DecoratorsTest(unittest.TestCase):
def _listen(self, site):
return reactor.listenTCP(0, site, interface="127.0.0.1")
def setUp(self):
r = TestAPI()
site = Site(r, timeout=None)
self.port = self._listen(site)
self.portno = self.port.getHost().port
def tearDown(self):
return self.port.stopListening()
def getURL(self, path):
return "http://127.0.0.1:%d/%s" % (self.portno, path)
@inlineCallbacks
def test_get(self):
url = self.getURL('test_thing/')
result = yield getPage(url, method='GET')
self.assertEqual(result, 'GET test_thing')
@inlineCallbacks
def test_put(self):
url = self.getURL('test_thing/')
result = yield getPage(url, method='PUT')
self.assertEqual(result, 'PUT test_thing')
@inlineCallbacks
def test_resource_wrapper(self):
url = self.getURL('gettest')
result = yield getPage(url, method='GET')
self.assertEqual(result, 'aresource')
def test_suite():
import unittest as ut
suite = unittest.TestSuite()
suite.addTest(ut.makeSuite(DecoratorsTest))
suite.addTest(ut.makeSuite(APIResourceTest))
suite.addTest(unittest.doctest.DocFileSuite(os.path.join('..', 'README.rst')))
return suite