counterfact 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +2 -1
  2. package/dist/cli/run.js +32 -5
  3. package/dist/cli/telemetry.js +10 -4
  4. package/dist/migrate/update-route-types.js +1 -0
  5. package/dist/msw.js +1 -0
  6. package/dist/repl/repl.js +4 -0
  7. package/dist/server/counterfact-types/example.ts +5 -1
  8. package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
  9. package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
  10. package/dist/server/counterfact-types/response-builder.ts +5 -0
  11. package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
  12. package/dist/server/dispatcher.js +12 -1
  13. package/dist/server/json-to-xml.js +32 -7
  14. package/dist/server/module-loader.js +5 -0
  15. package/dist/server/openapi-document.js +5 -0
  16. package/dist/server/registry.js +22 -5
  17. package/dist/server/response-builder.js +27 -5
  18. package/dist/server/web-server/create-koa-app.js +3 -1
  19. package/dist/server/web-server/openapi-middleware.js +1 -0
  20. package/dist/server/web-server/routes-middleware.js +43 -1
  21. package/dist/typescript-generator/code-generator.js +13 -4
  22. package/dist/typescript-generator/coder.js +1 -1
  23. package/dist/typescript-generator/jsdoc.js +11 -7
  24. package/dist/typescript-generator/operation-coder.js +14 -0
  25. package/dist/typescript-generator/operation-type-coder.js +24 -4
  26. package/dist/typescript-generator/requirement.js +25 -3
  27. package/dist/typescript-generator/response-type-coder.js +20 -7
  28. package/dist/typescript-generator/schema-coder.js +2 -2
  29. package/dist/typescript-generator/schema-type-coder.js +16 -3
  30. package/dist/typescript-generator/specification.js +3 -1
  31. package/dist/typescript-generator/streaming-content-types.js +16 -0
  32. package/dist/typescript-generator/versions-ts-generator.js +25 -0
  33. package/package.json +24 -26
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br>
6
6
 
7
- ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen)
7
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen) ![Swagger 2.0](https://img.shields.io/badge/Swagger-2.0-85EA2D) ![OpenAPI 3.0-3.2](https://img.shields.io/badge/OpenAPI-3.x-6BA539)
8
8
 
9
9
  </div>
10
10
 
@@ -17,6 +17,7 @@ Mock servers make it easy to get started, but hard to keep going.<br>
17
17
  Counterfact is an API simulator without those limits.
18
18
 
19
19
  Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
20
+ Supports Swagger 2.0 and OpenAPI 3.0, 3.1, and 3.2.
20
21
  - Type-safe TypeScript handlers for every endpoint
21
22
  - Hot reloading as you edit
22
23
  - Shared state across routes
package/dist/cli/run.js CHANGED
@@ -13,7 +13,7 @@ import { pathResolve } from "../util/forward-slash-path.js";
13
13
  import { loadConfigFile } from "../util/load-config-file.js";
14
14
  import { createIntroduction } from "./banner.js";
15
15
  import { checkForUpdates } from "./check-for-updates.js";
16
- import { isTelemetryEnabled, sendTelemetry } from "./telemetry.js";
16
+ import { hashTelemetryLocation, isTelemetryEnabled, sendTelemetry, } from "./telemetry.js";
17
17
  const debug = createDebug("counterfact:cli:run");
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  const DEFAULT_PORT = 3100;
@@ -55,6 +55,35 @@ export function normalizeSpecOption(specOption) {
55
55
  }
56
56
  return undefined;
57
57
  }
58
+ export function buildStartupTelemetryProperties(options, source, version, specs) {
59
+ const apiSources = specs?.map((spec) => spec.source) ?? [source];
60
+ const apiFileLocationHashes = apiSources
61
+ .filter((apiSource) => apiSource !== "_")
62
+ .map((apiSource) => hashTelemetryLocation(apiSource));
63
+ return {
64
+ alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
65
+ apiFileLocationHashes,
66
+ buildCache: Boolean(options.buildCache),
67
+ generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
68
+ generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
69
+ mode: specs !== undefined
70
+ ? "multi-spec"
71
+ : source === "_"
72
+ ? "without-openapi"
73
+ : "single-spec",
74
+ openBrowser: Boolean(options.open),
75
+ port: options.port,
76
+ prune: Boolean(options.prune),
77
+ repl: Boolean(options.repl),
78
+ serve: Boolean(options.serve),
79
+ updateCheck: Boolean(options.updateCheck),
80
+ validateRequest: Boolean(options.validateRequest),
81
+ validateResponse: Boolean(options.validateResponse),
82
+ version,
83
+ watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
84
+ watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
85
+ };
86
+ }
58
87
  /**
59
88
  * Builds the Commander program with all CLI options and the action handler.
60
89
  * Factored out of `runCli` so it is easy to test or extend.
@@ -116,6 +145,7 @@ function buildProgram(version, taglines) {
116
145
  debug("options: %o", options);
117
146
  debug("source: %s", source);
118
147
  debug("destination: %s", destination);
148
+ const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
119
149
  const openBrowser = options.open;
120
150
  const url = `http://localhost:${options.port}${options.prefix}`;
121
151
  const guiUrl = `${url}/counterfact/`;
@@ -213,6 +243,7 @@ function buildProgram(version, taglines) {
213
243
  process.exit(1);
214
244
  }
215
245
  debug("started server");
246
+ sendTelemetry("counterfact_started", startupTelemetryProperties);
216
247
  await updateCheckPromise;
217
248
  if (config.startRepl) {
218
249
  startRepl();
@@ -300,10 +331,6 @@ export async function runCli(argv) {
300
331
  catch {
301
332
  taglines = ["counterfact — mock API server"];
302
333
  }
303
- // Fire telemetry once on startup — fire-and-forget, never blocks.
304
- if (isTelemetryEnabled()) {
305
- sendTelemetry(version);
306
- }
307
334
  debug("running counterfact CLI v%s", version);
308
335
  const program = buildProgram(version, taglines);
309
336
  await program.parseAsync(argv);
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { PostHog } from "posthog-node";
3
3
  const POSTHOG_API_KEY = "phc_msXmBxiL8FVugNMLCx9bnPQGqfEMqmyBjnVkKhHkN3m7";
4
4
  const POSTHOG_HOST = "https://us.i.posthog.com";
@@ -15,24 +15,30 @@ export function isTelemetryEnabled() {
15
15
  return false;
16
16
  return true;
17
17
  }
18
+ export function hashTelemetryLocation(location) {
19
+ return createHash("sha256").update(location).digest("hex");
20
+ }
18
21
  /**
19
22
  * Fires a telemetry event to PostHog. Fire-and-forget — never blocks
20
23
  * startup and never surfaces errors to the user.
21
24
  */
