counterfact 2.9.0 → 2.10.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.
package/README.md CHANGED
@@ -1,34 +1,57 @@
1
1
  <div align="center" markdown="1">
2
2
 
3
- <img src="./counterfact.svg" alt="Counterfact" border=0>
3
+ <h1><img src="./counterfact.svg" alt="Counterfact" border=0></h1>
4
4
 
5
5
  <br>
6
6
 
7
- ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![TypeScript](./typescript-badge.png)](https://github.com/ellerbrock/typescript-badges/) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact)
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)
8
8
 
9
9
  </div>
10
10
 
11
- You've used mock servers. You know where they stop being useful: static responses, no shared state, no way to inject a failure mid-run, no control without restarting. Counterfact picks up where they leave off.
11
+ <div align="center" markdown="1">
12
+ <h2>Mock servers work—until you need state, failures, or control mid-run.</h2>
13
+ </div
14
+
15
+ Static responses aren’t enough. There’s no shared state. You can’t inject failures. You can’t test real workflows.<br>
16
+ Mock servers make it easy to get started, but hard to keep going.<br>
17
+ Counterfact is an API simulator without those limits.
18
+
19
+ Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
20
+ - Type-safe TypeScript handlers for every endpoint
21
+ - Hot reloading as you edit
22
+ - Shared state across routes
23
+ - **A built-in REPL to control behavior at runtime**
24
+ - Optional proxying to real backends
25
+
26
+ Flexbile for humans. Stable for [AI agents](https://github.com/counterfact/api-simulator/blob/main/docs/patterns/ai-assisted-implementation.md).
27
+
28
+ You’re in control—without restarting.
29
+
30
+ For a *frontend developer* waiting on a backend,<br>
31
+ a *test engineer* who needs clean, reproducible state,<br>
32
+ or an *AI agent* that needs a stable API
33
+
34
+ Real enough to be useful. Fake enough to be usable.
35
+
12
36
 
13
- Point it at an OpenAPI spec and it generates TypeScript handlers for every endpoint—type-safe, hot-reloading, sharing state across routes. A built-in REPL gives you a live control surface: seed data, trigger error conditions, proxy individual routes to a real backend, all on a running server. Whether you're a frontend developer waiting on a backend, a test engineer who needs clean reproducible state, or an AI agent that needs a stable API to work against, Counterfact is the simulator that doesn't plateau.
37
+ ## Try it now
14
38
 
15
39
  ```sh
16
40
  npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
17
41
  ```
18
42
 
43
+ > Starts a local server with a live REPL to inspect and control API behavior
19
44
  > Requires Node ≥ 22.0.0
20
45
 
21
46
  ## Go deeper
22
47
 
23
- | | |
24
- |---|---|
25
- | [Getting started](./docs/getting-started.md) | Detailed walkthrough with state, REPL, and proxy |
26
- | [Usage](./docs/usage.md) | Feature index: routes, context, REPL, proxy, middleware, and more |
27
- | [Patterns](./docs/patterns/index.md) | Failures, latency, AI sandboxes, integration tests |
28
- | [Reference](./docs/reference.md) | `$` API, CLI flags, architecture |
29
- | [How it compares](./docs/comparison.md) | json-server, WireMock, Prism, Microcks, MSW |
30
- | [FAQ](./docs/faq.md) | State, types, regeneration |
31
- | [Petstore example](https://github.com/counterfact/example-petstore) | Full working example |
48
+ - [Getting started](./docs/getting-started.md) – Detailed walkthrough with state, REPL, and proxy
49
+ - [Patterns](./docs/patterns/index.md) – How Counterfact transforms your workflow
50
+ - [Example repo](https://github.com/counterfact/example-petstore) Using Counterfact to implement the Swagger Petstore
51
+ - [How it compares](./docs/comparison.md) json-server, WireMock, Prism, Microcks, MSW
52
+ - [Usage](./docs/usage.md) Explore features and how to use them
53
+ - [Reference](./docs/reference.md) `$` API, CLI flags, architecture
54
+ - [FAQ](./docs/faq.md) State, types, regeneration
32
55
 
33
56
  <div align="center" markdown="1">
34
57
 
@@ -67,7 +67,7 @@ async function buildTypeNameMapping(specification) {
67
67
  return;
68
68
  }
69
69
  // Create the type coder to get the correct type name
70
- const typeCoder = new OperationTypeCoder(operation, requestMethod, securitySchemes);
70
+ const typeCoder = new OperationTypeCoder(operation, "", requestMethod, securitySchemes);
71
71
  // Get the type name (first from the names generator)
72
72
  const typeName = typeCoder.names().next().value;
73
73
  methodMap.set(requestMethod.toUpperCase(), typeName);
@@ -157,8 +157,7 @@ export type GenericResponseBuilder<
157
157
  : object extends OmitValueWhenNever<Omit<Response, "examples">>
158
158
  ? COUNTERFACT_RESPONSE
159
159
  : keyof OmitValueWhenNever<Omit<Response, "examples">> extends "headers"
160
- ? {
161
- ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE;
160
+ ? COUNTERFACT_RESPONSE & {
162
161
  header: HeaderFunction<Response>;
163
162
  }
164
163
  : GenericResponseBuilderInner<Response>;
@@ -5,12 +5,15 @@
5
5
  * argument object.
6
6
  */
7
7
  export interface OpenApiParameters {
8
+ explode?: boolean;
8
9
  in: "body" | "cookie" | "formData" | "header" | "path" | "query";
9
10
  name: string;
10
11
  required?: boolean;
11
12
  schema?: {
12
13
  [key: string]: unknown;
14
+ properties?: Record<string, unknown>;
13
15
  type?: string;
14
16
  };
17
+ style?: string;
15
18
  type?: "string" | "number" | "integer" | "boolean";
16
19
  }
@@ -2,7 +2,7 @@ import { mediaTypes } from "@hapi/accept";
2
2
  import createDebugger from "debug";
3
3
  import fetch, { Headers } from "node-fetch";
4
4
  import { createResponseBuilder } from "./response-builder.js";
5
- import { validateRequest } from "./request-validator.js";
5
+ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validator.js";
6
6
  import { validateResponse } from "./response-validator.js";
7
7
  import { Tools } from "./tools.js";
8
8
  const debug = createDebugger("counterfact:server:dispatcher");
@@ -36,6 +36,45 @@ function parseCookies(cookieHeader) {
36
36
  }
37
37
  return cookies;
38
38
  }
39
+ /**
40
+ * Gathers exploded object query parameters back into a nested object under the
41
+ * parameter's own name.
42
+ *
43
+ * Per OpenAPI 3.x, a query parameter of type `object` with `style: form` and
44
+ * `explode: true` (both defaults for query params) is serialised as individual
45
+ * query parameters — one per object property. This function reconstructs the
46
+ * object so that the route handler can access `$.query.<paramName>` rather than
47
+ * only the individual flat parameters.
48
+ *
49
+ * Properties that are "claimed" by an object parameter are removed from the
50
+ * top-level map and placed under the parameter name. Unclaimed keys remain at
51
+ * the top level.
52
+ *
53
+ * @param query - The raw parsed query-string map from the HTTP request.
54
+ * @param parameters - The OpenAPI parameter definitions for the current operation.
55
+ * @returns A new map with exploded object parameters reconstructed as nested objects.
56
+ */
57
+ export function collectExplodedObjectParams(query, parameters) {
58
+ const result = { ...query };
59
+ for (const parameter of parameters) {
60
+ if (!isExplodedObjectQueryParam(parameter))
61
+ continue;
62
+ const properties = parameter.schema?.properties;
63
+ if (!properties)
64
+ continue;
65
+ const obj = {};
66
+ for (const key of Object.keys(properties)) {
67
+ if (key in result) {
68
+ obj[key] = result[key];
69
+ delete result[key];
70
+ }
71
+ }
72
+ if (Object.keys(obj).length > 0) {
73
+ result[parameter.name] = obj;
74
+ }
75
+ }
76
+ return result;
77
+ }
39
78
  /**
40
79
  * Core HTTP request dispatcher.
41
80
  *
@@ -208,6 +247,9 @@ export class Dispatcher {
208
247
  };
209
248
  }
210
249
  }
250
+ // Reconstruct exploded object query parameters so that `$.query.<name>`
251
+ // contains the assembled object instead of only the individual flat keys.
252
+ const processedQuery = collectExplodedObjectParams(query, operation?.parameters ?? []);
211
253
  const continuousDistribution = (min, max) => {
212
254
  return min + Math.random() * (max - min);
213
255
  };
@@ -238,7 +280,8 @@ export class Dispatcher {
238
280
  status: fetchResponse.status,
239
281
  };
240
282
  },
241
- query,
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
284
+ query: processedQuery,
242
285
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
243
286
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
244
287
  tools: new Tools({ headers }),
@@ -4,10 +4,51 @@ const ajv = new Ajv({
4
4
  strict: false,
5
5
  coerceTypes: false,
6
6
  });
7
+ /**
8
+ * Returns `true` when a query parameter should be serialized as exploded form
9
+ * style — meaning its object properties appear as individual query parameters
10
+ * instead of being passed under the parameter's own name.
11
+ *
12
+ * Per OpenAPI 3.x: for `in: query`, the default `style` is `form` and the
13
+ * default `explode` for `form` is `true`. An object-type parameter with these
14
+ * defaults (or with them set explicitly) uses exploded form serialization.
15
+ */
16
+ export function isExplodedObjectQueryParam(parameter) {
17
+ if (parameter.in !== "query")
18
+ return false;
19
+ const schema = parameter.schema;
20
+ if (!schema)
21
+ return false;
22
+ // Must be an object type (explicit type or implied by presence of properties)
23
+ const isObjectType = schema.type === "object" || schema.properties !== undefined;
24
+ if (!isObjectType)
25
+ return false;
26
+ // style must be "form" (the default for query params) or unset
27
+ if (parameter.style !== undefined && parameter.style !== "form")
28
+ return false;
29
+ // explode must not be explicitly false (default for form style is true)
30
+ if (parameter.explode === false)
31
+ return false;
32
+ return true;
33
+ }
7
34
  function findMissingRequired(parameters, location, values) {
8
35
  return parameters
9
36
  .filter((p) => p.in === location && p.required === true)
10
- .filter((p) => !(p.name in values) || values[p.name] === undefined)
37
+ .filter((p) => {
38
+ // For exploded object query params the individual properties appear as
39
+ // separate query parameters, so we check for those instead of the name.
40
+ if (location === "query" && isExplodedObjectQueryParam(p)) {
41
+ const properties = p.schema?.properties;
42
+ if (!properties) {
43
+ // Free-form object with no declared properties: treat as always
44
+ // satisfied — we cannot know which keys belong to the parameter.
45
+ return false;
46
+ }
47
+ // The parameter is "missing" only when none of its properties are present.
48
+ return !Object.keys(properties).some((key) => key in values);
49
+ }
50
+ return !(p.name in values) || values[p.name] === undefined;
51
+ })
11
52
  .map((p) => `${location} parameter '${p.name}' is required`);
12
53
  }
13
54
  export function validateRequest(operation, request) {
@@ -116,7 +116,7 @@ export class CodeGenerator extends EventTarget {
116
116
  }
117
117
  repository
118
118
  .get(`routes${path}.ts`)
119
- .export(new OperationCoder(operation, requestMethod, securitySchemes));
119
+ .export(new OperationCoder(operation, "", requestMethod, securitySchemes));
120
120
  });
121
121
  });
