@bymax-one/nest-core 1.3.2 → 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 +142 -2
- package/README.md +222 -36
- package/dist/index.cjs +335 -161
- package/dist/index.d.cts +242 -16
- package/dist/index.d.ts +242 -16
- package/dist/index.mjs +336 -163
- package/dist/openapi/index.cjs +182 -19
- package/dist/openapi/index.d.cts +147 -2
- package/dist/openapi/index.d.ts +147 -2
- package/dist/openapi/index.mjs +182 -22
- package/package.json +3 -1
package/dist/openapi/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Logger, VersioningType, VERSION_NEUTRAL } from '@nestjs/common';
|
|
2
|
-
import { ApplicationConfig } from '@nestjs/core';
|
|
1
|
+
import { SetMetadata, Logger, VersioningType, VERSION_NEUTRAL } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, Reflector, ApplicationConfig } from '@nestjs/core';
|
|
3
3
|
|
|
4
4
|
// src/openapi/openapi.bootstrap.ts
|
|
5
5
|
|
|
@@ -15,6 +15,116 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
|
|
|
15
15
|
return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// src/discovery.ts
|
|
19
|
+
function labelFor(className, token) {
|
|
20
|
+
return className === "" ? String(token) : className;
|
|
21
|
+
}
|
|
22
|
+
function findMarkedProviders(discovery, reflector, metadataKey) {
|
|
23
|
+
const marked = [];
|
|
24
|
+
for (const wrapper of discovery.getProviders()) {
|
|
25
|
+
const metatype = wrapper.metatype;
|
|
26
|
+
if (typeof metatype !== "function") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (reflector.get(metadataKey, metatype) !== true) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
marked.push({ instance: wrapper.instance, label: labelFor(metatype.name, wrapper.name) });
|
|
33
|
+
}
|
|
34
|
+
return marked;
|
|
35
|
+
}
|
|
36
|
+
var BYMAX_OPENAPI_CONTRACT_VERSION = 1;
|
|
37
|
+
var BYMAX_OPENAPI_CONTRIBUTOR_METADATA = "bymax-one:openapi-contributor";
|
|
38
|
+
function BymaxOpenApiContributor() {
|
|
39
|
+
return SetMetadata(BYMAX_OPENAPI_CONTRIBUTOR_METADATA, true);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/openapi/openapi.contribution.ts
|
|
43
|
+
function createHandlerIdMap() {
|
|
44
|
+
const ids = /* @__PURE__ */ new Map();
|
|
45
|
+
const versions = /* @__PURE__ */ new Map();
|
|
46
|
+
return {
|
|
47
|
+
record: (controllerKey, methodKey, version, id) => {
|
|
48
|
+
const handlerKey = `${controllerKey}.${methodKey}`;
|
|
49
|
+
const seen = versions.get(handlerKey) ?? /* @__PURE__ */ new Set();
|
|
50
|
+
if (seen.has(version)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[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.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
seen.add(version);
|
|
56
|
+
versions.set(handlerKey, seen);
|
|
57
|
+
ids.set(handlerKey, [...ids.get(handlerKey) ?? [], id]);
|
|
58
|
+
},
|
|
59
|
+
idsFor: (handlerKey) => ids.get(handlerKey) ?? [],
|
|
60
|
+
keys: () => [...ids.keys()]
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function isContributor(instance) {
|
|
64
|
+
return typeof instance?.contributeOpenApi === "function";
|
|
65
|
+
}
|
|
66
|
+
function callContributor(contributor, label) {
|
|
67
|
+
try {
|
|
68
|
+
return contributor.contributeOpenApi();
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
71
|
+
throw new Error(
|
|
72
|
+
`[BymaxCoreModule] "${label}" failed to contribute to the OpenAPI document: ${reason}`,
|
|
73
|
+
{
|
|
74
|
+
cause
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function assertContractVersion(fragment, label) {
|
|
80
|
+
if (fragment.contractVersion === BYMAX_OPENAPI_CONTRACT_VERSION) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[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.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
function resolveOperations(fragment, label, handlers) {
|
|
88
|
+
const entries = Object.entries(fragment.operations ?? {});
|
|
89
|
+
const unmatched = entries.filter(([handlerKey]) => handlers.idsFor(handlerKey).length === 0);
|
|
90
|
+
if (unmatched.length > 0) {
|
|
91
|
+
const known = handlers.keys();
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[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(", ")}.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return Object.fromEntries(
|
|
97
|
+
entries.flatMap(
|
|
98
|
+
([handlerKey, operation]) => handlers.idsFor(handlerKey).map((id) => [id, operation])
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function collectContributions(discovery, reflector, handlers) {
|
|
103
|
+
const marked = [...findMarkedProviders(discovery, reflector, BYMAX_OPENAPI_CONTRIBUTOR_METADATA)];
|
|
104
|
+
const labels = marked.map(({ label }) => label);
|
|
105
|
+
const duplicated = labels.filter((label, index) => labels.indexOf(label) !== index);
|
|
106
|
+
if (duplicated.length > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[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.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
marked.sort((left, right) => left.label.localeCompare(right.label));
|
|
112
|
+
return marked.map(({ instance, label }) => {
|
|
113
|
+
if (!isContributor(instance)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[BymaxCoreModule] "${label}" is marked @BymaxOpenApiContributor() but does not implement IOpenApiContributor: it must expose a "contributeOpenApi" method.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const fragment = callContributor(instance, label);
|
|
119
|
+
assertContractVersion(fragment, label);
|
|
120
|
+
return {
|
|
121
|
+
label,
|
|
122
|
+
operations: resolveOperations(fragment, label, handlers),
|
|
123
|
+
components: fragment.components ?? {}
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
18
128
|
// src/route-defaults.ts
|
|
19
129
|
var DEFAULT_HEALTH_PATH = "health";
|
|
20
130
|
var DEFAULT_METRICS_PATH = "metrics";
|
|
@@ -207,7 +317,8 @@ function operationsOf(item) {
|
|
|
207
317
|
}
|
|
208
318
|
function mergeResponses(existing, additions) {
|
|
209
319
|
const merged = new Map(Object.entries(existing));
|
|
210
|
-
for (const [status,
|
|
320
|
+
for (const [status, value] of Object.entries(additions)) {
|
|
321
|
+
const contributed = asRecord(value);
|
|
211
322
|
const current = asRecord(merged.get(status));
|
|
212
323
|
if (current["content"] === void 0 && current["$ref"] === void 0) {
|
|
213
324
|
const described = current["description"] === void 0 || current["description"] === "" ? contributed["description"] : current["description"];
|
|
@@ -287,11 +398,29 @@ function coreResponses(path, options, routes) {
|
|
|
287
398
|
}
|
|
288
399
|
return responses;
|
|
289
400
|
}
|
|
290
|
-
function
|
|
291
|
-
|
|
292
|
-
|
|
401
|
+
function fragmentsFor(operationId, contributions) {
|
|
402
|
+
return contributions.flatMap(
|
|
403
|
+
(contribution) => Object.entries(contribution.operations).filter(([id]) => id === operationId).map(([, fragment]) => fragment)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
function mergeFragment(operation, fragment) {
|
|
407
|
+
const { responses, ...members } = fragment;
|
|
408
|
+
const merged = { ...members, ...operation };
|
|
409
|
+
if (responses !== void 0) {
|
|
410
|
+
merged["responses"] = mergeResponses(asRecord(operation["responses"]), asRecord(responses));
|
|
411
|
+
}
|
|
412
|
+
return merged;
|
|
413
|
+
}
|
|
414
|
+
function augmentOperation(operation, path, method, options, routes, contributions) {
|
|
415
|
+
const declaredByDocument = operation["security"] !== void 0;
|
|
416
|
+
let result = { ...operation };
|
|
417
|
+
for (const fragment of fragmentsFor(result["operationId"], contributions)) {
|
|
418
|
+
result = { ...mergeFragment(result, fragment) };
|
|
419
|
+
}
|
|
420
|
+
if (!declaredByDocument) {
|
|
293
421
|
const override = options.openapi.operationSecurity[operationKey(method, path)];
|
|
294
|
-
const
|
|
422
|
+
const describedByLibrary = result["security"] !== void 0;
|
|
423
|
+
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
|
|
295
424
|
if (security !== void 0) {
|
|
296
425
|
result["security"] = security;
|
|
297
426
|
}
|
|
@@ -347,24 +476,24 @@ function assertOverridesMatch(paths, openapi) {
|
|
|
347
476
|
`[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(", ")}.`
|
|
348
477
|
);
|
|
349
478
|
}
|
|
350
|
-
function augmentPaths(paths, options, routes) {
|
|
479
|
+
function augmentPaths(paths, options, routes, contributions) {
|
|
351
480
|
return Object.fromEntries(
|
|
352
481
|
Object.entries(paths).map(([path, item]) => {
|
|
353
482
|
const augmented = operationsOf(item).map(([method, operation]) => [
|
|
354
483
|
method,
|
|
355
|
-
augmentOperation(asRecord(operation), path, method, options, routes)
|
|
484
|
+
augmentOperation(asRecord(operation), path, method, options, routes, contributions)
|
|
356
485
|
]);
|
|
357
486
|
return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
|
|
358
487
|
})
|
|
359
488
|
);
|
|
360
489
|
}
|
|
361
|
-
function augmentDocument(document, options, pathPrefixes = [""]) {
|
|
490
|
+
function augmentDocument(document, options, pathPrefixes = [""], contributions = []) {
|
|
362
491
|
const { openapi } = options;
|
|
363
492
|
const components = asRecord(document.components);
|
|
364
|
-
const merged =
|
|
493
|
+
const merged = new Map(Object.entries(components));
|
|
365
494
|
if (openapi.includeCoreSchemas) {
|
|
366
|
-
merged
|
|
367
|
-
merged
|
|
495
|
+
merged.set("schemas", mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS));
|
|
496
|
+
merged.set("parameters", mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS));
|
|
368
497
|
}
|
|
369
498
|
const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
|
|
370
499
|
[METRICS_SCHEME_NAME]: {
|
|
@@ -374,20 +503,25 @@ function augmentDocument(document, options, pathPrefixes = [""]) {
|
|
|
374
503
|
}
|
|
375
504
|
};
|
|
376
505
|
assertScrapeSchemeIsOurs(openapi, components, options.metrics.authToken !== void 0);
|
|
377
|
-
const
|
|
506
|
+
const declaredSchemes = mergeAbsent(asRecord(components["securitySchemes"]), {
|
|
378
507
|
...openapi.securitySchemes,
|
|
379
508
|
...scrapeScheme
|
|
380
509
|
});
|
|
381
|
-
if (Object.keys(
|
|
382
|
-
merged
|
|
510
|
+
if (Object.keys(declaredSchemes).length > 0) {
|
|
511
|
+
merged.set("securitySchemes", declaredSchemes);
|
|
512
|
+
}
|
|
513
|
+
for (const contribution of contributions) {
|
|
514
|
+
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
515
|
+
merged.set(member, mergeAbsent(asRecord(merged.get(member)), entries));
|
|
516
|
+
}
|
|
383
517
|
}
|
|
384
|
-
assertSchemesDeclared(openapi,
|
|
518
|
+
assertSchemesDeclared(openapi, asRecord(merged.get("securitySchemes")));
|
|
385
519
|
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
386
520
|
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
387
521
|
assertOverridesMatch(served, openapi);
|
|
388
|
-
const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes) };
|
|
522
|
+
const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes, contributions) };
|
|
389
523
|
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
390
|
-
return { ...document, components: merged, ...paths, ...security };
|
|
524
|
+
return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
|
|
391
525
|
}
|
|
392
526
|
|
|
393
527
|
// src/optional-peer.ts
|
|
@@ -450,6 +584,27 @@ function readPathPrefixes(app) {
|
|
|
450
584
|
const globalPrefix = config.getGlobalPrefix();
|
|
451
585
|
return versionSegments(config.getVersioning()).map((segment) => `${globalPrefix}/${segment}`);
|
|
452
586
|
}
|
|
587
|
+
function recordingOperationIdFactory(handlers, configured) {
|
|
588
|
+
return (controllerKey, methodKey, version) => {
|
|
589
|
+
const id = configured === void 0 ? defaultOperationId(controllerKey, methodKey, version) : configured(controllerKey, methodKey, version);
|
|
590
|
+
handlers.record(controllerKey, methodKey, version, id);
|
|
591
|
+
return id;
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function defaultOperationId(controllerKey, methodKey, version) {
|
|
595
|
+
return version === void 0 ? `${controllerKey}_${methodKey}` : `${controllerKey}_${methodKey}_${version}`;
|
|
596
|
+
}
|
|
597
|
+
function readContributions(app, handlers) {
|
|
598
|
+
let discovery;
|
|
599
|
+
let reflector;
|
|
600
|
+
try {
|
|
601
|
+
discovery = app.get(DiscoveryService);
|
|
602
|
+
reflector = app.get(Reflector);
|
|
603
|
+
} catch {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
return collectContributions(discovery, reflector, handlers);
|
|
607
|
+
}
|
|
453
608
|
function buildConfig(builder, options) {
|
|
454
609
|
builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
|
|
455
610
|
for (const server of options.servers) {
|
|
@@ -474,10 +629,15 @@ async function applyBymaxOpenApi(app) {
|
|
|
474
629
|
}
|
|
475
630
|
const swagger = await loadSwagger();
|
|
476
631
|
const config = buildConfig(new swagger.DocumentBuilder(), options);
|
|
632
|
+
const handlers = createHandlerIdMap();
|
|
633
|
+
const generated = swagger.SwaggerModule.createDocument(app, config, {
|
|
634
|
+
operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
|
|
635
|
+
});
|
|
477
636
|
const document = augmentDocument(
|
|
478
|
-
|
|
637
|
+
generated,
|
|
479
638
|
resolved,
|
|
480
|
-
readPathPrefixes(app)
|
|
639
|
+
readPathPrefixes(app),
|
|
640
|
+
readContributions(app, handlers)
|
|
481
641
|
);
|
|
482
642
|
swagger.SwaggerModule.setup(options.path, app, document, {
|
|
483
643
|
jsonDocumentUrl: options.jsonPath
|
|
@@ -486,4 +646,4 @@ async function applyBymaxOpenApi(app) {
|
|
|
486
646
|
return { mounted: true, path: options.path };
|
|
487
647
|
}
|
|
488
648
|
|
|
489
|
-
export { applyBymaxOpenApi };
|
|
649
|
+
export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, applyBymaxOpenApi };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bymax-one/nest-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI documents in development, and OpenTelemetry trace correlation.",
|
|
5
5
|
"author": "Bymax One <support@bymax.one>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -136,6 +136,7 @@
|
|
|
136
136
|
"@nestjs/common": "^11.1.20",
|
|
137
137
|
"@nestjs/core": "^11.1.20",
|
|
138
138
|
"@nestjs/platform-express": "^11.1.20",
|
|
139
|
+
"@nestjs/platform-fastify": "^11.1.29",
|
|
139
140
|
"@nestjs/swagger": "^11.4.6",
|
|
140
141
|
"@nestjs/testing": "^11.1.20",
|
|
141
142
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -155,6 +156,7 @@
|
|
|
155
156
|
"eslint-plugin-import": "^2.32.0",
|
|
156
157
|
"eslint-plugin-prettier": "^5.5.5",
|
|
157
158
|
"eslint-plugin-security": "^4.0.0",
|
|
159
|
+
"fastify": "^5.11.3",
|
|
158
160
|
"globals": "^17.6.0",
|
|
159
161
|
"husky": "^9.1.7",
|
|
160
162
|
"jest": "^30.4.2",
|