openapi: emit a spec a machine can actually use

The generated spec carried no `parameters` and no `requestBody` on any of its
372 operations. That is invalid OpenAPI 3.1, and the practical cost was worse
than the formal one: the MCP server builds its tools from this file, so every
tool had an empty input schema and silently dropped whatever the caller passed.
A request for one asset returned the list, and nothing anywhere reported an
error. All 118 templated paths now declare their path parameters, typed from the
Flask converter that named them, and write verbs declare a JSON body.

The body is an open object carrying the prose description rather than an
invented schema. The inventory describes bodies in sentences, and a field list
this generator guessed at would be worse than none - but "an object, described
here" is the difference between a client that can send a body and one that
cannot send anything.

Security was wrong on 123 operations. `jwt-optional` means "works logged out,
returns more logged in", which OpenAPI expresses as the empty requirement
alongside the scheme; publishing them as bearer-required told every reader that
a public endpoint needs a token.

Responses were one hardcoded 200, so a generated client had no idea a call could
fail. Every operation now documents the error envelope - and the envelope itself
is a defined schema, because its error nests under `data.error` rather than at
the top level, which is the single thing people get wrong when writing against
this API.

95 summaries were cut at 120 characters mid-word, which is what a tool picker
shows a user as the whole description of a call. They now end on a word.

Tests pin the shape rather than the prose. One of them contradicted an older
test that REQUIRED the contract version as a literal in PLUGIN-HOOKS.md - the
same copying that left nine documents stale - so that test now asserts the doc
points at the generated map instead.
This commit is contained in:
cproudlock
2026-08-14 15:56:27 -04:00
parent 05be4c4489
commit 36b0265668
4 changed files with 11728 additions and 618 deletions

123
tests/test_openapi_spec.py Normal file
View File

@@ -0,0 +1,123 @@
"""The generated spec has to be usable by a machine, not just readable.
docs/openapi.json is the only machine-readable description of this API, and it
is what the MCP server builds its tools from. It was published with no
`parameters` and no `requestBody` on any operation - invalid OpenAPI 3.1, and
worse in practice: a tool built from an operation with no parameters has nowhere
to put the id its caller supplied, so every argument was dropped in silence. A
call for one asset returned the list.
These tests are the floor. They check the spec's shape, not its prose.
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
SPEC = REPO / 'docs' / 'openapi.json'
pytestmark = pytest.mark.skipif(
not SPEC.is_file(),
reason='no generated spec in this checkout')
@pytest.fixture(scope='module')
def spec():
return json.loads(SPEC.read_text())
def operations(spec):
for path, item in spec['paths'].items():
for verb, operation in item.items():
yield path, verb, operation
def test_every_templated_path_declares_its_parameters(spec):
"""`/api/assets/{id}` with no parameters is not a description of anything."""
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if '{' in path and not operation.get('parameters')
]
assert not offenders, (
'These operations have a templated path and no parameters, so a '
'generated client cannot fill it in:\n ' + '\n '.join(offenders[:15]))
def test_declared_parameters_match_the_template(spec):
"""A parameter that is not in the path, or a placeholder with no parameter,
is the same defect wearing different clothes."""
offenders = []
for path, verb, operation in operations(spec):
intemplate = set(re.findall(r'\{([^}]+)\}', path))
declared = {p['name'] for p in operation.get('parameters', [])
if p.get('in') == 'path'}
if intemplate != declared:
offenders.append('%s %s: template %s, declared %s'
% (verb.upper(), path, sorted(intemplate), sorted(declared)))
assert not offenders, '\n '.join(offenders[:15])
def test_write_verbs_can_carry_a_body(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if verb in ('post', 'put') and 'requestBody' not in operation
]
assert not offenders, (
'These write operations declare no request body, so a client built '
'from this spec cannot send one:\n ' + '\n '.join(offenders[:15]))
def test_optional_auth_is_expressed_as_optional(spec):
"""An endpoint that works logged out must not be published as requiring a
token - that is a lie to every reader, and it is the majority case here."""
optional = [operation for _, _, operation in operations(spec)
if any(item == {} for item in operation.get('security') or [])]
assert len(optional) > 50, (
'The inventory documents over a hundred jwt-optional endpoints; the '
'spec should express them with an empty security requirement '
'alongside the scheme, not as bearer-required.')
def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if not operation.get('security') and '401' in operation.get('responses', {})
]
assert not offenders, '\n '.join(offenders[:15])
def test_every_operation_documents_failure(spec):
offenders = [
'%s %s' % (verb.upper(), path)
for path, verb, operation in operations(spec)
if 'default' not in operation.get('responses', {})
]
assert not offenders, (
'These operations document only success:\n ' + '\n '.join(offenders[:15]))
def test_the_envelopes_are_defined_and_referenced(spec):
schemas = spec.get('components', {}).get('schemas', {})
assert 'SuccessEnvelope' in schemas and 'ErrorEnvelope' in schemas
# The error shape is the one people get wrong: nested under data.
error = schemas['ErrorEnvelope']['properties']['data']['properties']['error']
assert set(error['required']) == {'code', 'message'}
def test_summaries_are_not_cut_mid_word(spec):
"""A truncated summary is what a tool picker shows as the whole description
of a call."""
offenders = []
for path, verb, operation in operations(spec):
summary = operation.get('summary', '')
if summary.endswith('...'):
body = summary[:-3]
if body and not body[-1].isalnum() and body[-1] not in ')]"\'':
offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:]))
assert not offenders, '\n '.join(offenders[:15])