counterfact 2.10.0 → 2.11.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/dist/api-runner.js +19 -7
- package/dist/app.js +119 -15
- package/dist/cli/banner.js +1 -1
- package/dist/cli/run.js +10 -4
- package/dist/cli/telemetry.js +1 -6
- package/dist/repl/repl.js +1 -4
- package/dist/server/dispatcher.js +76 -12
- package/dist/server/web-server/admin-api-middleware.js +1 -1
- package/dist/typescript-generator/code-generator.js +4 -2
- package/dist/typescript-generator/operation-coder.js +9 -1
- package/dist/typescript-generator/operation-type-coder.js +161 -8
- package/dist/typescript-generator/requirement.js +12 -1
- package/dist/typescript-generator/responses-type-coder.js +8 -2
- package/dist/typescript-generator/script.js +46 -5
- package/dist/typescript-generator/versions-ts-generator.js +57 -0
- package/package.json +1 -1
package/dist/api-runner.js
CHANGED
|
@@ -64,6 +64,11 @@ export class ApiRunner {
|
|
|
64
64
|
* Defaults to `""` (no subdirectory).
|
|
65
65
|
*/
|
|
66
66
|
group;
|
|
67
|
+
/**
|
|
68
|
+
* Optional version label for this runner's spec (e.g. `"v1"`, `"v2"`).
|
|
69
|
+
* Defaults to `""` (unversioned).
|
|
70
|
+
*/
|
|
71
|
+
version;
|
|
67
72
|
/**
|
|
68
73
|
* The subdirectory path segment derived from {@link group}.
|
|
69
74
|
* Returns `""` when `group` is empty, otherwise `"/${group}"`.
|
|
@@ -72,8 +77,9 @@ export class ApiRunner {
|
|
|
72
77
|
return this.group ? `/${this.group}` : "";
|
|
73
78
|
}
|
|
74
79
|
config;
|
|
75
|
-
constructor(config, nativeTs, openApiDocument, group) {
|
|
80
|
+
constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
|
|
76
81
|
this.group = group;
|
|
82
|
+
this.version = version;
|
|
77
83
|
const modulesPath = this.group
|
|
78
84
|
? pathJoin(config.basePath, this.group)
|
|
79
85
|
: config.basePath;
|
|
@@ -87,8 +93,8 @@ export class ApiRunner {
|
|
|
87
93
|
this.contextRegistry = new ContextRegistry();
|
|
88
94
|
this.scenarioRegistry = new ScenarioRegistry();
|
|
89
95
|
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
|
|
90
|
-
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate);
|
|
91
|
-
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config);
|
|
96
|
+
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version);
|
|
97
|
+
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
|
|
92
98
|
this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
|
|
93
99
|
this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
|
|
94
100
|
}
|
|
@@ -101,8 +107,10 @@ export class ApiRunner {
|
|
|
101
107
|
*
|
|
102
108
|
* @param config - Runtime configuration for this runner instance.
|
|
103
109
|
* @param group - Optional group name placing generated code in a subdirectory (default `""`).
|
|
110
|
+
* @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
|
|
111
|
+
* @param versions - Optional ordered list of all version labels in this group (oldest first).
|
|
104
112
|
*/
|
|
105
|
-
static async create(config, group = "") {
|
|
113
|
+
static async create(config, group = "", version = "", versions = []) {
|
|
106
114
|
const nativeTs = await runtimeCanExecuteErasableTs();
|
|
107
115
|
const modulesPath = group
|
|
108
116
|
? pathJoin(config.basePath, group)
|
|
@@ -114,7 +122,7 @@ export class ApiRunner {
|
|
|
114
122
|
const openApiDocument = config.openApiPath === "_"
|
|
115
123
|
? undefined
|
|
116
124
|
: await loadOpenApiDocument(config.openApiPath);
|
|
117
|
-
return new ApiRunner(config, nativeTs, openApiDocument, group);
|
|
125
|
+
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
|
|
118
126
|
}
|
|
119
127
|
/**
|
|
120
128
|
* Generates TypeScript route stubs and type files from the OpenAPI spec.
|
|
@@ -123,11 +131,15 @@ export class ApiRunner {
|
|
|
123
131
|
* - Routes and types are only generated when `config.openApiPath` is not `"_"`.
|
|
124
132
|
* - The scenario context type file is always generated when
|
|
125
133
|
* `config.generate.types` is `true`, even without a spec.
|
|
134
|
+
*
|
|
135
|
+
* @param repository - Optional shared repository. Pass a shared instance
|
|
136
|
+
* when multiple versioned specs in the same group should merge their types
|
|
137
|
+
* into the same output tree.
|
|
126
138
|
*/
|
|
127
|
-
async generate() {
|
|
139
|
+
async generate(repository) {
|
|
128
140
|
if (this.config.openApiPath !== "_" &&
|
|
129
141
|
(this.config.generate.routes || this.config.generate.types)) {
|
|
130
|
-
await this.codeGenerator.generate();
|
|
142
|
+
await this.codeGenerator.generate(repository);
|
|
131
143
|
}
|
|
132
144
|
if (this.config.generate.types) {
|
|
133
145
|
await this.scenarioFileGenerator.generate();
|
package/dist/app.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import nodePath from "node:path";
|
|
1
3
|
import { createHttpTerminator } from "http-terminator";
|
|
2
4
|
import { ApiRunner } from "./api-runner.js";
|
|
3
5
|
import { startRepl as startReplServer } from "./repl/repl.js";
|
|
4
6
|
import { createRouteFunction } from "./repl/route-builder.js";
|
|
5
7
|
import { createKoaApp } from "./server/web-server/create-koa-app.js";
|
|
8
|
+
import { Repository } from "./typescript-generator/repository.js";
|
|
9
|
+
import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
|
|
10
|
+
import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
|
|
6
11
|
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
|
|
7
12
|
export { createMswHandlers, handleMswRequest, } from "./msw.js";
|
|
8
13
|
export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
|
|
@@ -18,19 +23,47 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
|
|
|
18
23
|
};
|
|
19
24
|
await indexModule["startup"](scenario$);
|
|
20
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Derives the URL prefix for a spec entry.
|
|
28
|
+
*
|
|
29
|
+
* Applies the following precedence rules:
|
|
30
|
+
* 1. Explicit `prefix` (even `""`) → returned as-is.
|
|
31
|
+
* 2. `group` + `version` both present → `/<group>/<version>`.
|
|
32
|
+
* 3. `group` present (no `version`) → `/<group>`.
|
|
33
|
+
* 4. Neither → `""` (root).
|
|
34
|
+
*/
|
|
35
|
+
function derivePrefix(spec) {
|
|
36
|
+
if (spec.prefix !== undefined) {
|
|
37
|
+
return spec.prefix;
|
|
38
|
+
}
|
|
39
|
+
if (spec.group && spec.version) {
|
|
40
|
+
return `/${spec.group}/${spec.version}`;
|
|
41
|
+
}
|
|
42
|
+
if (spec.group) {
|
|
43
|
+
return `/${spec.group}`;
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
21
47
|
/**
|
|
22
48
|
* Normalises the spec configuration to an array.
|
|
23
49
|
*
|
|
24
|
-
* When `specs` is provided
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
50
|
+
* When `specs` is provided, each entry's `prefix` is resolved via
|
|
51
|
+
* {@link derivePrefix} so the rest of the code can assume `prefix` is always
|
|
52
|
+
* a string. When `specs` is omitted, a single-entry array is constructed from
|
|
53
|
+
* `config.openApiPath`, `config.prefix`, and `group = ""`.
|
|
28
54
|
*/
|
|
29
55
|
function normalizeSpecs(config, specs) {
|
|
30
56
|
if (specs !== undefined) {
|
|
31
|
-
return specs;
|
|
57
|
+
return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) }));
|
|
32
58
|
}
|
|
33
|
-
return [
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
source: config.openApiPath,
|
|
62
|
+
prefix: config.prefix,
|
|
63
|
+
group: "",
|
|
64
|
+
version: "",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
34
67
|
}
|
|
35
68
|
function validateSpecGroups(specs) {
|
|
36
69
|
if (specs.length <= 1) {
|
|
@@ -41,20 +74,26 @@ function validateSpecGroups(specs) {
|
|
|
41
74
|
.filter(({ group }) => group === "")
|
|
42
75
|
.map(({ index }) => String(index + 1));
|
|
43
76
|
if (invalidSpecNumbers.length === 0) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
77
|
+
const seenKeys = new Set();
|
|
78
|
+
const duplicateKeys = new Set();
|
|
46
79
|
for (const spec of specs) {
|
|
47
80
|
const group = spec.group.trim();
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const version = spec.version?.trim() ?? "";
|
|
82
|
+
// Use group@version as the uniqueness key so that the same group can
|
|
83
|
+
// appear with different versions (e.g. v1 and v2 of the same API).
|
|
84
|
+
// The empty-group case is already rejected above, so `group` is always
|
|
85
|
+
// non-empty here and the `@version` suffix remains unambiguous.
|
|
86
|
+
const key = version ? `${group}@${version}` : group;
|
|
87
|
+
if (seenKeys.has(key)) {
|
|
88
|
+
duplicateKeys.add(key);
|
|
50
89
|
continue;
|
|
51
90
|
}
|
|
52
|
-
|
|
91
|
+
seenKeys.add(key);
|
|
53
92
|
}
|
|
54
|
-
if (
|
|
93
|
+
if (duplicateKeys.size === 0) {
|
|
55
94
|
return;
|
|
56
95
|
}
|
|
57
|
-
throw new Error(`Each spec must define a unique group when multiple APIs are configured (
|
|
96
|
+
throw new Error(`Each spec must define a unique group (and version) when multiple APIs are configured (duplicates: ${[...duplicateKeys].join(", ")}).`);
|
|
58
97
|
}
|
|
59
98
|
throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`);
|
|
60
99
|
}
|
|
@@ -81,7 +120,18 @@ function validateSpecGroups(specs) {
|
|
|
81
120
|
export async function counterfact(config, specs) {
|
|
82
121
|
const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs);
|
|
83
122
|
validateSpecGroups(normalizedSpecs);
|
|
84
|
-
|
|
123
|
+
// Compute the ordered versions per group (oldest first, as declared in specs).
|
|
124
|
+
// This list is passed to each runner so that $.minVersion() can compare
|
|
125
|
+
// version positions at runtime.
|
|
126
|
+
const versionsByGroup = new Map();
|
|
127
|
+
for (const spec of normalizedSpecs) {
|
|
128
|
+
const version = spec.version ?? "";
|
|
129
|
+
if (version) {
|
|
130
|
+
const existing = versionsByGroup.get(spec.group) ?? [];
|
|
131
|
+
versionsByGroup.set(spec.group, [...existing, version]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
|
|
85
135
|
const koaApp = createKoaApp({
|
|
86
136
|
runners,
|
|
87
137
|
config,
|
|
@@ -89,7 +139,61 @@ export async function counterfact(config, specs) {
|
|
|
89
139
|
// The REPL is configured using the first runner.
|
|
90
140
|
const primaryRunner = runners[0];
|
|
91
141
|
async function start(options) {
|
|
92
|
-
|
|
142
|
+
// Serialize generate() calls within each group to avoid concurrent writes
|
|
143
|
+
// to the same output directory. Runners that share a group share the same
|
|
144
|
+
// basePath subdirectory (and therefore the same counterfact-types
|
|
145
|
+
// destination), so running them in parallel would cause a race when both
|
|
146
|
+
// try to create that directory at startup. Different groups are still
|
|
147
|
+
// generated in parallel.
|
|
148
|
+
//
|
|
149
|
+
// When multiple versioned specs share the same group, they also share a
|
|
150
|
+
// single Repository instance so that the shared `types/paths/…` files
|
|
151
|
+
// accumulate all versions into a merged Versioned<…> type instead of each
|
|
152
|
+
// overwriting the previous version's types.
|
|
153
|
+
const runnersByGroup = new Map();
|
|
154
|
+
for (const runner of runners) {
|
|
155
|
+
const bucket = runnersByGroup.get(runner.group) ?? [];
|
|
156
|
+
bucket.push(runner);
|
|
157
|
+
runnersByGroup.set(runner.group, bucket);
|
|
158
|
+
}
|
|
159
|
+
await Promise.all(Array.from(runnersByGroup.values()).map(async (bucket) => {
|
|
160
|
+
const sharedRepository = bucket.length > 1 ? new Repository() : undefined;
|
|
161
|
+
for (const runner of bucket) {
|
|
162
|
+
await runner.generate(sharedRepository);
|
|
163
|
+
}
|
|
164
|
+
}));
|
|
165
|
+
if (options.generate?.types) {
|
|
166
|
+
// Build a per-group map of unique non-empty version strings in
|
|
167
|
+
// declaration order. new Set() preserves insertion order so the first
|
|
168
|
+
// occurrence of each version is kept and duplicates are dropped without
|
|
169
|
+
// reordering.
|
|
170
|
+
const versionsByGroup = new Map();
|
|
171
|
+
for (const spec of normalizedSpecs) {
|
|
172
|
+
const group = spec.group;
|
|
173
|
+
const version = (spec.version ?? "").trim();
|
|
174
|
+
if (version === "") {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const existing = versionsByGroup.get(group) ?? [];
|
|
178
|
+
if (!existing.includes(version)) {
|
|
179
|
+
existing.push(version);
|
|
180
|
+
}
|
|
181
|
+
versionsByGroup.set(group, existing);
|
|
182
|
+
}
|
|
183
|
+
// Write <basePath>/<group>/types/versions.ts for every group that has
|
|
184
|
+
// at least one versioned spec. When the group is empty the path
|
|
185
|
+
// collapses to <basePath>/types/versions.ts (the single-spec case).
|
|
186
|
+
await Promise.all(Array.from(versionsByGroup.entries()).map(async ([group, versions]) => {
|
|
187
|
+
const content = await generateVersionsTsContent(versions);
|
|
188
|
+
const versionsFilePath = group
|
|
189
|
+
? nodePath.join(config.basePath, group, "types", "versions.ts")
|
|
190
|
+
: nodePath.join(config.basePath, "types", "versions.ts");
|
|
191
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- path is derived from the caller-supplied basePath and fixed suffixes. */
|
|
192
|
+
await ensureDirectoryExists(versionsFilePath);
|
|
193
|
+
await fs.writeFile(versionsFilePath, content, "utf8");
|
|
194
|
+
/* eslint-enable security/detect-non-literal-fs-filename */
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
93
197
|
await Promise.all(runners.map((runner) => runner.watch()));
|
|
94
198
|
await Promise.all(runners.map((runner) => runner.start(options)));
|
|
95
199
|
let httpTerminator;
|
package/dist/cli/banner.js
CHANGED
|
@@ -67,7 +67,7 @@ export function createIntroduction(params) {
|
|
|
67
67
|
` API Base URL ${url}`,
|
|
68
68
|
source === "_" ? undefined : ` Swagger UI ${swaggerUrl}`,
|
|
69
69
|
"",
|
|
70
|
-
" Instructions https://
|
|
70
|
+
" Instructions https://github.com/counterfact/api-simulator/blob/main/docs/usage.md",
|
|
71
71
|
" Help/feedback https://github.com/pmcelhaney/counterfact/issues",
|
|
72
72
|
"",
|
|
73
73
|
...telemetryWarning,
|
package/dist/cli/run.js
CHANGED
|
@@ -22,18 +22,23 @@ const DEFAULT_PORT = 3100;
|
|
|
22
22
|
* CLI flag) into an array of {@link SpecConfig} objects, or `undefined` when
|
|
23
23
|
* the option is a plain string (single OpenAPI document path).
|
|
24
24
|
*
|
|
25
|
-
* - **Array**: each entry is mapped to `{source, prefix, group}` with defaults.
|
|
25
|
+
* - **Array**: each entry is mapped to `{source, prefix, group, version}` with defaults.
|
|
26
26
|
* - **Object**: wrapped in a single-element array.
|
|
27
27
|
* - **String / undefined**: returns `undefined` — caller handles the string
|
|
28
28
|
* case (it shifts the positional argument) and the `undefined` case
|
|
29
29
|
* (single spec derived from config).
|
|
30
|
+
*
|
|
31
|
+
* Note: `prefix` is intentionally left `undefined` when not supplied so that
|
|
32
|
+
* `normalizeSpecs` (in `app.ts`) can derive it automatically from
|
|
33
|
+
* `group`/`version`.
|
|
30
34
|
*/
|
|
31
35
|
export function normalizeSpecOption(specOption) {
|
|
32
36
|
if (Array.isArray(specOption)) {
|
|
33
37
|
return specOption.map((entry) => ({
|
|
34
38
|
source: entry.source,
|
|
35
|
-
prefix: entry.prefix
|
|
39
|
+
prefix: entry.prefix,
|
|
36
40
|
group: entry.group ?? "",
|
|
41
|
+
version: entry.version,
|
|
37
42
|
}));
|
|
38
43
|
}
|
|
39
44
|
if (typeof specOption === "object" &&
|
|
@@ -42,8 +47,9 @@ export function normalizeSpecOption(specOption) {
|
|
|
42
47
|
return [
|
|
43
48
|
{
|
|
44
49
|
source: specOption.source,
|
|
45
|
-
prefix: specOption.prefix
|
|
50
|
+
prefix: specOption.prefix,
|
|
46
51
|
group: specOption.group ?? "",
|
|
52
|
+
version: specOption.version,
|
|
47
53
|
},
|
|
48
54
|
];
|
|
49
55
|
}
|
|
@@ -253,7 +259,7 @@ function buildProgram(version, taglines) {
|
|
|
253
259
|
.option("--watch-routes", "generate + watch routes for changes")
|
|
254
260
|
.option("-s, --serve", "start the server")
|
|
255
261
|
.option("-b, --build-cache", "builds the cache of compiled routes and types")
|
|
256
|
-
.option("--
|
|
262
|
+
.option("--admin-api", "enable the admin API at /_counterfact/api/*")
|
|
257
263
|
.option("-r, --repl", "start the REPL")
|
|
258
264
|
.option("--proxy-url <string>", "proxy URL")
|
|
259
265
|
.option("--admin-api-token <string>", "bearer token required for /_counterfact/api/* endpoints (defaults to COUNTERFACT_ADMIN_API_TOKEN)")
|
package/dist/cli/telemetry.js
CHANGED
|
@@ -5,9 +5,7 @@ const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
|
5
5
|
/**
|
|
6
6
|
* Returns `true` when telemetry should be sent.
|
|
7
7
|
*
|
|
8
|
-
* Telemetry is disabled in CI
|
|
9
|
-
* or before the May 2026 rollout date unless the user has explicitly opted
|
|
10
|
-
* in with `COUNTERFACT_TELEMETRY_DISABLED=false`.
|
|
8
|
+
* Telemetry is disabled in CI or when `COUNTERFACT_TELEMETRY_DISABLED=true`.
|
|
11
9
|
*/
|
|
12
10
|
export function isTelemetryEnabled() {
|
|
13
11
|
if (process.env["CI"])
|
|
@@ -15,9 +13,6 @@ export function isTelemetryEnabled() {
|
|
|
15
13
|
const telemetryDisabledEnv = process.env["COUNTERFACT_TELEMETRY_DISABLED"];
|
|
16
14
|
if (telemetryDisabledEnv === "true")
|
|
17
15
|
return false;
|
|
18
|
-
const isBeforeRollout = new Date() < new Date("2026-05-01");
|
|
19
|
-
if (isBeforeRollout && telemetryDisabledEnv !== "false")
|
|
20
|
-
return false;
|
|
21
16
|
return true;
|
|
22
17
|
}
|
|
23
18
|
/**
|
package/dist/repl/repl.js
CHANGED
|
@@ -194,9 +194,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
194
194
|
}
|
|
195
195
|
seenGroups.add(binding.key);
|
|
196
196
|
}
|
|
197
|
-
if (duplicateGroups.size > 0) {
|
|
198
|
-
throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
|
|
199
|
-
}
|
|
200
197
|
}
|
|
201
198
|
const rootBinding = groupedBindings[0];
|
|
202
199
|
if (rootBinding === undefined) {
|
|
@@ -268,7 +265,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
268
265
|
print("- context: the root context ( same as loadContext('/') )");
|
|
269
266
|
print("- route('/some/path'): create a request builder for the given path");
|
|
270
267
|
print("");
|
|
271
|
-
print("For more information, see https://
|
|
268
|
+
print("For more information, see https://github.com/counterfact/api-simulator/blob/main/docs/usage.md");
|
|
272
269
|
print("");
|
|
273
270
|
this.clearBufferedCommand();
|
|
274
271
|
this.displayPrompt();
|
|
@@ -6,6 +6,23 @@ import { isExplodedObjectQueryParam, validateRequest, } from "./request-validato
|
|
|
6
6
|
import { validateResponse } from "./response-validator.js";
|
|
7
7
|
import { Tools } from "./tools.js";
|
|
8
8
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
9
|
+
/**
|
|
10
|
+
* Merges path-item-level and operation-level parameter arrays.
|
|
11
|
+
*
|
|
12
|
+
* Operation-level parameters take precedence when both arrays define a
|
|
13
|
+
* parameter with the same `name` and `in` location, per the OpenAPI
|
|
14
|
+
* specification.
|
|
15
|
+
*/
|
|
16
|
+
function mergeParameters(pathItemParams, operationParams) {
|
|
17
|
+
const map = new Map();
|
|
18
|
+
for (const p of pathItemParams) {
|
|
19
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
20
|
+
}
|
|
21
|
+
for (const p of operationParams) {
|
|
22
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
23
|
+
}
|
|
24
|
+
return [...map.values()];
|
|
25
|
+
}
|
|
9
26
|
/**
|
|
10
27
|
* Parses the `Cookie` request header into a key/value map.
|
|
11
28
|
*
|
|
@@ -91,12 +108,26 @@ export class Dispatcher {
|
|
|
91
108
|
openApiDocument;
|
|
92
109
|
fetch;
|
|
93
110
|
config; // Add config property
|
|
94
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The version label for this dispatcher's spec (e.g. `"v1"`, `"v2"`).
|
|
113
|
+
* Empty string when running without a version.
|
|
114
|
+
*/
|
|
115
|
+
version;
|
|
116
|
+
/**
|
|
117
|
+
* Ordered list of all version labels for the API group this dispatcher
|
|
118
|
+
* belongs to. The first entry is the oldest version. Used by
|
|
119
|
+
* `$.minVersion()` at runtime to determine if the current version is
|
|
120
|
+
* greater than or equal to a given minimum version.
|
|
121
|
+
*/
|
|
122
|
+
versions;
|
|
123
|
+
constructor(registry, contextRegistry, openApiDocument, config, version = "", versions = []) {
|
|
95
124
|
this.registry = registry;
|
|
96
125
|
this.contextRegistry = contextRegistry;
|
|
97
126
|
this.openApiDocument = openApiDocument;
|
|
98
127
|
this.fetch = fetch;
|
|
99
128
|
this.config = config;
|
|
129
|
+
this.version = version;
|
|
130
|
+
this.versions = versions;
|
|
100
131
|
}
|
|
101
132
|
parameterTypes(parameters) {
|
|
102
133
|
const types = {
|
|
@@ -111,43 +142,65 @@ export class Dispatcher {
|
|
|
111
142
|
return types;
|
|
112
143
|
}
|
|
113
144
|
for (const parameter of parameters) {
|
|
114
|
-
const type = parameter?.type;
|
|
145
|
+
const type = parameter?.type ?? parameter?.schema?.type;
|
|
115
146
|
if (type !== undefined) {
|
|
116
147
|
types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
|
|
117
148
|
}
|
|
118
149
|
}
|
|
119
150
|
return types;
|
|
120
151
|
}
|
|
121
|
-
|
|
122
|
-
if (this.openApiDocument) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
152
|
+
findPathItem(path) {
|
|
153
|
+
if (!this.openApiDocument) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
for (const key in this.openApiDocument.paths) {
|
|
157
|
+
if (key.toLowerCase() === path.toLowerCase()) {
|
|
158
|
+
return this.openApiDocument.paths[key];
|
|
127
159
|
}
|
|
128
160
|
}
|
|
129
161
|
return undefined;
|
|
130
162
|
}
|
|
131
163
|
/**
|
|
132
164
|
* Resolves the OpenAPI operation for `path` and `method`, merging any
|
|
133
|
-
* top-level `produces` array from the document root
|
|
165
|
+
* top-level `produces` array from the document root and any path-item-level
|
|
166
|
+
* `parameters` into the operation.
|
|
167
|
+
*
|
|
168
|
+
* Per the OpenAPI specification, parameters defined at the path item level
|
|
169
|
+
* are shared across all operations on that path. Operation-level parameters
|
|
170
|
+
* take precedence when both define a parameter with the same `name` and `in`.
|
|
134
171
|
*
|
|
135
172
|
* @param path - The matched route path (e.g. `"/pets/{petId}"`).
|
|
136
173
|
* @param method - The HTTP method.
|
|
137
174
|
* @returns The {@link OpenApiOperation} if found, or `undefined`.
|
|
138
175
|
*/
|
|
139
176
|
operationForPathAndMethod(path, method) {
|
|
140
|
-
const
|
|
177
|
+
const pathItem = this.findPathItem(path);
|
|
178
|
+
if (pathItem === undefined) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const operation = pathItem[method.toLowerCase()];
|
|
141
182
|
if (operation === undefined) {
|
|
142
183
|
return undefined;
|
|
143
184
|
}
|
|
185
|
+
// Merge path-item-level parameters with operation-level parameters.
|
|
186
|
+
// Operation-level parameters take precedence on same name+in collision.
|
|
187
|
+
const pathItemParams = pathItem.parameters ?? [];
|
|
188
|
+
const operationParams = operation.parameters ?? [];
|
|
189
|
+
const mergedParameters = pathItemParams.length > 0
|
|
190
|
+
? mergeParameters(pathItemParams, operationParams)
|
|
191
|
+
: operationParams.length > 0
|
|
192
|
+
? operationParams
|
|
193
|
+
: undefined;
|
|
194
|
+
const mergedOperation = mergedParameters !== undefined
|
|
195
|
+
? { ...operation, parameters: mergedParameters }
|
|
196
|
+
: operation;
|
|
144
197
|
if (this.openApiDocument?.produces) {
|
|
145
198
|
return {
|
|
146
199
|
produces: this.openApiDocument.produces,
|
|
147
|
-
...
|
|
200
|
+
...mergedOperation,
|
|
148
201
|
};
|
|
149
202
|
}
|
|
150
|
-
return
|
|
203
|
+
return mergedOperation;
|
|
151
204
|
}
|
|
152
205
|
normalizeResponse(response, acceptHeader) {
|
|
153
206
|
if (response.content !== undefined) {
|
|
@@ -285,6 +338,17 @@ export class Dispatcher {
|
|
|
285
338
|
// @ts-expect-error - Might be pushing the limits of what TypeScript can do here
|
|
286
339
|
response: createResponseBuilder(operation ?? { responses: {} }, this.config), // Pass config
|
|
287
340
|
tools: new Tools({ headers }),
|
|
341
|
+
...(this.version !== "" && {
|
|
342
|
+
version: this.version,
|
|
343
|
+
minVersion: (min) => {
|
|
344
|
+
const currentIdx = this.versions.indexOf(this.version);
|
|
345
|
+
const minIdx = this.versions.indexOf(min);
|
|
346
|
+
if (currentIdx === -1 || minIdx === -1) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return currentIdx >= minIdx;
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
288
352
|
});
|
|
289
353
|
if (response === undefined) {
|
|
290
354
|
return {
|
|
@@ -166,7 +166,7 @@ export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config
|
|
|
166
166
|
port: config.port,
|
|
167
167
|
proxyUrl: config.proxyUrl,
|
|
168
168
|
prefix: config.prefix,
|
|
169
|
-
startAdminApi: config.startAdminApi,
|
|
169
|
+
startAdminApi: config.startAdminApi ?? false,
|
|
170
170
|
startRepl: config.startRepl,
|
|
171
171
|
startServer: config.startServer,
|
|
172
172
|
watch: config.watch,
|
|
@@ -22,12 +22,14 @@ const debug = createDebug("counterfact:typescript-generator:generate");
|
|
|
22
22
|
export class CodeGenerator extends EventTarget {
|
|
23
23
|
openapiPath;
|
|
24
24
|
destination;
|
|
25
|
+
version;
|
|
25
26
|
generateOptions;
|
|
26
27
|
watcher;
|
|
27
|
-
constructor(openApiPath, destination, generateOptions) {
|
|
28
|
+
constructor(openApiPath, destination, generateOptions, version = "") {
|
|
28
29
|
super();
|
|
29
30
|
this.openapiPath = openApiPath;
|
|
30
31
|
this.destination = destination;
|
|
32
|
+
this.version = version;
|
|
31
33
|
this.generateOptions = generateOptions;
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
@@ -116,7 +118,7 @@ export class CodeGenerator extends EventTarget {
|
|
|
116
118
|
}
|
|
117
119
|
repository
|
|
118
120
|
.get(`routes${path}.ts`)
|
|
119
|
-
.export(new OperationCoder(operation,
|
|
121
|
+
.export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
|
|
120
122
|
});
|
|
121
123
|
});
|
|
122
124
|
debug("telling the repository to write the files to %s", destination);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pathJoin } from "../util/forward-slash-path.js";
|
|
2
2
|
import { Coder } from "./coder.js";
|
|
3
|
-
import { OperationTypeCoder, } from "./operation-type-coder.js";
|
|
3
|
+
import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
|
|
4
4
|
/**
|
|
5
5
|
* Generates the default route handler stub for a single OpenAPI operation.
|
|
6
6
|
*
|
|
@@ -41,6 +41,14 @@ export class OperationCoder extends Coder {
|
|
|
41
41
|
}
|
|
42
42
|
typeDeclaration(_namespace, script) {
|
|
43
43
|
const operationTypeCoder = new OperationTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
|
|
44
|
+
if (this.version !== "") {
|
|
45
|
+
// For versioned APIs: register this version's $-argument type on the
|
|
46
|
+
// shared script so that Script.versionsTypeStatements() can emit the
|
|
47
|
+
// merged handler type after all versions have been declared.
|
|
48
|
+
const versionedArgCoder = new VersionedArgTypeCoder(this.requirement, this.version, this.requestMethod, this.securitySchemes);
|
|
49
|
+
const sharedScript = script.repository.get(operationTypeCoder.modulePath());
|
|
50
|
+
sharedScript.declareVersion(versionedArgCoder, operationTypeCoder.getOperationBaseName());
|
|
51
|
+
}
|
|
44
52
|
return script.importType(operationTypeCoder);
|
|
45
53
|
}
|
|
46
54
|
modulePath() {
|
|
@@ -8,6 +8,7 @@ import { RESERVED_WORDS } from "./reserved-words.js";
|
|
|
8
8
|
import { ResponsesTypeCoder } from "./responses-type-coder.js";
|
|
9
9
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
10
10
|
import { TypeCoder } from "./type-coder.js";
|
|
11
|
+
import { Requirement } from "./requirement.js";
|
|
11
12
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
|
|
12
13
|
function sanitizeIdentifier(value) {
|
|
13
14
|
// Treat any run of non-identifier characters as a camelCase separator
|
|
@@ -32,6 +33,14 @@ function sanitizeIdentifier(value) {
|
|
|
32
33
|
* `cookie`, `body`, `context`, `response`, and `user` arguments.
|
|
33
34
|
*
|
|
34
35
|
* Output is written to `types/paths/<route>.types.ts`.
|
|
36
|
+
*
|
|
37
|
+
* **Versioned APIs**: when `version` is non-empty this coder emits only a
|
|
38
|
+
* sentinel `{raw: ""}` export (suppressing the normal flat type) and
|
|
39
|
+
* registers a formatter on the shared script so that
|
|
40
|
+
* {@link Script.versionsTypeStatements} can later emit the merged
|
|
41
|
+
* `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` handler type.
|
|
42
|
+
* Each version's `$`-argument type is emitted to
|
|
43
|
+
* `types/<version>/paths/<path>.types.ts` by {@link VersionedArgTypeCoder}.
|
|
35
44
|
*/
|
|
36
45
|
export class OperationTypeCoder extends TypeCoder {
|
|
37
46
|
requestMethod;
|
|
@@ -141,14 +150,57 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
141
150
|
}
|
|
142
151
|
return "never";
|
|
143
152
|
}
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Returns the effective parameters for this operation by merging path-item-level
|
|
155
|
+
* parameters with operation-level parameters. Per the OpenAPI specification,
|
|
156
|
+
* operation-level parameters override path-item-level parameters that share
|
|
157
|
+
* the same `name` and `in` location.
|
|
158
|
+
*
|
|
159
|
+
* Uses `this.requirement.parent` (the path item requirement) to access
|
|
160
|
+
* path-item-level parameters directly, without URL string parsing.
|
|
161
|
+
*
|
|
162
|
+
* When the parent is not set (e.g. in unit tests that construct requirements
|
|
163
|
+
* directly), only the operation-level parameters are returned.
|
|
164
|
+
*/
|
|
165
|
+
getEffectiveParameters() {
|
|
166
|
+
const operationParams = this.requirement.get("parameters");
|
|
167
|
+
const pathItemParams = this.requirement.parent?.get("parameters");
|
|
168
|
+
if (!pathItemParams) {
|
|
169
|
+
return operationParams;
|
|
170
|
+
}
|
|
171
|
+
if (!operationParams) {
|
|
172
|
+
return pathItemParams;
|
|
173
|
+
}
|
|
174
|
+
// Merge using a Map keyed on `${in}:${name}`.
|
|
175
|
+
// Path-level params are added first; operation-level overrides them.
|
|
176
|
+
const pathData = pathItemParams.data;
|
|
177
|
+
const opData = operationParams.data;
|
|
178
|
+
const map = new Map();
|
|
179
|
+
for (const p of pathData) {
|
|
180
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
181
|
+
}
|
|
182
|
+
for (const p of opData) {
|
|
183
|
+
map.set(`${p.in}:${p.name}`, p);
|
|
184
|
+
}
|
|
185
|
+
return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Builds the `OmitValueWhenNever<{…}>` dollar-argument type body and sets
|
|
189
|
+
* up all required shared-type imports on `script`.
|
|
190
|
+
*
|
|
191
|
+
* This helper is reused by both {@link writeCode} (non-versioned) and
|
|
192
|
+
* {@link VersionedArgTypeCoder.writeCode} (per-version file).
|
|
193
|
+
*
|
|
194
|
+
* @param script - The script to write imports and parameter-type exports into.
|
|
195
|
+
* @param baseName - Identifier prefix used for named parameter-type exports.
|
|
196
|
+
* @param modulePath - Repository-relative path for parameter-type exports.
|
|
197
|
+
*/
|
|
198
|
+
buildDollarArgType(script, baseName, modulePath) {
|
|
146
199
|
const xType = script.importSharedType("WideOperationArgument");
|
|
147
200
|
script.importSharedType("OmitValueWhenNever");
|
|
148
|
-
script.importSharedType("MaybePromise");
|
|
149
201
|
script.importSharedType("COUNTERFACT_RESPONSE");
|
|
150
202
|
const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
|
|
151
|
-
const parameters = this.
|
|
203
|
+
const parameters = this.getEffectiveParameters();
|
|
152
204
|
const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
|
|
153
205
|
const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
|
|
154
206
|
const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
|
|
@@ -168,13 +220,114 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
168
220
|
const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
|
|
169
221
|
const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
|
|
170
222
|
const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
|
|
171
|
-
// Get the base name for this operation and export parameter types
|
|
172
|
-
const baseName = this.getOperationBaseName();
|
|
173
|
-
const modulePath = this.modulePath();
|
|
174
223
|
const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
|
|
175
224
|
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
|
|
176
225
|
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
|
|
177
226
|
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
|
|
178
|
-
|
|
227
|
+
const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
|
|
228
|
+
return `OmitValueWhenNever<{ query: ${queryTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
|
|
229
|
+
}
|
|
230
|
+
writeCode(script) {
|
|
231
|
+
script.comments = READ_ONLY_COMMENTS;
|
|
232
|
+
if (this.version !== "") {
|
|
233
|
+
// Versioned case: suppress the normal flat export and register a
|
|
234
|
+
// formatter so that Script.versionsTypeStatements() can emit the
|
|
235
|
+
// merged HTTP_<METHOD>_$_Versions + HTTP_<METHOD> types after all
|
|
236
|
+
// versions have been declared via declareVersion().
|
|
237
|
+
const versionedType = script.importVersionsType("Versioned");
|
|
238
|
+
const maybePromiseType = script.importSharedType("MaybePromise");
|
|
239
|
+
const counterfactResponseType = script.importSharedType("COUNTERFACT_RESPONSE");
|
|
240
|
+
const baseName = this.getOperationBaseName();
|
|
241
|
+
script.setVersionFormatter(baseName, (versionCodes) => {
|
|
242
|
+
const versionsTypeName = `${baseName}_$_Versions`;
|
|
243
|
+
const versionMap = Array.from(versionCodes, ([v, code]) => `"${v}": ${code}`).join("; ");
|
|
244
|
+
return [
|
|
245
|
+
`type ${versionsTypeName} = { ${versionMap} };`,
|
|
246
|
+
`export type ${baseName} = ($: ${versionedType}<${versionsTypeName}>) => ${maybePromiseType}<${counterfactResponseType}>;`,
|
|
247
|
+
].join("\n");
|
|
248
|
+
});
|
|
249
|
+
// Return a raw-empty sentinel so exportStatements() emits nothing for
|
|
250
|
+
// this export entry. The real export is produced by
|
|
251
|
+
// versionsTypeStatements().
|
|
252
|
+
return { raw: "" };
|
|
253
|
+
}
|
|
254
|
+
// Non-versioned case: existing flat-type output.
|
|
255
|
+
// Import in the same order as the original writeCode so that the emitted
|
|
256
|
+
// import block is identical to the pre-refactor output (snapshot-safe).
|
|
257
|
+
script.importSharedType("WideOperationArgument");
|
|
258
|
+
script.importSharedType("OmitValueWhenNever");
|
|
259
|
+
script.importSharedType("MaybePromise");
|
|
260
|
+
script.importSharedType("COUNTERFACT_RESPONSE");
|
|
261
|
+
const baseName = this.getOperationBaseName();
|
|
262
|
+
const modulePath = this.modulePath();
|
|
263
|
+
const dollarArgType = this.buildDollarArgType(script, baseName, modulePath);
|
|
264
|
+
return `($: ${dollarArgType}) => MaybePromise<COUNTERFACT_RESPONSE>`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Emits a per-version `$`-argument type to
|
|
269
|
+
* `types/<version>/paths/<path>.types.ts`.
|
|
270
|
+
*
|
|
271
|
+
* When called from a *different* script (e.g. the shared
|
|
272
|
+
* `types/paths/…` script via `Script.declareVersion`), `write()` delegates to
|
|
273
|
+
* `script.importType(this)` so that the type is written to the per-version
|
|
274
|
+
* file and an import is added to the calling script.
|
|
275
|
+
*
|
|
276
|
+
* Only the `OmitValueWhenNever<{…}>` type body is emitted — the
|
|
277
|
+
* function-wrapper `($: Versioned<…>) => MaybePromise<COUNTERFACT_RESPONSE>`
|
|
278
|
+
* is assembled by the shared script's `versionsTypeStatements()`.
|
|
279
|
+
*/
|
|
280
|
+
export class VersionedArgTypeCoder extends OperationTypeCoder {
|
|
281
|
+
/**
|
|
282
|
+
* Include the version in the cache key so v1 and v2 coders are treated as
|
|
283
|
+
* distinct exports even when they share the same requirement URL.
|
|
284
|
+
*/
|
|
285
|
+
get id() {
|
|
286
|
+
return `${super.id}:${this.version}`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The per-version `$`-argument type is emitted to
|
|
290
|
+
* `types/<version>/paths/<path>.types.ts`, not to the shared path.
|
|
291
|
+
*/
|
|
292
|
+
modulePath() {
|
|
293
|
+
const pathString = this.requirement.url
|
|
294
|
+
.split("/")
|
|
295
|
+
.at(-2)
|
|
296
|
+
.replaceAll("~1", "/");
|
|
297
|
+
return `${pathJoin(`types/${this.version}/paths`, pathString === "/" ? "/index" : pathString)}.types.ts`;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Names are version-qualified (e.g. `HTTP_GET_$_v1`) so that importing
|
|
301
|
+
* multiple versions into the shared script requires no aliasing.
|
|
302
|
+
*/
|
|
303
|
+
*names() {
|
|
304
|
+
const baseName = `${this.getOperationBaseName()}_$_${sanitizeIdentifier(this.version)}`;
|
|
305
|
+
yield baseName;
|
|
306
|
+
let index = 1;
|
|
307
|
+
const MAX = 100;
|
|
308
|
+
while (index < MAX) {
|
|
309
|
+
index += 1;
|
|
310
|
+
yield `${baseName}${index}`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* When called from the per-version file itself, generate the actual type.
|
|
315
|
+
* When called from any other script (e.g. the shared file), export to the
|
|
316
|
+
* per-version file and import the result back into that script.
|
|
317
|
+
*/
|
|
318
|
+
write(script) {
|
|
319
|
+
if (script.path === this.modulePath()) {
|
|
320
|
+
return this.writeCode(script);
|
|
321
|
+
}
|
|
322
|
+
return script.importType(this);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Generates the `OmitValueWhenNever<{…}>` dollar-argument type and writes
|
|
326
|
+
* it to the per-version script.
|
|
327
|
+
*/
|
|
328
|
+
writeCode(script) {
|
|
329
|
+
script.comments = READ_ONLY_COMMENTS;
|
|
330
|
+
const baseName = this.getOperationBaseName();
|
|
331
|
+
return this.buildDollarArgType(script, baseName, this.modulePath());
|
|
179
332
|
}
|
|
180
333
|
}
|
|
@@ -10,6 +10,15 @@ export class Requirement {
|
|
|
10
10
|
data;
|
|
11
11
|
url;
|
|
12
12
|
specification;
|
|
13
|
+
/**
|
|
14
|
+
* The requirement that produced this one via a `get()` call, or `undefined`
|
|
15
|
+
* for root requirements that were constructed directly.
|
|
16
|
+
*
|
|
17
|
+
* For path-traversal purposes this is the "logical" parent: when a `$ref` is
|
|
18
|
+
* followed, the parent is the resolved reference target rather than the
|
|
19
|
+
* `$ref` node itself.
|
|
20
|
+
*/
|
|
21
|
+
parent;
|
|
13
22
|
constructor(data, url = "", specification = undefined) {
|
|
14
23
|
this.data = data;
|
|
15
24
|
this.url = url;
|
|
@@ -53,7 +62,9 @@ export class Requirement {
|
|
|
53
62
|
if (!this.has(key)) {
|
|
54
63
|
return undefined;
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
const child = new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
|
|
66
|
+
child.parent = this;
|
|
67
|
+
return child;
|
|
57
68
|
}
|
|
58
69
|
/**
|
|
59
70
|
* Navigates to a descendant node using a slash-delimited JSON Pointer path.
|
|
@@ -21,10 +21,16 @@ export class ResponsesTypeCoder extends TypeCoder {
|
|
|
21
21
|
return statusCode;
|
|
22
22
|
}
|
|
23
23
|
buildResponseObjectType(script) {
|
|
24
|
-
|
|
24
|
+
const entries = this.requirement.map((response, responseCode) => [
|
|
25
25
|
this.normalizeStatusCode(responseCode),
|
|
26
26
|
new ResponseTypeCoder(response, this.version, this.openApi2MediaTypes).write(script),
|
|
27
|
-
])
|
|
27
|
+
]);
|
|
28
|
+
const explicitEntries = entries.filter(([key]) => !key.startsWith("["));
|
|
29
|
+
const mappedEntries = entries.filter(([key]) => key.startsWith("["));
|
|
30
|
+
if (explicitEntries.length > 0 && mappedEntries.length > 0) {
|
|
31
|
+
return `${printObjectWithoutQuotes(explicitEntries)} & ${printObjectWithoutQuotes(mappedEntries)}`;
|
|
32
|
+
}
|
|
33
|
+
return printObjectWithoutQuotes(entries);
|
|
28
34
|
}
|
|
29
35
|
writeCode(script) {
|
|
30
36
|
script.importSharedType("ResponseBuilderFactory");
|
|
@@ -18,6 +18,7 @@ export class Script {
|
|
|
18
18
|
comments;
|
|
19
19
|
exports;
|
|
20
20
|
versions;
|
|
21
|
+
versionFormatters;
|
|
21
22
|
imports;
|
|
22
23
|
externalImport;
|
|
23
24
|
cache;
|
|
@@ -28,6 +29,7 @@ export class Script {
|
|
|
28
29
|
this.comments = [];
|
|
29
30
|
this.exports = new Map();
|
|
30
31
|
this.versions = new Map();
|
|
32
|
+
this.versionFormatters = new Map();
|
|
31
33
|
this.imports = new Map();
|
|
32
34
|
this.externalImport = new Map();
|
|
33
35
|
this.cache = new Map();
|
|
@@ -175,9 +177,30 @@ export class Script {
|
|
|
175
177
|
importSharedType(name) {
|
|
176
178
|
return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
|
|
177
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Imports a type from the generated `types/versions.ts` module,
|
|
182
|
+
* resolving the path relative to this script's location in the repository.
|
|
183
|
+
*
|
|
184
|
+
* @param name - The type name to import (e.g. `"Versioned"`).
|
|
185
|
+
*/
|
|
186
|
+
importVersionsType(name) {
|
|
187
|
+
return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true);
|
|
188
|
+
}
|
|
178
189
|
exportType(coder) {
|
|
179
190
|
return this.export(coder, true);
|
|
180
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Registers a formatter function for the merged versioned type emitted under
|
|
194
|
+
* `name` by {@link versionsTypeStatements}.
|
|
195
|
+
*
|
|
196
|
+
* When a formatter is present for a name, `versionsTypeStatements` delegates
|
|
197
|
+
* the entire type declaration to it instead of generating the default
|
|
198
|
+
* `Versions` object type. The formatter receives a `Map<version, importAlias>`
|
|
199
|
+
* and must return the complete TypeScript source for that operation type.
|
|
200
|
+
*/
|
|
201
|
+
setVersionFormatter(name, formatter) {
|
|
202
|
+
this.versionFormatters.set(name, formatter);
|
|
203
|
+
}
|
|
181
204
|
declareVersion(coder, name) {
|
|
182
205
|
const version = coder.version;
|
|
183
206
|
const versions = this.versions.get(name) ?? new Map();
|
|
@@ -254,11 +277,29 @@ export class Script {
|
|
|
254
277
|
if (this.versions.size === 0) {
|
|
255
278
|
return [];
|
|
256
279
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
const statements = [];
|
|
281
|
+
const unformatted = [];
|
|
282
|
+
for (const [name, versions] of this.versions) {
|
|
283
|
+
const formatter = this.versionFormatters.get(name);
|
|
284
|
+
if (formatter) {
|
|
285
|
+
const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [
|
|
286
|
+
version,
|
|
287
|
+
stmt.code,
|
|
288
|
+
]));
|
|
289
|
+
statements.push(formatter(versionCodes));
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
unformatted.push([name, versions]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (unformatted.length > 0) {
|
|
296
|
+
const names = unformatted.map(([name, versions]) => {
|
|
297
|
+
const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
|
|
298
|
+
return `"${name}": { ${mappedVersions.join(", ")} }`;
|
|
299
|
+
});
|
|
300
|
+
statements.push(`export type Versions = { ${names.join(", ")} };`);
|
|
301
|
+
}
|
|
302
|
+
return statements;
|
|
262
303
|
}
|
|
263
304
|
/**
|
|
264
305
|
* Formats the fully assembled script source with Prettier and returns it.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { format } from "prettier";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the `VersionsGTE` map: for each version V at index i,
|
|
4
|
+
* maps V to all versions at indices >= i (i.e. V and all later-declared
|
|
5
|
+
* versions, where "later" means "newer").
|
|
6
|
+
*/
|
|
7
|
+
function buildVersionsGTE(versions) {
|
|
8
|
+
const result = new Map();
|
|
9
|
+
versions.forEach((version, index) => {
|
|
10
|
+
result.set(version, versions.slice(index));
|
|
11
|
+
});
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Generates the TypeScript source text for `types/versions.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Returns an empty string when `versions` is empty.
|
|
18
|
+
* The returned string is formatted with Prettier.
|
|
19
|
+
*
|
|
20
|
+
* @param versions - Ordered list of unique, non-empty version strings.
|
|
21
|
+
* The first entry is the oldest version.
|
|
22
|
+
*/
|
|
23
|
+
export async function generateVersionsTsContent(versions) {
|
|
24
|
+
if (versions.length === 0) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
const versionsUnion = versions.map((v) => `"${v}"`).join(" | ");
|
|
28
|
+
const versionsGTE = buildVersionsGTE(versions);
|
|
29
|
+
const versionsGTEBody = Array.from(versionsGTE, ([v, gte]) => ` "${v}": ${gte.map((g) => `"${g}"`).join(" | ")};`).join("\n");
|
|
30
|
+
const source = [
|
|
31
|
+
"// This file is auto-generated by Counterfact. Do not edit.",
|
|
32
|
+
"",
|
|
33
|
+
`export type Versions = ${versionsUnion};`,
|
|
34
|
+
"",
|
|
35
|
+
"/**",
|
|
36
|
+
" * Maps each version to the set of versions that are greater than or equal to it.",
|
|
37
|
+
" * Used by `Versioned.minVersion()` to narrow which versions a handler must support.",
|
|
38
|
+
" */",
|
|
39
|
+
"export type VersionsGTE = {",
|
|
40
|
+
versionsGTEBody,
|
|
41
|
+
"};",
|
|
42
|
+
"",
|
|
43
|
+
"type VersionMap = Partial<Record<Versions, object>>;",
|
|
44
|
+
"",
|
|
45
|
+
"export type Versioned<",
|
|
46
|
+
" T extends VersionMap,",
|
|
47
|
+
" V extends keyof T & Versions = keyof T & Versions,",
|
|
48
|
+
"> = T[V] & {",
|
|
49
|
+
" version: V;",
|
|
50
|
+
" minVersion<M extends keyof T & Versions>(",
|
|
51
|
+
" min: M,",
|
|
52
|
+
" ): this is Versioned<T, Extract<V, VersionsGTE[M]>>;",
|
|
53
|
+
"};",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
return format(source, { parser: "typescript" });
|
|
57
|
+
}
|
package/package.json
CHANGED