feat(api): add_sink method

This patch adds a new method, add_sink(...), to the falcon.API class.

EXAMPLES
========

Any requested URI path requested that starts with '/my-proxy' will be
passed to my_proxy, regardless of HTTP verb.

    app.add_sink(my_proxy, prefix='/my-proxy')

Any URI path requested that doesn't match a route will be
passed to my_proxy, regardless of HTTP verb.

    app.add_sink(my_proxy)

In the case of a collision between a route and a sync, the route
always wins.

    app.add_route('/books', book_collection)
    app.add_sink(book_worm, '/books')

The prefix may either be a regex string or a precompiled regex object:

    app.add_sink(my_proxy, prefix=r'/my-proxy/\d+')
    app.add_sink(my_thingy, prefix=re.compile('/thingy/[a-zA-Z]+'))
This commit is contained in:
kgriffs
2013-12-31 16:49:16 -06:00
parent 6bf366ed56
commit 6963f627a1
5 changed files with 238 additions and 26 deletions

View File

@@ -16,11 +16,14 @@ limitations under the License.
"""
import re
from falcon import api_helpers as helpers
from falcon.request import Request
from falcon.response import Response
import falcon.responders
from falcon.status_codes import HTTP_416
from falcon import api_helpers as helpers
from falcon import util
from falcon.http_error import HTTPError
from falcon import DEFAULT_MEDIA_TYPE
@@ -35,7 +38,7 @@ class API(object):
"""
__slots__ = ('_after', '_before', '_error_handlers', '_media_type',
'_routes', '_default_route')
'_routes', '_default_route', '_sinks')
def __init__(self, media_type=DEFAULT_MEDIA_TYPE, before=None, after=None):
"""Initialize a new Falcon API instances
@@ -55,6 +58,7 @@ class API(object):
"""
self._routes = []
self._sinks = []
self._default_route = None
self._media_type = media_type
@@ -78,7 +82,7 @@ class API(object):
req = Request(env)
resp = Response()
responder, params, na_responder = self._get_responder(
responder, params = self._get_responder(
req.path, req.method)
try:
@@ -146,7 +150,7 @@ class API(object):
A resource is an instance of a class that defines various on_*
"responder" methods, one for each HTTP method the resource
allows. For example, to support GET, simply define an "on_get"
allows. For example, to support GET, simply define an `on_get`
responder. If a client requests an unsupported method, Falcon
will respond with "405 Method not allowed".
@@ -157,7 +161,7 @@ class API(object):
pass
In addition, if the route's uri template contains field
expressions, any responders that desires to receive requests
expressions, any responder that desires to receive requests
for that route must accept arguments named after the respective
field names defined in the template. For example, given the
following uri template:
@@ -174,15 +178,11 @@ class API(object):
def on_put(self, req, resp):
pass
Falcon would respond to the client's request with "405 Method
not allowed." This allows you to define multiple routes to the
same resource, e.g., in order to support GET for "/widget/1234"
and POST to "/widgets". In this last example, a POST to
"/widget/5000" would result in a 405 response.
Args:
uri_template: Relative URI template. Currently only Level 1
templates are supported. See also RFC 6570.
templates are supported. See also RFC 6570. Care must be
taken to ensure the template does not mask any sink
patterns (see also add_sink).
resource: Object which represents an HTTP/REST "resource". Falcon
will pass "GET" requests to on_get, "PUT" requests to on_put,
etc. If any HTTP methods are not supported by your resource,
@@ -192,15 +192,54 @@ class API(object):
"""
uri_fields, path_template = helpers.compile_uri_template(uri_template)
method_map, na_responder = helpers.create_http_method_map(
method_map = helpers.create_http_method_map(
resource, uri_fields, self._before, self._after)
# Insert at the head of the list in case we get duplicate
# adds (will cause the last one to win).
self._routes.insert(0, (path_template, method_map, na_responder))
self._routes.insert(0, (path_template, method_map))
def add_sink(self, sink, prefix=r'/'):
"""Add a "sink" responder to the API.
If no route matches a request, but the path in the requested URI
matches the specified prefix, Falcon will pass control to the
given sink, regardless of the HTTP method requested.
Args:
sink: A callable of the form:
func(req, resp)
prefix: A regex string, typically starting with '/', which
will trigger the sink if it matches the path portion of the
request's URI. Both strings and precompiled regex objects
may be specified. Characters are matched starting at the
beginning of the URI path.
Named groups are converted to kwargs and passed to
the sink as such.
If the route collides with a route's URI template, the
route will mask the sink (see also add_route).
"""
if not hasattr(prefix, 'match'):
# Assume it is a string
prefix = re.compile(prefix)
# NOTE(kgriffs): Insert at the head of the list such that
# in the case of a duplicate prefix, the last one added
# is preferred.
self._sinks.insert(0, (prefix, sink))
# TODO(kgriffs): Remove this functionality in Falcon version 0.2.0
@util.deprecated('Please migrate to add_sink(...) ASAP.')
def set_default_route(self, default_resource):
"""Route all the unrouted requests to a default resource
"""DEPRECATED: Route all the unrouted requests to a default resource
NOTE: If a default route is defined, all sinks are ignored.
Args:
default_resource: Object which works like an HTTP/REST resource.
@@ -225,6 +264,7 @@ class API(object):
when there is a matching exception when handling a request.
"""
# Insert at the head of the list in case we get duplicate
# adds (will cause the last one to win).
self._error_handlers.insert(0, (exception, handler))
@@ -249,7 +289,7 @@ class API(object):
"""
for route in self._routes:
path_template, method_map, na_responder = route
path_template, method_map = route
m = path_template.match(path)
if m:
params = m.groupdict()
@@ -263,16 +303,24 @@ class API(object):
else:
params = {}
if self._default_route is not None:
method_map, na_responder = self._default_route
if self._default_route is None:
for pattern, sink in self._sinks:
m = pattern.match(path)
if m:
params = m.groupdict()
responder = sink
break
else:
responder = falcon.responders.path_not_found
else:
method_map = self._default_route
try:
responder = method_map[method]
except KeyError:
responder = falcon.responders.bad_request
else:
responder = falcon.responders.path_not_found
na_responder = falcon.responders.create_method_not_allowed([])
return (responder, params, na_responder)
return (responder, params)

View File

@@ -217,7 +217,7 @@ def create_http_method_map(resource, uri_fields, before, after):
if method not in allowed_methods:
method_map[method] = na_responder
return method_map, na_responder
return method_map
#-----------------------------------------------------------------------------

View File

@@ -52,7 +52,7 @@ def rand_string(min, max):
int_gen = random.randint
string_length = int_gen(min, max)
return ''.join([chr(int_gen(ord('\t'), ord('~')))
return ''.join([chr(int_gen(ord(' '), ord('~')))
for i in range(string_length)])

116
falcon/tests/test_sinks.py Normal file
View File

@@ -0,0 +1,116 @@
import re
import falcon
import falcon.testing as testing
class Proxy(object):
def forward(self, req):
return falcon.HTTP_503
class Sink(object):
def __init__(self):
self._proxy = Proxy()
def __call__(self, req, resp, **kwargs):
resp.status = self._proxy.forward(req)
self.kwargs = kwargs
def sink_too(req, resp):
resp.status = falcon.HTTP_781
class BookCollection(testing.TestResource):
pass
class TestDefaultRouting(testing.TestBase):
def before(self):
self.sink = Sink()
self.resource = BookCollection()
def test_single_default_pattern(self):
self.api.add_sink(self.sink)
self.simulate_request('/')
self.assertEquals(self.srmock.status, falcon.HTTP_503)
def test_single_simple_pattern(self):
self.api.add_sink(self.sink, r'/foo')
self.simulate_request('/foo/bar')
self.assertEquals(self.srmock.status, falcon.HTTP_503)
def test_single_compiled_pattern(self):
self.api.add_sink(self.sink, re.compile(r'/foo'))
self.simulate_request('/foo/bar')
self.assertEquals(self.srmock.status, falcon.HTTP_503)
self.simulate_request('/auth')
self.assertEquals(self.srmock.status, falcon.HTTP_404)
def test_named_groups(self):
self.api.add_sink(self.sink, r'/user/(?P<id>\d+)')
self.simulate_request('/user/309')
self.assertEquals(self.srmock.status, falcon.HTTP_503)
self.assertEquals(self.sink.kwargs['id'], '309')
self.simulate_request('/user/sally')
self.assertEquals(self.srmock.status, falcon.HTTP_404)
def test_multiple_patterns(self):
self.api.add_sink(self.sink, r'/foo')
self.api.add_sink(sink_too, r'/foo') # Last duplicate wins
self.api.add_sink(self.sink, r'/katza')
self.simulate_request('/foo/bar')
self.assertEquals(self.srmock.status, falcon.HTTP_781)
self.simulate_request('/katza')
self.assertEquals(self.srmock.status, falcon.HTTP_503)
def test_with_route(self):
self.api.add_route('/books', self.resource)
self.api.add_sink(self.sink, '/proxy')
self.simulate_request('/proxy/books')
self.assertFalse(self.resource.called)
self.assertEquals(self.srmock.status, falcon.HTTP_503)
self.simulate_request('/books')
self.assertTrue(self.resource.called)
self.assertEquals(self.srmock.status, falcon.HTTP_200)
def test_route_precedence(self):
# NOTE(kgriffs): In case of collision, the route takes precedence.
self.api.add_route('/books', self.resource)
self.api.add_sink(self.sink, '/books')
self.simulate_request('/books')
self.assertTrue(self.resource.called)
self.assertEquals(self.srmock.status, falcon.HTTP_200)
def test_route_precedence_with_id(self):
# NOTE(kgriffs): In case of collision, the route takes precedence.
self.api.add_route('/books/{id}', self.resource)
self.api.add_sink(self.sink, '/books')
self.simulate_request('/books')
self.assertFalse(self.resource.called)
self.assertEquals(self.srmock.status, falcon.HTTP_503)
def test_route_precedence_with_both_id(self):
# NOTE(kgriffs): In case of collision, the route takes precedence.
self.api.add_route('/books/{id}', self.resource)
self.api.add_sink(self.sink, '/books/\d+')
self.simulate_request('/books/123')
self.assertTrue(self.resource.called)
self.assertEquals(self.srmock.status, falcon.HTTP_200)

View File

@@ -17,7 +17,10 @@ limitations under the License.
"""
import datetime
import functools
import six
import inspect
import warnings
if six.PY3: # pragma nocover
import urllib.parse as urllib
@@ -25,7 +28,52 @@ else: # pragma nocover
import urllib
__all__ = ('dt_to_http', 'http_date_to_dt', 'to_query_str', 'percent_escape')
__all__ = (
'deprecated',
'dt_to_http',
'http_date_to_dt',
'to_query_str',
'percent_escape')
# NOTE(kgriffs): We don't want our deprecations to be ignored by default,
# so create our own type.
#
# TODO(kgriffs): Revisit this decision if users complain.
class DeprecatedWarning(UserWarning):
pass
def deprecated(instructions):
"""Flags a method as deprecated.
Args:
instructions: A human-friendly string of instructions, such
as: 'Please migrate to add_proxy(...) ASAP.'
"""
def decorator(func):
'''This is a decorator which can be used to mark functions
as deprecated. It will result in a warning being emitted
when the function is used.'''
@functools.wraps(func)
def wrapper(*args, **kwargs):
message = 'Call to deprecated function {0}(...). {1}'.format(
func.__name__,
instructions)
frame = inspect.currentframe().f_back
warnings.warn_explicit(message,
category=DeprecatedWarning,
filename=inspect.getfile(frame.f_code),
lineno=frame.f_lineno)
return func(*args, **kwargs)
return wrapper
return decorator
def dt_to_http(dt):