Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/giant-drinks-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"counterfact": minor
---

Validate response headers at runtime and report type errors as `response-type-error` HTTP headers (one per error, multiple headers with the same name). Use --no-validate-response to disable.
5 changes: 5 additions & 0 deletions bin/counterfact.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,12 @@
// helper.ts is imported via .js extension — the TypeScript convention used
// throughout this codebase. If the runtime resolves helper.js → helper.ts,
// it is fully capable of running the TypeScript source tree.
fs.writeFileSync(

Check warning on line 116 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found writeFileSync from package "node:fs" with non literal argument at index 0
nodePath.join(dir, "helper.ts"),
'export const value: string = "ok";\n',
"utf8",
);
fs.writeFileSync(

Check warning on line 121 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found writeFileSync from package "node:fs" with non literal argument at index 0
nodePath.join(dir, "main.ts"),
'import { value } from "./helper.js"; export default value;\n',
"utf8",
Expand Down Expand Up @@ -293,7 +293,7 @@
const optionSource = program.getOptionValueSource(key);

if (optionSource !== "cli") {
options[key] = value;

Check warning on line 296 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -327,7 +327,7 @@
)
) {
for (const action of actions) {
options[action] = true;

Check warning on line 330 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -377,6 +377,7 @@
startServer: options.serve,
buildCache: options.buildCache || false,
validateRequests: options.validateRequest !== false,
validateResponses: options.validateResponse !== false,

watch: {
routes: options.watch || options.watchRoutes,
Expand All @@ -402,15 +403,15 @@
let didMigrate = false;
let didMigrateRouteTypes;

if (fs.existsSync(nodePath.join(config.basePath, "paths"))) {

Check warning on line 406 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found existsSync from package "node:fs" with non literal argument at index 0
await pathsToRoutes(config.basePath);
await fs.promises.rmdir(nodePath.join(config.basePath, "paths"), {

Check warning on line 408 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});
await fs.promises.rmdir(nodePath.join(config.basePath, "path-types"), {

Check warning on line 411 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});
await fs.promises.rmdir(nodePath.join(config.basePath, "components"), {

Check warning on line 414 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});

Expand Down Expand Up @@ -584,6 +585,10 @@
"--no-validate-request",
"disable request validation against the OpenAPI spec",
)
.option(
"--no-validate-response",
"disable response validation against the OpenAPI spec",
)
.option(
"--config <path>",
"path to a counterfact.yaml config file (default: counterfact.yaml in the current directory)",
Expand Down
6 changes: 6 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ Yes, by default. Requests that don't match the schema defined in the spec return

---

## Does it validate outgoing responses?

Yes, by default. Response headers are validated against the schema defined in the spec. Any validation errors (missing required headers or type mismatches) are reported as `response-type-error` HTTP response headers — one header per error; multiple headers with the same name are allowed. The response body is still returned normally — the errors are advisory only. Disable this with `--no-validate-response` if you need looser behavior.

---

## What OpenAPI versions are supported?

OpenAPI 3.x. Swagger 2 (OAS2) is not currently supported.
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ npx counterfact@latest [spec] [output] [options]
| `--proxy-url <url>` | _(none)_ | Default upstream for the proxy |
| `--prefix <path>` | _(none)_ | Global path prefix (e.g. `/api/v1`) |
| `--no-validate-request` | `false` | Skip OpenAPI request validation |
| `--no-validate-response` | `false` | Skip OpenAPI response header validation |
| `--generate-types` | `false` | Generate types only |
| `--generate-routes` | `false` | Generate routes only |
| `--watch-types` | `false` | Watch and regenerate types only |
Expand Down
1 change: 1 addition & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface Config {
startRepl: boolean;
startServer: boolean;
validateRequests: boolean;
validateResponses: boolean;
watch: {
routes: boolean;
types: boolean;
Expand Down
15 changes: 15 additions & 0 deletions src/server/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from "./registry.js";
import { createResponseBuilder } from "./response-builder.js";
import { validateRequest } from "./request-validator.js";
import { validateResponse } from "./response-validator.js";
import { Tools } from "./tools.js";
import type {
OpenApiOperation,
Expand Down Expand Up @@ -384,6 +385,20 @@ export class Dispatcher {
};
}

if (this.config?.validateResponses !== false) {
const validation = validateResponse(operation, normalizedResponse);

if (!validation.valid) {
return {
...normalizedResponse,
headers: {
...normalizedResponse.headers,
"response-type-error": validation.errors,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, one header per error.

},
};
}
}

return normalizedResponse;
}
}
96 changes: 96 additions & 0 deletions src/server/response-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Ajv from "ajv";

import type { OpenApiOperation } from "../counterfact-types/index.js";
import type { CounterfactResponseObject } from "./registry.js";

const ajv = new Ajv({
allErrors: true,
strict: false,
coerceTypes: false,
});

export interface ResponseValidationResult {
errors: string[];
valid: boolean;
}

export function validateResponse(
operation: OpenApiOperation | undefined,
response: CounterfactResponseObject,
): ResponseValidationResult {
if (!operation) {
return { errors: [], valid: true };
}

const errors: string[] = [];

const statusKey =
response.status !== undefined ? String(response.status) : undefined;

const responseSpec =
(statusKey !== undefined ? operation.responses[statusKey] : undefined) ??
operation.responses.default;

if (!responseSpec) {
return { errors: [], valid: true };
}

const specHeaders = responseSpec.headers ?? {};
const actualHeaders = response.headers ?? {};

for (const [name, headerSpec] of Object.entries(specHeaders)) {
const actualValue =
actualHeaders[name] ?? actualHeaders[name.toLowerCase()];

if (headerSpec.required === true && actualValue === undefined) {
errors.push(`response header '${name}' is required`);
continue;
}

if (actualValue !== undefined && headerSpec.schema !== undefined) {
const coercedValue =
typeof actualValue === "string"
? coerceHeaderValue(actualValue, headerSpec.schema)
: actualValue;

const valid = ajv.validate(headerSpec.schema, coercedValue);

if (!valid && ajv.errors) {
for (const error of ajv.errors) {
const path = error.instancePath ?? "";

errors.push(
`response header '${name}'${path} ${error.message ?? "is invalid"}`,
);
}
}
}
}

return {
errors,
valid: errors.length === 0,
};
}

function coerceHeaderValue(
value: string,
schema: { [key: string]: unknown },
): unknown {
const type = schema.type as string | undefined;

if (type === "integer" || type === "number") {
const num = Number(value);

return Number.isNaN(num) ? value : num;
}

if (type === "boolean") {
if (value === "true") return true;
if (value === "false") return false;

return value;
}

return value;
}
136 changes: 135 additions & 1 deletion test/server/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,7 +1220,10 @@ describe("given a request that contains the differently cased path", () => {
},
};

function makeDispatcher(validateRequests: boolean) {
function makeDispatcher(
validateRequests: boolean,
validateResponses = true,
) {
const registry = new Registry();

registry.add("/widgets", {
Expand All @@ -1244,6 +1247,7 @@ describe("given a request that contains the differently cased path", () => {
startRepl: false,
startServer: true,
validateRequests,
validateResponses,
watch: { routes: false, types: false },
});
}
Expand Down Expand Up @@ -1349,4 +1353,134 @@ describe("given a request that contains the differently cased path", () => {
expect(response.status).toBe(200);
});
});

describe("response validation", () => {
const openApiDocument: OpenApiDocument = {
paths: {
"/widgets": {
get: {
responses: {
200: {
content: { "text/plain": { schema: { type: "string" } } },
headers: {
"x-required-header": {
required: true,
schema: { type: "string" },
},
"x-count": {
required: false,
schema: { type: "integer" },
},
},
},
},
},
},
},
};

function makeResponseDispatcher(
validateResponses: boolean,
handlerHeaders: Record<string, string> = {},
) {
const registry = new Registry();

registry.add("/widgets", {
GET() {
return { body: "ok", headers: handlerHeaders, status: 200 };
},
});

return new Dispatcher(registry, new ContextRegistry(), openApiDocument, {
adminApiToken: "",
alwaysFakeOptionals: false,
basePath: "/",
buildCache: false,
generate: { routes: false, types: false },
openApiPath: "",
port: 3100,
proxyPaths: new Map(),
proxyUrl: "",
routePrefix: "",
startAdminApi: false,
startRepl: false,
startServer: true,
validateRequests: false,
validateResponses,
watch: { routes: false, types: false },
});
}

it("adds response-type-error headers when a required response header is missing", async () => {
const dispatcher = makeResponseDispatcher(true);

const response = await dispatcher.request({
body: "",
headers: {},
method: "GET",
path: "/widgets",
query: {},
req: { path: "/widgets" },
});

const errors = response.headers?.["response-type-error"];
const firstError = Array.isArray(errors) ? errors[0] : errors;

expect(firstError).toContain("x-required-header");
});

it("adds response-type-error headers when a response header has the wrong type", async () => {
const dispatcher = makeResponseDispatcher(true, {
"x-required-header": "present",
"x-count": "not-a-number",
});

const response = await dispatcher.request({
body: "",
headers: {},
method: "GET",
path: "/widgets",
query: {},
req: { path: "/widgets" },
});

const errors = response.headers?.["response-type-error"];
const firstError = Array.isArray(errors) ? errors[0] : errors;

expect(firstError).toContain("x-count");
});

it("does not add error headers when all required response headers are present and valid", async () => {
const dispatcher = makeResponseDispatcher(true, {
"x-required-header": "present",
"x-count": "42",
});

const response = await dispatcher.request({
body: "",
headers: {},
method: "GET",
path: "/widgets",
query: {},
req: { path: "/widgets" },
});

expect(response.headers?.["response-type-error"]).toBeUndefined();
});

it("skips response validation when validateResponses is false", async () => {
const dispatcher = makeResponseDispatcher(false);

const response = await dispatcher.request({
body: "",
headers: {},
method: "GET",
path: "/widgets",
query: {},
req: { path: "/widgets" },
});

expect(response.headers?.["response-type-error"]).toBeUndefined();
});
});
});
2 changes: 2 additions & 0 deletions test/server/koa-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const CONFIG: Config = {
},
alwaysFakeOptionals: false,
buildCache: false,
validateRequests: true,
validateResponses: true,
};

const mockKoaProxy = (path: string, { target }: IBaseKoaProxiesOptions) =>
Expand Down
Loading
Loading