Feature: Routing overload

When user specifies HTTP methods to function handlers, it automatically
will be overloaded unless they duplicate.

Example:

    # This is a new route. It works as before.
    @app.route('/overload', methods=['GET'])
    async def handler1(request):
        return text('OK1')

    # This is the exiting route but a new method. They are merged and
    # work as combined. The route will serve all of GET, POST and PUT.
    @app.route('/overload', methods=['POST', 'PUT'])
    async def handler2(request):
        return text('OK2')

    # This is the existing route and PUT method is the duplicated method.
    # It raises RouteExists.
    @app.route('/overload', methods=['PUT', 'DELETE'])
    async def handler3(request):
	return text('Duplicated')
This commit is contained in:
Jeong YunWon 2017-01-13 21:18:28 +09:00
parent 5a6fb679c9
commit 11f3c79a77
3 changed files with 129 additions and 6 deletions

View File

@ -3,6 +3,7 @@ from collections import defaultdict, namedtuple
from functools import lru_cache from functools import lru_cache
from .config import Config from .config import Config
from .exceptions import NotFound, InvalidUsage from .exceptions import NotFound, InvalidUsage
from .views import CompositionView
Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters'])
Parameter = namedtuple('Parameter', ['name', 'cast']) Parameter = namedtuple('Parameter', ['name', 'cast'])
@ -78,9 +79,6 @@ class Router:
self.hosts.add(host) self.hosts.add(host)
uri = host + uri uri = host + uri
if uri in self.routes_all:
raise RouteExists("Route already registered: {}".format(uri))
# Dict for faster lookups of if method allowed # Dict for faster lookups of if method allowed
if methods: if methods:
methods = frozenset(methods) methods = frozenset(methods)
@ -113,9 +111,35 @@ class Router:
pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) pattern_string = re.sub(r'<(.+?)>', add_parameter, uri)
pattern = re.compile(r'^{}$'.format(pattern_string)) pattern = re.compile(r'^{}$'.format(pattern_string))
route = Route( def merge_route(route, methods, handler):
handler=handler, methods=methods, pattern=pattern, # merge to the existing route when possible.
parameters=parameters) if not route.methods or not methods:
# method-unspecified routes are not mergeable.
raise RouteExists(
"Route already registered: {}".format(uri))
elif route.methods.intersection(methods):
# already existing method is not overloadable.
duplicated = methods.intersection(route.methods)
raise RouteExists(
"Route already registered: {} [{}]".format(
uri, ','.join(list(duplicated))))
if isinstance(route.handler, CompositionView):
view = route.handler
else:
view = CompositionView()
view.add(route.methods, route.handler)
view.add(methods, handler)
route = route._replace(
handler=view, methods=methods.union(route.methods))
return route
route = self.routes_all.get(uri)
if route:
route = merge_route(route, methods, handler)
else:
route = Route(
handler=handler, methods=methods, pattern=pattern,
parameters=parameters)
self.routes_all[uri] = route self.routes_all[uri] = route
if properties['unhashable']: if properties['unhashable']:

View File

@ -61,3 +61,38 @@ class HTTPMethodView:
view.__doc__ = cls.__doc__ view.__doc__ = cls.__doc__
view.__module__ = cls.__module__ view.__module__ = cls.__module__
return view return view
class CompositionView:
""" Simple method-function mapped view for the sanic.
You can add handler functions to methods (get, post, put, patch, delete)
for every HTTP method you want to support.
For example:
view = CompositionView()
view.add(['GET'], lambda request: text('I am get method'))
view.add(['POST', 'PUT'], lambda request: text('I am post/put method'))
etc.
If someone tries to use a non-implemented method, there will be a
405 response.
"""
def __init__(self):
self.handlers = {}
def add(self, methods, handler):
for method in methods:
if method in self.handlers:
raise KeyError(
'Method {} already is registered.'.format(method))
self.handlers[method] = handler
def __call__(self, request, *args, **kwargs):
handler = self.handlers.get(request.method.upper(), None)
if handler is None:
raise InvalidUsage(
'Method {} not allowed for URL {}'.format(
request.method, request.url), status_code=405)
return handler(request, *args, **kwargs)

View File

@ -463,3 +463,67 @@ def test_remove_route_without_clean_cache():
request, response = sanic_endpoint_test(app, uri='/test') request, response = sanic_endpoint_test(app, uri='/test')
assert response.status == 200 assert response.status == 200
def test_overload_routes():
app = Sanic('test_dynamic_route')
@app.route('/overload', methods=['GET'])
async def handler1(request):
return text('OK1')
@app.route('/overload', methods=['POST', 'PUT'])
async def handler2(request):
return text('OK2')
request, response = sanic_endpoint_test(app, 'get', uri='/overload')
assert response.text == 'OK1'
request, response = sanic_endpoint_test(app, 'post', uri='/overload')
assert response.text == 'OK2'
request, response = sanic_endpoint_test(app, 'put', uri='/overload')
assert response.text == 'OK2'
request, response = sanic_endpoint_test(app, 'delete', uri='/overload')
assert response.status == 405
with pytest.raises(RouteExists):
@app.route('/overload', methods=['PUT', 'DELETE'])
async def handler3(request):
return text('Duplicated')
def test_unmergeable_overload_routes():
app = Sanic('test_dynamic_route')
@app.route('/overload_whole')
async def handler1(request):
return text('OK1')
with pytest.raises(RouteExists):
@app.route('/overload_whole', methods=['POST', 'PUT'])
async def handler2(request):
return text('Duplicated')
request, response = sanic_endpoint_test(app, 'get', uri='/overload_whole')
assert response.text == 'OK1'
request, response = sanic_endpoint_test(app, 'post', uri='/overload_whole')
assert response.text == 'OK1'
@app.route('/overload_part', methods=['GET'])
async def handler1(request):
return text('OK1')
with pytest.raises(RouteExists):
@app.route('/overload_part')
async def handler2(request):
return text('Duplicated')
request, response = sanic_endpoint_test(app, 'get', uri='/overload_part')
assert response.text == 'OK1'
request, response = sanic_endpoint_test(app, 'post', uri='/overload_part')
assert response.status == 405