counterfact 2.9.0 → 2.11.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.
@@ -2,10 +2,27 @@ import { mediaTypes } from "@hapi/accept";
2
2
  import createDebugger from "debug";
3
3
  import fetch, { Headers } from "node-fetch";
4
4
  import { createResponseBuilder } from "./response-builder.js";
5
- import { validateRequest } from "./request-validator.js";
5
+ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validator.js";
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
  *
@@ -36,6 +53,45 @@ function parseCookies(cookieHeader) {
36
53
  }
37
54
  return cookies;
38
55
  }
56
+ /**
57
+ * Gathers exploded object query parameters back into a nested object under the
58
+ * parameter's own name.
59
+ *
60
+ * Per OpenAPI 3.x, a query parameter of type `object` with `style: form` and
61
+ * `explode: true` (both defaults for query params) is serialised as individual
62
+ * query parameters — one per object property. This function reconstructs the
63
+ * object so that the route handler can access `$.query.<paramName>` rather than
64
+ * only the individual flat parameters.
65
+ *
66
+ * Properties that are "claimed" by an object parameter are removed from the
67
+ * top-level map and placed under the parameter name. Unclaimed keys remain at
68
+ * the top level.
69
+ *
70
+ * @param query - The raw parsed query-string map from the HTTP request.
71
+ * @param parameters - The OpenAPI parameter definitions for the current operation.
72
+ * @returns A new map with exploded object parameters reconstructed as nested objects.
73
+ */
74
+ export function collectExplodedObjectParams(query, parameters) {
75
+ const result = { ...query };
76
+ for (const parameter of parameters) {
77
+ if (!isExplodedObjectQueryParam(parameter))
78
+ continue;
79
+ const properties = parameter.schema?.properties;
80
+ if (!properties)
81
+ continue;
82
+ const obj = {};
83
+ for (const key of Object.keys(properties)) {
84
+ if (key in result) {
85
+ obj[key] = result[key];
86
+ delete result[key];
87
+ }
88
+ }
89
+ if (Object.keys(obj).length > 0) {
90
+ result[parameter.name] = obj;
91
+ }
92
+ }
93
+ return result;
94
+ }
39
95
  /**
40
96
  * Core HTTP request dispatcher.
41
97
  *
@@ -52,12 +108,26 @@ export class Dispatcher {
52
108
  openApiDocument;
53
109
  fetch;
54
110
  config; // Add config property
55
- 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 = []) {
56
124
  this.registry = registry;
57
125
  this.contextRegistry = contextRegistry;
58
126
  this.openApiDocument = openApiDocument;
59
127
  this.fetch = fetch;
60
128
  this.config = config;
129
+ this.version = version;
130
+ this.versions = versions;
61
131
  }
62
132
  parameterTypes(parameters) {
63
133
  const types = {
@@ -72,43 +142,65 @@ export class Dispatcher {
72
142
  return types;
73
143
  }
74
144
  for (const parameter of parameters) {
75
- const type = parameter?.type;
145
+ const type = parameter?.type ?? parameter?.schema?.type;
76
146
  if (type !== undefined) {
77
147
  types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
78
148
  }
79
149
  }
80
150
  return types;
81
151
  }
82
- findOperation(path, method) {
83
- if (this.openApiDocument) {
84
- for (const key in this.openApiDocument.paths) {
85
- if (key.toLowerCase() === path.toLowerCase()) {
86
- return this.openApiDocument.paths[key]?.[method.toLowerCase()];
87
- }
152
+ findPathItem(path) {
153
+ if (!this.openApiDocument) {
154
+ return undefined;
155
+ }
156
+ for (const key in this.openApiDocument.paths) {
157
+ if (key.toLowerCase() === path.toLowerCase()) {
158
+ return this.openApiDocument.paths[key];
88
159
  }
89
160
  }
90
161
  return undefined;
91
162
  }
92
163
  /**
93
164
  * Resolves the OpenAPI operation for `path` and `method`, merging any
94
- * top-level `produces` array from the document root into the operation.
165
+ * top-level `produces` array from the document root and any path-item-level
166
+ * `parameters` into the operation.
167
+ *
168
+ * Per the OpenAPI specification, parameters defined at the path item level
169
+ * are shared across all operations on that path. Operation-level parameters
170
+ * take precedence when both define a parameter with the same `name` and `in`.
95
171
  *
96
172
  * @param path - The matched route path (e.g. `"/pets/{petId}"`).
97
173
  * @param method - The HTTP method.
98
174
  * @returns The {@link OpenApiOperation} if found, or `undefined`.
99
175
  */
