counterfact 2.5.0 → 2.6.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 (43) hide show
  1. package/README.md +1 -0
  2. package/bin/README.md +1 -0
  3. package/bin/counterfact.js +164 -23
  4. package/bin/register-ts-loader.mjs +17 -0
  5. package/bin/ts-loader.mjs +31 -0
  6. package/dist/app.js +23 -12
  7. package/dist/migrate/update-route-types.js +47 -29
  8. package/dist/repl/raw-http-client.js +14 -14
  9. package/dist/repl/repl.js +24 -2
  10. package/dist/repl/route-builder.js +270 -0
  11. package/dist/server/config.js +1 -1
  12. package/dist/server/context-registry.js +27 -3
  13. package/dist/server/counterfact-types/index.ts +11 -1
  14. package/dist/server/determine-module-kind.js +1 -1
  15. package/dist/server/dispatcher.js +21 -10
  16. package/dist/server/file-discovery.js +34 -0
  17. package/dist/server/middleware-detector.js +8 -0
  18. package/dist/server/module-dependency-graph.js +4 -1
  19. package/dist/server/module-loader.js +7 -31
  20. package/dist/server/module-tree.js +26 -23
  21. package/dist/server/openapi-middleware.js +2 -2
  22. package/dist/server/registry.js +2 -2
  23. package/dist/server/request-validator.js +61 -0
  24. package/dist/server/transpiler.js +13 -5
  25. package/dist/typescript-generator/coder.js +8 -4
  26. package/dist/typescript-generator/generate.js +3 -3
  27. package/dist/typescript-generator/jsdoc.js +45 -0
  28. package/dist/typescript-generator/operation-coder.js +8 -5
  29. package/dist/typescript-generator/operation-type-coder.js +21 -11
  30. package/dist/typescript-generator/parameter-export-type-coder.js +4 -1
  31. package/dist/typescript-generator/parameters-type-coder.js +6 -1
  32. package/dist/typescript-generator/prune.js +11 -11
  33. package/dist/typescript-generator/repository.js +1 -1
  34. package/dist/typescript-generator/requirement.js +10 -5
  35. package/dist/typescript-generator/response-type-coder.js +10 -5
  36. package/dist/typescript-generator/responses-type-coder.js +1 -0
  37. package/dist/typescript-generator/schema-coder.js +5 -5
  38. package/dist/typescript-generator/schema-type-coder.js +23 -12
  39. package/dist/typescript-generator/script.js +18 -5
  40. package/dist/typescript-generator/specification.js +13 -4
  41. package/dist/util/ensure-directory-exists.js +1 -1
  42. package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
  43. package/package.json +7 -6
@@ -3,8 +3,8 @@ function isDirectory(test) {
3
3
  }
