Skip to content

Commit e6fb532

Browse files
authored
Merge pull request #1653 from pmcelhaney/copilot/add-request-validation-feature
Add request validation against OpenAPI spec
2 parents e4b7a7c + a54abb3 commit e6fb532

File tree

11 files changed

+297
-2
lines changed

11 files changed

+297
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"counterfact": minor
3+
---
4+
5+
Add request validation against the OpenAPI spec. Incoming requests are now validated by default — missing required query parameters, missing required headers, and request bodies that do not match the declared schema all result in a 400 response with a descriptive error message. Validation can be disabled with the `--no-validate-request` CLI flag.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ npx counterfact@latest [openapi.yaml] [destination] [options]
205205
| `--spec <path>` | Path or URL to the OpenAPI document |
206206
| `--proxy-url <url>` | Forward all requests to this URL by default |
207207
| `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
208+
| `--no-validate-request` | Disable request validation against the OpenAPI spec |
208209

209210
Run `npx counterfact@latest --help` for the full list of options.
210211

bin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ npx counterfact@latest openapi.yaml ./api [options]
4141
| `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
4242
| `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
4343
| `--no-update-check` | Disable the npm update check on startup |
44+
| `--no-validate-request` | Disable request validation against the OpenAPI spec |
4445

4546
Run `npx counterfact@latest --help` to see the full option list.

bin/counterfact.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ async function main(source, destination) {
300300
startRepl: options.repl,
301301
startServer: options.serve,
302302
buildCache: options.buildCache || false,
303+
validateRequests: options.validateRequest !== false,
303304

304305
watch: {
305306
routes: options.watch || options.watchRoutes,
@@ -499,5 +500,9 @@ program
499500
"path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)",
500501
)
501502
.option("--no-update-check", "disable the npm update check on startup")
503+
.option(
504+
"--no-validate-request",
505+
"disable request validation against the OpenAPI spec",
506+
)
502507
.action(main)
503508
.parse(process.argv);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"@apidevtools/json-schema-ref-parser": "13.0.5",
127127
"@hapi/accept": "6.0.3",
128128
"@types/json-schema": "7.0.15",
129+
"ajv": "6.14.0",
129130
"chokidar": "5.0.0",
130131
"commander": "14.0.3",
131132
"debug": "4.4.3",

src/counterfact-types/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,25 @@ type HttpStatusCode =
255255
interface OpenApiParameters {
256256
in: "body" | "cookie" | "formData" | "header" | "path" | "query";
257257
name: string;
258+
required?: boolean;
258259
schema?: {
259-
type: string;
260+
[key: string]: unknown;
261+
type?: string;
260262
};
261263
type?: "string" | "number" | "integer" | "boolean";
262264
}
263265

264266
interface OpenApiOperation {
265267
parameters?: OpenApiParameters[];
266268
produces?: string[];
269+
requestBody?: {
270+
content?: {
271+
[mediaType: string]: {
272+
schema: { [key: string]: unknown };
273+
};
274+
};
275+
required?: boolean;
276+
};
267277
responses: {
268278
[status: string]: {
269279
content?: {

src/server/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface Config {
1616
startAdminApi: boolean;
1717
startRepl: boolean;
1818
startServer: boolean;
19+
validateRequests: boolean;
1920
watch: {
2021
routes: boolean;
2122
types: boolean;

src/server/dispatcher.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Registry,
1111
} from "./registry.js";
1212
import { createResponseBuilder } from "./response-builder.js";
13+
import { validateRequest } from "./request-validator.js";
1314
import { Tools } from "./tools.js";
1415
import type {
1516
OpenApiOperation,
@@ -297,6 +298,18 @@ export class Dispatcher {
297298

298299
const operation = this.operationForPathAndMethod(matchedPath, method);
299300

301+
if (this.config?.validateRequests !== false) {
302+
const validation = validateRequest(operation, { body, headers, query });
303+
304+
if (!validation.valid) {
305+
return {
306+
body: `Request validation failed:\n${validation.errors.join("\n")}`,
307+
contentType: "text/plain",
308+
status: 400,
309+
};
310+
}
311+
}
312+
300313
const continuousDistribution = (min: number, max: number) => {
301314
return min + Math.random() * (max - min);
302315
};

src/server/request-validator.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import Ajv from "ajv";
2+
3+
import type { OpenApiOperation } from "../counterfact-types/index.js";
4+
5+
const ajv = new Ajv({
6+
allErrors: true,
7+
unknownFormats: "ignore",
8+
coerceTypes: false,
9+
});
10+
11+
export interface ValidationResult {
12+
errors: string[];
13+
valid: boolean;
14+
}
15+
16+
function findMissingRequired(
17+
parameters: NonNullable<OpenApiOperation["parameters"]>,
18+
location: string,
19+
values: Record<string, string>,
20+
): string[] {
21+
return parameters
22+
.filter((p) => p.in === location && p.required === true)
23+
.filter((p) => !(p.name in values) || values[p.name] === undefined)
24+
.map((p) => `${location} parameter '${p.name}' is required`);
25+
}
26+
27+
export function validateRequest(
28+
operation: OpenApiOperation | undefined,
29+
request: {
30+
body: unknown;
31+
headers: Record<string, string>;
32+
query: Record<string, string>;
33+
},
34+
): ValidationResult {
35+
if (!operation) {
36+
return { errors: [], valid: true };
37+
}
38+
39+
const errors: string[] = [];
40+
const parameters = operation.parameters ?? [];
41+
42+
// For query and header parameters, HTTP always delivers values as strings.
43+
// Only check that required parameters are present; type coercion is handled
44+
// by the registry before the route handler is called.
45+
errors.push(...findMissingRequired(parameters, "query", request.query));
46+
errors.push(...findMissingRequired(parameters, "header", request.headers));
47+
48+
// Validate request body (OpenAPI 3.x requestBody)
49+
if (operation.requestBody?.content !== undefined) {
50+
const schema =
51+
operation.requestBody.content["application/json"]?.schema ??
52+
operation.requestBody.content["application/x-www-form-urlencoded"]
53+
?.schema;
54+
55+
if (schema !== undefined) {
56+
const valid = ajv.validate(schema, request.body);
57+
58+
if (!valid && ajv.errors) {
59+
for (const error of ajv.errors) {
60+
const path =
61+
(error as { instancePath?: string }).instancePath ??
62+
error.dataPath ??
63+
"";
64+
65+
errors.push(`body${path} ${error.message ?? "is invalid"}`);
66+
}
67+
}
68+
} else if (operation.requestBody.required === true && !request.body) {
69+
errors.push("body is required");
70+
}
71+
}
72+
73+
// Validate request body (OpenAPI 2.x body parameter)
74+
const bodyParam = parameters.find((p) => p.in === "body");
75+
76+
if (bodyParam?.schema !== undefined) {
77+
const valid = ajv.validate(bodyParam.schema, request.body);
78+
79+
if (!valid && ajv.errors) {
80+
for (const error of ajv.errors) {
81+
const path =
82+
(error as { instancePath?: string }).instancePath ??
83+
error.dataPath ??
84+
"";
85+
86+
errors.push(`body${path} ${error.message ?? "is invalid"}`);
87+
}
88+
}
89+
}
90+
91+
return {
92+
errors,
93+
valid: errors.length === 0,
94+
};
95+
}

test/server/dispatcher.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,4 +1186,167 @@ describe("given a request that contains the differently cased path", () => {
11861186

11871187
expect(response.body).toBe("ok");
11881188
});
1189+
1190+
describe("request validation", () => {
1191+
const openApiDocument: OpenApiDocument = {
1192+
paths: {
1193+
"/widgets": {
1194+
post: {
1195+
parameters: [
1196+
{ in: "query", name: "required-query", required: true },
1197+
{ in: "query", name: "optional-query" },
1198+
{ in: "header", name: "x-required-header", required: true },
1199+
],
1200+
requestBody: {
1201+
content: {
1202+
"application/json": {
1203+
schema: {
1204+
properties: {
1205+
name: { type: "string" },
1206+
},
1207+
required: ["name"],
1208+
type: "object",
1209+
},
1210+
},
1211+
},
1212+
},
1213+
responses: {
1214+
200: {
1215+
content: { "text/plain": { schema: { type: "string" } } },
1216+
},
1217+
},
1218+
},
1219+
},
1220+
},
1221+
};
1222+
1223+
function makeDispatcher(validateRequests: boolean) {
1224+
const registry = new Registry();
1225+
1226+
registry.add("/widgets", {
1227+
POST() {
1228+
return { body: "ok", status: 200 };
1229+
},
1230+
});
1231+
1232+
return new Dispatcher(registry, new ContextRegistry(), openApiDocument, {
1233+
adminApiToken: "",
1234+
alwaysFakeOptionals: false,
1235+
basePath: "/",
1236+
buildCache: false,
1237+
generate: { routes: false, types: false },
1238+
openApiPath: "",
1239+
port: 3100,
1240+
proxyPaths: new Map(),
1241+
proxyUrl: "",
1242+
routePrefix: "",
1243+
startAdminApi: false,
1244+
startRepl: false,
1245+
startServer: true,
1246+
validateRequests,
1247+
watch: { routes: false, types: false },
1248+
});
1249+
}
1250+
1251+
it("returns 400 when a required query parameter is missing", async () => {
1252+
const dispatcher = makeDispatcher(true);
1253+
1254+
const response = await dispatcher.request({
1255+
body: { name: "sprocket" },
1256+
headers: { "x-required-header": "yes" },
1257+
method: "POST",
1258+
path: "/widgets",
1259+
query: {},
1260+
req: { path: "/widgets" },
1261+
});
1262+
1263+
expect(response.status).toBe(400);
1264+
expect(response.body).toContain("required-query");
1265+
});
1266+
1267+
it("returns 400 when a required header is missing", async () => {
1268+
const dispatcher = makeDispatcher(true);
1269+
1270+
const response = await dispatcher.request({
1271+
body: { name: "sprocket" },
1272+
headers: {},
1273+
method: "POST",
1274+
path: "/widgets",
1275+
query: { "required-query": "yes" },
1276+
req: { path: "/widgets" },
1277+
});
1278+
1279+
expect(response.status).toBe(400);
1280+
expect(response.body).toContain("x-required-header");
1281+
});
1282+
1283+
it("returns 400 when the request body is missing a required field", async () => {
1284+
const dispatcher = makeDispatcher(true);
1285+
1286+
const response = await dispatcher.request({
1287+
body: {},
1288+
headers: { "x-required-header": "yes" },
1289+
method: "POST",
1290+
path: "/widgets",
1291+
query: { "required-query": "yes" },
1292+
req: { path: "/widgets" },
1293+
});
1294+
1295+
expect(response.status).toBe(400);
1296+
expect(response.body).toContain("name");
1297+
});
1298+
1299+
it("returns 200 when all required parameters and body are valid", async () => {
1300+
const dispatcher = makeDispatcher(true);
1301+
1302+
const response = await dispatcher.request({
1303+
body: { name: "sprocket" },
1304+
headers: { "x-required-header": "yes" },
1305+
method: "POST",
1306+
path: "/widgets",
1307+
query: { "required-query": "yes" },
1308+
req: { path: "/widgets" },
1309+
});
1310+
1311+
expect(response.status).toBe(200);
1312+
});
1313+
1314+
it("skips validation when validateRequests is false", async () => {
1315+
const dispatcher = makeDispatcher(false);
1316+
1317+
const response = await dispatcher.request({
1318+
body: {},
1319+
headers: {},
1320+
method: "POST",
1321+
path: "/widgets",
1322+
query: {},
1323+
req: { path: "/widgets" },
1324+
});
1325+
1326+
expect(response.status).toBe(200);
1327+
});
1328+
1329+
it("skips validation when there is no OpenAPI document", async () => {
1330+
const registry = new Registry();
1331+
1332+
registry.add("/widgets", {
1333+
POST() {
1334+
return { body: "ok", status: 200 };
1335+
},
1336+
});
1337+
1338+
const dispatcher = new Dispatcher(registry, new ContextRegistry());
1339+
1340+
const response = await dispatcher.request({
1341+
body: {},
1342+
headers: {},
1343+
method: "POST",
1344+
path: "/widgets",
1345+
query: {},
1346+
req: { path: "/widgets" },
1347+
});
1348+
1349+
expect(response.status).toBe(200);
1350+
});
1351+
});
11891352
});

0 commit comments

Comments
 (0)