counterfact 2.11.0 → 2.14.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 (39) hide show
  1. package/README.md +2 -1
  2. package/dist/api-runner.js +8 -2
  3. package/dist/app.js +8 -1
  4. package/dist/cli/run.js +44 -6
  5. package/dist/cli/telemetry.js +10 -4
  6. package/dist/migrate/update-route-types.js +1 -0
  7. package/dist/msw.js +1 -0
  8. package/dist/repl/repl.js +4 -0
  9. package/dist/server/counterfact-types/example.ts +5 -1
  10. package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
  11. package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
  12. package/dist/server/counterfact-types/response-builder.ts +5 -0
  13. package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
  14. package/dist/server/dispatcher.js +60 -6
  15. package/dist/server/json-to-xml.js +32 -7
  16. package/dist/server/load-openapi-document.js +2 -2
  17. package/dist/server/module-loader.js +5 -0
  18. package/dist/server/openapi-document.js +18 -1
  19. package/dist/server/registry.js +22 -5
  20. package/dist/server/request-validator.js +1 -0
  21. package/dist/server/response-builder.js +28 -5
  22. package/dist/server/web-server/create-koa-app.js +4 -1
  23. package/dist/server/web-server/openapi-middleware.js +5 -0
  24. package/dist/server/web-server/routes-middleware.js +43 -1
  25. package/dist/typescript-generator/code-generator.js +25 -10
  26. package/dist/typescript-generator/coder.js +1 -1
  27. package/dist/typescript-generator/jsdoc.js +11 -7
  28. package/dist/typescript-generator/operation-coder.js +14 -0
  29. package/dist/typescript-generator/operation-type-coder.js +65 -9
  30. package/dist/typescript-generator/repository.js +97 -8
  31. package/dist/typescript-generator/requirement.js +25 -3
  32. package/dist/typescript-generator/response-type-coder.js +20 -7
  33. package/dist/typescript-generator/schema-coder.js +2 -2
  34. package/dist/typescript-generator/schema-type-coder.js +16 -3
  35. package/dist/typescript-generator/specification.js +17 -6
  36. package/dist/typescript-generator/streaming-content-types.js +16 -0
  37. package/dist/typescript-generator/versions-ts-generator.js +25 -0
  38. package/dist/util/apply-overlay.js +119 -0
  39. package/package.json +29 -29
@@ -1,7 +1,7 @@
1
1
  import createDebugger from "debug";
2
2
  import { ModuleTree } from "./module-tree.js";
3
3
  const debug = createDebugger("counterfact:server:registry");
