counterfact 2.10.0 → 2.12.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 +19 -7
  3. package/dist/app.js +119 -15
  4. package/dist/cli/banner.js +1 -1
  5. package/dist/cli/run.js +42 -9
  6. package/dist/cli/telemetry.js +11 -10
  7. package/dist/migrate/update-route-types.js +1 -0
  8. package/dist/msw.js +1 -0
  9. package/dist/repl/repl.js +5 -4
  10. package/dist/server/counterfact-types/example.ts +5 -1
  11. package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
  12. package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
  13. package/dist/server/counterfact-types/response-builder.ts +5 -0
  14. package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
  15. package/dist/server/dispatcher.js +87 -12
  16. package/dist/server/json-to-xml.js +32 -7
  17. package/dist/server/module-loader.js +5 -0
  18. package/dist/server/openapi-document.js +5 -0
  19. package/dist/server/registry.js +22 -5
  20. package/dist/server/response-builder.js +27 -5
  21. package/dist/server/web-server/admin-api-middleware.js +1 -1
  22. package/dist/server/web-server/create-koa-app.js +3 -1
  23. package/dist/server/web-server/openapi-middleware.js +1 -0
  24. package/dist/server/web-server/routes-middleware.js +43 -1
  25. package/dist/typescript-generator/code-generator.js +17 -6
  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 +23 -1
  29. package/dist/typescript-generator/operation-type-coder.js +184 -11
  30. package/dist/typescript-generator/requirement.js +36 -3
  31. package/dist/typescript-generator/response-type-coder.js +20 -7
  32. package/dist/typescript-generator/responses-type-coder.js +8 -2
  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/script.js +46 -5
  36. package/dist/typescript-generator/specification.js +3 -1
  37. package/dist/typescript-generator/streaming-content-types.js +16 -0
  38. package/dist/typescript-generator/versions-ts-generator.js +82 -0
  39. package/package.json +24 -26
@@ -6,6 +6,23 @@ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validato
6
6
  import { validateResponse } from "./response-validator.js";
7
7
  import { Tools } from "./tools.js";
8
8
  const debug = createDebugger("counterfact:server:dispatcher");
9
+ /**
10
+ * Merges path-item-level and operation-level parameter arrays.
11
+ *
12
+ * Operation-level parameters take precedence when both arrays define a
13
+ * parameter with the same `name` and `in` location, per the OpenAPI
14
+ * specification.
15
+ */
16
+ function mergeParameters(pathItemParams, operationParams) {
17
+ const map = new Map();
18
+ for (const p of pathItemParams) {
19
+ map.set(`${p.in}:${p.name}`, p);
20
+ }
21
+ for (const p of operationParams) {
22
+ map.set(`${p.in}:${p.name}`, p);
23
+ }
24
+ return [...map.values()];
25
+ }
9
26
  /**
10
27
  * Parses the `Cookie` request header into a key/value map.
11
28
  *
@@ -91,12 +108,26 @@ export class Dispatcher {
91
108
  openApiDocument;
92
109
  fetch;
93
110
  config; // Add config property
94
- constructor(registry, contextRegistry, openApiDocument, config) {
111
+ /**
112
+ * The version label for this dispatcher's spec (e.g. `"v1"`, `"v2"`).
113
+ * Empty string when running without a version.
114
+ */
115
+ version;
116
+ /**
117
+ * Ordered list of all version labels for the API group this dispatcher
118
+ * belongs to. The first entry is the oldest version. Used by
119
+ * `$.minVersion()` at runtime to determine if the current version is
120
+ * greater than or equal to a given minimum version.
121
+ */
122
+ versions;
123
+ constructor(registry, contextRegistry, openApiDocument, config, version = "", versions = []) {
95
124
  this.registry = registry;
96
125
  this.contextRegistry = contextRegistry;
97
126
  this.openApiDocument = openApiDocument;
98
127
  this.fetch = fetch;
99
128
  this.config = config;
129
+ this.version = version;
130
+ this.versions = versions;
100
131
  }