100
176
  operationForPathAndMethod(path, method) {
101
- const operation = this.findOperation(path, method);
177
+ const pathItem = this.findPathItem(path);
178
+ if (pathItem === undefined) {
179
+ return undefined;
180
+ }
181
+ const operation = pathItem[method.toLowerCase()];
102
182
  if (operation === undefined) {
103
183
  return undefined;
104
184
  }
185
+ // Merge path-item-level parameters with operation-level parameters.
186
+ // Operation-level parameters take precedence on same name+in collision.
187
+ const pathItemParams = pathItem.parameters ?? [];
188
+ const operationParams = operation.parameters ?? [];
189
+ const mergedParameters = pathItemParams.length > 0
190
+ ? mergeParameters(pathItemParams, operationParams)
191
+ : operationParams.length > 0
192
+ ? operationParams
193
+ : undefined;
194
+ const mergedOperation = mergedParameters !== undefined
195
+ ? { ...operation, parameters: mergedParameters }
196
+ : operation;
105
197
  if (this.openApiDocument?.produces) {
106
198
  return {
107
199
  produces: this.openApiDocument.produces,
108
- ...operation,
200
+ ...mergedOperation,
109
201
  };
110
202
  }
111
- return operation;
203
+ return mergedOperation;
112
204
  }
113
205
  normalizeResponse(response, acceptHeader) {
114
206
  if (response.content !== undefined) {
@@ -208,6 +300,9 @@ export class Dispatcher {
208
300
  };
209
301
  }
210
302
  }
303
+ // Reconstruct exploded object query parameters so that `$.query.<name>`
304
+ // contains the assembled object instead of only the individual flat keys.
305
+ const processedQuery = collectExplodedObjectParams(query, operation?.parameters ?? []);
211
306
  const continuousDistribution = (min, max) => {
212
307
  return min + Math.random() * (max - min);
213
308
  };
@@ -238,10 +333,22 @@ export class Dispatcher {
238
333
  status: fetchResponse.status,
239
334
  };
240
335
  },
241
- query,
336
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
337
+ query: processedQuery,
242
338
  // @ts-expect-error - Might be pushing the limits of what TypeScript can do here
243
339
  response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
244
340
  tools: new Tools({ headers }),
341
+ ...(this.version !== "" && {
342
+ version: this.version,
343
+ minVersion: (min) => {
344
+ const currentIdx = this.versions.indexOf(this.version);
345
+ const minIdx = this.versions.indexOf(min);
346
+ if (currentIdx === -1 || minIdx === -1) {
347
+ return false;
348
+ }
349
+ return currentIdx >= minIdx;
350
+ },
351
+ }),
245
352
  });