4
- const ALL_HTTP_METHODS = [
4
+ const DEFAULT_HTTP_METHODS = [
5
5
  "DELETE",
6
6
  "GET",
7
7
  "HEAD",
@@ -9,6 +9,7 @@ const ALL_HTTP_METHODS = [
9
9
  "PATCH",
10
10
  "POST",
11
11
  "PUT",
12
+ "QUERY",
12
13
  "TRACE",
13
14
  ];
14
15
  /**
@@ -60,6 +61,7 @@ function castParameters(parameters = {}, parameterTypes = new Map()) {
60
61
  export class Registry {
61
62
  moduleTree = new ModuleTree();
62
63
  middlewares = new Map();
64
+ methodNames = new Set(DEFAULT_HTTP_METHODS);
63
65
  constructor() {
64
66
  this.middlewares.set("", ($, respondTo) => respondTo($));
65
67
  }
@@ -75,6 +77,9 @@ export class Registry {
75
77
  */
76
78
  add(url, module) {
77
79
  this.moduleTree.add(url, module);
80
+ for (const methodName of Object.keys(module)) {
81
+ this.methodNames.add(methodName.toUpperCase());
82
+ }
78
83
  }
79
84
  /**
80
85
  * Registers a middleware function that wraps every handler under `url`.
@@ -102,8 +107,20 @@ export class Registry {
102
107
  * @param method - HTTP method (e.g. `"GET"`).
103
108
  * @param url - The request URL.
104
109
  */
110
+ methodFromModule(module, method) {
111
+ if (module === undefined) {
112
+ return undefined;
113
+ }
114
+ return (module[method] ??
115
+ module[method.toUpperCase()] ??
116
+ module[method.toLowerCase()]);
117
+ }
105
118
  exists(method, url) {
106
- return Boolean(this.handler(url, method).module?.[method]);
119
+ return (this.methodFromModule(this.handler(url, method).module, method) !==
120
+ undefined);
121
+ }
122
+ methodsForPath(url) {
123
+ return [...this.methodNames].filter((method) => this.methodFromModule(this.moduleTree.match(url, method)?.module, method) !== undefined);
107
124
  }
108
125
  /**
109
126
  * Finds the best-matching module and extracts path-variable bindings for a
@@ -133,7 +150,7 @@ export class Registry {
133
150
  * @param excludeMethod - The method to exclude from the check.
134
151
  */
135
152
  pathExistsWithAnyMethod(url, excludeMethod) {
136
- return ALL_HTTP_METHODS.filter((method) => method !== excludeMethod).some((method) => this.moduleTree.match(url, method) !== undefined);
153
+ return this.methodsForPath(url).some((method) => method.toUpperCase() !== excludeMethod.toUpperCase());
137
154
  }
138
155
  /**
139
156
  * Returns a comma-separated list of HTTP methods that have a registered
@@ -143,7 +160,7 @@ export class Registry {
143
160
  * @param url - The request URL.
144
161
  */
145
162
  allowedMethods(url) {
146
- return ALL_HTTP_METHODS.filter((method) => Boolean(this.moduleTree.match(url, method)?.module?.[method])).join(", ");
163
+ return this.methodsForPath(url).join(", ");
147
164
  }
148
165
  /**
149
166
  * Returns an async function that executes the registered handler for
@@ -169,7 +186,7 @@ export class Registry {
169
186
  status: 500,
170
187
  });
171
188
  }
172
- const execute = handler.module?.[httpRequestMethod];
189
+ const execute = this.methodFromModule(handler.module, httpRequestMethod);
173
190
  if (!execute) {
174
191
  debug(`Could not find a ${httpRequestMethod} method matching ${url}\n`);
175
192
  return () => ({
@@ -62,6 +62,7 @@ export function validateRequest(operation, request) {
62
62
  // by the registry before the route handler is called.
63
63
  errors.push(...findMissingRequired(parameters, "query", request.query));
64
64
  errors.push(...findMissingRequired(parameters, "header", request.headers));
65
+ errors.push(...findMissingRequired(parameters, "cookie", request.cookie));
65
66
  // Validate request body (OpenAPI 3.x requestBody)
66
67
  if (operation.requestBody?.content !== undefined) {
67
68
  const schema = operation.requestBody.content["application/json"]?.schema ??
@@ -1,5 +1,7 @@
1
1
  import { generate } from "json-schema-faker";
2
+ /* eslint-disable security/detect-object-injection -- OpenAPI response/content maps are spec-defined dictionaries accessed by status code, media type, and example name. */
2
3
  import { jsonToXml } from "./json-to-xml.js";
4
+ import { STREAMING_CONTENT_TYPES } from "../typescript-generator/streaming-content-types.js";
3
5
  const DEFAULT_GENERATE_OPTIONS = {
4
6
  useExamplesValue: true,
5
7
  minItems: 0,
@@ -154,10 +156,16 @@ export function createResponseBuilder(operation, config) {
154
156
  }
155
157
  return {
156
158
  ...this,
157
- content: Object.keys(content).map((type) => ({
158
- body: convertToXmlIfNecessary(type, content[type]?.examples?.[name]?.value, content[type]?.schema),
159
- type,
160
- })),
159
+ content: Object.keys(content).map((type) => {
160
+ const example = content[type]?.examples?.[name];
161
+ const body = example !== undefined && "dataValue" in example
162
+ ? example.dataValue
163
+ : example?.value;
164
+ return {
165
+ body: convertToXmlIfNecessary(type, body, content[type]?.schema),
166
+ type,
167
+ };
168
+ }),
161
169
  };
162
170
  },
163
171
  async random() {
@@ -188,7 +196,9 @@ export function createResponseBuilder(operation, config) {
188
196
  ...this,
189
197
  content: await Promise.all(Object.keys(content).map(async (type) => ({
190
198
  body: convertToXmlIfNecessary(type, content[type]?.examples
191
- ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
199
+ ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => "dataValue" in example
200
+ ? example.dataValue
201
+ : example.value))
192
202
  : await generate((content[type]?.schema ?? {
193
203
  type: "object",
194
204
  }), generateOptions), content[type]?.schema),
@@ -217,6 +227,19 @@ export function createResponseBuilder(operation, config) {
217
227
  })),
218
228
  };
219
229
  },
230
+ stream(iterable) {
231
+ const response = operation.responses[this.status ?? "default"] ??
232
+ operation.responses.default;
233
+ const contentTypes = Object.keys(response?.content ?? {});
234
+ const contentType = contentTypes.find((ct) => STREAMING_CONTENT_TYPES.has(ct)) ??
235
+ "text/event-stream";
236
+ return {
237
+ body: iterable,
238
+ contentType,
239
+ headers: this.headers,
240
+ status: this.status,
241
+ };
242
+ },
220
243
  status: Number.parseInt(statusCode, 10),
221
244
  text(body) {
222
245
  return this.match("text/plain", body);
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import createDebug from "debug";
2
3
  import Koa from "koa";
3
4
  import bodyParser from "koa-bodyparser";
@@ -28,6 +29,7 @@ export function createKoaApp({ runners, config, }) {
28
29
  app.use(openapiMiddleware(`/counterfact/openapi${runner.subdirectory}`, {
29
30
  path: runner.openApiPath,
30
31
  baseUrl: `//localhost:${config.port}${runner.prefix}`,
32
+ overlays: runner.overlays,
31
33
  }));
32
34
  app.use(koaSwagger({
33
35
  routePrefix: `/counterfact/swagger${runner.subdirectory}`,
@@ -53,7 +55,8 @@ export function createKoaApp({ runners, config, }) {
53
55
  if (ctx.body !== null &&
54
56
  ctx.body !== undefined &&
55
57
  typeof ctx.body === "object" &&
56
- !Buffer.isBuffer(ctx.body)) {
58
+ !Buffer.isBuffer(ctx.body) &&
59
+ !(ctx.body instanceof Readable)) {
57
60
  ctx.body = JSON.stringify(ctx.body, null, 2);
58
61
  ctx.type = "application/json";
59
62
  }
@@ -1,5 +1,6 @@
1
1
  import { bundle } from "@apidevtools/json-schema-ref-parser";
2
2
  import { dump } from "js-yaml";
3
+ import { applyOverlays } from "../../util/apply-overlay.js";
3
4
  /**
4
5
  * Returns a Koa middleware that serves a bundled OpenAPI document as YAML at
5
6
  * the given `pathPrefix`.
@@ -22,8 +23,12 @@ export function openapiMiddleware(pathPrefix, document) {
22
23
  return await next();
23
24
  }
24
25
  const openApiDocument = (await bundle(document.path));
26
+ if (document.overlays && document.overlays.length > 0) {
27
+ await applyOverlays(openApiDocument, document.overlays);
28
+ }
25
29
  openApiDocument.servers ??= [];
26
30
  openApiDocument.servers.unshift({
31
+ name: "Counterfact",
27
32
  description: "Counterfact",
28
33
  url: document.baseUrl,
29
34
  });
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import createDebug from "debug";
2
3
  import koaProxy from "koa-proxies";
3
4
  import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js";
@@ -19,6 +20,36 @@ const HEADERS_TO_DROP = new Set([
19
20
  "trailer",
20
21
  "trailers",
21
22
  ]);
23
+ /**
24
+ * SSE/JSONL/JSON-seq formatter map. Each entry maps a content-type to the
25
+ * function that serialises a single stream item into the wire format.
26
+ */
27
+ const STREAMING_FORMATTERS = new Map([
28
+ ["text/event-stream", (item) => `data: ${JSON.stringify(item)}\n\n`],
29
+ ["application/json-seq", (item) => `\x1e${JSON.stringify(item)}\n`],
30
+ ]);
31
+ function defaultStreamFormatter(item) {
32
+ return `${JSON.stringify(item)}\n`;
33
+ }
34
+ function isAsyncIterable(value) {
35
+ return (value !== null &&
36
+ value !== undefined &&
37
+ typeof value === "object" &&
38
+ Symbol.asyncIterator in value);
39
+ }
40
+ /**
41
+ * Converts an `AsyncIterable` to a Node.js `Readable` stream, serialising
42
+ * each item according to the given content type.
43
+ */
44
+ function asyncIterableToReadable(iterable, contentType) {
45
+ const formatter = STREAMING_FORMATTERS.get(contentType) ?? defaultStreamFormatter;
46
+ async function* generate() {
47
+ for await (const item of iterable) {
48
+ yield formatter(item);
49
+ }
50
+ }
51
+ return Readable.from(generate());
52
+ }
22
53
  function addCors(ctx, allowedMethods, headers) {
23
54
  // Always append CORS headers, reflecting back the headers requested if any
24
55
  ctx.set("Access-Control-Allow-Origin", headers?.origin ?? "*");
@@ -92,7 +123,18 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
92
123
  rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
93
124
  req: { path: "", ...ctx.req },
94
125
  });
95
- ctx.body = response.body;
126
+ if (isAsyncIterable(response.body)) {
127
+ const contentType = response.contentType ?? "application/jsonl";
128
+ ctx.type = contentType;
129
+ ctx.body = asyncIterableToReadable(response.body, contentType);
130
+ if (contentType === "text/event-stream") {
131
+ ctx.set("Cache-Control", "no-cache");
132
+ ctx.set("X-Accel-Buffering", "no");
133
+ }
134
+ }
135
+ else {
136
+ ctx.body = response.body;
137
+ }
96
138
  if (response.contentType !== undefined &&
97
139
  response.contentType !== "unknown/unknown") {
98
140
  ctx.type = response.contentType;
@@ -23,13 +23,15 @@ export class CodeGenerator extends EventTarget {
23
23
  openapiPath;
24
24
  destination;
25
25
  version;
26
+ overlays;
26
27
  generateOptions;
27
28
  watcher;
28
- constructor(openApiPath, destination, generateOptions, version = "") {
29
+ constructor(openApiPath, destination, generateOptions, version = "", overlays = []) {
29
30
  super();
30
31
  this.openapiPath = openApiPath;
31
32
  this.destination = destination;
32
33
  this.version = version;
34
+ this.overlays = overlays;
33
35
  this.generateOptions = generateOptions;
34
36
  }
35
37
  /**
@@ -87,7 +89,7 @@ export class CodeGenerator extends EventTarget {
87
89
  await this.buildCacheDirectory(destination);
88
90
  debug("done initializing the .cache directory");
89
91
  debug("creating specification from %s", this.openapiPath);
90
- const specification = await Specification.fromFile(this.openapiPath);
92
+ const specification = await Specification.fromFile(this.openapiPath, this.overlays);
91
93
  debug("created specification: $o", specification);
92
94
  debug("reading the #/paths from the specification");
93
95
  const paths = await this.getPathsFromSpecification(specification);
@@ -109,13 +111,22 @@ export class CodeGenerator extends EventTarget {
109
111
  "patch",
110
112
  "trace",
111
113
  ]);
114
+ const operationMethodsForPath = (pathDefinition) => pathDefinition.flatMap((operation, requestMethod) => {
115
+ if (requestMethod === "additionalOperations") {
116
+ return operation.map((additionalOperation, additionalMethod) => [
117
+ additionalOperation,
118
+ additionalMethod.toLowerCase(),
119
+ ]);
120
+ }
121
+ if (!HTTP_VERBS.has(requestMethod)) {
122
+ return [];
123
+ }
124
+ return [[operation, requestMethod]];
125
+ });
112
126
  paths.forEach((pathDefinition, key) => {
113
127
  debug("processing path %s", key);
114
128
  const path = key === "/" ? "/index" : key;
115
- pathDefinition.forEach((operation, requestMethod) => {
116
- if (!HTTP_VERBS.has(requestMethod)) {
117
- return;
118
- }
129
+ operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
119
130
  repository
120
131
  .get(`routes${path}.ts`)
121
132
  .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
@@ -126,16 +137,20 @@ export class CodeGenerator extends EventTarget {
126
137
  debug("finished writing the files");
127
138
  }
128
139
  /**
129
- * Starts watching the OpenAPI document for changes.
140
+ * Starts watching the OpenAPI document and any overlay files for changes.
130
141
  *
131
- * Has no effect when `openApiPath` is a URL (HTTP sources are not watched).
142
+ * Has no effect when neither source is watchable (for example, an HTTP
143
+ * OpenAPI source with no local overlays).
132
144
  * Resolves once the watcher is ready.
133
145
  */
134
146
  async watch() {
135
- if (this.openapiPath.startsWith("http")) {
147
+ const watchablePaths = this.openapiPath.startsWith("http")
148
+ ? [...this.overlays]
149
+ : [this.openapiPath, ...this.overlays];
150
+ if (watchablePaths.length === 0) {
136
151
  return;
137
152
  }
138
- this.watcher = watch(this.openapiPath, CHOKIDAR_OPTIONS).on("change", () => {
153
+ this.watcher = watch(watchablePaths, CHOKIDAR_OPTIONS).on("change", () => {
139
154
  void this.generate().then(() => {
140
155
  this.dispatchEvent(new Event("generate"));
141
156
  return true;
@@ -23,7 +23,7 @@ export class Coder {
23
23
  */
24
24
  get id() {
25
25
  if (this.requirement.isReference) {
26
- return `${this.constructor.name}@${this.requirement.data["$ref"]}`;
26
+ return `${this.constructor.name}@${this.requirement.refUrl}`;
27
27
  }
28
28
  return `${this.constructor.name}@${this.requirement.url}`;
29
29
  }
@@ -3,14 +3,18 @@
3
3
  * Returns an empty string if there is no relevant metadata.
4
4
  */
5
5
  export function buildJsDoc(data) {
6
+ if (typeof data !== "object" || data === null) {
7
+ return "";
8
+ }
9
+ const record = data;
6
10
  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"];
11
+ const description = record["description"];
12
+ const summary = record["summary"];
13
+ const example = record["example"];
14
+ const examples = record["examples"];
15
+ const defaultValue = record["default"];
16
+ const format = record["format"];
17
+ const deprecated = record["deprecated"];
14
18
  const mainText = description ?? summary;
15
19
  if (mainText) {
16
20
  // Escape */ to prevent prematurely closing the JSDoc block
@@ -1,6 +1,7 @@
1
1
  import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
3
  import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
4
+ import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
4
5
  /**
5
6
  * Generates the default route handler stub for a single OpenAPI operation.
6
7
  *
@@ -33,6 +34,19 @@ export class OperationCoder extends Coder {
33
34
  !("content" in firstResponse || "schema" in firstResponse)) {
34
35
  return `async ($) => {
35
36
  return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].empty();
37
+ }`;
38
+ }
39
+ // Detect streaming responses (OpenAPI 3.2 itemSchema)
40
+ const content = firstResponse.content;
41
+ const hasStreamingContent = content !== undefined &&
42
+ Object.keys(content).some((ct) => STREAMING_CONTENT_TYPES.has(ct) &&
43
+ content[ct]?.itemSchema !== undefined);
44
+ if (hasStreamingContent) {
45
+ return `async ($) => {
46
+ async function* items() {
47
+ // yield items here
48
+ }
49
+ return $.response[${firstStatusCode === "default" ? 200 : firstStatusCode}].stream(items());
36
50
  }`;
37
51
  }
38
52
  return `async ($) => {
@@ -7,6 +7,7 @@ import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
7
7
  import { RESERVED_WORDS } from "./reserved-words.js";
8
8
  import { ResponsesTypeCoder } from "./responses-type-coder.js";
9
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
10
+ import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
10
11
  import { TypeCoder } from "./type-coder.js";
11
12
  import { Requirement } from "./requirement.js";
12
13
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
@@ -105,11 +106,23 @@ export class OperationTypeCoder extends TypeCoder {
105
106
  ? "number | undefined"
106
107
  : Number.parseInt(responseCode, 10);
107
108
  if (response.has("content")) {
108
- return response.get("content").map((content, contentType) => `{
109
+ return response.get("content").map((content, contentType) => {
110
+ let bodyType;
111
+ if (content.has("itemSchema") &&
112
+ STREAMING_CONTENT_TYPES.has(contentType)) {
113
+ bodyType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
114
+ }
115
+ else {
116
+ bodyType = content.has("schema")
117
+ ? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
118
+ : "unknown";
119
+ }
120
+ return `{
109
121
  status: ${status},
110
122
  contentType?: "${contentType}",
111
- body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
112
- }`);
123
+ body?: ${bodyType}
124
+ }`;
125
+ });
113
126
  }
114
127
  if (response.has("schema")) {
115
128
  const producesReq = this.requirement?.get("produces") ??
@@ -150,6 +163,23 @@ export class OperationTypeCoder extends TypeCoder {
150
163
  }
151
164
  return "never";
152
165
  }
166
+ /**
167
+ * Returns the TypeScript type for the `auth` argument.
168
+ *
169
+ * Includes basic-auth credentials when present and `apiKey` when at least one
170
+ * apiKey security scheme is configured.
171
+ */
172
+ authType() {
173
+ const fields = new Set();
174
+ if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
175
+ fields.add("username?: string");
176
+ fields.add("password?: string");
177
+ }
178
+ if (this.securitySchemes.some(({ type }) => type === "apiKey")) {
179
+ fields.add("apiKey: string");
180
+ }
181
+ return fields.size === 0 ? "never" : `{${[...fields].join(", ")}}`;
182
+ }
153
183
  /**
154
184
  * Returns the effective parameters for this operation by merging path-item-level
155
185
  * parameters with operation-level parameters. Per the OpenAPI specification,
@@ -165,16 +195,32 @@ export class OperationTypeCoder extends TypeCoder {
165
195
  getEffectiveParameters() {
166
196
  const operationParams = this.requirement.get("parameters");
167
197
  const pathItemParams = this.requirement.parent?.get("parameters");
168
- if (!pathItemParams) {
198
+ const apiKeyParameters = this.securitySchemes
199
+ .filter(({ in: location, name, type }) => type === "apiKey" &&
200
+ typeof name === "string" &&
201
+ (location === "header" ||
202
+ location === "query" ||
203
+ location === "cookie"))
204
+ .map(({ in: location, name }) => ({
205
+ in: location,
206
+ name,
207
+ required: true,
208
+ schema: { type: "string" },
209
+ }));
210
+ if (!pathItemParams && !operationParams && apiKeyParameters.length === 0) {
211
+ return undefined;
212
+ }
213
+ if (!pathItemParams && apiKeyParameters.length === 0) {
169
214
  return operationParams;
170
215
  }
171
- if (!operationParams) {
216
+ if (!operationParams && apiKeyParameters.length === 0) {
172
217
  return pathItemParams;
173
218
  }
174
219
  // Merge using a Map keyed on `${in}:${name}`.
175
- // Path-level params are added first; operation-level overrides them.
176
- const pathData = pathItemParams.data;
177
- const opData = operationParams.data;
220
+ // Path-level params are added first; operation-level and security-level
221
+ // params override them.
222
+ const pathData = pathItemParams?.data ?? [];
223
+ const opData = operationParams?.data ?? [];
178
224
  const map = new Map();
179
225
  for (const p of pathData) {
180
226
  map.set(`${p.in}:${p.name}`, p);
@@ -182,6 +228,9 @@ export class OperationTypeCoder extends TypeCoder {
182
228
  for (const p of opData) {
183
229
  map.set(`${p.in}:${p.name}`, p);
184
230
  }
231
+ for (const p of apiKeyParameters) {
232
+ map.set(`${p.in}:${p.name}`, p);
233
+ }
185
234
  return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
186
235
  }
187
236
  /**
@@ -224,8 +273,15 @@ export class OperationTypeCoder extends TypeCoder {
224
273
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
225
274
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
226
275
  const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
276
+ // OpenAPI 3.2 querystring parameter: the entire query string treated as a
277
+ // single typed object (similar to requestBody for query strings).
278
+ const querystringParam = parameters?.find((parameter) => parameter.get("in")?.data === "querystring");
279
+ const querystringType = querystringParam?.has("schema") === true
280
+ ? new SchemaTypeCoder(querystringParam.get("schema"), this.version).write(script)
281
+ : "never";
282
+ const querystringTypeName = this.exportParameterType(script, "querystring", querystringType, baseName, modulePath);
227
283
  const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
228
- 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}, version: ${versionLiteralType} }>`;
284
+ return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, auth: ${this.authType()}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
229
285
  }
230
286
  writeCode(script) {
231
287
  script.comments = READ_ONLY_COMMENTS;
@@ -70,9 +70,11 @@ export class Repository {
70
70
  /**
71
71
  * Waits for all scripts to finish, then writes each one to disk.
72
72
  *
73
- * Route files (`routes/…`) are never overwritten if they already exist on
74
- * disk, preserving user edits. Type files (`types/…`) are always
75
- * overwritten.
73
+ * Route files (`routes/…`) are never fully overwritten if they already exist
74
+ * on disk, preserving user edits. However, if the generated script contains
75
+ * HTTP-method handler exports that are absent from the existing file, those
76
+ * new exports (and their `import type` statements) are appended to the file.
77
+ * Type files (`types/…`) are always overwritten.
76
78
  *
77
79
  * @param destination - Absolute path to the output root directory.
78
80
  * @param options - Controls which artefacts are written.
@@ -87,13 +89,16 @@ export class Repository {
87
89
  await ensureDirectoryExists(fullPath);
88
90
  const shouldWriteRoutes = routes && path.startsWith("routes");
89
91
  const shouldWriteTypes = types && !path.startsWith("routes");
90
- if (shouldWriteRoutes &&
91
- (await fs
92
+ if (shouldWriteRoutes) {
93
+ const fileExists = await fs
92
94
  .stat(fullPath)
93
95
  .then((stat) => stat.isFile())
94
- .catch(() => false))) {
95
- debug(`not overwriting ${fullPath}\n`);
96
- return;
96
+ .catch(() => false);
97
+ if (fileExists) {
98
+ debug(`route file exists, checking for new handlers: ${fullPath}`);
99
+ await this.appendNewHandlers(fullPath, contents.replaceAll(CONTEXT_FILE_TOKEN, this.findContextPath(destination, path)));
100
+ return;
101
+ }
97
102
  }
98
103
  if (shouldWriteRoutes || shouldWriteTypes) {
99
104
  debug("about to write", fullPath);
@@ -139,6 +144,90 @@ export class Context {
139
144
  }
140
145
  `);
141
146
  }
147
+ /**
148
+ * Appends any HTTP-method handler exports that appear in `generatedContent`
149
+ * but are absent from the existing file at `fullPath`.
150
+ *
151
+ * For each new export the corresponding `import type` statement is inserted
152
+ * after the last existing import line (or prepended when no imports exist),
153
+ * and the export block is appended at the end of the file.
154
+ *
155
+ * @param fullPath - Absolute path of the route file to update.
156
+ * @param generatedContent - The fully-generated file content (used as the
157
+ * source of new import and export statements).
158
+ */
159
+ async appendNewHandlers(fullPath, generatedContent) {
160
+ const existingContent = await fs.readFile(fullPath, "utf8");
161
+ // Names already exported by the existing file (e.g. GET, POST).
162
+ // RegExp match groups are typed as optional strings, so narrow defensively.
163
+ const existingExportNames = new Set(Array.from(existingContent.matchAll(/^export\s+const\s+(\w+)/gmu), (m) => m[1]).filter((name) => name !== undefined));
164
+ // All named exports in the generated content together with their type names.
165
+ const generatedExports = Array.from(generatedContent.matchAll(/^export\s+const\s+(\w+)\s*:\s*(\w+)/gmu), (m) => ({ methodName: m[1], typeName: m[2] })).filter((value) => value.methodName !== undefined && value.typeName !== undefined);
166
+ const newExports = generatedExports.filter(({ methodName }) => !existingExportNames.has(methodName));
167
+ if (newExports.length === 0) {
168
+ debug(`no new handlers to append to ${fullPath}`);
169
+ return;
170
+ }
171
+ debug(`appending ${newExports.length} new handler(s) to ${fullPath}: %o`, newExports.map(({ methodName }) => methodName));
172
+ const newImportLines = [];
173
+ const newExportBlocks = [];
174
+ for (const { methodName, typeName } of newExports) {
175
+ // Both names come from \w+ captures so they are safe identifiers, but
176
+ // guard explicitly to satisfy static analysis and avoid RegExp injection.
177
+ if (!/^\w+$/u.test(typeName) || !/^\w+$/u.test(methodName)) {
178
+ debug(`skipping handler with unsafe name – methodName: %s, typeName: %s`, methodName, typeName);
179
+ continue;
180
+ }
181
+ // Find the `import type { TypeName } from "..."` line for this type.
182
+ const importMatch = generatedContent.match(new RegExp(`^import\\s+type\\s+\\{[^}]*\\b${typeName}\\b[^}]*\\}\\s+from\\s+["'][^"']+["'];`, "mu"));
183
+ if (importMatch?.[0] && !existingContent.includes(importMatch[0])) {
184
+ newImportLines.push(importMatch[0]);
185
+ }
186
+ // Find the export block: from `export const METHOD` to the closing `};`.
187
+ // The generated code is always Prettier-formatted, so the closing brace
188
+ // and semicolon of every top-level arrow-function export appear on their
189
+ // own line as `\n};`.
190
+ const startMatch = new RegExp(`^export\\s+const\\s+${methodName}\\b`, "mu").exec(generatedContent);
191
+ if (startMatch) {
192
+ const fromExport = generatedContent.slice(startMatch.index);
193
+ const closingIndex = fromExport.indexOf("\n};");
194
+ if (closingIndex !== -1) {
195
+ // Include the closing `};` (3 chars: \n, }, ;)
196
+ newExportBlocks.push(fromExport.slice(0, closingIndex + 3));
197
+ }
198
+ }
199
+ }
200
+ let updatedContent = existingContent;
201
+ // Insert new import lines right after the last existing import statement.
202
+ if (newImportLines.length > 0) {
203
+ const importMatches = [...existingContent.matchAll(/^import\s[^\n]*/gmu)];
204
+ if (importMatches.length > 0) {
205
+ const lastImport = importMatches[importMatches.length - 1];
206
+ const importIndex = lastImport?.index;
207
+ const insertPos = importIndex === undefined
208
+ ? 0
209
+ : (() => {
210
+ const lineEnd = existingContent.indexOf("\n", importIndex);
211
+ return lineEnd === -1 ? existingContent.length : lineEnd + 1;
212
+ })();
213
+ updatedContent =
214
+ existingContent.slice(0, insertPos) +
215
+ newImportLines.join("\n") +
216
+ "\n" +
217
+ existingContent.slice(insertPos);
218
+ }
219
+ else {
220
+ updatedContent = newImportLines.join("\n") + "\n" + existingContent;
221
+ }
222
+ }
223
+ // Append new export blocks at the end of the file.
224
+ if (newExportBlocks.length > 0) {
225
+ const separator = updatedContent.endsWith("\n") ? "\n" : "\n\n";
226
+ updatedContent += separator + newExportBlocks.join("\n\n") + "\n";
227
+ }
228
+ await fs.writeFile(fullPath, updatedContent);
229
+ debug(`appended new handlers to ${fullPath}`);
230
+ }
142
231
  /**
143
232
  * Returns the path of the `_.context.ts` file that is nearest to `path` in
144
233
  * the directory hierarchy, relative to the script's output directory.