From c22e32594dee01b150dd9b7dc182df1d48b810e2 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Fri, 3 Jul 2026 16:43:23 -0400 Subject: [PATCH 1/2] feat: Add opt-in route_json param to serve the spec as JSON The spec dict is already parsed at registration time, so serving it as JSON is just another view on the same data. Off by default. Resolves #10 --- README.md | 2 + pyramid_openapi3/__init__.py | 19 ++++++++++ pyramid_openapi3/tests/test_views.py | 56 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/README.md b/README.md index 05304b3..e20c4ea 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ The reason this package exists is to give you peace of mind when providing a RES config.pyramid_openapi3_add_explorer(route='/api/v1/') ``` + Pass `route_json='/api/v1/openapi.json'` to `pyramid_openapi3_spec` to also serve the spec as JSON (off by default). + 3. Use the `openapi` [view predicate](https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#view-configuration-parameters) to enable request/response validation: ```python diff --git a/pyramid_openapi3/__init__.py b/pyramid_openapi3/__init__.py index a1b661b..01f9621 100644 --- a/pyramid_openapi3/__init__.py +++ b/pyramid_openapi3/__init__.py @@ -257,6 +257,7 @@ def add_spec_view( filepath: str, route: str = "/openapi.yaml", route_name: str = "pyramid_openapi3.spec", + route_json: str | None = None, permission: str = NO_PERMISSION_REQUIRED, apiname: str = "pyramid_openapi3", ) -> None: @@ -265,6 +266,10 @@ def add_spec_view( :param filepath: absolute/relative path to the specification file :param route: URL path where to serve specification file :param route_name: Route name under which specification file will be served + :param route_json: URL path where to serve the specification as JSON. If not + provided, the JSON representation is not served. The route is registered + under ``route_name`` suffixed with ``_json``; not available on + `add_spec_view_directory`. :param permission: Permission for the spec view """ @@ -289,6 +294,20 @@ def spec_view(request: Request) -> FileResponse: config.add_route(route_name, route) config.add_view(route_name=route_name, permission=permission, view=spec_view) + if route_json is not None: + route_name_json = f"{route_name}_json" + spec_json = json.dumps(spec_dict) + + def spec_view_json(request: Request) -> Response: + return Response( + spec_json, content_type="application/json", charset="UTF-8" + ) + + config.add_route(route_name_json, route_json) + config.add_view( + route_name=route_name_json, permission=permission, view=spec_view_json + ) + config.registry.settings[apiname] = _create_api_settings( config, filepath, route_name, spec ) diff --git a/pyramid_openapi3/tests/test_views.py b/pyramid_openapi3/tests/test_views.py index 882f4b0..e915c2a 100644 --- a/pyramid_openapi3/tests/test_views.py +++ b/pyramid_openapi3/tests/test_views.py @@ -16,6 +16,7 @@ from pyramid.testing import testConfig from pyramid_openapi3.exceptions import RequestValidationError +import json import os import pytest import tempfile @@ -126,6 +127,61 @@ def test_add_spec_view() -> None: assert view(request=None, context=None).body == MINIMAL_DOCUMENT +def test_add_spec_view_json_not_served_by_default() -> None: + """Test that the JSON spec route is not registered unless opted in.""" + with testConfig() as config: + config.include("pyramid_openapi3") + + with tempfile.NamedTemporaryFile() as document: + document.write(MINIMAL_DOCUMENT) + document.seek(0) + + config.pyramid_openapi3_spec( + document.name, route="/foo.yaml", route_name="foo_api_spec" + ) + + # no route registered for the (guessed) json variant + route_request = config.registry.queryUtility( + IRouteRequest, name="foo_api_spec_json" + ) + assert route_request is None + + +def test_add_spec_view_json() -> None: + """Test registration of a view that serves the openapi document as JSON.""" + with testConfig() as config: + config.include("pyramid_openapi3") + + with tempfile.NamedTemporaryFile() as document: + document.write(MINIMAL_DOCUMENT) + document.seek(0) + + config.pyramid_openapi3_spec( + document.name, + route="/foo.yaml", + route_name="foo_api_spec", + route_json="/foo.json", + ) + + # assert route + mapper = config.registry.getUtility(IRoutesMapper) + routes = mapper.get_routes() + assert routes[1].name == "foo_api_spec_json" + assert routes[1].path == "/foo.json" + + # assert view + request = config.registry.queryUtility( + IRouteRequest, name="foo_api_spec_json" + ) + view = config.registry.adapters.registered( + (IViewClassifier, request, Interface), IView, name="" + ) + response = view(request=None, context=None) + assert response.content_type == "application/json" + spec = config.registry.settings["pyramid_openapi3"]["spec"] + assert json.loads(response.body) == spec.read_value() + + def test_add_spec_view_already_defined() -> None: """Test that creating a spec more than once raises an Exception.""" with testConfig() as config: From 9589f593133d07bfe68b819dfde165aaa14379ae Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Sat, 4 Jul 2026 09:05:12 -0400 Subject: [PATCH 2/2] Serve the spec as JSON by default, drop the opt-in param json.dumps on an already-parsed dict is free, and the earlier opt-in flag mostly added branching rather than avoiding real cost. The JSON route path is derived from route (extension swapped to .json) so multi-spec apps with different apinames don't collide. --- README.md | 2 +- pyramid_openapi3/__init__.py | 36 +++++++++++++-------------- pyramid_openapi3/tests/test_routes.py | 3 +++ pyramid_openapi3/tests/test_views.py | 27 ++------------------ 4 files changed, 24 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index e20c4ea..aac0044 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The reason this package exists is to give you peace of mind when providing a RES config.pyramid_openapi3_add_explorer(route='/api/v1/') ``` - Pass `route_json='/api/v1/openapi.json'` to `pyramid_openapi3_spec` to also serve the spec as JSON (off by default). + The spec is also served as JSON, alongside YAML, at `route` with its extension replaced by `.json` (e.g. `/api/v1/openapi.json`). 3. Use the `openapi` [view predicate](https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#view-configuration-parameters) to enable request/response validation: diff --git a/pyramid_openapi3/__init__.py b/pyramid_openapi3/__init__.py index 01f9621..8b3fc78 100644 --- a/pyramid_openapi3/__init__.py +++ b/pyramid_openapi3/__init__.py @@ -15,6 +15,7 @@ from openapi_spec_validator.readers import read_from_filename from openapi_spec_validator.versions.shortcuts import get_spec_version from pathlib import Path +from pathlib import PurePosixPath from pyramid.config import PHASE0_CONFIG from pyramid.config import PHASE1_CONFIG from pyramid.config import Configurator @@ -257,19 +258,19 @@ def add_spec_view( filepath: str, route: str = "/openapi.yaml", route_name: str = "pyramid_openapi3.spec", - route_json: str | None = None, permission: str = NO_PERMISSION_REQUIRED, apiname: str = "pyramid_openapi3", ) -> None: """Serve and register OpenApi 3.0 specification file. + Also serves the specification as JSON, at ``route`` with its extension + replaced by ``.json`` (e.g. ``/openapi.yaml`` -> ``/openapi.json``), under + ``route_name`` suffixed with ``_json``. Not available on + `add_spec_view_directory`. + :param filepath: absolute/relative path to the specification file :param route: URL path where to serve specification file :param route_name: Route name under which specification file will be served - :param route_json: URL path where to serve the specification as JSON. If not - provided, the JSON representation is not served. The route is registered - under ``route_name`` suffixed with ``_json``; not available on - `add_spec_view_directory`. :param permission: Permission for the spec view """ @@ -287,26 +288,25 @@ def register() -> None: validate(spec_dict) spec = SchemaPath.from_dict(spec_dict) + spec_json = json.dumps(spec_dict) def spec_view(request: Request) -> FileResponse: return FileResponse(filepath, request=request, content_type="text/yaml") + def spec_view_json(request: Request) -> Response: + return Response(spec_json, content_type="application/json", charset="UTF-8") + config.add_route(route_name, route) config.add_view(route_name=route_name, permission=permission, view=spec_view) - if route_json is not None: - route_name_json = f"{route_name}_json" - spec_json = json.dumps(spec_dict) - - def spec_view_json(request: Request) -> Response: - return Response( - spec_json, content_type="application/json", charset="UTF-8" - ) - - config.add_route(route_name_json, route_json) - config.add_view( - route_name=route_name_json, permission=permission, view=spec_view_json - ) + route_name_json = f"{route_name}_json" + # PurePosixPath (not Path) so this always uses forward slashes for the + # URL path, regardless of the host OS this runs on. + route_json = str(PurePosixPath(route).with_suffix(".json")) + config.add_route(route_name_json, route_json) + config.add_view( + route_name=route_name_json, permission=permission, view=spec_view_json + ) config.registry.settings[apiname] = _create_api_settings( config, filepath, route_name, spec diff --git a/pyramid_openapi3/tests/test_routes.py b/pyramid_openapi3/tests/test_routes.py index 95037c9..4f15fbc 100644 --- a/pyramid_openapi3/tests/test_routes.py +++ b/pyramid_openapi3/tests/test_routes.py @@ -46,6 +46,7 @@ def test_register_routes_simple() -> None: ] assert routes == [ ("pyramid_openapi3.spec", "/openapi.yaml"), + ("pyramid_openapi3.spec_json", "/openapi.json"), ("foo", "/foo"), ("bar", "/bar"), ] @@ -92,6 +93,7 @@ def test_register_routes_with_factory() -> None: ] assert routes == [ ("pyramid_openapi3.spec", "/openapi.yaml", None), + ("pyramid_openapi3.spec_json", "/openapi.json", None), ("foo", "/foo", None), ("bar", "/bar", dummy_factory), ] @@ -128,5 +130,6 @@ def test_register_routes_with_prefix() -> None: ] assert routes == [ ("pyramid_openapi3.spec", "/openapi.yaml"), + ("pyramid_openapi3.spec_json", "/openapi.json"), ("foo", "/api/v1/foo"), ] diff --git a/pyramid_openapi3/tests/test_views.py b/pyramid_openapi3/tests/test_views.py index e915c2a..94f9b63 100644 --- a/pyramid_openapi3/tests/test_views.py +++ b/pyramid_openapi3/tests/test_views.py @@ -127,28 +127,8 @@ def test_add_spec_view() -> None: assert view(request=None, context=None).body == MINIMAL_DOCUMENT -def test_add_spec_view_json_not_served_by_default() -> None: - """Test that the JSON spec route is not registered unless opted in.""" - with testConfig() as config: - config.include("pyramid_openapi3") - - with tempfile.NamedTemporaryFile() as document: - document.write(MINIMAL_DOCUMENT) - document.seek(0) - - config.pyramid_openapi3_spec( - document.name, route="/foo.yaml", route_name="foo_api_spec" - ) - - # no route registered for the (guessed) json variant - route_request = config.registry.queryUtility( - IRouteRequest, name="foo_api_spec_json" - ) - assert route_request is None - - def test_add_spec_view_json() -> None: - """Test registration of a view that serves the openapi document as JSON.""" + """Test that the openapi document is also served as JSON, alongside YAML.""" with testConfig() as config: config.include("pyramid_openapi3") @@ -157,10 +137,7 @@ def test_add_spec_view_json() -> None: document.seek(0) config.pyramid_openapi3_spec( - document.name, - route="/foo.yaml", - route_name="foo_api_spec", - route_json="/foo.json", + document.name, route="/foo.yaml", route_name="foo_api_spec" ) # assert route