246
353
  if (response === undefined) {
247
354
  return {
@@ -4,10 +4,51 @@ const ajv = new Ajv({
4
4
  strict: false,
5
5
  coerceTypes: false,
6
6
  });
7
+ /**
8
+ * Returns `true` when a query parameter should be serialized as exploded form
9
+ * style — meaning its object properties appear as individual query parameters
10
+ * instead of being passed under the parameter's own name.
11
+ *
12
+ * Per OpenAPI 3.x: for `in: query`, the default `style` is `form` and the
13
+ * default `explode` for `form` is `true`. An object-type parameter with these
14
+ * defaults (or with them set explicitly) uses exploded form serialization.
15
+ */
16
+ export function isExplodedObjectQueryParam(parameter) {
17
+ if (parameter.in !== "query")
18
+ return false;
19
+ const schema = parameter.schema;
20
+ if (!schema)
21
+ return false;
22
+ // Must be an object type (explicit type or implied by presence of properties)
23
+ const isObjectType = schema.type === "object" || schema.properties !== undefined;
24
+ if (!isObjectType)
25
+ return false;
26
+ // style must be "form" (the default for query params) or unset
27
+ if (parameter.style !== undefined && parameter.style !== "form")
28
+ return false;
29
+ // explode must not be explicitly false (default for form style is true)
30
+ if (parameter.explode === false)
31
+ return false;
32
+ return true;
33
+ }
7
34
  function findMissingRequired(parameters, location, values) {
8
35
  return parameters
9
36
  .filter((p) => p.in === location && p.required === true)
10
- .filter((p) => !(p.name in values) || values[p.name] === undefined)
37
+ .filter((p) => {
38
+ // For exploded object query params the individual properties appear as
39
+ // separate query parameters, so we check for those instead of the name.
40
+ if (location === "query" && isExplodedObjectQueryParam(p)) {
41
+ const properties = p.schema?.properties;
42
+ if (!properties) {
43
+ // Free-form object with no declared properties: treat as always
44
+ // satisfied — we cannot know which keys belong to the parameter.
45
+ return false;
46
+ }
47
+ // The parameter is "missing" only when none of its properties are present.
48
+ return !Object.keys(properties).some((key) => key in values);
49
+ }
50
+ return !(p.name in values) || values[p.name] === undefined;
51
+ })
11
52
  .map((p) => `${location} parameter '${p.name}' is required`);
12
53
  }
13
54
  export function validateRequest(operation, request) {
@@ -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,
@@ -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
  /**
@@ -116,7 +118,7 @@ export class CodeGenerator extends EventTarget {
116
118
  }
117
119
  repository
118
120
  .get(`routes${path}.ts`)
119
- .export(new OperationCoder(operation, requestMethod, securitySchemes));
121
+ .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
120
122
  });
121
123
  });
122
124
  debug("telling the repository to write the files to %s", destination);
@@ -12,8 +12,10 @@ import { RESERVED_WORDS } from "./reserved-words.js";
12
12
  */
13
13
  export class Coder {
14
14
  requirement;
15
- constructor(requirement) {
15
+ version;
16
+ constructor(requirement, version = "") {
16
17
  this.requirement = requirement;
18
+ this.version = version;
17
19
  }
18
20
  /**
19
21
  * A stable cache key for this coder, composed of the constructor name and
@@ -84,7 +86,7 @@ export class Coder {
84
86
  return this;
85
87
  }
86
88
  const requirement = await this.requirement.reference();
87
- return new this.constructor(requirement);
89
+ return new this.constructor(requirement, this.version);
88
90
  }
89
91
  /**
90
92
  * Generator that yields candidate export names for this coder.
@@ -1,6 +1,6 @@
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
4
  /**
5
5
  * Generates the default route handler stub for a single OpenAPI operation.
6
6
  *
@@ -14,9 +14,9 @@ import { OperationTypeCoder, } from "./operation-type-coder.js";
14
14
  export class OperationCoder extends Coder {
15
15
  requestMethod;
16
16
  securitySchemes;
17
- constructor(requirement, requestMethod, securitySchemes = []) {
18
- super(requirement);
19
- if (requestMethod === undefined) {
17
+ constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
18
+ super(requirement, version);
19
+ if (requestMethod === "") {
20
20
  throw new Error("requestMethod is required");
21
21
  }
22
22
  this.requestMethod = requestMethod;
@@ -40,7 +40,15 @@ export class OperationCoder extends Coder {
40
40
  }`;
41
41
  }
42
42
  typeDeclaration(_namespace, script) {
43
- const operationTypeCoder = new OperationTypeCoder(this.requirement, this.requestMethod, this.securitySchemes);
43
+ const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
44
+ if (this.version !== "") {
45
+ // For versioned APIs: register this version's $-argument type on the
46
+ // shared script so that Script.versionsTypeStatements() can emit the
47
+ // merged handler type after all versions have been declared.
48
+ const versionedArgCoder = new VersionedArgTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
49
+ const sharedScript = script.repository.get(operationTypeCoder.modulePath());
50
+ sharedScript.declareVersion(versionedArgCoder, operationTypeCoder.getOperationBaseName());
51
+ }
44
52
  return script.importType(operationTypeCoder);
45
53
  }
46
54
  modulePath() {
@@ -8,6 +8,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
10
  import { TypeCoder } from "./type-coder.js";
11
+ import { Requirement } from "./requirement.js";
11
12
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
12
13
  function sanitizeIdentifier(value) {
13
14
  // Treat any run of non-identifier characters as a camelCase separator
@@ -32,13 +33,21 @@ function sanitizeIdentifier(value) {
32
33
  * `cookie`, `body`, `context`, `response`, and `user` arguments.
33
34
  *
34
35
  * Output is written to `types/paths/<route>.types.ts`.
36
+ *
37
+ * **Versioned APIs**: when `version` is non-empty this coder emits only a
38
+ * sentinel `{raw: ""}` export (suppressing the normal flat type) and
39
+ * registers a formatter on the shared script so that
40
+ * {@link Script.versionsTypeStatements} can later emit the merged
41
+ * `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` handler type.
42
+ * Each version's `$`-argument type is emitted to
43
+ * `types/<version>/paths/<path>.types.ts` by {@link VersionedArgTypeCoder}.
35
44
  */
36
45
  export class OperationTypeCoder extends TypeCoder {
37
46
  requestMethod;
38
47
  securitySchemes;
39
- constructor(requirement, requestMethod, securitySchemes = []) {
40
- super(requirement);
41
- if (requestMethod === undefined) {
48
+ constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
49
+ super(requirement, version);
50
+ if (requestMethod === "") {
42
51
  throw new Error("requestMethod is required");
43
52
  }
44
53
  this.requestMethod = requestMethod;
@@ -79,7 +88,7 @@ export class OperationTypeCoder extends TypeCoder {
79
88
  }
80
89
  const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
81
90
  const typeName = `${baseName}_${capitalize(parameterKind)}`;
82
- const coder = new ParameterExportTypeCoder(this.requirement, typeName, inlineType, parameterKind);
91
+ const coder = new ParameterExportTypeCoder(this.requirement, this.version, typeName, inlineType, parameterKind);
83
92
  coder._modulePath = modulePath;
84
93
  return script.export(coder, true);
85
94
  }
@@ -99,7 +108,7 @@ export class OperationTypeCoder extends TypeCoder {
99
108
  return response.get("content").map((content, contentType) => `{
100
109
  status: ${status},
101
110
  contentType?: "${contentType}",
102
- body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema")).write(script) : "unknown"}
111
+ body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
103
112
  }`);
104
113
  }
105
114
  if (response.has("schema")) {
@@ -111,7 +120,7 @@ export class OperationTypeCoder extends TypeCoder {
111
120
  .map((contentType) => `{
112
121
  status: ${status},
113
122
  contentType?: "${contentType}",
114
- body?: ${new SchemaTypeCoder(response.get("schema")).write(script)}
123
+ body?: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
115
124
  }`)
116
125
  .join(" | ");
117
126
  }
@@ -141,18 +150,61 @@ export class OperationTypeCoder extends TypeCoder {
141
150
  }
142
151
  return "never";
143
152
  }
144
- writeCode(script) {
145
- script.comments = READ_ONLY_COMMENTS;
153
+ /**
154
+ * Returns the effective parameters for this operation by merging path-item-level
155
+ * parameters with operation-level parameters. Per the OpenAPI specification,
156
+ * operation-level parameters override path-item-level parameters that share
157
+ * the same `name` and `in` location.
158
+ *
159
+ * Uses `this.requirement.parent` (the path item requirement) to access
160
+ * path-item-level parameters directly, without URL string parsing.
161
+ *
162
+ * When the parent is not set (e.g. in unit tests that construct requirements
163
+ * directly), only the operation-level parameters are returned.
164
+ */
165
+ getEffectiveParameters() {
166
+ const operationParams = this.requirement.get("parameters");
167
+ const pathItemParams = this.requirement.parent?.get("parameters");
168
+ if (!pathItemParams) {
169
+ return operationParams;
170
+ }
171
+ if (!operationParams) {
172
+ return pathItemParams;
173
+ }
174
+ // 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;
178
+ const map = new Map();
179
+ for (const p of pathData) {
180
+ map.set(`${p.in}:${p.name}`, p);
181
+ }
182
+ for (const p of opData) {
183
+ map.set(`${p.in}:${p.name}`, p);
184
+ }
185
+ return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
186
+ }
187
+ /**
188
+ * Builds the `OmitValueWhenNever<{…}>` dollar-argument type body and sets
189
+ * up all required shared-type imports on `script`.
190
+ *
191
+ * This helper is reused by both {@link writeCode} (non-versioned) and
192
+ * {@link VersionedArgTypeCoder.writeCode} (per-version file).
193
+ *
194
+ * @param script - The script to write imports and parameter-type exports into.
195
+ * @param baseName - Identifier prefix used for named parameter-type exports.
196
+ * @param modulePath - Repository-relative path for parameter-type exports.
197
+ */
198
+ buildDollarArgType(script, baseName, modulePath) {
146
199
  const xType = script.importSharedType("WideOperationArgument");
147
200
  script.importSharedType("OmitValueWhenNever");
148
- script.importSharedType("MaybePromise");
149
201
  script.importSharedType("COUNTERFACT_RESPONSE");
150
202
  const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
151
- const parameters = this.requirement.get("parameters");
152
- const queryType = new ParametersTypeCoder(parameters, "query").write(script);
153
- const pathType = new ParametersTypeCoder(parameters, "path").write(script);
154
- const headersType = new ParametersTypeCoder(parameters, "header").write(script);
155
- const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
203
+ const parameters = this.getEffectiveParameters();
204
+ const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
205
+ const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
206
+ const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
207
+ const cookieType = new ParametersTypeCoder(parameters, this.version, "cookie").write(script);
156
208
  const bodyRequirement = (this.requirement.get("consumes") ??
157
209
  this.requirement.specification?.rootRequirement?.get("consumes"))
158
210
  ? parameters
@@ -161,19 +213,121 @@ export class OperationTypeCoder extends TypeCoder {
161
213
  : this.requirement.select("requestBody/content/application~1json/schema");
162
214
  const bodyType = bodyRequirement === undefined
163
215
  ? "never"
164
- : new SchemaTypeCoder(bodyRequirement).write(script);
165
- const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), (this.requirement.get("produces")?.data ??
216
+ : new SchemaTypeCoder(bodyRequirement, this.version).write(script);
217
+ const openApi2MediaTypes = (this.requirement.get("produces")?.data ??
166
218
  this.requirement.specification?.rootRequirement?.get("produces")
167
- ?.data)).write(script);
219
+ ?.data);
220
+ const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
168
221
  const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
169
222
  const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
170
- // Get the base name for this operation and export parameter types
171
- const baseName = this.getOperationBaseName();
172
- const modulePath = this.modulePath();
173
223
  const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
174
224
  const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
175
225
  const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
176
226
  const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
177
- return `($: OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType} }>) => MaybePromise<${this.responseTypes(script)} | { status: 415, contentType: "text/plain", body: string } | COUNTERFACT_RESPONSE | { ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE }>`;
227
+ 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} }>`;
229
+ }
230
+ writeCode(script) {
231
+ script.comments = READ_ONLY_COMMENTS;
232
+ if (this.version !== "") {
233
+ // Versioned case: suppress the normal flat export and register a
234
+ // formatter so that Script.versionsTypeStatements() can emit the
235
+ // merged HTTP_<METHOD>_$_Versions + HTTP_<METHOD> types after all
236
+ // versions have been declared via declareVersion().
237
+ const versionedType = script.importVersionsType("Versioned");
238
+ const maybePromiseType = script.importSharedType("MaybePromise");
239
+ const counterfactResponseType = script.importSharedType("COUNTERFACT_RESPONSE");
240
+ const baseName = this.getOperationBaseName();
241
+ script.setVersionFormatter(baseName, (versionCodes) => {
242
+ const versionsTypeName = `${baseName}_$_Versions`;
243
+ const versionMap = Array.from(versionCodes, ([v, code]) => `"${v}": ${code}`).join("; ");
244
+ return [
245
+ `type ${versionsTypeName} = { ${versionMap} };`,
246
+ `export type ${baseName} = ($: ${versionedType}<${versionsTypeName}>) => ${maybePromiseType}<${counterfactResponseType}>;`,
247
+ ].join("\n");
248
+ });
249
+ // Return a raw-empty sentinel so exportStatements() emits nothing for
250
+ // this export entry. The real export is produced by
251
+ // versionsTypeStatements().
252
+ return { raw: "" };
253
+ }
254
+ // Non-versioned case: existing flat-type output.
255
+ // Import in the same order as the original writeCode so that the emitted
256
+ // import block is identical to the pre-refactor output (snapshot-safe).
257
+ script.importSharedType("WideOperationArgument");
258
+ script.importSharedType("OmitValueWhenNever");
259
+ script.importSharedType("MaybePromise");
260
+ script.importSharedType("COUNTERFACT_RESPONSE");
261
+ const baseName = this.getOperationBaseName();
262
+ const modulePath = this.modulePath();
263
+ const dollarArgType = this.buildDollarArgType(script, baseName, modulePath);
264
+ return `($: ${dollarArgType}) => MaybePromise<COUNTERFACT_RESPONSE>`;
265
+ }
266
+ }
267
+ /**
268
+ * Emits a per-version `$`-argument type to
269
+ * `types/<version>/paths/<path>.types.ts`.
270
+ *
271
+ * When called from a *different* script (e.g. the shared
272
+ * `types/paths/…` script via `Script.declareVersion`), `write()` delegates to
273
+ * `script.importType(this)` so that the type is written to the per-version
274
+ * file and an import is added to the calling script.
275
+ *
276
+ * Only the `OmitValueWhenNever<{…}>` type body is emitted — the
277
+ * function-wrapper `($: Versioned<…>) => MaybePromise<COUNTERFACT_RESPONSE>`
278
+ * is assembled by the shared script's `versionsTypeStatements()`.
279
+ */
280
+ export class VersionedArgTypeCoder extends OperationTypeCoder {
281
+ /**
282
+ * Include the version in the cache key so v1 and v2 coders are treated as
283
+ * distinct exports even when they share the same requirement URL.
284
+ */
285
+ get id() {
286
+ return `${super.id}:${this.version}`;
287
+ }
288
+ /**
289
+ * The per-version `$`-argument type is emitted to
290
+ * `types/<version>/paths/<path>.types.ts`, not to the shared path.
291
+ */
292
+ modulePath() {
293
+ const pathString = this.requirement.url
294
+ .split("/")
295
+ .at(-2)
296
+ .replaceAll("~1", "/");
297
+ return `${pathJoin(`types/${this.version}/paths`, pathString === "/" ? "/index" : pathString)}.types.ts`;
298
+ }
299
+ /**
300
+ * Names are version-qualified (e.g. `HTTP_GET_$_v1`) so that importing
301
+ * multiple versions into the shared script requires no aliasing.
302
+ */
303
+ *names() {
304
+ const baseName = `${this.getOperationBaseName()}_$_${sanitizeIdentifier(this.version)}`;
305
+ yield baseName;
306
+ let index = 1;
307
+ const MAX = 100;
308
+ while (index < MAX) {
309
+ index += 1;
310
+ yield `${baseName}${index}`;
311
+ }
312
+ }
313
+ /**
314
+ * When called from the per-version file itself, generate the actual type.
315
+ * When called from any other script (e.g. the shared file), export to the
316
+ * per-version file and import the result back into that script.
317
+ */
318
+ write(script) {
319
+ if (script.path === this.modulePath()) {
320
+ return this.writeCode(script);
321
+ }
322
+ return script.importType(this);
323
+ }
324
+ /**
325
+ * Generates the `OmitValueWhenNever<{…}>` dollar-argument type and writes
326
+ * it to the per-version script.
327
+ */
328
+ writeCode(script) {
329
+ script.comments = READ_ONLY_COMMENTS;
330
+ const baseName = this.getOperationBaseName();
331
+ return this.buildDollarArgType(script, baseName, this.modulePath());
178
332
  }
179
333
  }
@@ -4,8 +4,8 @@ export class ParameterExportTypeCoder extends TypeCoder {
4
4
  _typeCode;
5
5
  _parameterKind;
6
6
  _modulePath;
7
- constructor(requirement, typeName, typeCode, parameterKind) {
8
- super(requirement);
7
+ constructor(requirement, version, typeName, typeCode, parameterKind) {
8
+ super(requirement, version);
9
9
  this._typeName = typeName;
10
10
  this._typeCode = typeCode;
11
11
  this._parameterKind = parameterKind;
@@ -4,8 +4,8 @@ import { SchemaTypeCoder } from "./schema-type-coder.js";
4
4
  import { TypeCoder } from "./type-coder.js";
5
5
  export class ParametersTypeCoder extends TypeCoder {
6
6
  placement;
7
- constructor(requirement, placement) {
8
- super(requirement);
7
+ constructor(requirement, version = "", placement = "") {
8
+ super(requirement, version);
9
9
  this.placement = placement;
10
10
  }
11
11
  names() {
@@ -26,7 +26,7 @@ export class ParametersTypeCoder extends TypeCoder {
26
26
  : parameter;
27
27
  const comment = buildJsDoc(parameter.data);
28
28
  const commentPrefix = comment ? `\n${comment}` : "";
29
- const typeString = new SchemaTypeCoder(schema).write(script);
29
+ const typeString = new SchemaTypeCoder(schema, this.version).write(script);
30
30
  return `${commentPrefix}"${name}"${optionalFlag}: ${typeString}`;
31
31
  });
32
32
  if (typeDefinitions.length === 0) {