22
- export function sendTelemetry(version) {
25
+ export function sendTelemetry(event, properties = {}) {
26
+ if (!isTelemetryEnabled()) {
27
+ return;
28
+ }
23
29
  const telemetryKey = process.env["POSTHOG_API_KEY"] ?? POSTHOG_API_KEY;
24
30
  const telemetryHost = process.env["POSTHOG_HOST"] ?? POSTHOG_HOST;
25
31
  try {
26
32
  const posthog = new PostHog(telemetryKey, { host: telemetryHost });
27
33
  posthog.capture({
28
34
  distinctId: randomUUID(),
29
- event: "counterfact_started",
35
+ event,
30
36
  properties: {
31
- version,
32
37
  nodeVersion: process.version,
33
38
  platform: process.platform,
34
39
  arch: process.arch,
35
40
  source: "counterfact-cli",
41
+ ...properties,
36
42
  },
37
43
  });
38
44
  posthog.flush().catch(() => {
@@ -14,6 +14,7 @@ const HTTP_METHODS = [
14
14
  "PATCH",
15
15
  "HEAD",
16
16
  "OPTIONS",
17
+ "QUERY",
17
18
  ];
18
19
  // Pre-compile regex patterns derived from HTTP_METHODS
19
20
  const HTTP_METHOD_ALTERNATION = HTTP_METHODS.join("|");
package/dist/msw.js CHANGED
@@ -14,6 +14,7 @@ const allowedMethods = [
14
14
  "delete",
15
15
  "patch",
16
16
  "options",
17
+ "query",
17
18
  ];
18
19
  const mswHandlers = {};
19
20
  /**
package/dist/repl/repl.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import repl from "node:repl";
2
+ import { sendTelemetry } from "../cli/telemetry.js";
2
3
  import { RawHttpClient } from "./raw-http-client.js";
3
4
  import { createRouteFunction } from "./route-builder.js";
4
5
  function printToStdout(line) {
@@ -258,6 +259,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
258
259
  : undefined);
259
260
  replServer.defineCommand("counterfact", {
260
261
  action() {
262
+ sendTelemetry("repl_command_used", { command: "counterfact" });
261
263
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
262
264
  print("Except that it's connected to the running server, which you can access with the following globals:");
263
265
  print("");
@@ -274,6 +276,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
274
276
  });
275
277
  replServer.defineCommand("proxy", {
276
278
  action(text) {
279
+ sendTelemetry("repl_command_used", { command: "proxy" });
277
280
  if (text === "help" || text === "") {
278
281
  print(".proxy [on|off] - turn the proxy on/off at the root level");
279
282
  print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
@@ -313,6 +316,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
313
316
  : {};
314
317
  replServer.defineCommand("scenario", {
315
318
  async action(text) {
319
+ sendTelemetry("repl_command_used", { command: "scenario" });
316
320
  const trimmedText = text.trim();
317
321
  const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
318
322
  const usage = isMultiApi
@@ -2,9 +2,13 @@
2
2
  * Represents a named example defined in an OpenAPI document.
3
3
  * Examples can be referenced by route handlers via the `.example(name)` method
4
4
  * on the response builder.
5
+ *
6
+ * OpenAPI 3.2 adds `dataValue` as a structured alternative to `value`.
7
+ * When present, `dataValue` is preferred over `value`.
5
8
  */
6
9
  export interface Example {
10
+ dataValue?: unknown;
7
11
  description: string;
8
12
  summary: string;
9
- value: unknown;
13
+ value?: unknown;
10
14
  }
@@ -130,6 +130,10 @@ export type GenericResponseBuilderInner<
130
130
  : (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
131
131
  text: MaybeShortcut<["text/plain"], Response>;
132
132
  xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
133
+ stream: MaybeShortcut<
134
+ ["text/event-stream", "application/jsonl", "application/json-seq"],
135
+ Response
136
+ >;
133
137
  }>;
134
138
 
135
139
  /**
@@ -6,7 +6,14 @@
6
6
  */
7
7
  export interface OpenApiParameters {
8
8
  explode?: boolean;
9
- in: "body" | "cookie" | "formData" | "header" | "path" | "query";
9
+ in:
10
+ | "body"
11
+ | "cookie"
12
+ | "formData"
13
+ | "header"
14
+ | "path"
15
+ | "query"
16
+ | "querystring";
10
17
  name: string;
11
18
  required?: boolean;
12
19
  schema?: {
@@ -26,6 +26,11 @@ export interface ResponseBuilder {
26
26
  random: () => MaybePromise<ResponseBuilder>;
27
27
  randomLegacy: () => MaybePromise<ResponseBuilder>;
28
28
  status?: number;
29
+ stream: (iterable: AsyncIterable<unknown>) => {
30
+ body: AsyncIterable<unknown>;
31
+ contentType: string;
32
+ status?: number;
33
+ };
29
34
  text: (body: unknown) => ResponseBuilder;
30
35
  xml: (body: unknown) => ResponseBuilder;
31
36
  }
@@ -23,4 +23,5 @@ export interface WideResponseBuilder {
23
23
  random: () => MaybePromise<WideResponseBuilder>;
24
24
  text: (body: unknown) => WideResponseBuilder;
25
25
  xml: (body: unknown) => WideResponseBuilder;
26
+ stream: (body: AsyncIterable<unknown>) => WideResponseBuilder;
26
27
  }
@@ -142,6 +142,11 @@ export class Dispatcher {
142
142
  return types;
143
143
  }
144
144
  for (const parameter of parameters) {
145
+ // querystring parameters represent the entire query string as a single
146
+ // typed object; they are not individually coerced by name.
147
+ if (parameter.in === "querystring") {
148
+ continue;
149
+ }
145
150
  const type = parameter?.type ?? parameter?.schema?.type;
146
151
  if (type !== undefined) {
147
152
  types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
@@ -178,7 +183,11 @@ export class Dispatcher {
178
183
  if (pathItem === undefined) {
179
184
  return undefined;
180
185
  }
181
- const operation = pathItem[method.toLowerCase()];
186
+ const normalizedMethod = method.toLowerCase();
187
+ const operation = pathItem[normalizedMethod] ??
188
+ pathItem.additionalOperations?.[method] ??
189
+ pathItem.additionalOperations?.[method.toUpperCase()] ??
190
+ pathItem.additionalOperations?.[normalizedMethod];
182
191
  if (operation === undefined) {
183
192
  return undefined;
184
193
  }
@@ -335,6 +344,8 @@ export class Dispatcher {
335
344
  },
336
345
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
337
346
  query: processedQuery,
347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- typed by generated route types; entire query string as a single object
348
+ querystring: processedQuery,
338
349
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
339
350
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
340
351
  tools: new Tools({ headers }),
@@ -22,19 +22,44 @@ function xmlEscape(xmlString) {
22
22
  }
23
23
  });
24
24
  }
25
+ function resolveNodeType(schema) {
26
+ if (schema?.xml?.nodeType !== undefined) {
27
+ return schema.xml.nodeType;
28
+ }
29
+ if (schema?.xml?.attribute || schema?.attribute) {
30
+ return "attribute";
31
+ }
32
+ return "element";
33
+ }
25
34
  function objectToXml(json, schema, name) {
26
35
  const xml = [];
27
36
  const attributes = [];
28
37
  Object.entries(json).forEach(([key, value]) => {
29
38
  const properties = schema?.properties?.[key];
30
- if (properties?.attribute) {
31
- attributes.push(` ${key}="${xmlEscape(String(value))}"`);
32
- }
33
- else {
34
- xml.push(jsonToXml(value, properties, key));
39
+ const nodeType = resolveNodeType(properties);
40
+ const xmlName = properties?.xml?.name ?? key;
41
+ switch (nodeType) {
42
+ case "attribute": {
43
+ attributes.push(` ${xmlName}="${xmlEscape(String(value))}"`);
44
+ break;
45
+ }
46
+ case "text": {
47
+ xml.push(xmlEscape(String(value)));
48
+ break;
49
+ }
50
+ case "cdata": {
51
+ xml.push(`<![CDATA[${String(value)}]]>`);
52
+ break;
53
+ }
54
+ case "none": {
55
+ break;
56
+ }
57
+ default: {
58
+ xml.push(jsonToXml(value, properties, key));
59
+ }
35
60
  }
36
61
  });
37
- return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
62
+ return `<${name}${attributes.join("")}>${xml.join("")}</${name}>`;
38
63
  }
39
64
  /**
40
65
  * Converts a JSON value to an XML string using optional OpenAPI `xml` schema
@@ -52,7 +77,7 @@ export function jsonToXml(json, schema, keyName = "root") {
52
77
  const items = json
53
78
  .map((item) => jsonToXml(item, schema?.items, name))
54
79
  .join("");
55
- if (schema?.xml?.wrapped) {
80
+ if (schema?.xml?.wrapped || schema?.xml?.nodeType === "element") {
56
81
  return `<${name}>${items}</${name}>`;
57
82
  }
58
83
  return items;
@@ -11,6 +11,7 @@ import { FileDiscovery } from "./file-discovery.js";
11
11
  import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
12
12
  import { ModuleDependencyGraph } from "./module-dependency-graph.js";
13
13
  import { uncachedImport } from "./uncached-import.js";
14
+ import { sendTelemetry } from "../cli/telemetry.js";
14
15
  import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
15
16
  import { unescapePathForWindows } from "../util/windows-escape.js";
16
17
  const { uncachedRequire } = await import("./uncached-require.cjs");
@@ -74,6 +75,10 @@ export class ModuleLoader extends EventTarget {
74
75
  return;
75
76
  }
76
77
  const parts = nodePath.parse(pathName.replace(this.basePath, ""));
78
+ sendTelemetry("file_change_detected", {
79
+ changeType: eventName,
80
+ fileType: this.isContextFile(pathName) ? "context" : "route",
81
+ });
77
82
  const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
78
83
  if (eventName === "unlink") {
79
84
  this.registry.remove(url);
@@ -2,6 +2,7 @@ import { watch } from "chokidar";
2
2
  import createDebug from "debug";
3
3
  import { dereference } from "@apidevtools/json-schema-ref-parser";
4
4
  import { waitForEvent } from "../util/wait-for-event.js";
5
+ import { sendTelemetry } from "../cli/telemetry.js";
5
6
  import { CHOKIDAR_OPTIONS } from "./constants.js";
6
7
  const debug = createDebug("counterfact:server:openapi-document");
7
8
  /**
@@ -49,6 +50,10 @@ export class OpenApiDocument extends EventTarget {
49
50
  return;
50
51
  }
51
52
  this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
53
+ sendTelemetry("file_change_detected", {
54
+ changeType: "change",
55
+ fileType: "openapi",
56
+ });
52
57
  void (async () => {
53
58
  try {
54
59
  await this.load();
@@ -1,7 +1,7 @@
1
1
  import createDebugger from "debug";
2
2
  import { ModuleTree } from "./module-tree.js";
3
3
  const debug = createDebugger("counterfact:server:registry");
4
- const ALL_HTTP_METHODS = [
4
+ const DEFAULT_HTTP_METHODS = [
5
5
  "DELETE",
6
6
  "GET",
7
7
  "HEAD",
@@ -9,6 +9,7 @@ const ALL_HTTP_METHODS = [
9
9
  "PATCH",
10
10
  "POST",
11
11
  "PUT",
12
+ "QUERY",
12
13
  "TRACE",
13
14
  ];
14
15
  /**
@@ -60,6 +61,7 @@ function castParameters(parameters = {}, parameterTypes = new Map()) {
60
61
  export class Registry {
61
62
  moduleTree = new ModuleTree();
62
63
  middlewares = new Map();
64
+ methodNames = new Set(DEFAULT_HTTP_METHODS);
63
65
  constructor() {
64
66
  this.middlewares.set("", ($, respondTo) => respondTo($));
65
67
  }
@@ -75,6 +77,9 @@ export class Registry {
75
77
  */
76
78
  add(url, module) {
77
79
  this.moduleTree.add(url, module);
80
+ for (const methodName of Object.keys(module)) {
81
+ this.methodNames.add(methodName.toUpperCase());
82
+ }
78
83
  }
79
84
  /**
80
85
  * Registers a middleware function that wraps every handler under `url`.
@@ -102,8 +107,20 @@ export class Registry {
102
107
  * @param method - HTTP method (e.g. `"GET"`).
103
108
  * @param url - The request URL.
104
109
  */
110
+ methodFromModule(module, method) {
111
+ if (module === undefined) {
112
+ return undefined;
113
+ }
114
+ return (module[method] ??
115
+ module[method.toUpperCase()] ??
116
+ module[method.toLowerCase()]);
117
+ }
105
118
  exists(method, url) {
106
- return Boolean(this.handler(url, method).module?.[method]);
119
+ return (this.methodFromModule(this.handler(url, method).module, method) !==
120
+ undefined);
121
+ }
122
+ methodsForPath(url) {
123
+ return [...this.methodNames].filter((method) => this.methodFromModule(this.moduleTree.match(url, method)?.module, method) !== undefined);
107
124
  }
108
125
  /**
109
126
  * Finds the best-matching module and extracts path-variable bindings for a
@@ -133,7 +150,7 @@ export class Registry {
133
150
  * @param excludeMethod - The method to exclude from the check.
134
151
  */
135
152
  pathExistsWithAnyMethod(url, excludeMethod) {
136
- return ALL_HTTP_METHODS.filter((method) => method !== excludeMethod).some((method) => this.moduleTree.match(url, method) !== undefined);
153
+ return this.methodsForPath(url).some((method) => method.toUpperCase() !== excludeMethod.toUpperCase());
137
154
  }
138
155
  /**
139
156
  * Returns a comma-separated list of HTTP methods that have a registered
@@ -143,7 +160,7 @@ export class Registry {
143
160
  * @param url - The request URL.
144
161
  */
145
162
  allowedMethods(url) {
146
- return ALL_HTTP_METHODS.filter((method) => Boolean(this.moduleTree.match(url, method)?.module?.[method])).join(", ");
163
+ return this.methodsForPath(url).join(", ");
147
164
  }
148
165
  /**
149
166
  * Returns an async function that executes the registered handler for
@@ -169,7 +186,7 @@ export class Registry {
169
186
  status: 500,
170
187
  });
171
188
  }
172
- const execute = handler.module?.[httpRequestMethod];
189
+ const execute = this.methodFromModule(handler.module, httpRequestMethod);
173
190
  if (!execute) {
174
191
  debug(`Could not find a ${httpRequestMethod} method matching ${url}\n`);
175
192
  return () => ({
@@ -1,5 +1,6 @@
1
1
  import { generate } from "json-schema-faker";
2
2
  import { jsonToXml } from "./json-to-xml.js";
3
+ import { STREAMING_CONTENT_TYPES } from "../typescript-generator/streaming-content-types.js";
3
4
  const DEFAULT_GENERATE_OPTIONS = {
4
5
  useExamplesValue: true,
5
6
  minItems: 0,
@@ -154,10 +155,16 @@ export function createResponseBuilder(operation, config) {
154
155
  }
155
156
  return {
156
157
  ...this,
157
- content: Object.keys(content).map((type) => ({
158
- body: convertToXmlIfNecessary(type, content[type]?.examples?.[name]?.value, content[type]?.schema),
159
- type,
160
- })),
158
+ content: Object.keys(content).map((type) => {
159
+ const example = content[type]?.examples?.[name];
160
+ const body = example !== undefined && "dataValue" in example
161
+ ? example.dataValue
162
+ : example?.value;
163
+ return {
164
+ body: convertToXmlIfNecessary(type, body, content[type]?.schema),
165
+ type,
166
+ };
167
+ }),
161
168
  };
162
169
  },
163
170
  async random() {
@@ -188,7 +195,9 @@ export function createResponseBuilder(operation, config) {
188
195
  ...this,
189
196
  content: await Promise.all(Object.keys(content).map(async (type) => ({
190
197
  body: convertToXmlIfNecessary(type, content[type]?.examples
191
- ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
198
+ ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => "dataValue" in example
199
+ ? example.dataValue
200
+ : example.value))
192
201
  : await generate((content[type]?.schema ?? {
193
202
  type: "object",
194
203
  }), generateOptions), content[type]?.schema),
@@ -217,6 +226,19 @@ export function createResponseBuilder(operation, config) {
217
226
  })),
218
227
  };
219
228
  },
229
+ stream(iterable) {
230
+ const response = operation.responses[this.status ?? "default"] ??
231
+ operation.responses.default;
232
+ const contentTypes = Object.keys(response?.content ?? {});
233
+ const contentType = contentTypes.find((ct) => STREAMING_CONTENT_TYPES.has(ct)) ??
234
+ "text/event-stream";
235
+ return {
236
+ body: iterable,
237
+ contentType,
238
+ headers: this.headers,
239
+ status: this.status,
240
+ };
241
+ },
220
242
  status: Number.parseInt(statusCode, 10),
221
243
  text(body) {
222
244
  return this.match("text/plain", body);
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import createDebug from "debug";
2
3
  import Koa from "koa";
3
4
  import bodyParser from "koa-bodyparser";
@@ -53,7 +54,8 @@ export function createKoaApp({ runners, config, }) {
53
54
  if (ctx.body !== null &&
54
55
  ctx.body !== undefined &&
55
56
  typeof ctx.body === "object" &&
56
- !Buffer.isBuffer(ctx.body)) {
57
+ !Buffer.isBuffer(ctx.body) &&
58
+ !(ctx.body instanceof Readable)) {
57
59
  ctx.body = JSON.stringify(ctx.body, null, 2);
58
60
  ctx.type = "application/json";
59
61
  }
@@ -24,6 +24,7 @@ export function openapiMiddleware(pathPrefix, document) {
24
24
  const openApiDocument = (await bundle(document.path));
25
25
  openApiDocument.servers ??= [];
26
26
  openApiDocument.servers.unshift({
27
+ name: "Counterfact",
27
28
  description: "Counterfact",
28
29
  url: document.baseUrl,
29
30
  });
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import createDebug from "debug";
2
3
  import koaProxy from "koa-proxies";
3
4
  import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js";
@@ -19,6 +20,36 @@ const HEADERS_TO_DROP = new Set([
19
20
  "trailer",
20
21
  "trailers",
21
22
  ]);
23
+ /**
24
+ * SSE/JSONL/JSON-seq formatter map. Each entry maps a content-type to the
25
+ * function that serialises a single stream item into the wire format.
26
+ */
27
+ const STREAMING_FORMATTERS = {
28
+ "text/event-stream": (item) => `data: ${JSON.stringify(item)}\n\n`,
29
+ "application/json-seq": (item) => `\x1e${JSON.stringify(item)}\n`,
30
+ };
31
+ function defaultStreamFormatter(item) {
32
+ return `${JSON.stringify(item)}\n`;
33
+ }
34
+ function isAsyncIterable(value) {
35
+ return (value !== null &&
36
+ value !== undefined &&
37
+ typeof value === "object" &&
38
+ Symbol.asyncIterator in value);
39
+ }
40
+ /**
41
+ * Converts an `AsyncIterable` to a Node.js `Readable` stream, serialising
42
+ * each item according to the given content type.
43
+ */
44
+ function asyncIterableToReadable(iterable, contentType) {
45
+ const formatter = STREAMING_FORMATTERS[contentType] ?? defaultStreamFormatter;
46
+ async function* generate() {
47
+ for await (const item of iterable) {
48
+ yield formatter(item);
49
+ }
50
+ }
51
+ return Readable.from(generate());
52
+ }
22
53
  function addCors(ctx, allowedMethods, headers) {
23
54
  // Always append CORS headers, reflecting back the headers requested if any
24
55
  ctx.set("Access-Control-Allow-Origin", headers?.origin ?? "*");
@@ -92,7 +123,18 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
92
123
  rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
93
124
  req: { path: "", ...ctx.req },
94
125
  });
95
- ctx.body = response.body;
126
+ if (isAsyncIterable(response.body)) {
127
+ const contentType = response.contentType ?? "application/jsonl";
128
+ ctx.type = contentType;
129
+ ctx.body = asyncIterableToReadable(response.body, contentType);
130
+ if (contentType === "text/event-stream") {
131
+ ctx.set("Cache-Control", "no-cache");
132
+ ctx.set("X-Accel-Buffering", "no");
133
+ }
134
+ }
135
+ else {
136
+ ctx.body = response.body;
137
+ }
96
138
  if (response.contentType !== undefined &&
97
139
  response.contentType !== "unknown/unknown") {
98
140
  ctx.type = response.contentType;
@@ -109,13 +109,22 @@ export class CodeGenerator extends EventTarget {
109
109
  "patch",
110
110
  "trace",
111
111
  ]);
112
+ const operationMethodsForPath = (pathDefinition) => pathDefinition.flatMap((operation, requestMethod) => {
113
+ if (requestMethod === "additionalOperations") {
114
+ return operation.map((additionalOperation, additionalMethod) => [
115
+ additionalOperation,
116
+ additionalMethod.toLowerCase(),
117
+ ]);
118
+ }
119
+ if (!HTTP_VERBS.has(requestMethod)) {
120
+ return [];
121
+ }
122
+ return [[operation, requestMethod]];
123
+ });
112
124
  paths.forEach((pathDefinition, key) => {
113
125
  debug("processing path %s", key);
114
126
  const path = key === "/" ? "/index" : key;
115
- pathDefinition.forEach((operation, requestMethod) => {
116
- if (!HTTP_VERBS.has(requestMethod)) {
117
- return;
118
- }
127
+ operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
119
128
  repository
120
129
  .get(`routes${path}.ts`)
121
130
  .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
@@ -23,7 +23,7 @@ export class Coder {
23
23
  */
24
24
  get id() {
25
25
  if (this.requirement.isReference) {
26
- return `${this.constructor.name}@${this.requirement.data["$ref"]}`;
26
+ return `${this.constructor.name}@${this.requirement.refUrl}`;
27
27
  }
28
28
  return `${this.constructor.name}@${this.requirement.url}`;
29
29
  }
@@ -3,14 +3,18 @@
3
3
  * Returns an empty string if there is no relevant metadata.
4
4
  */
5
5
  export function buildJsDoc(data) {
6
+ if (typeof data !== "object" || data === null) {
7
+ return "";
8
+ }
9
+ const record = data;
6
10
  const lines = [];
7
- const description = data["description"];
8
- const summary = data["summary"];
9
- const example = data["example"];
10
- const examples = data["examples"];
11
- const defaultValue = data["default"];
12
- const format = data["format"];
13
- const deprecated = data["deprecated"];
11
+ const description = record["description"];
12
+ const summary = record["summary"];
13
+ const example = record["example"];
14
+ const examples = record["examples"];
15
+ const defaultValue = record["default"];
16
+ const format = record["format"];
17
+ const deprecated = record["deprecated"];
14
18
  const mainText = description ?? summary;
15
19
  if (mainText) {
16
20
  // Escape */ to prevent prematurely closing the JSDoc block
@@ -1,6 +1,7 @@
1
1
  import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
3
  import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
4
+ import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
4
5
  /**
5
6
  * Generates the default route handler stub for a single OpenAPI operation.
6
7
  *
@@ -33,6 +34,19 @@ export class OperationCoder extends Coder {
33
34
  !("content" in firstResponse || "schema" in firstResponse)) {
34
35
  return `async ($) => {
35
36
  return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].empty();
37
+ }`;
38
+ }
39
+ // Detect streaming responses (OpenAPI 3.2 itemSchema)
40
+ const content = firstResponse.content;
41
+ const hasStreamingContent = content !== undefined &&
42
+ Object.keys(content).some((ct) => STREAMING_CONTENT_TYPES.has(ct) &&
43
+ content[ct]?.itemSchema !== undefined);
44
+ if (hasStreamingContent) {
45
+ return `async ($) => {
46
+ async function* items() {
47
+ // yield items here
48
+ }
49
+ return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].stream(items());
36
50
  }`;
37
51
  }
38
52
  return `async ($) => {
@@ -7,6 +7,7 @@ import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
7
7
  import { RESERVED_WORDS } from "./reserved-words.js";
8
8
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
9
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
10
+ import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
10
11
  import { TypeCoder } from "./type-coder.js";
11
12
  import { Requirement } from "./requirement.js";
12
13
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
@@ -105,11 +106,23 @@ export class OperationTypeCoder extends TypeCoder {
105
106
  ? "number | undefined"
106
107
  : Number.parseInt(responseCode, 10);
107
108
  if (response.has("content")) {
108
- return response.get("content").map((content, contentType) => `{
109
+ return response.get("content").map((content, contentType) => {
110
+ let bodyType;
111
+ if (content.has("itemSchema") &&
112
+ STREAMING_CONTENT_TYPES.has(contentType)) {
113
+ bodyType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
114
+ }
115
+ else {
116
+ bodyType = content.has("schema")
117
+ ? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
118
+ : "unknown";
119
+ }
120
+ return `{
109
121
  status: ${status},
110
122
  contentType?: "${contentType}",
111
- body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
112
- }`);
123
+ body?: ${bodyType}
124
+ }`;
125
+ });
113
126
  }
114
127
  if (response.has("schema")) {
115
128
  const producesReq = this.requirement?.get("produces") ??
@@ -224,8 +237,15 @@ export class OperationTypeCoder extends TypeCoder {
224
237
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
225
238
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
226
239
  const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
240
+ // OpenAPI 3.2 querystring parameter: the entire query string treated as a
241
+ // single typed object (similar to requestBody for query strings).
242
+ const querystringParam = parameters?.find((parameter) => parameter.get("in")?.data === "querystring");
243
+ const querystringType = querystringParam?.has("schema") === true
244
+ ? new SchemaTypeCoder(querystringParam.get("schema"), this.version).write(script)
245
+ : "never";
246
+ const querystringTypeName = this.exportParameterType(script, "querystring", querystringType, baseName, modulePath);
227
247
  const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
228
- return `OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
248
+ return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
229
249
  }
230
250
  writeCode(script) {
231
251
  script.comments = READ_ONLY_COMMENTS;
@@ -26,7 +26,19 @@ export class Requirement {
26
26
  }
27
27
  /** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
28
28
  get isReference() {
29
- return this.data["$ref"] !== undefined;
29
+ return (typeof this.data === "object" &&
30
+ this.data !== null &&
31
+ this.data["$ref"] !== undefined);
32
+ }
33
+ /**
34
+ * When this node is a JSON Reference, returns the raw `$ref` URL string.
35
+ * Returns `undefined` for non-reference (inline) nodes.
36
+ */
37
+ get refUrl() {
38
+ if (typeof this.data !== "object" || this.data === null) {
39
+ return undefined;
40
+ }
41
+ return this.data["$ref"];
30
42
  }
31
43
  /**
32
44
  * Resolves the `$ref` and returns the target {@link Requirement}.
@@ -34,7 +46,7 @@ export class Requirement {
34
46
  * @throws When `isReference` is `false` or the specification is not set.
35
47
  */
36
48
  reference() {
37
- return this.specification.getRequirement(this.data["$ref"]);
49
+ return this.specification.getRequirement(this.refUrl);
38
50
  }
39
51
  /**
40
52
  * Returns `true` when this node has a child property named `item`.
@@ -47,6 +59,9 @@ export class Requirement {
47
59
  if (this.isReference) {
48
60
  return this.reference().has(item);
49
61
  }
62
+ if (typeof this.data !== "object" || this.data === null) {
63
+ return false;
64
+ }
50
65
  return item in this.data;
51
66
  }
52
67
  /**
@@ -62,7 +77,11 @@ export class Requirement {
62
77
  if (!this.has(key)) {
63
78
  return undefined;
64
79
  }
65
- const child = new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
80
+ if (typeof this.data !== "object" || this.data === null) {
81
+ return undefined;
82
+ }
83
+ const objectData = this.data;
84
+ const child = new Requirement(objectData[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
66
85
  child.parent = this;
67
86
  return child;
68
87
  }
@@ -108,6 +127,9 @@ export class Requirement {
108
127
  * @param callback - Called for each child with `(child, key)`.
109
128
  */
110
129
  forEach(callback) {
130
+ if (typeof this.data !== "object" || this.data === null) {
131
+ return;
132
+ }
111
133
  Object.keys(this.data).forEach((key) => {
112
134
  callback(this.select(this.escapeJsonPointer(key)), key);
113
135
  });
@@ -1,5 +1,6 @@
1
1
  import { printObject } from "./printers.js";
2
2
  import { SchemaTypeCoder } from "./schema-type-coder.js";
3
+ import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
3
4
  import { TypeCoder } from "./type-coder.js";
4
5
  import { pathJoin } from "../util/forward-slash-path.js";
5
6
  export class ResponseTypeCoder extends TypeCoder {
@@ -9,18 +10,30 @@ export class ResponseTypeCoder extends TypeCoder {
9
10
  this.openApi2MediaTypes = openApi2MediaTypes;
10
11
  }
11
12
  names() {
12
- return super.names(this.requirement.data["$ref"].split("/").at(-1));
13
+ return super.names(this.requirement.refUrl.split("/").at(-1));
13
14
  }
14
15
  buildContentObjectType(script, response) {
15
16
  if (response.has("content")) {
16
17
  return response
17
18
  .get("content")
18
- .map((content, mediaType) => [
19
- mediaType,
20
- `{
21
- schema: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
19
+ .map((content, mediaType) => {
20
+ let schemaType;
21
+ if (content.has("itemSchema") &&
22
+ STREAMING_CONTENT_TYPES.has(mediaType)) {
23
+ schemaType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
24
+ }
25
+ else {
26
+ schemaType = content.has("schema")
27
+ ? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
28
+ : "unknown";
29
+ }
30
+ return [
31
+ mediaType,
32
+ `{
33
+ schema: ${schemaType}
22
34
  }`,
23
- ]);
35
+ ];
36
+ });
24
37
  }
25
38
  return this.openApi2MediaTypes.map((mediaType) => [
26
39
  mediaType,
@@ -78,7 +91,7 @@ export class ResponseTypeCoder extends TypeCoder {
78
91
  return printObject(exampleNames.map((name) => [name, "unknown"]));
79
92
  }
80
93
  modulePath() {
81
- return pathJoin("types", this.version, this.requirement.data["$ref"] + ".ts");
94
+ return pathJoin("types", this.version, this.requirement.refUrl + ".ts");
82
95
  }
83
96
  writeCode(script) {
84
97
  return `{
@@ -8,7 +8,7 @@ function scrubSchema(schema) {
8
8
  }
9
9
  export class SchemaCoder extends Coder {
10
10
  names() {
11
- return super.names(`${this.requirement.data["$ref"].split("/").at(-1)}Schema`);
11
+ return super.names(`${this.requirement.refUrl.split("/").at(-1)}Schema`);
12
12
  }
13
13
  objectSchema(script) {
14
14
  const { properties, required } = this.requirement.data;
@@ -34,7 +34,7 @@ export class SchemaCoder extends Coder {
34
34
  return script.importExternalType("JSONSchema6", "json-schema");
35
35
  }
36
36
  modulePath() {
37
- return `types/${this.requirement.data["$ref"]}.ts`;
37
+ return `types/${this.requirement.refUrl}.ts`;
38
38
  }
39
39
  writeCode(script) {
40
40
  const { type } = this.requirement.data;
@@ -1,9 +1,10 @@
1
1
  import { buildJsDoc } from "./jsdoc.js";
2
2
  import { TypeCoder } from "./type-coder.js";
3
+ import { Requirement } from "./requirement.js";
3
4
  import { pathJoin } from "../util/forward-slash-path.js";
4
5
  export class SchemaTypeCoder extends TypeCoder {
5
6
  names() {
6
- return super.names(this.requirement.data["$ref"]?.split("/").at(-1));
7
+ return super.names(this.requirement.refUrl?.split("/").at(-1));
7
8
  }
8
9
  jsdoc() {
9
10
  return buildJsDoc(this.requirement.data);
@@ -82,6 +83,14 @@ export class SchemaTypeCoder extends TypeCoder {
82
83
  const key = matchingKey();
83
84
  const items = (allOf ?? anyOf ?? oneOf);
84
85
  const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index), this.version).write(script));
86
+ // Include the default schema from discriminator.defaultMapping (OpenAPI 3.2)
87
+ if (!allOf) {
88
+ const { discriminator } = this.requirement.data;
89
+ if (discriminator?.defaultMapping) {
90
+ const defaultRequirement = new Requirement({ $ref: discriminator.defaultMapping }, "", this.requirement.specification);
91
+ types.push(new SchemaTypeCoder(defaultRequirement, this.version).write(script));
92
+ }
93
+ }
85
94
  return types.join(allOf ? " & " : " | ");
86
95
  }
87
96
  writeEnum(_script, requirement) {
@@ -90,10 +99,14 @@ export class SchemaTypeCoder extends TypeCoder {
90
99
  .join(" | ");
91
100
  }
92
101
  modulePath() {
93
- return pathJoin("types", this.version, this.requirement.data["$ref"].replace(/^#\//u, "") + ".ts");
102
+ return pathJoin("types", this.version, this.requirement.refUrl.replace(/^#\//u, "") + ".ts");
94
103
  }
95
104
  writeCode(script) {
96
- const { allOf, anyOf, oneOf, type, format } = this.requirement.data;
105
+ const { allOf, anyOf, oneOf, type, format, itemSchema } = this.requirement
106
+ .data;
107
+ if (itemSchema) {
108
+ return `AsyncIterable<${new SchemaTypeCoder(this.requirement.get("itemSchema"), this.version).write(script)}>`;
109
+ }
97
110
  if (allOf ?? anyOf ?? oneOf) {
98
111
  return this.writeGroup(script, { allOf, anyOf, oneOf });
99
112
  }
@@ -49,7 +49,9 @@ export class Specification {
49
49
  */
50
50
  async load(urlOrPath) {
51
51
  try {
52
- this.rootRequirement = new Requirement((await bundle(urlOrPath)), urlOrPath, this);
52
+ this.rootRequirement = new Requirement((await bundle(urlOrPath, {
53
+ resolve: { http: { safeUrlResolver: false } },
54
+ })), urlOrPath, this);
53
55
  }
54
56
  catch (error) {
55
57
  const details = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Content types that represent sequential/streaming media in OpenAPI 3.2.
3
+ *
4
+ * When a Media Type Object uses `itemSchema` together with one of these content
5
+ * types, the generated TypeScript body type is `AsyncIterable<T>` rather than
6
+ * a plain schema type. On the server side, returning an `AsyncIterable` for
7
+ * one of these content types causes Counterfact to stream each item in the
8
+ * appropriate wire format.
9
+ */
10
+ export const STREAMING_CONTENT_TYPES = new Set([
11
+ "text/event-stream",
12
+ "application/jsonl",
13
+ "application/x-ndjson",
14
+ "application/ndjson",
15
+ "application/json-seq",
16
+ ]);
@@ -30,6 +30,7 @@ export async function generateVersionsTsContent(versions) {
30
30
  const source = [
31
31
  "// This file is auto-generated by Counterfact. Do not edit.",
32
32
  "",
33
+ '/** Union of all version strings declared for this API group (e.g. `"v1" | "v2" | "v3"`). */',
33
34
  `export type Versions = ${versionsUnion};`,
34
35
  "",
35
36
  "/**",
@@ -42,6 +43,30 @@ export async function generateVersionsTsContent(versions) {
42
43
  "",
43
44
  "type VersionMap = Partial<Record<Versions, object>>;",
44
45
  "",
46
+ "/**",
47
+ " * The type of the `$` argument in a versioned route handler.",
48
+ " *",
49
+ " * @typeParam T - A map from version string to the `$`-arg type for that version",
50
+ " * (e.g. `{ v1: $v1Type; v2: $v2Type }`). Generated by Counterfact from the spec.",
51
+ " * @typeParam V - The union of currently active version keys; defaults to all keys of `T`.",
52
+ " *",
53
+ " * An instance of `Versioned<T, V>` exposes:",
54
+ " * - All properties of the intersection `T[V]` (request data, response builders, etc.)",
55
+ ' * - `version` — the version string for the current request (e.g. `"v2"`).',
56
+ " * - `minVersion(min)` — type predicate that returns `true` when the current version",
57
+ " * is at or after `min` in the declared version order and narrows `$` accordingly.",
58
+ " *",
59
+ " * @example",
60
+ " * ```ts",
61
+ " * export const GET: HTTP_GET = ($) => {",
62
+ ' * if ($.minVersion("v2")) {',
63
+ " * // $ is now typed as the v2+ arg — v2-only fields are available",
64
+ " * return $.response[200].json({ id: $.path.id, extra: $.body.extra });",
65
+ " * }",
66
+ " * return $.response[200].json({ id: $.path.id });",
67
+ " * };",
68
+ " * ```",
69
+ " */",
45
70
  "export type Versioned<",
46
71
  " T extends VersionMap,",
47
72
  " V extends keyof T & Versions = keyof T & Versions,",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
5
5
  "type": "module",
6
6
  "main": "./dist/app.js",
@@ -78,17 +78,16 @@
78
78
  "go:petstore2": "yarn build && yarn counterfact https://petstore.swagger.io/v2/swagger.json out",
79
79
  "go:example": "yarn build && node ./bin/counterfact.js ./test/fixtures/openapi/example.yaml out",
80
80
  "go:multiple-apis": "yarn build && node ./bin/counterfact.js --config ./test/fixtures/config/multiple-apis.yaml",
81
- "counterfact": "./bin/counterfact.js",
82
- "postinstall": "patch-package"
81
+ "counterfact": "./bin/counterfact.js"
83
82
  },
84
83
  "devDependencies": {
85
- "@changesets/cli": "2.30.0",
84
+ "@changesets/cli": "2.31.0",
86
85
  "@eslint/js": "10.0.1",
87
- "@jest/globals": "^30.3.0",
88
- "@swc/core": "1.15.24",
86
+ "@jest/globals": "30.4.1",
87
+ "@swc/core": "1.15.33",
89
88
  "@swc/jest": "0.2.39",
90
89
  "@testing-library/dom": "10.4.1",
91
- "@types/debug": "^4.1.12",
90
+ "@types/debug": "4.1.13",
92
91
  "@types/jest": "30.0.0",
93
92
  "@types/js-yaml": "4.0.9",
94
93
  "@types/koa": "3.0.2",
@@ -96,26 +95,26 @@
96
95
  "@types/koa-proxy": "1.0.8",
97
96
  "@types/koa-static": "4.0.4",
98
97
  "@types/node": "22",
99
- "@typescript-eslint/eslint-plugin": "^8.58.0",
100
- "@typescript-eslint/parser": "^8.58.0",
98
+ "@typescript-eslint/eslint-plugin": "8.59.3",
99
+ "@typescript-eslint/parser": "8.59.3",
101
100
  "copyfiles": "2.4.1",
102
- "eslint": "10.2.0",
101
+ "eslint": "10.3.0",
103
102
  "eslint-formatter-github-annotations": "0.1.0",
104
103
  "eslint-import-resolver-typescript": "4.4.4",
105
104
  "eslint-plugin-etc": "2.0.3",
106
105
  "eslint-plugin-file-progress": "4.0.0",
107
106
  "eslint-plugin-import": "2.32.0",
108
- "eslint-plugin-jest": "29.15.1",
107
+ "eslint-plugin-jest": "29.15.2",
109
108
  "eslint-plugin-jest-dom": "5.5.0",
110
- "eslint-plugin-n": "^17.24.0",
109
+ "eslint-plugin-n": "18.0.1",
111
110
  "eslint-plugin-no-explicit-type-exports": "0.12.1",
112
111
  "eslint-plugin-prettier": "5.5.5",
113
- "eslint-plugin-promise": "^7.2.1",
114
- "eslint-plugin-regexp": "^3.0.0",
115
- "eslint-plugin-security": "^4.0.0",
112
+ "eslint-plugin-promise": "7.3.0",
113
+ "eslint-plugin-regexp": "3.1.0",
114
+ "eslint-plugin-security": "4.0.0",
116
115
  "eslint-plugin-unused-imports": "4.4.1",
117
116
  "husky": "9.1.7",
118
- "jest": "30.3.0",
117
+ "jest": "30.4.2",
119
118
  "jest-retries": "1.0.1",
120
119
  "node-mocks-http": "1.17.2",
121
120
  "rimraf": "6.1.3",
@@ -124,17 +123,17 @@
124
123
  "using-temporary-files": "2.2.1"
125
124
  },
126
125
  "dependencies": {
127
- "@apidevtools/json-schema-ref-parser": "13.0.5",
126
+ "@apidevtools/json-schema-ref-parser": "15.3.5",
128
127
  "@hapi/accept": "6.0.3",
129
128
  "@types/json-schema": "7.0.15",
130
- "ajv": "8.18.0",
129
+ "ajv": "8.20.0",
131
130
  "chokidar": "5.0.0",
132
131
  "commander": "14.0.3",
133
132
  "debug": "4.4.3",
134
- "fs-extra": "11.3.4",
133
+ "fs-extra": "11.3.5",
135
134
  "http-terminator": "3.2.0",
136
135
  "js-yaml": "4.1.1",
137
- "json-schema-faker": "0.6.0",
136
+ "json-schema-faker": "0.6.1",
138
137
  "jsonwebtoken": "9.0.3",
139
138
  "koa": "3.2.0",
140
139
  "koa-bodyparser": "4.4.1",
@@ -142,13 +141,12 @@
142
141
  "koa2-swagger-ui": "5.12.0",
143
142
  "node-fetch": "3.3.2",
144
143
  "open": "11.0.0",
145
- "patch-package": "8.0.1",
146
- "posthog-node": "^5.28.11",
147
- "precinct": "12.2.0",
148
- "prettier": "3.8.1",
144
+ "posthog-node": "5.34.1",
145
+ "precinct": "12.3.2",
146
+ "prettier": "3.8.3",
149
147
  "recast": "0.23.11",
150
- "tsx": "^4.20.3",
151
- "typescript": "6.0.2"
148
+ "tsx": "4.21.0",
149
+ "typescript": "6.0.3"
152
150
  },
153
151
  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
154
152
  "resolutions": {