counterfact 2.9.0 → 2.11.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.
@@ -10,6 +10,15 @@ export class Requirement {
10
10
  data;
11
11
  url;
12
12
  specification;
13
+ /**
14
+ * The requirement that produced this one via a `get()` call, or `undefined`
15
+ * for root requirements that were constructed directly.
16
+ *
17
+ * For path-traversal purposes this is the "logical" parent: when a `$ref` is
18
+ * followed, the parent is the resolved reference target rather than the
19
+ * `$ref` node itself.
20
+ */
21
+ parent;
13
22
  constructor(data, url = "", specification = undefined) {
14
23
  this.data = data;
15
24
  this.url = url;
@@ -53,7 +62,9 @@ export class Requirement {
53
62
  if (!this.has(key)) {
54
63
  return undefined;
55
64
  }
56
- return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
65
+ const child = new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
66
+ child.parent = this;
67
+ return child;
57
68
  }
58
69
  /**
59
70
  * Navigates to a descendant node using a slash-delimited JSON Pointer path.
@@ -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) {
@@ -21,10 +21,16 @@ export class ResponsesTypeCoder extends TypeCoder {
21
21
  return statusCode;
22
22
  }
23
23
  buildResponseObjectType(script) {
24
- return printObjectWithoutQuotes(this.requirement.map((response, responseCode) => [
24
+ const entries = this.requirement.map((response, responseCode) => [
25
25
  this.normalizeStatusCode(responseCode),
26
- new ResponseTypeCoder(response, this.openApi2MediaTypes).write(script),
27
- ]));
26
+ new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
27
+ ]);
28
+ const explicitEntries = entries.filter(([key]) => !key.startsWith("["));
29
+ const mappedEntries = entries.filter(([key]) => key.startsWith("["));
30
+ if (explicitEntries.length > 0 && mappedEntries.length > 0) {
31
+ return `${printObjectWithoutQuotes(explicitEntries)} & ${printObjectWithoutQuotes(mappedEntries)}`;
32
+ }
33
+ return printObjectWithoutQuotes(entries);
28
34
  }
29
35
  writeCode(script) {
30
36
  script.importSharedType("ResponseBuilderFactory");
@@ -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,8 @@ export class Script {
17
17
  repository;
18
18
  comments;
19
19
  exports;
20
+ versions;
21
+ versionFormatters;
20
22
  imports;
21
23
  externalImport;
22
24
  cache;
@@ -26,6 +28,8 @@ export class Script {
26
28
  this.repository = repository;
27
29
  this.comments = [];
28
30
  this.exports = new Map();
31
+ this.versions = new Map();
32
+ this.versionFormatters = new Map();
29
33
  this.imports = new Map();
30
34
  this.externalImport = new Map();
31
35
  this.cache = new Map();
@@ -173,16 +177,77 @@ export class Script {
173
177
  importSharedType(name) {
174
178
  return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
175
179
  }
180
+ /**
181
+ * Imports a type from the generated `types/versions.ts` module,
182
+ * resolving the path relative to this script's location in the repository.
183
+ *
184
+ * @param name - The type name to import (e.g. `"Versioned"`).
185
+ */
186
+ importVersionsType(name) {
187
+ return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true);
188
+ }
176
189
  exportType(coder) {
177
190
  return this.export(coder, true);
178
191
  }
192
+ /**
193
+ * Registers a formatter function for the merged versioned type emitted under
194
+ * `name` by {@link versionsTypeStatements}.
195
+ *
196
+ * When a formatter is present for a name, `versionsTypeStatements` delegates
197
+ * the entire type declaration to it instead of generating the default
198
+ * `Versions` object type. The formatter receives a `Map<version, importAlias>`
199
+ * and must return the complete TypeScript source for that operation type.
200
+ */
201
+ setVersionFormatter(name, formatter) {
202
+ this.versionFormatters.set(name, formatter);
203
+ }
204
+ declareVersion(coder, name) {
205
+ const version = coder.version;
206
+ const versions = this.versions.get(name) ?? new Map();
207
+ this.versions.set(name, versions);
208
+ if (versions.has(version)) {
209
+ return;
210
+ }
211
+ const versionStatement = {
212
+ beforeExport: "",
213
+ done: false,
214
+ id: coder.id,
215
+ isDefault: false,
216
+ isType: true,
217
+ jsdoc: "",
218
+ typeDeclaration: "",
219
+ };
220
+ versionStatement.promise = coder
221
+ .delegate()
222
+ .then((availableCoder) => {
223
+ versionStatement.code = availableCoder.write(this);
224
+ return availableCoder;
225
+ })
226
+ .catch((error) => {
227
+ versionStatement.code = `unknown /* error declaring version "${name}" (${version}) for ${this.path}: ${error.message} */`;
228
+ versionStatement.error = error;
229
+ return undefined;
230
+ })
231
+ .finally(() => {
232
+ versionStatement.done = true;
233
+ });
234
+ versions.set(version, versionStatement);
235
+ }
179
236
  /** `true` while at least one export promise is still pending. */
180
237
  isInProgress() {
181
- return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done);
238
+ return (Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done) ||
239
+ Array.from(this.versions.values())
240
+ .flatMap((versions) => Array.from(versions.values()))
241
+ .some((versionStatement) => !versionStatement.done));
182
242
  }
