From 267bde664233ea4d4778869ff6bd31df08e0c75c Mon Sep 17 00:00:00 2001 From: testomut Date: Mon, 9 Nov 2026 21:26:23 +0100 Subject: [PATCH] fix(contract): a response without content is not a response without a body Only 204 / 205 / 304 and HEAD never carry a body (RFC 9110). A response without documented content is "not described": its body is accepted and not checked, with a note (Playwright annotation / pytest warning) and MISSING_RESPONSE_SCHEMA in coverage-gaps.json. Found on Toolshop: 15 correct 422 rejections of POST /products and GET /product-specs/names failed as unexpected-body. Same rule in validator.ts and contract.py. Co-Authored-By: Claude Opus 5.5 --- docs/SUPPORT_MATRIX.md | 3 ++- src/contract/contract.py | 12 ++++++++---- src/contract/validator.ts | 12 ++++++++---- tests/unit/validator.test.ts | 9 +++++++-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/SUPPORT_MATRIX.md b/docs/SUPPORT_MATRIX.md index f6826d0..8cf62b9 100644 --- a/docs/SUPPORT_MATRIX.md +++ b/docs/SUPPORT_MATRIX.md @@ -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) | diff --git a/src/contract/contract.py b/src/contract/contract.py index 0228215..f3ae422 100644 --- a/src/contract/contract.py +++ b/src/contract/contract.py @@ -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". diff --git a/src/contract/validator.ts b/src/contract/validator.ts index 9d5da94..1a74db6 100644 --- a/src/contract/validator.ts +++ b/src/contract/validator.ts @@ -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 } : {}) }; } diff --git a/tests/unit/validator.test.ts b/tests/unit/validator.test.ts index e580388..4bffd69 100644 --- a/tests/unit/validator.test.ts +++ b/tests/unit/validator.test.ts @@ -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' } } } } } } }, @@ -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'], @@ -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']);