mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
Add txrestapi
This commit is contained in:
parent
ad7fa4e40b
commit
d5725fd243
9 changed files with 472 additions and 0 deletions
3
txrestapi/.gitignore
vendored
Normal file
3
txrestapi/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
_trial_temp
|
||||||
|
txrestapi.egg-info
|
||||||
|
txrestapi/_trial_temp
|
146
txrestapi/README.rst
Normal file
146
txrestapi/README.rst
Normal 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
1
txrestapi/setup.cfg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[egg_info]
|
26
txrestapi/setup.py
Normal file
26
txrestapi/setup.py
Normal 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: -*-
|
||||||
|
""",
|
||||||
|
)
|
1
txrestapi/txrestapi/__init__.py
Normal file
1
txrestapi/txrestapi/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
#
|
29
txrestapi/txrestapi/methods.py
Normal file
29
txrestapi/txrestapi/methods.py
Normal 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')
|
65
txrestapi/txrestapi/resource.py
Normal file
65
txrestapi/txrestapi/resource.py
Normal 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
|
7
txrestapi/txrestapi/service.py
Normal file
7
txrestapi/txrestapi/service.py
Normal 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()
|
194
txrestapi/txrestapi/tests.py
Normal file
194
txrestapi/txrestapi/tests.py
Normal 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
|
||||||
|
|
Loading…
Add table
Reference in a new issue