counterfact 2.8.1 → 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.
Files changed (48) hide show
  1. package/README.md +36 -13
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -547
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +72 -138
  7. package/dist/cli/banner.js +81 -0
  8. package/dist/cli/check-for-updates.js +45 -0
  9. package/dist/cli/run.js +304 -0
  10. package/dist/cli/telemetry.js +50 -0
  11. package/dist/migrate/paths-to-routes.js +1 -0
  12. package/dist/migrate/update-route-types.js +2 -1
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +3 -1
  15. package/dist/repl/repl.js +228 -60
  16. package/dist/server/counterfact-types/generic-response-builder.ts +1 -2
  17. package/dist/server/counterfact-types/open-api-parameters.ts +3 -0
  18. package/dist/server/determine-module-kind.js +1 -0
  19. package/dist/server/dispatcher.js +45 -2
  20. package/dist/server/file-discovery.js +1 -0
  21. package/dist/server/module-loader.js +8 -0
  22. package/dist/server/request-validator.js +42 -1
  23. package/dist/server/transpiler.js +1 -0
  24. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  25. package/dist/server/web-server/create-koa-app.js +68 -0
  26. package/dist/server/web-server/openapi-middleware.js +34 -0
  27. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +11 -8
  28. package/dist/typescript-generator/code-generator.js +2 -1
  29. package/dist/typescript-generator/coder.js +4 -2
  30. package/dist/typescript-generator/operation-coder.js +4 -4
  31. package/dist/typescript-generator/operation-type-coder.js +15 -14
  32. package/dist/typescript-generator/parameter-export-type-coder.js +2 -2
  33. package/dist/typescript-generator/parameters-type-coder.js +3 -3
  34. package/dist/typescript-generator/prune.js +1 -0
  35. package/dist/typescript-generator/repository.js +1 -0
  36. package/dist/typescript-generator/response-type-coder.js +7 -6
  37. package/dist/typescript-generator/responses-type-coder.js +3 -3
  38. package/dist/typescript-generator/scenario-file-generator.js +1 -0
  39. package/dist/typescript-generator/schema-coder.js +2 -2
  40. package/dist/typescript-generator/schema-type-coder.js +7 -5
  41. package/dist/typescript-generator/script.js +58 -3
  42. package/dist/util/ensure-directory-exists.js +1 -0
  43. package/dist/util/load-config-file.js +2 -2
  44. package/dist/util/read-file.js +16 -2
  45. package/dist/util/runtime-can-execute-erasable-ts.js +1 -0
  46. package/package.json +3 -2
  47. package/dist/server/create-koa-app.js +0 -65
  48. package/dist/server/openapi-middleware.js +0 -48
