The app has a dark theme. /api/docs was the one page that stayed white when the
rest of the site went dark, which is exactly the page somebody has open beside
the app while they work.
Redoc does not read prefers-color-scheme, and the <redoc spec-url> element it
was using takes no theme at all - so the page now initialises through
Redoc.init, which does. Light keeps the default look; dark names the colours
Redoc otherwise derives from the background and gets wrong: body text, the
sidebar, and the right-hand sample panel, which came out dark-on-dark.
It re-initialises on a live scheme change rather than restyling, because Redoc
bakes its colours into styled-components at init - a CSS override reaches the
chrome and misses the content. The listener has the addListener fallback for the
older MediaQueryList, since a shop-floor kiosk browser is exactly where this
gets read.
Nothing new is loaded: same vendored bundle, same spec, no font, no CDN. A test
pins that, because theming is the kind of change that quietly introduces one.
The try-it page is left light. Swagger UI 5 ships no dark theme, so it would
mean vendoring a community stylesheet that fights upstream on every bump - and
it is a development tool that is off in production anyway.
/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.
/api/docs renders the spec with Redoc, which is read-only by design. The thing
people mean when they ask for Swagger is firing a request from the page, so
Swagger UI now sits beside it at /api/docs/try, reading the same spec - one
contract, two renderers.
It is OFF outside development and 404s rather than rendering a disabled page: a
production box holds live fleet data, and a docs page that can POST to it is a
different object from a docs page that cannot.
Vendored, not linked. swagger-ui-bundle.min.js and swagger-ui.css are served
from staticdocs/ beside the existing Redoc bundle, because production has no
internet. validatorUrl is null: left unset, Swagger UI hands the spec to the
public swagger validator to render a badge, which here is a request that cannot
succeed and elsewhere would ship the internal API surface off the box.
THE VENDORED REDOC BUNDLE WAS ALREADY REACHING THE INTERNET, found while
proving this page does not. redoc.standalone.js fetched
cdn.redoc.ly/redoc/logo-mini.svg for the "API docs by Redocly" mark, so every
load of /api/docs on the air-gapped server has been waiting on a request that
cannot complete. Replaced with an inline transparent GIF.
Three gate holes closed, each one a dodge that worked:
- the page is built by interpolating url_for output into a JS string literal,
unescaped. SCRIPT_NAME is proxy-controlled and ProxyFix honours
X-Forwarded-Prefix, so a mount path carrying a quote closed the literal and
executed what followed. The spec URL is now json.dumps'd and the rest
HTML-escaped; the injected quote renders as \" and is inert
- the "no internet" assertion on the page scanned for https?://, so
<script src="//cdn.jsdelivr.net/..."> - the form a copy-paste from any CDN's
own documentation produces - matched nothing and passed
- the same assertion on the vendored files was a five-host CDN allowlist. An
upstream bump to any other host walked through it, and the one real leak in
that directory was on no list. It now matches the SHAPES a browser fetches -
@import, css url(), src/href, @font-face - whatever the host, ignoring the
data: URIs this CSS is full of
And the Redoc page's link to the try-it view is built with url_for, which raises
BuildError when those routes are not mounted - every production box. Made
unconditional it would 500 the docs page in exactly the deployment nobody tests
locally, and no test caught that. One does now.