101
132
  parameterTypes(parameters) {
102
133
  const types = {
@@ -111,43 +142,74 @@ export class Dispatcher {
111
142
  return types;
112
143
  }
113
144
  for (const parameter of parameters) {
114
- const type = parameter?.type;
145
+ // querystring parameters represent the entire query string as a single
146
+ // typed object; they are not individually coerced by name.
147
+ if (parameter.in === "querystring") {
148
+ continue;
149
+ }
150
+ const type = parameter?.type ?? parameter?.schema?.type;
115
151
  if (type !== undefined) {
116
152
  types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
117
153
  }
118
154
  }
119
155
  return types;
120
156
  }
121
- findOperation(path, method) {
122
- if (this.openApiDocument) {
123
- for (const key in this.openApiDocument.paths) {
124
- if (key.toLowerCase() === path.toLowerCase()) {
125
- return this.openApiDocument.paths[key]?.[method.toLowerCase()];
126
- }
157
+ findPathItem(path) {
158
+ if (!this.openApiDocument) {
159
+ return undefined;
160
+ }
161
+ for (const key in this.openApiDocument.paths) {
162
+ if (key.toLowerCase() === path.toLowerCase()) {
163
+ return this.openApiDocument.paths[key];
127
164
  }
128
165
  }
129
166
  return undefined;
130
167
  }
131
168
  /**
132
169
  * Resolves the OpenAPI operation for `path` and `method`, merging any
133
- * top-level `produces` array from the document root into the operation.
170
+ * top-level `produces` array from the document root and any path-item-level
171
+ * `parameters` into the operation.
172
+ *
173
+ * Per the OpenAPI specification, parameters defined at the path item level
174
+ * are shared across all operations on that path. Operation-level parameters
175
+ * take precedence when both define a parameter with the same `name` and `in`.
134
176
  *
135
177
  * @param path - The matched route path (e.g. `"/pets/{petId}"`).
136
178
  * @param method - The HTTP method.
137
179
  * @returns The {@link OpenApiOperation} if found, or `undefined`.
138
180
  */
139
181
  operationForPathAndMethod(path, method) {
140
- const operation = this.findOperation(path, method);
182
+ const pathItem = this.findPathItem(path);
183
+ if (pathItem === undefined) {
184
+ return undefined;
185
+ }
186
+ const normalizedMethod = method.toLowerCase();
187
+ const operation = pathItem[normalizedMethod] ??
188
+ pathItem.additionalOperations?.[method] ??
189
+ pathItem.additionalOperations?.[method.toUpperCase()] ??
190
+ pathItem.additionalOperations?.[normalizedMethod];
141
191
  if (operation === undefined) {
142
192
  return undefined;
143
193
  }
194
+ // Merge path-item-level parameters with operation-level parameters.
195
+ // Operation-level parameters take precedence on same name+in collision.
196
+ const pathItemParams = pathItem.parameters ?? [];
197
+ const operationParams = operation.parameters ?? [];
198
+ const mergedParameters = pathItemParams.length > 0
199
+ ? mergeParameters(pathItemParams, operationParams)
200
+ : operationParams.length > 0
201
+ ? operationParams
202
+ : undefined;
203
+ const mergedOperation = mergedParameters !== undefined
204
+ ? { ...operation, parameters: mergedParameters }
205
+ : operation;
144
206
  if (this.openApiDocument?.produces) {
145
207
  return {
146
208
  produces: this.openApiDocument.produces,
147
- ...operation,
209
+ ...mergedOperation,
148
210
  };
149
211
  }
150
- return operation;
212
+ return mergedOperation;
151
213
  }
152
214
  normalizeResponse(response, acceptHeader) {
153
215
  if (response.content !== undefined) {
@@ -282,9 +344,22 @@ export class Dispatcher {
282
344
  },
283
345
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
284
346
  query: processedQuery,
347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- typed by generated route types; entire query string as a single object
348
+ querystring: processedQuery,
285
349
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
286
350
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
287
351
  tools: new Tools({ headers }),
352
+ ...(this.version !== "" && {
353
+ version: this.version,
354
+ minVersion: (min) => {
355
+ const currentIdx = this.versions.indexOf(this.version);
356
+ const minIdx = this.versions.indexOf(min);
357
+ if (currentIdx === -1 || minIdx === -1) {
358
+ return false;
359
+ }
360
+ return currentIdx >= minIdx;
361
+ },
362
+ }),
288
363
  });
289
364
  if (response === undefined) {
290
365
  return {
@@ -22,19 +22,44 @@ function xmlEscape(xmlString) {
22
22
  }
23
23
  });
24
24
  }
25
+ function resolveNodeType(schema) {
26
+ if (schema?.xml?.nodeType !== undefined) {
27
+ return schema.xml.nodeType;
28
+ }
29
+ if (schema?.xml?.attribute || schema?.attribute) {
30
+ return "attribute";
31
+ }
32
+ return "element";
33
+ }
25
34
  function objectToXml(json, schema, name) {
26
35
  const xml = [];
27
36
  const attributes = [];
28
37
  Object.entries(json).forEach(([key, value]) => {
29
38
  const properties = schema?.properties?.[key];
30
- if (properties?.attribute) {
31
- attributes.push(` ${key}="${xmlEscape(String(value))}"`);
32
- }
33
- else {
34
- xml.push(jsonToXml(value, properties, key));
39
+ const nodeType = resolveNodeType(properties);
40
+ const xmlName = properties?.xml?.name ?? key;
41
+ switch (nodeType) {
42
+ case "attribute": {
43
+ attributes.push(` ${xmlName}="${xmlEscape(String(value))}"`);
44
+ break;
45
+ }
46
+ case "text": {
47
+ xml.push(xmlEscape(String(value)));
48
+ break;
49
+ }
50
+ case "cdata": {
51
+ xml.push(`<![CDATA[${String(value)}]]>`);
52
+ break;
53
+ }
54
+ case "none": {
55
+ break;
56
+ }
57
+ default: {
58
+ xml.push(jsonToXml(value, properties, key));
59
+ }
35
60
  }
36
61
  });
37
- return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
62
+ return `<${name}${attributes.join("")}>${xml.join("")}</${name}>`;
38
63
  }
39
64
  /**
40
65
  * Converts a JSON value to an XML string using optional OpenAPI `xml` schema
@@ -52,7 +77,7 @@ export function jsonToXml(json, schema, keyName = "root") {
52
77
  const items = json
53
78
  .map((item) => jsonToXml(item, schema?.items, name))
54
79
  .join("");
55
- if (schema?.xml?.wrapped) {
80
+ if (schema?.xml?.wrapped || schema?.xml?.nodeType === "element") {
56
81
  return `<${name}>${items}</${name}>`;
57
82
  }
58
83
  return items;
@@ -11,6 +11,7 @@ import { FileDiscovery } from "./file-discovery.js";
11
11
  import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
12
12
  import { ModuleDependencyGraph } from "./module-dependency-graph.js";
13
13
  import { uncachedImport } from "./uncached-import.js";
14
+ import { sendTelemetry } from "../cli/telemetry.js";
14
15
  import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
15
16
  import { unescapePathForWindows } from "../util/windows-escape.js";
16
17
  const { uncachedRequire } = await import("./uncached-require.cjs");
@@ -74,6 +75,10 @@ export class ModuleLoader extends EventTarget {
74
75
  return;
75
76
  }
76
77
  const parts = nodePath.parse(pathName.replace(this.basePath, ""));
78
+ sendTelemetry("file_change_detected", {
79
+ changeType: eventName,
80
+ fileType: this.isContextFile(pathName) ? "context" : "route",
81
+ });
77
82
  const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
78
83
  if (eventName === "unlink") {
79
84
  this.registry.remove(url);
@@ -2,6 +2,7 @@ import { watch } from "chokidar";
2
2
  import createDebug from "debug";
3
3
  import { dereference } from "@apidevtools/json-schema-ref-parser";
4
4
  import { waitForEvent } from "../util/wait-for-event.js";
5
+ import { sendTelemetry } from "../cli/telemetry.js";
5
6
  import { CHOKIDAR_OPTIONS } from "./constants.js";
6
7
  const debug = createDebug("counterfact:server:openapi-document");
7
8
  /**
@@ -49,6 +50,10 @@ export class OpenApiDocument extends EventTarget {
49
50
  return;
50
51
  }
51
52
  this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
53
+ sendTelemetry("file_change_detected", {
54
+ changeType: "change",
55
+ fileType: "openapi",
56
+ });
52
57
  void (async () => {
53
58
  try {
54
59
  await this.load();
@@ -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 () => ({
@@ -1,5 +1,6 @@
1
1
  import { generate } from "json-schema-faker";
2
2
  import { jsonToXml } from "./json-to-xml.js";
3
+ import { STREAMING_CONTENT_TYPES } from "../typescript-generator/streaming-content-types.js";
3
4
  const DEFAULT_GENERATE_OPTIONS = {
4
5
  useExamplesValue: true,
5
6
  minItems: 0,
@@ -154,10 +155,16 @@ export function createResponseBuilder(operation, config) {
154
155
  }
155
156
  return {
156
157
  ...this,
157
- content: Object.keys(content).map((type) => ({
158
- body: convertToXmlIfNecessary(type, content[type]?.examples?.[name]?.value, content[type]?.schema),
159
- type,
160
- })),
158
+ content: Object.keys(content).map((type) => {
159
+ const example = content[type]?.examples?.[name];
160
+ const body = example !== undefined && "dataValue" in example
161
+ ? example.dataValue
162
+ : example?.value;
163
+ return {
164
+ body: convertToXmlIfNecessary(type, body, content[type]?.schema),
165
+ type,
166
+ };
167
+ }),
161
168
  };
162
169
  },
163
170
  async random() {
@@ -188,7 +195,9 @@ export function createResponseBuilder(operation, config) {
188
195
  ...this,
189
196
  content: await Promise.all(Object.keys(content).map(async (type) => ({
190
197
  body: convertToXmlIfNecessary(type, content[type]?.examples
191
- ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
198
+ ? oneOf(Object.values(content[type]?.examples ?? []).map((example) => "dataValue" in example
199
+ ? example.dataValue
200
+ : example.value))
192
201
  : await generate((content[type]?.schema ?? {
193
202
  type: "object",
194
203
  }), generateOptions), content[type]?.schema),
@@ -217,6 +226,19 @@ export function createResponseBuilder(operation, config) {
217
226
  })),
218
227
  };
219
228
  },
229
+ stream(iterable) {
230
+ const response = operation.responses[this.status ?? "default"] ??
231
+ operation.responses.default;
232
+ const contentTypes = Object.keys(response?.content ?? {});
233
+ const contentType = contentTypes.find((ct) => STREAMING_CONTENT_TYPES.has(ct)) ??
234
+ "text/event-stream";
235
+ return {
236
+ body: iterable,
237
+ contentType,
238
+ headers: this.headers,
239
+ status: this.status,
240
+ };
241
+ },
220
242
  status: Number.parseInt(statusCode, 10),
221
243
  text(body) {
222
244
  return this.match("text/plain", body);
@@ -166,7 +166,7 @@ export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config
166
166
  port: config.port,
167
167
  proxyUrl: config.proxyUrl,
168
168
  prefix: config.prefix,
169
- startAdminApi: config.startAdminApi,
169
+ startAdminApi: config.startAdminApi ?? false,
170
170
  startRepl: config.startRepl,
171
171
  startServer: config.startServer,
172
172
  watch: config.watch,
@@ -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";
@@ -53,7 +54,8 @@ export function createKoaApp({ runners, config, }) {
53
54
  if (ctx.body !== null &&
54
55
  ctx.body !== undefined &&
55
56
  typeof ctx.body === "object" &&
56
- !Buffer.isBuffer(ctx.body)) {
57
+ !Buffer.isBuffer(ctx.body) &&
58
+ !(ctx.body instanceof Readable)) {
57
59
  ctx.body = JSON.stringify(ctx.body, null, 2);
58
60
  ctx.type = "application/json";
59
61
  }
@@ -24,6 +24,7 @@ export function openapiMiddleware(pathPrefix, document) {
24
24
  const openApiDocument = (await bundle(document.path));
25
25
  openApiDocument.servers ??= [];
26
26
  openApiDocument.servers.unshift({
27
+ name: "Counterfact",
27
28
  description: "Counterfact",
28
29
  url: document.baseUrl,
29
30
  });
@@ -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 = {
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[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;
@@ -22,12 +22,14 @@ const debug = createDebug("counterfact:typescript-generator:generate");
22
22
  export class CodeGenerator extends EventTarget {
23
23
  openapiPath;
24
24
  destination;
25
+ version;
25
26
  generateOptions;
26
27
  watcher;
27
- constructor(openApiPath, destination, generateOptions) {
28
+ constructor(openApiPath, destination, generateOptions, version = "") {
28
29
  super();
29
30
  this.openapiPath = openApiPath;
30
31
  this.destination = destination;
32
+ this.version = version;
31
33
  this.generateOptions = generateOptions;
32
34
  }
33
35
  /**
@@ -107,16 +109,25 @@ export class CodeGenerator extends EventTarget {
107
109
  "patch",
108
110
  "trace",
109
111
  ]);
112
+ const operationMethodsForPath = (pathDefinition) => pathDefinition.flatMap((operation, requestMethod) => {
113
+ if (requestMethod === "additionalOperations") {
114
+ return operation.map((additionalOperation, additionalMethod) => [
115
+ additionalOperation,
116
+ additionalMethod.toLowerCase(),
117
+ ]);
118
+ }
119
+ if (!HTTP_VERBS.has(requestMethod)) {
120
+ return [];
121
+ }
122
+ return [[operation, requestMethod]];
123
+ });
110
124
  paths.forEach((pathDefinition, key) => {
111
125
  debug("processing path %s", key);
112
126
  const path = key === "/" ? "/index" : key;
113
- pathDefinition.forEach((operation, requestMethod) => {
114
- if (!HTTP_VERBS.has(requestMethod)) {
115
- return;
116
- }
127
+ operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
117
128
  repository
118
129
  .get(`routes${path}.ts`)
119
- .export(new OperationCoder(operation, "", requestMethod, securitySchemes));
130
+ .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
120
131
  });
121
132
  });
122
133
  debug("telling the repository to write the files to %s", destination);
@@ -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
- import { OperationTypeCoder, } from "./operation-type-coder.js";
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 ($) => {
@@ -41,6 +55,14 @@ export class OperationCoder extends Coder {
41
55
  }
42
56
  typeDeclaration(_namespace, script) {
43
57
  const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
58
+ if (this.version !== "") {
59
+ // For versioned APIs: register this version's $-argument type on the
60
+ // shared script so that Script.versionsTypeStatements() can emit the
61
+ // merged handler type after all versions have been declared.
62
+ const versionedArgCoder = new VersionedArgTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
63
+ const sharedScript = script.repository.get(operationTypeCoder.modulePath());
64
+ sharedScript.declareVersion(versionedArgCoder, operationTypeCoder.getOperationBaseName());
65
+ }
44
66
  return script.importType(operationTypeCoder);
45
67
  }
46
68
  modulePath() {