"""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. whole value and the whole reason it is off unless a site turns it on - see """ 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') @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') # DARK MODE. Redoc does not read prefers-color-scheme on its own, and # the element takes no theme - so the page goes through # Redoc.init instead, which does. The app has a dark theme and this was # the one page that stayed white when the rest of the site went dark. # # The page RE-INITIALISES when the OS scheme flips rather than restyling: # Redoc bakes its colours into styled-components at init, so a CSS # override reaches the chrome and misses the content. page = ( '' 'ShopDB API' '' '' '' '
' '' '' '' % (redoc_url, json.dumps(spec_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). ?tag=core-assets narrows it to one tag group. The whole spec is ~700 KB across 483 operations, which is more than a caller generating a client for one area needs and more than most model context windows should spend. The filtered copy keeps components intact, because a pruned schema graph is worse than a large one: a $ref that resolves to nothing turns a working generator into a confusing failure. """ if not os.path.isfile(_SPEC): return Response('{"error":"openapi.json not generated"}', status=404, mimetype='application/json') spec = _spec_with_server() wanted = (request.args.get('tag') or '').strip() if wanted: paths = {} for path, methods in (spec.get('paths') or {}).items(): kept = {m: op for m, op in methods.items() if not isinstance(op, dict) or wanted in (op.get('tags') or [])} # A path whose only survivors are shared keys (parameters, summary) # carries no operation and would read as an endpoint that exists # and does nothing. if any(isinstance(op, dict) and op.get('tags') for op in kept.values()): paths[path] = kept if not paths: return Response( json.dumps({'error': f'no operations tagged {wanted!r}', 'tags': sorted(_operation_tags(spec))}), status=404, mimetype='application/json') spec = dict(spec, paths=paths) return Response(json.dumps(spec), mimetype='application/json') def _operation_tags(spec): """Every tag that actually carries an operation.""" tags = set() for methods in (spec.get('paths') or {}).values(): for op in methods.values(): if isinstance(op, dict): tags.update(op.get('tags') or []) return tags @docs_bp.route('/operations.txt') def operations_index(): """Every operation, one line each: method, path, tag, auth, summary. WHY THIS EXISTS. The spec is the reference and it is ~700 KB; this is the index, and it is ~40 KB. A caller - a person or an agent writing a script - can hold every one of the 483 operations at once here, find the four it needs, and only then fetch their schemas from openapi.json?tag=... Handing over the whole spec instead means either truncating it or spending a context window on 479 operations nobody asked about. Plain text on purpose: it is meant to be read and grepped, not parsed. """ if not os.path.isfile(_SPEC): return Response('openapi.json not generated', status=404, mimetype='text/plain') spec = _spec_with_server() servers = spec.get('servers') or [{'url': '/'}] rows = [] for path, methods in sorted((spec.get('paths') or {}).items()): for method, op in sorted(methods.items()): if not isinstance(op, dict) or method.upper() not in _HTTP_METHODS: continue tag = (op.get('tags') or ['-'])[0] # security == [] means public. A list containing an EMPTY dict # means the scheme is optional - the route works signed out and # returns more when authenticated - and 14 routes here are that # shape. Collapsing them to the scheme name told a caller reading # only the index that it needed a token for a route that does not # require one. security = op.get('security') if security == []: auth = 'public' else: entries = security or [] schemes = sorted({k for entry in entries for k in entry}) optional = any(not entry for entry in entries) auth = ','.join(schemes) or 'bearer' if optional: auth += ',optional' summary = ' '.join((op.get('summary') or '').split()) rows.append(f'{method.upper():<6} {path:<52} [{tag}] ({auth}) {summary}') header = ( '# ShopDB API - operation index\n' f'# {len(rows)} operations. Base URL: {servers[0].get("url", "/")}\n' '#\n' '# This is the INDEX. Schemas, parameters and examples are in the spec:\n' '# /api/docs/openapi.json full (large)\n' '# /api/docs/openapi.json?tag= one group\n' '# Orientation for an agent, including the response envelope and auth:\n' '# /api/docs/llms.txt\n' '#\n' '# METHOD path [tag] (auth) summary\n' ) return Response(header + '\n'.join(rows) + '\n', mimetype='text/plain') _HTTP_METHODS = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} @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')