183
243
  /** Returns a promise that resolves when all pending export promises settle. */
184
244
  finished() {
185
- return Promise.all(Array.from(this.exports.values(), (value) => value.promise));
245
+ return Promise.all([
246
+ ...Array.from(this.exports.values(), (value) => value.promise),
247
+ ...Array.from(this.versions.values())
248
+ .flatMap((versions) => Array.from(versions.values()))
249
+ .map((value) => value.promise),
250
+ ]);
186
251
  }
187
252
  externalImportStatements() {
188
253
  return Array.from(this.externalImport, ([name, { isDefault, isType, modulePath }]) => `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${modulePath}";`);
@@ -208,18 +273,49 @@ export class Script {
208
273
  return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
209
274
  });
210
275
  }
276
+ versionsTypeStatements() {
277
+ if (this.versions.size === 0) {
278
+ return [];
279
+ }
280
+ const statements = [];
281
+ const unformatted = [];
282
+ for (const [name, versions] of this.versions) {
283
+ const formatter = this.versionFormatters.get(name);
284
+ if (formatter) {
285
+ const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [
286
+ version,
287
+ stmt.code,
288
+ ]));
289
+ statements.push(formatter(versionCodes));
290
+ }
291
+ else {
292
+ unformatted.push([name, versions]);
293
+ }
294
+ }
295
+ if (unformatted.length > 0) {
296
+ const names = unformatted.map(([name, versions]) => {
297
+ const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
298
+ return `"${name}": { ${mappedVersions.join(", ")} }`;
299
+ });
300
+ statements.push(`export type Versions = { ${names.join(", ")} };`);
301
+ }
302
+ return statements;
303
+ }
211
304
  /**
212
305
  * Formats the fully assembled script source with Prettier and returns it.
213
306
  *
214
307
  * All pending export promises are awaited before formatting.
215
308
  */
216
- contents() {
309
+ async contents() {
310
+ await this.finished();
217
311
  return format([
218
312
  this.comments.map((comment) => `// ${comment}`).join("\n"),
219
313
  this.comments.length > 0 ? "\n\n" : "",
220
314
  this.externalImportStatements().join("\n"),
221
315
  this.importStatements().join("\n"),
222
316
  "\n\n",
317
+ this.versionsTypeStatements().join("\n"),
318
+ this.versions.size > 0 ? "\n\n" : "",
223
319
  this.exportStatements().join("\n\n"),
224
320
  ].join(""), { parser: "typescript" });
225
321
  }
@@ -0,0 +1,57 @@
1
+ import { format } from "prettier";
2
+ /**
3
+ * Builds the `VersionsGTE` map: for each version V at index i,
4
+ * maps V to all versions at indices >= i (i.e. V and all later-declared
5
+ * versions, where "later" means "newer").
6
+ */
7
+ function buildVersionsGTE(versions) {
8
+ const result = new Map();
9
+ versions.forEach((version, index) => {
10
+ result.set(version, versions.slice(index));
11
+ });
12
+ return result;
13
+ }
14
+ /**
15
+ * Generates the TypeScript source text for `types/versions.ts`.
16
+ *
17
+ * Returns an empty string when `versions` is empty.
18
+ * The returned string is formatted with Prettier.
19
+ *
20
+ * @param versions - Ordered list of unique, non-empty version strings.
21
+ * The first entry is the oldest version.
22
+ */
23
+ export async function generateVersionsTsContent(versions) {
24
+ if (versions.length === 0) {
25
+ return "";
26
+ }
27
+ const versionsUnion = versions.map((v) => `"${v}"`).join(" | ");
28
+ const versionsGTE = buildVersionsGTE(versions);
29
+ const versionsGTEBody = Array.from(versionsGTE, ([v, gte]) => ` "${v}": ${gte.map((g) => `"${g}"`).join(" | ")};`).join("\n");
30
+ const source = [
31
+ "// This file is auto-generated by Counterfact. Do not edit.",
32
+ "",
33
+ `export type Versions = ${versionsUnion};`,
34
+ "",
35
+ "/**",
36
+ " * Maps each version to the set of versions that are greater than or equal to it.",
37
+ " * Used by `Versioned.minVersion()` to narrow which versions a handler must support.",
38
+ " */",
39
+ "export type VersionsGTE = {",
40
+ versionsGTEBody,
41
+ "};",
42
+ "",
43
+ "type VersionMap = Partial<Record<Versions, object>>;",
44
+ "",
45
+ "export type Versioned<",
46
+ " T extends VersionMap,",
47
+ " V extends keyof T & Versions = keyof T & Versions,",
48
+ "> = T[V] & {",
49
+ " version: V;",
50
+ " minVersion<M extends keyof T & Versions>(",
51
+ " min: M,",
52
+ " ): this is Versioned<T, Extract<V, VersionsGTE[M]>>;",
53
+ "};",
54
+ "",
55
+ ].join("\n");
56
+ return format(source, { parser: "typescript" });
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.9.0",
3
+ "version": "2.11.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",