From 6963f627a11ea6f226784b0b8b22e3f24d33510f Mon Sep 17 00:00:00 2001 From: kgriffs Date: Tue, 31 Dec 2013 16:49:16 -0600 Subject: [PATCH] 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]+')) --- falcon/api.py | 94 ++++++++++++++++++++++-------- falcon/api_helpers.py | 2 +- falcon/testing/helpers.py | 2 +- falcon/tests/test_sinks.py | 116 +++++++++++++++++++++++++++++++++++++ falcon/util.py | 50 +++++++++++++++- 5 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 falcon/tests/test_sinks.py diff --git a/falcon/api.py b/falcon/api.py index 526233b..83ab56f 100644 --- a/falcon/api.py +++ b/falcon/api.py @@ -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) diff --git a/falcon/api_helpers.py b/falcon/api_helpers.py index b9aa69d..3fa7dfa 100644 --- a/falcon/api_helpers.py +++ b/falcon/api_helpers.py @@ -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 #----------------------------------------------------------------------------- diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 7523938..7c955a7 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -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)]) diff --git a/falcon/tests/test_sinks.py b/falcon/tests/test_sinks.py new file mode 100644 index 0000000..e34362f --- /dev/null +++ b/falcon/tests/test_sinks.py @@ -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\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) diff --git a/falcon/util.py b/falcon/util.py index 3710750..61d51ab 100644 --- a/falcon/util.py +++ b/falcon/util.py @@ -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):