96 lines
3.6 KiB
Python
96 lines
3.6 KiB
Python
"""Every route the app serves is described in docs/api-inventory.json.
|
|
|
|
The inventory is hand-maintained, and deliberately so: its value is the prose -
|
|
what each endpoint is FOR, what it accepts, a worked example - and none of that
|
|
can be derived from the code. An audit of all 372 documented operations found
|
|
zero phantom routes and zero wrong parameter names, so the maintenance itself is
|
|
in good order.
|
|
|
|
What it cannot do is notice a route somebody added. Twenty-two were serving
|
|
traffic undocumented, including an entire plugin's surface and every one of the
|
|
dashboard card endpoints - each of them added after its surface was written.
|
|
|
|
So the prose stays hand-written and the COVERAGE is enforced here. Adding an
|
|
endpoint now means describing it, in the same commit, which is when the author
|
|
still knows what it is for.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from shopdb import create_app
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
INVENTORY = REPO / 'docs' / 'api-inventory.json'
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not INVENTORY.is_file(),
|
|
reason='docs/ is excluded from publication; nothing to check here')
|
|
|
|
# Flask's own machinery, not this product's API.
|
|
EXEMPT_ENDPOINTS = {'static'}
|
|
|
|
# Documented as a family rather than per-file: these serve the frontend bundle
|
|
# and its assets, not JSON.
|
|
EXEMPT_RULE_PREFIXES = ('/static', '/assets')
|
|
|
|
|
|
def normalise(rule):
|
|
"""Flask `<int:id>` and inventory `<id>` describe the same path.
|
|
|
|
Converters are an implementation detail of the route, and the inventory was
|
|
written without them. Comparing on the converter would report a difference
|
|
that is not one.
|
|
"""
|
|
return re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'<\1>', rule)
|
|
|
|
|
|
def served_operations():
|
|
app = create_app('testing')
|
|
served = set()
|
|
for rule in app.url_map.iter_rules():
|
|
if rule.endpoint in EXEMPT_ENDPOINTS:
|
|
continue
|
|
if any(rule.rule.startswith(prefix) for prefix in EXEMPT_RULE_PREFIXES):
|
|
continue
|
|
if not rule.rule.startswith('/api'):
|
|
continue
|
|
for method in sorted((rule.methods or set()) - {'HEAD', 'OPTIONS'}):
|
|
served.add((method, normalise(rule.rule)))
|
|
return served
|
|
|
|
|
|
def documented_operations():
|
|
documented = set()
|
|
for surface in json.loads(INVENTORY.read_text()):
|
|
for entry in surface.get('endpoints', []):
|
|
path = normalise(entry.get('path') or '')
|
|
# The inventory writes multi-verb routes as 'PUT|PATCH'. Splitting
|
|
# on comma and slash alone left those undocumented in this check
|
|
# AND dropped from the generated spec entirely.
|
|
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
|
method = method.strip().upper()
|
|
if method:
|
|
documented.add((method, path))
|
|
return documented
|
|
|
|
|
|
def test_every_served_route_is_documented():
|
|
missing = sorted(served_operations() - documented_operations())
|
|
assert not missing, (
|
|
'%d route(s) serve traffic with no entry in docs/api-inventory.json. '
|
|
'Describe them there (purpose, auth, params, example) and regenerate '
|
|
'the spec with scripts/gen_openapi.py:\n %s'
|
|
% (len(missing), '\n '.join('%s %s' % row for row in missing)))
|
|
|
|
|
|
def test_nothing_documented_has_been_removed():
|
|
"""A documented route that no longer exists sends a reader to a 404."""
|
|
stale = sorted(documented_operations() - served_operations())
|
|
assert not stale, (
|
|
'%d documented route(s) are no longer served:\n %s'
|
|
% (len(stale), '\n '.join('%s %s' % row for row in stale)))
|