diff --git a/src/validator.ts b/src/validator.ts index 602fc6a..93cb8e2 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -12,8 +12,11 @@ function checkType(value: unknown, expectedType: string): boolean { case "string": return typeof value === "string"; case "number": + return typeof value === "number" && Number.isFinite(value); case "integer": - return typeof value === "number"; + return typeof value === "number" && Number.isInteger(value); + case "null": + return value === null; case "boolean": return typeof value === "boolean"; case "array": @@ -128,7 +131,7 @@ export async function validateRequest( const rb = matched.operation.requestBody; const contentType = req.headers.get("content-type") || ""; - if (rb.required && !req.body && contentType === "") { + if (rb.required && !req.body) { violations.push({ issue: "Request body is required but missing", method, @@ -136,6 +139,7 @@ export async function validateRequest( timestamp: now(), type: "request", }); + return violations; } // Check Content-Type matches one of the expected media types @@ -164,12 +168,31 @@ export async function validateRequest( ) { try { const cloned = req.clone(); - const body = (await cloned.json()) as Record; + const body: unknown = await cloned.json(); const { schema } = rb.content["application/json"]; if (!schema) { return violations; } - violations.push(...validateBody(body, schema, method, pathname)); + if (schema.type && !checkType(body, schema.type)) { + violations.push({ + issue: `Request body expected type '${schema.type}'`, + method, + path: pathname, + timestamp: now(), + type: "request", + }); + return violations; + } + if (typeof body === "object" && body !== null && !Array.isArray(body)) { + violations.push( + ...validateBody( + body as Record, + schema, + method, + pathname + ) + ); + } } catch { violations.push({ issue: "Request body is not valid JSON", @@ -197,12 +220,14 @@ export function validateResponse( const violations: Violation[] = []; const method = req.method.toUpperCase(); const statusCode = String(res.status); + const statusRange = `${statusCode[0]}XX`; // Check that the status code is defined in the spec const definedStatuses = Object.keys(matched.operation.responses); if ( !( definedStatuses.includes(statusCode) || + definedStatuses.includes(statusRange) || definedStatuses.includes("default") ) ) { @@ -218,6 +243,7 @@ export function validateResponse( // Check response Content-Type if spec defines content for this status const responseSpec = matched.operation.responses[statusCode] || + matched.operation.responses[statusRange] || matched.operation.responses.default; if (responseSpec?.content) { const contentType = res.headers.get("content-type") || ""; diff --git a/tests/regression.test.ts b/tests/regression.test.ts new file mode 100644 index 0000000..5321ae9 --- /dev/null +++ b/tests/regression.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import type { MatchedOperation, SchemaObject } from "../src/types.js"; +import { validateRequest, validateResponse } from "../src/validator.js"; + +function operation(schema: SchemaObject): MatchedOperation { + return { + operation: { + requestBody: { + content: { "application/json": { schema } }, + required: true, + }, + responses: { "200": { description: "OK" } }, + }, + pathParams: {}, + pathTemplate: "/items", + }; +} +function request(body?: string): Request { + return new Request("https://example.com/items", { + body, + headers: { "content-type": "application/json" }, + method: "POST", + }); +} + +it("rejects fractional integers and nonfinite JSON numbers", async () => { + const matched = operation({ + properties: { count: { type: "integer" }, price: { type: "number" } }, + type: "object", + }); + const violations = await validateRequest( + request('{"count":1.5,"price":1e400}'), + matched, + "/items" + ); + assert.equal(violations.length, 2); + assert.equal( + ( + await validateRequest( + request('{"count":2,"price":3.5}'), + matched, + "/items" + ) + ).length, + 0 + ); +}); +it("reports required bodies missing even when Content-Type is present", async () => { + const violations = await validateRequest( + request(), + operation({ type: "object" }), + "/items" + ); + assert.ok( + violations.some((v) => v.issue === "Request body is required but missing") + ); +}); +it("validates the top-level JSON type without misclassifying valid JSON", async () => { + const wrongObject = await validateRequest( + request('"text"'), + operation({ type: "object" }), + "/items" + ); + assert.equal(wrongObject.length, 1); + assert.ok(wrongObject[0].issue.includes("expected type")); + const wrongArray = await validateRequest( + request("{}"), + operation({ type: "array" }), + "/items" + ); + assert.equal(wrongArray.length, 1); + assert.equal( + ( + await validateRequest( + request('"text"'), + operation({ type: "string" }), + "/items" + ) + ).length, + 0 + ); + assert.equal( + ( + await validateRequest( + request("null"), + operation({ type: "null" }), + "/items" + ) + ).length, + 0 + ); +}); +it("uses response ranges with exact-code then range then default precedence", async () => { + const req = request("{}"); + const matched = operation({ type: "object" }); + matched.operation.responses = { + "2XX": { content: { "application/json": {} }, description: "Success" }, + default: { content: { "text/plain": {} }, description: "Fallback" }, + }; + const response = new Response("{}", { + headers: { "content-type": "application/json" }, + status: 201, + }); + assert.equal( + (await validateResponse(req, response, matched, "/items")).length, + 0 + ); + const wrongMedia = new Response("text", { + headers: { "content-type": "text/plain" }, + status: 202, + }); + assert.equal( + (await validateResponse(req, wrongMedia, matched, "/items")).length, + 1 + ); + matched.operation.responses["201"] = { + content: { "text/plain": {} }, + description: "Exact", + }; + assert.equal( + (await validateResponse(req, response, matched, "/items")).length, + 1 + ); +});