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