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