Files
shopdb-flask/tests/test_api_inventory_parity.py

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)))