@@ -0,0 +1,68 @@
1
+ import createDebug from "debug";
2
+ import Koa from "koa";
3
+ import bodyParser from "koa-bodyparser";
4
+ import { koaSwagger } from "koa2-swagger-ui";
5
+ import { adminApiMiddleware } from "./admin-api-middleware.js";
6
+ import { routesMiddleware } from "./routes-middleware.js";
7
+ import { openapiMiddleware } from "./openapi-middleware.js";
8
+ const debug = createDebug("counterfact:server:create-koa-app");
9
+ /**
10
+ * Builds and configures the Koa application with all built-in middleware.
11
+ *
12
+ * The middleware stack (in order) is:
13
+ * 1. Per runner: OpenAPI document serving at `/counterfact/openapi${runner.subdirectory}`
14
+ * 2. Per runner: Swagger UI at `/counterfact/swagger${runner.subdirectory}`
15
+ * 3. Per runner: Admin API (when `config.startAdminApi` is `true`) at `/_counterfact/api${runner.subdirectory}`
16
+ * 4. Redirect `/counterfact` → `/counterfact/swagger`
17
+ * 5. Body parser
18
+ * 6. JSON serialisation of object bodies
19
+ * 7. Per runner: Route-dispatching middleware at `runner.prefix`
20
+ *
21
+ * @param runners - The ApiRunner instances, one per API spec.
22
+ * @param config - Server configuration.
23
+ * @returns A configured Koa application (not yet listening).
24
+ */
25
+ export function createKoaApp({ runners, config, }) {
26
+ const app = new Koa();
27
+ for (const runner of runners) {
28
+ app.use(openapiMiddleware(`/counterfact/openapi${runner.subdirectory}`, {
29
+ path: runner.openApiPath,
30
+ baseUrl: `//localhost:${config.port}${runner.prefix}`,
31
+ }));
32
+ app.use(koaSwagger({
33
+ routePrefix: `/counterfact/swagger${runner.subdirectory}`,
34
+ swaggerOptions: {
35
+ url: `/counterfact/openapi${runner.subdirectory}`,
36
+ },
37
+ }));
38
+ if (config.startAdminApi) {
39
+ app.use(adminApiMiddleware(`/_counterfact/api${runner.subdirectory}`, runner.registry, runner.contextRegistry, config));
40
+ }
41
+ }
42
+ debug("basePath: %s", config.basePath);
43
+ app.use(async (ctx, next) => {
44
+ if (ctx.URL.pathname === "/counterfact") {
45
+ ctx.redirect("/counterfact/swagger");
46
+ return;
47
+ }
48
+ await next();
49
+ });
50
+ app.use(bodyParser());
51
+ app.use(async (ctx, next) => {
52
+ await next();
53
+ if (ctx.body !== null &&
54
+ ctx.body !== undefined &&
55
+ typeof ctx.body === "object" &&
56
+ !Buffer.isBuffer(ctx.body)) {
57
+ ctx.body = JSON.stringify(ctx.body, null, 2);
58
+ ctx.type = "application/json";
59
+ }
60
+ });
61
+ for (const runner of runners) {
62
+ app.use(routesMiddleware(runner.prefix, runner.dispatcher, {
63
+ proxyPaths: config.proxyPaths,
64
+ proxyUrl: config.proxyUrl,
65
+ }));
66
+ }
67
+ return app;
68
+ }
@@ -0,0 +1,34 @@
1
+ import { bundle } from "@apidevtools/json-schema-ref-parser";
2
+ import { dump } from "js-yaml";
3
+ /**
4
+ * Returns a Koa middleware that serves a bundled OpenAPI document as YAML at
5
+ * the given `pathPrefix`.
6
+ *
7
+ * The served document is augmented with a `servers` entry (OpenAPI 3.x) and a
8
+ * `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
9
+ * requests to the running Counterfact instance.
10
+ *
11
+ * @param pathPrefix - The URL path at which to serve the document, e.g.
12
+ * `"/counterfact/openapi"`. Requests to any other path fall through to the
13
+ * next middleware.
14
+ * @param document - Descriptor providing `path` (file path or URL to the
15
+ * source OpenAPI document) and `baseUrl` (the base URL to inject, e.g.
16
+ * `"//localhost:3100/api"`).
17
+ * @returns A Koa middleware function.
18
+ */
19
+ export function openapiMiddleware(pathPrefix, document) {
20
+ return async (ctx, next) => {
21
+ if (ctx.URL.pathname !== pathPrefix) {
22
+ return await next();
23
+ }
24
+ const openApiDocument = (await bundle(document.path));
25
+ openApiDocument.servers ??= [];
26
+ openApiDocument.servers.unshift({
27
+ description: "Counterfact",
28
+ url: document.baseUrl,
29
+ });
30
+ // OpenApi 2 support:
31
+ openApiDocument.host = document.baseUrl;
32
+ ctx.body = dump(openApiDocument);
33
+ };
34
+ }
@@ -1,6 +1,6 @@
1
1
  import createDebug from "debug";
2
2
  import koaProxy from "koa-proxies";
3
- import { isProxyEnabledForPath } from "./is-proxy-enabled-for-path.js";
3
+ import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js";
4
4
  const debug = createDebug("counterfact:server:create-koa-app");
5
5
  const HTTP_STATUS_CODE_OK = 200;
