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 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__)
|
docs_bp = Blueprint('docs', __name__)
|
||||||
|
|
||||||
@@ -98,13 +98,50 @@ def docs_index():
|
|||||||
return Response(page, mimetype='text/html')
|
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')
|
@docs_bp.route('/openapi.json')
|
||||||
def openapi_spec():
|
def openapi_spec():
|
||||||
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
|
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
|
||||||
if not os.path.isfile(_SPEC):
|
if not os.path.isfile(_SPEC):
|
||||||
return Response('{"error":"openapi.json not generated"}',
|
return Response('{"error":"openapi.json not generated"}',
|
||||||
status=404, mimetype='application/json')
|
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')
|
@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:'), (
|
assert match.startswith('data:'), (
|
||||||
'%s loads %s rather than an inline data: URI'
|
'%s loads %s rather than an inline data: URI'
|
||||||
% (filename, match))
|
% (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