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.
- package/README.md +2 -1
- 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 +42 -9
- package/dist/cli/telemetry.js +11 -10
- package/dist/migrate/update-route-types.js +1 -0
- package/dist/msw.js +1 -0
- package/dist/repl/repl.js +5 -4
- package/dist/server/counterfact-types/example.ts +5 -1
- package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
- package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
- package/dist/server/counterfact-types/response-builder.ts +5 -0
- package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
- package/dist/server/dispatcher.js +87 -12
- package/dist/server/json-to-xml.js +32 -7
- package/dist/server/module-loader.js +5 -0
- package/dist/server/openapi-document.js +5 -0
- package/dist/server/registry.js +22 -5
- package/dist/server/response-builder.js +27 -5
- package/dist/server/web-server/admin-api-middleware.js +1 -1
- package/dist/server/web-server/create-koa-app.js +3 -1
- package/dist/server/web-server/openapi-middleware.js +1 -0
- package/dist/server/web-server/routes-middleware.js +43 -1
- package/dist/typescript-generator/code-generator.js +17 -6
- package/dist/typescript-generator/coder.js +1 -1
- package/dist/typescript-generator/jsdoc.js +11 -7
- package/dist/typescript-generator/operation-coder.js +23 -1
- package/dist/typescript-generator/operation-type-coder.js +184 -11
- package/dist/typescript-generator/requirement.js +36 -3
- package/dist/typescript-generator/response-type-coder.js +20 -7
- package/dist/typescript-generator/responses-type-coder.js +8 -2
- package/dist/typescript-generator/schema-coder.js +2 -2
- package/dist/typescript-generator/schema-type-coder.js +16 -3
- package/dist/typescript-generator/script.js +46 -5
- package/dist/typescript-generator/specification.js +3 -1
- package/dist/typescript-generator/streaming-content-types.js +16 -0
- package/dist/typescript-generator/versions-ts-generator.js +82 -0
- package/package.json +24 -26
|
@@ -7,7 +7,9 @@ import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
|
|
|
7
7
|
import { RESERVED_WORDS } from "./reserved-words.js";
|
|
8
8
|
import { ResponsesTypeCoder } from "./responses-type-coder.js";
|
|
9
9
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
10
|
+
import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
|
|
10
11
|
import { TypeCoder } from "./type-coder.js";
|
|
12
|
+
import { Requirement } from "./requirement.js";
|
|
11
13
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
|
|
12
14
|
function sanitizeIdentifier(value) {
|
|
13
15
|
// Treat any run of non-identifier characters as a camelCase separator
|
|
@@ -32,6 +34,14 @@ function sanitizeIdentifier(value) {
|
|
|
32
34
|
* `cookie`, `body`, `context`, `response`, and `user` arguments.
|
|
33
35
|
*
|
|
34
36
|
* Output is written to `types/paths/<route>.types.ts`.
|
|
37
|
+
*
|
|
38
|
+
* **Versioned APIs**: when `version` is non-empty this coder emits only a
|
|
39
|
+
* sentinel `{raw: ""}` export (suppressing the normal flat type) and
|
|
40
|
+
* registers a formatter on the shared script so that
|
|
41
|
+
* {@link Script.versionsTypeStatements} can later emit the merged
|
|
42
|
+
* `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` handler type.
|
|
43
|
+
* Each version's `$`-argument type is emitted to
|
|
44
|
+
* `types/<version>/paths/<path>.types.ts` by {@link VersionedArgTypeCoder}.
|
|
35
45
|
*/
|
|
36
46
|
export class OperationTypeCoder extends TypeCoder {
|
|
37
47
|
requestMethod;
|
|
@@ -96,11 +106,23 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
96
106
|
? "number | undefined"
|
|
97
107
|
: Number.parseInt(responseCode, 10);
|
|
98
108
|
if (response.has("content")) {
|
|
99
|
-
return response.get("content").map((content, contentType) =>
|
|
109
|
+
return response.get("content").map((content, contentType) => {
|
|
110
|
+
let bodyType;
|
|
111
|
+
if (content.has("itemSchema") &&
|
|
112
|
+
STREAMING_CONTENT_TYPES.has(contentType)) {
|
|
113
|
+
bodyType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
bodyType = content.has("schema")
|
|
117
|
+
? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
|
|
118
|
+
: "unknown";
|
|
119
|
+
}
|
|
120
|
+
return `{
|
|
100
121
|
status: ${status},
|
|
101
122
|
contentType?: "${contentType}",
|
|
102
|
-
body?: ${
|
|
103
|
-
}
|
|
123
|
+
body?: ${bodyType}
|
|
124
|
+
}`;
|
|
125
|
+
});
|
|
104
126
|
}
|
|
105
127
|
if (response.has("schema")) {
|
|
106
128
|
const producesReq = this.requirement?.get("produces") ??
|
|
@@ -141,14 +163,57 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
141
163
|
}
|
|
142
164
|
return "never";
|
|
143
165
|
}
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Returns the effective parameters for this operation by merging path-item-level
|
|
168
|
+
* parameters with operation-level parameters. Per the OpenAPI specification,
|
|
169
|
+
* operation-level parameters override path-item-level parameters that share
|
|
170
|
+
* the same `name` and `in` location.
|
|
171
|
+
*
|
|
172
|
+
* Uses `this.requirement.parent` (the path item requirement) to access
|
|
173
|
+
* path-item-level parameters directly, without URL string parsing.
|
|
174
|
+
*
|
|
175
|
+
* When the parent is not set (e.g. in unit tests that construct requirements
|
|
176
|
+
* directly), only the operation-level parameters are returned.
|
|
177
|
+
*/
|
|
178
|
+
getEffectiveParameters() {
|
|
179
|
+
const operationParams = this.requirement.get("parameters");
|
|
180
|
+
const pathItemParams = this.requirement.parent?.get("parameters");
|
|
181
|
+
if (!pathItemParams) {
|
|
182
|
+
return operationParams;
|
|
183
|
+
}
|
|
184
|
+
if (!operationParams) {
|
|
185
|
+
return pathItemParams;
|
|
186
|
+
}
|
|
187
|
+
// Merge using a Map keyed on `${in}:${name}`.
|
|
188
|
+
// Path-level params are added first; operation-level overrides them.
|
|
189
|
+
const pathData = pathItemParams.data;
|
|
190
|
+
const opData = operationParams.data;
|
|
191
|
+
const map = new Map();
|
|
192
|
+
for (const p of pathData) {
|
|
193
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
194
|
+
}
|
|
195
|
+
for (const p of opData) {
|
|
196
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
197
|
+
}
|
|
198
|
+
return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Builds the `OmitValueWhenNever<{…}>` dollar-argument type body and sets
|
|
202
|
+
* up all required shared-type imports on `script`.
|
|
203
|
+
*
|
|
204
|
+
* This helper is reused by both {@link writeCode} (non-versioned) and
|
|
205
|
+
* {@link VersionedArgTypeCoder.writeCode} (per-version file).
|
|
206
|
+
*
|
|
207
|
+
* @param script - The script to write imports and parameter-type exports into.
|
|
208
|
+
* @param baseName - Identifier prefix used for named parameter-type exports.
|
|
209
|
+
* @param modulePath - Repository-relative path for parameter-type exports.
|
|
210
|
+
*/
|
|
211
|
+
buildDollarArgType(script, baseName, modulePath) {
|
|
146
212
|
const xType = script.importSharedType("WideOperationArgument");
|
|
147
213
|
script.importSharedType("OmitValueWhenNever");
|
|
148
|
-
script.importSharedType("MaybePromise");
|
|
149
214
|
script.importSharedType("COUNTERFACT_RESPONSE");
|
|
150
215
|
const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
|
|
151
|
-
const parameters = this.
|
|
216
|
+
const parameters = this.getEffectiveParameters();
|
|
152
217
|
const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
|
|
153
218
|
const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
|
|
154
219
|
const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
|
|
@@ -168,13 +233,121 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
168
233
|
const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
|
|
169
234
|
const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
|
|
170
235
|
const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
|
|
171
|
-
// Get the base name for this operation and export parameter types
|
|
172
|
-
const baseName = this.getOperationBaseName();
|
|
173
|
-
const modulePath = this.modulePath();
|
|
174
236
|
const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
|
|
175
237
|
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
|
|
176
238
|
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
|
|
177
239
|
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
|
|
178
|
-
|
|
240
|
+
// OpenAPI 3.2 querystring parameter: the entire query string treated as a
|
|
241
|
+
// single typed object (similar to requestBody for query strings).
|
|
242
|
+
const querystringParam = parameters?.find((parameter) => parameter.get("in")?.data === "querystring");
|
|
243
|
+
const querystringType = querystringParam?.has("schema") === true
|
|
244
|
+
? new SchemaTypeCoder(querystringParam.get("schema"), this.version).write(script)
|
|
245
|
+
: "never";
|
|
246
|
+
const querystringTypeName = this.exportParameterType(script, "querystring", querystringType, baseName, modulePath);
|
|
247
|
+
const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
|
|
248
|
+
return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
|
|
249
|
+
}
|
|
250
|
+
writeCode(script) {
|
|
251
|
+
script.comments = READ_ONLY_COMMENTS;
|
|
252
|
+
if (this.version !== "") {
|
|
253
|
+
// Versioned case: suppress the normal flat export and register a
|
|
254
|
+
// formatter so that Script.versionsTypeStatements() can emit the
|
|
255
|
+
// merged HTTP_<METHOD>_$_Versions + HTTP_<METHOD> types after all
|
|
256
|
+
// versions have been declared via declareVersion().
|
|
257
|
+
const versionedType = script.importVersionsType("Versioned");
|
|
258
|
+
const maybePromiseType = script.importSharedType("MaybePromise");
|
|
259
|
+
const counterfactResponseType = script.importSharedType("COUNTERFACT_RESPONSE");
|
|
260
|
+
const baseName = this.getOperationBaseName();
|
|
261
|
+
script.setVersionFormatter(baseName, (versionCodes) => {
|
|
262
|
+
const versionsTypeName = `${baseName}_$_Versions`;
|
|
263
|
+
const versionMap = Array.from(versionCodes, ([v, code]) => `"${v}": ${code}`).join("; ");
|
|
264
|
+
return [
|
|
265
|
+
`type ${versionsTypeName} = { ${versionMap} };`,
|
|
266
|
+
`export type ${baseName} = ($: ${versionedType}<${versionsTypeName}>) => ${maybePromiseType}<${counterfactResponseType}>;`,
|
|
267
|
+
].join("\n");
|
|
268
|
+
});
|
|
269
|
+
// Return a raw-empty sentinel so exportStatements() emits nothing for
|
|
270
|
+
// this export entry. The real export is produced by
|
|
271
|
+
// versionsTypeStatements().
|
|
272
|
+
return { raw: "" };
|
|
273
|
+
}
|
|
274
|
+
// Non-versioned case: existing flat-type output.
|
|
275
|
+
// Import in the same order as the original writeCode so that the emitted
|
|
276
|
+
// import block is identical to the pre-refactor output (snapshot-safe).
|
|
277
|
+
script.importSharedType("WideOperationArgument");
|
|
278
|
+
script.importSharedType("OmitValueWhenNever");
|
|
279
|
+
script.importSharedType("MaybePromise");
|
|
280
|
+
script.importSharedType("COUNTERFACT_RESPONSE");
|
|
281
|
+
const baseName = this.getOperationBaseName();
|
|
282
|
+
const modulePath = this.modulePath();
|
|
283
|
+
const dollarArgType = this.buildDollarArgType(script, baseName, modulePath);
|
|
284
|
+
return `($: ${dollarArgType}) => MaybePromise<COUNTERFACT_RESPONSE>`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Emits a per-version `$`-argument type to
|
|
289
|
+
* `types/<version>/paths/<path>.types.ts`.
|
|
290
|
+
*
|
|
291
|
+
* When called from a *different* script (e.g. the shared
|
|
292
|
+
* `types/paths/…` script via `Script.declareVersion`), `write()` delegates to
|
|
293
|
+
* `script.importType(this)` so that the type is written to the per-version
|
|
294
|
+
* file and an import is added to the calling script.
|
|
295
|
+
*
|
|
296
|
+
* Only the `OmitValueWhenNever<{…}>` type body is emitted — the
|
|
297
|
+
* function-wrapper `($: Versioned<…>) => MaybePromise<COUNTERFACT_RESPONSE>`
|
|
298
|
+
* is assembled by the shared script's `versionsTypeStatements()`.
|
|
299
|
+
*/
|
|
300
|
+
export class VersionedArgTypeCoder extends OperationTypeCoder {
|
|
301
|
+
/**
|
|
302
|
+
* Include the version in the cache key so v1 and v2 coders are treated as
|
|
303
|
+
* distinct exports even when they share the same requirement URL.
|
|
304
|
+
*/
|
|
305
|
+
get id() {
|
|
306
|
+
return `${super.id}:${this.version}`;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The per-version `$`-argument type is emitted to
|
|
310
|
+
* `types/<version>/paths/<path>.types.ts`, not to the shared path.
|
|
311
|
+
*/
|
|
312
|
+
modulePath() {
|
|
313
|
+
const pathString = this.requirement.url
|
|
314
|
+
.split("/")
|
|
315
|
+
.at(-2)
|
|
316
|
+
.replaceAll("~1", "/");
|
|
317
|
+
return `${pathJoin(`types/${this.version}/paths`, pathString === "/" ? "/index" : pathString)}.types.ts`;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Names are version-qualified (e.g. `HTTP_GET_$_v1`) so that importing
|
|
321
|
+
* multiple versions into the shared script requires no aliasing.
|
|
322
|
+
*/
|
|
323
|
+
*names() {
|
|
324
|
+
const baseName = `${this.getOperationBaseName()}_$_${sanitizeIdentifier(this.version)}`;
|
|
325
|
+
yield baseName;
|
|
326
|
+
let index = 1;
|
|
327
|
+
const MAX = 100;
|
|
328
|
+
while (index < MAX) {
|
|
329
|
+
index += 1;
|
|
330
|
+
yield `${baseName}${index}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* When called from the per-version file itself, generate the actual type.
|
|
335
|
+
* When called from any other script (e.g. the shared file), export to the
|
|
336
|
+
* per-version file and import the result back into that script.
|
|
337
|
+
*/
|
|
338
|
+
write(script) {
|
|
339
|
+
if (script.path === this.modulePath()) {
|
|
340
|
+
return this.writeCode(script);
|
|
341
|
+
}
|
|
342
|
+
return script.importType(this);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Generates the `OmitValueWhenNever<{…}>` dollar-argument type and writes
|
|
346
|
+
* it to the per-version script.
|
|
347
|
+
*/
|
|
348
|
+
writeCode(script) {
|
|
349
|
+
script.comments = READ_ONLY_COMMENTS;
|
|
350
|
+
const baseName = this.getOperationBaseName();
|
|
351
|
+
return this.buildDollarArgType(script, baseName, this.modulePath());
|
|
179
352
|
}
|
|
180
353
|
}
|
|
@@ -10,6 +10,15 @@ export class Requirement {
|
|
|
10
10
|
data;
|
|
11
11
|
url;
|
|
12
12
|
specification;
|
|
13
|
+
/**
|
|
14
|
+
* The requirement that produced this one via a `get()` call, or `undefined`
|
|
15
|
+
* for root requirements that were constructed directly.
|
|
16
|
+
*
|
|
17
|
+
* For path-traversal purposes this is the "logical" parent: when a `$ref` is
|
|
18
|
+
* followed, the parent is the resolved reference target rather than the
|
|
19
|
+
* `$ref` node itself.
|
|
20
|
+
*/
|
|
21
|
+
parent;
|
|
13
22
|
constructor(data, url = "", specification = undefined) {
|
|
14
23
|
this.data = data;
|
|
15
24
|
this.url = url;
|
|
@@ -17,7 +26,19 @@ export class Requirement {
|
|
|
17
26
|
}
|
|
18
27
|
/** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
|
|
19
28
|
get isReference() {
|
|
20
|
-
return this.data
|
|
29
|
+
return (typeof this.data === "object" &&
|
|
30
|
+
this.data !== null &&
|
|
31
|
+
this.data["$ref"] !== undefined);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* When this node is a JSON Reference, returns the raw `$ref` URL string.
|
|
35
|
+
* Returns `undefined` for non-reference (inline) nodes.
|
|
36
|
+
*/
|
|
37
|
+
get refUrl() {
|
|
38
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return this.data["$ref"];
|
|
21
42
|
}
|
|
22
43
|
/**
|
|
23
44
|
* Resolves the `$ref` and returns the target {@link Requirement}.
|
|
@@ -25,7 +46,7 @@ export class Requirement {
|
|
|
25
46
|
* @throws When `isReference` is `false` or the specification is not set.
|
|
26
47
|
*/
|
|
27
48
|
reference() {
|
|
28
|
-
return this.specification.getRequirement(this.
|
|
49
|
+
return this.specification.getRequirement(this.refUrl);
|
|
29
50
|
}
|
|
30
51
|
/**
|
|
31
52
|
* Returns `true` when this node has a child property named `item`.
|
|
@@ -38,6 +59,9 @@ export class Requirement {
|
|
|
38
59
|
if (this.isReference) {
|
|
39
60
|
return this.reference().has(item);
|
|
40
61
|
}
|
|
62
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
41
65
|
return item in this.data;
|
|
42
66
|
}
|
|
43
67
|
/**
|
|
@@ -53,7 +77,13 @@ export class Requirement {
|
|
|
53
77
|
if (!this.has(key)) {
|
|
54
78
|
return undefined;
|
|
55
79
|
}
|
|
56
|
-
|
|
80
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const objectData = this.data;
|
|
84
|
+
const child = new Requirement(objectData[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
|
|
85
|
+
child.parent = this;
|
|
86
|
+
return child;
|
|
57
87
|
}
|
|
58
88
|
/**
|
|
59
89
|
* Navigates to a descendant node using a slash-delimited JSON Pointer path.
|
|
@@ -97,6 +127,9 @@ export class Requirement {
|
|
|
97
127
|
* @param callback - Called for each child with `(child, key)`.
|
|
98
128
|
*/
|
|
99
129
|
forEach(callback) {
|
|
130
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
100
133
|
Object.keys(this.data).forEach((key) => {
|
|
101
134
|
callback(this.select(this.escapeJsonPointer(key)), key);
|
|
102
135
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { printObject } from "./printers.js";
|
|
2
2
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
3
|
+
import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
|
|
3
4
|
import { TypeCoder } from "./type-coder.js";
|
|
4
5
|
import { pathJoin } from "../util/forward-slash-path.js";
|
|
5
6
|
export class ResponseTypeCoder extends TypeCoder {
|
|
@@ -9,18 +10,30 @@ export class ResponseTypeCoder extends TypeCoder {
|
|
|
9
10
|
this.openApi2MediaTypes = openApi2MediaTypes;
|
|
10
11
|
}
|
|
11
12
|
names() {
|
|
12
|
-
return super.names(this.requirement.
|
|
13
|
+
return super.names(this.requirement.refUrl.split("/").at(-1));
|
|
13
14
|
}
|
|
14
15
|
buildContentObjectType(script, response) {
|
|
15
16
|
if (response.has("content")) {
|
|
16
17
|
return response
|
|
17
18
|
.get("content")
|
|
18
|
-
.map((content, mediaType) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
.map((content, mediaType) => {
|
|
20
|
+
let schemaType;
|
|
21
|
+
if (content.has("itemSchema") &&
|
|
22
|
+
STREAMING_CONTENT_TYPES.has(mediaType)) {
|
|
23
|
+
schemaType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
schemaType = content.has("schema")
|
|
27
|
+
? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
|
|
28
|
+
: "unknown";
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
mediaType,
|
|
32
|
+
`{
|
|
33
|
+
schema: ${schemaType}
|
|
22
34
|
}`,
|
|
23
|
-
|
|
35
|
+
];
|
|
36
|
+
});
|
|
24
37
|
}
|
|
25
38
|
return this.openApi2MediaTypes.map((mediaType) => [
|
|
26
39
|
mediaType,
|
|
@@ -78,7 +91,7 @@ export class ResponseTypeCoder extends TypeCoder {
|
|
|
78
91
|
return printObject(exampleNames.map((name) => [name, "unknown"]));
|
|
79
92
|
}
|
|
80
93
|
modulePath() {
|
|
81
|
-
return pathJoin("types", this.version, this.requirement.
|
|
94
|
+
return pathJoin("types", this.version, this.requirement.refUrl + ".ts");
|
|
82
95
|
}
|
|
83
96
|
writeCode(script) {
|
|
84
97
|
return `{
|
|
@@ -21,10 +21,16 @@ export class ResponsesTypeCoder extends TypeCoder {
|
|
|
21
21
|
return statusCode;
|
|
22
22
|
}
|
|
23
23
|
buildResponseObjectType(script) {
|
|
24
|
-
|
|
24
|
+
const entries = this.requirement.map((response, responseCode) => [
|
|
25
25
|
this.normalizeStatusCode(responseCode),
|
|
26
26
|
new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
|
|
27
|
-
])
|
|
27
|
+
]);
|
|
28
|
+
const explicitEntries = entries.filter(([key]) => !key.startsWith("["));
|
|
29
|
+
const mappedEntries = entries.filter(([key]) => key.startsWith("["));
|
|
30
|
+
if (explicitEntries.length > 0 && mappedEntries.length > 0) {
|
|
31
|
+
return `${printObjectWithoutQuotes(explicitEntries)} & ${printObjectWithoutQuotes(mappedEntries)}`;
|
|
32
|
+
}
|
|
33
|
+
return printObjectWithoutQuotes(entries);
|
|
28
34
|
}
|
|
29
35
|
writeCode(script) {
|
|
30
36
|
script.importSharedType("ResponseBuilderFactory");
|
|
@@ -8,7 +8,7 @@ function scrubSchema(schema) {
|
|
|
8
8
|
}
|
|
9
9
|
export class SchemaCoder extends Coder {
|
|
10
10
|
names() {
|
|
11
|
-
return super.names(`${this.requirement.
|
|
11
|
+
return super.names(`${this.requirement.refUrl.split("/").at(-1)}Schema`);
|
|
12
12
|
}
|
|
13
13
|
objectSchema(script) {
|
|
14
14
|
const { properties, required } = this.requirement.data;
|
|
@@ -34,7 +34,7 @@ export class SchemaCoder extends Coder {
|
|
|
34
34
|
return script.importExternalType("JSONSchema6", "json-schema");
|
|
35
35
|
}
|
|
36
36
|
modulePath() {
|
|
37
|
-
return `types/${this.requirement.
|
|
37
|
+
return `types/${this.requirement.refUrl}.ts`;
|
|
38
38
|
}
|
|
39
39
|
writeCode(script) {
|
|
40
40
|
const { type } = this.requirement.data;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildJsDoc } from "./jsdoc.js";
|
|
2
2
|
import { TypeCoder } from "./type-coder.js";
|
|
3
|
+
import { Requirement } from "./requirement.js";
|
|
3
4
|
import { pathJoin } from "../util/forward-slash-path.js";
|
|
4
5
|
export class SchemaTypeCoder extends TypeCoder {
|
|
5
6
|
names() {
|
|
6
|
-
return super.names(this.requirement.
|
|
7
|
+
return super.names(this.requirement.refUrl?.split("/").at(-1));
|
|
7
8
|
}
|
|
8
9
|
jsdoc() {
|
|
9
10
|
return buildJsDoc(this.requirement.data);
|
|
@@ -82,6 +83,14 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
82
83
|
const key = matchingKey();
|
|
83
84
|
const items = (allOf ?? anyOf ?? oneOf);
|
|
84
85
|
const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index), this.version).write(script));
|
|
86
|
+
// Include the default schema from discriminator.defaultMapping (OpenAPI 3.2)
|
|
87
|
+
if (!allOf) {
|
|
88
|
+
const { discriminator } = this.requirement.data;
|
|
89
|
+
if (discriminator?.defaultMapping) {
|
|
90
|
+
const defaultRequirement = new Requirement({ $ref: discriminator.defaultMapping }, "", this.requirement.specification);
|
|
91
|
+
types.push(new SchemaTypeCoder(defaultRequirement, this.version).write(script));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
85
94
|
return types.join(allOf ? " & " : " | ");
|
|
86
95
|
}
|
|
87
96
|
writeEnum(_script, requirement) {
|
|
@@ -90,10 +99,14 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
90
99
|
.join(" | ");
|
|
91
100
|
}
|
|
92
101
|
modulePath() {
|
|
93
|
-
return pathJoin("types", this.version, this.requirement.
|
|
102
|
+
return pathJoin("types", this.version, this.requirement.refUrl.replace(/^#\//u, "") + ".ts");
|
|
94
103
|
}
|
|
95
104
|
writeCode(script) {
|
|
96
|
-
const { allOf, anyOf, oneOf, type, format } = this.requirement
|
|
105
|
+
const { allOf, anyOf, oneOf, type, format, itemSchema } = this.requirement
|
|
106
|
+
.data;
|
|
107
|
+
if (itemSchema) {
|
|
108
|
+
return `AsyncIterable<${new SchemaTypeCoder(this.requirement.get("itemSchema"), this.version).write(script)}>`;
|
|
109
|
+
}
|
|
97
110
|
if (allOf ?? anyOf ?? oneOf) {
|
|
98
111
|
return this.writeGroup(script, { allOf, anyOf, oneOf });
|
|
99
112
|
}
|
|
@@ -18,6 +18,7 @@ export class Script {
|
|
|
18
18
|
comments;
|
|
19
19
|
exports;
|
|
20
20
|
versions;
|
|
21
|
+
versionFormatters;
|
|
21
22
|
imports;
|
|
22
23
|
externalImport;
|
|
23
24
|
cache;
|
|
@@ -28,6 +29,7 @@ export class Script {
|
|
|
28
29
|
this.comments = [];
|
|
29
30
|
this.exports = new Map();
|
|
30
31
|
this.versions = new Map();
|
|
32
|
+
this.versionFormatters = new Map();
|
|
31
33
|
this.imports = new Map();
|
|
32
34
|
this.externalImport = new Map();
|
|
33
35
|
this.cache = new Map();
|
|
@@ -175,9 +177,30 @@ export class Script {
|
|
|
175
177
|
importSharedType(name) {
|
|
176
178
|
return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
|
|
177
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Imports a type from the generated `types/versions.ts` module,
|
|
182
|
+
* resolving the path relative to this script's location in the repository.
|
|
183
|
+
*
|
|
184
|
+
* @param name - The type name to import (e.g. `"Versioned"`).
|
|
185
|
+
*/
|
|
186
|
+
importVersionsType(name) {
|
|
187
|
+
return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true);
|
|
188
|
+
}
|
|
178
189
|
exportType(coder) {
|
|
179
190
|
return this.export(coder, true);
|
|
180
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Registers a formatter function for the merged versioned type emitted under
|
|
194
|
+
* `name` by {@link versionsTypeStatements}.
|
|
195
|
+
*
|
|
196
|
+
* When a formatter is present for a name, `versionsTypeStatements` delegates
|
|
197
|
+
* the entire type declaration to it instead of generating the default
|
|
198
|
+
* `Versions` object type. The formatter receives a `Map<version, importAlias>`
|
|
199
|
+
* and must return the complete TypeScript source for that operation type.
|
|
200
|
+
*/
|
|
201
|
+
setVersionFormatter(name, formatter) {
|
|
202
|
+
this.versionFormatters.set(name, formatter);
|
|
203
|
+
}
|
|
181
204
|
declareVersion(coder, name) {
|
|
182
205
|
const version = coder.version;
|
|
183
206
|
const versions = this.versions.get(name) ?? new Map();
|
|
@@ -254,11 +277,29 @@ export class Script {
|
|
|
254
277
|
if (this.versions.size === 0) {
|
|
255
278
|
return [];
|
|
256
279
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
const statements = [];
|
|
281
|
+
const unformatted = [];
|
|
282
|
+
for (const [name, versions] of this.versions) {
|
|
283
|
+
const formatter = this.versionFormatters.get(name);
|
|
284
|
+
if (formatter) {
|
|
285
|
+
const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [
|
|
286
|
+
version,
|
|
287
|
+
stmt.code,
|
|
288
|
+
]));
|
|
289
|
+
statements.push(formatter(versionCodes));
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
unformatted.push([name, versions]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (unformatted.length > 0) {
|
|
296
|
+
const names = unformatted.map(([name, versions]) => {
|
|
297
|
+
const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
|
|
298
|
+
return `"${name}": { ${mappedVersions.join(", ")} }`;
|
|
299
|
+
});
|
|
300
|
+
statements.push(`export type Versions = { ${names.join(", ")} };`);
|
|
301
|
+
}
|
|
302
|
+
return statements;
|
|
262
303
|
}
|
|
263
304
|
/**
|
|
264
305
|
* Formats the fully assembled script source with Prettier and returns it.
|
|
@@ -49,7 +49,9 @@ export class Specification {
|
|
|
49
49
|
*/
|
|
50
50
|
async load(urlOrPath) {
|
|
51
51
|
try {
|
|
52
|
-
this.rootRequirement = new Requirement((await bundle(urlOrPath
|
|
52
|
+
this.rootRequirement = new Requirement((await bundle(urlOrPath, {
|
|
53
|
+
resolve: { http: { safeUrlResolver: false } },
|
|
54
|
+
})), urlOrPath, this);
|
|
53
55
|
}
|
|
54
56
|
catch (error) {
|
|
55
57
|
const details = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content types that represent sequential/streaming media in OpenAPI 3.2.
|
|
3
|
+
*
|
|
4
|
+
* When a Media Type Object uses `itemSchema` together with one of these content
|
|
5
|
+
* types, the generated TypeScript body type is `AsyncIterable<T>` rather than
|
|
6
|
+
* a plain schema type. On the server side, returning an `AsyncIterable` for
|
|
7
|
+
* one of these content types causes Counterfact to stream each item in the
|
|
8
|
+
* appropriate wire format.
|
|
9
|
+
*/
|
|
10
|
+
export const STREAMING_CONTENT_TYPES = new Set([
|
|
11
|
+
"text/event-stream",
|
|
12
|
+
"application/jsonl",
|
|
13
|
+
"application/x-ndjson",
|
|
14
|
+
"application/ndjson",
|
|
15
|
+
"application/json-seq",
|
|
16
|
+
]);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { format } from "prettier";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the `VersionsGTE` map: for each version V at index i,
|
|
4
|
+
* maps V to all versions at indices >= i (i.e. V and all later-declared
|
|
5
|
+
* versions, where "later" means "newer").
|
|
6
|
+
*/
|
|
7
|
+
function buildVersionsGTE(versions) {
|
|
8
|
+
const result = new Map();
|
|
9
|
+
versions.forEach((version, index) => {
|
|
10
|
+
result.set(version, versions.slice(index));
|
|
11
|
+
});
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Generates the TypeScript source text for `types/versions.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Returns an empty string when `versions` is empty.
|
|
18
|
+
* The returned string is formatted with Prettier.
|
|
19
|
+
*
|
|
20
|
+
* @param versions - Ordered list of unique, non-empty version strings.
|
|
21
|
+
* The first entry is the oldest version.
|
|
22
|
+
*/
|
|
23
|
+
export async function generateVersionsTsContent(versions) {
|
|
24
|
+
if (versions.length === 0) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
const versionsUnion = versions.map((v) => `"${v}"`).join(" | ");
|
|
28
|
+
const versionsGTE = buildVersionsGTE(versions);
|
|
29
|
+
const versionsGTEBody = Array.from(versionsGTE, ([v, gte]) => ` "${v}": ${gte.map((g) => `"${g}"`).join(" | ")};`).join("\n");
|
|
30
|
+
const source = [
|
|
31
|
+
"// This file is auto-generated by Counterfact. Do not edit.",
|
|
32
|
+
"",
|
|
33
|
+
'/** Union of all version strings declared for this API group (e.g. `"v1" | "v2" | "v3"`). */',
|
|
34
|
+
`export type Versions = ${versionsUnion};`,
|
|
35
|
+
"",
|
|
36
|
+
"/**",
|
|
37
|
+
" * Maps each version to the set of versions that are greater than or equal to it.",
|
|
38
|
+
" * Used by `Versioned.minVersion()` to narrow which versions a handler must support.",
|
|
39
|
+
" */",
|
|
40
|
+
"export type VersionsGTE = {",
|
|
41
|
+
versionsGTEBody,
|
|
42
|
+
"};",
|
|
43
|
+
"",
|
|
44
|
+
"type VersionMap = Partial<Record<Versions, object>>;",
|
|
45
|
+
"",
|
|
46
|
+
"/**",
|
|
47
|
+
" * The type of the `$` argument in a versioned route handler.",
|
|
48
|
+
" *",
|
|
49
|
+
" * @typeParam T - A map from version string to the `$`-arg type for that version",
|
|
50
|
+
" * (e.g. `{ v1: $v1Type; v2: $v2Type }`). Generated by Counterfact from the spec.",
|
|
51
|
+
" * @typeParam V - The union of currently active version keys; defaults to all keys of `T`.",
|
|
52
|
+
" *",
|
|
53
|
+
" * An instance of `Versioned<T, V>` exposes:",
|
|
54
|
+
" * - All properties of the intersection `T[V]` (request data, response builders, etc.)",
|
|
55
|
+
' * - `version` — the version string for the current request (e.g. `"v2"`).',
|
|
56
|
+
" * - `minVersion(min)` — type predicate that returns `true` when the current version",
|
|
57
|
+
" * is at or after `min` in the declared version order and narrows `$` accordingly.",
|
|
58
|
+
" *",
|
|
59
|
+
" * @example",
|
|
60
|
+
" * ```ts",
|
|
61
|
+
" * export const GET: HTTP_GET = ($) => {",
|
|
62
|
+
' * if ($.minVersion("v2")) {',
|
|
63
|
+
" * // $ is now typed as the v2+ arg — v2-only fields are available",
|
|
64
|
+
" * return $.response[200].json({ id: $.path.id, extra: $.body.extra });",
|
|
65
|
+
" * }",
|
|
66
|
+
" * return $.response[200].json({ id: $.path.id });",
|
|
67
|
+
" * };",
|
|
68
|
+
" * ```",
|
|
69
|
+
" */",
|
|
70
|
+
"export type Versioned<",
|
|
71
|
+
" T extends VersionMap,",
|
|
72
|
+
" V extends keyof T & Versions = keyof T & Versions,",
|
|
73
|
+
"> = T[V] & {",
|
|
74
|
+
" version: V;",
|
|
75
|
+
" minVersion<M extends keyof T & Versions>(",
|
|
76
|
+
" min: M,",
|
|
77
|
+
" ): this is Versioned<T, Extract<V, VersionsGTE[M]>>;",
|
|
78
|
+
"};",
|
|
79
|
+
"",
|
|
80
|
+
].join("\n");
|
|
81
|
+
return format(source, { parser: "typescript" });
|
|
82
|
+
}
|