counterfact 2.6.0 → 2.8.1

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 (98) hide show
  1. package/README.md +14 -207
  2. package/bin/README.md +24 -4
  3. package/bin/counterfact.js +54 -3
  4. package/dist/app.js +81 -28
  5. package/dist/counterfact-types/cookie-options.js +1 -0
  6. package/dist/counterfact-types/counterfact-response.js +7 -0
  7. package/dist/counterfact-types/example-names.js +1 -0
  8. package/dist/counterfact-types/example.js +1 -0
  9. package/dist/counterfact-types/generic-response-builder.js +1 -0
  10. package/dist/counterfact-types/http-status-code.js +1 -0
  11. package/dist/counterfact-types/if-has-key.js +1 -0
  12. package/dist/counterfact-types/index.js +0 -1
  13. package/dist/counterfact-types/maybe-promise.js +1 -0
  14. package/dist/counterfact-types/media-type.js +1 -0
  15. package/dist/counterfact-types/omit-all.js +1 -0
  16. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  17. package/dist/counterfact-types/open-api-content.js +1 -0
  18. package/dist/counterfact-types/open-api-operation.js +1 -0
  19. package/dist/counterfact-types/open-api-parameters.js +1 -0
  20. package/dist/counterfact-types/open-api-response.js +1 -0
  21. package/dist/counterfact-types/random-function.js +1 -0
  22. package/dist/counterfact-types/response-builder-factory.js +1 -0
  23. package/dist/counterfact-types/response-builder.js +1 -0
  24. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  25. package/dist/counterfact-types/wide-response-builder.js +1 -0
  26. package/dist/migrate/update-route-types.js +2 -3
  27. package/dist/repl/raw-http-client.js +19 -0
  28. package/dist/repl/repl.js +116 -4
  29. package/dist/repl/route-builder.js +68 -0
  30. package/dist/server/constants.js +8 -0
  31. package/dist/server/context-registry.js +70 -1
  32. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  33. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  34. package/dist/server/counterfact-types/example-names.ts +13 -0
  35. package/dist/server/counterfact-types/example.ts +10 -0
  36. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  37. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  38. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  39. package/dist/server/counterfact-types/index.ts +20 -338
  40. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  41. package/dist/server/counterfact-types/media-type.ts +6 -0
  42. package/dist/server/counterfact-types/omit-all.ts +11 -0
  43. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  44. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  45. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  46. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  47. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  48. package/dist/server/counterfact-types/random-function.ts +9 -0
  49. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  50. package/dist/server/counterfact-types/response-builder.ts +31 -0
  51. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  52. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  53. package/dist/server/create-koa-app.js +28 -24
  54. package/dist/server/determine-module-kind.js +13 -0
  55. package/dist/server/dispatcher.js +64 -5
  56. package/dist/server/file-discovery.js +20 -9
  57. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  58. package/dist/server/json-to-xml.js +11 -1
  59. package/dist/server/koa-middleware.js +25 -2
  60. package/dist/server/load-openapi-document.js +6 -0
  61. package/dist/server/module-dependency-graph.js +25 -0
  62. package/dist/server/module-loader.js +112 -17
  63. package/dist/server/module-tree.js +36 -0
  64. package/dist/server/openapi-document.js +69 -0
  65. package/dist/server/openapi-middleware.js +34 -5
  66. package/dist/server/openapi-watcher.js +35 -0
  67. package/dist/server/registry.js +89 -0
  68. package/dist/server/request-validator.js +3 -7
  69. package/dist/server/response-builder.js +18 -0
  70. package/dist/server/response-validator.js +58 -0
  71. package/dist/server/scenario-registry.js +55 -0
  72. package/dist/server/tools.js +29 -2
  73. package/dist/server/transpiler.js +23 -9
  74. package/dist/typescript-generator/code-generator.js +117 -4
  75. package/dist/typescript-generator/coder.js +80 -2
  76. package/dist/typescript-generator/operation-coder.js +13 -5
  77. package/dist/typescript-generator/operation-type-coder.js +40 -53
  78. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  79. package/dist/typescript-generator/prune.js +2 -1
  80. package/dist/typescript-generator/read-only-comments.js +1 -1
  81. package/dist/typescript-generator/repository.js +76 -20
  82. package/dist/typescript-generator/requirement.js +77 -1
  83. package/dist/typescript-generator/reserved-words.js +50 -0
  84. package/dist/typescript-generator/scenario-file-generator.js +235 -0
  85. package/dist/typescript-generator/script.js +70 -7
  86. package/dist/typescript-generator/specification.js +27 -0
  87. package/dist/util/ensure-directory-exists.js +7 -0
  88. package/dist/util/forward-slash-path.js +63 -0
  89. package/dist/util/load-config-file.js +44 -0
  90. package/dist/util/read-file.js +11 -0
  91. package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
  92. package/dist/util/windows-escape.js +18 -0
  93. package/package.json +9 -10
  94. package/dist/client/README.md +0 -14
  95. package/dist/client/index.html.hbs +0 -244
  96. package/dist/client/rapi-doc.html.hbs +0 -36
  97. package/dist/server/page-middleware.js +0 -23
  98. package/dist/typescript-generator/generate.js +0 -63
