From d5725fd243e0a48392a53097a900bcdf76f6b585 Mon Sep 17 00:00:00 2001 From: Kload Date: Fri, 28 Jun 2013 17:59:04 +0000 Subject: [PATCH] Add txrestapi --- txrestapi/.gitignore | 3 + txrestapi/README.rst | 146 ++++++++++++++++++++++++ txrestapi/setup.cfg | 1 + txrestapi/setup.py | 26 +++++ txrestapi/txrestapi/__init__.py | 1 + txrestapi/txrestapi/methods.py | 29 +++++ txrestapi/txrestapi/resource.py | 65 +++++++++++ txrestapi/txrestapi/service.py | 7 ++ txrestapi/txrestapi/tests.py | 194 ++++++++++++++++++++++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 txrestapi/.gitignore create mode 100644 txrestapi/README.rst create mode 100644 txrestapi/setup.cfg create mode 100644 txrestapi/setup.py create mode 100644 txrestapi/txrestapi/__init__.py create mode 100644 txrestapi/txrestapi/methods.py create mode 100644 txrestapi/txrestapi/resource.py create mode 100644 txrestapi/txrestapi/service.py create mode 100644 txrestapi/txrestapi/tests.py diff --git a/txrestapi/.gitignore b/txrestapi/.gitignore new file mode 100644 index 00000000..2193e643 --- /dev/null +++ b/txrestapi/.gitignore @@ -0,0 +1,3 @@ +_trial_temp +txrestapi.egg-info +txrestapi/_trial_temp diff --git a/txrestapi/README.rst b/txrestapi/README.rst new file mode 100644 index 00000000..caf2cf32 --- /dev/null +++ b/txrestapi/README.rst @@ -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[^/]+)/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[^/]+)/info') + ... def get_info(self, request, id): + ... return 'Info for id %s' % id + ... + ... @PUT('^/(?P[^/]+)/update') + ... @POST('^/(?P[^/]+)/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. diff --git a/txrestapi/setup.cfg b/txrestapi/setup.cfg new file mode 100644 index 00000000..fa9fe18f --- /dev/null +++ b/txrestapi/setup.cfg @@ -0,0 +1 @@ +[egg_info] diff --git a/txrestapi/setup.py b/txrestapi/setup.py new file mode 100644 index 00000000..1ca9045a --- /dev/null +++ b/txrestapi/setup.py @@ -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: -*- + """, + ) diff --git a/txrestapi/txrestapi/__init__.py b/txrestapi/txrestapi/__init__.py new file mode 100644 index 00000000..792d6005 --- /dev/null +++ b/txrestapi/txrestapi/__init__.py @@ -0,0 +1 @@ +# diff --git a/txrestapi/txrestapi/methods.py b/txrestapi/txrestapi/methods.py new file mode 100644 index 00000000..8d5a89d9 --- /dev/null +++ b/txrestapi/txrestapi/methods.py @@ -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') diff --git a/txrestapi/txrestapi/resource.py b/txrestapi/txrestapi/resource.py new file mode 100644 index 00000000..322acd5f --- /dev/null +++ b/txrestapi/txrestapi/resource.py @@ -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 diff --git a/txrestapi/txrestapi/service.py b/txrestapi/txrestapi/service.py new file mode 100644 index 00000000..78d031a8 --- /dev/null +++ b/txrestapi/txrestapi/service.py @@ -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() diff --git a/txrestapi/txrestapi/tests.py b/txrestapi/txrestapi/tests.py new file mode 100644 index 00000000..3ee1b53c --- /dev/null +++ b/txrestapi/txrestapi/tests.py @@ -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/(?P[^/]*)/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/(?P[^/]*)/c', cb1) + r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)', 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/(?P[^/]*)$', 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('^/(?Ptest[^/]*)/?') + def _on_test_get(self, request, a): + return 'GET %s' % a + + @PUT('^/(?Ptest[^/]*)/?') + 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 +