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:
cproudlock
2026-09-20 12:59:49 -04:00
parent 6c00b10618
commit 762d1b7210
11 changed files with 894 additions and 4 deletions

View File

@@ -6,6 +6,7 @@
# gates - proves those checks actually RAN rather than skipping
# frontend - vitest + Vue build
# lean-build - ADR-013: a per-site build carries only its plugins
# api-contract - the published spec matches the code that serves it
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on
# a second run. The pytest suite only exercises SQLite
@@ -53,6 +54,106 @@ jobs:
- uses: actions/checkout@v5
- run: bash scripts/check-naming-and-style.sh
api-contract:
# The published API contract has to match the code that serves it. These
# gates catch a route that exists and is undocumented, a query parameter the
# handler reads that the spec omits, and a public route nobody declared
# public. They already work; until now nothing ran them except by hand, and
# the spec had silently drifted by three operations.
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements-dev.txt
- run: |
python -m pytest -q \
tests/test_api_inventory_parity.py \
tests/test_openapi_spec.py \
tests/test_openapi_examples.py \
tests/test_core/test_public_read_surface.py
- name: The spec must be regenerated, not just passing
# --refresh is derived from the code, so if it changes anything the
# committed spec was stale. Fail here rather than let a generated client
# be built from a file that no longer describes the API.
run: |
python scripts/gen_openapi.py --refresh
if ! git diff --quiet docs/openapi.json docs/api-inventory.json; then
echo "docs/openapi.json is stale. Run:"
echo " python scripts/gen_openapi.py --refresh"
git --no-pager diff --stat docs/openapi.json docs/api-inventory.json
exit 1
fi
migrations-mysql:
# THE JOB THE HEADER PROMISED AND NOBODY WROTE. Until now this existed only
# as a comment claiming to cover "the REAL multi-site deploy path", which is
# worse than no comment: it is exactly the false confidence that let a
# migration ship which could not build a schema on a fresh MySQL database.
#
# The pytest suite runs on SQLite, and SQLite accepts a foreign key onto a
# table that does not exist. MySQL does not. That difference is invisible to
# every other job here.
runs-on: arc-runner-set
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: ci
MYSQL_DATABASE: shopdb_flask
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci"
--health-interval=10s --health-timeout=5s --health-retries=10
ports:
- 3306:3306
env:
DATABASE_URL: mysql+pymysql://root:ci@127.0.0.1:3306/shopdb_flask?charset=utf8mb4
FLASK_APP: shopdb
SECRET_KEY: ci-not-a-secret
JWT_SECRET_KEY: ci-not-a-secret
CORS_ORIGINS: http://localhost
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements.txt
- name: Fresh install, exactly as the README tells a site to do it
run: |
flask db upgrade
flask plugin upgrade-all
flask seed permissions
flask seed settings
flask seed reference-data
- name: Enable every bundled plugin and migrate it
# A plugin's chain only runs once the plugin is ENABLED, so a core
# migration that depends on a plugin-owned table passes an empty install
# and fails a real one. Enabling them here is what reproduces that.
run: |
python - <<'PY'
import json, os, pathlib, datetime
root = pathlib.Path('plugins')
names = sorted(d.name for d in root.iterdir()
if (d / 'manifest.json').is_file())
now = datetime.datetime.now().isoformat()
state = {'plugins': {n: {'name': n, 'version': '1.0.0',
'installed_at': now, 'enabled': True,
'migrations_applied': [], 'config': {}}
for n in names}}
out = pathlib.Path('instance'); out.mkdir(exist_ok=True)
(out / 'plugins.json').write_text(json.dumps(state, indent=2))
print('enabled', len(names), 'plugins')
PY
flask plugin upgrade-all
- name: Re-run to prove it is idempotent
run: |
flask db upgrade
flask plugin upgrade-all
flask seed permissions
gates:
# Prove the gates RAN. Several skip themselves when the file they read is
# absent, which is correct in this checkout (docs/ becomes the wiki) and was