mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 21:17:13 +09:00
OpenAPIService.generate_spec() existed but nothing snapshotted it, so a route rename/removal/method change shipped invisibly to API-key consumers, the CLI, and agents. docs/API_SURFACE.md now pins the sorted METHOD /path inventory (1418 operations) with the same regenerate-and-review contract as docs/MIGRATION_INVENTORY.md: an intentional surface change regenerates the doc in the same commit (SERVERKIT_UPDATE_API_SURFACE=1 pytest tests/test_api_surface_inventory.py) so the diff is the review. Method+path only on purpose — summaries and schemas churn without breaking anyone. Named *_inventory so preflight's globs run it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
76 lines
3.1 KiB
Python
76 lines
3.1 KiB
Python
"""The committed API surface must match the served one (plan 82 §G.2).
|
|
|
|
OpenAPIService.generate_spec() existed but nothing snapshotted it, so a
|
|
breaking route change (rename, removal, method change) shipped invisibly to
|
|
API-key consumers, the CLI, and agents. This pins the sorted
|
|
``METHOD /path`` inventory of the spec against ``docs/API_SURFACE.md`` —
|
|
the same regenerate-and-review contract as docs/MIGRATION_INVENTORY.md:
|
|
an intentional surface change regenerates the doc in the same commit, so
|
|
every add/remove/method change is a visible reviewed diff::
|
|
|
|
SERVERKIT_UPDATE_API_SURFACE=1 pytest tests/test_api_surface_inventory.py
|
|
|
|
Deliberately method+path only: summaries and schemas churn without breaking
|
|
anyone; the surface is what consumers dial.
|
|
"""
|
|
import os
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DOC = os.path.join(os.path.dirname(os.path.dirname(HERE)),
|
|
'docs', 'API_SURFACE.md')
|
|
|
|
HEADER = (
|
|
'# ServerKit API surface\n'
|
|
'\n'
|
|
'Generated from OpenAPIService.generate_spec() — do not edit by hand.\n'
|
|
'Regenerate (backend/):\n'
|
|
'`SERVERKIT_UPDATE_API_SURFACE=1 pytest tests/test_api_surface_inventory.py`\n'
|
|
'\n'
|
|
)
|
|
|
|
|
|
def _measured_surface(app):
|
|
from app.services.openapi_service import OpenAPIService
|
|
with app.app_context(), app.test_request_context():
|
|
spec = OpenAPIService.generate_spec()
|
|
lines = []
|
|
for path, ops in spec.get('paths', {}).items():
|
|
for method in ops:
|
|
if method.upper() in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE',
|
|
'HEAD', 'OPTIONS'):
|
|
lines.append(f'{method.upper()} {path}')
|
|
return sorted(set(lines))
|
|
|
|
|
|
def test_committed_surface_matches_served_surface(app):
|
|
measured = _measured_surface(app)
|
|
assert len(measured) > 300, (
|
|
f'only {len(measured)} operations measured — generate_spec() or the '
|
|
f'extraction broke; fix that before trusting this gate')
|
|
rendered = HEADER + '\n'.join(f'- `{line}`' for line in measured) + '\n'
|
|
|
|
if os.environ.get('SERVERKIT_UPDATE_API_SURFACE') == '1':
|
|
with open(DOC, 'w', encoding='utf-8', newline='\n') as fh:
|
|
fh.write(rendered)
|
|
|
|
assert os.path.exists(DOC), (
|
|
'docs/API_SURFACE.md missing — generate it with '
|
|
'SERVERKIT_UPDATE_API_SURFACE=1 pytest '
|
|
'tests/test_api_surface_inventory.py')
|
|
with open(DOC, encoding='utf-8') as fh:
|
|
committed = fh.read()
|
|
|
|
committed_lines = {line[3:-1] for line in committed.splitlines()
|
|
if line.startswith('- `') and line.endswith('`')}
|
|
measured_lines = set(measured)
|
|
|
|
added = sorted(measured_lines - committed_lines)
|
|
removed = sorted(committed_lines - measured_lines)
|
|
assert not added and not removed, (
|
|
'API surface changed but docs/API_SURFACE.md was not regenerated.\n'
|
|
+ (f' new operations: {added[:20]}\n' if added else '')
|
|
+ (f' gone operations: {removed[:20]}\n' if removed else '')
|
|
+ 'If intentional, regenerate in this commit: '
|
|
'SERVERKIT_UPDATE_API_SURFACE=1 pytest '
|
|
'tests/test_api_surface_inventory.py — the diff is the review.')
|