diff --git a/src/validator.ts b/src/validator.ts index 096c7b7..8b9b80e 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -208,7 +208,9 @@ export async function validateRequest( // Check Content-Type matches one of the expected media types const expectedTypes = Object.keys(rb.content); - const baseContentType = contentType.split(";")[0].trim().toLowerCase(); + const baseContentType = + contentType.split(";")[0].trim().toLowerCase() || + "application/octet-stream"; const selectedMediaType = selectMediaType(rb.content, baseContentType); if (baseContentType && expectedTypes.length > 0 && !selectedMediaType) { @@ -222,7 +224,11 @@ export async function validateRequest( } // Validate body structure for JSON - if (baseContentType === "application/json" && selectedMediaType?.schema) { + if ( + (baseContentType === "application/json" || + baseContentType.endsWith("+json")) && + selectedMediaType?.schema + ) { try { const cloned = req.clone(); const body: unknown = await cloned.json(); @@ -314,7 +320,9 @@ export function validateResponse( matched.operation.responses.default; if (responseSpec?.content) { const contentType = res.headers.get("content-type") || ""; - const baseContentType = contentType.split(";")[0].trim().toLowerCase(); + const baseContentType = + contentType.split(";")[0].trim().toLowerCase() || + (res.body ? "application/octet-stream" : ""); const expectedTypes = Object.keys(responseSpec.content); if ( diff --git a/tests/nov4-regression.test.ts b/tests/nov4-regression.test.ts new file mode 100644 index 0000000..decbf78 --- /dev/null +++ b/tests/nov4-regression.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import type { MatchedOperation } from "../src/types.js"; +import { validateRequest, validateResponse } from "../src/validator.js"; + +function target(media = "application/json"): MatchedOperation { + return { + operation: { + requestBody: { + content: { [media]: { schema: { required: ["id"], type: "object" } } }, + }, + responses: { "200": { content: { [media]: {} }, description: "OK" } }, + }, + pathParams: {}, + pathTemplate: "/items", + }; +} + +it("validates JSON syntax and fields for structured JSON media types", async () => { + await Promise.all( + ["{}", "not json"].map(async (body) => { + const req = new Request("https://example.com/items", { + body, + headers: { "content-type": "Application/Problem+JSON; charset=utf-8" }, + method: "POST", + }); + const violations = await validateRequest( + req, + target("application/problem+json"), + "/items" + ); + assert.equal(violations.length, 1); + assert.equal( + violations[0].issue, + body === "{}" + ? "Missing required field: id" + : "Request body is not valid JSON" + ); + }) + ); + const req = new Request("https://example.com/items", { + body: '{"id":1}', + headers: { "content-type": "application/problem+json" }, + method: "POST", + }); + assert.deepEqual( + await validateRequest(req, target("application/problem+json"), "/items"), + [] + ); +}); + +it("checks the effective media type of bodies without Content-Type", async () => { + const bytes = new TextEncoder().encode("{}"); + const req = new Request("https://example.com/items", { + body: bytes, + method: "POST", + }); + const res = new Response(bytes); + assert.equal(req.headers.has("content-type"), false); + assert.equal(res.headers.has("content-type"), false); + assert.equal((await validateRequest(req, target(), "/items")).length, 1); + assert.equal( + (await validateResponse(req, res, target(), "/items")).length, + 1 + ); + assert.deepEqual(await validateRequest(req, target("*/*"), "/items"), []); + assert.deepEqual( + await validateResponse(req, res, target("*/*"), "/items"), + [] + ); + assert.deepEqual( + await validateResponse(req, new Response(null), target(), "/items"), + [] + ); +});