@knitli/openapi-mcp 1.1.0 → 1.2.1
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 +49 -0
- package/dist/cli.js +45 -0
- package/dist/runtime/serialize.js +21 -9
- package/dist/slice.d.ts +22 -0
- package/dist/slice.js +247 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,55 @@ base64url SPKI public key for the config with:
|
|
|
75
75
|
node --input-type=module -e 'import {readFileSync} from "node:fs"; import {createPublicKey} from "node:crypto"; console.log(createPublicKey(readFileSync("operator/keys/openapi-mcp.pub")).export({type:"spki",format:"der"}).toString("base64url"))'
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### Slice a large document down to one deployment's catalog
|
|
79
|
+
|
|
80
|
+
`compile-release` enforces `maxDocumentKeys` (default 250,000) and
|
|
81
|
+
`maxDocumentNodes` (default 1,000,000) on the whole spec. A document like
|
|
82
|
+
Microsoft Graph's full v1.0 (over 1M nodes, served from a single `/v1.0`) will
|
|
83
|
+
never fit, no matter how few operations one deployment actually exposes. `slice`
|
|
84
|
+
filters an OpenAPI document down to the operations you select by `--tag` and/or
|
|
85
|
+
`--operation`, prunes every `components` entry nothing kept still references,
|
|
86
|
+
and rewrites `servers` to the bare origin `compile-release` requires (it rejects
|
|
87
|
+
any `servers[0].url` with a path component):
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
./node_modules/.bin/openapi-mcp slice \
|
|
91
|
+
--spec ./graph-v1.0.yaml --out ./sliced/mail.json \
|
|
92
|
+
--tag me.message --operation me.sendMail \
|
|
93
|
+
--max-document-keys 5000000 --max-document-nodes 20000000
|
|
94
|
+
./node_modules/.bin/openapi-mcp compile-release \
|
|
95
|
+
--spec ./sliced/mail.json --source-label graph-mail --source-revision revision-1 \
|
|
96
|
+
--catalog mail --release mail-1 --generation 1 \
|
|
97
|
+
--issuer example-operator --key-id signing-1 --policy-id local-policy-1 \
|
|
98
|
+
--allowed-origin https://graph.microsoft.com \
|
|
99
|
+
--out ./operator/releases --sign-key ./operator/keys/openapi-mcp.key
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`--max-document-keys` and `--max-document-nodes` only raise the limits `slice`
|
|
103
|
+
itself loads the source document under (`loadSpecV4`'s `CompilerLimits`); they
|
|
104
|
+
have no effect on the sliced output's own size, which is whatever fits under
|
|
105
|
+
`compile-release`'s (unraised) defaults. On success `slice` prints a summary
|
|
106
|
+
line, for example:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
paths=23 operations=33 schemas=30 keys=3027 (fits compile-release's default maxDocumentKeys=250000)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`keys=` is the sliced document's own key count, counted the same way the
|
|
113
|
+
compiler counts `maxDocumentKeys`; the number in parentheses is
|
|
114
|
+
`compile-release`'s own (unraised) default, so you can tell before running it
|
|
115
|
+
whether the selection is small enough. An `--operation` or `--tag` that matched
|
|
116
|
+
nothing is an error, not a silently smaller release — fix the typo it names.
|
|
117
|
+
OpenAPI 3.1 `webhooks` are not part of `paths` and are left untouched, dangling
|
|
118
|
+
refs and all; Graph's v1.0 document (3.0) has none.
|
|
119
|
+
|
|
120
|
+
`--optional <propertyName>` (repeatable) removes that property from every
|
|
121
|
+
`required` array anywhere in the sliced output — component schemas and inline
|
|
122
|
+
request, parameter and response schemas alike — dropping `required` entirely
|
|
123
|
+
where that empties it. Graph's OpenAPI document marks `@odata.type` `required` on nearly
|
|
124
|
+
every schema, but the Graph service itself never requires it on request
|
|
125
|
+
bodies, so pass `--optional '@odata.type'` when slicing Graph specs.
|
|
126
|
+
|
|
78
127
|
## Configure and start the local MCP server
|
|
79
128
|
|
|
80
129
|
Save this as `operator/config.json`, substituting absolute paths, the public key,
|
package/dist/cli.js
CHANGED
|
@@ -4,8 +4,10 @@ import { join } from "node:path";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { compile } from "./compile.js";
|
|
6
6
|
import { compileRelease } from "./release/compile-release.js";
|
|
7
|
+
import { DEFAULT_COMPILER_LIMITS, loadSpecV4 } from "./release/load-v4.js";
|
|
7
8
|
import { publishRelease } from "./release/publish.js";
|
|
8
9
|
import { generateKeypair, signArtifact, verifyArtifact } from "./sign.js";
|
|
10
|
+
import { countKeys, HTTP_METHODS, keyBudgetNote, sliceSpec } from "./slice.js";
|
|
9
11
|
const USAGE = `openapi-mcp — compile OpenAPI documents into signed MCP artifacts
|
|
10
12
|
|
|
11
13
|
compile --spec <path> --api <name> --out <path> [--append] [--permissions <path>] [--sign-key <path>] (legacy v3)
|
|
@@ -13,6 +15,7 @@ const USAGE = `openapi-mcp — compile OpenAPI documents into signed MCP artifac
|
|
|
13
15
|
--catalog <id> --release <id> --generation <n> --issuer <id> --key-id <id>
|
|
14
16
|
--policy-id <id> --allowed-origin <https-origin> --out <directory> --sign-key <path>
|
|
15
17
|
[--permissions <path>] [--reference-root <path> --reference-map <path>]
|
|
18
|
+
slice --spec <openapi.yaml|json> --out <sliced.json> [--tag <tag>]... [--operation <operationId>]... [--optional <propertyName>]... [--max-document-keys <n>] [--max-document-nodes <n>]
|
|
16
19
|
verify --artifact <path> --sig <path> --pub <path> (legacy v3 exact-file signature)
|
|
17
20
|
keygen [--out <dir>]
|
|
18
21
|
serve --config <absolute-config-path>
|
|
@@ -203,6 +206,48 @@ if (command === "compile-release") {
|
|
|
203
206
|
}
|
|
204
207
|
process.exit(0);
|
|
205
208
|
}
|
|
209
|
+
if (command === "slice") {
|
|
210
|
+
const { values } = parseOrFail({
|
|
211
|
+
args: rest,
|
|
212
|
+
options: {
|
|
213
|
+
spec: { type: "string" },
|
|
214
|
+
out: { type: "string" },
|
|
215
|
+
tag: { type: "string", multiple: true },
|
|
216
|
+
operation: { type: "string", multiple: true },
|
|
217
|
+
optional: { type: "string", multiple: true },
|
|
218
|
+
"max-document-keys": { type: "string" },
|
|
219
|
+
"max-document-nodes": { type: "string" },
|
|
220
|
+
},
|
|
221
|
+
strict: true,
|
|
222
|
+
});
|
|
223
|
+
if (!values.spec)
|
|
224
|
+
fail("--spec is required");
|
|
225
|
+
if (!values.out)
|
|
226
|
+
fail("--out is required");
|
|
227
|
+
try {
|
|
228
|
+
const document = (await loadSpecV4(values.spec, {
|
|
229
|
+
maxDocumentKeys: Number(values["max-document-keys"] ?? 5_000_000),
|
|
230
|
+
maxDocumentNodes: Number(values["max-document-nodes"] ?? 20_000_000),
|
|
231
|
+
}));
|
|
232
|
+
const sliced = sliceSpec(document, {
|
|
233
|
+
tags: values.tag ?? [],
|
|
234
|
+
operations: values.operation ?? [],
|
|
235
|
+
optional: values.optional ?? [],
|
|
236
|
+
});
|
|
237
|
+
await writeFile(values.out, `${JSON.stringify(sliced, null, 2)}\n`);
|
|
238
|
+
const paths = sliced.paths;
|
|
239
|
+
const operationCount = Object.values(paths).reduce((n, item) => n +
|
|
240
|
+
Object.keys(item).filter((k) => HTTP_METHODS.includes(k)).length, 0);
|
|
241
|
+
const schemaCount = Object.keys((sliced.components.schemas ?? {})).length;
|
|
242
|
+
const keys = countKeys(sliced);
|
|
243
|
+
console.log(`paths=${Object.keys(paths).length} operations=${operationCount} schemas=${schemaCount} ` +
|
|
244
|
+
`keys=${keys} (${keyBudgetNote(keys, DEFAULT_COMPILER_LIMITS.maxDocumentKeys)})`);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
fail(err instanceof Error ? err.message : String(err), { usage: false });
|
|
248
|
+
}
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
206
251
|
if (command !== "serve") {
|
|
207
252
|
if (command !== "compile") {
|
|
208
253
|
fail(command ? `unknown command "${command}"` : "no command given");
|
|
@@ -308,6 +308,18 @@ function validateDiscriminator(schema, branches, value) {
|
|
|
308
308
|
schemaFailure();
|
|
309
309
|
return selected[0];
|
|
310
310
|
}
|
|
311
|
+
/** Indices of the `anyOf`/`oneOf` branches the value validates against. When the schema also
|
|
312
|
+
* carries a discriminator, the branch it selects must be among them. */
|
|
313
|
+
function matchingBranches(schema, raw, value, closure, depth, context) {
|
|
314
|
+
const branches = schemaArray(raw);
|
|
315
|
+
const selected = validateDiscriminator(schema, branches, value);
|
|
316
|
+
const matches = branches
|
|
317
|
+
.map((branch, index) => validates(branch, value, closure, depth + 1, context) ? index : -1)
|
|
318
|
+
.filter((index) => index >= 0);
|
|
319
|
+
if (selected !== null && !matches.includes(selected))
|
|
320
|
+
schemaFailure();
|
|
321
|
+
return matches;
|
|
322
|
+
}
|
|
311
323
|
function validateReference(id, schema, value, closure, depth, context) {
|
|
312
324
|
const activeValues = context.activeReferences.get(id) ?? new Set();
|
|
313
325
|
if (activeValues.has(value))
|
|
@@ -349,22 +361,22 @@ function validateSchema(schema, value, closure, depth, context) {
|
|
|
349
361
|
validateSchema(branch, value, closure, depth + 1, context);
|
|
350
362
|
}
|
|
351
363
|
if (Object.hasOwn(schema, "anyOf")) {
|
|
352
|
-
const matches =
|
|
364
|
+
const matches = matchingBranches(schema, schema.anyOf, value, closure, depth, context);
|
|
353
365
|
if (matches.length === 0)
|
|
354
366
|
schemaFailure();
|
|
355
367
|
}
|
|
356
368
|
if (Object.hasOwn(schema, "oneOf")) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
const matches = branches
|
|
360
|
-
.map((branch, index) => validates(branch, value, closure, depth + 1, context) ? index : -1)
|
|
361
|
-
.filter((index) => index >= 0);
|
|
362
|
-
if (matches.length !== 1 || (selected !== null && matches[0] !== selected))
|
|
369
|
+
const matches = matchingBranches(schema, schema.oneOf, value, closure, depth, context);
|
|
370
|
+
if (matches.length !== 1)
|
|
363
371
|
schemaFailure();
|
|
364
372
|
}
|
|
365
|
-
|
|
373
|
+
// A `discriminator` with no `oneOf`/`anyOf` alongside it is OpenAPI 3.0's inheritance form
|
|
374
|
+
// (OpenAPI 3.0.3 §4.7.24.2, Discriminator Object, "composition and inheritance"): it names the
|
|
375
|
+
// property a *consumer* would branch on, but the schema itself is a plain `type: object`
|
|
376
|
+
// (optionally under `allOf`) with no selection for us to perform. Ignore it rather than reject,
|
|
377
|
+
// but keep the same shape check every other supported key gets.
|
|
378
|
+
else if (Object.hasOwn(schema, "discriminator") && !isObject(schema.discriminator))
|
|
366
379
|
schemaFailure();
|
|
367
|
-
}
|
|
368
380
|
if (Object.hasOwn(schema, "nullable")) {
|
|
369
381
|
if (typeof schema.nullable !== "boolean")
|
|
370
382
|
schemaFailure();
|
package/dist/slice.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
2
|
+
type Json = Record<string, unknown>;
|
|
3
|
+
/** Which operations survive: any of these tags, or any of these operation ids. `optional` names
|
|
4
|
+
* properties to drop from every `required` array in the sliced output — see `dropRequired`. */
|
|
5
|
+
export interface SliceSelection {
|
|
6
|
+
readonly tags: readonly string[];
|
|
7
|
+
readonly operations: readonly string[];
|
|
8
|
+
readonly optional?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Returns a new value with every `name` in `names` removed from every `required` array found
|
|
11
|
+
* anywhere in `document` — component schemas and inline schemas (paths, requestBodies,
|
|
12
|
+
* parameters) alike — deleting the `required` key entirely if that empties it. Does not mutate
|
|
13
|
+
* `document`. Graph's OpenAPI document marks `@odata.type` `required` on nearly every schema,
|
|
14
|
+
* but the service itself never requires it on request bodies. */
|
|
15
|
+
export declare function dropRequired(document: Json, names: ReadonlySet<string>): Json;
|
|
16
|
+
/** Number of object keys in a JSON value; compare with the compiler's `maxDocumentKeys` (250,000). */
|
|
17
|
+
export declare function countKeys(value: unknown): number;
|
|
18
|
+
/** Describes a key count against compile-release's default `maxDocumentKeys`, and what to do if it doesn't fit. */
|
|
19
|
+
export declare function keyBudgetNote(keys: number, limit: number): string;
|
|
20
|
+
/** Pure: returns a new document containing only the selected operations and what they reference. */
|
|
21
|
+
export declare function sliceSpec(document: Json, selection: SliceSelection): Json;
|
|
22
|
+
export {};
|
package/dist/slice.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Slice an OpenAPI 3.x document down to the operations a deployment will expose, prune the
|
|
2
|
+
// components nothing left references, and rewrite `servers` to the bare origin `compile-release`
|
|
3
|
+
// requires. This is what lets a document far beyond `maxDocumentKeys` (Microsoft Graph's full
|
|
4
|
+
// v1.0, served from `/v1.0`) be compiled as one catalog per deployment.
|
|
5
|
+
//
|
|
6
|
+
// OpenAPI 3.1 `webhooks` are deliberately left untouched: they are not part of `paths` and are
|
|
7
|
+
// spread through as-is, dangling refs and all, same as any other top-level key. Graph is 3.0 and
|
|
8
|
+
// has none.
|
|
9
|
+
export const HTTP_METHODS = [
|
|
10
|
+
"get",
|
|
11
|
+
"put",
|
|
12
|
+
"post",
|
|
13
|
+
"delete",
|
|
14
|
+
"options",
|
|
15
|
+
"head",
|
|
16
|
+
"patch",
|
|
17
|
+
"trace",
|
|
18
|
+
];
|
|
19
|
+
/** Returns a new value with every `name` in `names` removed from every `required` array found
|
|
20
|
+
* anywhere in `document` — component schemas and inline schemas (paths, requestBodies,
|
|
21
|
+
* parameters) alike — deleting the `required` key entirely if that empties it. Does not mutate
|
|
22
|
+
* `document`. Graph's OpenAPI document marks `@odata.type` `required` on nearly every schema,
|
|
23
|
+
* but the service itself never requires it on request bodies. */
|
|
24
|
+
export function dropRequired(document, names) {
|
|
25
|
+
function walk(value) {
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return value.map(walk);
|
|
28
|
+
if (!value || typeof value !== "object")
|
|
29
|
+
return value;
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const [key, item] of Object.entries(value)) {
|
|
32
|
+
if (key === "required" && Array.isArray(item)) {
|
|
33
|
+
const kept = item.filter((name) => typeof name !== "string" || !names.has(name));
|
|
34
|
+
if (kept.length > 0)
|
|
35
|
+
result[key] = kept;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
result[key] = walk(item);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
return walk(document);
|
|
43
|
+
}
|
|
44
|
+
/** Number of object keys in a JSON value; compare with the compiler's `maxDocumentKeys` (250,000). */
|
|
45
|
+
export function countKeys(value) {
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.reduce((sum, item) => sum + countKeys(item), 0);
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
return Object.entries(value).reduce((sum, [, item]) => sum + 1 + countKeys(item), 0);
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
/** Describes a key count against compile-release's default `maxDocumentKeys`, and what to do if it doesn't fit. */
|
|
54
|
+
export function keyBudgetNote(keys, limit) {
|
|
55
|
+
return keys <= limit
|
|
56
|
+
? `fits compile-release's default maxDocumentKeys=${limit}`
|
|
57
|
+
: `exceeds compile-release's default maxDocumentKeys=${limit}; pass --max-document-keys to compile-release`;
|
|
58
|
+
}
|
|
59
|
+
function collectRefs(value, into) {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const item of value)
|
|
62
|
+
collectRefs(item, into);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!value || typeof value !== "object")
|
|
66
|
+
return;
|
|
67
|
+
for (const [key, item] of Object.entries(value)) {
|
|
68
|
+
if (key === "$ref" &&
|
|
69
|
+
typeof item === "string" &&
|
|
70
|
+
item.startsWith("#/components/"))
|
|
71
|
+
into.add(item);
|
|
72
|
+
else
|
|
73
|
+
collectRefs(item, into);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** A `$ref` path segment is a URI-fragment-encoded JSON pointer token: decode `%xx`, then `~1`
|
|
77
|
+
* (`/`) and `~0` (`~`), in that order, so a component named e.g. `a/b` round-trips. */
|
|
78
|
+
function decodeRefSegment(raw) {
|
|
79
|
+
return decodeURIComponent(raw).replaceAll("~1", "/").replaceAll("~0", "~");
|
|
80
|
+
}
|
|
81
|
+
function refParts(ref) {
|
|
82
|
+
const [, , kind, name] = ref.split("/");
|
|
83
|
+
if (kind === undefined || name === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { kind, name: decodeRefSegment(name) };
|
|
86
|
+
}
|
|
87
|
+
function securitySchemeNames(requirements, into) {
|
|
88
|
+
if (!Array.isArray(requirements))
|
|
89
|
+
return;
|
|
90
|
+
for (const requirement of requirements) {
|
|
91
|
+
if (!requirement || typeof requirement !== "object")
|
|
92
|
+
continue;
|
|
93
|
+
for (const name of Object.keys(requirement))
|
|
94
|
+
into.add(name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Walks `document.paths`, keeping only operations matching `selection`, and reports which
|
|
98
|
+
* selectors actually matched something (an unmatched selector is the caller's problem, not
|
|
99
|
+
* this function's — checked by `sliceSpec`). */
|
|
100
|
+
function selectOperations(document, selection) {
|
|
101
|
+
const tags = new Set(selection.tags);
|
|
102
|
+
const operations = new Set(selection.operations);
|
|
103
|
+
const matchedTags = new Set();
|
|
104
|
+
const matchedOperations = new Set();
|
|
105
|
+
const keptOperations = [];
|
|
106
|
+
const paths = {};
|
|
107
|
+
for (const [path, item] of Object.entries((document.paths ?? {}))) {
|
|
108
|
+
const source = item;
|
|
109
|
+
const target = { ...source };
|
|
110
|
+
let keptAny = false;
|
|
111
|
+
for (const method of HTTP_METHODS) {
|
|
112
|
+
const operation = source[method];
|
|
113
|
+
if (!operation)
|
|
114
|
+
continue;
|
|
115
|
+
const operationTags = Array.isArray(operation.tags)
|
|
116
|
+
? operation.tags
|
|
117
|
+
: [];
|
|
118
|
+
const matchingTags = operationTags.filter((tag) => tags.has(tag));
|
|
119
|
+
const operationId = operation.operationId;
|
|
120
|
+
const matchesOperation = operationId !== undefined && operations.has(operationId);
|
|
121
|
+
if (matchingTags.length === 0 && !matchesOperation) {
|
|
122
|
+
delete target[method];
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const tag of matchingTags)
|
|
126
|
+
matchedTags.add(tag);
|
|
127
|
+
if (matchesOperation && operationId !== undefined)
|
|
128
|
+
matchedOperations.add(operationId);
|
|
129
|
+
keptAny = true;
|
|
130
|
+
keptOperations.push(operation);
|
|
131
|
+
}
|
|
132
|
+
if (!keptAny)
|
|
133
|
+
continue;
|
|
134
|
+
paths[path] = target;
|
|
135
|
+
}
|
|
136
|
+
if (matchedTags.size === 0 && matchedOperations.size === 0)
|
|
137
|
+
throw new Error("The selection selected no operations.");
|
|
138
|
+
const unmatched = [
|
|
139
|
+
...[...tags].filter((tag) => !matchedTags.has(tag)),
|
|
140
|
+
...[...operations].filter((id) => !matchedOperations.has(id)),
|
|
141
|
+
];
|
|
142
|
+
if (unmatched.length > 0)
|
|
143
|
+
throw new Error(`Selection matched nothing for: ${unmatched.join(", ")}`);
|
|
144
|
+
return { paths, keptOperations };
|
|
145
|
+
}
|
|
146
|
+
/** Transitive closure over `$ref` keys only, starting from the kept paths. Discriminator
|
|
147
|
+
* mappings are plain strings and are followed separately (`pruneDiscriminatorMapping`), so a
|
|
148
|
+
* pruned mapping target can be dropped instead of resolved. */
|
|
149
|
+
function resolveReachableRefs(paths, components) {
|
|
150
|
+
const reachable = new Set();
|
|
151
|
+
const pending = new Set();
|
|
152
|
+
collectRefs(paths, pending);
|
|
153
|
+
while (pending.size) {
|
|
154
|
+
const ref = pending.values().next().value;
|
|
155
|
+
pending.delete(ref);
|
|
156
|
+
if (reachable.has(ref))
|
|
157
|
+
continue;
|
|
158
|
+
reachable.add(ref);
|
|
159
|
+
const parts = refParts(ref);
|
|
160
|
+
const component = parts !== undefined ? components[parts.kind]?.[parts.name] : undefined;
|
|
161
|
+
if (component === undefined)
|
|
162
|
+
throw new Error(`Unresolvable reference ${ref}`);
|
|
163
|
+
const next = new Set();
|
|
164
|
+
collectRefs(component, next);
|
|
165
|
+
for (const item of next)
|
|
166
|
+
if (!reachable.has(item))
|
|
167
|
+
pending.add(item);
|
|
168
|
+
}
|
|
169
|
+
return reachable;
|
|
170
|
+
}
|
|
171
|
+
/** Security schemes are referenced by name in `security` requirements, not by `$ref`, so the
|
|
172
|
+
* closure in `resolveReachableRefs` never finds them; collect the names the kept operations (or
|
|
173
|
+
* the root, when an operation has no override) actually use. */
|
|
174
|
+
function collectUsedSecuritySchemes(document, keptOperations) {
|
|
175
|
+
const usedSchemes = new Set();
|
|
176
|
+
securitySchemeNames(document.security, usedSchemes);
|
|
177
|
+
for (const operation of keptOperations)
|
|
178
|
+
securitySchemeNames(operation.security, usedSchemes);
|
|
179
|
+
return usedSchemes;
|
|
180
|
+
}
|
|
181
|
+
function buildPrunedComponents(components, reachable, usedSchemes) {
|
|
182
|
+
const prunedComponents = {};
|
|
183
|
+
for (const ref of reachable) {
|
|
184
|
+
const parts = refParts(ref);
|
|
185
|
+
if (parts === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
const bucket = components[parts.kind];
|
|
188
|
+
if (bucket === undefined)
|
|
189
|
+
continue;
|
|
190
|
+
let component = bucket[parts.name];
|
|
191
|
+
if (parts.kind === "schemas" &&
|
|
192
|
+
component &&
|
|
193
|
+
typeof component === "object") {
|
|
194
|
+
component = pruneDiscriminatorMapping(component, reachable);
|
|
195
|
+
}
|
|
196
|
+
const target = prunedComponents[parts.kind] ?? {};
|
|
197
|
+
target[parts.name] = component;
|
|
198
|
+
prunedComponents[parts.kind] = target;
|
|
199
|
+
}
|
|
200
|
+
if (usedSchemes.size > 0) {
|
|
201
|
+
const schemes = components.securitySchemes ?? {};
|
|
202
|
+
const target = {};
|
|
203
|
+
for (const name of usedSchemes)
|
|
204
|
+
if (name in schemes)
|
|
205
|
+
target[name] = schemes[name];
|
|
206
|
+
if (Object.keys(target).length > 0)
|
|
207
|
+
prunedComponents.securitySchemes = target;
|
|
208
|
+
}
|
|
209
|
+
return prunedComponents;
|
|
210
|
+
}
|
|
211
|
+
/** Pure: returns a new document containing only the selected operations and what they reference. */
|
|
212
|
+
export function sliceSpec(document, selection) {
|
|
213
|
+
const { paths, keptOperations } = selectOperations(document, selection);
|
|
214
|
+
const components = (document.components ?? {});
|
|
215
|
+
const reachable = resolveReachableRefs(paths, components);
|
|
216
|
+
const usedSchemes = collectUsedSecuritySchemes(document, keptOperations);
|
|
217
|
+
const prunedComponents = buildPrunedComponents(components, reachable, usedSchemes);
|
|
218
|
+
const server = document.servers?.[0]?.url;
|
|
219
|
+
if (!server)
|
|
220
|
+
throw new Error("The document names no server.");
|
|
221
|
+
const sliced = {
|
|
222
|
+
...document,
|
|
223
|
+
servers: [{ url: new URL(server).origin }],
|
|
224
|
+
paths,
|
|
225
|
+
components: prunedComponents,
|
|
226
|
+
};
|
|
227
|
+
const optional = selection.optional ?? [];
|
|
228
|
+
return optional.length > 0 ? dropRequired(sliced, new Set(optional)) : sliced;
|
|
229
|
+
}
|
|
230
|
+
/** Drops `discriminator.mapping` entries whose target is not in the kept closure, on a shallow
|
|
231
|
+
* copy — the schema itself is left aliased to the source document when there is nothing to prune. */
|
|
232
|
+
function pruneDiscriminatorMapping(schema, reachable) {
|
|
233
|
+
const discriminator = schema.discriminator;
|
|
234
|
+
const mapping = discriminator?.mapping;
|
|
235
|
+
if (!discriminator || !mapping)
|
|
236
|
+
return schema;
|
|
237
|
+
const prunedMapping = {};
|
|
238
|
+
for (const [key, target] of Object.entries(mapping))
|
|
239
|
+
if (reachable.has(target))
|
|
240
|
+
prunedMapping[key] = target;
|
|
241
|
+
if (Object.keys(prunedMapping).length === Object.keys(mapping).length)
|
|
242
|
+
return schema;
|
|
243
|
+
return {
|
|
244
|
+
...schema,
|
|
245
|
+
discriminator: { ...discriminator, mapping: prunedMapping },
|
|
246
|
+
};
|
|
247
|
+
}
|
package/package.json
CHANGED