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/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/api-runner.js
CHANGED
|
@@ -59,6 +59,11 @@ export class ApiRunner {
|
|
|
59
59
|
openApiPath;
|
|
60
60
|
/** URL prefix that this runner intercepts (default `""`). */
|
|
61
61
|
prefix;
|
|
62
|
+
/**
|
|
63
|
+
* Ordered list of overlay file paths/URLs applied to the OpenAPI document
|
|
64
|
+
* after loading. Empty when no overlays are configured.
|
|
65
|
+
*/
|
|
66
|
+
overlays;
|
|
62
67
|
/**
|
|
63
68
|
* Optional group name that places generated code in a subdirectory.
|
|
64
69
|
* Defaults to `""` (no subdirectory).
|
|
@@ -89,11 +94,12 @@ export class ApiRunner {
|
|
|
89
94
|
this.openApiDocument = openApiDocument;
|
|
90
95
|
this.openApiPath = config.openApiPath;
|
|
91
96
|
this.prefix = config.prefix;
|
|
97
|
+
this.overlays = config.overlays ?? [];
|
|
92
98
|
this.registry = new Registry();
|
|
93
99
|
this.contextRegistry = new ContextRegistry();
|
|
94
100
|
this.scenarioRegistry = new ScenarioRegistry();
|
|
95
101
|
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
96
|
-
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version);
|
|
102
|
+
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version, config.overlays ?? []);
|
|
97
103
|
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
|
|
98
104
|
this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
|
|
99
105
|
this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
|
|
@@ -121,7 +127,7 @@ export class ApiRunner {
|
|
|
121
127
|
}
|
|
122
128
|
const openApiDocument = config.openApiPath === "_"
|
|
123
129
|
? undefined
|
|
124
|
-
: await loadOpenApiDocument(config.openApiPath);
|
|
130
|
+
: await loadOpenApiDocument(config.openApiPath, config.overlays ?? []);
|
|
125
131
|
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
|
|
126
132
|
}
|
|
127
133
|
/**
|
package/dist/app.js
CHANGED
|
@@ -131,7 +131,14 @@ export async function counterfact(config, specs) {
|
|
|
131
131
|
versionsByGroup.set(spec.group, [...existing, version]);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
|
|
134
|
+
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
|
|
135
|
+
...config,
|
|
136
|
+
openApiPath: spec.source,
|
|
137
|
+
// Per-spec overlays take precedence; fall back to config-level overlays
|
|
138
|
+
// so that the --overlay CLI flag works in single-spec mode.
|
|
139
|
+
overlays: spec.overlays ?? config.overlays ?? [],
|
|
140
|
+
prefix: spec.prefix,
|
|
141
|
+
}, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
|
|
135
142
|
const koaApp = createKoaApp({
|
|
136
143
|
runners,
|
|
137
144
|
config,
|
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;
|
|
@@ -22,7 +22,7 @@ const DEFAULT_PORT = 3100;
|
|
|
22
22
|
* CLI flag) into an array of {@link SpecConfig} objects, or `undefined` when
|
|
23
23
|
* the option is a plain string (single OpenAPI document path).
|
|
24
24
|
*
|
|
25
|
-
* - **Array**: each entry is mapped to `{source, prefix, group, version}` with defaults.
|
|
25
|
+
* - **Array**: each entry is mapped to `{source, prefix, group, version, overlays}` with defaults.
|
|
26
26
|
* - **Object**: wrapped in a single-element array.
|
|
27
27
|
* - **String / undefined**: returns `undefined` — caller handles the string
|
|
28
28
|
* case (it shifts the positional argument) and the `undefined` case
|
|
@@ -39,6 +39,7 @@ export function normalizeSpecOption(specOption) {
|
|
|
39
39
|
prefix: entry.prefix,
|
|
40
40
|
group: entry.group ?? "",
|
|
41
41
|
version: entry.version,
|
|
42
|
+
overlays: entry.overlays,
|
|
42
43
|
}));
|
|
43
44
|
}
|
|
44
45
|
if (typeof specOption === "object" &&
|
|
@@ -50,11 +51,41 @@ export function normalizeSpecOption(specOption) {
|
|
|
50
51
|
prefix: specOption.prefix,
|
|
51
52
|
group: specOption.group ?? "",
|
|
52
53
|
version: specOption.version,
|
|
54
|
+
overlays: specOption.overlays,
|
|
53
55
|
},
|
|
54
56
|
];
|
|
55
57
|
}
|
|
56
58
|
return undefined;
|
|
57
59
|
}
|
|
60
|
+
export function buildStartupTelemetryProperties(options, source, version, specs) {
|
|
61
|
+
const apiSources = specs?.map((spec) => spec.source) ?? [source];
|
|
62
|
+
const apiFileLocationHashes = apiSources
|
|
63
|
+
.filter((apiSource) => apiSource !== "_")
|
|
64
|
+
.map((apiSource) => hashTelemetryLocation(apiSource));
|
|
65
|
+
return {
|
|
66
|
+
alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
|
|
67
|
+
apiFileLocationHashes,
|
|
68
|
+
buildCache: Boolean(options.buildCache),
|
|
69
|
+
generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
|
|
70
|
+
generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
|
|
71
|
+
mode: specs !== undefined
|
|
72
|
+
? "multi-spec"
|
|
73
|
+
: source === "_"
|
|
74
|
+
? "without-openapi"
|
|
75
|
+
: "single-spec",
|
|
76
|
+
openBrowser: Boolean(options.open),
|
|
77
|
+
port: options.port,
|
|
78
|
+
prune: Boolean(options.prune),
|
|
79
|
+
repl: Boolean(options.repl),
|
|
80
|
+
serve: Boolean(options.serve),
|
|
81
|
+
updateCheck: Boolean(options.updateCheck),
|
|
82
|
+
validateRequest: Boolean(options.validateRequest),
|
|
83
|
+
validateResponse: Boolean(options.validateResponse),
|
|
84
|
+
version,
|
|
85
|
+
watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
|
|
86
|
+
watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
58
89
|
/**
|
|
59
90
|
* Builds the Commander program with all CLI options and the action handler.
|
|
60
91
|
* Factored out of `runCli` so it is easy to test or extend.
|
|
@@ -75,11 +106,17 @@ function buildProgram(version, taglines) {
|
|
|
75
106
|
const configFilePath = resolve(options.config ?? "counterfact.yaml");
|
|
76
107
|
const fileConfig = await loadConfigFile(configFilePath, options.config !== undefined);
|
|
77
108
|
debug("fileConfig: %o", fileConfig);
|
|
109
|
+
const knownOptionKeys = new Set(program.options.map((option) => option.attributeName()));
|
|
78
110
|
// Apply config file values for any option that was not explicitly set on
|
|
79
111
|
// the command line (i.e. its source is "default" or it was never defined).
|
|
80
112
|
for (const [key, value] of Object.entries(fileConfig)) {
|
|
113
|
+
if (!knownOptionKeys.has(key)) {
|
|
114
|
+
debug("ignoring unknown config key %s", key);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
81
117
|
const optionSource = program.getOptionValueSource(key);
|
|
82
118
|
if (optionSource !== "cli") {
|
|
119
|
+
// eslint-disable-next-line security/detect-object-injection -- key is validated against known Commander option names above.
|
|
83
120
|
options[key] = value;
|
|
84
121
|
}
|
|
85
122
|
}
|
|
@@ -110,12 +147,14 @@ function buildProgram(version, taglines) {
|
|
|
110
147
|
const actions = ["repl", "serve", "watch", "generate", "buildCache"];
|
|
111
148
|
if (!Object.keys(options).some((argument) => actions.some((action) => argument.startsWith(action)))) {
|
|
112
149
|
for (const action of actions) {
|
|
150
|
+
// eslint-disable-next-line security/detect-object-injection -- action names come from the local allowlist above.
|
|
113
151
|
options[action] = true;
|
|
114
152
|
}
|
|
115
153
|
}
|
|
116
154
|
debug("options: %o", options);
|
|
117
155
|
debug("source: %s", source);
|
|
118
156
|
debug("destination: %s", destination);
|
|
157
|
+
const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
|
|
119
158
|
const openBrowser = options.open;
|
|
120
159
|
const url = `http://localhost:${options.port}${options.prefix}`;
|
|
121
160
|
const guiUrl = `${url}/counterfact/`;
|
|
@@ -140,6 +179,7 @@ function buildProgram(version, taglines) {
|
|
|
140
179
|
prune: Boolean(options.prune),
|
|
141
180
|
},
|
|
142
181
|
openApiPath: source,
|
|
182
|
+
overlays: options.overlay ?? [],
|
|
143
183
|
port: options.port,
|
|
144
184
|
proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
|
|
145
185
|
proxyUrl: options.proxyUrl ?? "",
|
|
@@ -213,6 +253,7 @@ function buildProgram(version, taglines) {
|
|
|
213
253
|
process.exit(1);
|
|
214
254
|
}
|
|
215
255
|
debug("started server");
|
|
256
|
+
sendTelemetry("counterfact_started", startupTelemetryProperties);
|
|
216
257
|
await updateCheckPromise;
|
|
217
258
|
if (config.startRepl) {
|
|
218
259
|
startRepl();
|
|
@@ -267,6 +308,7 @@ function buildProgram(version, taglines) {
|
|
|
267
308
|
.option("--always-fake-optionals", "random responses will include optional fields")
|
|
268
309
|
.option("--prune", "remove route files that no longer exist in the OpenAPI spec")
|
|
269
310
|
.option("--spec <string>", "path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)")
|
|
311
|
+
.option("--overlay <path>", "path or URL to an OpenAPI overlay file to apply (repeatable)", (value, previous) => [...previous, value], [])
|
|
270
312
|
.option("--no-update-check", "disable the npm update check on startup")
|
|
271
313
|
.option("--no-validate-request", "disable request validation against the OpenAPI spec")
|
|
272
314
|
.option("--no-validate-response", "disable response validation against the OpenAPI spec")
|
|
@@ -300,10 +342,6 @@ export async function runCli(argv) {
|
|
|
300
342
|
catch {
|
|
301
343
|
taglines = ["counterfact — mock API server"];
|
|
302
344
|
}
|
|
303
|
-
// Fire telemetry once on startup — fire-and-forget, never blocks.
|
|
304
|
-
if (isTelemetryEnabled()) {
|
|
305
|
-
sendTelemetry(version);
|
|
306
|
-
}
|
|
307
345
|
debug("running counterfact CLI v%s", version);
|
|
308
346
|
const program = buildProgram(version, taglines);
|
|
309
347
|
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);
|
|
@@ -160,6 +165,36 @@ export class Dispatcher {
|
|
|
160
165
|
}
|
|
161
166
|
return undefined;
|
|
162
167
|
}
|
|
168
|
+
apiKeySecurityParameters() {
|
|
169
|
+
const schemes = this.openApiDocument?.components?.securitySchemes;
|
|
170
|
+
return Object.values(schemes ?? {})
|
|
171
|
+
.filter(({ in: location, name, type }) => type === "apiKey" &&
|
|
172
|
+
typeof name === "string" &&
|
|
173
|
+
(location === "header" ||
|
|
174
|
+
location === "query" ||
|
|
175
|
+
location === "cookie"))
|
|
176
|
+
.map(({ in: location, name }) => ({
|
|
177
|
+
in: location,
|
|
178
|
+
name: name,
|
|
179
|
+
required: false,
|
|
180
|
+
schema: { type: "string" },
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
authWithApiKey(auth, cookie, headers, query) {
|
|
184
|
+
const apiKeyScheme = this.apiKeySecurityParameters().find((parameter) => ["cookie", "header", "query"].includes(parameter.in));
|
|
185
|
+
if (!apiKeyScheme) {
|
|
186
|
+
return auth;
|
|
187
|
+
}
|
|
188
|
+
const apiKey = apiKeyScheme.in === "query"
|
|
189
|
+
? query[apiKeyScheme.name]
|
|
190
|
+
: apiKeyScheme.in === "cookie"
|
|
191
|
+
? cookie[apiKeyScheme.name]
|
|
192
|
+
: Object.entries(headers).find(([key]) => key.toLowerCase() === apiKeyScheme.name.toLowerCase())?.[1];
|
|
193
|
+
const normalizedApiKey = Array.isArray(apiKey)
|
|
194
|
+
? (apiKey[0] ?? "")
|
|
195
|
+
: (apiKey ?? "");
|
|
196
|
+
return { ...auth, apiKey: normalizedApiKey };
|
|
197
|
+
}
|
|
163
198
|
/**
|
|
164
199
|
* Resolves the OpenAPI operation for `path` and `method`, merging any
|
|
165
200
|
* top-level `produces` array from the document root and any path-item-level
|
|
@@ -178,7 +213,11 @@ export class Dispatcher {
|
|
|
178
213
|
if (pathItem === undefined) {
|
|
179
214
|
return undefined;
|
|
180
215
|
}
|
|
181
|
-
const
|
|
216
|
+
const normalizedMethod = method.toLowerCase();
|
|
217
|
+
const operation = pathItem[normalizedMethod] ??
|
|
218
|
+
pathItem.additionalOperations?.[method] ??
|
|
219
|
+
pathItem.additionalOperations?.[method.toUpperCase()] ??
|
|
220
|
+
pathItem.additionalOperations?.[normalizedMethod];
|
|
182
221
|
if (operation === undefined) {
|
|
183
222
|
return undefined;
|
|
184
223
|
}
|
|
@@ -194,13 +233,20 @@ export class Dispatcher {
|
|
|
194
233
|
const mergedOperation = mergedParameters !== undefined
|
|
195
234
|
? { ...operation, parameters: mergedParameters }
|
|
196
235
|
: operation;
|
|
236
|
+
const apiKeyParameters = this.apiKeySecurityParameters();
|
|
237
|
+
const operationWithSecurity = apiKeyParameters.length > 0
|
|
238
|
+
? {
|
|
239
|
+
...mergedOperation,
|
|
240
|
+
parameters: mergeParameters(mergedOperation.parameters ?? [], apiKeyParameters),
|
|
241
|
+
}
|
|
242
|
+
: mergedOperation;
|
|
197
243
|
if (this.openApiDocument?.produces) {
|
|
198
244
|
return {
|
|
199
245
|
produces: this.openApiDocument.produces,
|
|
200
|
-
...
|
|
246
|
+
...operationWithSecurity,
|
|
201
247
|
};
|
|
202
248
|
}
|
|
203
|
-
return
|
|
249
|
+
return operationWithSecurity;
|
|
204
250
|
}
|
|
205
251
|
normalizeResponse(response, acceptHeader) {
|
|
206
252
|
if (response.content !== undefined) {
|
|
@@ -290,8 +336,14 @@ export class Dispatcher {
|
|
|
290
336
|
};
|
|
291
337
|
}
|
|
292
338
|
const operation = this.operationForPathAndMethod(matchedPath, method);
|
|
339
|
+
const requestCookie = parseCookies(headers.cookie ?? headers.Cookie ?? "");
|
|
293
340
|
if (this.config?.validateRequests !== false) {
|
|
294
|
-
const validation = validateRequest(operation, {
|
|
341
|
+
const validation = validateRequest(operation, {
|
|
342
|
+
body,
|
|
343
|
+
cookie: requestCookie,
|
|
344
|
+
headers,
|
|
345
|
+
query,
|
|
346
|
+
});
|
|
295
347
|
if (!validation.valid) {
|
|
296
348
|
return {
|
|
297
349
|
body: `Request validation failed:\n${validation.errors.join("\n")}`,
|
|
@@ -307,7 +359,7 @@ export class Dispatcher {
|
|
|
307
359
|
return min + Math.random() * (max - min);
|
|
308
360
|
};
|
|
309
361
|
const response = await this.registry.endpoint(method, path, this.parameterTypes(operation?.parameters))({
|
|
310
|
-
auth,
|
|
362
|
+
auth: this.authWithApiKey(auth, requestCookie, headers, query),
|
|
311
363
|
body,
|
|
312
364
|
context: this.contextRegistry.find(matchedPath),
|
|
313
365
|
async delay(milliseconds = 0, maxMilliseconds = 0) {
|
|
@@ -316,7 +368,7 @@ export class Dispatcher {
|
|
|
316
368
|
: continuousDistribution(milliseconds, maxMilliseconds);
|
|
317
369
|
return new Promise((resolve) => setTimeout(resolve, delayInMs));
|
|
318
370
|
},
|
|
319
|
-
cookie:
|
|
371
|
+
cookie: requestCookie,
|
|
320
372
|
headers,
|
|
321
373
|
proxy: async (url) => {
|
|
322
374
|
delete headers.host;
|
|
@@ -335,6 +387,8 @@ export class Dispatcher {
|
|
|
335
387
|
},
|
|
336
388
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
|
|
337
389
|
query: processedQuery,
|
|
390
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- typed by generated route types; entire query string as a single object
|
|
391
|
+
querystring: processedQuery,
|
|
338
392
|
// @ts-expect-error - Might be pushing the limits of what TypeScript can do here
|
|
339
393
|
response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
|
|
340
394
|
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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OpenApiDocument } from "./openapi-document.js";
|
|
2
|
-
export async function loadOpenApiDocument(source) {
|
|
3
|
-
const document = new OpenApiDocument(source);
|
|
2
|
+
export async function loadOpenApiDocument(source, overlays = []) {
|
|
3
|
+
const document = new OpenApiDocument(source, overlays);
|
|
4
4
|
await document.load();
|
|
5
5
|
return document;
|
|
6
6
|
}
|
|
@@ -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);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { watch } from "chokidar";
|
|
2
2
|
import createDebug from "debug";
|
|
3
3
|
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
4
|
+
import { applyOverlays } from "../util/apply-overlay.js";
|
|
4
5
|
import { waitForEvent } from "../util/wait-for-event.js";
|
|
6
|
+
import { sendTelemetry } from "../cli/telemetry.js";
|
|
5
7
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
6
8
|
const debug = createDebug("counterfact:server:openapi-document");
|
|
7
9
|
/**
|
|
@@ -13,13 +15,20 @@ const debug = createDebug("counterfact:server:openapi-document");
|
|
|
13
15
|
export class OpenApiDocument extends EventTarget {
|
|
14
16
|
/** The path or URL of the OpenAPI source file. */
|
|
15
17
|
source;
|
|
18
|
+
/**
|
|
19
|
+
* Optional ordered list of overlay file paths/URLs to apply after each
|
|
20
|
+
* load of the document.
|
|
21
|
+
*/
|
|
22
|
+
overlays;
|
|
16
23
|
basePath;
|
|
24
|
+
components;
|
|
17
25
|
paths = {};
|
|
18
26
|
produces;
|
|
19
27
|
watcher;
|
|
20
|
-
constructor(source) {
|
|
28
|
+
constructor(source, overlays = []) {
|
|
21
29
|
super();
|
|
22
30
|
this.source = source;
|
|
31
|
+
this.overlays = overlays;
|
|
23
32
|
}
|
|
24
33
|
/**
|
|
25
34
|
* Reads the source file and populates the document's properties.
|
|
@@ -28,7 +37,11 @@ export class OpenApiDocument extends EventTarget {
|
|
|
28
37
|
async load() {
|
|
29
38
|
try {
|
|
30
39
|
const data = (await dereference(this.source));
|
|
40
|
+
if (this.overlays.length > 0) {
|
|
41
|
+
await applyOverlays(data, this.overlays);
|
|
42
|
+
}
|
|
31
43
|
this.basePath = data.basePath;
|
|
44
|
+
this.components = data.components;
|
|
32
45
|
this.paths = data.paths;
|
|
33
46
|
this.produces = data.produces;
|
|
34
47
|
}
|
|
@@ -49,6 +62,10 @@ export class OpenApiDocument extends EventTarget {
|
|
|
49
62
|
return;
|
|
50
63
|
}
|
|
51
64
|
this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
|
|
65
|
+
sendTelemetry("file_change_detected", {
|
|
66
|
+
changeType: "change",
|
|
67
|
+
fileType: "openapi",
|
|
68
|
+
});
|
|
52
69
|
void (async () => {
|
|
53
70
|
try {
|
|
54
71
|
await this.load();
|