diff --git a/shopdb/core/api/docs.py b/shopdb/core/api/docs.py index 4d929b10..cc266f4e 100644 --- a/shopdb/core/api/docs.py +++ b/shopdb/core/api/docs.py @@ -18,7 +18,7 @@ import os 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__) @@ -98,13 +98,50 @@ def docs_index(): 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 send_file(_SPEC, mimetype='application/json') + return Response(json.dumps(_spec_with_server()), + mimetype='application/json') @docs_bp.route('/redoc.standalone.js') diff --git a/tests/test_core/test_api_docs_tryit.py b/tests/test_core/test_api_docs_tryit.py index c114ee6f..40145aef 100644 --- a/tests/test_core/test_api_docs_tryit.py +++ b/tests/test_core/test_api_docs_tryit.py @@ -214,3 +214,36 @@ def test_a_vendored_asset_fetches_nothing_from_the_internet(filename): assert match.startswith('data:'), ( '%s loads %s rather than an inline data: URI' % (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']