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

Allow for spec_url file in root without prefix #299

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions spectree/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .models import SecurityScheme, Server
from .page import DEFAULT_PAGE_TEMPLATES
from .utils import join_path

# Fall back to a str field if email-validator is not installed.
if TYPE_CHECKING:
Expand Down Expand Up @@ -65,7 +66,7 @@ class Configuration(BaseSettings):
license: Optional[License] = None

# SpecTree configurations
#: OpenAPI doc route path prefix (i.e. /apidoc/)
#: OpenAPI doc route path prefix (i.e. /apidoc/) or empty string for no path prefix.
path: str = "apidoc"
#: OpenAPI file route path suffix (i.e. /apidoc/openapi.json)
filename: str = "openapi.json"
Expand Down Expand Up @@ -117,7 +118,18 @@ def convert_to_lower_case(cls, values: Mapping[str, Any]) -> Dict[str, Any]:

@property
def spec_url(self) -> str:
return f"/{self.path}/{self.filename}"
return self.join_doc_path(self.filename)

@property
def doc_root(self) -> str:
return self.join_doc_path("")

def join_doc_path(self, path: str) -> str:
"""
Return the documentation path constructed using the configured
self.path documentation prefix and the given path string.
"""
return f"/{join_path((self.path, path))}"

def swagger_oauth2_config(self) -> Dict[str, str]:
"""
Expand Down
4 changes: 2 additions & 2 deletions spectree/plugins/falcon_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ def register_route(self, app: Any):
)
for ui in self.config.page_templates:
self.app.add_route(
f"/{self.config.path}/{ui}",
self.config.join_doc_path(ui),
self.DOC_PAGE_ROUTE_CLASS(
self.config.page_templates[ui],
spec_url=self.config.spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
),
)
Expand Down
11 changes: 5 additions & 6 deletions spectree/plugins/flask_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ class FlaskPlugin(BasePlugin):
def find_routes(self):
for rule in current_app.url_map.iter_rules():
if any(
str(rule).startswith(path)
for path in (f"/{self.config.path}", "/static")
str(rule).startswith(path) for path in (self.config.doc_root, "/static")
):
continue
if rule.endpoint.startswith("openapi"):
Expand Down Expand Up @@ -252,13 +251,13 @@ def gen_doc_page(ui):

return self.config.page_templates[ui].format(
spec_url=spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
)

for ui in self.config.page_templates:
app.add_url_rule(
rule=f"/{self.config.path}/{ui}/",
rule=f"{self.config.join_doc_path(ui)}/",
endpoint=f"openapi_{self.config.path}_{ui.replace('.', '_')}",
view_func=lambda ui=ui: gen_doc_page(ui),
)
Expand All @@ -267,11 +266,11 @@ def gen_doc_page(ui):
else:
for ui in self.config.page_templates:
app.add_url_rule(
rule=f"/{self.config.path}/{ui}/",
rule=f"{self.config.join_doc_path(ui)}/",
endpoint=f"openapi_{self.config.path}_{ui}",
view_func=lambda ui=ui: self.config.page_templates[ui].format(
spec_url=self.config.spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
),
)
8 changes: 4 additions & 4 deletions spectree/plugins/quart_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,13 @@ def gen_doc_page(ui):

return self.config.page_templates[ui].format(
spec_url=spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
)

for ui in self.config.page_templates:
app.add_url_rule(
f"/{self.config.path}/{ui}/",
f"{self.config.join_doc_path(ui)}/",
endpoint=f"openapi_{self.config.path}_{ui.replace('.', '_')}",
view_func=lambda ui=ui: gen_doc_page(ui),
)
Expand All @@ -279,11 +279,11 @@ def gen_doc_page(ui):
else:
for ui in self.config.page_templates:
app.add_url_rule(
f"/{self.config.path}/{ui}/",
f"{self.config.join_doc_path(ui)}/",
endpoint=f"openapi_{self.config.path}_{ui}",
view_func=lambda ui=ui: self.config.page_templates[ui].format(
spec_url=self.config.spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
),
)
4 changes: 2 additions & 2 deletions spectree/plugins/starlette_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ def register_route(self, app):

for ui in self.config.page_templates:
self.app.add_route(
f"/{self.config.path}/{ui}",
self.config.join_doc_path(ui),
lambda request, ui=ui: HTMLResponse(
self.config.page_templates[ui].format(
spec_url=self.config.spec_url,
spec_path=self.config.path,
spec_path=self.config.doc_root,
**self.config.swagger_oauth2_config(),
)
),
Expand Down
14 changes: 14 additions & 0 deletions spectree/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Mapping,
Expand Down Expand Up @@ -349,3 +350,16 @@ def parse_resp(func: Any, naming_strategy: NamingStrategy = get_model_key):
responses = func.resp.generate_spec(naming_strategy)

return responses


def join_path(paths: Iterable[str]) -> str:
"""
Join path parts together.

