Serve a spec that knows where the instance is mounted

/api/docs on the production site handed out
https://<host>/api/apitokens for an app that lives at /shopdb. Copy that URL
and it 404s; Swagger's try-it fired at the same wrong address.

The mount is a DEPLOYMENT fact and the spec is a BUILD artifact.
gen_openapi.py writes servers: [{"url": "/"}] because at generation time nobody
knows where the app will be hung, and wsgi.py only moves MOUNT_PATH from
PATH_INFO into SCRIPT_NAME when the process starts. Served as written, the file
was right for a root install and wrong for every subpath one - which is how
production runs.

The endpoint now sets `servers` per request from request.script_root: empty at
root, /shopdb under the subpath method, and correct behind a proxy that sets the
prefix. The paths themselves stay mount-free, because the prefix belongs in the
server entry and duplicating it into each path would double it.

The file is about a megabyte, so it is parsed once and cached by mtime, and each
request gets a shallow copy with only `servers` replaced - two requests arriving
on different mounts cannot race on one dict.

A test pins both cases and the fact that paths stay unprefixed. Planting a wrong
server URL fails it.
This commit is contained in:
cproudlock
2026-09-19 12:30:46 -04:00
parent f9d6b57f0f
commit 76f085e224
2 changed files with 72 additions and 2 deletions

View File

@@ -18,7 +18,7 @@ import os
from markupsafe import escape
from flask import Blueprint, Response, current_app, send_file, url_for
from flask import Blueprint, Response, current_app, request, send_file, url_for
docs_bp = Blueprint('docs', __name__)
@@ -98,13 +98,50 @@ def docs_index():
return Response(page, mimetype='text/html')
_SPEC_CACHE = {}
def _spec_with_server():
"""The generated spec, with `servers` set to where this instance is mounted.
THE MOUNT IS A DEPLOYMENT FACT, NOT A BUILD FACT. gen_openapi.py writes
servers: [{"url": "/"}] because it cannot know where the app will be hung,
and wsgi.py moves MOUNT_PATH from PATH_INFO into SCRIPT_NAME at runtime. So
an instance under /shopdb served a spec whose paths start at /api, and both
renderers dutifully built https://host/api/apitokens - a URL that 404s.
Anyone who copied it out of the docs page got a broken call, and Swagger's
try-it fired at the wrong place.
request.script_root is the runtime answer: '' for a root install, '/shopdb'
under the subpath method, and correct behind a proxy that sets the prefix.
The file is ~1 MB, so it is parsed once and cached by mtime; each request
gets a shallow copy with only `servers` replaced, which keeps two requests
on different mounts from racing on one dict.
"""
stamp = os.path.getmtime(_SPEC)
if _SPEC_CACHE.get('stamp') != stamp:
with open(_SPEC, 'r', encoding='utf-8') as handle:
_SPEC_CACHE['spec'] = json.load(handle)
_SPEC_CACHE['stamp'] = stamp
root = request.script_root or ''
spec = dict(_SPEC_CACHE['spec'])
spec['servers'] = [{
'url': root or '/',
'description': 'this instance',
}]
return spec
@docs_bp.route('/openapi.json')
def openapi_spec():
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
if not os.path.isfile(_SPEC):
return Response('{"error":"openapi.json not generated"}',
status=404, mimetype='application/json')
return send_file(_SPEC, mimetype='application/json')
return Response(json.dumps(_spec_with_server()),
mimetype='application/json')
@docs_bp.route('/redoc.standalone.js')

View File

@@ -214,3 +214,36 @@ def test_a_vendored_asset_fetches_nothing_from_the_internet(filename):
assert match.startswith('data:'), (
'%s loads %s rather than an inline data: URI'
% (filename, match))
# ---------------------------------------------------------------------------
# The spec has to say where this instance actually lives
# ---------------------------------------------------------------------------
def test_the_spec_declares_the_mount_it_is_served_from(enabled_client):
"""Production runs under /shopdb, and the generated file cannot know that.
gen_openapi.py writes servers: [{"url": "/"}] - correct for a root install
and wrong for every subpath one - and wsgi.py only moves MOUNT_PATH into
SCRIPT_NAME at runtime. Served as written, both renderers built
https://host/api/... for an app mounted at /shopdb: a URL an operator copied
out of the docs page and got a 404 from, and the address Swagger's try-it
fired at.
"""
import json as _json
at_root = _json.loads(
enabled_client.get('/api/docs/openapi.json').get_data(as_text=True))
assert at_root['servers'] == [{'url': '/', 'description': 'this instance'}]
mounted = _json.loads(enabled_client.get(
'/api/docs/openapi.json',
environ_overrides={'SCRIPT_NAME': '/shopdb'}).get_data(as_text=True))
assert mounted['servers'] == [
{'url': '/shopdb', 'description': 'this instance'}]
# The paths themselves stay mount-free: the server prefix is what varies,
# and duplicating it into every path would double it under a subpath.
assert '/api/apitokens' in mounted['paths']
assert '/shopdb/api/apitokens' not in mounted['paths']