diff --git a/README.md b/README.md index 23dc7d8..bbce5d9 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,47 @@ config.add_route("foo_route", pattern="/foo") The `pyramid_openapi3_register_routes()` method supports setting a factory and route prefix as well. See the source for details. +### Deployment: the `servers` URL is a mount point, not a route prefix + +A common point of confusion is how a `servers` entry with a path interacts with +route registration: + +```yaml +servers: + - url: /api +paths: + /v2/users/: + x-pyramid-route-name: users +``` + +`pyramid_openapi3` treats the `servers` URL path (`/api`) as **where the whole +application is mounted**, not as a prefix to prepend to your route patterns. Your +spec paths (`/v2/users/`) are written *relative to that mount point*. This gives +you two supported deployment styles — pick **one**: + +- **Mounted (recommended when a reverse proxy / WSGI compositor mounts the app):** + deploy the app under `/api` so the WSGI server sets `SCRIPT_NAME=/api`, and + register routes verbatim. Requests to `/api/v2/users/` are routed and validated + correctly. + + ```python + config.pyramid_openapi3_register_routes() # routes at /v2/users/ + ``` + +- **Prefixed (when the app runs at the URL root):** bake the prefix into the routes + with `route_prefix`, and do **not** mount the app. + + ```python + config.pyramid_openapi3_register_routes(route_prefix="/api") # routes at /api/v2/users/ + ``` + +Do **not** combine the two: mounting the app at `/api` *and* passing +`route_prefix="/api"` double-prefixes the routes (they end up expecting +`/api/api/v2/users/`) and requests will 404. Likewise, using `servers: /api` while +running at the root *without* either mounting or `route_prefix="/api"` means +`/api/v2/users/` has no route (404) and the un-prefixed `/v2/users/` fails +server-matching validation — pick one of the two styles above instead. + ### Specify protocol and port for getting the OpenAPI 3 spec file Sometimes, it is necessary to specify the protocol and port to access the openapi3 spec file. This can be configured using the `proto_port` optional parameter to the the `pyramid_openapi3_add_explorer` function: diff --git a/pyramid_openapi3/tests/test_routes.py b/pyramid_openapi3/tests/test_routes.py index 4f15fbc..e626987 100644 --- a/pyramid_openapi3/tests/test_routes.py +++ b/pyramid_openapi3/tests/test_routes.py @@ -1,8 +1,11 @@ """Tests routes.""" +from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request from pyramid.testing import testConfig +from webtest.app import TestApp +import pytest import tempfile @@ -133,3 +136,80 @@ def test_register_routes_with_prefix() -> None: ("pyramid_openapi3.spec_json", "/openapi.json"), ("foo", "/api/v1/foo"), ] + + +# GH #194 -- the `servers` URL is a mount point, not a route prefix. +GH194_SPEC = b"""\ +openapi: "3.1.0" +info: + version: "1.0.0" + title: Foo API +servers: + - url: /api +paths: + /v2/users/: + x-pyramid-route-name: users + get: + responses: + "200": + description: A list of users + content: + application/json: + schema: + type: array + items: + type: string +""" + + +def _users_view(*args: object) -> list: + """Return a response that validates against GH194_SPEC's 200.""" + return [] + + +@pytest.mark.parametrize( + ("route_prefix", "script_name", "path_info", "expected_status"), + [ + # style A -- mounted at /api (SCRIPT_NAME), routes registered verbatim. + # The `servers` prefix is the mount point, so verbatim is correct. + pytest.param(None, "/api", "/v2/users/", 200, id="mounted-verbatim"), + # style B -- app at the root, prefix baked into the routes explicitly. + pytest.param("/api", "", "/api/v2/users/", 200, id="root-route_prefix"), + # style C (the trap) -- mounting *and* route_prefix double-prefixes, so the + # route expects /api/api/v2/users/ and the request 404s. This is why the + # `servers` prefix must NOT be auto-derived: it would push every mounted app + # (style A) into this broken state. + pytest.param("/api", "/api", "/v2/users/", 404, id="mounted-and-route_prefix"), + ], +) +def test_servers_url_is_mount_point_not_route_prefix( + route_prefix: str | None, script_name: str, path_info: str, expected_status: int +) -> None: + """GH #194: `servers` is a mount point, so verbatim registration is correct. + + Drive a real request through each supported deployment style (and the trap of + combining them). The external URL is http://localhost/api/v2/users/ in every + case; ``SCRIPT_NAME`` is the mount point, ``PATH_INFO`` is the remainder. + """ + with testConfig() as config: + config.include("pyramid_openapi3") + # Render an unmatched route as a 404 response (like a real app) instead of + # letting the raw HTTPNotFound propagate, so the style-C miss is observable. + config.add_notfound_view(lambda request: HTTPNotFound()) + with tempfile.NamedTemporaryFile() as tempdoc: + tempdoc.write(GH194_SPEC) + tempdoc.seek(0) + config.pyramid_openapi3_spec(tempdoc.name) + # route_prefix=None is the directive's default (verbatim registration). + config.pyramid_openapi3_register_routes(route_prefix=route_prefix) + config.add_view( + _users_view, route_name="users", renderer="json", openapi=True + ) + test_app = TestApp(config.make_wsgi_app()) + response = test_app.get( + path_info, + extra_environ={"SCRIPT_NAME": script_name}, + expect_errors=True, + ) + + assert response.status_int == expected_status