feat(API): Foundational support for URI template field converters (#1067)

Provides an initial converter (int) along with relevant plumbing and
tests. Additional converters and docs to come in future PRs.
This commit is contained in:
Kurt Griffiths 2017-06-27 21:25:15 -06:00 committed by John Vrbanac
parent fe1dfd0fad
commit f6ca886324
10 changed files with 718 additions and 84 deletions

View File

@ -21,3 +21,5 @@ standard-compliant WSGI server.
.. autoclass:: falcon.ResponseOptions
:members:
.. autoclass:: falcon.routing.CompiledRouterOptions
:noindex:

View File

@ -63,4 +63,5 @@ A custom routing engine may be specified when instantiating
api = API(router=fancy)
.. automodule:: falcon.routing
:members: create_http_method_map, compile_uri_template, CompiledRouter
:members: create_http_method_map, compile_uri_template,
CompiledRouter, CompiledRouterOptions

View File

@ -113,6 +113,11 @@ class API(object):
requests. See also: :py:class:`~.RequestOptions`
resp_options: A set of behavioral options related to outgoing
responses. See also: :py:class:`~.ResponseOptions`
router_options: Configuration options for the router. If a
custom router is in use, and it does not expose any
configurable options, referencing this attribute will raise
an instance of ``AttributeError``. See also:
:py:class:`falcon.routing.CompiledRouterOptions`.
"""
# PERF(kgriffs): Reference via self since that is faster than
@ -282,6 +287,10 @@ class API(object):
start_response(resp_status, headers)
return body
@property
def router_options(self):
return self._router.options
def add_route(self, uri_template, resource, *args, **kwargs):
"""Associates a templatized URI path with a resource.

View File

@ -19,7 +19,7 @@ includes utility functions to aid in the implementation of custom
routers.
"""
from falcon.routing.compiled import CompiledRouter
from falcon.routing.compiled import CompiledRouter, CompiledRouterOptions # NOQA
from falcon.routing.util import create_http_method_map # NOQA
from falcon.routing.util import compile_uri_template # NOQA

View File

@ -16,10 +16,18 @@
import keyword
import re
import textwrap
from six.moves import UserDict
from falcon.routing import converters
_FIELD_PATTERN = re.compile('{([^}]*)}')
_TAB_STR = ' ' * 4
_FIELD_PATTERN = re.compile(
'{((?P<fname>[^}:]*)((?P<cname_sep>:(?P<cname>[^}\(]*))(\((?P<argstr>.*)\))?)?)}'
)
_IDENTIFIER_PATTERN = re.compile('[A-Za-z_][A-Za-z0-9_]*$')
class CompiledRouter(object):
@ -37,19 +45,38 @@ class CompiledRouter(object):
__slots__ = (
'_ast',
'_converter_map',
'_converters',
'_find',
'_finder_src',
'_options',
'_patterns',
'_return_values',
'_roots',
)
def __init__(self):
self._roots = []
self._find = self._compile()
self._ast = None
self._converters = None
self._finder_src = None
self._options = CompiledRouterOptions()
# PERF(kgriffs): This is usually an anti-pattern, but we do it
# here to reduce lookup time.
self._converter_map = self._options.converters.data
self._patterns = None
self._return_values = None
self._roots = []
# NOTE(kgriffs): Call _compile() last since it depends on
# the variables above.
self._find = self._compile()
@property
def options(self):
return self._options
@property
def finder_src(self):
@ -66,34 +93,17 @@ class CompiledRouter(object):
the URI template.
"""
if re.search('\s', uri_template):
# NOTE(kgriffs): Fields may have whitespace in them, so sub
# those before checking the rest of the URI template.
if re.search('\s', _FIELD_PATTERN.sub('{FIELD}', uri_template)):
raise ValueError('URI templates may not include whitespace.')
# NOTE(kgriffs): Ensure fields are valid Python identifiers,
# since they will be passed as kwargs to responders. Also
# ensure there are no duplicate names, since that causes the
# following problems:
#
# 1. For simple nodes, values from deeper nodes overwrite
# values from more shallow nodes.
# 2. For complex nodes, re.compile() raises a nasty error
#
fields = _FIELD_PATTERN.findall(uri_template)
used_names = set()
for name in fields:
is_identifier = re.match('[A-Za-z_][A-Za-z0-9_]*$', name)
if not is_identifier or name in keyword.kwlist:
raise ValueError('Field names must be valid identifiers '
"('{}' is not valid).".format(name))
if name in used_names:
raise ValueError('Field names may not be duplicated '
"('{}' was used more than once)".format(name))
used_names.add(name)
path = uri_template.strip('/').split('/')
used_names = set()
for segment in path:
self._validate_template_segment(segment, used_names)
def insert(nodes, path_index=0):
for node in nodes:
segment = path[path_index]
@ -110,16 +120,13 @@ class CompiledRouter(object):
return
if node.conflicts_with(segment):
msg = (
'The URI template for this route conflicts with another'
"route's template. This is usually caused by using "
'different field names at the same level in the path. '
'For example, given the route paths '
"'/parents/{id}' and '/parents/{parent_id}/children', "
'the conflict can be resolved by renaming one of the '
'fields to match the other, i.e.: '
"'/parents/{parent_id}' and '/parents/{parent_id}/children'."
)
msg = textwrap.dedent("""
The URI template for this route is inconsistent or conflicts with another
route's template. This is usually caused by configuring a field converter
differently for the same field in two different routes, or by using
different field names at the same level in the path (e.g.,
'/parents/{id}' and '/parents/{parent_id}/children')
""").strip().replace('\n', ' ')
raise ValueError(msg)
# NOTE(richardolsson): If we got this far, the node doesn't already
@ -157,7 +164,8 @@ class CompiledRouter(object):
path = uri.lstrip('/').split('/')
params = {}
node = self._find(path, self._return_values, self._patterns, params)
node = self._find(path, self._return_values, self._patterns,
self._converters, params)
if node is not None:
return node.resource, node.method_map, params, node.uri_template
@ -168,6 +176,48 @@ class CompiledRouter(object):
# Private
# -----------------------------------------------------------------
def _validate_template_segment(self, segment, used_names):
"""Validates a single path segment of a URI template.
1. Ensure field names are valid Python identifiers, since they
will be passed as kwargs to responders.
2. Check that there are no duplicate names, since that causes
(at least) the following problems:
a. For simple nodes, values from deeper nodes overwrite
values from more shallow nodes.
b. For complex nodes, re.compile() raises a nasty error
3. Check that when the converter syntax is used, the named
converter exists.
"""
for field in _FIELD_PATTERN.finditer(segment):
name = field.group('fname')
is_identifier = _IDENTIFIER_PATTERN.match(name)
if not is_identifier or name in keyword.kwlist:
msg_template = ('Field names must be valid identifiers '
'("{0}" is not valid)')
msg = msg_template.format(name)
raise ValueError(msg)
if name in used_names:
msg_template = ('Field names may not be duplicated '
'("{0}" was used more than once)')
msg = msg_template.format(name)
raise ValueError(msg)
used_names.add(name)
if field.group('cname_sep') == ':':
msg = 'Missing converter for field "{0}"'.format(name)
raise ValueError(msg)
name = field.group('cname')
if name and name not in self._converter_map:
msg = 'Unknown converter: "{0}"'.format(name)
raise ValueError(msg)
def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_return=True):
"""Generates a coarse AST for the router."""
@ -176,7 +226,7 @@ class CompiledRouter(object):
return
outer_parent = _CxIfPathLength('>', level)
parent.append(outer_parent)
parent.append_child(outer_parent)
parent = outer_parent
found_simple = False
@ -212,13 +262,45 @@ class CompiledRouter(object):
construct = _CxIfPathSegmentPattern(level, pattern_idx,
node.var_pattern.pattern)
parent.append(construct)
parent.append_child(construct)
parent = construct
if node.var_converter_map:
parent.append_child(_CxPrefetchGroupsFromPatternMatch())
parent = self._generate_conversion_ast(parent, node)
else:
parent.append_child(_CxSetParamsFromPatternMatch())
else:
# NOTE(kgriffs): Simple nodes just capture the entire path
# segment as the value for the param.
parent.append(_CxSetParam(node.var_name, level))
if node.var_converter_map:
assert len(node.var_converter_map) == 1
parent.append_child(_CxSetFragmentFromPath(level))
field_name = node.var_name
__, converter_name, converter_argstr = node.var_converter_map[0]
converter_class = self._converter_map[converter_name]
converter_obj = self._instantiate_converter(
converter_class,
converter_argstr
)
converter_idx = len(self._converters)
self._converters.append(converter_obj)
construct = _CxIfConverterField(
field_name,
converter_idx,
)
parent.append_child(construct)
parent = construct
else:
parent.append_child(_CxSetParam(node.var_name, level))
# NOTE(kgriffs): We don't allow multiple simple var nodes
# to exist at the same level, e.g.:
@ -233,7 +315,7 @@ class CompiledRouter(object):
else:
# NOTE(kgriffs): Not a param, so must match exactly
construct = _CxIfPathSegmentLiteral(level, node.raw_segment)
parent.append(construct)
parent.append_child(construct)
parent = construct
if node.resource is not None:
@ -253,22 +335,52 @@ class CompiledRouter(object):
if node.resource is None:
if fast_return:
parent.append(_CxReturnNone())
parent.append_child(_CxReturnNone())
else:
# NOTE(kgriffs): Make sure that we have consumed all of
# the segments for the requested route; otherwise we could
# mistakenly match "/foo/23/bar" against "/foo/{id}".
construct = _CxIfPathLength('==', level + 1)
construct.append(_CxReturnValue(resource_idx))
parent.append(construct)
construct.append_child(_CxReturnValue(resource_idx))
parent.append_child(construct)
if fast_return:
parent.append(_CxReturnNone())
parent.append_child(_CxReturnNone())
parent = outer_parent
if not found_simple and fast_return:
parent.append(_CxReturnNone())
parent.append_child(_CxReturnNone())
def _generate_conversion_ast(self, parent, node):
# NOTE(kgriffs): Unroll the converter loop into
# a series of nested "if" constructs.
for field_name, converter_name, converter_argstr in node.var_converter_map:
converter_class = self._converter_map[converter_name]
converter_obj = self._instantiate_converter(
converter_class,
converter_argstr
)
converter_idx = len(self._converters)
self._converters.append(converter_obj)
parent.append_child(_CxSetFragmentFromField(field_name))
construct = _CxIfConverterField(
field_name,
converter_idx,
)
parent.append_child(construct)
parent = construct
# NOTE(kgriffs): Add remaining fields that were not
# converted, if any.
if node.num_fields > len(node.var_converter_map):
parent.append_child(_CxSetParamsFromPatternMatchPrefetched())
return parent
def _compile(self):
"""Generates Python code for the entire routing tree.
@ -278,12 +390,13 @@ class CompiledRouter(object):
"""
src_lines = [
'def find(path, return_values, patterns, params):',
'def find(path, return_values, patterns, converters, params):',
_TAB_STR + 'path_len = len(path)',
]
self._return_values = []
self._patterns = []
self._converters = []
self._ast = _CxParent()
self._generate_ast(
@ -307,6 +420,14 @@ class CompiledRouter(object):
return scope['find']
def _instantiate_converter(self, klass, argstr=None):
if argstr is None:
return klass()
# NOTE(kgriffs): Don't try this at home. ;)
src = '{0}({1})'.format(klass.__name__, argstr)
return eval(src, {klass.__name__: klass})
class CompiledRouterNode(object):
"""Represents a single URI segment in a URI."""
@ -322,8 +443,13 @@ class CompiledRouterNode(object):
self.is_var = False
self.is_complex = False
self.num_fields = 0
# TODO(kgriffs): Rename these since the docs talk about "fields"
# or "field expressions", not "vars" or "variables".
self.var_name = None
self.var_pattern = None
self.var_converter_map = []
# NOTE(kgriffs): CompiledRouter.add_route validates field names,
# so here we can just assume they are OK and use the simple
@ -334,14 +460,34 @@ class CompiledRouterNode(object):
self.is_var = False
else:
self.is_var = True
self.num_fields = len(matches)
if len(matches) == 1 and matches[0].span() == (0, len(raw_segment)):
# NOTE(richardolsson): if there is a single variable and
# it spans the entire segment, the segment is not
# complex and the variable name is simply the string
# contained within curly braces.
for field in matches:
# NOTE(kgriffs): We already validated the field
# expression to disallow blank converter names, or names
# that don't match a known converter, so if a name is
# given, we can just go ahead and use it.
if field.group('cname'):
self.var_converter_map.append(
(
field.group('fname'),
field.group('cname'),
field.group('argstr'),
)
)
if matches[0].span() == (0, len(raw_segment)):
# NOTE(kgriffs): Single field, spans entire segment
assert len(matches) == 1
# TODO(kgriffs): It is not "complex" because it only
# contains a single field. Rename this variable to make
# it more descriptive.
self.is_complex = False
self.var_name = raw_segment[1:-1]
field = matches[0]
self.var_name = field.group('fname')
else:
# NOTE(richardolsson): Complex segments need to be
# converted into regular expressions in order to match
@ -364,12 +510,15 @@ class CompiledRouterNode(object):
# trick the parser into doing the right thing.
escaped_segment = re.sub(r'[\.\(\)\[\]\?\$\*\+\^\|]', r'\\\g<0>', raw_segment)
pattern_text = _FIELD_PATTERN.sub(r'(?P<\1>.+)', escaped_segment)
pattern_text = _FIELD_PATTERN.sub(r'(?P<\2>.+)', escaped_segment)
pattern_text = '^' + pattern_text + '$'
self.is_complex = True
self.var_pattern = re.compile(pattern_text)
if self.is_complex:
assert self.is_var
def matches(self, segment):
"""Returns True if this node matches the supplied template segment."""
@ -431,6 +580,111 @@ class CompiledRouterNode(object):
return False
class ConverterDict(UserDict):
"""A dict-like class for storing field converters."""
def update(self, other):
try:
# NOTE(kgriffs): If it is a mapping type, it should
# implement keys().
names = other.keys()
except AttributeError:
# NOTE(kgriffs): Not a mapping type, so assume it is an
# iterable of 2-item iterables. But we need to make it
# re-iterable if it is a generator, for when we pass
# it on to the parent's update().
other = list(other)
names = [n for n, __ in other]
for n in names:
self._validate(n)
UserDict.update(self, other)
def __setitem__(self, name, converter):
self._validate(name)
UserDict.__setitem__(self, name, converter)
def _validate(self, name):
if not _IDENTIFIER_PATTERN.match(name):
raise ValueError(
'Invalid converter name. Names may not be blank, and may '
'only use ASCII letters, digits, and underscores. Names'
'must begin with a letter or underscore.'
)
class CompiledRouterOptions(object):
"""Defines a set of configurable router options.
An instance of this class is exposed via :any:`API.router_options`
for configuring certain :py:class:`~.CompiledRouter` behaviors.
Attributes:
converters: Represents the collection of named
converters that may be referenced in URI template field
expressions. Adding additional converters is simply a
matter of mapping a name to a converter class::
api.router_options.converters['myconverter'] = MyConverter
Note:
Converter names may only contain ASCII letters, digits,
and underscores, and must start with either a letter or
an underscore.
A converter is any class that implements the following
method::
def convert(self, fragment):
# TODO: Convert the matched URI path fragment and
# return the result, or None to reject the fragment
# if it is not in the expected format or otherwise
# can not be converted.
pass
Converters are instantiated with the argument specification
given in the field expression. These specifications follow
the standard Python syntax for passing arguments. For
example, the comments in the following code show how a
converter would be instantiated given different
argument specifications in the URI template::
# MyConverter()
api.add_route(
'/a/{some_field:myconverter}',
some_resource
)
# MyConverter(True)
api.add_route(
'/b/{some_field:myconverter(True)}',
some_resource
)
# MyConverter(True, some_kwarg=10)
api.add_route(
'/c/{some_field:myconverter(True, some_kwarg=10)}',
some_resource
)
Warning:
Converter instances are shared between requests.
Therefore, in threaded deployments, care must be taken
to implement custom converters in a thread-safe
manner.
"""
__slots__ = ('converters',)
def __init__(self):
self.converters = ConverterDict(
(name, converter) for name, converter in converters.BUILTIN
)
# --------------------------------------------------------------------
# AST Constructs
#
@ -448,7 +702,7 @@ class _CxParent(object):
def __init__(self):
self._children = []
def append(self, construct):
def append_child(self, construct):
self._children.append(construct)
def src(self, indentation):
@ -503,27 +757,86 @@ class _CxIfPathSegmentPattern(_CxParent):
self._pattern_text = pattern_text
def src(self, indentation):
lines = []
lines.append(
lines = [
'{0}match = patterns[{1}].match(path[{2}]) # {3}'.format(
_TAB_STR * indentation,
self._pattern_idx,
self._segment_idx,
self._pattern_text,
)
)
lines.append('{0}if match is not None:'.format(_TAB_STR * indentation))
lines.append('{0}params.update(match.groupdict())'.format(
_TAB_STR * (indentation + 1)
))
lines.append(self._children_src(indentation + 1))
),
'{0}if match is not None:'.format(_TAB_STR * indentation),
self._children_src(indentation + 1),
]
return '\n'.join(lines)
class _CxIfConverterField(_CxParent):
def __init__(self, field_name, converter_idx):
super(_CxIfConverterField, self).__init__()
self._field_name = field_name
self._converter_idx = converter_idx
def src(self, indentation):
lines = [
'{0}field_value = converters[{1}].convert(fragment)'.format(
_TAB_STR * indentation,
self._converter_idx,
),
'{0}if field_value is not None:'.format(_TAB_STR * indentation),
"{0}params['{1}'] = field_value".format(
_TAB_STR * (indentation + 1),
self._field_name,
),
self._children_src(indentation + 1),
]
return '\n'.join(lines)
class _CxSetFragmentFromField(object):
def __init__(self, field_name):
self._field_name = field_name
def src(self, indentation):
return "{0}fragment = groups.pop('{1}')".format(
_TAB_STR * indentation,
self._field_name,
)
class _CxSetFragmentFromPath(object):
def __init__(self, segment_idx):
self._segment_idx = segment_idx
def src(self, indentation):
return '{0}fragment = path[{1}]'.format(
_TAB_STR * indentation,
self._segment_idx,
)
class _CxSetParamsFromPatternMatch(object):
def src(self, indentation):
return '{0}params.update(match.groupdict())'.format(
_TAB_STR * indentation
)
class _CxSetParamsFromPatternMatchPrefetched(object):
def src(self, indentation):
return '{0}params.update(groups)'.format(
_TAB_STR * indentation
)
class _CxPrefetchGroupsFromPatternMatch(object):
def src(self, indentation):
return '{0}groups = match.groupdict()'.format(
_TAB_STR * indentation
)
class _CxReturnNone(object):
def src(self, indentation):
return '{0}return None'.format(_TAB_STR * indentation)

