Skip to content

Commit

Permalink
Merge pull request #1609 from nickvalin1/main
Browse files Browse the repository at this point in the history
Add regex pattern routing for flask apis
  • Loading branch information
RobbeSneyders committed Nov 24, 2022
2 parents 670bee9 + 5d40752 commit 86b5872
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 1 deletion.
8 changes: 7 additions & 1 deletion connexion/apis/flask_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ def flaskify_endpoint(identifier, randomize=None):
def convert_path_parameter(match, types):
name = match.group(1)
swagger_type = types.get(name)
converter = PATH_PARAMETER_CONVERTERS.get(swagger_type)
if swagger_type and swagger_type.startswith("regex"):
converter = swagger_type
else:
converter = PATH_PARAMETER_CONVERTERS.get(swagger_type)
return "<{}{}{}>".format(
converter or "", ":" if converter else "", name.replace("-", "_")
)
Expand All @@ -63,6 +66,9 @@ def flaskify_path(swagger_path, types=None):
>>> flaskify_path('/foo/{someint}', {'someint': 'int'})
'/foo/<int:someint>'
>>> flaskify_path('/foo/{somestring}', {'somestring': 'regex("[0-9a-z]{20}")'})
'/foo/<regex("[0-9a-z]{20}"):somestring>'
"""
if types is None:
types = {}
Expand Down
7 changes: 7 additions & 0 deletions connexion/apps/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def create_app(self):
app.json = FlaskJSONProvider(app)
app.url_map.converters["float"] = NumberConverter
app.url_map.converters["int"] = IntegerConverter
app.url_map.converters["regex"] = FlaskRegexConverter
return app

def _apply_middleware(self, middlewares):
Expand Down Expand Up @@ -207,3 +208,9 @@ class IntegerConverter(werkzeug.routing.BaseConverter):

def to_python(self, value):
return int(value)


class FlaskRegexConverter(werkzeug.routing.BaseConverter):
def __init__(self, url_map, *items):
super(FlaskRegexConverter, self).__init__(url_map)
self.regex = items[0]
4 changes: 4 additions & 0 deletions connexion/operations/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ def get_path_parameter_types(self):
):
# path is special case for type 'string'
path_type = "path"
elif path_schema.get("type") == "string" and path_schema.get("pattern"):
# regex patterns are also a special case for 'string'
pattern = path_schema.get("pattern")
path_type = f'regex("{pattern}")'
else:
path_type = path_schema.get("type")
types[path_defn["name"]] = path_type
Expand Down
4 changes: 4 additions & 0 deletions connexion/operations/swagger2.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ def get_path_parameter_types(self):
if path_defn.get("type") == "string" and path_defn.get("format") == "path":
# path is special case for type 'string'
path_type = "path"
elif path_defn.get("type") == "string" and path_defn.get("pattern"):
# regex patterns are also a special case for 'string'
pattern = path_defn.get("pattern")
path_type = f'regex("{pattern}")'
else:
path_type = path_defn.get("type")
types[path_defn["name"]] = path_type
Expand Down
52 changes: 52 additions & 0 deletions docs/routing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,58 @@ Specify a route parameter's type as ``integer`` or ``number`` or its type as
will create an equivalent Flask route ``/greeting/<path:name>``, allowing
requests to include forward slashes in the ``name`` url variable.

Regex Routing with Path Parameter Patterns
------------------------------------------

In addition to validating string parameters with the ``pattern`` property, Connexion can also use it to
route otherwise identical requests paths, for example:

.. code-block:: yaml
paths:
/greeting/{identifier}:
# ...
parameters:
- name: identifier
in: path
required: true
schema:
type: string
pattern: '[0-9a-z]{20}'
/greeting/{short_name}:
# ...
parameters:
- name: short_name
in: path
required: true
schema:
type: string
pattern: '\w*{1,10}'
/greeting/{long_name}:
# ...
parameters:
- name: long_name
in: path
required: true
schema:
type: string
``/greeting/123abc456def789ghijk`` will route to the first endpoint.

``/greeting/Trillian`` will route to the second endpoint.

``/greeting/Tricia McMillan`` will route the the third endpoint because it has no pattern defined,
and therefore acts as a catch-all for requests that don't match any defined patterns for the same path.

NOTE: Regex values for the same path must be mutually exclusive. If not, and the regex overlaps,
the routing behavior will be undefined.

NOTE: Pattern routing in connexion v3 will slightly change the behavior of existing endpoints from connexion v2.
In connexion v2, a request that provides a parameter that does not match
the defined regex pattern will return a 400 error with a message about the pattern not matching.
In connexion v3, the same request will return a 404 error.


API Versioning and basePath
---------------------------

Expand Down
18 changes: 18 additions & 0 deletions tests/api/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,21 @@ def test_cookie_param(simple_app):
response = app_client.get("/v1.0/test-cookie-param")
assert response.status_code == 200
assert response.json == {"cookie_value": "hello"}


def test_unmatched_path_param_pattern(simple_app):
app_client = simple_app.app.test_client()
response = app_client.get("/v1.0/unmatched_pattern_route/Zaphod")
assert response.status_code == 404


def test_matched_path_param_pattern(simple_app):
app_client = simple_app.app.test_client()

response = app_client.get("/v1.0/matched_pattern_route/Arthur")
assert response.status_code == 200
assert response.json == {"shard": 1}

response = app_client.get("/v1.0/matched_pattern_route/Zaphod")
assert response.status_code == 200
assert response.json == {"shard": 2}
12 changes: 12 additions & 0 deletions tests/fakeapi/hello/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,15 @@ def get_streaming_response():

async def async_route():
return {}, 200


def unmatched_pattern_route(name):
return name


def matched_pattern_route_shard_1(shard_1):
return {"shard": 1}


def matched_pattern_route_shard_2(shard_2):
return {"shard": 2}
45 changes: 45 additions & 0 deletions tests/fixtures/simple/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,51 @@ paths:
responses:
200:
description: 'OK'
/unmatched_pattern_route/{name}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.unmatched_pattern_route
parameters:
- name: name
in: path
description: Name of the person to greet.
required: true
schema:
type: string
pattern: '[A-M]\w*'
responses:
'200':
description: OK
/matched_pattern_route/{shard_1}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.matched_pattern_route_shard_1
parameters:
- name: shard_1
in: path
description: Shard assigned to the person.
required: true
schema:
type: string
pattern: '[A-M]\w*'
responses:
'200':
description: OK
/matched_pattern_route/{shard_2}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.matched_pattern_route_shard_2
parameters:
- name: shard_2
in: path
description: Shard assigned to the person.
required: true
schema:
type: string
pattern: '[N-Z]\w*'
responses:
'200':
description: OK


servers:
Expand Down
43 changes: 43 additions & 0 deletions tests/fixtures/simple/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,49 @@ paths:
200:
description: 'OK'

/unmatched_pattern_route/{name}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.unmatched_pattern_route
parameters:
- name: name
in: path
description: Name of the person to greet.
required: true
type: string
pattern: '[A-M]\w*'
responses:
'200':
description: OK
/matched_pattern_route/{shard_1}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.matched_pattern_route_shard_1
parameters:
- name: shard_1
in: path
description: Shard assigned to the person.
required: true
type: string
pattern: '[A-M]\w*'
responses:
'200':
description: OK
/matched_pattern_route/{shard_2}:
get:
description: Getting a route with a pattern in the path that does not match
operationId: fakeapi.hello.matched_pattern_route_shard_2
parameters:
- name: shard_2
in: path
description: Shard assigned to the person.
required: true
type: string
pattern: '[N-Z]\w*'
responses:
'200':
description: OK

definitions:
new_stack:
type: object
Expand Down
4 changes: 4 additions & 0 deletions tests/test_flask_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def test_flaskify_path():
)
assert flask_utils.flaskify_path("foo/{a}/{b}", {"a": "path"}) == "foo/<path:a>/<b>"
assert flask_utils.flaskify_path("foo/{a}", {"a": "path"}) == "foo/<path:a>"
assert (
flask_utils.flaskify_path("/foo/{a}", {"a": 'regex("[0-9a-z]{20}")'})
== '/foo/<regex("[0-9a-z]{20}"):a>'
)


def test_flaskify_endpoint():
Expand Down

0 comments on commit 86b5872

Please sign in to comment.