counterfact 2.11.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/cli/run.js +32 -5
- 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 +12 -1
- 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/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 +13 -4
- 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 +24 -4
- 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 +3 -1
- package/dist/typescript-generator/streaming-content-types.js +16 -0
- package/dist/typescript-generator/versions-ts-generator.js +25 -0
- package/package.json +24 -26
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br>
|
|
6
6
|
|
|
7
|
-
 [](https://coveralls.io/github/pmcelhaney/counterfact) 
|
|
7
|
+
 [](https://coveralls.io/github/pmcelhaney/counterfact)   
|
|
8
8
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
@@ -17,6 +17,7 @@ Mock servers make it easy to get started, but hard to keep going.<br>
|
|
|
17
17
|
Counterfact is an API simulator without those limits.
|
|
18
18
|
|
|
19
19
|
Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
|
|
20
|
+
Supports Swagger 2.0 and OpenAPI 3.0, 3.1, and 3.2.
|
|
20
21
|
- Type-safe TypeScript handlers for every endpoint
|
|
21
22
|
- Hot reloading as you edit
|
|
22
23
|
- Shared state across routes
|
package/dist/cli/run.js
CHANGED
|
@@ -13,7 +13,7 @@ import { pathResolve } from "../util/forward-slash-path.js";
|
|
|
13
13
|
import { loadConfigFile } from "../util/load-config-file.js";
|
|
14
14
|
import { createIntroduction } from "./banner.js";
|
|
15
15
|
import { checkForUpdates } from "./check-for-updates.js";
|
|
16
|
-
import { isTelemetryEnabled, sendTelemetry } from "./telemetry.js";
|
|
16
|
+
import { hashTelemetryLocation, isTelemetryEnabled, sendTelemetry, } from "./telemetry.js";
|
|
17
17
|
const debug = createDebug("counterfact:cli:run");
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const DEFAULT_PORT = 3100;
|
|
@@ -55,6 +55,35 @@ export function normalizeSpecOption(specOption) {
|
|
|
55
55
|
}
|
|
56
56
|
return undefined;
|
|
57
57
|
}
|
|
58
|
+
export function buildStartupTelemetryProperties(options, source, version, specs) {
|
|
59
|
+
const apiSources = specs?.map((spec) => spec.source) ?? [source];
|
|
60
|
+
const apiFileLocationHashes = apiSources
|
|
61
|
+
.filter((apiSource) => apiSource !== "_")
|
|
62
|
+
.map((apiSource) => hashTelemetryLocation(apiSource));
|
|
63
|
+
return {
|
|
64
|
+
alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
|
|
65
|
+
apiFileLocationHashes,
|
|
66
|
+
buildCache: Boolean(options.buildCache),
|
|
67
|
+
generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
|
|
68
|
+
generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
|
|
69
|
+
mode: specs !== undefined
|
|
70
|
+
? "multi-spec"
|
|
71
|
+
: source === "_"
|
|
72
|
+
? "without-openapi"
|
|
73
|
+
: "single-spec",
|
|
74
|
+
openBrowser: Boolean(options.open),
|
|
75
|
+
port: options.port,
|
|
76
|
+
prune: Boolean(options.prune),
|
|
77
|
+
repl: Boolean(options.repl),
|
|
78
|
+
serve: Boolean(options.serve),
|
|
79
|
+
updateCheck: Boolean(options.updateCheck),
|
|
80
|
+
validateRequest: Boolean(options.validateRequest),
|
|
81
|
+
validateResponse: Boolean(options.validateResponse),
|
|
82
|
+
version,
|
|
83
|
+
watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
|
|
84
|
+
watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
58
87
|
/**
|
|
59
88
|
* Builds the Commander program with all CLI options and the action handler.
|
|
60
89
|
* Factored out of `runCli` so it is easy to test or extend.
|
|
@@ -116,6 +145,7 @@ function buildProgram(version, taglines) {
|
|
|
116
145
|
debug("options: %o", options);
|
|
117
146
|
debug("source: %s", source);
|
|
118
147
|
debug("destination: %s", destination);
|
|
148
|
+
const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
|
|
119
149
|
const openBrowser = options.open;
|
|
120
150
|
const url = `http://localhost:${options.port}${options.prefix}`;
|
|
121
151
|
const guiUrl = `${url}/counterfact/`;
|
|
@@ -213,6 +243,7 @@ function buildProgram(version, taglines) {
|
|
|
213
243
|
process.exit(1);
|
|
214
244
|
}
|
|
215
245
|
debug("started server");
|
|
246
|
+
sendTelemetry("counterfact_started", startupTelemetryProperties);
|
|
216
247
|
await updateCheckPromise;
|
|
217
248
|
if (config.startRepl) {
|
|
218
249
|
startRepl();
|
|
@@ -300,10 +331,6 @@ export async function runCli(argv) {
|
|
|
300
331
|
catch {
|
|
301
332
|
taglines = ["counterfact — mock API server"];
|
|
302
333
|
}
|
|
303
|
-
// Fire telemetry once on startup — fire-and-forget, never blocks.
|
|
304
|
-
if (isTelemetryEnabled()) {
|
|
305
|
-
sendTelemetry(version);
|
|
306
|
-
}
|
|
307
334
|
debug("running counterfact CLI v%s", version);
|
|
308
335
|
const program = buildProgram(version, taglines);
|
|
309
336
|
await program.parseAsync(argv);
|
package/dist/cli/telemetry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { PostHog } from "posthog-node";
|
|
3
3
|
const POSTHOG_API_KEY = "phc_msXmBxiL8FVugNMLCx9bnPQGqfEMqmyBjnVkKhHkN3m7";
|
|
4
4
|
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
@@ -15,24 +15,30 @@ export function isTelemetryEnabled() {
|
|
|
15
15
|
return false;
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
|
+
export function hashTelemetryLocation(location) {
|
|
19
|
+
return createHash("sha256").update(location).digest("hex");
|
|
20
|
+
}
|
|
18
21
|
/**
|
|
19
22
|
* Fires a telemetry event to PostHog. Fire-and-forget — never blocks
|
|
20
23
|
* startup and never surfaces errors to the user.
|
|
21
24
|
*/
|
|
22
|
-
export function sendTelemetry(
|
|
25
|
+
export function sendTelemetry(event, properties = {}) {
|
|
26
|
+
if (!isTelemetryEnabled()) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
23
29
|
const telemetryKey = process.env["POSTHOG_API_KEY"] ?? POSTHOG_API_KEY;
|
|
24
30
|
const telemetryHost = process.env["POSTHOG_HOST"] ?? POSTHOG_HOST;
|
|
25
31
|
try {
|
|
26
32
|
const posthog = new PostHog(telemetryKey, { host: telemetryHost });
|
|
27
33
|
posthog.capture({
|
|
28
34
|
distinctId: randomUUID(),
|
|
29
|
-
event
|
|
35
|
+
event,
|
|
30
36
|
properties: {
|
|
31
|
-
version,
|
|
32
37
|
nodeVersion: process.version,
|
|
33
38
|
platform: process.platform,
|
|
34
39
|
arch: process.arch,
|
|
35
40
|
source: "counterfact-cli",
|
|
41
|
+
...properties,
|
|
36
42
|
},
|
|
37
43
|
});
|
|
38
44
|
posthog.flush().catch(() => {
|
package/dist/msw.js
CHANGED
package/dist/repl/repl.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import repl from "node:repl";
|
|
2
|
+
import { sendTelemetry } from "../cli/telemetry.js";
|
|
2
3
|
import { RawHttpClient } from "./raw-http-client.js";
|
|
3
4
|
import { createRouteFunction } from "./route-builder.js";
|
|
4
5
|
function printToStdout(line) {
|
|
@@ -258,6 +259,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
258
259
|
: undefined);
|
|
259
260
|
replServer.defineCommand("counterfact", {
|
|
260
261
|
action() {
|
|
262
|
+
sendTelemetry("repl_command_used", { command: "counterfact" });
|
|
261
263
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
262
264
|
print("Except that it's connected to the running server, which you can access with the following globals:");
|
|
263
265
|
print("");
|
|
@@ -274,6 +276,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
274
276
|
});
|
|
275
277
|
replServer.defineCommand("proxy", {
|
|
276
278
|
action(text) {
|
|
279
|
+
sendTelemetry("repl_command_used", { command: "proxy" });
|
|
277
280
|
if (text === "help" || text === "") {
|
|
278
281
|
print(".proxy [on|off] - turn the proxy on/off at the root level");
|
|
279
282
|
print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
|
|
@@ -313,6 +316,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
313
316
|
: {};
|
|
314
317
|
replServer.defineCommand("scenario", {
|
|
315
318
|
async action(text) {
|
|
319
|
+
sendTelemetry("repl_command_used", { command: "scenario" });
|
|
316
320
|
const trimmedText = text.trim();
|
|
317
321
|
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
|
|
318
322
|
const usage = isMultiApi
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Represents a named example defined in an OpenAPI document.
|
|
3
3
|
* Examples can be referenced by route handlers via the `.example(name)` method
|
|
4
4
|
* on the response builder.
|
|
5
|
+
*
|
|
6
|
+
* OpenAPI 3.2 adds `dataValue` as a structured alternative to `value`.
|
|
7
|
+
* When present, `dataValue` is preferred over `value`.
|
|
5
8
|
*/
|
|
6
9
|
export interface Example {
|
|
10
|
+
dataValue?: unknown;
|
|
7
11
|
description: string;
|
|
8
12
|
summary: string;
|
|
9
|
-
value
|
|
13
|
+
value?: unknown;
|
|
10
14
|
}
|
|
@@ -130,6 +130,10 @@ export type GenericResponseBuilderInner<
|
|
|
130
130
|
: (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
|
|
131
131
|
text: MaybeShortcut<["text/plain"], Response>;
|
|
132
132
|
xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
|
|
133
|
+
stream: MaybeShortcut<
|
|
134
|
+
["text/event-stream", "application/jsonl", "application/json-seq"],
|
|
135
|
+
Response
|
|
136
|
+
>;
|
|
133
137
|
}>;
|
|
134
138
|
|
|
135
139
|
/**
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export interface OpenApiParameters {
|
|
8
8
|
explode?: boolean;
|
|
9
|
-
in:
|
|
9
|
+
in:
|
|
10
|
+
| "body"
|
|
11
|
+
| "cookie"
|
|
12
|
+
| "formData"
|
|
13
|
+
| "header"
|
|
14
|
+
| "path"
|
|
15
|
+
| "query"
|
|
16
|
+
| "querystring";
|
|
10
17
|
name: string;
|
|
11
18
|
required?: boolean;
|
|
12
19
|
schema?: {
|
|
@@ -26,6 +26,11 @@ export interface ResponseBuilder {
|
|
|
26
26
|
random: () => MaybePromise<ResponseBuilder>;
|
|
27
27
|
randomLegacy: () => MaybePromise<ResponseBuilder>;
|
|
28
28
|
status?: number;
|
|
29
|
+
stream: (iterable: AsyncIterable<unknown>) => {
|
|
30
|
+
body: AsyncIterable<unknown>;
|
|
31
|
+
contentType: string;
|
|
32
|
+
status?: number;
|
|
33
|
+
};
|
|
29
34
|
text: (body: unknown) => ResponseBuilder;
|
|
30
35
|
xml: (body: unknown) => ResponseBuilder;
|
|
31
36
|
}
|
|
@@ -142,6 +142,11 @@ export class Dispatcher {
|
|
|
142
142
|
return types;
|
|
143
143
|
}
|
|
144
144
|
for (const parameter of parameters) {
|
|
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
|
+
}
|
|
145
150
|
const type = parameter?.type ?? parameter?.schema?.type;
|
|
146
151
|
if (type !== undefined) {
|
|
147
152
|
types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
|
|
@@ -178,7 +183,11 @@ export class Dispatcher {
|
|
|
178
183
|
if (pathItem === undefined) {
|
|
179
184
|
return undefined;
|
|
180
185
|
}
|
|
181
|
-
const
|
|
186
|
+
const normalizedMethod = method.toLowerCase();
|
|
187
|
+
const operation = pathItem[normalizedMethod] ??
|
|
188
|
+
pathItem.additionalOperations?.[method] ??
|
|
189
|
+
pathItem.additionalOperations?.[method.toUpperCase()] ??
|
|
190
|
+
pathItem.additionalOperations?.[normalizedMethod];
|
|
182
191
|
if (operation === undefined) {
|
|
183
192
|
return undefined;
|
|
184
193
|
}
|
|
@@ -335,6 +344,8 @@ export class Dispatcher {
|
|
|
335
344
|
},
|
|
336
345
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
|
|
337
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,
|
|
338
349
|
// @ts-expect-error - Might be pushing the limits of what TypeScript can do here
|
|
339
350
|
response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
|
|
340
351
|
tools: new Tools({ headers }),
|
|
@@ -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);
|
|
@@ -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;
|
|
@@ -109,13 +109,22 @@ export class CodeGenerator extends EventTarget {
|
|
|
109
109
|
"patch",
|
|
110
110
|
"trace",
|
|
111
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
|
+
});
|
|
112
124
|
paths.forEach((pathDefinition, key) => {
|
|
113
125
|
debug("processing path %s", key);
|
|
114
126
|
const path = key === "/" ? "/index" : key;
|
|
115
|
-
pathDefinition.forEach((operation, requestMethod) => {
|
|
116
|
-
if (!HTTP_VERBS.has(requestMethod)) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
127
|
+
operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
|
|
119
128
|
repository
|
|
120
129
|
.get(`routes${path}.ts`)
|
|
121
130
|
.export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
|
|
@@ -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") ??
|
|
@@ -224,8 +237,15 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
224
237
|
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
|
|
225
238
|
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
|
|
226
239
|
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
|
|
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);
|
|
227
247
|
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} }>`;
|
|
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} }>`;
|
|
229
249
|
}
|
|
230
250
|
writeCode(script) {
|
|
231
251
|
script.comments = READ_ONLY_COMMENTS;
|
|
@@ -26,7 +26,19 @@ export class Requirement {
|
|
|
26
26
|
}
|
|
27
27
|
/** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
|
|
28
28
|
get isReference() {
|
|
29
|
-
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"];
|
|
30
42
|
}
|
|
31
43
|
/**
|
|
32
44
|
* Resolves the `$ref` and returns the target {@link Requirement}.
|
|
@@ -34,7 +46,7 @@ export class Requirement {
|
|
|
34
46
|
* @throws When `isReference` is `false` or the specification is not set.
|
|
35
47
|
*/
|
|
36
48
|
reference() {
|
|
37
|
-
return this.specification.getRequirement(this.
|
|
49
|
+
return this.specification.getRequirement(this.refUrl);
|
|
38
50
|
}
|
|
39
51
|
/**
|
|
40
52
|
* Returns `true` when this node has a child property named `item`.
|
|
@@ -47,6 +59,9 @@ export class Requirement {
|
|
|
47
59
|
if (this.isReference) {
|
|
48
60
|
return this.reference().has(item);
|
|
49
61
|
}
|
|
62
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
50
65
|
return item in this.data;
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
@@ -62,7 +77,11 @@ export class Requirement {
|
|
|
62
77
|
if (!this.has(key)) {
|
|
63
78
|
return undefined;
|
|
64
79
|
}
|
|
65
|
-
|
|
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);
|
|
66
85
|
child.parent = this;
|
|
67
86
|
return child;
|
|
68
87
|
}
|
|
@@ -108,6 +127,9 @@ export class Requirement {
|
|
|
108
127
|
* @param callback - Called for each child with `(child, key)`.
|
|
109
128
|
*/
|
|
110
129
|
forEach(callback) {
|
|
130
|
+
if (typeof this.data !== "object" || this.data === null) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
111
133
|
Object.keys(this.data).forEach((key) => {
|
|
112
134
|
callback(this.select(this.escapeJsonPointer(key)), key);
|
|
113
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 `{
|
|
@@ -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
|
}
|
|
@@ -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
|
+
]);
|
|
@@ -30,6 +30,7 @@ export async function generateVersionsTsContent(versions) {
|
|
|
30
30
|
const source = [
|
|
31
31
|
"// This file is auto-generated by Counterfact. Do not edit.",
|
|
32
32
|
"",
|
|
33
|
+
'/** Union of all version strings declared for this API group (e.g. `"v1" | "v2" | "v3"`). */',
|
|
33
34
|
`export type Versions = ${versionsUnion};`,
|
|
34
35
|
"",
|
|
35
36
|
"/**",
|
|
@@ -42,6 +43,30 @@ export async function generateVersionsTsContent(versions) {
|
|
|
42
43
|
"",
|
|
43
44
|
"type VersionMap = Partial<Record<Versions, object>>;",
|
|
44
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
|
+
" */",
|
|
45
70
|
"export type Versioned<",
|
|
46
71
|
" T extends VersionMap,",
|
|
47
72
|
" V extends keyof T & Versions = keyof T & Versions,",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/app.js",
|
|
@@ -78,17 +78,16 @@
|
|
|
78
78
|
"go:petstore2": "yarn build && yarn counterfact https://petstore.swagger.io/v2/swagger.json out",
|
|
79
79
|
"go:example": "yarn build && node ./bin/counterfact.js ./test/fixtures/openapi/example.yaml out",
|
|
80
80
|
"go:multiple-apis": "yarn build && node ./bin/counterfact.js --config ./test/fixtures/config/multiple-apis.yaml",
|
|
81
|
-
"counterfact": "./bin/counterfact.js"
|
|
82
|
-
"postinstall": "patch-package"
|
|
81
|
+
"counterfact": "./bin/counterfact.js"
|
|
83
82
|
},
|
|
84
83
|
"devDependencies": {
|
|
85
|
-
"@changesets/cli": "2.
|
|
84
|
+
"@changesets/cli": "2.31.0",
|
|
86
85
|
"@eslint/js": "10.0.1",
|
|
87
|
-
"@jest/globals": "
|
|
88
|
-
"@swc/core": "1.15.
|
|
86
|
+
"@jest/globals": "30.4.1",
|
|
87
|
+
"@swc/core": "1.15.33",
|
|
89
88
|
"@swc/jest": "0.2.39",
|
|
90
89
|
"@testing-library/dom": "10.4.1",
|
|
91
|
-
"@types/debug": "
|
|
90
|
+
"@types/debug": "4.1.13",
|
|
92
91
|
"@types/jest": "30.0.0",
|
|
93
92
|
"@types/js-yaml": "4.0.9",
|
|
94
93
|
"@types/koa": "3.0.2",
|
|
@@ -96,26 +95,26 @@
|
|
|
96
95
|
"@types/koa-proxy": "1.0.8",
|
|
97
96
|
"@types/koa-static": "4.0.4",
|
|
98
97
|
"@types/node": "22",
|
|
99
|
-
"@typescript-eslint/eslint-plugin": "
|
|
100
|
-
"@typescript-eslint/parser": "
|
|
98
|
+
"@typescript-eslint/eslint-plugin": "8.59.3",
|
|
99
|
+
"@typescript-eslint/parser": "8.59.3",
|
|
101
100
|
"copyfiles": "2.4.1",
|
|
102
|
-
"eslint": "10.
|
|
101
|
+
"eslint": "10.3.0",
|
|
103
102
|
"eslint-formatter-github-annotations": "0.1.0",
|
|
104
103
|
"eslint-import-resolver-typescript": "4.4.4",
|
|
105
104
|
"eslint-plugin-etc": "2.0.3",
|
|
106
105
|
"eslint-plugin-file-progress": "4.0.0",
|
|
107
106
|
"eslint-plugin-import": "2.32.0",
|
|
108
|
-
"eslint-plugin-jest": "29.15.
|
|
107
|
+
"eslint-plugin-jest": "29.15.2",
|
|
109
108
|
"eslint-plugin-jest-dom": "5.5.0",
|
|
110
|
-
"eslint-plugin-n": "
|
|
109
|
+
"eslint-plugin-n": "18.0.1",
|
|
111
110
|
"eslint-plugin-no-explicit-type-exports": "0.12.1",
|
|
112
111
|
"eslint-plugin-prettier": "5.5.5",
|
|
113
|
-
"eslint-plugin-promise": "
|
|
114
|
-
"eslint-plugin-regexp": "
|
|
115
|
-
"eslint-plugin-security": "
|
|
112
|
+
"eslint-plugin-promise": "7.3.0",
|
|
113
|
+
"eslint-plugin-regexp": "3.1.0",
|
|
114
|
+
"eslint-plugin-security": "4.0.0",
|
|
116
115
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
117
116
|
"husky": "9.1.7",
|
|
118
|
-
"jest": "30.
|
|
117
|
+
"jest": "30.4.2",
|
|
119
118
|
"jest-retries": "1.0.1",
|
|
120
119
|
"node-mocks-http": "1.17.2",
|
|
121
120
|
"rimraf": "6.1.3",
|
|
@@ -124,17 +123,17 @@
|
|
|
124
123
|
"using-temporary-files": "2.2.1"
|
|
125
124
|
},
|
|
126
125
|
"dependencies": {
|
|
127
|
-
"@apidevtools/json-schema-ref-parser": "
|
|
126
|
+
"@apidevtools/json-schema-ref-parser": "15.3.5",
|
|
128
127
|
"@hapi/accept": "6.0.3",
|
|
129
128
|
"@types/json-schema": "7.0.15",
|
|
130
|
-
"ajv": "8.
|
|
129
|
+
"ajv": "8.20.0",
|
|
131
130
|
"chokidar": "5.0.0",
|
|
132
131
|
"commander": "14.0.3",
|
|
133
132
|
"debug": "4.4.3",
|
|
134
|
-
"fs-extra": "11.3.
|
|
133
|
+
"fs-extra": "11.3.5",
|
|
135
134
|
"http-terminator": "3.2.0",
|
|
136
135
|
"js-yaml": "4.1.1",
|
|
137
|
-
"json-schema-faker": "0.6.
|
|
136
|
+
"json-schema-faker": "0.6.1",
|
|
138
137
|
"jsonwebtoken": "9.0.3",
|
|
139
138
|
"koa": "3.2.0",
|
|
140
139
|
"koa-bodyparser": "4.4.1",
|
|
@@ -142,13 +141,12 @@
|
|
|
142
141
|
"koa2-swagger-ui": "5.12.0",
|
|
143
142
|
"node-fetch": "3.3.2",
|
|
144
143
|
"open": "11.0.0",
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"prettier": "3.8.1",
|
|
144
|
+
"posthog-node": "5.34.1",
|
|
145
|
+
"precinct": "12.3.2",
|
|
146
|
+
"prettier": "3.8.3",
|
|
149
147
|
"recast": "0.23.11",
|
|
150
|
-
"tsx": "
|
|
151
|
-
"typescript": "6.0.
|
|
148
|
+
"tsx": "4.21.0",
|
|
149
|
+
"typescript": "6.0.3"
|
|
152
150
|
},
|
|
153
151
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
|
154
152
|
"resolutions": {
|