Merge pull request #387 from subyraman/url-for-v3

Add `url_for` method for simple routes, blueprints, HTTPMethodView
This commit is contained in:
Eli Uriegas 2017-02-08 19:20:09 -06:00 committed by GitHub
commit 6724d8131c
10 changed files with 489 additions and 13 deletions

View File

@ -158,3 +158,22 @@ app.blueprint(blueprint_v2)
app.run(host='0.0.0.0', port=8000, debug=True) app.run(host='0.0.0.0', port=8000, debug=True)
``` ```
## URL Building with `url_for`
If you wish to generate a URL for a route inside of a blueprint, remember that the endpoint name
takes the format `<blueprint_name>.<handler_name>`. For example:
```
@blueprint_v1.route('/')
async def root(request):
url = app.url_for('v1.post_handler', post_id=5)
return redirect(url)
@blueprint_v1.route('/post/<post_id>')
async def post_handler(request, post_id):
return text('Post {} in Blueprint V1'.format(post_id))
```

View File

@ -67,7 +67,7 @@ app.add_route(NameView.as_view(), '/<name>')
If you want to add any decorators to the class, you can set the `decorators` If you want to add any decorators to the class, you can set the `decorators`
class variable. These will be applied to the class when `as_view` is called. class variable. These will be applied to the class when `as_view` is called.
``` ```python
class ViewWithDecorator(HTTPMethodView): class ViewWithDecorator(HTTPMethodView):
decorators = [some_decorator_here] decorators = [some_decorator_here]
@ -77,6 +77,27 @@ class ViewWithDecorator(HTTPMethodView):
app.add_route(ViewWithDecorator.as_view(), '/url') app.add_route(ViewWithDecorator.as_view(), '/url')
``` ```
#### URL Building
If you wish to build a URL for an HTTPMethodView, remember that the class name will be the endpoint
that you will pass into `url_for`. For example:
```python
@app.route('/')
def index(request):
url = app.url_for('SpecialClassView')
return redirect(url)
class SpecialClassView(HTTPMethodView):
def get(self, request):
return text('Hello from the Special Class View!')
app.add_route(SpecialClassView.as_view(), '/special_class_view')
```
## Using CompositionView ## Using CompositionView
As an alternative to the `HTTPMethodView`, you can use `CompositionView` to As an alternative to the `HTTPMethodView`, you can use `CompositionView` to
@ -106,3 +127,5 @@ view.add(['POST', 'PUT'], lambda request: text('I am a post/put method'))
# Use the new view to handle requests to the base URL # Use the new view to handle requests to the base URL
app.add_route(view, '/') app.add_route(view, '/')
``` ```
Note: currently you cannot build a URL for a CompositionView using `url_for`.

View File

@ -119,3 +119,35 @@ app.add_route(handler1, '/test')
app.add_route(handler2, '/folder/<name>') app.add_route(handler2, '/folder/<name>')
app.add_route(person_handler2, '/person/<name:[A-z]>', methods=['GET']) app.add_route(person_handler2, '/person/<name:[A-z]>', methods=['GET'])
``` ```
## URL building with `url_for`
Sanic provides a `url_for` method, to generate URLs based on the handler method name. This is useful if you want to avoid hardcoding url paths into your app; instead, you can just reference the handler name. For example:
```python
@app.route('/')
async def index(request):
# generate a URL for the endpoint `post_handler`
url = app.url_for('post_handler', post_id=5)
# the URL is `/posts/5`, redirect to it
return redirect(url)
@app.route('/posts/<post_id>')
async def post_handler(request, post_id):
return text('Post - {}'.format(post_id))
```
Other things to keep in mind when using `url_for`:
- Keyword arguments passed to `url_for` that are not request parameters will be included in the URL's query string. For example:
```python
url = app.url_for('post_handler', post_id=5, arg_one='one', arg_two='two')
# /posts/5?arg_one=one&arg_two=two
```
- All valid parameters must be passed to `url_for` to build a URL. If a parameter is not supplied, or if a parameter does not match the specified type, a `URLBuildError` will be thrown.