6
6
  const HEADERS_TO_DROP = new Set([
@@ -45,29 +45,32 @@ function getAuthObject(ctx) {
45
45
  * the Counterfact {@link Dispatcher}.
46
46
  *
47
47
  * Responsibilities:
48
- * - Respects `routePrefix` — requests outside the prefix are passed to `next`.
48
+ * - Respects `prefix` — requests outside the prefix are passed to `next`.
49
49
  * - Adds CORS headers to every response.
50
50
  * - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body).
51
51
  * - Proxies the request upstream when proxy is enabled for the path.
52
52
  * - Forwards the request to the dispatcher and maps the response back onto
53
53
  * the Koa context.
54
54
  *
55
+ * @param prefix - The URL path prefix that this middleware handles, e.g.
56
+ * `"/api/v1"`. Requests to paths that do not start with this prefix fall
57
+ * through to the next middleware.
55
58
  * @param dispatcher - The {@link Dispatcher} instance that handles requests.
56
- * @param config - Server configuration (proxy settings, route prefix, etc.).
59
+ * @param config - Server configuration (proxy settings, etc.).
57
60
  * @param proxy - Proxy factory; injectable for testing.
58
61
  * @returns A Koa middleware function.
59
62
  */
60
- export function routesMiddleware(dispatcher, config, proxy = koaProxy) {
63
+ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
61
64
  return async function middleware(ctx, next) {
62
- const { proxyUrl, routePrefix } = config;
65
+ const { proxyUrl } = config;
63
66
  debug("middleware running for path: %s", ctx.request.path);
64
- debug("routePrefix: %s", routePrefix);
65
- if (!ctx.request.path.startsWith(routePrefix)) {
67
+ debug("prefix: %s", prefix);
68
+ if (!ctx.request.path.startsWith(prefix)) {
66
69
  return await next();
67
70
  }
68
71
  const auth = getAuthObject(ctx);
69
72
  const { body, headers, query, rawBody } = ctx.request;
70
- const path = ctx.request.path.slice(routePrefix.length);
73
+ const path = ctx.request.path.slice(prefix.length);
71
74
  const method = ctx.request.method;
72
75
  if (isProxyEnabledForPath(path, config) && proxyUrl) {
73
76
  return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next);
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath from "node:path";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- generated files are written under the caller-provided destination tree. */
4
5
  import { watch } from "chokidar";
5
6
  import createDebug from "debug";
6
7
  import { CHOKIDAR_OPTIONS } from "../server/constants.js";
@@ -115,7 +116,7 @@ export class CodeGenerator extends EventTarget {
115
116
  }
116
117
  repository
117
118
  .get(`routes${path}.ts`)
118
- .export(new OperationCoder(operation, requestMethod, securitySchemes));
119
+ .export(new OperationCoder(operation, "", requestMethod, securitySchemes));
119
120
  });
120
121
  });
121
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,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
+ /* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */
3
4
  import createDebug from "debug";
4
5
  import { toForwardSlashPath } from "../util/forward-slash-path.js";
5
6
  const debug = createDebug("counterfact:typescript-generator:prune");
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath, { dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ /* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */
5
6
  import createDebug from "debug";
6
7
  import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
7
8
  import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
@@ -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) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath from "node:path";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- scenario files are discovered and generated under the configured destination tree. */
4
5
  import { watch } from "chokidar";
5
6
  import { CHOKIDAR_OPTIONS } from "../server/constants.js";
6
7
  import { pathRelative } from "../util/forward-slash-path.js";
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import nodePath from "node:path";
3
+ /* eslint-disable security/detect-non-literal-fs-filename -- helper creates parent directories for caller-provided output file paths. */
3
4
  /**
4
5
  * Synchronously ensures that the directory containing `filePath` exists,
5
6
  * creating it (and any missing ancestors) if necessary.
@@ -1,5 +1,5 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { load as loadYaml } from "js-yaml";
2
+ import { readFile } from "./read-file.js";
3
3
  function kebabToCamel(str) {
4
4
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
5
5
  }
@@ -17,7 +17,7 @@ function normalizeKeys(obj) {
17
17
  export async function loadConfigFile(configPath, required = false) {
18
18
  let content;
19
19
  try {
20
- content = await readFile(configPath, "utf8");
20
+ content = await readFile(configPath);
21
21
  }
22
22
  catch (error) {
23
23
  if (typeof error === "object" &&
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs/promises";
2
+ import nodePath from "node:path";
2
3
  import nodeFetch from "node-fetch";
4
+ function normalizeLocalPath(path) {
5
+ if (path.includes("\0")) {
6
+ throw new Error("File path cannot contain NUL bytes.");
7
+ }
8
+ return nodePath.resolve(path);
9
+ }
3
10
  /**
4
11
  * Reads the content of a file or URL and returns it as a UTF-8 string.
5
12
  *
@@ -17,7 +24,14 @@ export async function readFile(urlOrPath) {
17
24
  return await response.text();
18
25
  }
19
26
  if (urlOrPath.startsWith("file")) {
20
- return await fs.readFile(new URL(urlOrPath), "utf8");
27
+ const fileUrl = new URL(urlOrPath);
28
+ if (fileUrl.protocol !== "file:") {
29
+ throw new Error(`Unsupported URL protocol for file read: ${fileUrl.protocol}`);
30
+ }
31
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- file URL is parsed and protocol-validated immediately above.
32
+ return await fs.readFile(fileUrl, "utf8");
21
33
  }
22
- return await fs.readFile(urlOrPath, "utf8");
34
+ const normalizedPath = normalizeLocalPath(urlOrPath);
35
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is normalized and NUL-byte validated before filesystem access.
36
+ return await fs.readFile(normalizedPath, "utf8");
23
37
  }