import textwrap import pytest import falcon from falcon import testing from falcon.routing import DefaultRouter @pytest.fixture def client(): return testing.TestClient(falcon.API()) @pytest.fixture def router(): router = DefaultRouter() router.add_route( '/repos', {}, ResourceWithId(1)) router.add_route( '/repos/{org}', {}, ResourceWithId(2)) router.add_route( '/repos/{org}/{repo}', {}, ResourceWithId(3)) router.add_route( '/repos/{org}/{repo}/commits', {}, ResourceWithId(4)) router.add_route( u'/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}', {}, ResourceWithId(5)) router.add_route( '/teams/{id}', {}, ResourceWithId(6)) router.add_route( '/teams/{id}/members', {}, ResourceWithId(7)) router.add_route( '/teams/default', {}, ResourceWithId(19)) router.add_route( '/teams/default/members/thing', {}, ResourceWithId(19)) router.add_route( '/user/memberships', {}, ResourceWithId(8)) router.add_route( '/emojis', {}, ResourceWithId(9)) router.add_route( '/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}/full', {}, ResourceWithId(10)) router.add_route( '/repos/{org}/{repo}/compare/all', {}, ResourceWithId(11)) # NOTE(kgriffs): The ordering of these calls is significant; we # need to test that the {id} field does not match the other routes, # regardless of the order they are added. router.add_route( '/emojis/signs/0', {}, ResourceWithId(12)) router.add_route( '/emojis/signs/{id}', {}, ResourceWithId(13)) router.add_route( '/emojis/signs/42', {}, ResourceWithId(14)) router.add_route( '/emojis/signs/42/small.jpg', {}, ResourceWithId(23)) router.add_route( '/emojis/signs/78/small.png', {}, ResourceWithId(24)) # Test some more special chars router.add_route( '/emojis/signs/78/small(png)', {}, ResourceWithId(25)) router.add_route( '/emojis/signs/78/small_png', {}, ResourceWithId(26)) router.add_route('/images/{id}.gif', {}, ResourceWithId(27)) router.add_route( '/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}/part', {}, ResourceWithId(15)) router.add_route( '/repos/{org}/{repo}/compare/{usr0}:{branch0}', {}, ResourceWithId(16)) router.add_route( '/repos/{org}/{repo}/compare/{usr0}:{branch0}/full', {}, ResourceWithId(17)) router.add_route( '/gists/{id}/{representation}', {}, ResourceWithId(21)) router.add_route( '/gists/{id}/raw', {}, ResourceWithId(18)) router.add_route( '/gists/first', {}, ResourceWithId(20)) 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 class ResourceWithId(object): def __init__(self, resource_id): self.resource_id = resource_id def __repr__(self): return 'ResourceWithId({0})'.format(self.resource_id) def on_get(self, req, resp): 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 # ===================================================================== def test_user_regression_versioned_url(): router = DefaultRouter() router.add_route('/{version}/messages', {}, ResourceWithId(2)) resource, __, __, __ = router.find('/v2/messages') assert resource.resource_id == 2 router.add_route('/v2', {}, ResourceWithId(1)) resource, __, __, __ = router.find('/v2') assert resource.resource_id == 1 resource, __, __, __ = router.find('/v2/messages') assert resource.resource_id == 2 resource, __, __, __ = router.find('/v1/messages') assert resource.resource_id == 2 route = router.find('/v1') assert route is None def test_user_regression_recipes(): router = DefaultRouter() router.add_route( '/recipes/{activity}/{type_id}', {}, ResourceWithId(1) ) router.add_route( '/recipes/baking', {}, ResourceWithId(2) ) resource, __, __, __ = router.find('/recipes/baking/4242') assert resource.resource_id == 1 resource, __, __, __ = router.find('/recipes/baking') assert resource.resource_id == 2 route = router.find('/recipes/grilling') assert route is None @pytest.mark.parametrize('uri_template,path,expected_params', [ ('/serviceRoot/People|{field}', '/serviceRoot/People|susie', {'field': 'susie'}), ('/serviceRoot/People[{field}]', "/serviceRoot/People['calvin']", {'field': "'calvin'"}), ('/serviceRoot/People({field})', "/serviceRoot/People('hobbes')", {'field': "'hobbes'"}), ('/serviceRoot/People({field})', "/serviceRoot/People('hob)bes')", {'field': "'hob)bes'"}), ('/serviceRoot/People({field})(z)', '/serviceRoot/People(hobbes)(z)', {'field': 'hobbes'}), ("/serviceRoot/People('{field}')", "/serviceRoot/People('rosalyn')", {'field': 'rosalyn'}), ('/^{field}', '/^42', {'field': '42'}), ('/+{field}', '/+42', {'field': '42'}), ( '/foo/{first}_{second}/bar', '/foo/abc_def_ghijk/bar', # NOTE(kgriffs): The regex pattern is greedy, so this is # expected. We can not change this behavior in a minor # release, since it would be a breaking change. If there # is enough demand for it, we could introduce an option # to toggle this behavior. {'first': 'abc_def', 'second': 'ghijk'}, ), # NOTE(kgriffs): Why someone would use a question mark like this # I have no idea (esp. since it would have to be encoded to avoid # being mistaken for the query string separator). Including it only # for completeness. ('/items/{x}?{y}', '/items/1080?768', {'x': '1080', 'y': '768'}), ('/items/{x}|{y}', '/items/1080|768', {'x': '1080', 'y': '768'}), ('/items/{x},{y}', '/items/1080,768', {'x': '1080', 'y': '768'}), ('/items/{x}^^{y}', '/items/1080^^768', {'x': '1080', 'y': '768'}), ('/items/{x}*{y}*', '/items/1080*768*', {'x': '1080', 'y': '768'}), ('/thing-2/something+{field}+', '/thing-2/something+42+', {'field': '42'}), ('/thing-2/something*{field}/notes', '/thing-2/something*42/notes', {'field': '42'}), ( '/thing-2/something+{field}|{q}/notes', '/thing-2/something+else|z/notes', {'field': 'else', 'q': 'z'}, ), ( "serviceRoot/$metadata#Airports('{field}')/Name", "serviceRoot/$metadata#Airports('KSFO')/Name", {'field': 'KSFO'}, ), ]) def test_user_regression_special_chars(uri_template, path, expected_params): router = DefaultRouter() router.add_route(uri_template, {}, ResourceWithId(1)) route = router.find(path) assert route is not None resource, __, params, __ = route assert resource.resource_id == 1 assert params == expected_params # ===================================================================== # Other tests # ===================================================================== @pytest.mark.parametrize('uri_template', [ {}, set(), object() ]) def test_not_str(uri_template): app = falcon.API() with pytest.raises(TypeError): app.add_route(uri_template, ResourceWithId(-1)) def test_root_path(): router = DefaultRouter() router.add_route('/', {}, ResourceWithId(42)) resource, __, __, __ = router.find('/') assert resource.resource_id == 42 expected_src = textwrap.dedent(""" def find(path, return_values, patterns, converters, params): path_len = len(path) if path_len > 0: if path[0] == '': if path_len == 1: return return_values[0] return None return None return None """).strip() assert router.finder_src == expected_src @pytest.mark.parametrize('uri_template', [ '/{field}{field}', '/{field}...{field}', '/{field}/{another}/{field}', '/{field}/something/something/{field}/something', ]) def test_duplicate_field_names(uri_template): router = DefaultRouter() with pytest.raises(ValueError): router.add_route(uri_template, {}, ResourceWithId(1)) @pytest.mark.parametrize('uri_template,path', [ ('/items/thing', '/items/t'), ('/items/{x}|{y}|', '/items/1080|768'), ('/items/{x}*{y}foo', '/items/1080*768foobar'), ('/items/{x}*768*', '/items/1080*768***'), ]) def test_match_entire_path(uri_template, path): router = DefaultRouter() router.add_route(uri_template, {}, ResourceWithId(1)) route = router.find(path) assert route is None @pytest.mark.parametrize('uri_template', [ '/teams/{conflict}', # simple vs simple '/emojis/signs/{id_too}', # another simple vs simple '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}:{conflict}', '/teams/{id:int}/settings', # converted vs. non-converted ]) def test_conflict(router, uri_template): with pytest.raises(ValueError): router.add_route(uri_template, {}, ResourceWithId(-1)) @pytest.mark.parametrize('uri_template', [ '/repos/{org}/{repo}/compare/{simple_vs_complex}', '/repos/{complex}.{vs}.{simple}', '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}/full', ]) def test_non_conflict(router, uri_template): router.add_route(uri_template, {}, ResourceWithId(-1)) @pytest.mark.parametrize('uri_template', [ # Missing field name '/{}', '/repos/{org}/{repo}/compare/{}', '/repos/{complex}.{}.{thing}', # Field names must be valid Python identifiers '/{9v}', '/{524hello}/world', '/hello/{1world}', '/repos/{complex}.{9v}.{thing}/etc', '/{*kgriffs}', '/{@kgriffs}', '/repos/{complex}.{v}.{@thing}/etc', '/{-kgriffs}', '/repos/{complex}.{-v}.{thing}/etc', '/repos/{simple-thing}/etc', # Neither fields nor literal segments may not contain whitespace '/this and that', '/this\tand\tthat' '/this\nand\nthat' '/{thing }/world', '/{thing\t}/world', '/{\nthing}/world', '/{th\ving}/world', '/{ thing}/world', '/{ thing }/world', '/{thing}/wo rld', '/{thing} /world', '/repos/{or g}/{repo}/compare/{thing}', '/repos/{org}/{repo}/compare/{th\ting}', ]) def test_invalid_field_name(router, uri_template): with pytest.raises(ValueError): router.add_route(uri_template, {}, ResourceWithId(-1)) def test_print_src(router): """Diagnostic test that simply prints the router's find() source code. Example: $ tox -e py27_debug -- -k test_print_src -s """ print('\n\n' + router.finder_src + '\n') def test_override(router): router.add_route('/emojis/signs/0', {}, ResourceWithId(-1)) resource, __, __, __ = router.find('/emojis/signs/0') assert resource.resource_id == -1 def test_literal_segment(router): resource, __, __, __ = router.find('/emojis/signs/0') assert resource.resource_id == 12 resource, __, __, __ = router.find('/emojis/signs/1') assert resource.resource_id == 13 resource, __, __, __ = router.find('/emojis/signs/42') assert resource.resource_id == 14 resource, __, __, __ = router.find('/emojis/signs/42/small.jpg') assert resource.resource_id == 23 route = router.find('/emojis/signs/1/small') assert route is None @pytest.mark.parametrize('path', [ '/teams', '/emojis/signs', '/gists', '/gists/42', ]) def test_dead_segment(router, path): route = router.find(path) assert route is None @pytest.mark.parametrize('path', [ '/repos/racker/falcon/compare/foo', '/repos/racker/falcon/compare/foo/full', ]) def test_malformed_pattern(router, path): route = router.find(path) assert route is None def test_literal(router): resource, __, __, __ = router.find('/user/memberships') 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 assert params == {'id': '42'} __, __, params, __ = router.find('/emojis/signs/stop') assert params == {'id': 'stop'} __, __, params, __ = router.find('/gists/42/raw') assert params == {'id': '42'} __, __, params, __ = router.find('/images/42.gif') assert params == {'id': '42'} def test_single_character_field_name(router): __, __, params, __ = router.find('/item/1234') assert params == {'q': '1234'} @pytest.mark.parametrize('path,expected_id', [ ('/teams/default', 19), ('/teams/default/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), ('/gists/1776/pdf', 21), ('/emojis/signs/78', 13), ('/emojis/signs/78/small.png', 24), ('/emojis/signs/78/small(png)', 25), ('/emojis/signs/78/small_png', 26), ]) def test_literal_vs_variable(router, path, expected_id): resource, __, __, __ = router.find(path) assert resource.resource_id == expected_id @pytest.mark.parametrize('path', [ # Misc. '/this/does/not/exist', '/user/bogus', '/repos/racker/falcon/compare/johndoe:master...janedoe:dev/bogus', # Literal vs variable (teams) '/teams', '/teams/42/members/undefined', '/teams/42/undefined', '/teams/42/undefined/segments', '/teams/default/members/undefined', '/teams/default/members/thing/undefined', '/teams/default/members/thing/undefined/segments', '/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', '/emojis/signs/0/undefined', '/emojis/signs/0/undefined/segments', '/emojis/signs/20/small', '/emojis/signs/20/undefined', '/emojis/signs/42/undefined', '/emojis/signs/78/undefined', ]) def test_not_found(router, path): route = router.find(path) assert route is None def test_subsegment_not_found(router): route = router.find('/emojis/signs/0/x') assert route is None def test_multivar(router): resource, __, params, __ = router.find('/repos/racker/falcon/commits') assert resource.resource_id == 4 assert params == {'org': 'racker', 'repo': 'falcon'} resource, __, params, __ = router.find('/repos/racker/falcon/compare/all') assert resource.resource_id == 11 assert params == {'org': 'racker', 'repo': 'falcon'} @pytest.mark.parametrize('url_postfix,resource_id', [ ('', 5), ('/full', 10), ('/part', 15), ]) def test_complex(router, url_postfix, resource_id): uri = '/repos/racker/falcon/compare/johndoe:master...janedoe:dev' resource, __, params, __ = router.find(uri + url_postfix) assert resource.resource_id == resource_id assert (params == { 'org': 'racker', 'repo': 'falcon', 'usr0': 'johndoe', 'branch0': 'master', 'usr1': 'janedoe', 'branch1': 'dev', }) @pytest.mark.parametrize('url_postfix,resource_id,expected_template', [ ('', 16, '/repos/{org}/{repo}/compare/{usr0}:{branch0}'), ('/full', 17, '/repos/{org}/{repo}/compare/{usr0}:{branch0}/full') ]) def test_complex_alt(router, url_postfix, resource_id, expected_template): uri = '/repos/falconry/falcon/compare/johndoe:master' + url_postfix resource, __, params, uri_template = router.find(uri) assert resource.resource_id == resource_id assert (params == { 'org': 'falconry', 'repo': 'falcon', 'usr0': 'johndoe', '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, })