Make the API usable by an agent, and stop the contract going stale
Someone writing a script against ShopDB had two options: read a 705 KB spec, or guess. The spec is the reference, not an index, and handing an agent the whole thing means either truncating it or spending a context window on 479 operations nobody asked about. Three additions, each feeding the next. /api/docs/operations.txt is the index: every operation on one line with its method, path, tag, auth and summary, at around 70 KB for all 486. A caller can hold the entire API at once, find the four operations it needs, and only then fetch their schemas. /api/docs/openapi.json?tag=... narrows the spec to one of the 41 tag groups. Components are kept intact deliberately: pruning the schema graph to match would leave references resolving to nothing, and a generator that fails confusingly is worse than a file that is larger than strictly necessary. tools/mcp/ is an MCP server built on both. Five tools rather than 483, because a tool per operation is unusable - clients degrade past a few dozen and models start picking the wrong one. It reads everything from the live instance at call time, so enabling a plugin adds operations it can already reach with no change here. The token is the security boundary and the README says so plainly: every operation the credential permits is reachable, so exploratory work wants a read-scoped token. The rest of this is about the contract not drifting, which is what makes any of the above trustworthy. Two CI jobs: - api-contract runs the parity gates and then regenerates the spec and fails if anything changed. Those gates already existed and already worked - they caught both routes added here, unprompted - but nothing ran them except by hand, and the committed spec had silently drifted by three operations, including two setup endpoints added the day before. - migrations-mysql is the job the header comment has been claiming for some time while not existing. It described "the REAL multi-site deploy path" and "what catches an Alembic regression on MySQL before it ships", and there was no such job. That is worse than no comment: it is the false confidence that let a migration ship which could not build a schema on a fresh MySQL database. The suite runs on SQLite, and SQLite accepts a foreign key onto a table that does not exist. CONTRIBUTING.md now says to run gen_openapi.py --refresh after changing a route, and that a route the sweep cannot classify belongs in PUBLIC_ENDPOINTS with a reason rather than hand-edited into the inventory, which the next refresh would overwrite. The new index route is declared there alongside the spec it indexes.
This commit is contained in:
@@ -297,12 +297,94 @@ def _spec_with_server():
|
||||
|
||||
@docs_bp.route('/openapi.json')
|
||||
def openapi_spec():
|
||||
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
|
||||
"""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')
|
||||
return Response(json.dumps(_spec_with_server()),
|
||||
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]
|
||||
auth = 'public' if op.get('security') == [] else (
|
||||
','.join(sorted({k for s in (op.get('security') or [])
|
||||
for k in s})) or 'bearer')
|
||||
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=<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')
|
||||
|
||||
Reference in New Issue
Block a user