View File

@ -0,0 +1,64 @@
# Copyright 2017 by Rackspace Hosting, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
class IntConverter(object):
"""Converts a field value to an int.
Keyword Args:
num_digits (int): Require the value to have the given
number of digits.
min (int): Reject the value if it is less than this value.
max (int): Reject the value if it is greater than this value.
"""
__slots__ = ('_num_digits', '_min', '_max')
def __init__(self, num_digits=None, min=None, max=None):
if num_digits is not None and num_digits < 1:
raise ValueError('num_digits must be at least 1')
self._num_digits = num_digits
self._min = min
self._max = max
def convert(self, fragment):
if self._num_digits is not None and len(fragment) != self._num_digits:
return None
# NOTE(kgriffs): int() will accept numbers with preceding or
# trailing whitespace, so we need to do our own check. Using
# strip() is faster than either a regex or a series of or'd
# membership checks via "in", esp. as the length of contiguous
# numbers in the fragment grows.
if fragment.strip() != fragment:
return None
try:
value = int(fragment)
except ValueError:
return None
if self._min is not None and value < self._min:
return None
if self._max is not None and value > self._max:
return None
return value
BUILTIN = (
('int', IntConverter),
)

View File

@ -2,7 +2,6 @@ import platform
import pytest
import falcon

