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:
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user