counterfact 2.9.0 → 2.10.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 +36 -13
- package/dist/migrate/update-route-types.js +1 -1
- package/dist/server/counterfact-types/generic-response-builder.ts +1 -2
- package/dist/server/counterfact-types/open-api-parameters.ts +3 -0
- package/dist/server/dispatcher.js +45 -2
- package/dist/server/request-validator.js +42 -1
- package/dist/typescript-generator/code-generator.js +1 -1
- package/dist/typescript-generator/coder.js +4 -2
- package/dist/typescript-generator/operation-coder.js +4 -4
- package/dist/typescript-generator/operation-type-coder.js +15 -14
- package/dist/typescript-generator/parameter-export-type-coder.js +2 -2
- package/dist/typescript-generator/parameters-type-coder.js +3 -3
- package/dist/typescript-generator/response-type-coder.js +7 -6
- package/dist/typescript-generator/responses-type-coder.js +3 -3
- package/dist/typescript-generator/schema-coder.js +2 -2
- package/dist/typescript-generator/schema-type-coder.js +7 -5
- package/dist/typescript-generator/script.js +58 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,34 +1,57 @@
|
|
|
1
1
|
<div align="center" markdown="1">
|
|
2
2
|
|
|
3
|
-
<img src="./counterfact.svg" alt="Counterfact" border=0>
|
|
3
|
+
<h1><img src="./counterfact.svg" alt="Counterfact" border=0></h1>
|
|
4
4
|
|
|
5
5
|
<br>
|
|
6
6
|
|
|
7
|
-
 [ [](https://coveralls.io/github/pmcelhaney/counterfact) 
|
|
8
8
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
<div align="center" markdown="1">
|
|
12
|
+
<h2>Mock servers work—until you need state, failures, or control mid-run.</h2>
|
|
13
|
+
</div
|
|
14
|
+
|
|
15
|
+
Static responses aren’t enough. There’s no shared state. You can’t inject failures. You can’t test real workflows.<br>
|
|
16
|
+
Mock servers make it easy to get started, but hard to keep going.<br>
|
|
17
|
+
Counterfact is an API simulator without those limits.
|
|
18
|
+
|
|
19
|
+
Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
|
|
20
|
+
- Type-safe TypeScript handlers for every endpoint
|
|
21
|
+
- Hot reloading as you edit
|
|
22
|
+
- Shared state across routes
|
|
23
|
+
- **A built-in REPL to control behavior at runtime**
|
|
24
|
+
- Optional proxying to real backends
|
|
25
|
+
|
|
26
|
+
Flexbile for humans. Stable for [AI agents](https://github.com/counterfact/api-simulator/blob/main/docs/patterns/ai-assisted-implementation.md).
|
|
27
|
+
|
|
28
|
+
You’re in control—without restarting.
|
|
29
|
+
|
|
30
|
+
For a *frontend developer* waiting on a backend,<br>
|
|
31
|
+
a *test engineer* who needs clean, reproducible state,<br>
|
|
32
|
+
or an *AI agent* that needs a stable API
|
|
33
|
+
|
|
34
|
+
Real enough to be useful. Fake enough to be usable.
|
|
35
|
+
|
|
12
36
|
|
|
13
|
-
|
|
37
|
+
## Try it now
|
|
14
38
|
|
|
15
39
|
```sh
|
|
16
40
|
npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
|
|
17
41
|
```
|
|
18
42
|
|
|
43
|
+
> Starts a local server with a live REPL to inspect and control API behavior
|
|
19
44
|
> Requires Node ≥ 22.0.0
|
|
20
45
|
|
|
21
46
|
## Go deeper
|
|
22
47
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
| [FAQ](./docs/faq.md) | State, types, regeneration |
|
|
31
|
-
| [Petstore example](https://github.com/counterfact/example-petstore) | Full working example |
|
|
48
|
+
- [Getting started](./docs/getting-started.md) – Detailed walkthrough with state, REPL, and proxy
|
|
49
|
+
- [Patterns](./docs/patterns/index.md) – How Counterfact transforms your workflow
|
|
50
|
+
- [Example repo](https://github.com/counterfact/example-petstore) – Using Counterfact to implement the Swagger Petstore
|
|
51
|
+
- [How it compares](./docs/comparison.md) – json-server, WireMock, Prism, Microcks, MSW
|
|
52
|
+
- [Usage](./docs/usage.md) – Explore features and how to use them
|
|
53
|
+
- [Reference](./docs/reference.md) – `$` API, CLI flags, architecture
|
|
54
|
+
- [FAQ](./docs/faq.md) – State, types, regeneration
|
|
32
55
|
|
|
33
56
|
<div align="center" markdown="1">
|
|
34
57
|
|
|
@@ -67,7 +67,7 @@ async function buildTypeNameMapping(specification) {
|
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
// Create the type coder to get the correct type name
|
|
70
|
-
const typeCoder = new OperationTypeCoder(operation, requestMethod, securitySchemes);
|
|
70
|
+
const typeCoder = new OperationTypeCoder(operation, "", requestMethod, securitySchemes);
|
|
71
71
|
// Get the type name (first from the names generator)
|
|
72
72
|
const typeName = typeCoder.names().next().value;
|
|
73
73
|
methodMap.set(requestMethod.toUpperCase(), typeName);
|
|
@@ -157,8 +157,7 @@ export type GenericResponseBuilder<
|
|
|
157
157
|
: object extends OmitValueWhenNever<Omit<Response, "examples">>
|
|
158
158
|
? COUNTERFACT_RESPONSE
|
|
159
159
|
: keyof OmitValueWhenNever<Omit<Response, "examples">> extends "headers"
|
|
160
|
-
? {
|
|
161
|
-
ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE;
|
|
160
|
+
? COUNTERFACT_RESPONSE & {
|
|
162
161
|
header: HeaderFunction<Response>;
|
|
163
162
|
}
|
|
164
163
|
: GenericResponseBuilderInner<Response>;
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* argument object.
|
|
6
6
|
*/
|
|
7
7
|
export interface OpenApiParameters {
|
|
8
|
+
explode?: boolean;
|
|
8
9
|
in: "body" | "cookie" | "formData" | "header" | "path" | "query";
|
|
9
10
|
name: string;
|
|
10
11
|
required?: boolean;
|
|
11
12
|
schema?: {
|
|
12
13
|
[key: string]: unknown;
|
|
14
|
+
properties?: Record<string, unknown>;
|
|
13
15
|
type?: string;
|
|
14
16
|
};
|
|
17
|
+
style?: string;
|
|
15
18
|
type?: "string" | "number" | "integer" | "boolean";
|
|
16
19
|
}
|
|
@@ -2,7 +2,7 @@ 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";
|
|
5
|
+
import { isExplodedObjectQueryParam, validateRequest, } from "./request-validator.js";
|
|
6
6
|
import { validateResponse } from "./response-validator.js";
|
|
7
7
|
import { Tools } from "./tools.js";
|
|
8
8
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
@@ -36,6 +36,45 @@ function parseCookies(cookieHeader) {
|
|
|
36
36
|
}
|
|
37
37
|
return cookies;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Gathers exploded object query parameters back into a nested object under the
|
|
41
|
+
* parameter's own name.
|
|
42
|
+
*
|
|
43
|
+
* Per OpenAPI 3.x, a query parameter of type `object` with `style: form` and
|
|
44
|
+
* `explode: true` (both defaults for query params) is serialised as individual
|
|
45
|
+
* query parameters — one per object property. This function reconstructs the
|
|
46
|
+
* object so that the route handler can access `$.query.<paramName>` rather than
|
|
47
|
+
* only the individual flat parameters.
|
|
48
|
+
*
|
|
49
|
+
* Properties that are "claimed" by an object parameter are removed from the
|
|
50
|
+
* top-level map and placed under the parameter name. Unclaimed keys remain at
|
|
51
|
+
* the top level.
|
|
52
|
+
*
|
|
53
|
+
* @param query - The raw parsed query-string map from the HTTP request.
|
|
54
|
+
* @param parameters - The OpenAPI parameter definitions for the current operation.
|
|
55
|
+
* @returns A new map with exploded object parameters reconstructed as nested objects.
|
|
56
|
+
*/
|
|
57
|
+
export function collectExplodedObjectParams(query, parameters) {
|
|
58
|
+
const result = { ...query };
|
|
59
|
+
for (const parameter of parameters) {
|
|
60
|
+
if (!isExplodedObjectQueryParam(parameter))
|
|
61
|
+
continue;
|
|
62
|
+
const properties = parameter.schema?.properties;
|
|
63
|
+
if (!properties)
|
|
64
|
+
continue;
|
|
65
|
+
const obj = {};
|
|
66
|
+
for (const key of Object.keys(properties)) {
|
|
67
|
+
if (key in result) {
|
|
68
|
+
obj[key] = result[key];
|
|
69
|
+
delete result[key];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(obj).length > 0) {
|
|
73
|
+
result[parameter.name] = obj;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
39
78
|
/**
|
|
40
79
|
* Core HTTP request dispatcher.
|
|
41
80
|
*
|
|
@@ -208,6 +247,9 @@ export class Dispatcher {
|
|
|
208
247
|
};
|
|
209
248
|
}
|
|
210
249
|
}
|
|
250
|
+
// Reconstruct exploded object query parameters so that `$.query.<name>`
|
|
251
|
+
// contains the assembled object instead of only the individual flat keys.
|
|
252
|
+
const processedQuery = collectExplodedObjectParams(query, operation?.parameters ?? []);
|
|
211
253
|
const continuousDistribution = (min, max) => {
|
|
212
254
|
return min + Math.random() * (max - min);
|
|
213
255
|
};
|
|
@@ -238,7 +280,8 @@ export class Dispatcher {
|
|
|
238
280
|
status: fetchResponse.status,
|
|
239
281
|
};
|
|
240
282
|
},
|
|
241
|
-
|
|
283
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- object params are reconstructed and typed by generated route types
|
|
284
|
+
query: processedQuery,
|
|
242
285
|
// @ts-expect-error - Might be pushing the limits of what TypeScript can do here
|
|
243
286
|
response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
|
|
244
287
|
tools: new Tools({ headers }),
|
|
@@ -4,10 +4,51 @@ const ajv = new Ajv({
|
|
|
4
4
|
strict: false,
|
|
5
5
|
coerceTypes: false,
|
|
6
6
|
});
|
|
7
|
+
/**
|
|
8
|
+
* Returns `true` when a query parameter should be serialized as exploded form
|
|
9
|
+
* style — meaning its object properties appear as individual query parameters
|
|
10
|
+
* instead of being passed under the parameter's own name.
|
|
11
|
+
*
|
|
12
|
+
* Per OpenAPI 3.x: for `in: query`, the default `style` is `form` and the
|
|
13
|
+
* default `explode` for `form` is `true`. An object-type parameter with these
|
|
14
|
+
* defaults (or with them set explicitly) uses exploded form serialization.
|
|
15
|
+
*/
|
|
16
|
+
export function isExplodedObjectQueryParam(parameter) {
|
|
17
|
+
if (parameter.in !== "query")
|
|
18
|
+
return false;
|
|
19
|
+
const schema = parameter.schema;
|
|
20
|
+
if (!schema)
|
|
21
|
+
return false;
|
|
22
|
+
// Must be an object type (explicit type or implied by presence of properties)
|
|
23
|
+
const isObjectType = schema.type === "object" || schema.properties !== undefined;
|
|
24
|
+
if (!isObjectType)
|
|
25
|
+
return false;
|
|
26
|
+
// style must be "form" (the default for query params) or unset
|
|
27
|
+
if (parameter.style !== undefined && parameter.style !== "form")
|
|
28
|
+
return false;
|
|
29
|
+
// explode must not be explicitly false (default for form style is true)
|
|
30
|
+
if (parameter.explode === false)
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
7
34
|
function findMissingRequired(parameters, location, values) {
|
|
8
35
|
return parameters
|
|
9
36
|
.filter((p) => p.in === location && p.required === true)
|
|
10
|
-
.filter((p) =>
|
|
37
|
+
.filter((p) => {
|
|
38
|
+
// For exploded object query params the individual properties appear as
|
|
39
|
+
// separate query parameters, so we check for those instead of the name.
|
|
40
|
+
if (location === "query" && isExplodedObjectQueryParam(p)) {
|
|
41
|
+
const properties = p.schema?.properties;
|
|
42
|
+
if (!properties) {
|
|
43
|
+
// Free-form object with no declared properties: treat as always
|
|
44
|
+
// satisfied — we cannot know which keys belong to the parameter.
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// The parameter is "missing" only when none of its properties are present.
|
|
48
|
+
return !Object.keys(properties).some((key) => key in values);
|
|
49
|
+
}
|
|
50
|
+
return !(p.name in values) || values[p.name] === undefined;
|
|
51
|
+
})
|
|
11
52
|
.map((p) => `${location} parameter '${p.name}' is required`);
|
|
12
53
|
}
|
|
13
54
|
export function validateRequest(operation, request) {
|
|
@@ -116,7 +116,7 @@ export class CodeGenerator extends EventTarget {
|
|
|
116
116
|
}
|
|
117
117
|
repository
|
|
118
118
|
.get(`routes${path}.ts`)
|
|
119
|
-
.export(new OperationCoder(operation, requestMethod, securitySchemes));
|
|
119
|
+
.export(new OperationCoder(operation, "", requestMethod, securitySchemes));
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
debug("telling the repository to write the files to %s", destination);
|
|
@@ -12,8 +12,10 @@ import { RESERVED_WORDS } from "./reserved-words.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export class Coder {
|
|
14
14
|
requirement;
|
|
15
|
-
|
|
15
|
+
version;
|
|
16
|
+
constructor(requirement, version = "") {
|
|
16
17
|
this.requirement = requirement;
|
|
18
|
+
this.version = version;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* A stable cache key for this coder, composed of the constructor name and
|
|
@@ -84,7 +86,7 @@ export class Coder {
|
|
|
84
86
|
return this;
|
|
85
87
|
}
|
|
86
88
|
const requirement = await this.requirement.reference();
|
|
87
|
-
return new this.constructor(requirement);
|
|
89
|
+
return new this.constructor(requirement, this.version);
|
|
88
90
|
}
|
|
89
91
|
/**
|
|
90
92
|
* Generator that yields candidate export names for this coder.
|
|
@@ -14,9 +14,9 @@ import { OperationTypeCoder, } from "./operation-type-coder.js";
|
|
|
14
14
|
export class OperationCoder extends Coder {
|
|
15
15
|
requestMethod;
|
|
16
16
|
securitySchemes;
|
|
17
|
-
constructor(requirement, requestMethod, securitySchemes = []) {
|
|
18
|
-
super(requirement);
|
|
19
|
-
if (requestMethod ===
|
|
17
|
+
constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
|
|
18
|
+
super(requirement, version);
|
|
19
|
+
if (requestMethod === "") {
|
|
20
20
|
throw new Error("requestMethod is required");
|
|
21
21
|
}
|
|
22
22
|
this.requestMethod = requestMethod;
|
|
@@ -40,7 +40,7 @@ export class OperationCoder extends Coder {
|
|
|
40
40
|
}`;
|
|
41
41
|
}
|
|
42
42
|
typeDeclaration(_namespace, script) {
|
|
43
|
-
const operationTypeCoder = new OperationTypeCoder(this.requirement, this.requestMethod, this.securitySchemes);
|
|
43
|
+
const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
|
|
44
44
|
return script.importType(operationTypeCoder);
|
|
45
45
|
}
|
|
46
46
|
modulePath() {
|
|
@@ -36,9 +36,9 @@ function sanitizeIdentifier(value) {
|
|
|
36
36
|
export class OperationTypeCoder extends TypeCoder {
|
|
37
37
|
requestMethod;
|
|
38
38
|
securitySchemes;
|
|
39
|
-
constructor(requirement, requestMethod, securitySchemes = []) {
|
|
40
|
-
super(requirement);
|
|
41
|
-
if (requestMethod ===
|
|
39
|
+
constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
|
|
40
|
+
super(requirement, version);
|
|
41
|
+
if (requestMethod === "") {
|
|
42
42
|
throw new Error("requestMethod is required");
|
|
43
43
|
}
|
|
44
44
|
this.requestMethod = requestMethod;
|
|
@@ -79,7 +79,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
79
79
|
}
|
|
80
80
|
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
|
81
81
|
const typeName = `${baseName}_${capitalize(parameterKind)}`;
|
|
82
|
-
const coder = new ParameterExportTypeCoder(this.requirement, typeName, inlineType, parameterKind);
|
|
82
|
+
const coder = new ParameterExportTypeCoder(this.requirement, this.version, typeName, inlineType, parameterKind);
|
|
83
83
|
coder._modulePath = modulePath;
|
|
84
84
|
return script.export(coder, true);
|
|
85
85
|
}
|
|
@@ -99,7 +99,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
99
99
|
return response.get("content").map((content, contentType) => `{
|
|
100
100
|
status: ${status},
|
|
101
101
|
contentType?: "${contentType}",
|
|
102
|
-
body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema")).write(script) : "unknown"}
|
|
102
|
+
body?: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
|
|
103
103
|
}`);
|
|
104
104
|
}
|
|
105
105
|
if (response.has("schema")) {
|
|
@@ -111,7 +111,7 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
111
111
|
.map((contentType) => `{
|
|
112
112
|
status: ${status},
|
|
113
113
|
contentType?: "${contentType}",
|
|
114
|
-
body?: ${new SchemaTypeCoder(response.get("schema")).write(script)}
|
|
114
|
+
body?: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
|
|
115
115
|
}`)
|
|
116
116
|
.join(" | ");
|
|
117
117
|
}
|
|
@@ -149,10 +149,10 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
149
149
|
script.importSharedType("COUNTERFACT_RESPONSE");
|
|
150
150
|
const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
|
|
151
151
|
const parameters = this.requirement.get("parameters");
|
|
152
|
-
const queryType = new ParametersTypeCoder(parameters, "query").write(script);
|
|
153
|
-
const pathType = new ParametersTypeCoder(parameters, "path").write(script);
|
|
154
|
-
const headersType = new ParametersTypeCoder(parameters, "header").write(script);
|
|
155
|
-
const cookieType = new ParametersTypeCoder(parameters, "cookie").write(script);
|
|
152
|
+
const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
|
|
153
|
+
const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
|
|
154
|
+
const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
|
|
155
|
+
const cookieType = new ParametersTypeCoder(parameters, this.version, "cookie").write(script);
|
|
156
156
|
const bodyRequirement = (this.requirement.get("consumes") ??
|
|
157
157
|
this.requirement.specification?.rootRequirement?.get("consumes"))
|
|
158
158
|
? parameters
|
|
@@ -161,10 +161,11 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
161
161
|
: this.requirement.select("requestBody/content/application~1json/schema");
|
|
162
162
|
const bodyType = bodyRequirement === undefined
|
|
163
163
|
? "never"
|
|
164
|
-
: new SchemaTypeCoder(bodyRequirement).write(script);
|
|
165
|
-
const
|
|
164
|
+
: new SchemaTypeCoder(bodyRequirement, this.version).write(script);
|
|
165
|
+
const openApi2MediaTypes = (this.requirement.get("produces")?.data ??
|
|
166
166
|
this.requirement.specification?.rootRequirement?.get("produces")
|
|
167
|
-
?.data)
|
|
167
|
+
?.data);
|
|
168
|
+
const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
|
|
168
169
|
const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
|
|
169
170
|
const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
|
|
170
171
|
// Get the base name for this operation and export parameter types
|
|
@@ -174,6 +175,6 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
174
175
|
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
|
|
175
176
|
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
|
|
176
177
|
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
|
|
177
|
-
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
|
|
178
|
+
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<COUNTERFACT_RESPONSE>`;
|
|
178
179
|
}
|
|
179
180
|
}
|
|
@@ -4,8 +4,8 @@ export class ParameterExportTypeCoder extends TypeCoder {
|
|
|
4
4
|
_typeCode;
|
|
5
5
|
_parameterKind;
|
|
6
6
|
_modulePath;
|
|
7
|
-
constructor(requirement, typeName, typeCode, parameterKind) {
|
|
8
|
-
super(requirement);
|
|
7
|
+
constructor(requirement, version, typeName, typeCode, parameterKind) {
|
|
8
|
+
super(requirement, version);
|
|
9
9
|
this._typeName = typeName;
|
|
10
10
|
this._typeCode = typeCode;
|
|
11
11
|
this._parameterKind = parameterKind;
|
|
@@ -4,8 +4,8 @@ import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
|
4
4
|
import { TypeCoder } from "./type-coder.js";
|
|
5
5
|
export class ParametersTypeCoder extends TypeCoder {
|
|
6
6
|
placement;
|
|
7
|
-
constructor(requirement, placement) {
|
|
8
|
-
super(requirement);
|
|
7
|
+
constructor(requirement, version = "", placement = "") {
|
|
8
|
+
super(requirement, version);
|
|
9
9
|
this.placement = placement;
|
|
10
10
|
}
|
|
11
11
|
names() {
|
|
@@ -26,7 +26,7 @@ export class ParametersTypeCoder extends TypeCoder {
|
|
|
26
26
|
: parameter;
|
|
27
27
|
const comment = buildJsDoc(parameter.data);
|
|
28
28
|
const commentPrefix = comment ? `\n${comment}` : "";
|
|
29
|
-
const typeString = new SchemaTypeCoder(schema).write(script);
|
|
29
|
+
const typeString = new SchemaTypeCoder(schema, this.version).write(script);
|
|
30
30
|
return `${commentPrefix}"${name}"${optionalFlag}: ${typeString}`;
|
|
31
31
|
});
|
|
32
32
|
if (typeDefinitions.length === 0) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { printObject } from "./printers.js";
|
|
2
2
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
3
3
|
import { TypeCoder } from "./type-coder.js";
|
|
4
|
+
import { pathJoin } from "../util/forward-slash-path.js";
|
|
4
5
|
export class ResponseTypeCoder extends TypeCoder {
|
|
5
6
|
openApi2MediaTypes;
|
|
6
|
-
constructor(requirement, openApi2MediaTypes = []) {
|
|
7
|
-
super(requirement);
|
|
7
|
+
constructor(requirement, version = "", openApi2MediaTypes = []) {
|
|
8
|
+
super(requirement, version);
|
|
8
9
|
this.openApi2MediaTypes = openApi2MediaTypes;
|
|
9
10
|
}
|
|
10
11
|
names() {
|
|
@@ -17,14 +18,14 @@ export class ResponseTypeCoder extends TypeCoder {
|
|
|
17
18
|
.map((content, mediaType) => [
|
|
18
19
|
mediaType,
|
|
19
20
|
`{
|
|
20
|
-
schema: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema")).write(script) : "unknown"}
|
|
21
|
+
schema: ${content.has("schema") ? new SchemaTypeCoder(content.get("schema"), this.version).write(script) : "unknown"}
|
|
21
22
|
}`,
|
|
22
23
|
]);
|
|
23
24
|
}
|
|
24
25
|
return this.openApi2MediaTypes.map((mediaType) => [
|
|
25
26
|
mediaType,
|
|
26
27
|
`{
|
|
27
|
-
schema: ${new SchemaTypeCoder(response.get("schema")).write(script)}
|
|
28
|
+
schema: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
|
|
28
29
|
}`,
|
|
29
30
|
]);
|
|
30
31
|
}
|
|
@@ -39,7 +40,7 @@ export class ResponseTypeCoder extends TypeCoder {
|
|
|
39
40
|
.get("headers")
|
|
40
41
|
.map((value, name) => [
|
|
41
42
|
name,
|
|
42
|
-
`{ schema: ${new SchemaTypeCoder(value.get("schema") ?? value).write(script)}}`,
|
|
43
|
+
`{ schema: ${new SchemaTypeCoder(value.get("schema") ?? value, this.version).write(script)}}`,
|
|
43
44
|
]);
|
|
44
45
|
}
|
|
45
46
|
printHeaders(script, response) {
|
|
@@ -77,7 +78,7 @@ export class ResponseTypeCoder extends TypeCoder {
|
|
|
77
78
|
return printObject(exampleNames.map((name) => [name, "unknown"]));
|
|
78
79
|
}
|
|
79
80
|
modulePath() {
|
|
80
|
-
return
|
|
81
|
+
return pathJoin("types", this.version, this.requirement.data["$ref"] + ".ts");
|
|
81
82
|
}
|
|
82
83
|
writeCode(script) {
|
|
83
84
|
return `{
|
|
@@ -3,8 +3,8 @@ import { ResponseTypeCoder } from "./response-type-coder.js";
|
|
|
3
3
|
import { TypeCoder } from "./type-coder.js";
|
|
4
4
|
export class ResponsesTypeCoder extends TypeCoder {
|
|
5
5
|
openApi2MediaTypes;
|
|
6
|
-
constructor(requirement, openApi2MediaTypes = []) {
|
|
7
|
-
super(requirement);
|
|
6
|
+
constructor(requirement, version = "", openApi2MediaTypes = []) {
|
|
7
|
+
super(requirement, version);
|
|
8
8
|
this.openApi2MediaTypes = openApi2MediaTypes;
|
|
9
9
|
}
|
|
10
10
|
typeForDefaultStatusCode(listedStatusCodes) {
|
|
@@ -23,7 +23,7 @@ export class ResponsesTypeCoder extends TypeCoder {
|
|
|
23
23
|
buildResponseObjectType(script) {
|
|
24
24
|
return printObjectWithoutQuotes(this.requirement.map((response, responseCode) => [
|
|
25
25
|
this.normalizeStatusCode(responseCode),
|
|
26
|
-
new ResponseTypeCoder(response, this.openApi2MediaTypes).write(script),
|
|
26
|
+
new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
|
|
27
27
|
]));
|
|
28
28
|
}
|
|
29
29
|
writeCode(script) {
|
|
@@ -13,7 +13,7 @@ export class SchemaCoder extends Coder {
|
|
|
13
13
|
objectSchema(script) {
|
|
14
14
|
const { properties, required } = this.requirement.data;
|
|
15
15
|
const propertyLines = Object.keys(properties ?? {}).map((name) => {
|
|
16
|
-
const schemaCoder = new SchemaCoder(this.requirement.select(`properties/${name}`));
|
|
16
|
+
const schemaCoder = new SchemaCoder(this.requirement.select(`properties/${name}`), this.version);
|
|
17
17
|
return `"${name}": ${schemaCoder.write(script)}`;
|
|
18
18
|
});
|
|
19
19
|
return `
|
|
@@ -27,7 +27,7 @@ export class SchemaCoder extends Coder {
|
|
|
27
27
|
arraySchema(script) {
|
|
28
28
|
return `{
|
|
29
29
|
type: "array",
|
|
30
|
-
items: ${new SchemaCoder(this.requirement.get("items")).write(script)}
|
|
30
|
+
items: ${new SchemaCoder(this.requirement.get("items"), this.version).write(script)}
|
|
31
31
|
}`;
|
|
32
32
|
}
|
|
33
33
|
typeDeclaration(_namespace, script) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildJsDoc } from "./jsdoc.js";
|
|
2
2
|
import { TypeCoder } from "./type-coder.js";
|
|
3
|
+
import { pathJoin } from "../util/forward-slash-path.js";
|
|
3
4
|
export class SchemaTypeCoder extends TypeCoder {
|
|
4
5
|
names() {
|
|
5
6
|
return super.names(this.requirement.data["$ref"]?.split("/").at(-1));
|
|
@@ -16,7 +17,7 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
16
17
|
return "unknown";
|
|
17
18
|
}
|
|
18
19
|
const requirement = this.requirement.get("additionalProperties");
|
|
19
|
-
return new SchemaTypeCoder(requirement).write(script);
|
|
20
|
+
return new SchemaTypeCoder(requirement, this.version).write(script);
|
|
20
21
|
}
|
|
21
22
|
objectSchema(script) {
|
|
22
23
|
const { data } = this.requirement;
|
|
@@ -28,7 +29,8 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
28
29
|
const optionalFlag = isRequired ? "" : "?";
|
|
29
30
|
const comment = buildJsDoc(property.data);
|
|
30
31
|
const commentPrefix = comment ? `\n${comment}` : "";
|
|
31
|
-
|
|
32
|
+
const propertyType = new SchemaTypeCoder(property, this.version).write(script);
|
|
33
|
+
return `${commentPrefix}"${name}"${optionalFlag}: ${propertyType}`;
|
|
32
34
|
});
|
|
33
35
|
if (typedData.additionalProperties) {
|
|
34
36
|
properties.push(`[key: string]: ${this.additionalPropertiesType(script)}`);
|
|
@@ -36,7 +38,7 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
36
38
|
return `{${properties.join(",")}}`;
|
|
37
39
|
}
|
|
38
40
|
arraySchema(script) {
|
|
39
|
-
return `Array<${new SchemaTypeCoder(this.requirement.get("items")).write(script)}>`;
|
|
41
|
+
return `Array<${new SchemaTypeCoder(this.requirement.get("items"), this.version).write(script)}>`;
|
|
40
42
|
}
|
|
41
43
|
writePrimitive(value) {
|
|
42
44
|
if (typeof value === "string") {
|
|
@@ -79,7 +81,7 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
79
81
|
}
|
|
80
82
|
const key = matchingKey();
|
|
81
83
|
const items = (allOf ?? anyOf ?? oneOf);
|
|
82
|
-
const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index)).write(script));
|
|
84
|
+
const types = items.map((_item, index) => new SchemaTypeCoder(this.requirement.get(key).get(index), this.version).write(script));
|
|
83
85
|
return types.join(allOf ? " & " : " | ");
|
|
84
86
|
}
|
|
85
87
|
writeEnum(_script, requirement) {
|
|
@@ -88,7 +90,7 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
88
90
|
.join(" | ");
|
|
89
91
|
}
|
|
90
92
|
modulePath() {
|
|
91
|
-
return
|
|
93
|
+
return pathJoin("types", this.version, this.requirement.data["$ref"].replace(/^#\//u, "") + ".ts");
|
|
92
94
|
}
|
|
93
95
|
writeCode(script) {
|
|
94
96
|
const { allOf, anyOf, oneOf, type, format } = this.requirement.data;
|
|
@@ -17,6 +17,7 @@ export class Script {
|
|
|
17
17
|
repository;
|
|
18
18
|
comments;
|
|
19
19
|
exports;
|
|
20
|
+
versions;
|
|
20
21
|
imports;
|
|
21
22
|
externalImport;
|
|
22
23
|
cache;
|
|
@@ -26,6 +27,7 @@ export class Script {
|
|
|
26
27
|
this.repository = repository;
|
|
27
28
|
this.comments = [];
|
|
28
29
|
this.exports = new Map();
|
|
30
|
+
this.versions = new Map();
|
|
29
31
|
this.imports = new Map();
|
|
30
32
|
this.externalImport = new Map();
|
|
31
33
|
this.cache = new Map();
|
|
@@ -176,13 +178,53 @@ export class Script {
|
|
|
176
178
|
exportType(coder) {
|
|
177
179
|
return this.export(coder, true);
|
|
178
180
|
}
|
|
181
|
+
declareVersion(coder, name) {
|
|
182
|
+
const version = coder.version;
|
|
183
|
+
const versions = this.versions.get(name) ?? new Map();
|
|
184
|
+
this.versions.set(name, versions);
|
|
185
|
+
if (versions.has(version)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const versionStatement = {
|
|
189
|
+
beforeExport: "",
|
|
190
|
+
done: false,
|
|
191
|
+
id: coder.id,
|
|
192
|
+
isDefault: false,
|
|
193
|
+
isType: true,
|
|
194
|
+
jsdoc: "",
|
|
195
|
+
typeDeclaration: "",
|
|
196
|
+
};
|
|
197
|
+
versionStatement.promise = coder
|
|
198
|
+
.delegate()
|
|
199
|
+
.then((availableCoder) => {
|
|
200
|
+
versionStatement.code = availableCoder.write(this);
|
|
201
|
+
return availableCoder;
|
|
202
|
+
})
|
|
203
|
+
.catch((error) => {
|
|
204
|
+
versionStatement.code = `unknown /* error declaring version "${name}" (${version}) for ${this.path}: ${error.message} */`;
|
|
205
|
+
versionStatement.error = error;
|
|
206
|
+
return undefined;
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
versionStatement.done = true;
|
|
210
|
+
});
|
|
211
|
+
versions.set(version, versionStatement);
|
|
212
|
+
}
|
|
179
213
|
/** `true` while at least one export promise is still pending. */
|
|
180
214
|
isInProgress() {
|
|
181
|
-
return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done)
|
|
215
|
+
return (Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done) ||
|
|
216
|
+
Array.from(this.versions.values())
|
|
217
|
+
.flatMap((versions) => Array.from(versions.values()))
|
|
218
|
+
.some((versionStatement) => !versionStatement.done));
|
|
182
219
|
}
|
|
183
220
|
/** Returns a promise that resolves when all pending export promises settle. */
|
|
184
221
|
finished() {
|
|
185
|
-
return Promise.all(
|
|
222
|
+
return Promise.all([
|
|
223
|
+
...Array.from(this.exports.values(), (value) => value.promise),
|
|
224
|
+
...Array.from(this.versions.values())
|
|
225
|
+
.flatMap((versions) => Array.from(versions.values()))
|
|
226
|
+
.map((value) => value.promise),
|
|
227
|
+
]);
|
|
186
228
|
}
|
|
187
229
|
externalImportStatements() {
|
|
188
230
|
return Array.from(this.externalImport, ([name, { isDefault, isType, modulePath }]) => `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${modulePath}";`);
|
|
@@ -208,18 +250,31 @@ export class Script {
|
|
|
208
250
|
return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
|
|
209
251
|
});
|
|
210
252
|
}
|
|
253
|
+
versionsTypeStatements() {
|
|
254
|
+
if (this.versions.size === 0) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
const names = Array.from(this.versions, ([name, versions]) => {
|
|
258
|
+
const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
|
|
259
|
+
return `"${name}": { ${mappedVersions.join(", ")} }`;
|
|
260
|
+
});
|
|
261
|
+
return [`export type Versions = { ${names.join(", ")} };`];
|
|
262
|
+
}
|
|
211
263
|
/**
|
|
212
264
|
* Formats the fully assembled script source with Prettier and returns it.
|
|
213
265
|
*
|
|
214
266
|
* All pending export promises are awaited before formatting.
|
|
215
267
|
*/
|
|
216
|
-
contents() {
|
|
268
|
+
async contents() {
|
|
269
|
+
await this.finished();
|
|
217
270
|
return format([
|
|
218
271
|
this.comments.map((comment) => `// ${comment}`).join("\n"),
|
|
219
272
|
this.comments.length > 0 ? "\n\n" : "",
|
|
220
273
|
this.externalImportStatements().join("\n"),
|
|
221
274
|
this.importStatements().join("\n"),
|
|
222
275
|
"\n\n",
|
|
276
|
+
this.versionsTypeStatements().join("\n"),
|
|
277
|
+
this.versions.size > 0 ? "\n\n" : "",
|
|
223
278
|
this.exportStatements().join("\n\n"),
|
|
224
279
|
].join(""), { parser: "typescript" });
|
|
225
280
|
}
|
package/package.json
CHANGED