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 @@ -191,6 +191,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 @@ -304,6 +304,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
30 changes: 9 additions & 21 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import fs, { rm } from "node:fs/promises";
import nodePath from "node:path";

import { dereference } from "@apidevtools/json-schema-ref-parser";
import createDebug from "debug";
import { createHttpTerminator, type HttpTerminator } from "http-terminator";

import { startRepl as startReplServer } from "./repl/repl.js";
import type { Config } from "./server/config.js";
import { ContextRegistry } from "./server/context-registry.js";
import { createKoaApp } from "./server/create-koa-app.js";
import {
Dispatcher,
type DispatcherRequest,
type OpenApiDocument,
} from "./server/dispatcher.js";
import { Dispatcher, type DispatcherRequest } from "./server/dispatcher.js";
import { koaMiddleware } from "./server/koa-middleware.js";
import { loadOpenApiDocument } from "./server/load-openapi-document.js";
import { ModuleLoader } from "./server/module-loader.js";
import { OpenApiWatcher } from "./server/openapi-watcher.js";
import { Registry } from "./server/registry.js";
import { Transpiler } from "./server/transpiler.js";
import { CodeGenerator } from "./typescript-generator/code-generator.js";
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";

const debug = createDebug("counterfact:app");
export { loadOpenApiDocument } from "./server/load-openapi-document.js";

type MswHandlerMap = {
[key: string]: (request: MockRequest) => Promise<unknown>;
Expand All @@ -39,19 +35,6 @@ const allowedMethods = [

export type MockRequest = DispatcherRequest & { rawPath: string };

export async function loadOpenApiDocument(source: string) {
try {
return (await dereference(source)) as OpenApiDocument;
} catch (error) {
debug("could not load OpenAPI document from %s: %o", source, error);
const details = error instanceof Error ? error.message : String(error);
throw new Error(
`Could not load the OpenAPI spec from "${source}".\n${details}`,
{ cause: error },
);
}
}

const mswHandlers: MswHandlerMap = {};

export async function handleMswRequest(request: MockRequest) {
Expand Down Expand Up @@ -165,6 +148,8 @@ export async function counterfact(config: Config) {

const koaApp = createKoaApp(registry, middleware, config, contextRegistry);

const openApiWatcher = new OpenApiWatcher(config.openApiPath, dispatcher);

async function start(options: Config) {
const { generate, startServer, watch, buildCache } = options;

Expand All @@ -179,6 +164,8 @@ export async function counterfact(config: Config) {
let httpTerminator: HttpTerminator | undefined;

if (startServer) {
await openApiWatcher.watch();

if (!nativeTs) {
await transpiler.watch();
}
Expand All @@ -203,6 +190,7 @@ export async function counterfact(config: Config) {
await codeGenerator.stopWatching();
await transpiler.stopWatching();
await moduleLoader.stopWatching();
await openApiWatcher.stopWatching();
await httpTerminator?.terminate();
},
};
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
18 changes: 18 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,23 @@ export class Dispatcher {
};
}

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

if (!validation.valid) {
return {
...normalizedResponse,
appendedHeaders: [
...(normalizedResponse.appendedHeaders ?? []),
...validation.errors.map((error): [string, string] => [
"response-type-error",
error,
]),
],
};
}
}

return normalizedResponse;
}
}
6 changes: 6 additions & 0 deletions src/server/koa-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ export function koaMiddleware(
}
}

if (response.appendedHeaders) {
for (const [key, value] of response.appendedHeaders) {
ctx.res.appendHeader(key, value);
}
}

ctx.status = response.status ?? HTTP_STATUS_CODE_OK;

return undefined;
Expand Down
19 changes: 19 additions & 0 deletions src/server/load-openapi-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { dereference } from "@apidevtools/json-schema-ref-parser";
import createDebug from "debug";

import type { OpenApiDocument } from "./dispatcher.js";

const debug = createDebug("counterfact:server:load-openapi-document");

export async function loadOpenApiDocument(source: string) {
try {
return (await dereference(source)) as OpenApiDocument;
} catch (error) {
debug("could not load OpenAPI document from %s: %o", source, error);
const details = error instanceof Error ? error.message : String(error);
throw new Error(
`Could not load the OpenAPI spec from "${source}".\n${details}`,
{ cause: error },
);
}
}
54 changes: 54 additions & 0 deletions src/server/openapi-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type FSWatcher, watch } from "chokidar";
import createDebug from "debug";

import { waitForEvent } from "../util/wait-for-event.js";
import { CHOKIDAR_OPTIONS } from "./constants.js";
import type { Dispatcher } from "./dispatcher.js";
import { loadOpenApiDocument } from "./load-openapi-document.js";

const debug = createDebug("counterfact:server:openapi-watcher");

export class OpenApiWatcher {
private readonly openApiPath: string;

private readonly dispatcher: Dispatcher;

private watcher: FSWatcher | undefined;

public constructor(openApiPath: string, dispatcher: Dispatcher) {
this.openApiPath = openApiPath;
this.dispatcher = dispatcher;
}

public async watch(): Promise<void> {
if (this.openApiPath === "_" || this.openApiPath.startsWith("http")) {
return;
}

this.watcher = watch(this.openApiPath, CHOKIDAR_OPTIONS).on(
"change",
() => {
void (async () => {
try {
this.dispatcher.openApiDocument = await loadOpenApiDocument(
this.openApiPath,
);
debug("reloaded OpenAPI document from %s", this.openApiPath);
} catch (error: unknown) {
debug(
"failed to reload OpenAPI document from %s: %o",
this.openApiPath,
error,
);
}
})();
},
);

await waitForEvent(this.watcher, "ready");
}

public async stopWatching(): Promise<void> {
await this.watcher?.close();
}
}
1 change: 1 addition & 0 deletions src/server/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ interface Module {
}

type CounterfactResponseObject = {
appendedHeaders?: [string, string][];
body?: Uint8Array | string;
content?: {
body: unknown;
Expand Down
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;
}
Loading
Loading