Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Multi-Responders with the same method type #30

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
20 changes: 20 additions & 0 deletions .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[bumpversion]
current_version = 0.5.0alpha0
commit = True
tag = False
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?P<release>[a-z]+)?(?P<inc>\d+)?
serialize =
{major}.{minor}.{patch}{release}{inc}
{major}.{minor}.{patch}

[bumpversion:part:release]
optional_value = production
first_value = alpha
values =
alpha
rc
production

[bumpversion:file:falcon_apispec/version.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
22 changes: 22 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[paths]
source =
falcon_apispec
*/site-packages

[run]
branch = true
source =
falcon_apispec
tests
parallel = true

[report]
show_missing = true
precision = 2
omit = *migrations*
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain if tests don't hit defensive assertion code:
raise NotImplementedError
pass
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,46 @@ spec.to_yaml()
# tags: []
```



### Falcon Route Suffix Support

Since Falcon 2.0, a single resource may contain several responders of the same HTTP type (e.g. 2 GETs) if a suffix is added at route creation.

falcon-apispec >= 0.5 supports this through multiple APISpec path registration:

```python
class SuffixedHelloResource:
def on_get_hello(self):
"""A greeting endpoint.
---
description: get a greeting
responses:
200:
description: said hi
"""
return "dummy_hello"

def on_get(self):
"""Base method.
---
description: get something
responses:
200:
description: said ???
"""
return "dummy"


suffixed_resource = SuffixedHelloResource()
app.add_route("/say", suffixed_resource)
app.add_route("/say/hi", suffixed_resource, suffix="hello")

spec = spec_factory(app)
spec.path(resource=suffixed_resource) # registers on_get
spec.path(resource=suffixed_resource, suffix="hello") # registers on_get_hello
```

## Contributing

### Setting Up for Local Development
Expand Down
44 changes: 26 additions & 18 deletions falcon_apispec/falcon_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,45 +12,53 @@ def __init__(self, app):
self._app = app

@staticmethod
def _generate_resource_uri_mapping(app):
def _generate_resource_uri_mapping(app, resource, suffix):
routes_to_check = copy.copy(app._router._roots)

mapping = {}
for route in routes_to_check:
uri = route.uri_template
resource = route.resource
mapping[resource] = {
"uri": uri,
"methods": {}
}

if route.method_map:
for method_name, method_handler in route.method_map.items():
if method_handler.__dict__.get("__module__") == "falcon.responders":
continue
mapping[resource]["methods"][method_name.lower()] = method_handler
# Filter by resource so we don't have to parse all routes
if route.resource == resource:
mapping[uri] = {}
if route.method_map:
methods = {}
for method_name, method_handler in route.method_map.items():
# Multiple conditions to ignore the method : falcon responder, or a method that does not
# meet the suffix requirements: no suffix provided = ignore suffixed methods, and vice-versa
if method_handler.__dict__.get("__module__") == "falcon.responders" or \
(suffix is not None and not method_handler.__name__.lower().endswith(suffix)) or \
(suffix is None and not method_handler.__name__.lower().endswith(method_name.lower())):
continue
methods.update({method_name.lower(): method_handler})
mapping[uri] = methods

routes_to_check.extend(route.children)
return mapping

def path_helper(self, operations, resource, base_path=None, **kwargs):
def path_helper(self, operations, resource, base_path=None, suffix=None, **kwargs):
"""Path helper that allows passing a Falcon resource instance."""
resource_uri_mapping = self._generate_resource_uri_mapping(self._app)
resource_uri_mapping = self._generate_resource_uri_mapping(self._app, resource, suffix)

if resource not in resource_uri_mapping:
if not resource_uri_mapping:
raise APISpecError("Could not find endpoint for resource {0}".format(resource))

operations.update(yaml_utils.load_operations_from_docstring(resource.__doc__) or {})
path = resource_uri_mapping[resource]["uri"]

# In case multiple uri were found, keep the only one that has methods
try:
path = next(uri for uri, methods in resource_uri_mapping.items() if methods)
except StopIteration:
path = next(iter(resource_uri_mapping))

methods = resource_uri_mapping[path]

if base_path is not None:
# make sure base_path accept either with or without leading slash
# swagger 2 usually come with leading slash but not in openapi 3.x.x
base_path = '/' + base_path.strip('/')
path = re.sub(base_path, "", path, 1)

methods = resource_uri_mapping[resource]["methods"]

for method_name, method_handler in methods.items():
docstring_yaml = yaml_utils.load_yaml_from_docstring(method_handler.__doc__)
operations[method_name] = docstring_yaml or dict()
Expand Down
2 changes: 1 addition & 1 deletion falcon_apispec/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.5.0alpha0"
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ def find_version(*file_paths):
"apispec>=1.0",
"falcon",
],
extras_require={
'dev': ['bump2version', 'tox', 'pytest', 'pytest-cov'],
},
packages=find_packages(exclude=["tests", ]),
test_suite='tests',

Expand Down
115 changes: 90 additions & 25 deletions tests/falcon_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,37 @@
import pytest
from apispec import APISpec
from apispec.exceptions import APISpecError
from unittest.mock import MagicMock

from falcon_apispec import FalconPlugin


@pytest.fixture
def suffixed_resource():
class SuffixedResource:
def on_get_hello(self):
"""A greeting endpoint.
---
description: get a greeting
responses:
200:
description: said hi
"""
return "dummy_hello"

def on_get(self):
"""An invalid method.
---
description: get something
responses:
200:
description: said ???
"""
return "dummy"

return SuffixedResource()


@pytest.fixture()
def spec_factory():
def _spec(app):
Expand Down Expand Up @@ -91,6 +118,22 @@ class HelloResource:

assert spec._paths["/hi"]["x-extension"] == "global metadata"

def test_resource_no_methods(self, app, spec_factory):
class HelloResource:
"""Greeting API.
---
x-extension: global metadata
"""

hello_resource = HelloResource()
magic_route = MagicMock(uri_template="/hi", resource=hello_resource, method_map=[])
app._router._roots.append(magic_route)

spec = spec_factory(app)
spec.path(resource=hello_resource)

assert spec._paths["/hi"]["x-extension"] == "global metadata"

def test_unredundant_basepath_resource_with_slash(self, app, spec_factory):
class HelloResource:
def on_get(self, req, resp):
Expand Down Expand Up @@ -139,41 +182,63 @@ def on_get(self, req, resp):

assert spec._paths["/foo/v1"]["get"] == expected

def test_path_with_suffix(self, app, spec_factory):
class HelloResource:
def on_get_hello(self):
"""A greeting endpoint.
---
description: get a greeting
responses:
200:
description: said hi
"""
return "dummy"

def on_get(self):
"""An invalid method.
---
description: this should not pass
responses:
200:
description: said hi
"""
return "invalid"

def test_path_with_suffix(self, app, spec_factory, suffixed_resource):
expected = {
"description": "get a greeting",
"responses": {"200": {"description": "said hi"}},
}

hello_resource_with_suffix = HelloResource()
app.add_route("/hi", hello_resource_with_suffix, suffix="hello")
app.add_route("/hi", suffixed_resource, suffix="hello")

spec = spec_factory(app)
spec.path(resource=hello_resource_with_suffix)
spec.path(resource=suffixed_resource, suffix="hello")

assert spec._paths["/hi"]["get"] == expected

def test_path_ignore_suffix(self, app, spec_factory, suffixed_resource):
expected = {
"description": "get something",
"responses": {"200": {"description": "said ???"}},
}

app.add_route("/say", suffixed_resource)

spec = spec_factory(app)
spec.path(resource=suffixed_resource)

assert spec._paths["/say"]["get"] == expected

def test_path_suffix_all(self, app, spec_factory, suffixed_resource):

app.add_route("/say", suffixed_resource)
app.add_route("/say/hi", suffixed_resource, suffix="hello")

spec = spec_factory(app)
spec.path(resource=suffixed_resource)
spec.path(resource=suffixed_resource, suffix="hello")

assert spec._paths["/say"]["get"]["description"] == "get something"
assert spec._paths["/say/hi"]["get"]["description"] == "get a greeting"

def test_path_multiple_routes_same_resource(self, app, spec_factory):
class HelloResource:
"""Greeting API.
---
x-extension: global metadata
"""

hello_resource = HelloResource()
app.add_route("/hi", hello_resource)
app.add_route("/greet", hello_resource)

spec = spec_factory(app)
spec.path(resource=hello_resource)

assert spec._paths["/hi"]["x-extension"] == "global metadata"
with pytest.raises(KeyError):
# Limitation: one route will not be documented!!!
assert spec._paths["/greet"]["x-extension"] == "global metadata"

def test_resource_without_endpoint(self, app, spec_factory):
class HelloResource:
def on_get(self, req, resp):
Expand Down
31 changes: 30 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
[tox]
envlist =
check,
{py35, py37}

[testenv]
basepython =
py35: {env:TOXPYTHON:python3.5}
py37: {env:TOXPYTHON:python3.7}
{docs,clean,check,report}: {env:TOXPYTHON:python3}
setenv =
PYTHONPATH={toxinidir}/tests
PYTHONUNBUFFERED=yes
passenv =
*
usedevelop = false
deps =
pytest
pytest-cov
commands =
pytest --cov --cov-report=term-missing -vv tests {posargs}

[testenv:check]
deps =
flake8
skip_install = true
commands =
flake8 falcon_apispec tests setup.py

[flake8]
max_line_length = 99
max-line-length = 120