View File

@ -17,3 +17,5 @@ app.static('/the_best.png', '/home/ubuntu/test.png')
app.run(host="0.0.0.0", port=8000) app.run(host="0.0.0.0", port=8000)
``` ```
Note: currently you cannot build a URL for a static file using `url_for`.

View File

@ -35,6 +35,9 @@ class Blueprint:
# Routes # Routes
for future in self.routes: for future in self.routes:
# attach the blueprint name to the handler so that it can be
# prefixed properly in the router
future.handler.__blueprintname__ = self.name
# Prepend the blueprint URI prefix if available # Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri uri = url_prefix + future.uri if url_prefix else future.uri
app.route( app.route(

View File

@ -120,6 +120,10 @@ class ServerError(SanicException):
status_code = 500 status_code = 500
class URLBuildError(SanicException):
status_code = 500
class FileNotFound(NotFound): class FileNotFound(NotFound):
status_code = 404 status_code = 404

View File

@ -4,7 +4,9 @@ from functools import lru_cache
from .exceptions import NotFound, InvalidUsage from .exceptions import NotFound, InvalidUsage
from .views import CompositionView from .views import CompositionView
Route = namedtuple('Route', ['handler', 'methods', 'pattern', 'parameters']) Route = namedtuple(
'Route',
['handler', 'methods', 'pattern', 'parameters', 'name'])
Parameter = namedtuple('Parameter', ['name', 'cast']) Parameter = namedtuple('Parameter', ['name', 'cast'])
REGEX_TYPES = { REGEX_TYPES = {
@ -59,6 +61,7 @@ class Router:
routes_static = None routes_static = None
routes_dynamic = None routes_dynamic = None
routes_always_check = None routes_always_check = None
parameter_pattern = re.compile(r'<(.+?)>')
def __init__(self): def __init__(self):
self.routes_all = {} self.routes_all = {}
@ -67,6 +70,29 @@ class Router:
self.routes_always_check = [] self.routes_always_check = []
self.hosts = None self.hosts = None
def parse_parameter_string(self, parameter_string):
"""
Parse a parameter string into its constituent name, type, and pattern
For example:
`parse_parameter_string('<param_one:[A-z]')` ->
('param_one', str, '[A-z]')
:param parameter_string: String to parse
:return: tuple containing
(parameter_name, parameter_type, parameter_pattern)
"""
# We could receive NAME or NAME:PATTERN
name = parameter_string
pattern = 'string'
if ':' in parameter_string:
name, pattern = parameter_string.split(':', 1)
default = (str, pattern)
# Pull from pre-configured types
_type, pattern = REGEX_TYPES.get(pattern, default)
return name, _type, pattern
def add(self, uri, methods, handler, host=None): def add(self, uri, methods, handler, host=None):
""" """
Adds a handler to the route list Adds a handler to the route list
@ -104,16 +130,11 @@ class Router:
properties = {"unhashable": None} properties = {"unhashable": None}
def add_parameter(match): def add_parameter(match):
# We could receive NAME or NAME:PATTERN
name = match.group(1) name = match.group(1)
pattern = 'string' name, _type, pattern = self.parse_parameter_string(name)
if ':' in name:
name, pattern = name.split(':', 1)
default = (str, pattern) parameter = Parameter(
# Pull from pre-configured types name=name, cast=_type)
_type, pattern = REGEX_TYPES.get(pattern, default)
parameter = Parameter(name=name, cast=_type)
parameters.append(parameter) parameters.append(parameter)
# Mark the whole route as unhashable if it has the hash key in it # Mark the whole route as unhashable if it has the hash key in it
@ -125,7 +146,7 @@ class Router:
return '({})'.format(pattern) return '({})'.format(pattern)
pattern_string = re.sub(r'<(.+?)>', add_parameter, uri) pattern_string = re.sub(self.parameter_pattern, add_parameter, uri)
pattern = re.compile(r'^{}$'.format(pattern_string)) pattern = re.compile(r'^{}$'.format(pattern_string))
def merge_route(route, methods, handler): def merge_route(route, methods, handler):
@ -169,9 +190,17 @@ class Router:
if route: if route:
route = merge_route(route, methods, handler) route = merge_route(route, methods, handler)
else: else:
# prefix the handler name with the blueprint name
# if available
if hasattr(handler, '__blueprintname__'):
handler_name = '{}.{}'.format(
handler.__blueprintname__, handler.__name__)
else:
handler_name = getattr(handler, '__name__', None)
route = Route( route = Route(
handler=handler, methods=methods, pattern=pattern, handler=handler, methods=methods, pattern=pattern,
parameters=parameters) parameters=parameters, name=handler_name)
self.routes_all[uri] = route self.routes_all[uri] = route
if properties['unhashable']: if properties['unhashable']:
@ -208,6 +237,23 @@ class Router:
if clean_cache: if clean_cache:
self._get.cache_clear() self._get.cache_clear()
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
def find_route_by_view_name(self, view_name):
"""
Find a route in the router based on the specified view name.
:param view_name: string of view name to search by
:return: tuple containing (uri, Route)
"""
if not view_name:
return (None, None)
for uri, route in self.routes_all.items():
if route.name == view_name:
return uri, route
return (None, None)
def get(self, request): def get(self, request):
""" """
Gets a request handler based on the URL of the request, or raises an Gets a request handler based on the URL of the request, or raises an

View File

@ -3,13 +3,15 @@ from asyncio import get_event_loop
from collections import deque from collections import deque
from functools import partial from functools import partial
from inspect import isawaitable, stack, getmodulename from inspect import isawaitable, stack, getmodulename
import re
from traceback import format_exc from traceback import format_exc
from urllib.parse import urlencode, urlunparse
import warnings import warnings
from .config import Config from .config import Config
from .constants import HTTP_METHODS from .constants import HTTP_METHODS
from .exceptions import Handler from .exceptions import Handler
from .exceptions import ServerError from .exceptions import ServerError, URLBuildError
from .log import log from .log import log
from .response import HTTPResponse from .response import HTTPResponse
from .router import Router from .router import Router
@ -192,6 +194,89 @@ class Sanic:
DeprecationWarning) DeprecationWarning)
return self.blueprint(*args, **kwargs) return self.blueprint(*args, **kwargs)
def url_for(self, view_name: str, **kwargs):
"""Builds a URL based on a view name and the values provided.
In order to build a URL, all request parameters must be supplied as
keyword arguments, and each parameter must pass the test for the
specified parameter type. If these conditions are not met, a
`URLBuildError` will be thrown.
Keyword arguments that are not request parameters will be included in
the output URL's query string.
:param view_name: A string referencing the view name
:param **kwargs: keys and values that are used to build request
parameters and query string arguments.
:return: the built URL
Raises:
URLBuildError
"""
# find the route by the supplied view name
uri, route = self.router.find_route_by_view_name(view_name)
if not uri or not route:
raise URLBuildError(
'Endpoint with name `{}` was not found'.format(
view_name))
out = uri
# find all the parameters we will need to build in the URL
matched_params = re.findall(
self.router.parameter_pattern, uri)
for match in matched_params:
name, _type, pattern = self.router.parse_parameter_string(
match)
# we only want to match against each individual parameter
specific_pattern = '^{}$'.format(pattern)
supplied_param = None
if kwargs.get(name):
supplied_param = kwargs.get(name)
del kwargs[name]
else:
raise URLBuildError(
'Required parameter `{}` was not passed to url_for'.format(
name))
supplied_param = str(supplied_param)
# determine if the parameter supplied by the caller passes the test
# in the URL
passes_pattern = re.match(specific_pattern, supplied_param)
if not passes_pattern:
if _type != str:
msg = (
'Value "{}" for parameter `{}` does not '
'match pattern for type `{}`: {}'.format(
supplied_param, name, _type.__name__, pattern))
else:
msg = (
'Value "{}" for parameter `{}` '
'does not satisfy pattern {}'.format(
supplied_param, name, pattern))
raise URLBuildError(msg)
# replace the parameter in the URL with the supplied value
replacement_regex = '(<{}.*?>)'.format(name)
out = re.sub(
replacement_regex, supplied_param, out)
# parse the remainder of the keyword arguments into a querystring
if kwargs:
query_string = urlencode(kwargs)
out = urlunparse((
'', '', out,
'', query_string, ''
))
return out
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
# Request Handling # Request Handling
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #

View File

@ -64,6 +64,7 @@ class HTTPMethodView:
view.view_class = cls view.view_class = cls
view.__doc__ = cls.__doc__ view.__doc__ = cls.__doc__
view.__module__ = cls.__module__ view.__module__ = cls.__module__
view.__name__ = cls.__name__
return view return view

261
tests/test_url_building.py Normal file
View File

@ -0,0 +1,261 @@
import pytest as pytest
from urllib.parse import urlsplit, parse_qsl
from sanic import Sanic
from sanic.response import text
from sanic.views import HTTPMethodView
from sanic.blueprints import Blueprint
from sanic.utils import sanic_endpoint_test
from sanic.exceptions import URLBuildError
import string
def _generate_handlers_from_names(app, l):
for name in l:
# this is the easiest way to generate functions with dynamic names
exec('@app.route(name)\ndef {}(request):\n\treturn text("{}")'.format(
name, name))
@pytest.fixture
def simple_app():
app = Sanic('simple_app')
handler_names = list(string.ascii_letters)
_generate_handlers_from_names(app, handler_names)
return app
def test_simple_url_for_getting(simple_app):
for letter in string.ascii_letters:
url = simple_app.url_for(letter)
assert url == '/{}'.format(letter)
request, response = sanic_endpoint_test(
simple_app, uri=url)
assert response.status == 200
assert response.text == letter
def test_fails_if_endpoint_not_found():
app = Sanic('fail_url_build')
@app.route('/fail')
def fail():
return text('this should fail')
with pytest.raises(URLBuildError) as e:
app.url_for('passes')
assert str(e.value) == 'Endpoint with name `passes` was not found'
def test_fails_url_build_if_param_not_passed():
url = '/'
for letter in string.ascii_letters:
url += '<{}>/'.format(letter)
app = Sanic('fail_url_build')
@app.route(url)
def fail():
return text('this should fail')
fail_args = list(string.ascii_letters)
fail_args.pop()
fail_kwargs = {l: l for l in fail_args}
with pytest.raises(URLBuildError) as e:
app.url_for('fail', **fail_kwargs)
assert 'Required parameter `Z` was not passed to url_for' in str(e.value)
COMPLEX_PARAM_URL = (
'/<foo:int>/<four_letter_string:[A-z]{4}>/'
'<two_letter_string:[A-z]{2}>/<normal_string>/<some_number:number>')
PASSING_KWARGS = {
'foo': 4, 'four_letter_string': 'woof',
'two_letter_string': 'ba', 'normal_string': 'normal',
'some_number': '1.001'}
EXPECTED_BUILT_URL = '/4/woof/ba/normal/1.001'
def test_fails_with_int_message():
app = Sanic('fail_url_build')
@app.route(COMPLEX_PARAM_URL)
def fail():
return text('this should fail')
failing_kwargs = dict(PASSING_KWARGS)
failing_kwargs['foo'] = 'not_int'
with pytest.raises(URLBuildError) as e:
app.url_for('fail', **failing_kwargs)
expected_error = (
'Value "not_int" for parameter `foo` '
'does not match pattern for type `int`: \d+')
assert str(e.value) == expected_error
def test_fails_with_two_letter_string_message():
app = Sanic('fail_url_build')
@app.route(COMPLEX_PARAM_URL)
def fail():
return text('this should fail')
failing_kwargs = dict(PASSING_KWARGS)
failing_kwargs['two_letter_string'] = 'foobar'
with pytest.raises(URLBuildError) as e:
app.url_for('fail', **failing_kwargs)
expected_error = (
'Value "foobar" for parameter `two_letter_string` '
'does not satisfy pattern [A-z]{2}')
assert str(e.value) == expected_error
def test_fails_with_number_message():
app = Sanic('fail_url_build')
@app.route(COMPLEX_PARAM_URL)
def fail():
return text('this should fail')
failing_kwargs = dict(PASSING_KWARGS)
failing_kwargs['some_number'] = 'foo'
with pytest.raises(URLBuildError) as e:
app.url_for('fail', **failing_kwargs)
expected_error = (
'Value "foo" for parameter `some_number` '
'does not match pattern for type `float`: [0-9\\\\.]+')
assert str(e.value) == expected_error
def test_adds_other_supplied_values_as_query_string():
app = Sanic('passes')
@app.route(COMPLEX_PARAM_URL)
def passes():
return text('this should pass')
new_kwargs = dict(PASSING_KWARGS)
new_kwargs['added_value_one'] = 'one'
new_kwargs['added_value_two'] = 'two'
url = app.url_for('passes', **new_kwargs)
query = dict(parse_qsl(urlsplit(url).query))
assert query['added_value_one'] == 'one'
assert query['added_value_two'] == 'two'
@pytest.fixture
def blueprint_app():
app = Sanic('blueprints')
first_print = Blueprint('first', url_prefix='/first')
second_print = Blueprint('second', url_prefix='/second')
@first_print.route('/foo')
def foo():
return text('foo from first')
@first_print.route('/foo/<param>')
def foo_with_param(request, param):
return text(
'foo from first : {}'.format(param))
@second_print.route('/foo') # noqa
def foo():
return text('foo from second')
@second_print.route('/foo/<param>') # noqa
def foo_with_param(request, param):
return text(
'foo from second : {}'.format(param))
app.blueprint(first_print)
app.blueprint(second_print)
return app
def test_blueprints_are_named_correctly(blueprint_app):
first_url = blueprint_app.url_for('first.foo')
assert first_url == '/first/foo'
second_url = blueprint_app.url_for('second.foo')
assert second_url == '/second/foo'
def test_blueprints_work_with_params(blueprint_app):
first_url = blueprint_app.url_for('first.foo_with_param', param='bar')
assert first_url == '/first/foo/bar'
second_url = blueprint_app.url_for('second.foo_with_param', param='bar')
assert second_url == '/second/foo/bar'
@pytest.fixture
def methodview_app():
app = Sanic('methodview')
class ViewOne(HTTPMethodView):
def get(self, request):
return text('I am get method')
def post(self, request):
return text('I am post method')
def put(self, request):
return text('I am put method')
def patch(self, request):
return text('I am patch method')
def delete(self, request):
return text('I am delete method')
app.add_route(ViewOne.as_view('view_one'), '/view_one')
class ViewTwo(HTTPMethodView):
def get(self, request):
return text('I am get method')
def post(self, request):
return text('I am post method')
def put(self, request):
return text('I am put method')
def patch(self, request):
return text('I am patch method')
def delete(self, request):
return text('I am delete method')
app.add_route(ViewTwo.as_view(), '/view_two')
return app
def test_methodview_naming(methodview_app):
viewone_url = methodview_app.url_for('ViewOne')
viewtwo_url = methodview_app.url_for('ViewTwo')
assert viewone_url == '/view_one'
assert viewtwo_url == '/view_two'