Normalizes any leading or trailing slashes on the given path parts and avoids
behavior of urllib.parse.urljoin and posixpath.join where a path part with
leading slash overrides any path parts before it due to being treated as an
absolute path.
"""
normalized_paths = (x.strip("/") for x in paths if x)
return "/".join(x for x in normalized_paths if x)
7 changes: 6 additions & 1 deletion tests/flask_imports/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from .dry_plugin_flask import (
import pytest

# Enable pytest assertion rewriting for dry module. Must come before module is imported.
pytest.register_assert_rewrite("tests.flask_imports.dry_plugin_flask")

from .dry_plugin_flask import ( # noqa: E402
test_flask_doc,
test_flask_list_json_request,
test_flask_no_response,
Expand Down
20 changes: 14 additions & 6 deletions tests/flask_imports/dry_plugin_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,28 +73,36 @@ def test_flask_validation_error_response_status_code(


@pytest.mark.parametrize(
"test_client_and_api, expected_doc_pages",
"test_client_and_api, doc_prefix, expected_doc_pages",
[
pytest.param({}, ["redoc", "swagger"], id="default-page-templates"),
pytest.param({}, "/apidoc", ["redoc", "swagger"], id="default-page-templates"),
pytest.param(
{"api_kwargs": {"path": ""}},
"",
["redoc", "swagger"],
id="default-page-templates-in-root-path",
),
pytest.param(
{"api_kwargs": {"page_templates": {"custom_page": "{spec_url}"}}},
"/apidoc",
["custom_page"],
id="custom-page-templates",
),
],
indirect=["test_client_and_api"],
)
def test_flask_doc(test_client_and_api, expected_doc_pages):
def test_flask_doc(test_client_and_api, doc_prefix, expected_doc_pages):
client, api = test_client_and_api

resp = client.get("/apidoc/openapi.json")
resp = client.get(f"{doc_prefix}/openapi.json")
assert resp.status_code == 200
assert resp.json == api.spec

for doc_page in expected_doc_pages:
resp = client.get(f"/apidoc/{doc_page}/")
resp = client.get(f"{doc_prefix}/{doc_page}/")
assert resp.status_code == 200

resp = client.get(f"/apidoc/{doc_page}")
resp = client.get(f"{doc_prefix}/{doc_page}")
assert resp.status_code == 308


Expand Down
6 changes: 5 additions & 1 deletion tests/quart_imports/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from .dry_plugin_quart import (
import pytest

pytest.register_assert_rewrite("tests.quart_imports.dry_plugin_quart")

from .dry_plugin_quart import ( # noqa: E402
test_quart_doc,
test_quart_no_response,
test_quart_return_model,
Expand Down
20 changes: 14 additions & 6 deletions tests/quart_imports/dry_plugin_quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,36 @@ def test_quart_validation_error_response_status_code(


@pytest.mark.parametrize(
"test_client_and_api, expected_doc_pages",
"test_client_and_api, doc_prefix, expected_doc_pages",
[
pytest.param({}, ["redoc", "swagger"], id="default-page-templates"),
pytest.param({}, "/apidoc", ["redoc", "swagger"], id="default-page-templates"),
pytest.param(
{"api_kwargs": {"path": ""}},
"",
["redoc", "swagger"],
id="default-page-templates-in-root-path",
),
pytest.param(
{"api_kwargs": {"page_templates": {"custom_page": "{spec_url}"}}},
"/apidoc",
["custom_page"],
id="custom-page-templates",
),
],
indirect=["test_client_and_api"],
)
def test_quart_doc(test_client_and_api, expected_doc_pages):
def test_quart_doc(test_client_and_api, doc_prefix, expected_doc_pages):
client, api = test_client_and_api

resp = asyncio.run(client.get("/apidoc/openapi.json"))
resp = asyncio.run(client.get(f"{doc_prefix}/openapi.json"))
assert resp.status_code == 200
assert asyncio.run(resp.json) == api.spec

for doc_page in expected_doc_pages:
resp = asyncio.run(client.get(f"/apidoc/{doc_page}/"))
resp = asyncio.run(client.get(f"{doc_prefix}/{doc_page}/"))
assert resp.status_code == 200

resp = asyncio.run(client.get(f"/apidoc/{doc_page}"))
resp = asyncio.run(client.get(f"{doc_prefix}/{doc_page}"))
assert resp.status_code == 308


Expand Down
52 changes: 52 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,58 @@ def test_config_case():
assert config.title == "Demo"


@pytest.mark.parametrize("filename", ["openapi.json", "/openapi.json"])
@pytest.mark.parametrize("path", ["", "/"])
def test_config_spec_url_when_given_empty_path(path, filename):
"""
Test spec_url given empty path values and filename values with and
without leading slash.
"""
config = Configuration(path=path, filename=filename)
assert config.spec_url == "/openapi.json"


@pytest.mark.parametrize("filename", ["openapi.json", "/openapi.json"])
@pytest.mark.parametrize("path", ["prefix", "/prefix", "prefix/", "/prefix/"])
def test_config_spec_url_when_given_path_and_filename(path, filename):
"""
Test spec_url given path and filename values with and without
leading/trailing slashes.
"""
config = Configuration(path=path, filename=filename)
assert config.spec_url == "/prefix/openapi.json"


@pytest.mark.parametrize(
"config_path, test_path, expected_path",
[
pytest.param("prefix", "", "/prefix", id="root"),
pytest.param(
"prefix", "swagger", "/prefix/swagger", id="test-path-no-trailing-slash"
),
pytest.param(
"prefix", "swagger/", "/prefix/swagger", id="test-path-trailing-slash"
),
],
)
def test_config_join_doc_path(config_path, test_path, expected_path):
config = Configuration(path=config_path)
assert config.join_doc_path(test_path) == expected_path


@pytest.mark.parametrize(
"config_path, expected_doc_root_path",
[
pytest.param("", "/", id="root"),
pytest.param("prefix", "/prefix", id="path-no-trailing-slash"),
pytest.param("prefix/", "/prefix", id="path-trailing-slash"),
],
)
def test_config_doc_root(config_path, expected_doc_root_path):
config = Configuration(path=config_path)
assert config.doc_root == expected_doc_root_path


@pytest.mark.parametrize(("secure_item"), SECURITY_SCHEMAS)
def test_update_security_scheme(secure_item: Type[SecurityScheme]):
# update and validate each schema type
Expand Down
18 changes: 13 additions & 5 deletions tests/test_plugin_falcon.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,25 +400,33 @@ def test_validation_error_response_status_code(


@pytest.mark.parametrize(
"test_client_and_api, expected_doc_pages",
"test_client_and_api, doc_prefix, expected_doc_pages",
[
pytest.param({}, ["redoc", "swagger"], id="default-page-templates"),
pytest.param({}, "/apidoc", ["redoc", "swagger"], id="default-page-templates"),
pytest.param(
{"api_kwargs": {"path": ""}},
"",
["redoc", "swagger"],
id="default-page-templates-in-root-path",
),
pytest.param(
{"api_kwargs": {"page_templates": {"custom_page": "{spec_url}"}}},
"/apidoc",
["custom_page"],
id="custom-page-templates",
),
],
indirect=["test_client_and_api"],
)
def test_falcon_doc(test_client_and_api, expected_doc_pages):
def test_falcon_doc(test_client_and_api, doc_prefix, expected_doc_pages):
client, api = test_client_and_api

resp = client.simulate_get("/apidoc/openapi.json")
resp = client.simulate_get(f"{doc_prefix}/openapi.json")
assert resp.status_code == 200
assert resp.json == api.spec

for doc_page in expected_doc_pages:
resp = client.simulate_get(f"/apidoc/{doc_page}")
resp = client.simulate_get(f"{doc_prefix}/{doc_page}")
assert resp.status_code == 200


Expand Down
Loading