@@ -1,7 +1,23 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import nodePath from "node:path";
1
4
  import { watch } from "chokidar";
5
+ import createDebug from "debug";
2
6
  import { CHOKIDAR_OPTIONS } from "../server/constants.js";
7
+ import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
3
8
  import { waitForEvent } from "../util/wait-for-event.js";
4
- import { generate } from "./generate.js";
9
+ import { OperationCoder } from "./operation-coder.js";
10
+ import { pruneRoutes } from "./prune.js";
11
+ import { Repository } from "./repository.js";
12
+ import { Specification } from "./specification.js";
13
+ const debug = createDebug("counterfact:typescript-generator:generate");
14
+ /**
15
+ * Orchestrates the code-generation pipeline and optional file-system watching.
16
+ *
17
+ * When {@link watch} is called, Counterfact watches the source OpenAPI document
18
+ * for changes and re-runs code generation automatically. `"generate"` and
19
+ * `"failed"` events are emitted after each attempt.
20
+ */
5
21
  export class CodeGenerator extends EventTarget {
6
22
  openapiPath;
7
23
  destination;
@@ -13,15 +29,111 @@ export class CodeGenerator extends EventTarget {
13
29
  this.destination = destination;
14
30
  this.generateOptions = generateOptions;
15
31
  }
16
- async generate() {
17
- await generate(this.openapiPath, this.destination, this.generateOptions);
32
+ /**
33
+ * Initialises the `.cache` directory that holds compiled JS output.
34
+ *
35
+ * Creates a `.gitignore` file that excludes the `.cache` sub-directory and a
36
+ * `README.md` inside `.cache` that explains its purpose.
37
+ *
38
+ * @param destination - The root output directory.
39
+ */
40
+ async buildCacheDirectory(destination) {
41
+ const gitignorePath = nodePath.join(destination, ".gitignore");
42
+ const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
43
+ debug("ensuring the directory containing .gitgnore exists");
44
+ await ensureDirectoryExists(gitignorePath);
45
+ debug("creating the .gitignore file if it doesn't already exist");
46
+ if (!existsSync(gitignorePath)) {
47
+ await fs.writeFile(gitignorePath, ".cache\n", "utf8");
48
+ }
49
+ debug("creating the .cache/README.md file");
50
+ ensureDirectoryExists(cacheReadmePath);
51
+ await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
52
+ }
53
+ /**
54
+ * Reads and returns the `#/paths` requirement from `specification`.
55
+ *
56
+ * Writes a diagnostic message to stderr and returns an empty set when the
57
+ * `paths` key is missing or cannot be read.
58
+ *
59
+ * @param specification - The loaded OpenAPI specification.
60
+ */
61
+ async getPathsFromSpecification(specification) {
62
+ try {
63
+ return specification.getRequirement("#/paths") ?? new Set();
64
+ }
65
+ catch (error) {
66
+ process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
67
+ return undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Runs the main code-generation pipeline once and resolves when complete.
72
+ *
73
+ * Loads the OpenAPI spec from `openapiPath`, optionally prunes defunct route
74
+ * files, registers all path operations as {@link OperationCoder} exports, and
75
+ * writes the resulting TypeScript files to `destination`.
76
+ *
77
+ * @param repository - Injectable repository instance; defaults to a fresh one
78
+ * (primarily useful in tests).
79
+ */
80
+ async generate(repository = new Repository()) {
81
+ const { destination } = this;
82
+ debug("generating code from %s to %s", this.openapiPath, destination);
83
+ debug("initializing the .cache directory");
84
+ await this.buildCacheDirectory(destination);
85
+ debug("done initializing the .cache directory");
86
+ debug("creating specification from %s", this.openapiPath);
87
+ const specification = await Specification.fromFile(this.openapiPath);
88
+ debug("created specification: $o", specification);
89
+ debug("reading the #/paths from the specification");
90
+ const paths = await this.getPathsFromSpecification(specification);
91
+ debug("got %i paths", paths?.map?.length ?? 0);
92
+ if (this.generateOptions.prune && this.generateOptions.routes) {
93
+ debug("pruning defunct route files");
94
+ await pruneRoutes(destination, paths.map((_v, key) => key));
95
+ debug("done pruning");
96
+ }
97
+ const securityRequirement = specification.getRequirement("#/components/securitySchemes");
98
+ const securitySchemes = Object.values(securityRequirement?.data ?? {});
99
+ const HTTP_VERBS = new Set([
100
+ "get",
101
+ "put",
102
+ "post",
103
+ "delete",
104
+ "options",
105
+ "head",
106
+ "patch",
107
+ "trace",
108
+ ]);
109
+ paths.forEach((pathDefinition, key) => {
110
+ debug("processing path %s", key);
111
+ const path = key === "/" ? "/index" : key;
112
+ pathDefinition.forEach((operation, requestMethod) => {
113
+ if (!HTTP_VERBS.has(requestMethod)) {
114
+ return;
115
+ }
116
+ repository
117
+ .get(`routes${path}.ts`)
118
+ .export(new OperationCoder(operation, requestMethod, securitySchemes));
119
+ });
120
+ });
121
+ debug("telling the repository to write the files to %s", destination);
122
+ await repository.writeFiles(destination, this.generateOptions);
123
+ debug("finished writing the files");
18
124
  }
125
+ /**
126
+ * Starts watching the OpenAPI document for changes.
127
+ *
128
+ * Has no effect when `openApiPath` is a URL (HTTP sources are not watched).
129
+ * Resolves once the watcher is ready.
130
+ */
19
131
  async watch() {
20
132
  if (this.openapiPath.startsWith("http")) {
21
133
  return;
22
134
  }
23
135
  this.watcher = watch(this.openapiPath, CHOKIDAR_OPTIONS).on("change", () => {
24
- void generate(this.openapiPath, this.destination, this.generateOptions).then(() => {
136
+ void this.generate().then(() => {
25
137
  this.dispatchEvent(new Event("generate"));
26
138
  return true;
27
139
  }, () => {
@@ -31,6 +143,7 @@ export class CodeGenerator extends EventTarget {
31
143
  });
32
144
  await waitForEvent(this.watcher, "ready");
33
145
  }
146
+ /** Closes the file-system watcher. */
34
147
  async stopWatching() {
35
148
  await this.watcher?.close();
36
149
  }
@@ -1,29 +1,84 @@
1
+ import { RESERVED_WORDS } from "./reserved-words.js";
2
+ /**
3
+ * Base class for all code-generation helpers in the TypeScript generator.
4
+ *
5
+ * A `Coder` wraps a single {@link Requirement} node from the OpenAPI spec and
6
+ * knows how to emit TypeScript code for it. Subclasses override
7
+ * {@link writeCode} to produce the actual source text.
8
+ *
9
+ * Coders are used by {@link Script} and {@link Repository} to lazily generate
10
+ * exports and imports, resolving `$ref` references through the
11
+ * {@link Specification} before writing.
12
+ */
1
13
  export class Coder {
2
14
  requirement;
3
15
  constructor(requirement) {
4
16
  this.requirement = requirement;
5
17
  }
18
+ /**
19
+ * A stable cache key for this coder, composed of the constructor name and
20
+ * either the `$ref` value (for references) or the requirement URL.
21
+ */
6
22
  get id() {
7
23
  if (this.requirement.isReference) {
8
24
  return `${this.constructor.name}@${this.requirement.data["$ref"]}`;
9
25
  }
10
26
  return `${this.constructor.name}@${this.requirement.url}`;
11
27
  }
28
+ /**
29
+ * Optional preamble emitted before the `export` keyword.
30
+ *
31
+ * Subclasses can return a string (e.g. a type alias) that must appear in the
32
+ * output before this coder's export statement.
33
+ *
34
+ * @param _path - The path of the script being written (unused in base class).
35
+ */
12
36
  beforeExport(_path) {
13
37
  return "";
14
38
  }
39
+ /**
40
+ * Returns a JSDoc comment block to be placed immediately before the export.
41
+ *
42
+ * Returns `""` by default; subclasses override this to surface OpenAPI
43
+ * metadata (description, summary, examples, etc.).
44
+ */
15
45
  jsdoc() {
16
46
  return "";
17
47
  }
48
+ /**
49
+ * Writes this coder's contribution to `script`.
50
+ *
51
+ * When the requirement is a `$ref`, delegates to {@link Script.import} so
52
+ * the reference target is exported from its own module. Otherwise calls
53
+ * {@link writeCode}.
54
+ *
55
+ * @param script - The script being assembled.
56
+ * @returns The TypeScript source text for this coder's export value.
57
+ */
18
58
  write(script) {
19
59
  if (this.requirement.isReference) {
20
60
  return script.import(this);
21
61
  }
22
62
  return this.writeCode(script);
23
63
  }
64
+ /**
65
+ * Generates the TypeScript source text for this coder's value.
66
+ *
67
+ * This method is abstract — subclasses **must** override it.
68
+ *
69
+ * @param _script - The script being assembled.
70
+ * @throws Always — callers should never reach the base implementation.
71
+ */
24
72
  writeCode(_script) {
25
73
  throw new Error("write() is abstract and should be overwritten by a subclass");
26
74
  }
75
+ /**
76
+ * Resolves `$ref` references by returning the target coder.
77
+ *
78
+ * When this coder's requirement is not a reference, returns `this`.
79
+ * Otherwise loads the referenced requirement and wraps it in an instance of
80
+ * the same concrete coder class.
81
+ */
27
82
  async delegate() {
28
83
  if (!this.requirement.isReference) {
29
84
  return this;
@@ -31,21 +86,44 @@ export class Coder {
31
86
  const requirement = await this.requirement.reference();
32
87
  return new this.constructor(requirement);
33
88
  }
89
+ /**
90
+ * Generator that yields candidate export names for this coder.
91
+ *
92
+ * The first name is derived from the last path segment of the requirement
93
+ * URL, sanitised to be a valid TypeScript identifier. Subsequent names have
94
+ * an incrementing numeric suffix to resolve collisions.
95
+ *
96
+ * @param rawName - Override the starting name (used by subclasses).
97
+ */
34
98
  *names(rawName = this.requirement.url.split("/").at(-1)) {
35
99
  const name = rawName
36
100
  .replace(/^\d/u, (digit) => `_${digit}`)
37
101
  .replaceAll(/[^\w$]/gu, "_");
38
- yield name;
102
+ const baseName = RESERVED_WORDS.has(name) ? `${name}_` : name;
103
+ yield baseName;
39
104
  let index = 1;
40
105
  const MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP = 100;
41
106
  while (index < MAX_NAMES_TO_GENERATE_BEFORE_GIVING_UP) {
42
107
  index += 1;
43
- yield name + index;
108
+ yield baseName + index;
44
109
  }
45
110
  }
111
+ /**
112
+ * Returns an optional TypeScript type annotation string to be placed between
113
+ * the export name and its value (`export const name: <type> = value`).
114
+ *
115
+ * Returns `""` by default.
116
+ */
46
117
  typeDeclaration(_namespace, _script) {
47
118
  return "";
48
119
  }
120
+ /**
121
+ * Returns the repository-relative path of the script where this coder's
122
+ * export should live when imported by another script.
123
+ *
124
+ * Subclasses override this to place type exports in `types/paths/…` and
125
+ * route exports in `routes/…`.
126
+ */
49
127
  modulePath() {
50
128
  return "did-not-override-coder-modulePath.ts";
51
129
  }
@@ -1,6 +1,16 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
3
  import { OperationTypeCoder, } from "./operation-type-coder.js";
4
+ /**
5
+ * Generates the default route handler stub for a single OpenAPI operation.
6
+ *
7
+ * The generated stub calls `$.response[statusCode].random()` (or `.empty()`)
8
+ * for the first response defined in the spec. It is only written when no
9
+ * handler file exists yet — users are expected to replace it with real logic.
10
+ *
11
+ * The corresponding TypeScript type is emitted by {@link OperationTypeCoder}
12
+ * into the `types/paths/…` tree.
13
+ */
4
14
  export class OperationCoder extends Coder {
5
15
  requestMethod;
6
16
  securitySchemes;
@@ -22,7 +32,7 @@ export class OperationCoder extends Coder {
22
32
  if (firstResponse === undefined ||
23
33
  !("content" in firstResponse || "schema" in firstResponse)) {
24
34
  return `async ($) => {
25
- return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}];
35
+ return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].empty();
26
36
  }`;
27
37
  }
28
38
  return `async ($) => {
@@ -38,8 +48,6 @@ export class OperationCoder extends Coder {
38
48
  .split("/")
39
49
  .at(-2)
40
50
  .replaceAll("~1", "/");
41
- return `${nodePath
42
- .join("routes", pathString)
43
- .replaceAll("\\", "/")}.types.ts`;
51
+ return `${pathJoin("routes", pathString)}.types.ts`;
44
52
  }
45
53
  }
@@ -1,62 +1,14 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
3
3
  import { buildJsDoc } from "./jsdoc.js";
4
4
  import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
5
5
  import { ParametersTypeCoder } from "./parameters-type-coder.js";
6
6
  import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
7
+ import { RESERVED_WORDS } from "./reserved-words.js";
7
8
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
8
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
9
10
  import { TypeCoder } from "./type-coder.js";
10
11
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
11
- const RESERVED_WORDS = new Set([
12
- "break",
13
- "case",
14
- "catch",
15
- "class",
16
- "const",
17
- "continue",
18
- "debugger",
19
- "default",
20
- "delete",
21
- "do",
22
- "else",
23
- "export",
24
- "extends",
25
- "false",
26
- "finally",
27
- "for",
28
- "function",
29
- "if",
30
- "import",
31
- "in",
32
- "instanceof",
33
- "new",
34
- "null",
35
- "return",
36
- "static",
37
- "super",
38
- "switch",
39
- "this",
40
- "throw",
41
- "true",
42
- "try",
43
- "typeof",
44
- "var",
45
- "void",
46
- "while",
47
- "with",
48
- "yield",
49
- "await",
50
- "enum",
51
- "implements",
52
- "interface",
53
- "let",
54
- "package",
55
- "private",
56
- "protected",
57
- "public",
58
- "type",
59
- ]);
60
12
  function sanitizeIdentifier(value) {
61
13
  // Treat any run of non-identifier characters as a camelCase separator
62
14
  let result = value.replaceAll(/[^\w$]+(?<next>.)/gu, (_, char) => char.toUpperCase());
@@ -72,6 +24,15 @@ function sanitizeIdentifier(value) {
72
24
  }
73
25
  return result || "_";
74
26
  }
27
+ /**
28
+ * Generates the TypeScript type for a single OpenAPI operation.
29
+ *
30
+ * The emitted type describes the function signature that a Counterfact route
31
+ * handler must satisfy, including strongly-typed `query`, `path`, `headers`,
32
+ * `cookie`, `body`, `context`, `response`, and `user` arguments.
33
+ *
34
+ * Output is written to `types/paths/<route>.types.ts`.
35
+ */
75
36
  export class OperationTypeCoder extends TypeCoder {
76
37
  requestMethod;
77
38
  securitySchemes;
@@ -83,6 +44,10 @@ export class OperationTypeCoder extends TypeCoder {
83
44
  this.requestMethod = requestMethod;
84
45
  this.securitySchemes = securitySchemes;
85
46
  }
47
+ /**
48
+ * Returns the base identifier for this operation, derived from its
49
+ * `operationId` (sanitised) or falling back to `HTTP_<METHOD>`.
50
+ */
86
51
  getOperationBaseName() {
87
52
  const operationId = this.requirement.get("operationId")?.data;
88
53
  return operationId
@@ -95,6 +60,19 @@ export class OperationTypeCoder extends TypeCoder {
95
60
  names() {
96
61
  return super.names(this.getOperationBaseName());
97
62
  }
63
+ /**
64
+ * Generates and exports a named parameter type (e.g. `ListPets_Query`) from
65
+ * `modulePath` and returns the exported type name.
66
+ *
67
+ * Returns `"never"` without creating an export when `inlineType` is
68
+ * `"never"`.
69
+ *
70
+ * @param script - The script being assembled.
71
+ * @param parameterKind - `"query"`, `"path"`, `"headers"`, or `"cookie"`.
72
+ * @param inlineType - The inline TypeScript type string to export.
73
+ * @param baseName - The base identifier prefix for the exported type name.
74
+ * @param modulePath - The repository-relative path of the type file.
75
+ */
98
76
  exportParameterType(script, parameterKind, inlineType, baseName, modulePath) {
99
77
  if (inlineType === "never") {
100
78
  return "never";
@@ -105,6 +83,11 @@ export class OperationTypeCoder extends TypeCoder {
105
83
  coder._modulePath = modulePath;
106
84
  return script.export(coder, true);
107
85
  }
86
+ /**
87
+ * Returns the union of all possible response type shapes for this operation.
88
+ *
89
+ * @param script - The script being assembled (used to resolve imports).
90
+ */
108
91
  responseTypes(script) {
109
92
  return this.requirement
110
93
  .get("responses")
@@ -144,10 +127,14 @@ export class OperationTypeCoder extends TypeCoder {
144
127
  .split("/")
145
128
  .at(-2)
146
129
  .replaceAll("~1", "/");
147
- return `${nodePath
148
- .join("types/paths", pathString === "/" ? "/index" : pathString)
149
- .replaceAll("\\", "/")}.types.ts`;
130
+ return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
150
131
  }
132
+ /**
133
+ * Returns the TypeScript type for the `user` argument.
134
+ *
135
+ * When the operation is protected by HTTP Basic auth, the type is
136
+ * `{username?: string, password?: string}`. Otherwise it is `"never"`.
137
+ */
151
138
  userType() {
152
139
  if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
153
140
  return "{username?: string, password?: string}";
@@ -1,4 +1,4 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { buildJsDoc } from "./jsdoc.js";
3
3
  import { SchemaTypeCoder } from "./schema-type-coder.js";
4
4
  import { TypeCoder } from "./type-coder.js";
@@ -39,8 +39,6 @@ export class ParametersTypeCoder extends TypeCoder {
39
39
  .split("/")
40
40
  .at(-2)
41
41
  .replaceAll("~1", "/");
42
- return `${nodePath
43
- .join("parameters", pathString)
44
- .replaceAll("\\", "/")}.types.ts`;
42
+ return `${pathJoin("parameters", pathString)}.types.ts`;
45
43
  }
46
44
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
3
  import createDebug from "debug";
4
+ import { toForwardSlashPath } from "../util/forward-slash-path.js";
4
5
  const debug = createDebug("counterfact:typescript-generator:prune");
5
6
  /**
6
7
  * Collects all .ts route files in a directory recursively.
@@ -87,7 +88,7 @@ export async function pruneRoutes(destination, openApiPaths) {
87
88
  debug("actual route files: %o", actualFiles);
88
89
  let prunedCount = 0;
89
90
  for (const file of actualFiles) {
90
- const normalizedFile = file.replaceAll("\\", "/");
91
+ const normalizedFile = toForwardSlashPath(file);
91
92
  if (!expectedFiles.has(normalizedFile)) {
92
93
  const fullPath = nodePath.join(routesDir, file);
93
94
  debug("pruning %s", fullPath);
@@ -1,5 +1,5 @@
1
1
  export const READ_ONLY_COMMENTS = [
2
2
  "This code was automatically generated from an OpenAPI description.",
3
3
  "Do not edit this file. Edit the OpenAPI file instead.",
4
- "For more information, see https://github.com/pmcelhaney/counterfact/blob/main/docs/faq-generated-code.md",
4
+ "For more information, see https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md",
5
5
  ];
@@ -4,17 +4,33 @@ import nodePath, { dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import createDebug from "debug";
6
6
  import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
7
+ import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
7
8
  import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
8
9
  import { Script } from "./script.js";
9
10
  import { escapePathForWindows } from "../util/windows-escape.js";
10
11
  const debug = createDebug("counterfact:server:repository");
11
- const __dirname = dirname(fileURLToPath(import.meta.url)).replaceAll("\\", "/");
12
+ const __dirname = toForwardSlashPath(dirname(fileURLToPath(import.meta.url)));
12
13
  debug("dirname is %s", __dirname);
14
+ /**
15
+ * Collection of {@link Script} objects keyed by their repository-relative
16
+ * path.
17
+ *
18
+ * Coders call {@link get} to obtain (or create) the script where they should
19
+ * export their generated TypeScript. After all coders have been registered,
20
+ * {@link writeFiles} waits for every script to finish and writes the output to
21
+ * disk.
22
+ */
13
23
  export class Repository {
14
24
  scripts;
15
25
  constructor() {
16
26
  this.scripts = new Map();
17
27
  }
28
+ /**
29
+ * Returns the {@link Script} for `path`, creating it if it does not yet
30
+ * exist.
31
+ *
32
+ * @param path - Repository-relative path (e.g. `"routes/pets.ts"`).
33
+ */
18
34
  get(path) {
19
35
  debug("getting script at %s", path);
20
36
  if (this.scripts.has(path)) {
@@ -26,12 +42,22 @@ export class Repository {
26
42
  this.scripts.set(path, script);
27
43
  return script;
28
44
  }
45
+ /** Waits until all scripts have resolved all of their pending export promises. */
29
46
  async finished() {
30
47
  while (Array.from(this.scripts.values()).some((script) => script.isInProgress())) {
31
48
  debug("waiting for %i scripts to finish", this.scripts.size);
32
49
  await Promise.all(Array.from(this.scripts.values(), (script) => script.finished()));
33
50
  }
34
51
  }
52
+ /**
53
+ * Copies the compiled `counterfact-types` directory from the Counterfact
54
+ * distribution into the generated output tree.
55
+ *
56
+ * Returns `false` when the source directory does not exist (e.g. running
57
+ * from source without a prior build).
58
+ *
59
+ * @param destination - The root of the generated output tree.
60
+ */
35
61
  async copyCoreFiles(destination) {
36
62
  const sourcePath = nodePath.join(__dirname, "../../dist/server/counterfact-types");
37
63
  const destinationPath = nodePath.join(destination, "counterfact-types");
@@ -40,13 +66,23 @@ export class Repository {
40
66
  }
41
67
  return fs.cp(sourcePath, destinationPath, { recursive: true });
42
68
  }
69
+ /**
70
+ * Waits for all scripts to finish, then writes each one to disk.
71
+ *
72
+ * Route files (`routes/…`) are never overwritten if they already exist on
73
+ * disk, preserving user edits. Type files (`types/…`) are always
74
+ * overwritten.
75
+ *
76
+ * @param destination - Absolute path to the output root directory.
77
+ * @param options - Controls which artefacts are written.
78
+ */
43
79
  async writeFiles(destination, { routes, types }) {
44
80
  debug("waiting for %i or more scripts to finish before writing files", this.scripts.size);
45
81
  await this.finished();
46
82
  debug("all %i scripts are finished", this.scripts.size);
47
83
  const writeFiles = Array.from(this.scripts.entries(), async ([path, script]) => {
48
84
  const contents = await script.contents();
49
- const fullPath = escapePathForWindows(nodePath.join(destination, path).replaceAll("\\", "/"));
85
+ const fullPath = escapePathForWindows(pathJoin(destination, path));
50
86
  await ensureDirectoryExists(fullPath);
51
87
  const shouldWriteRoutes = routes && path.startsWith("routes");
52
88
  const shouldWriteTypes = types && !path.startsWith("routes");
@@ -70,37 +106,57 @@ export class Repository {
70
106
  await this.createDefaultContextFile(destination);
71
107
  }
72
108
  }
109
+ /**
110
+ * Creates the default `routes/_.context.ts` file if it does not already
111
+ * exist.
112
+ *
113
+ * @param destination - Absolute path to the output root directory.
114
+ */
73
115
  async createDefaultContextFile(destination) {
74
116
  const contextFilePath = nodePath.join(destination, "routes", "_.context.ts");
75
117
  if (existsSync(contextFilePath)) {
76
118
  return;
77
119
  }
78
120
  await ensureDirectoryExists(contextFilePath);
79
- await fs.writeFile(contextFilePath, `/**
80
- * This is the default context for Counterfact.
81
- *
82
- * It defines the context object in the REPL
83
- * and the $.context object in the code.
84
- *
85
- * Add properties and methods to suit your needs.
86
- *
87
- * See https://counterfact.dev/docs/usage.html#working-with-state-the-codecontextcode-object-and-codecontexttscode
88
- */
89
- export class Context {
121
+ await fs.writeFile(contextFilePath, `import type { Context$ } from "../types/_.context.js";
122
+
123
+ /**
124
+ * This is the default context for Counterfact.
125
+ *
126
+ * It defines the context object in the REPL
127
+ * and the $.context object in the code.
128
+ *
129
+ * Add properties and methods to suit your needs.
130
+ *
131
+ * See https://github.com/counterfact/api-simulator/blob/main/docs/features/state.md
132
+ */
90
133
 
134
+ export class Context {
135
+ constructor($: Context$) {
136
+ void $;
137
+ }
91
138
  }
92
139
  `);
93
140
  }
141
+ /**
142
+ * Returns the path of the `_.context.ts` file that is nearest to `path` in
143
+ * the directory hierarchy, relative to the script's output directory.
144
+ *
145
+ * @param destination - Output root directory.
146
+ * @param path - Repository-relative path of the script being generated.
147
+ */
94
148
  findContextPath(destination, path) {
95
- return nodePath
96
- .relative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path))
97
- .replaceAll("\\", "/");
149
+ return pathRelative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path));
98
150
  }
151
+ /**
152
+ * Walks up the directory tree from `path` to find the nearest
153
+ * `_.context.ts` file, falling back to `routes/_.context.ts` at the root.
154
+ *
155
+ * @param destination - Output root directory.
156
+ * @param path - Repository-relative path to start from.
157
+ */
99
158
  nearestContextFile(destination, path) {
100
- const directory = nodePath
101
- .dirname(path)
102
- .replaceAll("\\", "/")
103
- .replace("types/paths", "routes");
159
+ const directory = pathDirname(path).replace("types/paths", "routes");
104
160
  const candidate = nodePath.join(destination, directory, "_.context.ts");
105
161
  if (directory.length <= 1) {
106
162
  // No _context.ts was found so import the one that should be in the root