/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.
248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""Interactive API docs: self-hosted Redoc over the generated OpenAPI spec.
|
|
|
|
Served at /api/docs (relative to the mount). The Redoc bundle is vendored in
|
|
staticdocs/ so this works fully offline on the air-gapped prod box - no CDN.
|
|
The spec is docs/openapi.json in the repo (regenerate with
|
|
scripts/gen_openapi.py after API changes).
|
|
|
|
Two renderers over ONE spec. Redoc is read-only by design and is the default,
|
|
because what is published is a contract. Swagger UI at /api/docs/try adds the
|
|
one thing Redoc will not do: fire a real request from the page, against this
|
|
server, with whatever session the browser is already carrying. That is its
|
|
whole value and the whole reason it is off unless a site turns it on - see
|
|
API_DOCS_TRYIT_ENABLED in shopdb/config.py.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
from markupsafe import escape
|
|
|
|
from flask import Blueprint, Response, current_app, request, send_file, url_for
|
|
|
|
docs_bp = Blueprint('docs', __name__)
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
# shopdb/core/api -> repo root
|
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(_HERE)))
|
|
_SPEC = os.path.join(_REPO_ROOT, 'docs', 'openapi.json')
|
|
_LLMS = os.path.join(_REPO_ROOT, 'docs', 'llms.txt')
|
|
_REDOC = os.path.join(_HERE, 'staticdocs', 'redoc.standalone.js')
|
|
|
|
# Vendored from swagger-ui-dist 5.33.0. TWO files out of the ~30 in that dist,
|
|
# because those are the two the page below actually loads:
|
|
# swagger-ui.css - the stylesheet. Its images are inline data: URIs,
|
|
# so it pulls nothing else. The trailing
|
|
# sourceMappingURL comment was stripped: the .map
|
|
# is not vendored, and left in place it turns every
|
|
# devtools open into a 404 on a box with no
|
|
# internet to fall back on.
|
|
# swagger-ui-bundle.min.js - the renderer plus the core presets, self-
|
|
# contained. NOT vendored beside it:
|
|
# swagger-ui-standalone-preset.js (the topbar with
|
|
# the "load any spec URL" box - this page serves
|
|
# one spec on purpose), the ES bundles (module
|
|
# builds of the same code), the .map files (~10 MB
|
|
# of source maps that only devtools reads),
|
|
# oauth2-redirect.html (no OAuth flow here), and
|
|
# the favicons.
|
|
# The upstream name is swagger-ui-bundle.js; the vendored copy carries .min.js,
|
|
# which is what it is - a minified single-line build. That suffix is also what
|
|
# scripts/check-naming-and-style.sh already filters vendored bundles by, so the
|
|
# style gate does not report thousands of hits in code nobody here wrote.
|
|
# swagger-ui.LICENSE.txt is the Apache-2.0 text that ships with the dist. It is
|
|
# never served; it is here because redistributing the bundle requires it.
|
|
_SWAGGER_CSS = os.path.join(_HERE, 'staticdocs', 'swagger-ui.css')
|
|
_SWAGGER_JS = os.path.join(_HERE, 'staticdocs', 'swagger-ui-bundle.min.js')
|
|
|
|
_TRYIT_ENDPOINT = 'docs.swagger_tryit'
|
|
|
|
|
|
def _tryit_is_mounted():
|
|
"""True when the try-it page exists on THIS app.
|
|
|
|
Reads the url map rather than the config flag on purpose: the routes are
|
|
bound once, at blueprint registration, so a config value changed after boot
|
|
would have the Redoc page advertise a link that answers 404.
|
|
"""
|
|
return _TRYIT_ENDPOINT in current_app.view_functions
|
|
|
|
|
|
@docs_bp.route('/', strict_slashes=False)
|
|
def docs_index():
|
|
"""Redoc page. url_for keeps the asset URLs correct under any mount."""
|
|
spec_url = url_for('docs.openapi_spec')
|
|
redoc_url = url_for('docs.redoc_js')
|
|
# Mentioned only when it is actually there. An unconditional note would be
|
|
# an advert for a 404 on every box that ships the shipped default.
|
|
tryit_note = ''
|
|
if _tryit_is_mounted():
|
|
tryit_note = (
|
|
'<p style="font:14px/1.4 system-ui,sans-serif;margin:0;'
|
|
'padding:10px 16px;background:#eef2f7;border-bottom:1px solid #cbd5e1">'
|
|
'This page is read-only. To send a real request against this '
|
|
'server, use <a href="%s">the try-it view</a>.</p>'
|
|
% url_for(_TRYIT_ENDPOINT)
|
|
)
|
|
page = (
|
|
'<!doctype html><html><head>'
|
|
'<title>ShopDB API</title>'
|
|
'<meta charset="utf-8">'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
'<style>body{margin:0;padding:0}</style></head><body>'
|
|
'%s'
|
|
'<redoc spec-url="%s"></redoc>'
|
|
'<script src="%s"></script>'
|
|
'</body></html>' % (tryit_note, spec_url, redoc_url)
|
|
)
|
|
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 Response(json.dumps(_spec_with_server()),
|
|
mimetype='application/json')
|
|
|
|
|
|
@docs_bp.route('/redoc.standalone.js')
|
|
def redoc_js():
|
|
"""Vendored Redoc bundle (offline)."""
|
|
return send_file(_REDOC, mimetype='application/javascript')
|
|
|
|
|
|
@docs_bp.route('/llms.txt')
|
|
def llms_txt():
|
|
"""Concise LLM-oriented API guide."""
|
|
if not os.path.isfile(_LLMS):
|
|
return Response('llms.txt not found', status=404, mimetype='text/plain')
|
|
return send_file(_LLMS, mimetype='text/plain')
|
|
|
|
|
|
def swagger_tryit():
|
|
"""Swagger UI over the SAME spec the Redoc page renders.
|
|
|
|
Every URL on the page comes from url_for, so the spec and both assets stay
|
|
same-origin and correct under a mount path (prod runs under /shopdb).
|
|
"""
|
|
spec_url = url_for('docs.openapi_spec')
|
|
css_url = url_for('docs.swagger_css')
|
|
js_url = url_for('docs.swagger_js')
|
|
redoc_page_url = url_for('docs.docs_index')
|
|
page = (
|
|
'<!doctype html><html><head>'
|
|
'<title>ShopDB API - try it</title>'
|
|
'<meta charset="utf-8">'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
'<link rel="stylesheet" href="%(css)s">'
|
|
'<style>body{margin:0;padding:0}'
|
|
'.tryit-warn{font:14px/1.4 system-ui,sans-serif;background:#7f1d1d;'
|
|
'color:#fff;margin:0;padding:10px 16px}'
|
|
'.tryit-warn a{color:#fff}</style>'
|
|
'</head><body>'
|
|
'<p class="tryit-warn"><b>Execute sends a real request to this '
|
|
'server</b>, as whoever is signed in on this browser. A write '
|
|
'operation changes live data. The read-only contract is at '
|
|
'<a href="%(redoc)s">/api/docs</a>.</p>'
|
|
'<div id="swagger-ui"></div>'
|
|
'<script src="%(js)s"></script>'
|
|
'<script>\n'
|
|
'window.ui = SwaggerUIBundle({\n'
|
|
# json.dumps, NOT a bare "%s". SCRIPT_NAME is proxy-controlled and
|
|
# ProxyFix honours X-Forwarded-Prefix, so a mount path carrying a quote
|
|
# closed this string literal and executed what followed: SCRIPT_NAME of
|
|
# /a"+alert(1)+" rendered url: "/a"+alert(1)+"/api/docs/openapi.json".
|
|
# Escaping it as JSON is what makes the value data rather than code.
|
|
' url: %(spec)s,\n'
|
|
' dom_id: "#swagger-ui",\n'
|
|
' presets: [SwaggerUIBundle.presets.apis],\n'
|
|
' layout: "BaseLayout",\n'
|
|
' deepLinking: true,\n'
|
|
' filter: true,\n'
|
|
# 573 operations. Expanded by default, the page spends many seconds
|
|
# laying out before it answers a scroll.
|
|
' docExpansion: "none",\n'
|
|
' withCredentials: true,\n'
|
|
# THE air-gap setting. Left unset, Swagger UI renders a "valid" badge
|
|
# by handing this spec to the public swagger validator - an outbound
|
|
# request that cannot succeed here, and that would ship the internal
|
|
# API surface off the box if it ever could.
|
|
' validatorUrl: null\n'
|
|
'});\n'
|
|
'</script>'
|
|
'</body></html>'
|
|
% {'css': escape(css_url), 'js': escape(js_url),
|
|
'spec': json.dumps(spec_url),
|
|
'redoc': escape(redoc_page_url)}
|
|
)
|
|
return Response(page, mimetype='text/html')
|
|
|
|
|
|
def swagger_css():
|
|
"""Vendored Swagger UI stylesheet (offline)."""
|
|
return send_file(_SWAGGER_CSS, mimetype='text/css')
|
|
|
|
|
|
def swagger_js():
|
|
"""Vendored Swagger UI bundle (offline)."""
|
|
return send_file(_SWAGGER_JS, mimetype='application/javascript')
|
|
|
|
|
|
@docs_bp.record_once
|
|
def _mount_tryit(state):
|
|
"""Bind the try-it routes only when the flag is on for this app.
|
|
|
|
Binding at registration rather than checking the flag inside the view, so
|
|
that when it is off the routes DO NOT EXIST: the URL gets Flask's own 404
|
|
and confirms nothing, and the unauthenticated-GET sweep in
|
|
tests/test_core/test_public_read_surface.py never has to make an exception
|
|
for three routes that are only meant to be reachable on a dev box.
|
|
"""
|
|
if not state.app.config.get('API_DOCS_TRYIT_ENABLED'):
|
|
return
|
|
state.add_url_rule('/try', endpoint='swagger_tryit',
|
|
view_func=swagger_tryit, strict_slashes=False)
|
|
state.add_url_rule('/swagger-ui.css', endpoint='swagger_css',
|
|
view_func=swagger_css)
|
|
state.add_url_rule('/swagger-ui-bundle.min.js', endpoint='swagger_js',
|
|
view_func=swagger_js)
|