diff --git a/.gitattributes b/.gitattributes index 9a0a204a..b6c75456 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,10 @@ scripts/check-naming-and-style.sh text eol=lf # nowhere on the error path - which is the path that matters in a promote # script. *.cmd text eol=crlf + +# Vendored API-docs renderers (Redoc, Swagger UI). Minified upstream builds of +# code nobody here wrote: one line, over a megabyte each. -diff keeps a textual +# diff of that line out of every review, and linguist-vendored keeps them out of +# the language breakdown, which they would otherwise dominate. +shopdb/core/api/staticdocs/*.js linguist-vendored -diff +shopdb/core/api/staticdocs/*.css linguist-vendored -diff diff --git a/shopdb/config.py b/shopdb/config.py index 0d4447e5..f1f2a457 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -185,6 +185,22 @@ class Config: AUTH_RATELIMIT_WINDOW_SECONDS = int( os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)) + # Swagger UI "try it" page at /api/docs/try, beside the read-only Redoc + # page at /api/docs. One spec, two renderers. + # + # Off unless a site turns it on, and that is the point rather than caution: + # Swagger UI's Execute button fires a REAL request at THIS server from the + # operator's browser, carrying whatever session that browser already holds. + # On a production box that is a one-click way to POST against live fleet + # data from a page anyone can open. Redoc, which cannot send anything, stays + # the default and is always on. + # + # When off the routes are never bound at all, so /api/docs/try is Flask's + # own 404 and the Redoc page does not mention it. See + # shopdb/core/api/docs.py. + API_DOCS_TRYIT_ENABLED = os.environ.get( + 'API_DOCS_TRYIT_ENABLED', 'false').lower() == 'true' + DEFAULT_PAGE_SIZE = 20 MAX_PAGE_SIZE = 100 @@ -214,6 +230,12 @@ class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_ECHO = True + # On in development: firing the request is the whole reason a developer + # opens the API docs. Still read from the environment, so a shared dev + # instance can turn it back off without editing this file. + API_DOCS_TRYIT_ENABLED = os.environ.get( + 'API_DOCS_TRYIT_ENABLED', 'true').lower() == 'true' + SQLALCHEMY_DATABASE_URI = os.environ.get( 'DATABASE_URL', 'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask', @@ -234,6 +256,16 @@ class TestingConfig(Config): # xdist distribution, so the failure moves around. The tests that are about # the limiter turn it on themselves. ENDPOINTS_ENROLL_RATELIMIT_ENABLED = False + # Off, and stated here rather than inherited, because a test suite that + # silently ran with it on would be testing a configuration no site ships. + # The session-wide app in tests/conftest.py is what + # tests/test_core/test_public_read_surface.py sweeps for ungated GET + # routes, and the try-it page plus its two vendored assets are + # deliberately unauthenticated. Keeping the default here means that sweep + # keeps describing what production actually exposes; the tests that ARE + # about the page build their own app with the flag on + # (tests/test_core/test_api_docs_tryit.py). + API_DOCS_TRYIT_ENABLED = False SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' SQLALCHEMY_ENGINE_OPTIONS = { 'connect_args': {'check_same_thread': False}, diff --git a/shopdb/core/api/docs.py b/shopdb/core/api/docs.py index 11181ed4..4d929b10 100644 --- a/shopdb/core/api/docs.py +++ b/shopdb/core/api/docs.py @@ -4,11 +4,21 @@ 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 flask import Blueprint, Response, send_file, url_for +from markupsafe import escape + +from flask import Blueprint, Response, current_app, send_file, url_for docs_bp = Blueprint('docs', __name__) @@ -19,21 +29,71 @@ _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 = ( + '
' + 'This page is read-only. To send a real request against this ' + 'server, use the try-it view.
' + % url_for(_TRYIT_ENDPOINT) + ) page = ( '' '