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:
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user