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
34 changes: 30 additions & 4 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -128,14 +131,15 @@ 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,
path: pathname,
timestamp: now(),
type: "request",
});
return violations;
}

// Check Content-Type matches one of the expected media types
Expand Down Expand Up @@ -164,12 +168,31 @@ export async function validateRequest(
) {
try {
const cloned = req.clone();
const body = (await cloned.json()) as Record<string, unknown>;
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<string, unknown>,
schema,
method,
pathname
)
);
}
} catch {
violations.push({
issue: "Request body is not valid JSON",
Expand Down Expand Up @@ -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")
)
) {
Expand All @@ -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") || "";
Expand Down
125 changes: 125 additions & 0 deletions tests/regression.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
Loading