@bymax-one/nest-core 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -1
- package/README.md +418 -48
- package/dist/index.cjs +344 -163
- package/dist/index.d.cts +327 -18
- package/dist/index.d.ts +327 -18
- package/dist/index.mjs +345 -165
- package/dist/openapi/index.cjs +401 -15
- package/dist/openapi/index.d.cts +159 -4
- package/dist/openapi/index.d.ts +159 -4
- package/dist/openapi/index.mjs +400 -17
- package/package.json +3 -1
package/dist/openapi/index.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var common = require('@nestjs/common');
|
|
4
|
+
var core = require('@nestjs/core');
|
|
4
5
|
|
|
5
6
|
// src/openapi/openapi.bootstrap.ts
|
|
6
7
|
|
|
@@ -16,6 +17,120 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
|
|
|
16
17
|
return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
// src/discovery.ts
|
|
21
|
+
function labelFor(className, token) {
|
|
22
|
+
return className === "" ? String(token) : className;
|
|
23
|
+
}
|
|
24
|
+
function findMarkedProviders(discovery, reflector, metadataKey) {
|
|
25
|
+
const marked = [];
|
|
26
|
+
for (const wrapper of discovery.getProviders()) {
|
|
27
|
+
const metatype = wrapper.metatype;
|
|
28
|
+
if (typeof metatype !== "function") {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (reflector.get(metadataKey, metatype) !== true) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
marked.push({ instance: wrapper.instance, label: labelFor(metatype.name, wrapper.name) });
|
|
35
|
+
}
|
|
36
|
+
return marked;
|
|
37
|
+
}
|
|
38
|
+
var BYMAX_OPENAPI_CONTRACT_VERSION = 1;
|
|
39
|
+
var BYMAX_OPENAPI_CONTRIBUTOR_METADATA = "bymax-one:openapi-contributor";
|
|
40
|
+
function BymaxOpenApiContributor() {
|
|
41
|
+
return common.SetMetadata(BYMAX_OPENAPI_CONTRIBUTOR_METADATA, true);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/openapi/openapi.contribution.ts
|
|
45
|
+
function createHandlerIdMap() {
|
|
46
|
+
const ids = /* @__PURE__ */ new Map();
|
|
47
|
+
const versions = /* @__PURE__ */ new Map();
|
|
48
|
+
return {
|
|
49
|
+
record: (controllerKey, methodKey, version, id) => {
|
|
50
|
+
const handlerKey = `${controllerKey}.${methodKey}`;
|
|
51
|
+
const seen = versions.get(handlerKey) ?? /* @__PURE__ */ new Set();
|
|
52
|
+
if (seen.has(version)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[BymaxCoreModule] two route handlers in this application answer to "${handlerKey}", so an OpenAPI fragment addressing it would apply to both. Handler keys are "<ControllerClassName>.<methodName>"; rename one of the controller classes.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
seen.add(version);
|
|
58
|
+
versions.set(handlerKey, seen);
|
|
59
|
+
ids.set(handlerKey, [...ids.get(handlerKey) ?? [], id]);
|
|
60
|
+
},
|
|
61
|
+
idsFor: (handlerKey) => ids.get(handlerKey) ?? [],
|
|
62
|
+
keys: () => [...ids.keys()]
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isContributor(instance) {
|
|
66
|
+
return typeof instance?.contributeOpenApi === "function";
|
|
67
|
+
}
|
|
68
|
+
function callContributor(contributor, label) {
|
|
69
|
+
try {
|
|
70
|
+
return contributor.contributeOpenApi();
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
73
|
+
throw new Error(
|
|
74
|
+
`[BymaxCoreModule] "${label}" failed to contribute to the OpenAPI document: ${reason}`,
|
|
75
|
+
{
|
|
76
|
+
cause
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function assertContractVersion(fragment, label) {
|
|
82
|
+
if (fragment.contractVersion === BYMAX_OPENAPI_CONTRACT_VERSION) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[BymaxCoreModule] "${label}" contributed a fragment written against OpenAPI contract version ${String(fragment.contractVersion)}, and this package speaks version ${String(BYMAX_OPENAPI_CONTRACT_VERSION)}. Upgrade whichever of the two is behind; the shapes are not interchangeable.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function resolveOperations(fragment, label, handlers) {
|
|
90
|
+
const entries = Object.entries(fragment.operations ?? {});
|
|
91
|
+
const unmatched = entries.filter(([handlerKey]) => handlers.idsFor(handlerKey).length === 0);
|
|
92
|
+
if (unmatched.length > 0) {
|
|
93
|
+
const known = handlers.keys();
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[BymaxCoreModule] "${label}" contributed fragments for ${unmatched.length} handler(s) this application does not have: ${unmatched.map(([key]) => key).join(", ")}. Keys are "<ControllerClassName>.<methodName>". The application has: ${known.length === 0 ? "(none)" : known.join(", ")}.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
entries.flatMap(
|
|
100
|
+
([handlerKey, operation]) => handlers.idsFor(handlerKey).map((id) => [id, operation])
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
function collectContributions(discovery, reflector, handlers) {
|
|
105
|
+
const marked = [...findMarkedProviders(discovery, reflector, BYMAX_OPENAPI_CONTRIBUTOR_METADATA)];
|
|
106
|
+
const labels = marked.map(({ label }) => label);
|
|
107
|
+
const duplicated = labels.filter((label, index) => labels.indexOf(label) !== index);
|
|
108
|
+
if (duplicated.length > 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[BymaxCoreModule] more than one OpenAPI contributor is named "${[...new Set(duplicated)].join('", "')}", so the order they merge in would depend on the container rather than on anything stated. Rename one of the contributor classes.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
marked.sort((left, right) => left.label.localeCompare(right.label));
|
|
114
|
+
return marked.map(({ instance, label }) => {
|
|
115
|
+
if (!isContributor(instance)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[BymaxCoreModule] "${label}" is marked @BymaxOpenApiContributor() but does not implement IOpenApiContributor: it must expose a "contributeOpenApi" method.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const fragment = callContributor(instance, label);
|
|
121
|
+
assertContractVersion(fragment, label);
|
|
122
|
+
return {
|
|
123
|
+
label,
|
|
124
|
+
operations: resolveOperations(fragment, label, handlers),
|
|
125
|
+
components: fragment.components ?? {}
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/route-defaults.ts
|
|
131
|
+
var DEFAULT_HEALTH_PATH = "health";
|
|
132
|
+
var DEFAULT_METRICS_PATH = "metrics";
|
|
133
|
+
|
|
19
134
|
// src/envelope/error-codes.ts
|
|
20
135
|
var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
|
|
21
136
|
var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
|
|
@@ -177,6 +292,19 @@ var CORE_PARAMETERS = {
|
|
|
177
292
|
};
|
|
178
293
|
|
|
179
294
|
// src/openapi/openapi.document.ts
|
|
295
|
+
var OPERATION_METHODS = [
|
|
296
|
+
"get",
|
|
297
|
+
"post",
|
|
298
|
+
"put",
|
|
299
|
+
"patch",
|
|
300
|
+
"delete",
|
|
301
|
+
"head",
|
|
302
|
+
"options",
|
|
303
|
+
"trace"
|
|
304
|
+
];
|
|
305
|
+
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
306
|
+
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
307
|
+
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
180
308
|
function asRecord(value) {
|
|
181
309
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
182
310
|
return {};
|
|
@@ -186,21 +314,216 @@ function asRecord(value) {
|
|
|
186
314
|
function mergeAbsent(existing, additions) {
|
|
187
315
|
return { ...additions, ...existing };
|
|
188
316
|
}
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
317
|
+
function operationsOf(item) {
|
|
318
|
+
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
319
|
+
}
|
|
320
|
+
function mergeResponses(existing, additions) {
|
|
321
|
+
const merged = new Map(Object.entries(existing));
|
|
322
|
+
for (const [status, value] of Object.entries(additions)) {
|
|
323
|
+
const contributed = asRecord(value);
|
|
324
|
+
const current = asRecord(merged.get(status));
|
|
325
|
+
if (current["content"] === void 0 && current["$ref"] === void 0) {
|
|
326
|
+
const described = current["description"] === void 0 || current["description"] === "" ? contributed["description"] : current["description"];
|
|
327
|
+
merged.set(status, { ...current, ...contributed, description: described });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return Object.fromEntries(merged);
|
|
331
|
+
}
|
|
332
|
+
function trimSlashes(segment) {
|
|
333
|
+
return segment.split("/").filter((part) => part !== "").join("/");
|
|
334
|
+
}
|
|
335
|
+
function routePath(prefix, suffix) {
|
|
336
|
+
return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
|
|
337
|
+
}
|
|
338
|
+
function healthRoutes(options) {
|
|
339
|
+
const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
|
|
340
|
+
return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
|
|
341
|
+
}
|
|
342
|
+
function metricsRoutes(options) {
|
|
343
|
+
return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
|
|
344
|
+
}
|
|
345
|
+
function indexOwnRoutes(options, prefixes) {
|
|
346
|
+
const normalized = prefixes.map(trimSlashes);
|
|
347
|
+
const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
|
|
348
|
+
const health = expand(healthRoutes(options));
|
|
349
|
+
const metrics = expand(metricsRoutes(options));
|
|
350
|
+
return {
|
|
351
|
+
isHealth: (path) => health.includes(path),
|
|
352
|
+
isMetrics: (path) => metrics.includes(path)
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function withoutDisabledRoutes(paths, options, routes) {
|
|
356
|
+
const disabled = (path) => !options.health.enabled && routes.isHealth(path) || !options.metrics.enabled && routes.isMetrics(path);
|
|
357
|
+
const kept = Object.entries(paths).map(([path, item]) => {
|
|
358
|
+
if (!disabled(path)) {
|
|
359
|
+
return [path, item];
|
|
360
|
+
}
|
|
361
|
+
const remaining = Object.fromEntries(
|
|
362
|
+
Object.entries(asRecord(item)).filter(([key]) => key !== "get")
|
|
201
363
|
);
|
|
364
|
+
return [path, operationsOf(remaining).length === 0 ? void 0 : remaining];
|
|
365
|
+
});
|
|
366
|
+
return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
|
|
367
|
+
}
|
|
368
|
+
function ownRouteSecurity(path, method, options, routes) {
|
|
369
|
+
if (method !== "get") {
|
|
370
|
+
return void 0;
|
|
371
|
+
}
|
|
372
|
+
if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
|
|
373
|
+
return [{ [METRICS_SCHEME_NAME]: [] }];
|
|
202
374
|
}
|
|
203
|
-
|
|
375
|
+
if (options.openapi.security.length > 0 && routes.isHealth(path)) {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
function operationKey(method, path) {
|
|
381
|
+
return `${method.toUpperCase()} ${path}`;
|
|
382
|
+
}
|
|
383
|
+
function coreResponses(path, options, routes) {
|
|
384
|
+
const responses = {};
|
|
385
|
+
if (options.envelope.enabled) {
|
|
386
|
+
responses["default"] = {
|
|
387
|
+
description: "Error envelope returned by every failing request.",
|
|
388
|
+
content: {
|
|
389
|
+
"application/json": { schema: { $ref: `#/components/schemas/${ERROR_ENVELOPE_SCHEMA}` } }
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (routes.isHealth(path)) {
|
|
394
|
+
responses["200"] = {
|
|
395
|
+
description: "Aggregated health report.",
|
|
396
|
+
content: {
|
|
397
|
+
"application/json": { schema: { $ref: `#/components/schemas/${HEALTH_RESPONSE_SCHEMA}` } }
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return responses;
|
|
402
|
+
}
|
|
403
|
+
function fragmentsFor(operationId, contributions) {
|
|
404
|
+
return contributions.flatMap(
|
|
405
|
+
(contribution) => Object.entries(contribution.operations).filter(([id]) => id === operationId).map(([, fragment]) => fragment)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
function mergeFragment(operation, fragment) {
|
|
409
|
+
const { responses, ...members } = fragment;
|
|
410
|
+
const merged = { ...members, ...operation };
|
|
411
|
+
if (responses !== void 0) {
|
|
412
|
+
merged["responses"] = mergeResponses(asRecord(operation["responses"]), asRecord(responses));
|
|
413
|
+
}
|
|
414
|
+
return merged;
|
|
415
|
+
}
|
|
416
|
+
function augmentOperation(operation, path, method, options, routes, contributions) {
|
|
417
|
+
const declaredByDocument = operation["security"] !== void 0;
|
|
418
|
+
let result = { ...operation };
|
|
419
|
+
for (const fragment of fragmentsFor(result["operationId"], contributions)) {
|
|
420
|
+
result = { ...mergeFragment(result, fragment) };
|
|
421
|
+
}
|
|
422
|
+
if (!declaredByDocument) {
|
|
423
|
+
const override = options.openapi.operationSecurity[operationKey(method, path)];
|
|
424
|
+
const describedByLibrary = result["security"] !== void 0;
|
|
425
|
+
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
|
|
426
|
+
if (security !== void 0) {
|
|
427
|
+
result["security"] = security;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (options.openapi.includeCoreSchemas) {
|
|
431
|
+
result["responses"] = mergeResponses(
|
|
432
|
+
asRecord(result["responses"]),
|
|
433
|
+
coreResponses(path, options, routes)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
function assertSchemesDeclared(openapi, schemes) {
|
|
439
|
+
const required = [...openapi.security, ...Object.values(openapi.operationSecurity).flat()];
|
|
440
|
+
const named = [...new Set(required.flatMap((requirement) => Object.keys(requirement)))];
|
|
441
|
+
const declared = Object.keys(schemes);
|
|
442
|
+
const missing = named.filter((name) => !declared.includes(name));
|
|
443
|
+
if (missing.length === 0) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
throw new Error(
|
|
447
|
+
`[BymaxCoreModule] openapi security names ${missing.length} scheme(s) that the document does not define: ${missing.join(", ")}. Declare them in openapi.securitySchemes, or drop the requirement. The document defines: ${declared.length === 0 ? "(none)" : declared.join(", ")}.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
function assertScrapeSchemeIsOurs(openapi, components, contributes) {
|
|
451
|
+
if (!contributes) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const declaredByConsumer = Object.keys(openapi.securitySchemes).includes(METRICS_SCHEME_NAME);
|
|
455
|
+
const declaredByDocument = Object.keys(asRecord(components["securitySchemes"])).includes(
|
|
456
|
+
METRICS_SCHEME_NAME
|
|
457
|
+
);
|
|
458
|
+
if (!declaredByConsumer && !declaredByDocument) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
throw new Error(
|
|
462
|
+
`[BymaxCoreModule] the security scheme "${METRICS_SCHEME_NAME}" is reserved: this package contributes it to document the bearer token the scrape endpoint checks, and it is already defined ${declaredByConsumer ? "in openapi.securitySchemes" : "by the generated document"}. Rename yours, or unset metrics.authToken if the endpoint is not protected.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
function documentedOperationKeys(paths) {
|
|
466
|
+
return Object.entries(paths).flatMap(
|
|
467
|
+
([path, item]) => operationsOf(item).map(([method]) => operationKey(method, path))
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
function assertOverridesMatch(paths, openapi) {
|
|
471
|
+
const configured = Object.keys(openapi.operationSecurity);
|
|
472
|
+
const documented = documentedOperationKeys(paths);
|
|
473
|
+
const unmatched = configured.filter((key) => !documented.includes(key));
|
|
474
|
+
if (unmatched.length === 0) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
throw new Error(
|
|
478
|
+
`[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
function augmentPaths(paths, options, routes, contributions) {
|
|
482
|
+
return Object.fromEntries(
|
|
483
|
+
Object.entries(paths).map(([path, item]) => {
|
|
484
|
+
const augmented = operationsOf(item).map(([method, operation]) => [
|
|
485
|
+
method,
|
|
486
|
+
augmentOperation(asRecord(operation), path, method, options, routes, contributions)
|
|
487
|
+
]);
|
|
488
|
+
return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
function augmentDocument(document, options, pathPrefixes = [""], contributions = []) {
|
|
493
|
+
const { openapi } = options;
|
|
494
|
+
const components = asRecord(document.components);
|
|
495
|
+
const merged = new Map(Object.entries(components));
|
|
496
|
+
if (openapi.includeCoreSchemas) {
|
|
497
|
+
merged.set("schemas", mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS));
|
|
498
|
+
merged.set("parameters", mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS));
|
|
499
|
+
}
|
|
500
|
+
const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
|
|
501
|
+
[METRICS_SCHEME_NAME]: {
|
|
502
|
+
type: "http",
|
|
503
|
+
scheme: "bearer",
|
|
504
|
+
description: "Bearer token required by the metrics scrape endpoint."
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
assertScrapeSchemeIsOurs(openapi, components, options.metrics.authToken !== void 0);
|
|
508
|
+
const declaredSchemes = mergeAbsent(asRecord(components["securitySchemes"]), {
|
|
509
|
+
...openapi.securitySchemes,
|
|
510
|
+
...scrapeScheme
|
|
511
|
+
});
|
|
512
|
+
if (Object.keys(declaredSchemes).length > 0) {
|
|
513
|
+
merged.set("securitySchemes", declaredSchemes);
|
|
514
|
+
}
|
|
515
|
+
for (const contribution of contributions) {
|
|
516
|
+
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
517
|
+
merged.set(member, mergeAbsent(asRecord(merged.get(member)), entries));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
assertSchemesDeclared(openapi, asRecord(merged.get("securitySchemes")));
|
|
521
|
+
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
522
|
+
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
523
|
+
assertOverridesMatch(served, openapi);
|
|
524
|
+
const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes, contributions) };
|
|
525
|
+
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
526
|
+
return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
|
|
204
527
|
}
|
|
205
528
|
|
|
206
529
|
// src/optional-peer.ts
|
|
@@ -234,6 +557,56 @@ function resolveCoreOptions(app) {
|
|
|
234
557
|
throw new Error(OPTIONS_UNRESOLVED_MESSAGE, { cause });
|
|
235
558
|
}
|
|
236
559
|
}
|
|
560
|
+
function readAppConfig(app) {
|
|
561
|
+
try {
|
|
562
|
+
return app.get(core.ApplicationConfig);
|
|
563
|
+
} catch {
|
|
564
|
+
return void 0;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function versionSegments(versioning) {
|
|
568
|
+
if (versioning === void 0 || versioning.type !== common.VersioningType.URI) {
|
|
569
|
+
return [""];
|
|
570
|
+
}
|
|
571
|
+
const prefix = versioning.prefix === false ? "" : versioning.prefix ?? "v";
|
|
572
|
+
const declared = versioning.defaultVersion;
|
|
573
|
+
if (declared === void 0) {
|
|
574
|
+
return [""];
|
|
575
|
+
}
|
|
576
|
+
const versions = Array.isArray(declared) ? declared : [declared];
|
|
577
|
+
return versions.map(
|
|
578
|
+
(version) => version === common.VERSION_NEUTRAL ? "" : `${prefix}${String(version)}`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
function readPathPrefixes(app) {
|
|
582
|
+
const config = readAppConfig(app);
|
|
583
|
+
if (config === void 0) {
|
|
584
|
+
return [""];
|
|
585
|
+
}
|
|
586
|
+
const globalPrefix = config.getGlobalPrefix();
|
|
587
|
+
return versionSegments(config.getVersioning()).map((segment) => `${globalPrefix}/${segment}`);
|
|
588
|
+
}
|
|
589
|
+
function recordingOperationIdFactory(handlers, configured) {
|
|
590
|
+
return (controllerKey, methodKey, version) => {
|
|
591
|
+
const id = configured === void 0 ? defaultOperationId(controllerKey, methodKey, version) : configured(controllerKey, methodKey, version);
|
|
592
|
+
handlers.record(controllerKey, methodKey, version, id);
|
|
593
|
+
return id;
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function defaultOperationId(controllerKey, methodKey, version) {
|
|
597
|
+
return version === void 0 ? `${controllerKey}_${methodKey}` : `${controllerKey}_${methodKey}_${version}`;
|
|
598
|
+
}
|
|
599
|
+
function readContributions(app, handlers) {
|
|
600
|
+
let discovery;
|
|
601
|
+
let reflector;
|
|
602
|
+
try {
|
|
603
|
+
discovery = app.get(core.DiscoveryService);
|
|
604
|
+
reflector = app.get(core.Reflector);
|
|
605
|
+
} catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
return collectContributions(discovery, reflector, handlers);
|
|
609
|
+
}
|
|
237
610
|
function buildConfig(builder, options) {
|
|
238
611
|
builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
|
|
239
612
|
for (const server of options.servers) {
|
|
@@ -243,7 +616,8 @@ function buildConfig(builder, options) {
|
|
|
243
616
|
}
|
|
244
617
|
async function applyBymaxOpenApi(app) {
|
|
245
618
|
const logger = new common.Logger("BymaxCoreModule");
|
|
246
|
-
const
|
|
619
|
+
const resolved = resolveCoreOptions(app);
|
|
620
|
+
const options = resolved.openapi;
|
|
247
621
|
if (isProductionRuntime()) {
|
|
248
622
|
if (options.suppressedInProduction || options.enabled) {
|
|
249
623
|
logger.warn(
|
|
@@ -257,7 +631,16 @@ async function applyBymaxOpenApi(app) {
|
|
|
257
631
|
}
|
|
258
632
|
const swagger = await loadSwagger();
|
|
259
633
|
const config = buildConfig(new swagger.DocumentBuilder(), options);
|
|
260
|
-
const
|
|
634
|
+
const handlers = createHandlerIdMap();
|
|
635
|
+
const generated = swagger.SwaggerModule.createDocument(app, config, {
|
|
636
|
+
operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
|
|
637
|
+
});
|
|
638
|
+
const document = augmentDocument(
|
|
639
|
+
generated,
|
|
640
|
+
resolved,
|
|
641
|
+
readPathPrefixes(app),
|
|
642
|
+
readContributions(app, handlers)
|
|
643
|
+
);
|
|
261
644
|
swagger.SwaggerModule.setup(options.path, app, document, {
|
|
262
645
|
jsonDocumentUrl: options.jsonPath
|
|
263
646
|
});
|
|
@@ -265,4 +648,7 @@ async function applyBymaxOpenApi(app) {
|
|
|
265
648
|
return { mounted: true, path: options.path };
|
|
266
649
|
}
|
|
267
650
|
|
|
651
|
+
exports.BYMAX_OPENAPI_CONTRACT_VERSION = BYMAX_OPENAPI_CONTRACT_VERSION;
|
|
652
|
+
exports.BYMAX_OPENAPI_CONTRIBUTOR_METADATA = BYMAX_OPENAPI_CONTRIBUTOR_METADATA;
|
|
653
|
+
exports.BymaxOpenApiContributor = BymaxOpenApiContributor;
|
|
268
654
|
exports.applyBymaxOpenApi = applyBymaxOpenApi;
|
package/dist/openapi/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { INestApplication } from '@nestjs/common';
|
|
1
|
+
import { INestApplication, CustomDecorator } from '@nestjs/common';
|
|
2
2
|
|
|
3
3
|
/** Why the document was not mounted. */
|
|
4
4
|
type OpenApiSkipReason =
|
|
@@ -30,10 +30,20 @@ interface OpenApiMountOutcome {
|
|
|
30
30
|
* It is safe to call unconditionally: with the feature disabled, or in
|
|
31
31
|
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
32
32
|
*
|
|
33
|
+
* Testing this under Jest needs one flag. `@nestjs/swagger` is loaded through a
|
|
34
|
+
* dynamic `import()`, which is what keeps the peer optional for everyone who
|
|
35
|
+
* never enables the document — and Jest's module registry cannot service a
|
|
36
|
+
* dynamic import without `NODE_OPTIONS=--experimental-vm-modules`. Without it,
|
|
37
|
+
* only the *enabled* path fails, with `dynamic import callback invoked without
|
|
38
|
+
* --experimental-vm-modules`; the disabled and production paths never reach the
|
|
39
|
+
* loader and pass either way, which is what makes the omission confusing.
|
|
40
|
+
*
|
|
33
41
|
* @param app - The created Nest application, not yet listening.
|
|
34
42
|
* @returns What happened: mounted, or skipped with a reason.
|
|
35
|
-
* @throws Error When `BymaxCoreModule` is not registered,
|
|
36
|
-
* enabled and the optional peer `@nestjs/swagger` is not installed
|
|
43
|
+
* @throws Error When `BymaxCoreModule` is not registered, when the feature is
|
|
44
|
+
* enabled and the optional peer `@nestjs/swagger` is not installed, or when
|
|
45
|
+
* `openapi.operationSecurity` addresses an operation the generated document
|
|
46
|
+
* does not contain.
|
|
37
47
|
* @example
|
|
38
48
|
* const app = await NestFactory.create(AppModule)
|
|
39
49
|
* await applyBymaxOpenApi(app)
|
|
@@ -41,4 +51,149 @@ interface OpenApiMountOutcome {
|
|
|
41
51
|
*/
|
|
42
52
|
declare function applyBymaxOpenApi(app: INestApplication): Promise<OpenApiMountOutcome>;
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Addresses one route handler, as `'<ControllerClassName>.<methodName>'`.
|
|
56
|
+
*
|
|
57
|
+
* Controller handlers only. A route the application mounts some other way — raw
|
|
58
|
+
* middleware, a router the framework never scanned — produces no operation in
|
|
59
|
+
* the generated document and therefore no handler to address; a fragment naming
|
|
60
|
+
* one fails the build rather than being quietly dropped.
|
|
61
|
+
*
|
|
62
|
+
* This is the key a library writes its fragments against, and it is a contract:
|
|
63
|
+
* `@nestjs/swagger` hands the same pair to the operation-id factory this package
|
|
64
|
+
* installs, so a fragment can be matched to the operation the scan produced
|
|
65
|
+
* without either side reconstructing a path.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately not the operation id itself. The id is whatever the ecosystem
|
|
68
|
+
* already produces — `AuthController_login` by default — and a library keying on
|
|
69
|
+
* it would break the moment a consumer supplied their own naming, while a
|
|
70
|
+
* consumer changing that naming would silently unmatch every fragment.
|
|
71
|
+
*
|
|
72
|
+
* @example 'AuthController.login'
|
|
73
|
+
* @example 'PasswordResetController.resetPassword'
|
|
74
|
+
*/
|
|
75
|
+
type OpenApiHandlerKey = `${string}.${string}`;
|
|
76
|
+
/**
|
|
77
|
+
* A single OpenAPI object, copied into the document and never interpreted.
|
|
78
|
+
* Modelled as an open record for the same reason the contributed schemas are: a
|
|
79
|
+
* partial local model of the specification would be a second contract to keep in
|
|
80
|
+
* sync with the real one.
|
|
81
|
+
*/
|
|
82
|
+
type OpenApiFragmentObject = Readonly<Record<string, unknown>>;
|
|
83
|
+
/**
|
|
84
|
+
* What a contributor hands over: operation fragments addressed by handler, and
|
|
85
|
+
* the components those fragments reference.
|
|
86
|
+
*
|
|
87
|
+
* The dialect is **OpenAPI 3.0**, which is what `@nestjs/swagger` produces —
|
|
88
|
+
* measured, not assumed: its `DocumentBuilder` writes `openapi: '3.0.0'` and a
|
|
89
|
+
* served document reports the same. A 3.1 fragment merged into a 3.0 document
|
|
90
|
+
* produces one that validates as neither, so nullability belongs in `nullable`
|
|
91
|
+
* rather than in a union type.
|
|
92
|
+
*/
|
|
93
|
+
interface OpenApiFragment {
|
|
94
|
+
/**
|
|
95
|
+
* Which revision of this contract the fragment is written against. Always
|
|
96
|
+
* {@link BYMAX_OPENAPI_CONTRACT_VERSION}.
|
|
97
|
+
*
|
|
98
|
+
* A fragment is data crossing a boundary between independently released
|
|
99
|
+
* packages, and on that boundary compile-time types protect nothing: a
|
|
100
|
+
* library compiled against one revision of this contract runs inside an
|
|
101
|
+
* application that installed another, each having type-checked against its
|
|
102
|
+
* own copy. Only the value travelling at runtime can say which shape it is.
|
|
103
|
+
* So it self-describes, and a revision this package does not know fails the
|
|
104
|
+
* document build naming both — the same reason a Kubernetes object carries
|
|
105
|
+
* `apiVersion` rather than trusting that client and server agree.
|
|
106
|
+
*
|
|
107
|
+
* A plain integer rather than a semver range: this is the shape of the
|
|
108
|
+
* exchange, not the version of any package, and it changes only when the
|
|
109
|
+
* shape does.
|
|
110
|
+
*/
|
|
111
|
+
readonly contractVersion: typeof BYMAX_OPENAPI_CONTRACT_VERSION;
|
|
112
|
+
/**
|
|
113
|
+
* Operation objects to merge, keyed by the handler that produced the
|
|
114
|
+
* operation. A key addressing a handler the document does not contain fails
|
|
115
|
+
* the build naming the contributor: a fragment for a renamed handler is a
|
|
116
|
+
* check that stopped running, which is worse than one that fails.
|
|
117
|
+
*/
|
|
118
|
+
readonly operations?: Readonly<Record<OpenApiHandlerKey, OpenApiFragmentObject>>;
|
|
119
|
+
/**
|
|
120
|
+
* Entries to merge under the document's `components`, keyed by member —
|
|
121
|
+
* `schemas`, `securitySchemes`, `responses`, and so on. Additive: an entry the
|
|
122
|
+
* document already defines under that name is kept.
|
|
123
|
+
*/
|
|
124
|
+
readonly components?: Readonly<Record<string, Readonly<Record<string, OpenApiFragmentObject>>>>;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A library that describes its own routes in the document.
|
|
128
|
+
*
|
|
129
|
+
* Called once, while the document is being built, and only when the OpenAPI
|
|
130
|
+
* feature is enabled — so a library implementing it costs an application that
|
|
131
|
+
* never builds a document nothing beyond one metadata entry.
|
|
132
|
+
*/
|
|
133
|
+
interface IOpenApiContributor {
|
|
134
|
+
/**
|
|
135
|
+
* Produce this library's fragments.
|
|
136
|
+
*
|
|
137
|
+
* Called after the application's options have resolved, so a contributor may
|
|
138
|
+
* derive its contribution from its own configuration — which is the case that
|
|
139
|
+
* makes this contract necessary rather than convenient. A library whose
|
|
140
|
+
* credential names or transport are configurable cannot state its security
|
|
141
|
+
* statically; only it can say what its resolved options mean.
|
|
142
|
+
*
|
|
143
|
+
* @returns The fragments to merge. Return an empty object to contribute
|
|
144
|
+
* nothing; throwing fails the document build with the contributor named.
|
|
145
|
+
*/
|
|
146
|
+
contributeOpenApi(): OpenApiFragment;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The revision of the fragment exchange this package speaks.
|
|
150
|
+
*
|
|
151
|
+
* Bumped only when the shape of {@link OpenApiFragment} changes in a way a
|
|
152
|
+
* previous revision's fragment would be misread under — not when a member is
|
|
153
|
+
* added, which older fragments simply do not carry.
|
|
154
|
+
*/
|
|
155
|
+
declare const BYMAX_OPENAPI_CONTRACT_VERSION = 1;
|
|
156
|
+
/**
|
|
157
|
+
* Reflect metadata key carrying the contributor marker.
|
|
158
|
+
*
|
|
159
|
+
* A literal string rather than a key from `DiscoveryService.createDecorator()`,
|
|
160
|
+
* which mints a random key per module load: this package ships one bundle per
|
|
161
|
+
* subpath, so a library decorating its class through `./openapi` would get a
|
|
162
|
+
* different key than the scan running from the package root and nothing would
|
|
163
|
+
* ever be discovered. The same reasoning governs the health and metrics markers.
|
|
164
|
+
* Namespaced so it cannot collide with a consumer's own metadata.
|
|
165
|
+
*/
|
|
166
|
+
declare const BYMAX_OPENAPI_CONTRIBUTOR_METADATA = "bymax-one:openapi-contributor";
|
|
167
|
+
/**
|
|
168
|
+
* Mark a provider class as an OpenAPI contributor, so `applyBymaxOpenApi` finds
|
|
169
|
+
* it without the application wiring anything.
|
|
170
|
+
*
|
|
171
|
+
* The class must implement {@link IOpenApiContributor}; a marked provider that
|
|
172
|
+
* does not fails the document build with a message naming it, rather than being
|
|
173
|
+
* skipped silently.
|
|
174
|
+
*
|
|
175
|
+
* @returns The class decorator carrying the marker.
|
|
176
|
+
* @example
|
|
177
|
+
* \@BymaxOpenApiContributor()
|
|
178
|
+
* \@Injectable()
|
|
179
|
+
* export class AuthOpenApi implements IOpenApiContributor {
|
|
180
|
+
* constructor(private readonly options: ResolvedAuthOptions) {}
|
|
181
|
+
* contributeOpenApi(): OpenApiFragment {
|
|
182
|
+
* return {
|
|
183
|
+
* components: {
|
|
184
|
+
* securitySchemes: {
|
|
185
|
+
* authCookie: { type: 'apiKey', in: 'cookie', name: this.options.cookies.accessTokenName },
|
|
186
|
+
* refreshCookie: { type: 'apiKey', in: 'cookie', name: this.options.cookies.refreshTokenName }
|
|
187
|
+
* }
|
|
188
|
+
* },
|
|
189
|
+
* operations: {
|
|
190
|
+
* 'AuthController.login': { security: [] },
|
|
191
|
+
* 'AuthController.refresh': { security: [{ refreshCookie: [] }] }
|
|
192
|
+
* }
|
|
193
|
+
* }
|
|
194
|
+
* }
|
|
195
|
+
* }
|
|
196
|
+
*/
|
|
197
|
+
declare function BymaxOpenApiContributor(): CustomDecorator<string>;
|
|
198
|
+
|
|
199
|
+
export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, type IOpenApiContributor, type OpenApiFragment, type OpenApiFragmentObject, type OpenApiHandlerKey, type OpenApiMountOutcome, type OpenApiSkipReason, applyBymaxOpenApi };
|