View File

@ -88,6 +88,29 @@ def router():
router.add_route('/item/{q}', {}, ResourceWithId(28))
# ----------------------------------------------------------------
# Routes with field converters
# ----------------------------------------------------------------
router.add_route(
'/cvt/teams/{id:int(min=7)}', {}, ResourceWithId(29))
router.add_route(
'/cvt/teams/{id:int(min=7)}/members', {}, ResourceWithId(30))
router.add_route(
'/cvt/teams/default', {}, ResourceWithId(31))
router.add_route(
'/cvt/teams/default/members/{id:int}-{tenure:int}', {}, ResourceWithId(32))
router.add_route(
'/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}...{usr1}:{branch1:int}/part',
{}, ResourceWithId(33))
router.add_route(
'/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}',
{}, ResourceWithId(34))
router.add_route(
'/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}/full',
{}, ResourceWithId(35))
return router
@ -102,6 +125,19 @@ class ResourceWithId(object):
resp.body = self.resource_id
class SpamConverter(object):
def __init__(self, times, eggs=False):
self._times = times
self._eggs = eggs
def convert(self, fragment):
item = fragment
if self._eggs:
item += '&eggs'
return ', '.join(item for i in range(self._times))
# =====================================================================
# Regression tests for use cases reported by users
# =====================================================================
@ -233,7 +269,7 @@ def test_root_path():
assert resource.resource_id == 42
expected_src = textwrap.dedent("""
def find(path, return_values, patterns, params):
def find(path, return_values, patterns, converters, params):
path_len = len(path)
if path_len > 0:
if path[0] == '':
@ -275,11 +311,12 @@ def test_match_entire_path(uri_template, path):
@pytest.mark.parametrize('uri_template', [
'/teams/{collision}', # simple vs simple
'/teams/{conflict}', # simple vs simple
'/emojis/signs/{id_too}', # another simple vs simple
'/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}:{collision}',
'/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}:{conflict}',
'/teams/{id:int}/settings', # converted vs. non-converted
])
def test_collision(router, uri_template):
def test_conflict(router, uri_template):
with pytest.raises(ValueError):
router.add_route(uri_template, {}, ResourceWithId(-1))
@ -289,7 +326,7 @@ def test_collision(router, uri_template):
'/repos/{complex}.{vs}.{simple}',
'/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}/full',
])
def test_non_collision(router, uri_template):
def test_non_conflict(router, uri_template):
router.add_route(uri_template, {}, ResourceWithId(-1))
@ -365,23 +402,23 @@ def test_literal_segment(router):
assert route is None
@pytest.mark.parametrize('uri_template', [
@pytest.mark.parametrize('path', [
'/teams',
'/emojis/signs',
'/gists',
'/gists/42',
])
def test_dead_segment(router, uri_template):
route = router.find(uri_template)
def test_dead_segment(router, path):
route = router.find(path)
assert route is None
@pytest.mark.parametrize('uri_template', [
@pytest.mark.parametrize('path', [
'/repos/racker/falcon/compare/foo',
'/repos/racker/falcon/compare/foo/full',
])
def test_malformed_pattern(router, uri_template):
route = router.find(uri_template)
def test_malformed_pattern(router, path):
route = router.find(path)
assert route is None
@ -390,6 +427,58 @@ def test_literal(router):
assert resource.resource_id == 8
@pytest.mark.parametrize('path,expected_params', [
('/cvt/teams/007', {'id': 7}),
('/cvt/teams/1234/members', {'id': 1234}),
('/cvt/teams/default/members/700-5', {'id': 700, 'tenure': 5}),
(
'/cvt/repos/org/repo/compare/xkcd:353',
{'org': 'org', 'repo': 'repo', 'usr0': 'xkcd', 'branch0': 353},
),
(
'/cvt/repos/org/repo/compare/gunmachan:1234...kumamon:5678/part',
{
'org': 'org',
'repo': 'repo',
'usr0': 'gunmachan',
'branch0': 1234,
'usr1': 'kumamon',
'branch1': 5678,
}
),
(
'/cvt/repos/xkcd/353/compare/susan:0001/full',
{'org': 'xkcd', 'repo': '353', 'usr0': 'susan', 'branch0': 1},
)
])
def test_converters(router, path, expected_params):
__, __, params, __ = router.find(path)
assert params == expected_params
@pytest.mark.parametrize('uri_template', [
'/foo/{bar:int(0)}',
'/foo/{bar:int(num_digits=0)}',
'/foo/{bar:int(-1)}/baz',
'/foo/{bar:int(num_digits=-1)}/baz',
])
def test_converters_with_invalid_options(router, uri_template):
# NOTE(kgriffs): Sanity-check that errors are properly bubbled up
# when calling add_route(). Additional checks can be found
# in test_uri_converters.py
with pytest.raises(ValueError):
router.add_route(uri_template, {}, ResourceWithId(1))
@pytest.mark.parametrize('uri_template', [
'/foo/{bar:}',
'/foo/{bar:unknown}/baz',
])
def test_converters_malformed_specification(router, uri_template):
with pytest.raises(ValueError):
router.add_route(uri_template, {}, ResourceWithId(1))
def test_variable(router):
resource, __, params, __ = router.find('/teams/42')
assert resource.resource_id == 6
@ -413,8 +502,10 @@ def test_single_character_field_name(router):
@pytest.mark.parametrize('path,expected_id', [
('/teams/default', 19),
('/teams/default/members', 7),
('/teams/foo', 6),
('/teams/foo/members', 7),
('/cvt/teams/default', 31),
('/cvt/teams/default/members/1234-10', 32),
('/teams/1234', 6),
('/teams/1234/members', 7),
('/gists/first', 20),
('/gists/first/raw', 18),
('/gists/first/pdf', 21),
@ -446,6 +537,11 @@ def test_literal_vs_variable(router, path, expected_id):
'/teams/default/undefined',
'/teams/default/undefined/segments',
# Literal vs. variable (converters)
'/cvt/teams/default/members', # 'default' can't be converted to an int
'/cvt/teams/NaN',
'/cvt/teams/default/members/NaN',
# Literal vs variable (emojis)
'/emojis/signs',
'/emojis/signs/0/small',
@ -512,3 +608,51 @@ def test_complex_alt(router, url_postfix, resource_id, expected_template):
'branch0': 'master',
})
assert uri_template == expected_template
def test_options_converters_set(router):
router.options.converters['spam'] = SpamConverter
router.add_route('/{food:spam(3, eggs=True)}', {}, ResourceWithId(1))
resource, __, params, __ = router.find('/spam')
assert params == {'food': 'spam&eggs, spam&eggs, spam&eggs'}
@pytest.mark.parametrize('converter_name', [
'spam',
'spam_2'
])
def test_options_converters_update(router, converter_name):
router.options.converters.update({
'spam': SpamConverter,
'spam_2': SpamConverter,
})
template = '/{food:' + converter_name + '(3, eggs=True)}'
router.add_route(template, {}, ResourceWithId(1))
resource, __, params, __ = router.find('/spam')
assert params == {'food': 'spam&eggs, spam&eggs, spam&eggs'}
@pytest.mark.parametrize('name', [
'has whitespace',
'whitespace ',
' whitespace ',
' whitespace',
'funky$character',
'42istheanswer',
'with-hyphen',
])
def test_options_converters_invalid_name(router, name):
with pytest.raises(ValueError):
router.options.converters[name] = object
def test_options_converters_invalid_name_on_update(router):
with pytest.raises(ValueError):
router.options.converters.update({
'valid_name': SpamConverter,
'7eleven': SpamConverter,
})

View File

@ -0,0 +1,54 @@
import string
import pytest
from falcon.routing import converters
@pytest.mark.parametrize('segment,num_digits,min,max,expected', [
('123', None, None, None, 123),
('01', None, None, None, 1),
('001', None, None, None, 1),
('0', None, None, None, 0),
('00', None, None, None, 00),
('1', 1, None, None, 1),
('12', 1, None, None, None),
('12', 2, None, None, 12),
('1', 1, 1, 1, 1),
('1', 1, 1, None, 1),
('1', 1, 1, 2, 1),
('1', 1, 2, None, None),
('1', 1, 2, 1, None),
('2', 1, 1, 2, 2),
('2', 1, 2, 2, 2),
('3', 1, 1, 2, None),
('12', 1, None, None, None),
('12', 1, 1, 12, None),
('12', 2, None, None, 12),
('12', 2, 1, 12, 12),
('12', 2, 12, 12, 12),
('12', 2, 13, 12, None),
('12', 2, 13, 13, None),
])
def test_int_filter(segment, num_digits, min, max, expected):
c = converters.IntConverter(num_digits, min, max)
assert c.convert(segment) == expected
@pytest.mark.parametrize('segment', (
['0x0F', 'something', '', ' '] +
['123' + w for w in string.whitespace] +
[w + '123' for w in string.whitespace]
))
def test_int_filter_malformed(segment):
c = converters.IntConverter()
assert c.convert(segment) is None
@pytest.mark.parametrize('num_digits', [0, -1, -10])
def test_int_filter_invalid_config(num_digits):
with pytest.raises(ValueError):
converters.IntConverter(num_digits)

View File

@ -131,6 +131,54 @@ def test_single(client, resource, field_name):
assert resource.captured_kwargs[field_name] == '123'
@pytest.mark.parametrize('uri_template,', [
'/{id:int}',
'/{id:int(3)}',
'/{id:int(min=123)}',
'/{id:int(min=123, max=123)}',
])
def test_converter(client, uri_template):
resource1 = IDResource()
client.app.add_route(uri_template, resource1)
result = client.simulate_get('/123')
assert result.status_code == 200
assert resource1.called
assert resource1.id == 123
assert resource1.req.path == '/123'
@pytest.mark.parametrize('uri_template,', [
'/{id:int(2)}',
'/{id:int(min=124)}',
'/{id:int(num_digits=3, max=100)}',
])
def test_converter_rejections(client, uri_template):
resource1 = IDResource()
client.app.add_route(uri_template, resource1)
result = client.simulate_get('/123')
assert result.status_code == 404
assert not resource1.called
def test_converter_custom(client, resource):
class SpamConverter(object):
def convert(self, fragment):
return 'spam!'
client.app.router_options.converters['spam'] = SpamConverter
client.app.add_route('/{food:spam}', resource)
result = client.simulate_get('/something')
assert result.status_code == 200
assert resource.called
assert resource.captured_kwargs['food'] == 'spam!'
def test_single_trailing_slash(client):
resource1 = IDResource()
client.app.add_route('/1/{id}/', resource1)