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.
- package/README.md +36 -13
- package/dist/api-runner.js +19 -7
- package/dist/app.js +119 -15
- package/dist/cli/banner.js +1 -1
- package/dist/cli/run.js +10 -4
- package/dist/cli/telemetry.js +1 -6
- package/dist/migrate/update-route-types.js +1 -1
- package/dist/repl/repl.js +1 -4
- package/dist/server/counterfact-types/generic-response-builder.ts +1 -2
- package/dist/server/counterfact-types/open-api-parameters.ts +3 -0
- package/dist/server/dispatcher.js +121 -14
- package/dist/server/request-validator.js +42 -1
- package/dist/server/web-server/admin-api-middleware.js +1 -1
- package/dist/typescript-generator/code-generator.js +4 -2
- package/dist/typescript-generator/coder.js +4 -2
- package/dist/typescript-generator/operation-coder.js +13 -5
- package/dist/typescript-generator/operation-type-coder.js +175 -21
- package/dist/typescript-generator/parameter-export-type-coder.js +2 -2
- package/dist/typescript-generator/parameters-type-coder.js +3 -3
- package/dist/typescript-generator/requirement.js +12 -1
- package/dist/typescript-generator/response-type-coder.js +7 -6
- package/dist/typescript-generator/responses-type-coder.js +11 -5
- package/dist/typescript-generator/schema-coder.js +2 -2
- package/dist/typescript-generator/schema-type-coder.js +7 -5
- package/dist/typescript-generator/script.js +99 -3
- package/dist/typescript-generator/versions-ts-generator.js +57 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
83
|
-
if (this.openApiDocument) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
|
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
|
-
...
|
|
200
|
+
...mergedOperation,
|
|
109
201
|
};
|
|
110
202
|
}
|
|
111
|
-
return
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
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
|
-
|
|
145
|
-
|
|
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.
|
|
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
|
|
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)
|
|
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
|
-
|
|
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) {
|