counterfact 2.2.1 → 2.4.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 +1 -0
- package/bin/README.md +1 -0
- package/bin/counterfact.js +28 -8
- package/dist/app.js +5 -10
- package/dist/counterfact-types/index.js +0 -3
- package/dist/migrate/update-route-types.js +1 -1
- package/dist/repl/repl.js +26 -2
- package/dist/server/admin-api-middleware.js +5 -4
- package/dist/server/counterfact-types/OpenApiHeader.ts +2 -1
- package/dist/server/counterfact-types/index.ts +46 -20
- package/dist/server/determine-module-kind.js +0 -1
- package/dist/server/dispatcher.js +21 -0
- package/dist/server/koa-middleware.js +10 -1
- package/dist/server/module-loader.js +19 -2
- package/dist/server/module-tree.js +47 -6
- package/dist/server/openapi-middleware.js +2 -2
- package/dist/server/registry.js +13 -5
- package/dist/server/response-builder.js +94 -18
- package/dist/server/tools.js +2 -4
- package/dist/typescript-generator/generate.js +6 -1
- package/dist/typescript-generator/operation-type-coder.js +3 -1
- package/dist/typescript-generator/prune.js +101 -0
- package/dist/typescript-generator/repository.js +1 -1
- package/dist/typescript-generator/requirement.js +1 -1
- package/dist/typescript-generator/schema-type-coder.js +4 -1
- package/dist/typescript-generator/script.js +2 -2
- package/dist/typescript-generator/specification.js +0 -1
- package/dist/util/ensure-directory-exists.js +0 -1
- package/package.json +21 -13
package/README.md
CHANGED
|
@@ -202,6 +202,7 @@ npx counterfact@latest [openapi.yaml] [destination] [options]
|
|
|
202
202
|
| `-w, --watch` | Generate and watch for spec changes |
|
|
203
203
|
| `-s, --serve` | Start the mock server |
|
|
204
204
|
| `-r, --repl` | Start the interactive REPL |
|
|
205
|
+
| `--spec <path>` | Path or URL to the OpenAPI document |
|
|
205
206
|
| `--proxy-url <url>` | Forward all requests to this URL by default |
|
|
206
207
|
| `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
|
|
207
208
|
|
package/bin/README.md
CHANGED
|
@@ -37,6 +37,7 @@ npx counterfact openapi.yaml ./api [options]
|
|
|
37
37
|
| `-w, --watch` | Re-generate whenever the spec changes |
|
|
38
38
|
| `-s, --serve` | Start the HTTP server |
|
|
39
39
|
| `-r, --repl` | Start the interactive REPL |
|
|
40
|
+
| `--spec <path>` | Path or URL to the OpenAPI document (alternative to positional argument) |
|
|
40
41
|
| `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
|
|
41
42
|
| `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
|
|
42
43
|
|
package/bin/counterfact.js
CHANGED
|
@@ -47,7 +47,7 @@ function padTagLine(tagLine) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function createWatchMessage(config) {
|
|
50
|
-
let watchMessage
|
|
50
|
+
let watchMessage;
|
|
51
51
|
|
|
52
52
|
switch (true) {
|
|
53
53
|
case config.watch.routes && config.watch.types: {
|
|
@@ -102,11 +102,18 @@ async function main(source, destination) {
|
|
|
102
102
|
|
|
103
103
|
const options = program.opts();
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// --spec takes precedence over the positional [openapi.yaml] argument.
|
|
106
|
+
// When --spec is provided, the [openapi.yaml] positional slot shifts to
|
|
107
|
+
// become the [destination] argument (so `counterfact --spec api.yaml ./api`
|
|
108
|
+
// works the same as `counterfact api.yaml ./api`).
|
|
109
|
+
if (options.spec) {
|
|
110
|
+
if (source !== "_") {
|
|
111
|
+
destination = source;
|
|
112
|
+
}
|
|
113
|
+
source = options.spec;
|
|
114
|
+
}
|
|
106
115
|
|
|
107
|
-
const destinationPath = nodePath
|
|
108
|
-
.join(process.cwd(), destination)
|
|
109
|
-
.replaceAll("\\", "/");
|
|
116
|
+
const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
|
|
110
117
|
|
|
111
118
|
const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
|
|
112
119
|
|
|
@@ -155,6 +162,8 @@ async function main(source, destination) {
|
|
|
155
162
|
options.watch ||
|
|
156
163
|
options.watchTypes ||
|
|
157
164
|
options.buildCache,
|
|
165
|
+
|
|
166
|
+
prune: Boolean(options.prune),
|
|
158
167
|
},
|
|
159
168
|
|
|
160
169
|
openApiPath: source,
|
|
@@ -181,9 +190,8 @@ async function main(source, destination) {
|
|
|
181
190
|
debug("loading counterfact (%o)", configForLogging);
|
|
182
191
|
|
|
183
192
|
let didMigrate = false;
|
|
184
|
-
let didMigrateRouteTypes
|
|
193
|
+
let didMigrateRouteTypes;
|
|
185
194
|
|
|
186
|
-
// eslint-disable-next-line n/no-sync
|
|
187
195
|
if (fs.existsSync(nodePath.join(config.basePath, "paths"))) {
|
|
188
196
|
await pathsToRoutes(config.basePath);
|
|
189
197
|
await fs.promises.rmdir(nodePath.join(config.basePath, "paths"), {
|
|
@@ -199,7 +207,7 @@ async function main(source, destination) {
|
|
|
199
207
|
didMigrate = true;
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
const { start } = await counterfact(config);
|
|
210
|
+
const { start, startRepl } = await counterfact(config);
|
|
203
211
|
|
|
204
212
|
debug("loaded counterfact", configForLogging);
|
|
205
213
|
|
|
@@ -246,6 +254,10 @@ async function main(source, destination) {
|
|
|
246
254
|
await start(config);
|
|
247
255
|
debug("started server");
|
|
248
256
|
|
|
257
|
+
if (config.startRepl) {
|
|
258
|
+
startRepl();
|
|
259
|
+
}
|
|
260
|
+
|
|
249
261
|
if (openBrowser) {
|
|
250
262
|
debug("opening browser");
|
|
251
263
|
await open(guiUrl);
|
|
@@ -326,5 +338,13 @@ program
|
|
|
326
338
|
"--always-fake-optionals",
|
|
327
339
|
"random responses will include optional fields",
|
|
328
340
|
)
|
|
341
|
+
.option(
|
|
342
|
+
"--prune",
|
|
343
|
+
"remove route files that no longer exist in the OpenAPI spec",
|
|
344
|
+
)
|
|
345
|
+
.option(
|
|
346
|
+
"--spec <string>",
|
|
347
|
+
"path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)",
|
|
348
|
+
)
|
|
329
349
|
.action(main)
|
|
330
350
|
.parse(process.argv);
|
package/dist/app.js
CHANGED
|
@@ -2,8 +2,7 @@ import fs, { rm } from "node:fs/promises";
|
|
|
2
2
|
import nodePath from "node:path";
|
|
3
3
|
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
4
4
|
import { createHttpTerminator } from "http-terminator";
|
|
5
|
-
import
|
|
6
|
-
import { startRepl } from "./repl/repl.js";
|
|
5
|
+
import { startRepl as startReplServer } from "./repl/repl.js";
|
|
7
6
|
import { ContextRegistry } from "./server/context-registry.js";
|
|
8
7
|
import { createKoaApp } from "./server/create-koa-app.js";
|
|
9
8
|
import { Dispatcher, } from "./server/dispatcher.js";
|
|
@@ -12,7 +11,6 @@ import { ModuleLoader } from "./server/module-loader.js";
|
|
|
12
11
|
import { Registry } from "./server/registry.js";
|
|
13
12
|
import { Transpiler } from "./server/transpiler.js";
|
|
14
13
|
import { CodeGenerator } from "./typescript-generator/code-generator.js";
|
|
15
|
-
import { readFile } from "./util/read-file.js";
|
|
16
14
|
const allowedMethods = [
|
|
17
15
|
"all",
|
|
18
16
|
"head",
|
|
@@ -25,9 +23,7 @@ const allowedMethods = [
|
|
|
25
23
|
];
|
|
26
24
|
export async function loadOpenApiDocument(source) {
|
|
27
25
|
try {
|
|
28
|
-
|
|
29
|
-
const openApiDocument = await yaml.load(text);
|
|
30
|
-
return (await dereference(openApiDocument));
|
|
26
|
+
return (await dereference(source));
|
|
31
27
|
}
|
|
32
28
|
catch {
|
|
33
29
|
return undefined;
|
|
@@ -46,7 +42,7 @@ export async function handleMswRequest(request) {
|
|
|
46
42
|
export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
|
|
47
43
|
// TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
|
|
48
44
|
// If we "pre-read" the file here it works. This is a workaround to avoid the issue.
|
|
49
|
-
|
|
45
|
+
await fs.readFile(config.openApiPath);
|
|
50
46
|
const openApiDocument = await loadOpenApiDocument(config.openApiPath);
|
|
51
47
|
if (openApiDocument === undefined) {
|
|
52
48
|
throw new Error(`Could not load OpenAPI document from ${config.openApiPath}`);
|
|
@@ -92,7 +88,7 @@ export async function counterfact(config) {
|
|
|
92
88
|
const middleware = koaMiddleware(dispatcher, config);
|
|
93
89
|
const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
|
|
94
90
|
async function start(options) {
|
|
95
|
-
const { generate,
|
|
91
|
+
const { generate, startServer, watch, buildCache } = options;
|
|
96
92
|
if (generate.routes || generate.types) {
|
|
97
93
|
await codeGenerator.generate();
|
|
98
94
|
}
|
|
@@ -116,9 +112,7 @@ export async function counterfact(config) {
|
|
|
116
112
|
await transpiler.watch();
|
|
117
113
|
await transpiler.stopWatching();
|
|
118
114
|
}
|
|
119
|
-
const replServer = shouldStartRepl && startRepl(contextRegistry, config);
|
|
120
115
|
return {
|
|
121
|
-
replServer,
|
|
122
116
|
async stop() {
|
|
123
117
|
await codeGenerator.stopWatching();
|
|
124
118
|
await transpiler.stopWatching();
|
|
@@ -133,5 +127,6 @@ export async function counterfact(config) {
|
|
|
133
127
|
koaMiddleware: middleware,
|
|
134
128
|
registry,
|
|
135
129
|
start,
|
|
130
|
+
startRepl: () => startReplServer(contextRegistry, registry, config),
|
|
136
131
|
};
|
|
137
132
|
}
|
|
@@ -94,7 +94,7 @@ async function updateRouteFile(filePath, methodToTypeName) {
|
|
|
94
94
|
// Build a map of old type names to new type names found in this file
|
|
95
95
|
const replacements = new Map();
|
|
96
96
|
// Find all import statements with HTTP_ patterns
|
|
97
|
-
const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["']
|
|
97
|
+
const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["'][^"']+["'];?/gu;
|
|
98
98
|
let importMatch;
|
|
99
99
|
while ((importMatch = importRegex.exec(content)) !== null) {
|
|
100
100
|
const importedTypes = importMatch.groups.types
|
package/dist/repl/repl.js
CHANGED
|
@@ -3,7 +3,25 @@ import { RawHttpClient } from "./RawHttpClient.js";
|
|
|
3
3
|
function printToStdout(line) {
|
|
4
4
|
process.stdout.write(`${line}\n`);
|
|
5
5
|
}
|
|
6
|
-
export function
|
|
6
|
+
export function createCompleter(registry, fallback) {
|
|
7
|
+
return (line, callback) => {
|
|
8
|
+
const match = line.match(/client\.(?:get|post|put|patch|delete)\("(?<partial>[^"]*)$/u);
|
|
9
|
+
if (!match) {
|
|
10
|
+
if (fallback) {
|
|
11
|
+
fallback(line, callback);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
callback(null, [[], line]);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const partial = match.groups?.["partial"] ?? "";
|
|
19
|
+
const routes = registry.routes.map((route) => route.path);
|
|
20
|
+
const matches = routes.filter((route) => route.startsWith(partial));
|
|
21
|
+
callback(null, [matches, partial]);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function startRepl(contextRegistry, registry, config, print = printToStdout) {
|
|
7
25
|
function printProxyStatus() {
|
|
8
26
|
if (config.proxyUrl === "") {
|
|
9
27
|
print("The proxy URL is not set.");
|
|
@@ -41,7 +59,13 @@ export function startRepl(contextRegistry, config, print = printToStdout) {
|
|
|
41
59
|
print(`Requests to ${printEndpoint} will be handled by local code`);
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
|
-
const replServer = repl.start({
|
|
62
|
+
const replServer = repl.start({
|
|
63
|
+
prompt: "⬣> ",
|
|
64
|
+
});
|
|
65
|
+
const builtinCompleter = replServer.completer;
|
|
66
|
+
// completer is typed as readonly in @types/node but is writable at runtime
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
replServer.completer = createCompleter(registry, builtinCompleter);
|
|
45
69
|
replServer.defineCommand("counterfact", {
|
|
46
70
|
action() {
|
|
47
71
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
@@ -118,10 +118,10 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
118
118
|
// ===== Update Context =====
|
|
119
119
|
if (resource === "contexts" && rest.length > 0 && ctx.method === "POST") {
|
|
120
120
|
const path = "/" + rest.join("/");
|
|
121
|
-
const
|
|
122
|
-
if (!
|
|
123
|
-
typeof
|
|
124
|
-
Array.isArray(
|
|
121
|
+
const newContextCandidate = ctx.request.body;
|
|
122
|
+
if (!newContextCandidate ||
|
|
123
|
+
typeof newContextCandidate !== "object" ||
|
|
124
|
+
Array.isArray(newContextCandidate)) {
|
|
125
125
|
ctx.status = 400;
|
|
126
126
|
ctx.body = {
|
|
127
127
|
success: false,
|
|
@@ -130,6 +130,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
// Update the context using the registry's smart diffing
|
|
133
|
+
const newContext = newContextCandidate;
|
|
133
134
|
contextRegistry.update(path, newContext);
|
|
134
135
|
ctx.body = {
|
|
135
136
|
success: true,
|
|
@@ -10,14 +10,22 @@ interface Example {
|
|
|
10
10
|
value: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface CookieOptions {
|
|
14
|
+
domain?: string;
|
|
15
|
+
expires?: Date;
|
|
16
|
+
httpOnly?: boolean;
|
|
17
|
+
maxAge?: number;
|
|
18
|
+
path?: string;
|
|
19
|
+
sameSite?: "lax" | "none" | "strict";
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
const counterfactResponse = Symbol("Counterfact Response");
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
[counterfactResponse]: counterfactResponse
|
|
25
|
+
type COUNTERFACT_RESPONSE = {
|
|
26
|
+
[counterfactResponse]: typeof counterfactResponse;
|
|
17
27
|
};
|
|
18
28
|
|
|
19
|
-
type COUNTERFACT_RESPONSE = typeof counterfactResponseObject;
|
|
20
|
-
|
|
21
29
|
type MediaType = `${string}/${string}`;
|
|
22
30
|
|
|
23
31
|
type MaybePromise<T> = T | Promise<T>;
|
|
@@ -38,7 +46,7 @@ type OmitValueWhenNever<Base> = Pick<
|
|
|
38
46
|
interface OpenApiResponse {
|
|
39
47
|
content: { [key: MediaType]: OpenApiContent };
|
|
40
48
|
examples?: { [key: string]: unknown };
|
|
41
|
-
headers: { [key: string]:
|
|
49
|
+
headers: { [key: string]: { schema: unknown } };
|
|
42
50
|
requiredHeaders: string;
|
|
43
51
|
}
|
|
44
52
|
|
|
@@ -55,12 +63,12 @@ type IfHasKey<
|
|
|
55
63
|
infer FirstKey extends string,
|
|
56
64
|
...infer RestKeys extends string[],
|
|
57
65
|
]
|
|
58
|
-
? keyof SomeObject extends
|
|
59
|
-
? Yes
|
|
60
|
-
:
|
|
66
|
+
? Extract<keyof SomeObject, `${string}${FirstKey}${string}`> extends never
|
|
67
|
+
? IfHasKey<SomeObject, RestKeys, Yes, No>
|
|
68
|
+
: Yes
|
|
61
69
|
: No;
|
|
62
70
|
|
|
63
|
-
type SchemasOf<T extends { [key: string]: { schema:
|
|
71
|
+
type SchemasOf<T extends { [key: string]: { schema: unknown } }> = {
|
|
64
72
|
[K in keyof T]: T[K]["schema"];
|
|
65
73
|
}[keyof T];
|
|
66
74
|
|
|
@@ -78,7 +86,7 @@ type MaybeShortcut<
|
|
|
78
86
|
never
|
|
79
87
|
>;
|
|
80
88
|
|
|
81
|
-
type NeverIfEmpty<Record> =
|
|
89
|
+
type NeverIfEmpty<Record> = object extends Record ? never : Record;
|
|
82
90
|
|
|
83
91
|
type MatchFunction<Response extends OpenApiResponse> = <
|
|
84
92
|
ContentType extends MediaType & keyof Response["content"],
|
|
@@ -102,9 +110,7 @@ type HeaderFunction<Response extends OpenApiResponse> = <
|
|
|
102
110
|
requiredHeaders: Exclude<Response["requiredHeaders"], Header>;
|
|
103
111
|
}>;
|
|
104
112
|
|
|
105
|
-
type RandomFunction
|
|
106
|
-
Header extends string & keyof Response["headers"],
|
|
107
|
-
>() => COUNTERFACT_RESPONSE;
|
|
113
|
+
type RandomFunction = () => MaybePromise<COUNTERFACT_RESPONSE>;
|
|
108
114
|
|
|
109
115
|
type ExampleNames<Response extends OpenApiResponse> = Response extends {
|
|
110
116
|
examples: infer E;
|
|
@@ -114,15 +120,21 @@ type ExampleNames<Response extends OpenApiResponse> = Response extends {
|
|
|
114
120
|
|
|
115
121
|
interface ResponseBuilder {
|
|
116
122
|
[status: number | `${number} ${string}`]: ResponseBuilder;
|
|
123
|
+
binary: (body: Uint8Array | string) => ResponseBuilder;
|
|
117
124
|
content?: { body: unknown; type: string }[];
|
|
125
|
+
cookie: (
|
|
126
|
+
name: string,
|
|
127
|
+
value: string,
|
|
128
|
+
options?: CookieOptions,
|
|
129
|
+
) => ResponseBuilder;
|
|
118
130
|
example: (name: string) => ResponseBuilder;
|
|
119
131
|
header: (name: string, value: string) => ResponseBuilder;
|
|
120
|
-
headers: { [name: string]: string };
|
|
132
|
+
headers: { [name: string]: string | string[] };
|
|
121
133
|
html: (body: unknown) => ResponseBuilder;
|
|
122
134
|
json: (body: unknown) => ResponseBuilder;
|
|
123
135
|
match: (contentType: string, body: unknown) => ResponseBuilder;
|
|
124
|
-
random: () => ResponseBuilder
|
|
125
|
-
randomLegacy: () => ResponseBuilder
|
|
136
|
+
random: () => MaybePromise<ResponseBuilder>;
|
|
137
|
+
randomLegacy: () => MaybePromise<ResponseBuilder>;
|
|
126
138
|
status?: number;
|
|
127
139
|
text: (body: unknown) => ResponseBuilder;
|
|
128
140
|
xml: (body: unknown) => ResponseBuilder;
|
|
@@ -131,6 +143,12 @@ interface ResponseBuilder {
|
|
|
131
143
|
export type GenericResponseBuilderInner<
|
|
132
144
|
Response extends OpenApiResponse = OpenApiResponse,
|
|
133
145
|
> = OmitValueWhenNever<{
|
|
146
|
+
binary: MaybeShortcut<["application/octet-stream"], Response>;
|
|
147
|
+
cookie: (
|
|
148
|
+
name: string,
|
|
149
|
+
value: string,
|
|
150
|
+
options?: CookieOptions,
|
|
151
|
+
) => GenericResponseBuilder<Response>;
|
|
134
152
|
header: [keyof Response["headers"]] extends [never]
|
|
135
153
|
? never
|
|
136
154
|
: HeaderFunction<Response>;
|
|
@@ -148,9 +166,7 @@ export type GenericResponseBuilderInner<
|
|
|
148
166
|
match: [keyof Response["content"]] extends [never]
|
|
149
167
|
? never
|
|
150
168
|
: MatchFunction<Response>;
|
|
151
|
-
random: [keyof Response["content"]] extends [never]
|
|
152
|
-
? never
|
|
153
|
-
: RandomFunction<Response>;
|
|
169
|
+
random: [keyof Response["content"]] extends [never] ? never : RandomFunction;
|
|
154
170
|
example: [ExampleNames<Response>] extends [never]
|
|
155
171
|
? never
|
|
156
172
|
: (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
|
|
@@ -257,18 +273,27 @@ interface OpenApiOperation {
|
|
|
257
273
|
};
|
|
258
274
|
};
|
|
259
275
|
examples?: { [key: string]: unknown };
|
|
276
|
+
headers?: {
|
|
277
|
+
[name: string]: OpenApiHeader;
|
|
278
|
+
};
|
|
260
279
|
schema?: { [key: string]: unknown };
|
|
261
280
|
};
|
|
262
281
|
};
|
|
263
282
|
}
|
|
264
283
|
|
|
265
284
|
interface WideResponseBuilder {
|
|
285
|
+
binary: (body: Uint8Array | string) => WideResponseBuilder;
|
|
266
286
|
example: (name: string) => WideResponseBuilder;
|
|
287
|
+
cookie: (
|
|
288
|
+
name: string,
|
|
289
|
+
value: string,
|
|
290
|
+
options?: CookieOptions,
|
|
291
|
+
) => WideResponseBuilder;
|
|
267
292
|
header: (body: unknown) => WideResponseBuilder;
|
|
268
293
|
html: (body: unknown) => WideResponseBuilder;
|
|
269
294
|
json: (body: unknown) => WideResponseBuilder;
|
|
270
295
|
match: (contentType: string, body: unknown) => WideResponseBuilder;
|
|
271
|
-
random: () => WideResponseBuilder
|
|
296
|
+
random: () => MaybePromise<WideResponseBuilder>;
|
|
272
297
|
text: (body: unknown) => WideResponseBuilder;
|
|
273
298
|
xml: (body: unknown) => WideResponseBuilder;
|
|
274
299
|
}
|
|
@@ -286,6 +311,7 @@ interface WideOperationArgument {
|
|
|
286
311
|
export type { COUNTERFACT_RESPONSE };
|
|
287
312
|
|
|
288
313
|
export type {
|
|
314
|
+
CookieOptions,
|
|
289
315
|
ExampleNames,
|
|
290
316
|
HttpStatusCode,
|
|
291
317
|
MaybePromise,
|
|
@@ -4,6 +4,26 @@ import fetch, { Headers } from "node-fetch";
|
|
|
4
4
|
import { createResponseBuilder } from "./response-builder.js";
|
|
5
5
|
import { Tools } from "./tools.js";
|
|
6
6
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
7
|
+
function parseCookies(cookieHeader) {
|
|
8
|
+
const cookies = {};
|
|
9
|
+
for (const part of cookieHeader.split(";")) {
|
|
10
|
+
const eqIndex = part.indexOf("=");
|
|
11
|
+
if (eqIndex === -1) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const key = part.slice(0, eqIndex).trim();
|
|
15
|
+
const value = part.slice(eqIndex + 1).trim();
|
|
16
|
+
if (key && !(key in cookies)) {
|
|
17
|
+
try {
|
|
18
|
+
cookies[key] = decodeURIComponent(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
cookies[key] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return cookies;
|
|
26
|
+
}
|
|
7
27
|
export class Dispatcher {
|
|
8
28
|
registry;
|
|
9
29
|
contextRegistry;
|
|
@@ -143,6 +163,7 @@ export class Dispatcher {
|
|
|
143
163
|
: continuousDistribution(milliseconds, maxMilliseconds);
|
|
144
164
|
return new Promise((resolve) => setTimeout(resolve, delayInMs));
|
|
145
165
|
},
|
|
166
|
+
cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
|
|
146
167
|
headers,
|
|
147
168
|
proxy: async (url) => {
|
|
148
169
|
if (body !== undefined && headers.contentType !== "application/json") {
|
|
@@ -72,10 +72,19 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
72
72
|
req: { path: "", ...ctx.req },
|
|
73
73
|
});
|
|
74
74
|
ctx.body = response.body;
|
|
75
|
+
if (response.contentType !== undefined &&
|
|
76
|
+
response.contentType !== "unknown/unknown") {
|
|
77
|
+
ctx.type = response.contentType;
|
|
78
|
+
}
|
|
75
79
|
if (response.headers) {
|
|
76
80
|
for (const [key, value] of Object.entries(response.headers)) {
|
|
77
81
|
if (!HEADERS_TO_DROP.has(key.toLowerCase())) {
|
|
78
|
-
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
ctx.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
ctx.set(key, value.toString());
|
|
87
|
+
}
|
|
79
88
|
}
|
|
80
89
|
}
|
|
81
90
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable n/no-sync */
|
|
2
1
|
import { once } from "node:events";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
3
|
import fs from "node:fs/promises";
|
|
@@ -103,17 +102,35 @@ export class ModuleLoader extends EventTarget {
|
|
|
103
102
|
const doImport = (await determineModuleKind(pathName)) === "commonjs"
|
|
104
103
|
? uncachedRequire
|
|
105
104
|
: uncachedImport;
|
|
106
|
-
const endpoint = (await doImport(pathName).catch((
|
|
105
|
+
const endpoint = (await doImport(pathName).catch(() => {
|
|
107
106
|
console.log("ERROR");
|
|
108
107
|
}));
|
|
109
108
|
this.dispatchEvent(new Event("add"));
|
|
110
109
|
if (basename(pathName).startsWith("_.context.") &&
|
|
111
110
|
isContextModule(endpoint)) {
|
|
112
111
|
const loadContext = (path) => this.contextRegistry.find(path);
|
|
112
|
+
const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
|
|
113
|
+
const readJson = async (relativePath) => {
|
|
114
|
+
const absolutePath = nodePath.resolve(contextDir, relativePath);
|
|
115
|
+
let content;
|
|
116
|
+
try {
|
|
117
|
+
content = await fs.readFile(absolutePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new Error(`readJson: could not read file at "${absolutePath}" (resolved from "${relativePath}" relative to "${contextDir}")`);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(content);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error(`readJson: file at "${absolutePath}" does not contain valid JSON`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
113
129
|
this.contextRegistry.update(directory,
|
|
114
130
|
// @ts-expect-error TS says Context has no constructable signatures but that's not true?
|
|
115
131
|
new endpoint.Context({
|
|
116
132
|
loadContext,
|
|
133
|
+
readJson,
|
|
117
134
|
}));
|
|
118
135
|
return;
|
|
119
136
|
}
|
|
@@ -17,6 +17,7 @@ export class ModuleTree {
|
|
|
17
17
|
if (remainingSegments.length === 0) {
|
|
18
18
|
return directory;
|
|
19
19
|
}
|
|
20
|
+
const isNewDirectory = directory.directories[segment.toLowerCase()] === undefined;
|
|
20
21
|
const nextDirectory = (directory.directories[segment.toLowerCase()] ??= {
|
|
21
22
|
directories: {},
|
|
22
23
|
files: {},
|
|
@@ -24,6 +25,12 @@ export class ModuleTree {
|
|
|
24
25
|
name: segment.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
|
|
25
26
|
rawName: segment,
|
|
26
27
|
});
|
|
28
|
+
if (isNewDirectory && segment.startsWith("{")) {
|
|
29
|
+
const ambiguousWildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
|
|
30
|
+
if (ambiguousWildcardDirectories.length > 1) {
|
|
31
|
+
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard directories exist at the same level: ${ambiguousWildcardDirectories.map((d) => d.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
27
34
|
return this.putDirectory(nextDirectory, remainingSegments);
|
|
28
35
|
}
|
|
29
36
|
addModuleToDirectory(directory, segments, module) {
|
|
@@ -41,6 +48,12 @@ export class ModuleTree {
|
|
|
41
48
|
name: filename.replace(/^\{(?<name>.*)\}$/u, "$<name>"),
|
|
42
49
|
rawName: filename,
|
|
43
50
|
};
|
|
51
|
+
if (filename.startsWith("{")) {
|
|
52
|
+
const ambiguousWildcardFiles = Object.values(targetDirectory.files).filter((file) => file.isWildcard);
|
|
53
|
+
if (ambiguousWildcardFiles.length > 1) {
|
|
54
|
+
process.stderr.write(`[counterfact] ERROR: Ambiguous wildcard paths detected. Multiple wildcard files exist at the same path level: ${ambiguousWildcardFiles.map((f) => f.rawName).join(", ")}. Requests may be routed unpredictably.\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
44
57
|
}
|
|
45
58
|
add(url, module) {
|
|
46
59
|
this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
|
|
@@ -75,8 +88,31 @@ export class ModuleTree {
|
|
|
75
88
|
}
|
|
76
89
|
return "";
|
|
77
90
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
91
|
+
const exactMatchFile = directory.files[normalizedSegment(segment, directory)];
|
|
92
|
+
// If the URL segment literally matches a file key (e.g., requesting "/{x}"
|
|
93
|
+
// as a literal URL value), exactMatchFile may be a wildcard file. In that
|
|
94
|
+
// case, fall through to wildcard matching below.
|
|
95
|
+
if (exactMatchFile !== undefined && !exactMatchFile.isWildcard) {
|
|
96
|
+
return {
|
|
97
|
+
...exactMatchFile,
|
|
98
|
+
matchedPath: `${matchedPath}/${exactMatchFile.rawName}`,
|
|
99
|
+
pathVariables,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const wildcardFiles = Object.values(directory.files).filter((file) => file.isWildcard && this.fileModuleDefined(file, method));
|
|
103
|
+
if (wildcardFiles.length > 1) {
|
|
104
|
+
const firstWildcard = wildcardFiles[0];
|
|
105
|
+
return {
|
|
106
|
+
...firstWildcard,
|
|
107
|
+
ambiguous: true,
|
|
108
|
+
matchedPath: `${matchedPath}/${firstWildcard.rawName}`,
|
|
109
|
+
pathVariables: {
|
|
110
|
+
...pathVariables,
|
|
111
|
+
[firstWildcard.name]: segment,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const match = exactMatchFile ?? wildcardFiles[0];
|
|
80
116
|
if (match === undefined) {
|
|
81
117
|
return undefined;
|
|
82
118
|
}
|
|
@@ -113,16 +149,21 @@ export class ModuleTree {
|
|
|
113
149
|
return this.matchWithinDirectory(exactMatch, remainingSegments, pathVariables, `${matchedPath}/${segment}`, method);
|
|
114
150
|
}
|
|
115
151
|
const wildcardDirectories = Object.values(directory.directories).filter((subdirectory) => subdirectory.isWildcard);
|
|
152
|
+
const wildcardMatches = [];
|
|
116
153
|
for (const wildcardDirectory of wildcardDirectories) {
|
|
117
|
-
const
|
|
154
|
+
const wildcardMatch = this.matchWithinDirectory(wildcardDirectory, remainingSegments, {
|
|
118
155
|
...pathVariables,
|
|
119
156
|
[wildcardDirectory.name]: segment,
|
|
120
157
|
}, `${matchedPath}/${wildcardDirectory.rawName}`, method);
|
|
121
|
-
if (
|
|
122
|
-
|
|
158
|
+
if (wildcardMatch !== undefined) {
|
|
159
|
+
wildcardMatches.push(wildcardMatch);
|
|
123
160
|
}
|
|
124
161
|
}
|
|
125
|
-
|
|
162
|
+
if (wildcardMatches.length > 1) {
|
|
163
|
+
const firstMatch = wildcardMatches[0];
|
|
164
|
+
return { ...firstMatch, ambiguous: true };
|
|
165
|
+
}
|
|
166
|
+
return wildcardMatches[0];
|
|
126
167
|
}
|
|
127
168
|
match(url, method) {
|
|
128
169
|
return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
1
2
|
import yaml from "js-yaml";
|
|
2
|
-
import { readFile } from "../util/read-file.js";
|
|
3
3
|
export function openapiMiddleware(openApiPath, url) {
|
|
4
4
|
return async (ctx, next) => {
|
|
5
5
|
if (ctx.URL.pathname === "/counterfact/openapi") {
|
|
6
|
-
const openApiDocument = (await
|
|
6
|
+
const openApiDocument = (await bundle(openApiPath));
|
|
7
7
|
openApiDocument.servers ??= [];
|
|
8
8
|
openApiDocument.servers.unshift({
|
|
9
9
|
description: "Counterfact",
|
package/dist/server/registry.js
CHANGED
|
@@ -37,7 +37,7 @@ export class Registry {
|
|
|
37
37
|
moduleTree = new ModuleTree();
|
|
38
38
|
middlewares = new Map();
|
|
39
39
|
constructor() {
|
|
40
|
-
this.middlewares.set("
|
|
40
|
+
this.middlewares.set("", ($, respondTo) => respondTo($));
|
|
41
41
|
}
|
|
42
42
|
get routes() {
|
|
43
43
|
return this.moduleTree.routes;
|
|
@@ -46,7 +46,7 @@ export class Registry {
|
|
|
46
46
|
this.moduleTree.add(url, module);
|
|
47
47
|
}
|
|
48
48
|
addMiddleware(url, callback) {
|
|
49
|
-
this.middlewares.set(url, callback);
|
|
49
|
+
this.middlewares.set(url === "/" ? "" : url, callback);
|
|
50
50
|
}
|
|
51
51
|
remove(url) {
|
|
52
52
|
this.moduleTree.remove(url);
|
|
@@ -57,6 +57,7 @@ export class Registry {
|
|
|
57
57
|
handler(url, method) {
|
|
58
58
|
const match = this.moduleTree.match(url, method);
|
|
59
59
|
return {
|
|
60
|
+
ambiguous: match?.ambiguous ?? false,
|
|
60
61
|
matchedPath: match?.matchedPath ?? "",
|
|
61
62
|
module: match?.module,
|
|
62
63
|
path: match?.pathVariables ?? {},
|
|
@@ -71,6 +72,14 @@ export class Registry {
|
|
|
71
72
|
endpoint(httpRequestMethod, url, parameterTypes = {}) {
|
|
72
73
|
const handler = this.handler(url, httpRequestMethod);
|
|
73
74
|
debug("handler for %s: %o", url, handler);
|
|
75
|
+
if (handler.ambiguous) {
|
|
76
|
+
return () => ({
|
|
77
|
+
body: `Ambiguous wildcard paths: the request to ${url} matches multiple routes. Please resolve the ambiguity in your API spec or route handlers.`,
|
|
78
|
+
contentType: "text/plain",
|
|
79
|
+
headers: {},
|
|
80
|
+
status: 500,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
74
83
|
const execute = handler.module?.[httpRequestMethod];
|
|
75
84
|
if (!execute) {
|
|
76
85
|
debug(`Could not find a ${httpRequestMethod} method matching ${url}\n`);
|
|
@@ -111,11 +120,10 @@ export class Registry {
|
|
|
111
120
|
};
|
|
112
121
|
const middlewares = this.middlewares;
|
|
113
122
|
function recurse(path, respondTo) {
|
|
123
|
+
debug("recursing path", path);
|
|
114
124
|
if (path === null)
|
|
115
125
|
return respondTo;
|
|
116
|
-
const nextPath = path === "
|
|
117
|
-
? null
|
|
118
|
-
: path.slice(0, path.lastIndexOf("/")) || "/";
|
|
126
|
+
const nextPath = path === "" ? null : path.slice(0, path.lastIndexOf("/"));
|
|
119
127
|
const middleware = middlewares.get(path);
|
|
120
128
|
if (middleware !== undefined) {
|
|
121
129
|
return recurse(nextPath, ($) => middleware($, respondTo));
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generate } from "json-schema-faker";
|
|
2
2
|
import { jsonToXml } from "./json-to-xml.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const DEFAULT_GENERATE_OPTIONS = {
|
|
4
|
+
useExamplesValue: true,
|
|
5
|
+
minItems: 0,
|
|
6
|
+
maxItems: 20,
|
|
7
|
+
failOnInvalidTypes: false,
|
|
8
|
+
fillProperties: false,
|
|
9
|
+
};
|
|
9
10
|
function convertToXmlIfNecessary(type, body, schema) {
|
|
10
11
|
if (type.endsWith("/xml")) {
|
|
11
12
|
return jsonToXml(body, schema, "root");
|
|
@@ -18,6 +19,32 @@ function oneOf(items) {
|
|
|
18
19
|
}
|
|
19
20
|
return oneOf(Object.values(items));
|
|
20
21
|
}
|
|
22
|
+
function serializeCookie(name, value, options = {}) {
|
|
23
|
+
const parts = [`${name}=${value}`];
|
|
24
|
+
if (options.path !== undefined) {
|
|
25
|
+
parts.push(`Path=${options.path}`);
|
|
26
|
+
}
|
|
27
|
+
if (options.domain !== undefined) {
|
|
28
|
+
parts.push(`Domain=${options.domain}`);
|
|
29
|
+
}
|
|
30
|
+
if (options.maxAge !== undefined) {
|
|
31
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
32
|
+
}
|
|
33
|
+
if (options.expires !== undefined) {
|
|
34
|
+
parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
35
|
+
}
|
|
36
|
+
if (options.httpOnly) {
|
|
37
|
+
parts.push("HttpOnly");
|
|
38
|
+
}
|
|
39
|
+
if (options.secure) {
|
|
40
|
+
parts.push("Secure");
|
|
41
|
+
}
|
|
42
|
+
if (options.sameSite !== undefined) {
|
|
43
|
+
const sameSiteMap = { lax: "Lax", none: "None", strict: "Strict" };
|
|
44
|
+
parts.push(`SameSite=${sameSiteMap[options.sameSite]}`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join("; ");
|
|
47
|
+
}
|
|
21
48
|
function unknownStatusCodeResponse(statusCode) {
|
|
22
49
|
return {
|
|
23
50
|
content: [
|
|
@@ -41,6 +68,28 @@ export function createResponseBuilder(operation, config) {
|
|
|
41
68
|
},
|
|
42
69
|
};
|
|
43
70
|
},
|
|
71
|
+
binary(body) {
|
|
72
|
+
const buffer = typeof body === "string"
|
|
73
|
+
? Buffer.from(body, "base64")
|
|
74
|
+
: Buffer.from(body);
|
|
75
|
+
return this.match("application/octet-stream", buffer);
|
|
76
|
+
},
|
|
77
|
+
cookie(name, value, options = {}) {
|
|
78
|
+
const cookieString = serializeCookie(name, value, options);
|
|
79
|
+
const existing = this.headers?.["set-cookie"];
|
|
80
|
+
const existingArray = Array.isArray(existing)
|
|
81
|
+
? existing
|
|
82
|
+
: existing !== undefined
|
|
83
|
+
? [existing]
|
|
84
|
+
: [];
|
|
85
|
+
return {
|
|
86
|
+
...this,
|
|
87
|
+
headers: {
|
|
88
|
+
...this.headers,
|
|
89
|
+
"set-cookie": [...existingArray, cookieString],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
44
93
|
html(body) {
|
|
45
94
|
return this.match("text/html", body);
|
|
46
95
|
},
|
|
@@ -73,6 +122,18 @@ export function createResponseBuilder(operation, config) {
|
|
|
73
122
|
return unknownStatusCodeResponse(this.status);
|
|
74
123
|
}
|
|
75
124
|
const { content } = response;
|
|
125
|
+
const exampleExists = Object.values(content).some((contentType) => contentType?.examples?.[name] !== undefined);
|
|
126
|
+
if (!exampleExists) {
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
body: `The OpenAPI document does not define an example named "${name}" for status code ${this.status ?? "unknown"}`,
|
|
131
|
+
type: "text/plain",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
status: 500,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
76
137
|
return {
|
|
77
138
|
...this,
|
|
78
139
|
content: Object.keys(content).map((type) => ({
|
|
@@ -81,12 +142,15 @@ export function createResponseBuilder(operation, config) {
|
|
|
81
142
|
})),
|
|
82
143
|
};
|
|
83
144
|
},
|
|
84
|
-
random() {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
145
|
+
async random() {
|
|
146
|
+
const generateOptions = config?.alwaysFakeOptionals
|
|
147
|
+
? {
|
|
148
|
+
...DEFAULT_GENERATE_OPTIONS,
|
|
149
|
+
alwaysFakeOptionals: true,
|
|
150
|
+
fixedProbabilities: true,
|
|
151
|
+
optionalsProbability: 1.0,
|
|
152
|
+
}
|
|
153
|
+
: DEFAULT_GENERATE_OPTIONS;
|
|
90
154
|
if (operation.produces) {
|
|
91
155
|
return this.randomLegacy();
|
|
92
156
|
}
|
|
@@ -96,17 +160,29 @@ export function createResponseBuilder(operation, config) {
|
|
|
96
160
|
return unknownStatusCodeResponse(this.status);
|
|
97
161
|
}
|
|
98
162
|
const { content } = response;
|
|
163
|
+
const generatedHeaders = {};
|
|
164
|
+
for (const [name, header] of Object.entries(response.headers ?? {})) {
|
|
165
|
+
if (header.required && !(name in (this.headers ?? {}))) {
|
|
166
|
+
generatedHeaders[name] = (await generate((header.schema ?? { type: "string" }), generateOptions));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
99
169
|
return {
|
|
100
170
|
...this,
|
|
101
|
-
content: Object.keys(content).map((type) => ({
|
|
171
|
+
content: await Promise.all(Object.keys(content).map(async (type) => ({
|
|
102
172
|
body: convertToXmlIfNecessary(type, content[type]?.examples
|
|
103
173
|
? oneOf(Object.values(content[type]?.examples ?? []).map((example) => example.value))
|
|
104
|
-
:
|
|
174
|
+
: await generate((content[type]?.schema ?? {
|
|
175
|
+
type: "object",
|
|
176
|
+
}), generateOptions), content[type]?.schema),
|
|
105
177
|
type,
|
|
106
|
-
})),
|
|
178
|
+
}))),
|
|
179
|
+
headers: {
|
|
180
|
+
...generatedHeaders,
|
|
181
|
+
...this.headers,
|
|
182
|
+
},
|
|
107
183
|
};
|
|
108
184
|
},
|
|
109
|
-
randomLegacy() {
|
|
185
|
+
async randomLegacy() {
|
|
110
186
|
const response = operation.responses[this.status ?? "default"] ??
|
|
111
187
|
operation.responses.default;
|
|
112
188
|
if (response === undefined) {
|
|
@@ -114,7 +190,7 @@ export function createResponseBuilder(operation, config) {
|
|
|
114
190
|
}
|
|
115
191
|
const body = response.examples
|
|
116
192
|
? oneOf(response.examples)
|
|
117
|
-
:
|
|
193
|
+
: await generate((response.schema ?? { type: "object" }), DEFAULT_GENERATE_OPTIONS);
|
|
118
194
|
return {
|
|
119
195
|
...this,
|
|
120
196
|
content: operation.produces?.map((type) => ({
|
package/dist/server/tools.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
JSONSchemaFaker.option("useExamplesValue", true);
|
|
3
|
-
JSONSchemaFaker.option("fillProperties", false);
|
|
1
|
+
import { generate } from "json-schema-faker";
|
|
4
2
|
export class Tools {
|
|
5
3
|
headers;
|
|
6
4
|
constructor({ headers = {}, } = {}) {
|
|
@@ -22,6 +20,6 @@ export class Tools {
|
|
|
22
20
|
});
|
|
23
21
|
}
|
|
24
22
|
randomFromSchema(schema) {
|
|
25
|
-
return
|
|
23
|
+
return generate(schema, { useExamplesValue: true, fillProperties: false });
|
|
26
24
|
}
|
|
27
25
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/* eslint-disable n/no-sync */
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import nodePath from "node:path";
|
|
5
4
|
import createDebug from "debug";
|
|
6
5
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
7
6
|
import { OperationCoder } from "./operation-coder.js";
|
|
7
|
+
import { pruneRoutes } from "./prune.js";
|
|
8
8
|
import { Repository } from "./repository.js";
|
|
9
9
|
import { Specification } from "./specification.js";
|
|
10
10
|
const debug = createDebug("counterfact:typescript-generator:generate");
|
|
@@ -41,6 +41,11 @@ export async function generate(source, destination, generateOptions, repository
|
|
|
41
41
|
debug("reading the #/paths from the specification");
|
|
42
42
|
const paths = await getPathsFromSpecification(specification);
|
|
43
43
|
debug("got %i paths", paths.size);
|
|
44
|
+
if (generateOptions.prune && generateOptions.routes) {
|
|
45
|
+
debug("pruning defunct route files");
|
|
46
|
+
await pruneRoutes(destination, paths.keys());
|
|
47
|
+
debug("done pruning");
|
|
48
|
+
}
|
|
44
49
|
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
45
50
|
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
46
51
|
paths.forEach((pathDefinition, key) => {
|
|
@@ -102,6 +102,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
102
102
|
const queryType = new ParametersTypeCoder(parameters, "query").write(script);
|
|
103
103
|
const pathType = new ParametersTypeCoder(parameters, "path").write(script);
|
|
104
104
|
const headersType = new ParametersTypeCoder(parameters, "header").write(script);
|
|
105
|
+
const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
|
|
105
106
|
const bodyRequirement = this.requirement.get("consumes") ||
|
|
106
107
|
this.requirement.specification?.rootRequirement?.get("consumes")
|
|
107
108
|
? parameters
|
|
@@ -121,6 +122,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
121
122
|
const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
|
|
122
123
|
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
|
|
123
124
|
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
|
|
124
|
-
|
|
125
|
+
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
|
|
126
|
+
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} }>) => MaybePromise<${this.responseTypes(script)} | { status: 415, contentType: "text/plain", body: string } | COUNTERFACT_RESPONSE | { ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE }>`;
|
|
125
127
|
}
|
|
126
128
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import nodePath from "node:path";
|
|
3
|
+
import createDebug from "debug";
|
|
4
|
+
const debug = createDebug("counterfact:typescript-generator:prune");
|
|
5
|
+
/**
|
|
6
|
+
* Collects all .ts route files in a directory recursively.
|
|
7
|
+
* Context files (_.context.ts) are excluded.
|
|
8
|
+
* @param {string} routesDir - Path to routes directory
|
|
9
|
+
* @param {string} currentPath - Current subdirectory being processed (relative to routesDir)
|
|
10
|
+
* @returns {Promise<string[]>} - Array of relative paths (using forward slashes)
|
|
11
|
+
*/
|
|
12
|
+
async function collectRouteFiles(routesDir, currentPath = "") {
|
|
13
|
+
const files = [];
|
|
14
|
+
try {
|
|
15
|
+
const fullDir = currentPath
|
|
16
|
+
? nodePath.join(routesDir, currentPath)
|
|
17
|
+
: routesDir;
|
|
18
|
+
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const relativePath = currentPath
|
|
21
|
+
? `${currentPath}/${entry.name}`
|
|
22
|
+
: entry.name;
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
files.push(...(await collectRouteFiles(routesDir, relativePath)));
|
|
25
|
+
}
|
|
26
|
+
else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
|
|
27
|
+
files.push(relativePath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code !== "ENOENT") {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Recursively removes empty directories under rootDir, but not rootDir itself.
|
|
40
|
+
* @param {string} dir - Directory to check
|
|
41
|
+
* @param {string} rootDir - Root directory that should never be removed
|
|
42
|
+
*/
|
|
43
|
+
async function removeEmptyDirectories(dir, rootDir) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
await removeEmptyDirectories(nodePath.join(dir, entry.name), rootDir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (nodePath.resolve(dir) === nodePath.resolve(rootDir)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const remaining = await fs.readdir(dir);
|
|
60
|
+
if (remaining.length === 0) {
|
|
61
|
+
await fs.rmdir(dir);
|
|
62
|
+
debug("removed empty directory: %s", dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Converts an OpenAPI path to the expected route file path (relative to routesDir).
|
|
67
|
+
* e.g. "/pet/{id}" -> "pet/{id}.ts", "/" -> "index.ts"
|
|
68
|
+
* @param {string} openApiPath
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function openApiPathToRouteFile(openApiPath) {
|
|
72
|
+
const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1);
|
|
73
|
+
return `${filePath}.ts`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prunes route files that no longer correspond to any path in the OpenAPI spec.
|
|
77
|
+
* Context files (_.context.ts) are never pruned.
|
|
78
|
+
* @param {string} destination - Base destination directory (contains the routes/ sub-directory)
|
|
79
|
+
* @param {Iterable<string>} openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}")
|
|
80
|
+
* @returns {Promise<number>} - Number of files removed
|
|
81
|
+
*/
|
|
82
|
+
export async function pruneRoutes(destination, openApiPaths) {
|
|
83
|
+
const routesDir = nodePath.join(destination, "routes");
|
|
84
|
+
const expectedFiles = new Set(Array.from(openApiPaths).map(openApiPathToRouteFile));
|
|
85
|
+
debug("expected route files: %o", Array.from(expectedFiles));
|
|
86
|
+
const actualFiles = await collectRouteFiles(routesDir);
|
|
87
|
+
debug("actual route files: %o", actualFiles);
|
|
88
|
+
let prunedCount = 0;
|
|
89
|
+
for (const file of actualFiles) {
|
|
90
|
+
const normalizedFile = file.replaceAll("\\", "/");
|
|
91
|
+
if (!expectedFiles.has(normalizedFile)) {
|
|
92
|
+
const fullPath = nodePath.join(routesDir, file);
|
|
93
|
+
debug("pruning %s", fullPath);
|
|
94
|
+
await fs.rm(fullPath);
|
|
95
|
+
prunedCount++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await removeEmptyDirectories(routesDir, routesDir);
|
|
99
|
+
debug("pruned %d files", prunedCount);
|
|
100
|
+
return prunedCount;
|
|
101
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable n/no-sync */
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import nodePath, { dirname } from "node:path";
|
|
@@ -38,6 +37,7 @@ export class Repository {
|
|
|
38
37
|
if (!existsSync(sourcePath)) {
|
|
39
38
|
return false;
|
|
40
39
|
}
|
|
40
|
+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
|
|
41
41
|
return fs.cp(sourcePath, destinationPath, { recursive: true });
|
|
42
42
|
}
|
|
43
43
|
async writeFiles(destination, { routes, types }) {
|
|
@@ -25,7 +25,7 @@ export class Requirement {
|
|
|
25
25
|
}
|
|
26
26
|
return new Requirement(this.data[item], `${this.url}/${this.escapeJsonPointer(item)}`, this.specification);
|
|
27
27
|
}
|
|
28
|
-
select(path
|
|
28
|
+
select(path) {
|
|
29
29
|
const parts = path
|
|
30
30
|
.split("/")
|
|
31
31
|
.map(this.unescapeJsonPointer)
|
|
@@ -80,13 +80,16 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
80
80
|
}
|
|
81
81
|
writeCode(script) {
|
|
82
82
|
// script.comments = READ_ONLY_COMMENTS;
|
|
83
|
-
const { allOf, anyOf, oneOf, type } = this.requirement.data;
|
|
83
|
+
const { allOf, anyOf, oneOf, type, format } = this.requirement.data;
|
|
84
84
|
if (allOf ?? anyOf ?? oneOf) {
|
|
85
85
|
return this.writeGroup(script, { allOf, anyOf, oneOf });
|
|
86
86
|
}
|
|
87
87
|
if (this.requirement.has("enum")) {
|
|
88
88
|
return this.writeEnum(script, this.requirement.get("enum"));
|
|
89
89
|
}
|
|
90
|
+
if ((type === "string" && format === "binary") || type === "file") {
|
|
91
|
+
return "Uint8Array | string";
|
|
92
|
+
}
|
|
90
93
|
return this.writeType(script, type);
|
|
91
94
|
}
|
|
92
95
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import nodePath from "node:path";
|
|
2
2
|
import createDebugger from "debug";
|
|
3
|
-
import
|
|
3
|
+
import { format } from "prettier";
|
|
4
4
|
const debug = createDebugger("counterfact:typescript-generator:script");
|
|
5
5
|
export class Script {
|
|
6
6
|
constructor(repository, path) {
|
|
@@ -137,7 +137,7 @@ export class Script {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
contents() {
|
|
140
|
-
return
|
|
140
|
+
return format([
|
|
141
141
|
this.comments.map((comment) => `// ${comment}`).join("\n"),
|
|
142
142
|
this.comments.length > 0 ? "\n\n" : "",
|
|
143
143
|
this.externalImportStatements().join("\n"),
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
7
|
-
"exports":
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/app.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
8
12
|
"types": "./dist/server/types.d.ts",
|
|
9
13
|
"typesVersions": {
|
|
10
14
|
"*": {
|
|
@@ -49,7 +53,7 @@
|
|
|
49
53
|
"swagger-tools"
|
|
50
54
|
],
|
|
51
55
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
56
|
+
"node": ">=22"
|
|
53
57
|
},
|
|
54
58
|
"bin": {
|
|
55
59
|
"counterfact": "./bin/counterfact.js"
|
|
@@ -61,7 +65,7 @@
|
|
|
61
65
|
"sideEffects": false,
|
|
62
66
|
"scripts": {
|
|
63
67
|
"test": "yarn node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns=black-box",
|
|
64
|
-
"test:black-box": "rimraf dist &&
|
|
68
|
+
"test:black-box": "rimraf dist && yarn build && python3 -m pytest test-black-box/ -v",
|
|
65
69
|
"test:mutants": "stryker run stryker.config.json",
|
|
66
70
|
"test:tsd": "tsd --typings ./dist/server/counterfact-types/index.ts --files ./test/**/*.test-d.ts",
|
|
67
71
|
"build": "rm -rf dist && tsc && copyfiles -f \"src/client/**\" dist/client && copyfiles -f \"src/counterfact-types/*.ts\" dist/server/counterfact-types && copyfiles -f \"src/server/*.cjs\" dist/server",
|
|
@@ -78,30 +82,33 @@
|
|
|
78
82
|
},
|
|
79
83
|
"devDependencies": {
|
|
80
84
|
"@changesets/cli": "2.30.0",
|
|
85
|
+
"@eslint/js": "10.0.1",
|
|
86
|
+
"@jest/globals": "^30.3.0",
|
|
81
87
|
"@stryker-mutator/core": "9.6.0",
|
|
82
88
|
"@stryker-mutator/jest-runner": "9.6.0",
|
|
83
89
|
"@stryker-mutator/typescript-checker": "9.6.0",
|
|
84
|
-
"@swc/core": "1.15.
|
|
90
|
+
"@swc/core": "1.15.21",
|
|
85
91
|
"@swc/jest": "0.2.39",
|
|
86
92
|
"@testing-library/dom": "10.4.1",
|
|
87
93
|
"@types/debug": "^4.1.12",
|
|
88
94
|
"@types/jest": "30.0.0",
|
|
89
95
|
"@types/js-yaml": "4.0.9",
|
|
90
|
-
"@types/koa": "3.0.
|
|
96
|
+
"@types/koa": "3.0.2",
|
|
91
97
|
"@types/koa-bodyparser": "4.3.13",
|
|
92
98
|
"@types/koa-proxy": "1.0.8",
|
|
93
99
|
"@types/koa-static": "4.0.4",
|
|
94
100
|
"@types/lodash": "4.17.24",
|
|
101
|
+
"@types/node": "22",
|
|
95
102
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
96
103
|
"@typescript-eslint/parser": "^8.53.0",
|
|
97
104
|
"copyfiles": "2.4.1",
|
|
98
|
-
"eslint": "
|
|
105
|
+
"eslint": "10.1.0",
|
|
99
106
|
"eslint-formatter-github-annotations": "0.1.0",
|
|
100
107
|
"eslint-import-resolver-typescript": "4.4.4",
|
|
101
108
|
"eslint-plugin-etc": "2.0.3",
|
|
102
|
-
"eslint-plugin-file-progress": "
|
|
109
|
+
"eslint-plugin-file-progress": "4.0.0",
|
|
103
110
|
"eslint-plugin-import": "2.32.0",
|
|
104
|
-
"eslint-plugin-jest": "29.15.
|
|
111
|
+
"eslint-plugin-jest": "29.15.1",
|
|
105
112
|
"eslint-plugin-jest-dom": "5.5.0",
|
|
106
113
|
"eslint-plugin-n": "^17.24.0",
|
|
107
114
|
"eslint-plugin-no-explicit-type-exports": "0.12.1",
|
|
@@ -129,10 +136,10 @@
|
|
|
129
136
|
"debug": "4.4.3",
|
|
130
137
|
"fetch": "1.1.0",
|
|
131
138
|
"fs-extra": "11.3.4",
|
|
132
|
-
"handlebars": "4.7.
|
|
139
|
+
"handlebars": "4.7.9",
|
|
133
140
|
"http-terminator": "3.2.0",
|
|
134
141
|
"js-yaml": "4.1.1",
|
|
135
|
-
"json-schema-faker": "0.
|
|
142
|
+
"json-schema-faker": "0.6.0",
|
|
136
143
|
"jsonwebtoken": "9.0.3",
|
|
137
144
|
"koa": "3.1.2",
|
|
138
145
|
"koa-bodyparser": "4.4.1",
|
|
@@ -144,10 +151,11 @@
|
|
|
144
151
|
"patch-package": "8.0.1",
|
|
145
152
|
"precinct": "12.2.0",
|
|
146
153
|
"prettier": "3.8.1",
|
|
147
|
-
"typescript": "
|
|
154
|
+
"typescript": "6.0.2"
|
|
148
155
|
},
|
|
149
156
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
|
150
157
|
"resolutions": {
|
|
151
|
-
"js-yaml": "4.1.1"
|
|
158
|
+
"js-yaml": "4.1.1",
|
|
159
|
+
"@typescript-eslint/utils": "^8.58.0"
|
|
152
160
|
}
|
|
153
161
|
}
|