counterfact 2.5.1 → 2.7.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 +103 -140
- package/bin/README.md +25 -4
- package/bin/counterfact.js +208 -24
- package/bin/register-ts-loader.mjs +17 -0
- package/bin/ts-loader.mjs +31 -0
- package/dist/app.js +31 -21
- package/dist/counterfact-types/cookie-options.js +1 -0
- package/dist/counterfact-types/counterfact-response.js +7 -0
- package/dist/counterfact-types/example-names.js +1 -0
- package/dist/counterfact-types/example.js +1 -0
- package/dist/counterfact-types/generic-response-builder.js +1 -0
- package/dist/counterfact-types/http-status-code.js +1 -0
- package/dist/counterfact-types/if-has-key.js +1 -0
- package/dist/counterfact-types/index.js +0 -1
- package/dist/counterfact-types/maybe-promise.js +1 -0
- package/dist/counterfact-types/media-type.js +1 -0
- package/dist/counterfact-types/omit-all.js +1 -0
- package/dist/counterfact-types/omit-value-when-never.js +1 -0
- package/dist/counterfact-types/open-api-content.js +1 -0
- package/dist/counterfact-types/open-api-operation.js +1 -0
- package/dist/counterfact-types/open-api-parameters.js +1 -0
- package/dist/counterfact-types/open-api-response.js +1 -0
- package/dist/counterfact-types/random-function.js +1 -0
- package/dist/counterfact-types/response-builder-factory.js +1 -0
- package/dist/counterfact-types/response-builder.js +1 -0
- package/dist/counterfact-types/wide-operation-argument.js +1 -0
- package/dist/counterfact-types/wide-response-builder.js +1 -0
- package/dist/migrate/update-route-types.js +30 -10
- package/dist/repl/raw-http-client.js +14 -14
- package/dist/repl/repl.js +119 -4
- package/dist/repl/route-builder.js +270 -0
- package/dist/server/config.js +1 -1
- package/dist/server/context-registry.js +44 -4
- package/dist/server/counterfact-types/cookie-options.ts +14 -0
- package/dist/server/counterfact-types/counterfact-response.ts +15 -0
- package/dist/server/counterfact-types/example-names.ts +13 -0
- package/dist/server/counterfact-types/example.ts +10 -0
- package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
- package/dist/server/counterfact-types/http-status-code.ts +62 -0
- package/dist/server/counterfact-types/if-has-key.ts +19 -0
- package/dist/server/counterfact-types/index.ts +20 -328
- package/dist/server/counterfact-types/maybe-promise.ts +6 -0
- package/dist/server/counterfact-types/media-type.ts +6 -0
- package/dist/server/counterfact-types/omit-all.ts +11 -0
- package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
- package/dist/server/counterfact-types/open-api-content.ts +8 -0
- package/dist/server/counterfact-types/open-api-operation.ts +36 -0
- package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
- package/dist/server/counterfact-types/open-api-response.ts +22 -0
- package/dist/server/counterfact-types/random-function.ts +9 -0
- package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
- package/dist/server/counterfact-types/response-builder.ts +31 -0
- package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
- package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
- package/dist/server/create-koa-app.js +1 -20
- package/dist/server/determine-module-kind.js +1 -1
- package/dist/server/dispatcher.js +39 -15
- package/dist/server/file-discovery.js +34 -0
- package/dist/server/json-to-xml.js +1 -1
- package/dist/server/koa-middleware.js +7 -1
- package/dist/server/load-openapi-document.js +13 -0
- package/dist/server/middleware-detector.js +8 -0
- package/dist/server/module-dependency-graph.js +4 -1
- package/dist/server/module-loader.js +81 -33
- package/dist/server/module-tree.js +26 -23
- package/dist/server/openapi-middleware.js +2 -2
- package/dist/server/openapi-watcher.js +35 -0
- package/dist/server/registry.js +2 -2
- package/dist/server/request-validator.js +57 -0
- package/dist/server/response-builder.js +3 -0
- package/dist/server/response-validator.js +58 -0
- package/dist/server/scenario-registry.js +29 -0
- package/dist/server/tools.js +2 -2
- package/dist/server/transpiler.js +13 -5
- package/dist/typescript-generator/coder.js +7 -2
- package/dist/typescript-generator/generate.js +155 -0
- package/dist/typescript-generator/jsdoc.js +45 -0
- package/dist/typescript-generator/operation-coder.js +1 -1
- package/dist/typescript-generator/operation-type-coder.js +5 -49
- package/dist/typescript-generator/parameters-type-coder.js +5 -1
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/read-only-comments.js +1 -1
- package/dist/typescript-generator/requirement.js +8 -1
- package/dist/typescript-generator/reserved-words.js +50 -0
- package/dist/typescript-generator/schema-type-coder.js +7 -1
- package/dist/typescript-generator/script.js +5 -3
- package/dist/typescript-generator/specification.js +7 -1
- package/dist/util/load-config-file.js +44 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
- package/package.json +12 -12
- package/dist/client/README.md +0 -14
- package/dist/client/index.html.hbs +0 -244
- package/dist/client/rapi-doc.html.hbs +0 -36
- package/dist/server/page-middleware.js +0 -23
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MediaType } from "./media-type.js";
|
|
2
|
+
import type { OpenApiContent } from "./open-api-content.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Describes a single HTTP response as modelled in an OpenAPI document.
|
|
6
|
+
* Contains the allowed content types, optional named examples, and the
|
|
7
|
+
* required/optional response headers for that response.
|
|
8
|
+
*/
|
|
9
|
+
export interface OpenApiResponse {
|
|
10
|
+
content: { [key: MediaType]: OpenApiContent };
|
|
11
|
+
examples?: { [key: string]: unknown };
|
|
12
|
+
headers: { [key: string]: { schema: unknown } };
|
|
13
|
+
requiredHeaders: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A map of HTTP status codes (or `"default"`) to their corresponding
|
|
18
|
+
* `OpenApiResponse` definitions for a given operation.
|
|
19
|
+
*/
|
|
20
|
+
export interface OpenApiResponses {
|
|
21
|
+
[key: string]: OpenApiResponse;
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { COUNTERFACT_RESPONSE } from "./counterfact-response.js";
|
|
2
|
+
import type { MaybePromise } from "./maybe-promise.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The type of the `.random()` method on the response builder.
|
|
6
|
+
* When called, it randomly selects one of the available content-type examples
|
|
7
|
+
* and returns a completed `COUNTERFACT_RESPONSE`.
|
|
8
|
+
*/
|
|
9
|
+
export type RandomFunction = () => MaybePromise<COUNTERFACT_RESPONSE>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GenericResponseBuilder } from "./generic-response-builder.js";
|
|
2
|
+
import type { OpenApiResponses } from "./open-api-response.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps each HTTP status code (or `"default"`) in an OpenAPI operation's
|
|
6
|
+
* response definitions to the corresponding `GenericResponseBuilder`.
|
|
7
|
+
* This is the type of the `response` property in a generated route handler's
|
|
8
|
+
* argument object, allowing handlers to call e.g. `response[200].json(body)`.
|
|
9
|
+
*/
|
|
10
|
+
export type ResponseBuilderFactory<
|
|
11
|
+
Responses extends OpenApiResponses = OpenApiResponses,
|
|
12
|
+
> = {
|
|
13
|
+
[StatusCode in keyof Responses]: GenericResponseBuilder<
|
|
14
|
+
Responses[StatusCode]
|
|
15
|
+
>;
|
|
16
|
+
} & { [key: string]: GenericResponseBuilder<Responses["default"]> };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CookieOptions } from "./cookie-options.js";
|
|
2
|
+
import type { MaybePromise } from "./maybe-promise.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A loosely-typed, chainable response builder used in non-generated contexts
|
|
6
|
+
* (e.g. middleware or wide/catch-all route handlers) where the exact response
|
|
7
|
+
* shape is not statically known. For generated route handlers, prefer the
|
|
8
|
+
* strongly-typed `GenericResponseBuilder`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ResponseBuilder {
|
|
11
|
+
[status: number | `${number} ${string}`]: ResponseBuilder;
|
|
12
|
+
binary: (body: Uint8Array | string) => ResponseBuilder;
|
|
13
|
+
content?: { body: unknown; type: string }[];
|
|
14
|
+
cookie: (
|
|
15
|
+
name: string,
|
|
16
|
+
value: string,
|
|
17
|
+
options?: CookieOptions,
|
|
18
|
+
) => ResponseBuilder;
|
|
19
|
+
empty: () => ResponseBuilder;
|
|
20
|
+
example: (name: string) => ResponseBuilder;
|
|
21
|
+
header: (name: string, value: string) => ResponseBuilder;
|
|
22
|
+
headers: { [name: string]: string | string[] };
|
|
23
|
+
html: (body: unknown) => ResponseBuilder;
|
|
24
|
+
json: (body: unknown) => ResponseBuilder;
|
|
25
|
+
match: (contentType: string, body: unknown) => ResponseBuilder;
|
|
26
|
+
random: () => MaybePromise<ResponseBuilder>;
|
|
27
|
+
randomLegacy: () => MaybePromise<ResponseBuilder>;
|
|
28
|
+
status?: number;
|
|
29
|
+
text: (body: unknown) => ResponseBuilder;
|
|
30
|
+
xml: (body: unknown) => ResponseBuilder;
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WideResponseBuilder } from "./wide-response-builder.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The loosely-typed argument object passed to wide (catch-all) route handlers.
|
|
5
|
+
* Unlike the generated operation argument types, all fields are typed as
|
|
6
|
+
* `unknown` or broad index signatures. Use this when writing handlers that
|
|
7
|
+
* should accept any request without compile-time schema enforcement.
|
|
8
|
+
*/
|
|
9
|
+
export interface WideOperationArgument {
|
|
10
|
+
body: unknown;
|
|
11
|
+
context: unknown;
|
|
12
|
+
headers: { [key: string]: string };
|
|
13
|
+
path: { [key: string]: string };
|
|
14
|
+
proxy: (url: string) => { proxyUrl: string };
|
|
15
|
+
query: { [key: string]: string };
|
|
16
|
+
response: { [key: number]: WideResponseBuilder };
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CookieOptions } from "./cookie-options.js";
|
|
2
|
+
import type { MaybePromise } from "./maybe-promise.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A loosely-typed response builder used in wide (catch-all) route handlers
|
|
6
|
+
* where the response shape is not known at compile time. Unlike the generated
|
|
7
|
+
* `GenericResponseBuilder`, this interface accepts `unknown` for all body
|
|
8
|
+
* arguments and does not enforce content-type constraints.
|
|
9
|
+
*/
|
|
10
|
+
export interface WideResponseBuilder {
|
|
11
|
+
binary: (body: Uint8Array | string) => WideResponseBuilder;
|
|
12
|
+
empty: () => WideResponseBuilder;
|
|
13
|
+
example: (name: string) => WideResponseBuilder;
|
|
14
|
+
cookie: (
|
|
15
|
+
name: string,
|
|
16
|
+
value: string,
|
|
17
|
+
options?: CookieOptions,
|
|
18
|
+
) => WideResponseBuilder;
|
|
19
|
+
header: (body: unknown) => WideResponseBuilder;
|
|
20
|
+
html: (body: unknown) => WideResponseBuilder;
|
|
21
|
+
json: (body: unknown) => WideResponseBuilder;
|
|
22
|
+
match: (contentType: string, body: unknown) => WideResponseBuilder;
|
|
23
|
+
random: () => MaybePromise<WideResponseBuilder>;
|
|
24
|
+
text: (body: unknown) => WideResponseBuilder;
|
|
25
|
+
xml: (body: unknown) => WideResponseBuilder;
|
|
26
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { pathToFileURL } from "node:url";
|
|
2
1
|
import createDebug from "debug";
|
|
3
2
|
import Koa from "koa";
|
|
4
3
|
import bodyParser from "koa-bodyparser";
|
|
5
4
|
import { koaSwagger } from "koa2-swagger-ui";
|
|
6
5
|
import { adminApiMiddleware } from "./admin-api-middleware.js";
|
|
7
6
|
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
8
|
-
import { pageMiddleware } from "./page-middleware.js";
|
|
9
7
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
10
8
|
export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
|
|
11
9
|
const app = new Koa();
|
|
@@ -21,30 +19,13 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
|
|
|
21
19
|
}
|
|
22
20
|
debug("basePath: %s", config.basePath);
|
|
23
21
|
debug("routes", registry.routes);
|
|
24
|
-
app.use(pageMiddleware("/counterfact/", "index", {
|
|
25
|
-
basePath: config.basePath,
|
|
26
|
-
methods: ["get", "post", "put", "delete", "patch"],
|
|
27
|
-
openApiHref: config.openApiPath.includes("://")
|
|
28
|
-
? config.openApiPath
|
|
29
|
-
: pathToFileURL(config.openApiPath).href,
|
|
30
|
-
openApiPath: config.openApiPath,
|
|
31
|
-
get routes() {
|
|
32
|
-
return registry.routes;
|
|
33
|
-
},
|
|
34
|
-
}));
|
|
35
22
|
app.use(async (ctx, next) => {
|
|
36
23
|
if (ctx.URL.pathname === "/counterfact") {
|
|
37
|
-
ctx.redirect("/counterfact/");
|
|
24
|
+
ctx.redirect("/counterfact/swagger");
|
|
38
25
|
return;
|
|
39
26
|
}
|
|
40
27
|
await next();
|
|
41
28
|
});
|
|
42
|
-
app.use(pageMiddleware("/counterfact/rapidoc", "rapi-doc", {
|
|
43
|
-
basePath: config.basePath,
|
|
44
|
-
get routes() {
|
|
45
|
-
return registry.routes;
|
|
46
|
-
},
|
|
47
|
-
}));
|
|
48
29
|
app.use(bodyParser());
|
|
49
30
|
app.use(async (ctx, next) => {
|
|
50
31
|
await next();
|
|
@@ -6,7 +6,7 @@ export async function determineModuleKind(modulePath) {
|
|
|
6
6
|
if (modulePath.endsWith(".cjs")) {
|
|
7
7
|
return "commonjs";
|
|
8
8
|
}
|
|
9
|
-
if (modulePath.endsWith(".mjs")) {
|
|
9
|
+
if (modulePath.endsWith(".mjs") || modulePath.endsWith(".ts")) {
|
|
10
10
|
return "module";
|
|
11
11
|
}
|
|
12
12
|
if (modulePath === path.parse(modulePath).root) {
|
|
@@ -2,6 +2,8 @@ import { mediaTypes } from "@hapi/accept";
|
|
|
2
2
|
import createDebugger from "debug";
|
|
3
3
|
import fetch, { Headers } from "node-fetch";
|
|
4
4
|
import { createResponseBuilder } from "./response-builder.js";
|
|
5
|
+
import { validateRequest } from "./request-validator.js";
|
|
6
|
+
import { validateResponse } from "./response-validator.js";
|
|
5
7
|
import { Tools } from "./tools.js";
|
|
6
8
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
7
9
|
function parseCookies(cookieHeader) {
|
|
@@ -17,7 +19,8 @@ function parseCookies(cookieHeader) {
|
|
|
17
19
|
try {
|
|
18
20
|
cookies[key] = decodeURIComponent(value);
|
|
19
21
|
}
|
|
20
|
-
catch {
|
|
22
|
+
catch (error) {
|
|
23
|
+
debug("could not decode cookie value for key %s: %o", key, error);
|
|
21
24
|
cookies[key] = value;
|
|
22
25
|
}
|
|
23
26
|
}
|
|
@@ -39,12 +42,12 @@ export class Dispatcher {
|
|
|
39
42
|
}
|
|
40
43
|
parameterTypes(parameters) {
|
|
41
44
|
const types = {
|
|
42
|
-
body:
|
|
43
|
-
cookie:
|
|
44
|
-
formData:
|
|
45
|
-
header:
|
|
46
|
-
path:
|
|
47
|
-
query:
|
|
45
|
+
body: new Map(),
|
|
46
|
+
cookie: new Map(),
|
|
47
|
+
formData: new Map(),
|
|
48
|
+
header: new Map(),
|
|
49
|
+
path: new Map(),
|
|
50
|
+
query: new Map(),
|
|
48
51
|
};
|
|
49
52
|
if (!parameters) {
|
|
50
53
|
return types;
|
|
@@ -52,8 +55,7 @@ export class Dispatcher {
|
|
|
52
55
|
for (const parameter of parameters) {
|
|
53
56
|
const type = parameter?.type;
|
|
54
57
|
if (type !== undefined) {
|
|
55
|
-
types[parameter.in]
|
|
56
|
-
type === "integer" ? "number" : type;
|
|
58
|
+
types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
return types;
|
|
@@ -130,14 +132,14 @@ export class Dispatcher {
|
|
|
130
132
|
}
|
|
131
133
|
return false;
|
|
132
134
|
}
|
|
133
|
-
async request({ auth, body, headers = {}, method, path, query, req, }) {
|
|
135
|
+
async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
|
|
134
136
|
debug(`request: ${method} ${path}`);
|
|
135
137
|
debug(`headers: ${JSON.stringify(headers)}`);
|
|
136
138
|
debug(`body: ${JSON.stringify(body)}`);
|
|
137
139
|
// If the incoming path includes the base path, remove it
|
|
138
140
|
if (this.openApiDocument?.basePath !== undefined &&
|
|
139
141
|
path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
|
|
140
|
-
path = path.
|
|
142
|
+
path = path.slice(this.openApiDocument.basePath.length);
|
|
141
143
|
}
|
|
142
144
|
const { matchedPath } = this.registry.handler(path, method);
|
|
143
145
|
if (!this.registry.exists(method, path) &&
|
|
@@ -150,6 +152,16 @@ export class Dispatcher {
|
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
const operation = this.operationForPathAndMethod(matchedPath, method);
|
|
155
|
+
if (this.config?.validateRequests !== false) {
|
|
156
|
+
const validation = validateRequest(operation, { body, headers, query });
|
|
157
|
+
if (!validation.valid) {
|
|
158
|
+
return {
|
|
159
|
+
body: `Request validation failed:\n${validation.errors.join("\n")}`,
|
|
160
|
+
contentType: "text/plain",
|
|
161
|
+
status: 400,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
153
165
|
const continuousDistribution = (min, max) => {
|
|
154
166
|
return min + Math.random() * (max - min);
|
|
155
167
|
};
|
|
@@ -166,12 +178,9 @@ export class Dispatcher {
|
|
|
166
178
|
cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
|
|
167
179
|
headers,
|
|
168
180
|
proxy: async (url) => {
|
|
169
|
-
if (body !== undefined && headers.contentType !== "application/json") {
|
|
170
|
-
throw new Error(`$.proxy() is currently limited to application/json requests. You tried to proxy to ${url} with a Content-Type of ${headers.contentType ?? "[unknown]"}. Please open an issue at https://github.com/pmcelhaney/counterfact/issues and prod me to fix this limitation.`);
|
|
171
|
-
}
|
|
172
181
|
delete headers.host;
|
|
173
182
|
const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
|
|
174
|
-
body: body === undefined ? undefined :
|
|
183
|
+
body: body === undefined ? undefined : rawBody,
|
|
175
184
|
headers: new Headers(headers),
|
|
176
185
|
method,
|
|
177
186
|
});
|
|
@@ -202,6 +211,21 @@ export class Dispatcher {
|
|
|
202
211
|
status: 406,
|
|
203
212
|
};
|
|
204
213
|
}
|
|
214
|
+
if (this.config?.validateResponses !== false) {
|
|
215
|
+
const validation = validateResponse(operation, normalizedResponse);
|
|
216
|
+
if (!validation.valid) {
|
|
217
|
+
return {
|
|
218
|
+
...normalizedResponse,
|
|
219
|
+
appendedHeaders: [
|
|
220
|
+
...(normalizedResponse.appendedHeaders ?? []),
|
|
221
|
+
...validation.errors.map((error) => [
|
|
222
|
+
"response-type-error",
|
|
223
|
+
error,
|
|
224
|
+
]),
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
205
229
|
return normalizedResponse;
|
|
206
230
|
}
|
|
207
231
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
5
|
+
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
|
|
6
|
+
export class FileDiscovery {
|
|
7
|
+
basePath;
|
|
8
|
+
constructor(basePath) {
|
|
9
|
+
this.basePath = basePath.replaceAll("\\", "/");
|
|
10
|
+
}
|
|
11
|
+
async findFiles(directory = "") {
|
|
12
|
+
const fullDir = nodePath
|
|
13
|
+
.join(this.basePath, directory)
|
|
14
|
+
.replaceAll("\\", "/");
|
|
15
|
+
if (!existsSync(fullDir)) {
|
|
16
|
+
throw new Error(`Directory does not exist ${fullDir}`);
|
|
17
|
+
}
|
|
18
|
+
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
|
+
const results = await Promise.all(entries.map(async (entry) => {
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
return this.findFiles(nodePath.join(directory, entry.name).replaceAll("\\", "/"));
|
|
22
|
+
}
|
|
23
|
+
const extension = entry.name.split(".").at(-1);
|
|
24
|
+
if (!JS_EXTENSIONS.has(extension ?? "")) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const fullPath = nodePath
|
|
28
|
+
.join(this.basePath, directory, entry.name)
|
|
29
|
+
.replaceAll("\\", "/");
|
|
30
|
+
return [escapePathForWindows(fullPath)];
|
|
31
|
+
}));
|
|
32
|
+
return results.flat();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -50,5 +50,5 @@ export function jsonToXml(json, schema, keyName = "root") {
|
|
|
50
50
|
if (typeof json === "object" && json !== null) {
|
|
51
51
|
return objectToXml(json, schema, name);
|
|
52
52
|
}
|
|
53
|
-
return `<${name}>${String(json)}</${name}>`;
|
|
53
|
+
return `<${name}>${xmlEscape(String(json))}</${name}>`;
|
|
54
54
|
}
|
|
@@ -49,7 +49,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
49
49
|
return await next();
|
|
50
50
|
}
|
|
51
51
|
const auth = getAuthObject(ctx);
|
|
52
|
-
const { body, headers, query } = ctx.request;
|
|
52
|
+
const { body, headers, query, rawBody } = ctx.request;
|
|
53
53
|
const path = ctx.request.path.slice(routePrefix.length);
|
|
54
54
|
const method = ctx.request.method;
|
|
55
55
|
if (isProxyEnabledForPath(path, config) && proxyUrl) {
|
|
@@ -69,6 +69,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
69
69
|
path,
|
|
70
70
|
/* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */
|
|
71
71
|
query,
|
|
72
|
+
rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
|
|
72
73
|
req: { path: "", ...ctx.req },
|
|
73
74
|
});
|
|
74
75
|
ctx.body = response.body;
|
|
@@ -88,6 +89,11 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
if (response.appendedHeaders) {
|
|
93
|
+
for (const [key, value] of response.appendedHeaders) {
|
|
94
|
+
ctx.res.appendHeader(key, value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
91
97
|
ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
|
|
92
98
|
return undefined;
|
|
93
99
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
2
|
+
import createDebug from "debug";
|
|
3
|
+
const debug = createDebug("counterfact:server:load-openapi-document");
|
|
4
|
+
export async function loadOpenApiDocument(source) {
|
|
5
|
+
try {
|
|
6
|
+
return (await dereference(source));
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
debug("could not load OpenAPI document from %s: %o", source, error);
|
|
10
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
11
|
+
throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isContextModule(module) {
|
|
2
|
+
return "Context" in module && typeof module.Context === "function";
|
|
3
|
+
}
|
|
4
|
+
export function isMiddlewareModule(module) {
|
|
5
|
+
return ("middleware" in module &&
|
|
6
|
+
typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
|
|
7
|
+
"function");
|
|
8
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { dirname, resolve } from "node:path";
|
|
2
|
+
import createDebug from "debug";
|
|
2
3
|
import precinct from "precinct";
|
|
4
|
+
const debug = createDebug("counterfact:server:module-dependency-graph");
|
|
3
5
|
export class ModuleDependencyGraph {
|
|
4
6
|
dependents = new Map();
|
|
5
7
|
loadDependencies(path) {
|
|
6
8
|
try {
|
|
7
9
|
return precinct.paperwork(path);
|
|
8
10
|
}
|
|
9
|
-
catch {
|
|
11
|
+
catch (error) {
|
|
12
|
+
debug("could not load dependencies for %s: %o", path, error);
|
|
10
13
|
return [];
|
|
11
14
|
}
|
|
12
15
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import nodePath, { basename, dirname } from "node:path";
|
|
5
4
|
import { watch } from "chokidar";
|
|
@@ -7,33 +6,34 @@ import createDebug from "debug";
|
|
|
7
6
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
8
7
|
import { ContextRegistry } from "./context-registry.js";
|
|
9
8
|
import { determineModuleKind } from "./determine-module-kind.js";
|
|
9
|
+
import { FileDiscovery } from "./file-discovery.js";
|
|
10
|
+
import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
|
|
10
11
|
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
|
|
11
12
|
import { uncachedImport } from "./uncached-import.js";
|
|
13
|
+
import { unescapePathForWindows } from "../util/windows-escape.js";
|
|
12
14
|
const { uncachedRequire } = await import("./uncached-require.cjs");
|
|
13
15
|
const debug = createDebug("counterfact:server:module-loader");
|
|
14
|
-
import { escapePathForWindows, unescapePathForWindows, } from "../util/windows-escape.js";
|
|
15
|
-
function isContextModule(module) {
|
|
16
|
-
return "Context" in module && typeof module.Context === "function";
|
|
17
|
-
}
|
|
18
|
-
function isMiddlewareModule(module) {
|
|
19
|
-
return ("middleware" in module &&
|
|
20
|
-
typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
|
|
21
|
-
"function");
|
|
22
|
-
}
|
|
23
16
|
export class ModuleLoader extends EventTarget {
|
|
24
17
|
basePath;
|
|
25
18
|
registry;
|
|
26
19
|
watcher;
|
|
20
|
+
scenariosWatcher;
|
|
27
21
|
contextRegistry;
|
|
22
|
+
scenariosPath;
|
|
23
|
+
scenarioRegistry;
|
|
28
24
|
dependencyGraph = new ModuleDependencyGraph();
|
|
25
|
+
fileDiscovery;
|
|
29
26
|
uncachedImport = async function (moduleName) {
|
|
30
27
|
throw new Error(`uncachedImport not set up; importing ${moduleName}`);
|
|
31
28
|
};
|
|
32
|
-
constructor(basePath, registry, contextRegistry = new ContextRegistry()) {
|
|
29
|
+
constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
|
|
33
30
|
super();
|
|
34
31
|
this.basePath = basePath.replaceAll("\\", "/");
|
|
35
32
|
this.registry = registry;
|
|
36
33
|
this.contextRegistry = contextRegistry;
|
|
34
|
+
this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
|
|
35
|
+
this.scenarioRegistry = scenarioRegistry;
|
|
36
|
+
this.fileDiscovery = new FileDiscovery(this.basePath);
|
|
37
37
|
}
|
|
38
38
|
async watch() {
|
|
39
39
|
this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
@@ -42,7 +42,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
42
42
|
return;
|
|
43
43
|
const pathName = pathNameOriginal.replaceAll("\\", "/");
|
|
44
44
|
if (pathName.includes("$.context") && eventName === "add") {
|
|
45
|
-
process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/
|
|
45
|
+
process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`);
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
if (!["add", "change", "unlink"].includes(eventName)) {
|
|
@@ -55,6 +55,9 @@ export class ModuleLoader extends EventTarget {
|
|
|
55
55
|
if (eventName === "unlink") {
|
|
56
56
|
this.registry.remove(url);
|
|
57
57
|
this.dispatchEvent(new Event("remove"));
|
|
58
|
+
if (this.isContextFile(pathName)) {
|
|
59
|
+
this.contextRegistry.remove(unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/");
|
|
60
|
+
}
|
|
58
61
|
return;
|
|
59
62
|
}
|
|
60
63
|
const dependencies = this.dependencyGraph.dependentsOf(pathName);
|
|
@@ -64,32 +67,78 @@ export class ModuleLoader extends EventTarget {
|
|
|
64
67
|
}
|
|
65
68
|
});
|
|
66
69
|
await once(this.watcher, "ready");
|
|
70
|
+
if (this.scenariosPath && this.scenarioRegistry) {
|
|
71
|
+
const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
|
|
72
|
+
const scenariosPath = this.scenariosPath;
|
|
73
|
+
this.scenariosWatcher = watch(scenariosPath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
74
|
+
if (!JS_EXTENSIONS.some((ext) => pathNameOriginal.endsWith(`.${ext}`)))
|
|
75
|
+
return;
|
|
76
|
+
if (!["add", "change", "unlink"].includes(eventName))
|
|
77
|
+
return;
|
|
78
|
+
const pathName = pathNameOriginal.replaceAll("\\", "/");
|
|
79
|
+
if (eventName === "unlink") {
|
|
80
|
+
const fileKey = this.scenarioFileKey(pathName);
|
|
81
|
+
this.scenarioRegistry?.remove(fileKey);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
void this.loadScenarioFile(pathName);
|
|
85
|
+
});
|
|
86
|
+
await once(this.scenariosWatcher, "ready");
|
|
87
|
+
}
|
|
67
88
|
}
|
|
68
89
|
async stopWatching() {
|
|
69
90
|
await this.watcher?.close();
|
|
91
|
+
await this.scenariosWatcher?.close();
|
|
92
|
+
}
|
|
93
|
+
isContextFile(pathName) {
|
|
94
|
+
return basename(pathName).startsWith("_.context.");
|
|
70
95
|
}
|
|
71
96
|
async load(directory = "") {
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
const files = await this.fileDiscovery.findFiles(directory);
|
|
98
|
+
await Promise.all(files.map((file) => this.loadEndpoint(file)));
|
|
99
|
+
await this.loadScenarios();
|
|
100
|
+
}
|
|
101
|
+
shouldLoadScenarioFile(pathName) {
|
|
102
|
+
return !pathName.endsWith(".d.ts") && !pathName.endsWith(".map");
|
|
103
|
+
}
|
|
104
|
+
async loadScenarios() {
|
|
105
|
+
if (!this.scenariosPath || !this.scenarioRegistry)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const fileDiscovery = new FileDiscovery(this.scenariosPath);
|
|
109
|
+
const files = await fileDiscovery.findFiles();
|
|
110
|
+
const loadableFiles = files.filter((file) => this.shouldLoadScenarioFile(file));
|
|
111
|
+
await Promise.all(loadableFiles.map((file) => this.loadScenarioFile(file)));
|
|
74
112
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
catch {
|
|
114
|
+
// Scenarios directory does not exist yet — that's fine.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
scenarioFileKey(pathName) {
|
|
118
|
+
const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll("\\", "/");
|
|
119
|
+
const directory = dirname(pathName.slice(normalizedScenariosPath.length)).replaceAll("\\", "/");
|
|
120
|
+
const name = nodePath.parse(basename(pathName)).name;
|
|
121
|
+
const url = unescapePathForWindows(`/${nodePath.join(directory, name)}`
|
|
122
|
+
.replaceAll("\\", "/")
|
|
123
|
+
.replaceAll(/\/+/gu, "/"));
|
|
124
|
+
return url.slice(1); // strip leading "/"
|
|
125
|
+
}
|
|
126
|
+
async loadScenarioFile(pathName) {
|
|
127
|
+
if (!this.scenariosPath || !this.scenarioRegistry)
|
|
128
|
+
return;
|
|
129
|
+
const fileKey = this.scenarioFileKey(pathName);
|
|
130
|
+
try {
|
|
131
|
+
const doImport = (await determineModuleKind(pathName)) === "commonjs"
|
|
132
|
+
? uncachedRequire
|
|
133
|
+
: uncachedImport;
|
|
134
|
+
const module = await doImport(pathName);
|
|
135
|
+
if (module) {
|
|
136
|
+
this.scenarioRegistry.add(fileKey, module);
|
|
86
137
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
92
|
-
await Promise.all(imports);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
process.stdout.write(`\nError loading scenario ${pathName}:\n${String(error)}\n`);
|
|
141
|
+
}
|
|
93
142
|
}
|
|
94
143
|
async loadEndpoint(pathName) {
|
|
95
144
|
debug("importing module: %s", pathName);
|
|
@@ -137,8 +186,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
137
186
|
return;
|
|
138
187
|
}
|
|
139
188
|
this.dispatchEvent(new Event("add"));
|
|
140
|
-
if (
|
|
141
|
-
isContextModule(endpoint)) {
|
|
189
|
+
if (this.isContextFile(pathName) && isContextModule(endpoint)) {
|
|
142
190
|
const loadContext = (path) => this.contextRegistry.find(path);
|
|
143
191
|
const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
|
|
144
192
|
const readJson = async (relativePath) => {
|