122
122
  debug("telling the repository to write the files to %s", destination);
@@ -12,8 +12,10 @@ import { RESERVED_WORDS } from "./reserved-words.js";
12
12
  */
13
13
  export class Coder {
14
14
  requirement;
15
- constructor(requirement) {
15
+ version;
16
+ constructor(requirement, version = "") {
16
17
  this.requirement = requirement;
18
+ this.version = version;
17
19
  }
18
20
  /**
19
21
  * A stable cache key for this coder, composed of the constructor name and
@@ -84,7 +86,7 @@ export class Coder {
84
86
  return this;
85
87
  }
86
88
  const requirement = await this.requirement.reference();
87
- return new this.constructor(requirement);
89
+ return new this.constructor(requirement, this.version);
88
90
  }
89
91
  /**
90
92
  * Generator that yields candidate export names for this coder.
@@ -14,9 +14,9 @@ import { OperationTypeCoder, } from "./operation-type-coder.js";
14
14
  export class OperationCoder extends Coder {
15
15
  requestMethod;
16
16
  securitySchemes;
17
- constructor(requirement, requestMethod, securitySchemes = []) {
18
- super(requirement);
19
- if (requestMethod === undefined) {
17
+ constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
18
+ super(requirement, version);
19
+ if (requestMethod === "") {
20
20
  throw new Error("requestMethod is required");
21
21
  }
22
22
  this.requestMethod = requestMethod;
@@ -40,7 +40,7 @@ export class OperationCoder extends Coder {
40
40
  }`;
41
41
  }
42
42
  typeDeclaration(_namespace, script) {
43
- const operationTypeCoder = new OperationTypeCoder(this.requirement, this.requestMethod, this.securitySchemes);
43
+ const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
44
44
  return script.importType(operationTypeCoder);
45
45
  }
46
46
  modulePath() {
@@ -36,9 +36,9 @@ function sanitizeIdentifier(value) {
36
36
  export class OperationTypeCoder extends TypeCoder {
37
37
  requestMethod;
38
38
  securitySchemes;
39
- constructor(requirement, requestMethod, securitySchemes = []) {
40
- super(requirement);
41
- if (requestMethod === undefined) {
39
+ constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
40
+ super(requirement, version);
41
+ if (requestMethod === "") {
42
42
  throw new Error("requestMethod is required");
43
43
  }
44
44
  this.requestMethod = requestMethod;
@@ -79,7 +79,7 @@ export class OperationTypeCoder extends TypeCoder {
79
79
  }
80
80
  const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
81
81
  const typeName = `${baseName}_${capitalize(parameterKind)}`;
82
- const coder = new ParameterExportTypeCoder(this.requirement, typeName, inlineType, parameterKind);
82
+ const coder = new ParameterExportTypeCoder(this.requirement, this.version, typeName, inlineType, parameterKind);
83
83
  coder._modulePath = modulePath;
84
84
  return script.export(coder, true);
85
85
  }
@@ -99,7 +99,7 @@ export class OperationTypeCoder extends TypeCoder {
99
99
  return response.get("content").map((content, contentType) => `{
100
100
  status: ${status},
101
101
  contentType?: "${contentType}",
102
- body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema")).write(script) : "unknown"}
102
+ body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
103
103
  }`);
104
104
  }
105
105
  if (response.has("schema")) {
@@ -111,7 +111,7 @@ export class OperationTypeCoder extends TypeCoder {
111
111
  .map((contentType) => `{
112
112
  status: ${status},
113
113
  contentType?: "${contentType}",
114
- body?: ${new SchemaTypeCoder(response.get("schema")).write(script)}
114
+ body?: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
115
115
  }`)
116
116
  .join(" | ");
117
117
  }
@@ -149,10 +149,10 @@ export class OperationTypeCoder extends TypeCoder {
149
149
  script.importSharedType("COUNTERFACT_RESPONSE");
150
150
  const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
151
151
  const parameters = this.requirement.get("parameters");
152
- const queryType = new ParametersTypeCoder(parameters, "query").write(script);
153
- const pathType = new ParametersTypeCoder(parameters, "path").write(script);
154
- const headersType = new ParametersTypeCoder(parameters, "header").write(script);
155
- const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
152
+ const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
153
+ const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
154
+ const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
155
+ const cookieType = new ParametersTypeCoder(parameters, this.version, "cookie").write(script);
156
156
  const bodyRequirement = (this.requirement.get("consumes") ??
157
157
  this.requirement.specification?.rootRequirement?.get("consumes"))
158
158
  ? parameters
@@ -161,10 +161,11 @@ export class OperationTypeCoder extends TypeCoder {
161
161
  : this.requirement.select("requestBody/content/application~1json/schema");
162
162
  const bodyType = bodyRequirement === undefined
163
163
  ? "never"
164
- : new SchemaTypeCoder(bodyRequirement).write(script);
165
- const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), (this.requirement.get("produces")?.data ??
164
+ : new SchemaTypeCoder(bodyRequirement, this.version).write(script);
165
+ const openApi2MediaTypes = (this.requirement.get("produces")?.data ??
166
166
  this.requirement.specification?.rootRequirement?.get("produces")
167
- ?.data)).write(script);
167
+ ?.data);
168
+ const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
168
169
  const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
169
170
  const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
170
171
  // Get the base name for this operation and export parameter types
@@ -174,6 +175,6 @@ export class OperationTypeCoder extends TypeCoder {
174
175
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
175
176
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
176
177
  const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
177
- 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} }>) => MaybePromise<${this.responseTypes(script)} | { status: 415, contentType: "text/plain", body: string } | COUNTERFACT_RESPONSE | { ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE }>`;
178
+ 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} }>) => MaybePromise<COUNTERFACT_RESPONSE>`;
178
179
  }
179
180
  }
@@ -4,8 +4,8 @@ export class ParameterExportTypeCoder extends TypeCoder {
4
4
  _typeCode;
5
5
  _parameterKind;
6
6
  _modulePath;
7
- constructor(requirement, typeName, typeCode, parameterKind) {
8
- super(requirement);
7
+ constructor(requirement, version, typeName, typeCode, parameterKind) {
8
+ super(requirement, version);
9
9
  this._typeName = typeName;
10
10
  this._typeCode = typeCode;
11
11
  this._parameterKind = parameterKind;
@@ -4,8 +4,8 @@ import { SchemaTypeCoder } from "./schema-type-coder.js";
4
4
  import { TypeCoder } from "./type-coder.js";
5
5
  export class ParametersTypeCoder extends TypeCoder {
6
6
  placement;
7
- constructor(requirement, placement) {
8
- super(requirement);
7
+ constructor(requirement, version = "", placement = "") {
8
+ super(requirement, version);
9
9
  this.placement = placement;
10
10
  }
11
11
  names() {
@@ -26,7 +26,7 @@ export class ParametersTypeCoder extends TypeCoder {
26
26
  : parameter;
27
27
  const comment = buildJsDoc(parameter.data);
28
28
  const commentPrefix = comment ? `\n${comment}` : "";
29
- const typeString = new SchemaTypeCoder(schema).write(script);
29
+ const typeString = new SchemaTypeCoder(schema, this.version).write(script);
30
30
  return `${commentPrefix}"${name}"${optionalFlag}: ${typeString}`;
31
31
  });
32
32
  if (typeDefinitions.length === 0) {
@@ -1,10 +1,11 @@
1
1
  import { printObject } from "./printers.js";
2
2
  import { SchemaTypeCoder } from "./schema-type-coder.js";
3
3
  import { TypeCoder } from "./type-coder.js";
4
+ import { pathJoin } from "../util/forward-slash-path.js";
4
5
  export class ResponseTypeCoder extends TypeCoder {
5
6
  openApi2MediaTypes;
6
- constructor(requirement, openApi2MediaTypes = []) {
7
- super(requirement);
7
+ constructor(requirement, version = "", openApi2MediaTypes = []) {
8
+ super(requirement, version);
8
9
  this.openApi2MediaTypes = openApi2MediaTypes;
9
10
  }
10
11
  names() {
@@ -17,14 +18,14 @@ export class ResponseTypeCoder extends TypeCoder {
17
18
  .map((content, mediaType) => [
18
19
  mediaType,
19
20
  `{
20
- schema: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema")).write(script) : "unknown"}
21
+ schema: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
21
22
  }`,
22
23
  ]);
23
24
  }
24
25
  return this.openApi2MediaTypes.map((mediaType) => [
25
26
  mediaType,
26
27
  `{
27
- schema: ${new SchemaTypeCoder(response.get("schema")).write(script)}
28
+ schema: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
28
29
  }`,
29
30
  ]);
30
31
  }
@@ -39,7 +40,7 @@ export class ResponseTypeCoder extends TypeCoder {
39
40
  .get("headers")
40
41
  .map((value, name) => [
41
42
  name,
42
- `{ schema: ${new SchemaTypeCoder(value.get("schema") ?? value).write(script)}}`,
43
+ `{ schema: ${new SchemaTypeCoder(value.get("schema") ?? value, this.version).write(script)}}`,
43
44
  ]);
44
45
  }
45
46
  printHeaders(script, response) {
@@ -77,7 +78,7 @@ export class ResponseTypeCoder extends TypeCoder {
77
78
  return printObject(exampleNames.map((name) => [name, "unknown"]));
78
79
  }
79
80
  modulePath() {
80
- return `types/${this.requirement.data["$ref"]}.ts`;
81
+ return pathJoin("types", this.version, this.requirement.data["$ref"] + ".ts");
81
82
  }
82
83
  writeCode(script) {
83
84
  return `{
@@ -3,8 +3,8 @@ import { ResponseTypeCoder } from "./response-type-coder.js";
3
3
  import { TypeCoder } from "./type-coder.js";
4
4
  export class ResponsesTypeCoder extends TypeCoder {
5
5
  openApi2MediaTypes;
6
- constructor(requirement, openApi2MediaTypes = []) {
7
- super(requirement);
6
+ constructor(requirement, version = "", openApi2MediaTypes = []) {
7
+ super(requirement, version);
8
8
  this.openApi2MediaTypes = openApi2MediaTypes;
9
9
  }
10
10
  typeForDefaultStatusCode(listedStatusCodes) {
@@ -23,7 +23,7 @@ export class ResponsesTypeCoder extends TypeCoder {
23
23
  buildResponseObjectType(script) {
24
24
  return printObjectWithoutQuotes(this.requirement.map((response, responseCode) => [
25
25
  this.normalizeStatusCode(responseCode),
26
- new ResponseTypeCoder(response, this.openApi2MediaTypes).write(script),
26
+ new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
27
27
  ]));
28
28
  }
29
29
  writeCode(script) {
@@ -13,7 +13,7 @@ export class SchemaCoder extends Coder {
13
13
  objectSchema(script) {
14
14
  const { properties, required } = this.requirement.data;
15
15
  const propertyLines = Object.keys(properties ?? {}).map((name) => {
16
- const schemaCoder = new SchemaCoder(this.requirement.select(`properties/${name}`));
16
+ const schemaCoder = new SchemaCoder(this.requirement.select(`properties/${name}`), this.version);
17
17
  return `"${name}": ${schemaCoder.write(script)}`;
18
18
  });
19
19
  return `
@@ -27,7 +27,7 @@ export class SchemaCoder extends Coder {
27
27
  arraySchema(script) {
28
28
  return `{
29
29
  type: "array",
30
- items: ${new SchemaCoder(this.requirement.get("items")).write(script)}
30
+ items: ${new SchemaCoder(this.requirement.get("items"), this.version).write(script)}
31
31
  }`;
32
32
  }
33
33
  typeDeclaration(_namespace, script) {
@@ -1,5 +1,6 @@
1
1
  import { buildJsDoc } from "./jsdoc.js";
2
2
  import { TypeCoder } from "./type-coder.js";
3
+ import { pathJoin } from "../util/forward-slash-path.js";
3
4
  export class SchemaTypeCoder extends TypeCoder {
4
5
  names() {
5
6
  return super.names(this.requirement.data["$ref"]?.split("/").at(-1));
@@ -16,7 +17,7 @@ export class SchemaTypeCoder extends TypeCoder {
16
17
  return "unknown";
17
18
  }
18
19
  const requirement = this.requirement.get("additionalProperties");
19
- return new SchemaTypeCoder(requirement).write(script);
20
+ return new SchemaTypeCoder(requirement, this.version).write(script);
20
21
  }
21
22
  objectSchema(script) {
22
23
  const { data } = this.requirement;
@@ -28,7 +29,8 @@ export class SchemaTypeCoder extends TypeCoder {
28
29
  const optionalFlag = isRequired ? "" : "?";
29
30
  const comment = buildJsDoc(property.data);
30
31
  const commentPrefix = comment ? `\n${comment}` : "";
31
- return `${commentPrefix}"${name}"${optionalFlag}: ${new SchemaTypeCoder(property).write(script)}`;
32
+ const propertyType = new SchemaTypeCoder(property, this.version).write(script);
33
+ return `${commentPrefix}"${name}"${optionalFlag}: ${propertyType}`;
32
34
  });
33
35
  if (typedData.additionalProperties) {
34
36
  properties.push(`[key: string]: ${this.additionalPropertiesType(script)}`);
@@ -36,7 +38,7 @@ export class SchemaTypeCoder extends TypeCoder {
36
38
  return `{${properties.join(",")}}`;
37
39
  }
38
40
  arraySchema(script) {
39
- return `Array<${new SchemaTypeCoder(this.requirement.get("items")).write(script)}>`;
41
+ return `Array<${new SchemaTypeCoder(this.requirement.get("items"), this.version).write(script)}>`;
40
42
  }
41
43
  writePrimitive(value) {
42
44
  if (typeof value === "string") {
@@ -79,7 +81,7 @@ export class SchemaTypeCoder extends TypeCoder {
79
81
  }
80
82
  const key = matchingKey();
81
83
  const items = (allOf ?? anyOf ?? oneOf);
82
- const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index)).write(script));
84
+ const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index), this.version).write(script));
83
85
  return types.join(allOf ? " & " : " | ");
84
86
  }
85
87
  writeEnum(_script, requirement) {
@@ -88,7 +90,7 @@ export class SchemaTypeCoder extends TypeCoder {
88
90
  .join(" | ");
89
91
  }
90
92
  modulePath() {
91
- return `types/${this.requirement.data["$ref"].replace(/^#\//u, "")}.ts`;
93
+ return pathJoin("types", this.version, this.requirement.data["$ref"].replace(/^#\//u, "") + ".ts");
92
94
  }
93
95
  writeCode(script) {
94
96
  const { allOf, anyOf, oneOf, type, format } = this.requirement.data;
@@ -17,6 +17,7 @@ export class Script {
17
17
  repository;
18
18
  comments;
19
19
  exports;
20
+ versions;
20
21
  imports;
21
22
  externalImport;
22
23
  cache;
@@ -26,6 +27,7 @@ export class Script {
26
27
  this.repository = repository;
27
28
  this.comments = [];
28
29
  this.exports = new Map();
30
+ this.versions = new Map();
29
31
  this.imports = new Map();
30
32
  this.externalImport = new Map();
31
33
  this.cache = new Map();
@@ -176,13 +178,53 @@ export class Script {
176
178
  exportType(coder) {
177
179
  return this.export(coder, true);
178
180
  }
181
+ declareVersion(coder, name) {
182
+ const version = coder.version;
183
+ const versions = this.versions.get(name) ?? new Map();
184
+ this.versions.set(name, versions);
185
+ if (versions.has(version)) {
186
+ return;
187
+ }
188
+ const versionStatement = {
189
+ beforeExport: "",
190
+ done: false,
191
+ id: coder.id,
192
+ isDefault: false,
193
+ isType: true,
194
+ jsdoc: "",
195
+ typeDeclaration: "",
196
+ };
197
+ versionStatement.promise = coder
198
+ .delegate()
199
+ .then((availableCoder) => {
200
+ versionStatement.code = availableCoder.write(this);
201
+ return availableCoder;
202
+ })
203
+ .catch((error) => {
204
+ versionStatement.code = `unknown /* error declaring version "${name}" (${version}) for ${this.path}: ${error.message} */`;
205
+ versionStatement.error = error;
206
+ return undefined;
207
+ })
208
+ .finally(() => {
209
+ versionStatement.done = true;
210
+ });
211
+ versions.set(version, versionStatement);
212
+ }
179
213
  /** `true` while at least one export promise is still pending. */
180
214
  isInProgress() {
181
- return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done);
215
+ return (Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done) ||
216
+ Array.from(this.versions.values())
217
+ .flatMap((versions) => Array.from(versions.values()))
218
+ .some((versionStatement) => !versionStatement.done));
182
219
  }
183
220
  /** Returns a promise that resolves when all pending export promises settle. */
184
221
  finished() {
185
- return Promise.all(Array.from(this.exports.values(), (value) => value.promise));
222
+ return Promise.all([
223
+ ...Array.from(this.exports.values(), (value) => value.promise),
224
+ ...Array.from(this.versions.values())
225
+ .flatMap((versions) => Array.from(versions.values()))
226
+ .map((value) => value.promise),
227
+ ]);
186
228
  }
187
229
  externalImportStatements() {
188
230
  return Array.from(this.externalImport, ([name, { isDefault, isType, modulePath }]) => `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${modulePath}";`);
@@ -208,18 +250,31 @@ export class Script {
208
250
  return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
209
251
  });
210
252
  }
253
+ versionsTypeStatements() {
254
+ if (this.versions.size === 0) {
255
+ return [];
256
+ }
257
+ const names = Array.from(this.versions, ([name, versions]) => {
258
+ const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
259
+ return `"${name}": { ${mappedVersions.join(", ")} }`;
260
+ });
261
+ return [`export type Versions = { ${names.join(", ")} };`];
262
+ }
211
263
  /**
212
264
  * Formats the fully assembled script source with Prettier and returns it.
213
265
  *
214
266
  * All pending export promises are awaited before formatting.
215
267
  */
216
- contents() {
268
+ async contents() {
269
+ await this.finished();
217
270
  return format([
218
271
  this.comments.map((comment) => `// ${comment}`).join("\n"),
219
272
  this.comments.length > 0 ? "\n\n" : "",
220
273
  this.externalImportStatements().join("\n"),
221
274
  this.importStatements().join("\n"),
222
275
  "\n\n",
276
+ this.versionsTypeStatements().join("\n"),
277
+ this.versions.size > 0 ? "\n\n" : "",
223
278
  this.exportStatements().join("\n\n"),
224
279
  ].join(""), { parser: "typescript" });
225
280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.9.0",
3
+ "version": "2.10.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",