4
4
  export class ModuleTree {
5
5
  root = {
6
- directories: {},
7
- files: {},
6
+ directories: new Map(),
7
+ files: new Map(),
8
8
  isWildcard: false,
9
9
  name: "",
10
10
  rawName: "",
@@ -17,16 +17,19 @@ export class ModuleTree {
17
17
  if (remainingSegments.length === 0) {
18
18
  return directory;
19
19
  }
20
- const isNewDirectory = directory.directories[segment.toLowerCase()] === undefined;
21
- const nextDirectory = (directory.directories[segment.toLowerCase()] ??= {
22
- directories: {},
23
- files: {},
24
- isWildcard: segment.startsWith("{"),
25
- name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
26
- rawName: segment,
27
- });
20
+ const isNewDirectory = !directory.directories.has(segment.toLowerCase());
21
+ if (isNewDirectory) {
22
+ directory.directories.set(segment.toLowerCase(), {
23
+ directories: new Map(),
24
+ files: new Map(),
25
+ isWildcard: segment.startsWith("{"),
26
+ name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
27
+ rawName: segment,
28
+ });
29
+ }
30
+ const nextDirectory = directory.directories.get(segment.toLowerCase());
28
31
  if (isNewDirectory && segment.startsWith("{")) {
29
- const ambiguousWildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
32
+ const ambiguousWildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
30
33
  if (ambiguousWildcardDirectories.length > 1) {
31
34
  process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard directories exist at the same level: ${ambiguousWildcardDirectories.map((d) => d.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
32
35
  }
@@ -42,14 +45,14 @@ export class ModuleTree {
42
45
  if (filename === undefined) {
43
46
  throw new Error("The file name (the last segment of the URL) is undefined. This is theoretically impossible but TypeScript can't enforce it.");
44
47
  }
45
- targetDirectory.files[filename] = {
48
+ targetDirectory.files.set(filename, {
46
49
  isWildcard: filename.startsWith("{"),
47
50
  module,
48
51
  name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
49
52
  rawName: filename,
50
- };
53
+ });
51
54
  if (filename.startsWith("{")) {
52
- const ambiguousWildcardFiles = Object.values(targetDirectory.files).filter((file) => file.isWildcard);
55
+ const ambiguousWildcardFiles = Array.from(targetDirectory.files.values()).filter((file) => file.isWildcard);
53
56
  if (ambiguousWildcardFiles.length > 1) {
54
57
  process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard files exist at the same path level: ${ambiguousWildcardFiles.map((f) => f.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
55
58
  }
@@ -67,10 +70,10 @@ export class ModuleTree {
67
70
  return;
68
71
  }
69
72
  if (remainingSegments.length === 0) {
70
- delete directory.files[segment.toLowerCase()];
73
+ directory.files.delete(segment.toLowerCase());
71
74
  return;
72
75
  }
73
- this.removeModuleFromDirectory(directory.directories[segment.toLowerCase()], remainingSegments);
76
+ this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
74
77
  }
75
78
  remove(url) {
76
79
  const segments = url.split("/").slice(1);
@@ -81,14 +84,14 @@ export class ModuleTree {
81
84
  }
82
85
  buildMatch(directory, segment, pathVariables, matchedPath, method) {
83
86
  function normalizedSegment(segment, directory) {
84
- for (const file in directory.files) {
87
+ for (const file of directory.files.keys()) {
85
88
  if (file.toLowerCase() === segment.toLowerCase()) {
86
89
  return file;
87
90
  }
88
91
  }
89
92
  return "";
90
93
  }
91
- const exactMatchFile = directory.files[normalizedSegment(segment, directory)];
94
+ const exactMatchFile = directory.files.get(normalizedSegment(segment, directory));
92
95
  // If the URL segment literally matches a file key (e.g., requesting "/{x}"
93
96
  // as a literal URL value), exactMatchFile may be a wildcard file. In that
94
97
  // case, fall through to wildcard matching below.
@@ -99,7 +102,7 @@ export class ModuleTree {
99
102
  pathVariables,
100
103
  };
101
104
  }
102
- const wildcardFiles = Object.values(directory.files).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
105
+ const wildcardFiles = Array.from(directory.files.values()).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
103
106
  if (wildcardFiles.length > 1) {
104
107
  const firstWildcard = wildcardFiles[0];
105
108
  return {
@@ -144,11 +147,11 @@ export class ModuleTree {
144
147
  (remainingSegments.length === 1 && remainingSegments[0] === "")) {
145
148
  return this.buildMatch(directory, segment, pathVariables, matchedPath, method);
146
149
  }
147
- const exactMatch = directory.directories[segment.toLowerCase()];
150
+ const exactMatch = directory.directories.get(segment.toLowerCase());
148
151
  if (isDirectory(exactMatch)) {
149
152
  return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
150
153
  }
151
- const wildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
154
+ const wildcardDirectories = Array.from(directory.directories.values()).filter((subdirectory) => subdirectory.isWildcard);
152
155
  const wildcardMatches = [];
153
156
  for (const wildcardDirectory of wildcardDirectories) {
154
157
  const wildcardMatch = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
@@ -171,10 +174,10 @@ export class ModuleTree {
171
174
  get routes() {
172
175
  const routes = [];
173
176
  function traverse(directory, path) {
174
- Object.values(directory.directories).forEach((subdirectory) => {
177
+ directory.directories.forEach((subdirectory) => {
175
178
  traverse(subdirectory, `${path}/${subdirectory.rawName}`);
176
179
  });
177
- Object.values(directory.files).forEach((file) => {
180
+ directory.files.forEach((file) => {
178
181
  const methods = Object.entries(file.module).map(([method, implementation]) => [method, String(implementation)]);
179
182
  routes.push({
180
183
  methods: Object.fromEntries(methods),
@@ -1,5 +1,5 @@
1
1
  import { bundle } from "@apidevtools/json-schema-ref-parser";
2
- import yaml from "js-yaml";
2
+ import { dump } from "js-yaml";
3
3
  export function openapiMiddleware(openApiPath, url) {
4
4
  return async (ctx, next) => {
5
5
  if (ctx.URL.pathname === "/counterfact/openapi") {
@@ -11,7 +11,7 @@ export function openapiMiddleware(openApiPath, url) {
11
11
  });
12
12
  // OpenApi 2 support:
13
13
  openApiDocument.host = url;
14
- ctx.body = yaml.dump(openApiDocument);
14
+ ctx.body = dump(openApiDocument);
15
15
  return;
16
16
  }
17
17
  await next();
@@ -26,10 +26,10 @@ function castParameter(value, type) {
26
26
  }
27
27
  return value;
28
28
  }
29
- function castParameters(parameters = {}, parameterTypes = {}) {
29
+ function castParameters(parameters = {}, parameterTypes = new Map()) {
30
30
  const copy = {};
31
31
  Object.entries(parameters).forEach(([key, value]) => {
32
- copy[key] = castParameter(value, parameterTypes?.[key] ?? "string");
32
+ copy[key] = castParameter(value, parameterTypes.get(key) ?? "string");
33
33
  });
34
34
  return copy;
35
35
  }
@@ -0,0 +1,61 @@
1
+ import Ajv from "ajv";
2
+ const ajv = new Ajv({
3
+ allErrors: true,
4
+ unknownFormats: "ignore",
5
+ coerceTypes: false,
6
+ });
7
+ function findMissingRequired(parameters, location, values) {
8
+ return parameters
9
+ .filter((p) => p.in === location && p.required === true)
10
+ .filter((p) => !(p.name in values) || values[p.name] === undefined)
11
+ .map((p) => `${location} parameter '${p.name}' is required`);
12
+ }
13
+ export function validateRequest(operation, request) {
14
+ if (!operation) {
15
+ return { errors: [], valid: true };
16
+ }
17
+ const errors = [];
18
+ const parameters = operation.parameters ?? [];
19
+ // For query and header parameters, HTTP always delivers values as strings.
20
+ // Only check that required parameters are present; type coercion is handled
21
+ // by the registry before the route handler is called.
22
+ errors.push(...findMissingRequired(parameters, "query", request.query));
23
+ errors.push(...findMissingRequired(parameters, "header", request.headers));
24
+ // Validate request body (OpenAPI 3.x requestBody)
25
+ if (operation.requestBody?.content !== undefined) {
26
+ const schema = operation.requestBody.content["application/json"]?.schema ??
27
+ operation.requestBody.content["application/x-www-form-urlencoded"]
28
+ ?.schema;
29
+ if (schema !== undefined) {
30
+ const valid = ajv.validate(schema, request.body);
31
+ if (!valid && ajv.errors) {
32
+ for (const error of ajv.errors) {
33
+ const path = error.instancePath ??
34
+ error.dataPath ??
35
+ "";
36
+ errors.push(`body${path} ${error.message ?? "is invalid"}`);
37
+ }
38
+ }
39
+ }
40
+ else if (operation.requestBody.required === true && !request.body) {
41
+ errors.push("body is required");
42
+ }
43
+ }
44
+ // Validate request body (OpenAPI 2.x body parameter)
45
+ const bodyParam = parameters.find((p) => p.in === "body");
46
+ if (bodyParam?.schema !== undefined) {
47
+ const valid = ajv.validate(bodyParam.schema, request.body);
48
+ if (!valid && ajv.errors) {
49
+ for (const error of ajv.errors) {
50
+ const path = error.instancePath ??
51
+ error.dataPath ??
52
+ "";
53
+ errors.push(`body${path} ${error.message ?? "is invalid"}`);
54
+ }
55
+ }
56
+ }
57
+ return {
58
+ errors,
59
+ valid: errors.length === 0,
60
+ };
61
+ }
@@ -67,12 +67,20 @@ export class Transpiler extends EventTarget {
67
67
  async transpileFile(eventName, sourcePath, destinationPath) {
68
68
  ensureDirectoryExists(destinationPath);
69
69
  const source = await fs.readFile(sourcePath, "utf8");
70
- const result = ts.transpileModule(source, {
70
+ const transpileOutput = ts.transpileModule(source, {
71
71
  compilerOptions: {
72
72
  module: ts.ModuleKind[this.moduleKind.toLowerCase() === "module" ? "ES2022" : "CommonJS"],
73
73
  target: ts.ScriptTarget.ES2015,
74
74
  },
75
- }).outputText;
75
+ reportDiagnostics: true,
76
+ });
77
+ if (transpileOutput.diagnostics?.length) {
78
+ for (const diagnostic of transpileOutput.diagnostics) {
79
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
80
+ debug("TypeScript diagnostic in %s: %s", sourcePath, message);
81
+ }
82
+ }
83
+ const result = transpileOutput.outputText;
76
84
  const fullDestination = nodePath
77
85
  .join(sourcePath
78
86
  .replace(this.sourcePath, this.destinationPath)
@@ -82,10 +90,10 @@ export class Transpiler extends EventTarget {
82
90
  try {
83
91
  await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
84
92
  }
85
- catch {
86
- debug("error transpiling %s", fullDestination);
93
+ catch (error) {
94
+ debug("error writing transpiled output to %s: %o", fullDestination, error);
87
95
  this.dispatchEvent(new Event("error"));
88
- throw new Error("could not transpile");
96
+ throw new Error("could not transpile", { cause: error });
89
97
  }
90
98
  this.dispatchEvent(new Event("write"));
91
99
  }
@@ -1,14 +1,18 @@
1
1
  export class Coder {
2
+ requirement;
2
3
  constructor(requirement) {
3
4
  this.requirement = requirement;
4
5
  }
5
6
  get id() {
6
7
  if (this.requirement.isReference) {
7
- return `${this.constructor.name}@${this.requirement.$ref}`;
8
+ return `${this.constructor.name}@${this.requirement.data["$ref"]}`;
8
9
  }
9
10
  return `${this.constructor.name}@${this.requirement.url}`;
10
11
  }
11
- beforeExport() {
12
+ beforeExport(_path) {
13
+ return "";
14
+ }
15
+ jsdoc() {
12
16
  return "";
13
17
  }
14
18
  write(script) {
@@ -17,7 +21,7 @@ export class Coder {
17
21
  }
18
22
  return this.writeCode(script);
19
23
  }
20
- writeCode() {
24
+ writeCode(_script) {
21
25
  throw new Error("write() is abstract and should be overwritten by a subclass");
22
26
  }
23
27
  async delegate() {
@@ -39,7 +43,7 @@ export class Coder {
39
43
  yield name + index;
40
44
  }
41
45
  }
42
- typeDeclaration() {
46
+ typeDeclaration(_namespace, _script) {
43
47
  return "";
44
48
  }
45
49
  modulePath() {
@@ -27,7 +27,7 @@ async function getPathsFromSpecification(specification) {
27
27
  }
28
28
  catch (error) {
29
29
  process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
30
- return new Set();
30
+ return undefined;
31
31
  }
32
32
  }
33
33
  export async function generate(source, destination, generateOptions, repository = new Repository()) {
@@ -40,10 +40,10 @@ export async function generate(source, destination, generateOptions, repository
40
40
  debug("created specification: $o", specification);
41
41
  debug("reading the #/paths from the specification");
42
42
  const paths = await getPathsFromSpecification(specification);
43
- debug("got %i paths", paths.size);
43
+ debug("got %i paths", paths?.map?.length ?? 0);
44
44
  if (generateOptions.prune && generateOptions.routes) {
45
45
  debug("pruning defunct route files");
46
- await pruneRoutes(destination, paths.keys());
46
+ await pruneRoutes(destination, paths.map((_v, key) => key));
47
47
  debug("done pruning");
48
48
  }
49
49
  const securityRequirement = specification.getRequirement("#/components/securitySchemes");
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Builds a JSDoc comment string from OpenAPI schema metadata.
3
+ * Returns an empty string if there is no relevant metadata.
4
+ */
5
+ export function buildJsDoc(data) {
6
+ const lines = [];
7
+ const description = data["description"];
8
+ const summary = data["summary"];
9
+ const example = data["example"];
10
+ const examples = data["examples"];
11
+ const defaultValue = data["default"];
12
+ const format = data["format"];
13
+ const deprecated = data["deprecated"];
14
+ const mainText = description ?? summary;
15
+ if (mainText) {
16
+ // Escape */ to prevent prematurely closing the JSDoc block
17
+ const escaped = String(mainText).replace(/\*\//gu, "* /");
18
+ const textLines = escaped.split("\n");
19
+ for (const line of textLines) {
20
+ lines.push(` * ${line}`);
21
+ }
22
+ }
23
+ if (format !== undefined) {
24
+ lines.push(` * @format ${format}`);
25
+ }
26
+ if (defaultValue !== undefined) {
27
+ lines.push(` * @default ${JSON.stringify(defaultValue)}`);
28
+ }
29
+ // Use scalar `example`, or fall back to the first value from `examples`
30
+ const exampleValue = example !== undefined
31
+ ? example
32
+ : examples !== undefined
33
+ ? Object.values(examples)[0]?.value
34
+ : undefined;
35
+ if (exampleValue !== undefined) {
36
+ lines.push(` * @example ${JSON.stringify(exampleValue)}`);
37
+ }
38
+ if (deprecated === true) {
39
+ lines.push(` * @deprecated`);
40
+ }
41
+ if (lines.length === 0) {
42
+ return "";
43
+ }
44
+ return `/**\n${lines.join("\n")}\n */\n`;
45
+ }
@@ -1,8 +1,10 @@
1
1
  import nodePath from "node:path";
2
2
  import { Coder } from "./coder.js";
3
- import { OperationTypeCoder } from "./operation-type-coder.js";
3
+ import { OperationTypeCoder, } from "./operation-type-coder.js";
4
4
  export class OperationCoder extends Coder {
5
- constructor(requirement, requestMethod, securitySchemes = {}) {
5
+ requestMethod;
6
+ securitySchemes;
7
+ constructor(requirement, requestMethod, securitySchemes = []) {
6
8
  super(requirement);
7
9
  if (requestMethod === undefined) {
8
10
  throw new Error("requestMethod is required");
@@ -15,9 +17,10 @@ export class OperationCoder extends Coder {
15
17
  }
16
18
  write() {
17
19
  const responses = this.requirement.get("responses");
18
- const [firstStatusCode] = responses.map((response, statusCode) => statusCode);
20
+ const [firstStatusCode] = responses.map((_response, statusCode) => statusCode);
19
21
  const [firstResponse] = responses.map((response) => response.data);
20
- if (!("content" in firstResponse || "schema" in firstResponse)) {
22
+ if (firstResponse === undefined ||
23
+ !("content" in firstResponse || "schema" in firstResponse)) {
21
24
  return `async ($) => {
22
25
  return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}];
23
26
  }`;
@@ -26,7 +29,7 @@ export class OperationCoder extends Coder {
26
29
  return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].random();
27
30
  }`;
28
31
  }
29
- typeDeclaration(namespace, script) {
32
+ typeDeclaration(_namespace, script) {
30
33
  const operationTypeCoder = new OperationTypeCoder(this.requirement, this.requestMethod, this.securitySchemes);
31
34
  return script.importType(operationTypeCoder);
32
35
  }
@@ -1,11 +1,12 @@
1
1
  import nodePath from "node:path";
2
2
  import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
3
+ import { buildJsDoc } from "./jsdoc.js";
4
+ import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
3
5
  import { ParametersTypeCoder } from "./parameters-type-coder.js";
4
6
  import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
5
7
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
6
8
  import { SchemaTypeCoder } from "./schema-type-coder.js";
7
9
  import { TypeCoder } from "./type-coder.js";
8
- import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
9
10
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
10
11
  const RESERVED_WORDS = new Set([
11
12
  "break",
@@ -72,6 +73,8 @@ function sanitizeIdentifier(value) {
72
73
  return result || "_";
73
74
  }
74
75
  export class OperationTypeCoder extends TypeCoder {
76
+ requestMethod;
77
+ securitySchemes;
75
78
  constructor(requirement, requestMethod, securitySchemes = []) {
76
79
  super(requirement);
77
80
  if (requestMethod === undefined) {
@@ -86,6 +89,9 @@ export class OperationTypeCoder extends TypeCoder {
86
89
  ? sanitizeIdentifier(operationId)
87
90
  : `HTTP_${this.requestMethod.toUpperCase()}`;
88
91
  }
92
+ jsdoc() {
93
+ return buildJsDoc(this.requirement.data);
94
+ }
89
95
  names() {
90
96
  return super.names(this.getOperationBaseName());
91
97
  }
@@ -114,15 +120,18 @@ export class OperationTypeCoder extends TypeCoder {
114
120
  }`);
115
121
  }
116
122
  if (response.has("schema")) {
117
- const produces = this.requirement?.get("produces")?.data ??
118
- this.requirement.specification.rootRequirement.get("produces").data;
119
- return produces
120
- .map((contentType) => `{
123
+ const producesReq = this.requirement?.get("produces") ??
124
+ this.requirement.specification?.rootRequirement?.get("produces");
125
+ const produces = producesReq?.data;
126
+ if (produces) {
127
+ return produces
128
+ .map((contentType) => `{
121
129
  status: ${status},
122
130
  contentType?: "${contentType}",
123
131
  body?: ${new SchemaTypeCoder(response.get("schema")).write(script)}
124
132
  }`)
125
- .join(" | ");
133
+ .join(" | ");
134
+ }
126
135
  }
127
136
  return `{
128
137
  status: ${status}
@@ -157,17 +166,18 @@ export class OperationTypeCoder extends TypeCoder {
157
166
  const pathType = new ParametersTypeCoder(parameters, "path").write(script);
158
167
  const headersType = new ParametersTypeCoder(parameters, "header").write(script);
159
168
  const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
160
- const bodyRequirement = this.requirement.get("consumes") ||
161
- this.requirement.specification?.rootRequirement?.get("consumes")
169
+ const bodyRequirement = (this.requirement.get("consumes") ??
170
+ this.requirement.specification?.rootRequirement?.get("consumes"))
162
171
  ? parameters
163
- ?.find((parameter) => ["body", "formData"].includes(parameter.get("in").data))
172
+ ?.find((parameter) => ["body", "formData"].includes(parameter.get("in")?.data))
164
173
  ?.get("schema")
165
174
  : this.requirement.select("requestBody/content/application~1json/schema");
166
175
  const bodyType = bodyRequirement === undefined
167
176
  ? "never"
168
177
  : new SchemaTypeCoder(bodyRequirement).write(script);
169
- const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.requirement.get("produces")?.data ??
170
- this.requirement.specification?.rootRequirement?.get("produces")?.data).write(script);
178
+ const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), (this.requirement.get("produces")?.data ??
179
+ this.requirement.specification?.rootRequirement?.get("produces")
180
+ ?.data)).write(script);
171
181
  const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
172
182
  const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
173
183
  // Get the base name for this operation and export parameter types
@@ -1,6 +1,9 @@
1
1
  import { TypeCoder } from "./type-coder.js";
2
- // Helper class for exporting parameter types
3
2
  export class ParameterExportTypeCoder extends TypeCoder {
3
+ _typeName;
4
+ _typeCode;
5
+ _parameterKind;
6
+ _modulePath;
4
7
  constructor(requirement, typeName, typeCode, parameterKind) {
5
8
  super(requirement);
6
9
  this._typeName = typeName;
@@ -1,7 +1,9 @@
1
1
  import nodePath from "node:path";
2
+ import { buildJsDoc } from "./jsdoc.js";
2
3
  import { SchemaTypeCoder } from "./schema-type-coder.js";
3
4
  import { TypeCoder } from "./type-coder.js";
4
5
  export class ParametersTypeCoder extends TypeCoder {
6
+ placement;
5
7
  constructor(requirement, placement) {
6
8
  super(requirement);
7
9
  this.placement = placement;
@@ -22,7 +24,10 @@ export class ParametersTypeCoder extends TypeCoder {
22
24
  const schema = parameter.has("schema")
23
25
  ? parameter.get("schema")
24
26
  : parameter;
25
- return `"${name}"${optionalFlag}: ${new SchemaTypeCoder(schema).write(script)}`;
27
+ const comment = buildJsDoc(parameter.data);
28
+ const commentPrefix = comment ? `\n${comment}` : "";
29
+ const typeString = new SchemaTypeCoder(schema).write(script);
30
+ return `${commentPrefix}"${name}"${optionalFlag}: ${typeString}`;
26
31
  });
27
32
  if (typeDefinitions.length === 0) {
28
33
  return "never";
@@ -5,9 +5,9 @@ const debug = createDebug("counterfact:typescript-generator:prune");
5
5
  /**
6
6
  * Collects all .ts route files in a directory recursively.
7
7
  * Context files (_.context.ts) are excluded.
8
- * @param {string} routesDir - Path to routes directory
9
- * @param {string} currentPath - Current subdirectory being processed (relative to routesDir)
10
- * @returns {Promise<string[]>} - Array of relative paths (using forward slashes)
8
+ * @param routesDir - Path to routes directory
9
+ * @param currentPath - Current subdirectory being processed (relative to routesDir)
10
+ * @returns Array of relative paths (using forward slashes)
11
11
  */
12
12
  async function collectRouteFiles(routesDir, currentPath = "") {
13
13
  const files = [];
@@ -37,15 +37,16 @@ async function collectRouteFiles(routesDir, currentPath = "") {
37
37
  }
38
38
  /**
39
39
  * Recursively removes empty directories under rootDir, but not rootDir itself.
40
- * @param {string} dir - Directory to check
41
- * @param {string} rootDir - Root directory that should never be removed
40
+ * @param dir - Directory to check
41
+ * @param rootDir - Root directory that should never be removed
42
42
  */
43
43
  async function removeEmptyDirectories(dir, rootDir) {
44
44
  let entries;
45
45
  try {
46
46
  entries = await fs.readdir(dir, { withFileTypes: true });
47
47
  }
48
- catch {
48
+ catch (error) {
49
+ debug("could not read directory %s: %o", dir, error);
49
50
  return;
50
51
  }
51
52
  for (const entry of entries) {
@@ -65,8 +66,7 @@ async function removeEmptyDirectories(dir, rootDir) {
65
66
  /**
66
67
  * Converts an OpenAPI path to the expected route file path (relative to routesDir).
67
68
  * e.g. "/pet/{id}" -> "pet/{id}.ts", "/" -> "index.ts"
68
- * @param {string} openApiPath
69
- * @returns {string}
69
+ * @param openApiPath - The OpenAPI path string
70
70
  */
71
71
  function openApiPathToRouteFile(openApiPath) {
72
72
  const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1);
@@ -75,9 +75,9 @@ function openApiPathToRouteFile(openApiPath) {
75
75
  /**
76
76
  * Prunes route files that no longer correspond to any path in the OpenAPI spec.
77
77
  * Context files (_.context.ts) are never pruned.
78
- * @param {string} destination - Base destination directory (contains the routes/ sub-directory)
79
- * @param {Iterable<string>} openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}")
80
- * @returns {Promise<number>} - Number of files removed
78
+ * @param destination - Base destination directory (contains the routes/ sub-directory)
79
+ * @param openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}")
80
+ * @returns Number of files removed
81
81
  */
82
82
  export async function pruneRoutes(destination, openApiPaths) {
83
83
  const routesDir = nodePath.join(destination, "routes");
@@ -11,6 +11,7 @@ const debug = createDebug("counterfact:server:repository");
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url)).replaceAll("\\", "/");
12
12
  debug("dirname is %s", __dirname);
13
13
  export class Repository {
14
+ scripts;
14
15
  constructor() {
15
16
  this.scripts = new Map();
16
17
  }
@@ -37,7 +38,6 @@ export class Repository {
37
38
  if (!existsSync(sourcePath)) {
38
39
  return false;
39
40
  }
40
- // eslint-disable-next-line n/no-unsupported-features/node-builtins
41
41
  return fs.cp(sourcePath, destinationPath, { recursive: true });
42
42
  }
43
43
  async writeFiles(destination, { routes, types }) {
@@ -1,14 +1,17 @@
1
1
  export class Requirement {
2
+ data;
3
+ url;
4
+ specification;
2
5
  constructor(data, url = "", specification = undefined) {
3
6
  this.data = data;
4
7
  this.url = url;
5
8
  this.specification = specification;
6
9
  }
7
10
  get isReference() {
8
- return this.data?.$ref !== undefined;
11
+ return this.data["$ref"] !== undefined;
9
12
  }
10
13
  reference() {
11
- return this.specification.getRequirement(this.data.$ref);
14
+ return this.specification.getRequirement(this.data["$ref"]);
12
15
  }
13
16
  has(item) {
14
17
  if (this.isReference) {
@@ -20,19 +23,21 @@ export class Requirement {
20
23
  if (this.isReference) {
21
24
  return this.reference().get(item);
22
25
  }
23
- if (!this.has(item)) {
26
+ const key = String(item);
27
+ if (!this.has(key)) {
24
28
  return undefined;
25
29
  }
26
- return new Requirement(this.data[item], `${this.url}/${this.escapeJsonPointer(item)}`, this.specification);
30
+ return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
27
31
  }
28
32
  select(path) {
29
33
  const parts = path
30
34
  .split("/")
31
- .map(this.unescapeJsonPointer)
35
+ .map((p) => this.unescapeJsonPointer(p))
32
36
  // Unescape URL encoded characters (e.g. %20 -> " ")
33
37
  // Technically we should not be unescaping, but it came up in https://github.com/pmcelhaney/counterfact/issues/1083
34
38
  // and I can't think of a reason anyone would intentionally put a % in a key name.
35
39
  .map(unescape);
40
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
36
41
  let result = this;
37
42
  for (const part of parts) {
38
43
  result = result.get(part);