Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/SUPPORT_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ The TypeScript and Python validators apply the same rules; `tests/unit/validator
| JSON schema (3.0 nullable, 3.1 / 3.2 JSON Schema 2020-12, formats) | ✅ | | |
| local / bundled `$ref` | ✅ | the snapshot is bundled | |
| response headers | ✅ | required ones present; documented ones match their schema (integer / number / boolean coerced, arrays comma-separated, style simple); `Content-Type` ignored as OpenAPI says | 5 ✔ |
| no body for `204` / `205` / `304`, `HEAD`, responses without `content` | ✅ | a body that arrives anyway is `unexpected-body` | 5 ✔ |
| no body for `204` / `205` / `304`, `HEAD` | ✅ | a body that arrives anyway is `unexpected-body` (RFC 9110) | 5 ✔ |
| a response without `content` | ✅ | means "not described", not "no body": a body is accepted and not checked, with a note and `MISSING_RESPONSE_SCHEMA` | 5 ✔ |
| empty body where a schema is documented | ✅ | `empty-body` (JSON `null` is a body) | 5 ✔ |
| text media types with a schema | ✅ | the text is validated as a string | 5 ✔ |
| other media types (XML, binary, …) | ⛔ | not checked: a note in the test report (Playwright annotation `contract-note`, pytest warning) and `UNSUPPORTED_MEDIA_TYPE` in `coverage-gaps.json` | 5 ✔ (gap) |
Expand Down
12 changes: 8 additions & 4 deletions src/contract/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,15 @@ def validate(self, method: str, path: str, status: int, content_type: str | None
raw = raw_text if raw_text is not None else ("" if body is _UNPARSEABLE else (body if isinstance(body, str) else json.dumps(body)))
content = resp.get("content") or {}
head = method.lower() == "head"
if not content or status in _NO_BODY_STATUS or head:
# No body is documented (or the status / method has none): one that arrives anyway breaks the contract.
# 204 / 205 / 304 and HEAD never carry a body (RFC 9110): one that arrives breaks the contract.
if status in _NO_BODY_STATUS or head:
if raw.strip() and not head:
why = f"status {code} has no body" if status in _NO_BODY_STATUS else f"no content is documented for {matched}"
return False, "unexpected-body", [f"A body was returned but {why} ({len(raw)} bytes)"]
return False, "unexpected-body", [f"A body was returned but status {code} has no body ({len(raw)} bytes)"]
return True, None, []
# No content documented means "not described", not "no body": the body is not checked, and that is said.
if not content:
if raw.strip():
self.notes.append(f"{matched}: no content documented, body not checked ({len(raw)} bytes)")
return True, None, []
actual = (content_type or "").split(";")[0].strip().lower()
# Compare without parameters: "application/json; v=1.0" documents "application/json".
Expand Down
12 changes: 8 additions & 4 deletions src/contract/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ export class ContractValidator {
const raw = input.rawText ?? (typeof input.body === 'string' ? input.body : input.body === undefined ? '' : JSON.stringify(input.body));
const content = resp.node?.content;
const head = input.method.toLowerCase() === 'head';
if (!content || !Object.keys(content).length || NO_BODY_STATUS.has(input.status) || head) {
// No body is documented (or the status / method has none): one that arrives anyway breaks the contract.
// 204 / 205 / 304 and HEAD never carry a body (RFC 9110): one that arrives breaks the contract.
if (NO_BODY_STATUS.has(input.status) || head) {
if (raw.trim().length && !head) {
const why = NO_BODY_STATUS.has(input.status) ? `status ${code} has no body` : `no content is documented for ${matched}`;
return { ok: false, bodyChecked: true, kind: 'unexpected-body', matchedStatus: matched, errors: [`A body was returned but ${why} (${raw.length} bytes)`] };
return { ok: false, bodyChecked: true, kind: 'unexpected-body', matchedStatus: matched, errors: [`A body was returned but status ${code} has no body (${raw.length} bytes)`] };
}
return { ok: true, bodyChecked: false, errors: [], matchedStatus: matched };
}
// No content documented means "not described", not "no body": the body is not checked, and that is said.
if (!content || !Object.keys(content).length) {
if (raw.trim().length) notes.push(`${matched}: no content documented, body not checked (${raw.length} bytes)`);
return { ok: true, bodyChecked: false, errors: [], matchedStatus: matched, ...(notes.length ? { notes } : {}) };
}

Expand Down
9 changes: 7 additions & 2 deletions tests/unit/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const doc = {
default: { description: 'error', content: { 'application/json': { schema: { type: 'object', required: ['message'], properties: { message: { type: 'string' } } } } } },
},
},
delete: { responses: { '204': { description: 'gone' }, '4XX': { description: 'client error' } } },
delete: { responses: { '204': { description: 'gone' }, '205': { description: 'reset' }, '4XX': { description: 'client error' } } },
head: { responses: { '200': { description: 'ok', content: { 'application/json': { schema: { type: 'object' } } } } } },
},
'/text': { get: { responses: { '200': { description: 'ok', content: { 'text/plain': { schema: { type: 'string', pattern: '^OK' } } } } } } },
Expand Down Expand Up @@ -64,7 +64,8 @@ const CASES: [string, ResponseInput, boolean, string | undefined][] = [
['empty body where a schema is documented', { method: 'get', path: '/users', status: 200, contentType: 'application/json', body: null, rawText: '', headers: okHeaders }, false, 'empty-body'],
['204 without a body', { method: 'delete', path: '/users', status: 204, contentType: null, body: '', rawText: '' }, true, undefined],
['204 with a body', { method: 'delete', path: '/users', status: 204, ...json({ deleted: true }) }, false, 'unexpected-body'],
['4XX range without content, body returned', { method: 'delete', path: '/users', status: 409, ...json({ message: 'conflict' }) }, false, 'unexpected-body'],
['no content documented is "not described": a body is accepted, not checked', { method: 'delete', path: '/users', status: 409, ...json({ message: 'conflict' }) }, true, undefined],
['205 with a body', { method: 'delete', path: '/users', status: 205, ...json({ reset: true }) }, false, 'unexpected-body'],
['HEAD: no body is expected', { method: 'head', path: '/users', status: 200, contentType: 'application/json', body: '', rawText: '' }, true, undefined],
['text/plain matches its string schema', { method: 'get', path: '/text', status: 200, contentType: 'text/plain', body: 'OK fine', rawText: 'OK fine' }, true, undefined],
['text/plain breaks its pattern', { method: 'get', path: '/text', status: 200, contentType: 'text/plain', body: 'bad', rawText: 'bad' }, false, 'schema'],
Expand All @@ -87,6 +88,10 @@ describe('stage 5: response contract', () => {
});
}

it('a body under a response without content is accepted with a note', () => {
expect(v.validateResponse({ method: 'delete', path: '/users', status: 409, ...json({ message: 'conflict' }) }).notes).toEqual(['4XX: no content documented, body not checked (22 bytes)']);
});

it('notes say what was not checked', () => {
expect(v.validateResponse(CASES.find((c) => c[0].startsWith('xml'))![1]).notes).toEqual(['200 application/xml: media type not supported by the validator, body not checked']);
expect(v.validateResponse(CASES.find((c) => c[0].startsWith('json without'))![1]).notes).toEqual(['200 application/json: no schema documented, body not checked']);
Expand Down
Loading