@bymax-one/nest-core 1.6.0 → 1.6.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/CHANGELOG.md +85 -1
- package/README.md +27 -1
- package/dist/openapi/index.cjs +108 -34
- package/dist/openapi/index.d.cts +97 -53
- package/dist/openapi/index.d.ts +97 -53
- package/dist/openapi/index.mjs +108 -34
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,89 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.6.1] - 2026-09-05
|
|
15
|
+
|
|
16
|
+
A library contributing an OpenAPI component under a name the application already
|
|
17
|
+
declared had its entry discarded, and neither side could tell. The document
|
|
18
|
+
validated, the served output looked exactly as it would have had nothing been
|
|
19
|
+
offered, and the contributing library received no signal at all — so the failure
|
|
20
|
+
was indistinguishable from success at both ends.
|
|
21
|
+
|
|
22
|
+
Two changes, and they are independent: an empty placeholder no longer counts as a
|
|
23
|
+
declaration, and every genuine collision is now reported.
|
|
24
|
+
|
|
25
|
+
**This patch adds public surface**, which a patch number cannot tell you:
|
|
26
|
+
`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type it holds.
|
|
27
|
+
Both are additive and nothing is removed or renamed, so the digit stays at a
|
|
28
|
+
patch under this project's versioning policy — which means this paragraph, rather
|
|
29
|
+
than the version, is what says there is something new to look for.
|
|
30
|
+
|
|
31
|
+
**Apply to a derived backend:** nothing is required, and no served document loses
|
|
32
|
+
anything it carried. A schema the application declares with a shape of its own
|
|
33
|
+
still wins, exactly as before. What changes is that a schema present only as
|
|
34
|
+
`{"type":"object","properties":{}}` — which `@nestjs/swagger` emits for every DTO
|
|
35
|
+
in a package built without its plugin — is now filled from the contribution
|
|
36
|
+
rather than preferred over it. Read `discarded` on the `OpenApiMountOutcome` to
|
|
37
|
+
see what a contributor offered and did not place; the boot log reports the same
|
|
38
|
+
entries.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type.**
|
|
43
|
+
Lists every contributed component entry the document already declared with a
|
|
44
|
+
shape of its own, naming the contributor, the member and the entry. Present
|
|
45
|
+
whenever the document mounted, empty when every contribution was placed — an
|
|
46
|
+
empty array reports that the merge looked and found none, which absence would
|
|
47
|
+
confuse with not having looked.
|
|
48
|
+
|
|
49
|
+
Carries the contributor's label because a collision is only actionable if you
|
|
50
|
+
know whose entry lost: an application with several contributing libraries would
|
|
51
|
+
otherwise be told a name was dropped without being told by whom.
|
|
52
|
+
|
|
53
|
+
A warning reports the same entries on the way up, elided into a count on the
|
|
54
|
+
same reasoning as the unsecured-operation line beside it — a library contributing a
|
|
55
|
+
schema per DTO turns one collision into a hundred, and the version that prints
|
|
56
|
+
them all is the one an operator learns to skip.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **A schema that describes no shape no longer counts as a declaration.** The
|
|
61
|
+
merge still prefers what the document carries: a consumer authored theirs on
|
|
62
|
+
purpose, and a library silently overwriting it is the worse defect wearing the
|
|
63
|
+
other sign. But `@nestjs/swagger` emits `{"type":"object","properties":{}}` for
|
|
64
|
+
every DTO in a package built without its plugin, so the name is never actually
|
|
65
|
+
missing and a contributed schema under it would never be written. The more
|
|
66
|
+
correct the library's own schema, the more completely it was discarded, because
|
|
67
|
+
a correct schema and an empty one collide on the same key.
|
|
68
|
+
|
|
69
|
+
This is the rule `mergeResponses` already applied to responses, arriving
|
|
70
|
+
through a different placeholder — a `200` carrying a description and no
|
|
71
|
+
`content` occupies its status the same way. Filling one in is additive: it
|
|
72
|
+
constrains nothing before and describes a shape after.
|
|
73
|
+
|
|
74
|
+
The test is deliberately narrow. `type` must be absent or `object`, because
|
|
75
|
+
`{"type":"string"}` carries no properties and is nonetheless complete;
|
|
76
|
+
reading "no properties" as "no shape" would discard a real schema, which is
|
|
77
|
+
the failure this path exists to prevent. An empty `required` and a
|
|
78
|
+
`description` do not change that; any other keyword, or a single property,
|
|
79
|
+
makes the entry a declaration. A description the document
|
|
80
|
+
wrote survives being filled in, the same carve-out `mergeResponses` makes.
|
|
81
|
+
|
|
82
|
+
The carve-out applies to `schemas` alone. A parameter carries `name` and `in`
|
|
83
|
+
rather than `properties`, so a schema-shaped emptiness test reads every one of
|
|
84
|
+
them as a placeholder and would let a contributor overwrite one the consumer
|
|
85
|
+
wrote — the precedence failure inverted, arriving through the fix for the
|
|
86
|
+
other one.
|
|
87
|
+
|
|
88
|
+
This package's own catalogue, under `openapi.includeCoreSchemas`, is placed by
|
|
89
|
+
the same rule and before any library. A placeholder under a catalogue name is
|
|
90
|
+
filled with the package's definition, so a library offering that name meets a
|
|
91
|
+
declaration and is reported rather than outranking the package.
|
|
92
|
+
|
|
93
|
+
- **`fastify` and `@nestjs/platform-fastify` devDependency floors raised to
|
|
94
|
+
`^5.12.5` and `^11.2.4`.** Shipped in the tarball's manifest only; `dist/` is
|
|
95
|
+
unchanged by them.
|
|
96
|
+
|
|
14
97
|
## [1.6.0] - 2026-08-29
|
|
15
98
|
|
|
16
99
|
A readiness check that fails without rejecting left no record an operator would
|
|
@@ -1003,4 +1086,5 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
1003
1086
|
[1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
|
|
1004
1087
|
[1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
|
|
1005
1088
|
[1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
|
|
1006
|
-
[
|
|
1089
|
+
[1.6.1]: https://github.com/bymaxone/nest-core/compare/v1.6.0...v1.6.1
|
|
1090
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.1...HEAD
|
package/README.md
CHANGED
|
@@ -1163,10 +1163,35 @@ can emit it once and never branch:
|
|
|
1163
1163
|
|
|
1164
1164
|
| Result | Meaning |
|
|
1165
1165
|
| ------------------------------------------ | ------------------------------------------------------- |
|
|
1166
|
-
| `{ mounted: true, path }`
|
|
1166
|
+
| `{ mounted: true, path, discarded }` | The UI and the document are served at `path`. |
|
|
1167
1167
|
| `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
|
|
1168
1168
|
| `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
|
|
1169
1169
|
|
|
1170
|
+
`discarded` lists every component entry a library contributed that the served
|
|
1171
|
+
document already defined with a shape of its own — a `DiscardedComponent` per
|
|
1172
|
+
entry, naming the contributor, the member and the name. It is empty when every
|
|
1173
|
+
contribution was placed, and the boot log reports the same entries.
|
|
1174
|
+
|
|
1175
|
+
A collision is not an error and does not stop the boot: the merge prefers
|
|
1176
|
+
whatever holds the name already. That is usually your own declaration, and you
|
|
1177
|
+
authored it on purpose — but it may also be a core schema this package
|
|
1178
|
+
contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
|
|
1179
|
+
entry, since contributors merge in class-name order. The report names what was
|
|
1180
|
+
dropped and by whom, not who owns the definition that won, so read the document
|
|
1181
|
+
before deciding what to remove.
|
|
1182
|
+
|
|
1183
|
+
It is reported because it is otherwise unobservable from either side: the
|
|
1184
|
+
contributing library cannot tell its entry lost, the application cannot tell one
|
|
1185
|
+
was offered, and the served document looks the same either way.
|
|
1186
|
+
|
|
1187
|
+
A schema present only as `{"type":"object","properties":{}}` is **not** a
|
|
1188
|
+
declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
|
|
1189
|
+
in a package built without its plugin, so such a name is filled from the
|
|
1190
|
+
contribution rather than preferred over it, and does not appear in `discarded`.
|
|
1191
|
+
An empty `required: []` and a `description` do not change that; anything else
|
|
1192
|
+
does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
|
|
1193
|
+
"any value" — and are kept.
|
|
1194
|
+
|
|
1170
1195
|
### Production is a closed door
|
|
1171
1196
|
|
|
1172
1197
|
`NODE_ENV` decides, and the decision is fail-closed: only `development` and
|
|
@@ -1773,6 +1798,7 @@ in the sections above.
|
|
|
1773
1798
|
| -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
|
|
1774
1799
|
| `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
|
|
1775
1800
|
| `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
|
|
1801
|
+
| `DiscardedComponent` | type | A contributed component entry the document already declared. |
|
|
1776
1802
|
| `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
|
|
1777
1803
|
| `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
|
|
1778
1804
|
| `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
|
package/dist/openapi/index.cjs
CHANGED
|
@@ -134,6 +134,82 @@ function collectContributions(discovery, reflector, handlers) {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// src/openapi/openapi.shape.ts
|
|
138
|
+
var OPERATION_METHODS = [
|
|
139
|
+
"get",
|
|
140
|
+
"post",
|
|
141
|
+
"put",
|
|
142
|
+
"patch",
|
|
143
|
+
"delete",
|
|
144
|
+
"head",
|
|
145
|
+
"options",
|
|
146
|
+
"trace"
|
|
147
|
+
];
|
|
148
|
+
function asRecord(value) {
|
|
149
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function operationsOf(item) {
|
|
155
|
+
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
156
|
+
}
|
|
157
|
+
function operationKey(method, path) {
|
|
158
|
+
return `${method.toUpperCase()} ${path}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/openapi/openapi.components.ts
|
|
162
|
+
function isEmptyRequired(key, value) {
|
|
163
|
+
return key === "required" && Array.isArray(value) && value.length === 0;
|
|
164
|
+
}
|
|
165
|
+
var IGNORABLE_KEYS = /* @__PURE__ */ new Set(["type", "properties", "description"]);
|
|
166
|
+
function describesNoShape(entry) {
|
|
167
|
+
const type = entry["type"];
|
|
168
|
+
if (type !== void 0 && type !== "object") {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const properties = entry["properties"];
|
|
172
|
+
if (!Object.hasOwn(entry, "properties") || asRecord(properties) !== properties) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(asRecord(properties)).length > 0) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return Object.entries(entry).every(
|
|
179
|
+
([key, value]) => IGNORABLE_KEYS.has(key) || isEmptyRequired(key, value)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
function placeComponentEntries(existing, member, offered, onKept) {
|
|
183
|
+
const next = new Map(Object.entries(asRecord(existing)));
|
|
184
|
+
for (const [name, contributed] of Object.entries(asRecord(offered))) {
|
|
185
|
+
const declared = next.get(name);
|
|
186
|
+
const held = asRecord(declared);
|
|
187
|
+
const replaceable = member === "schemas" && describesNoShape(held);
|
|
188
|
+
if (declared !== void 0 && !replaceable) {
|
|
189
|
+
onKept(name);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const described = held["description"];
|
|
193
|
+
next.set(
|
|
194
|
+
name,
|
|
195
|
+
described === void 0 || described === "" ? contributed : { ...asRecord(contributed), description: described }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return Object.fromEntries(next);
|
|
199
|
+
}
|
|
200
|
+
function mergeContributedComponents(merged, contributions) {
|
|
201
|
+
const discarded = [];
|
|
202
|
+
for (const contribution of contributions) {
|
|
203
|
+
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
204
|
+
const placed = placeComponentEntries(merged.get(member), member, entries, (name) => {
|
|
205
|
+
discarded.push({ contributor: contribution.label, member, name });
|
|
206
|
+
});
|
|
207
|
+
merged.set(member, placed);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return discarded;
|
|
211
|
+
}
|
|
212
|
+
|
|
137
213
|
// src/route-defaults.ts
|
|
138
214
|
var DEFAULT_HEALTH_PATH = "health";
|
|
139
215
|
var DEFAULT_METRICS_PATH = "metrics";
|
|
@@ -326,30 +402,6 @@ var CORE_PARAMETERS = {
|
|
|
326
402
|
}
|
|
327
403
|
};
|
|
328
404
|
|
|
329
|
-
// src/openapi/openapi.shape.ts
|
|
330
|
-
var OPERATION_METHODS = [
|
|
331
|
-
"get",
|
|
332
|
-
"post",
|
|
333
|
-
"put",
|
|
334
|
-
"patch",
|
|
335
|
-
"delete",
|
|
336
|
-
"head",
|
|
337
|
-
"options",
|
|
338
|
-
"trace"
|
|
339
|
-
];
|
|
340
|
-
function asRecord(value) {
|
|
341
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
342
|
-
return {};
|
|
343
|
-
}
|
|
344
|
-
return value;
|
|
345
|
-
}
|
|
346
|
-
function operationsOf(item) {
|
|
347
|
-
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
348
|
-
}
|
|
349
|
-
function operationKey(method, path) {
|
|
350
|
-
return `${method.toUpperCase()} ${path}`;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
405
|
// src/openapi/openapi.document.ts
|
|
354
406
|
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
355
407
|
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
@@ -541,8 +593,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
541
593
|
const components = asRecord(document.components);
|
|
542
594
|
const merged = new Map(Object.entries(components));
|
|
543
595
|
if (openapi.includeCoreSchemas) {
|
|
544
|
-
|
|
545
|
-
merged.set(
|
|
596
|
+
const keep = () => void 0;
|
|
597
|
+
merged.set(
|
|
598
|
+
"schemas",
|
|
599
|
+
placeComponentEntries(components["schemas"], "schemas", CORE_SCHEMAS, keep)
|
|
600
|
+
);
|
|
601
|
+
merged.set(
|
|
602
|
+
"parameters",
|
|
603
|
+
placeComponentEntries(components["parameters"], "parameters", CORE_PARAMETERS, keep)
|
|
604
|
+
);
|
|
546
605
|
}
|
|
547
606
|
const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
|
|
548
607
|
[METRICS_SCHEME_NAME]: {
|
|
@@ -559,11 +618,7 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
559
618
|
if (Object.keys(declaredSchemes).length > 0) {
|
|
560
619
|
merged.set("securitySchemes", declaredSchemes);
|
|
561
620
|
}
|
|
562
|
-
|
|
563
|
-
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
564
|
-
merged.set(member, mergeAbsent(asRecord(merged.get(member)), entries));
|
|
565
|
-
}
|
|
566
|
-
}
|
|
621
|
+
const discarded = mergeContributedComponents(merged, contributions);
|
|
567
622
|
assertSchemesDeclared(openapi, asRecord(merged.get("securitySchemes")));
|
|
568
623
|
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
569
624
|
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
@@ -578,7 +633,10 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
578
633
|
)
|
|
579
634
|
};
|
|
580
635
|
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
581
|
-
return {
|
|
636
|
+
return {
|
|
637
|
+
document: { ...document, components: Object.fromEntries(merged), ...paths, ...security },
|
|
638
|
+
discarded
|
|
639
|
+
};
|
|
582
640
|
}
|
|
583
641
|
|
|
584
642
|
// src/optional-peer.ts
|
|
@@ -673,6 +731,16 @@ function warnUnsecuredOperations(logger, keys) {
|
|
|
673
731
|
`a client generated from the OpenAPI document will send no credentials to ${keys.length} operation(s): ${listed}${elided > 0 ? `, and ${elided} more` : ""}. They state no security requirement, the document declares no default, and other operations in it do state one \u2014 so this is more often a missing openapi.security default than a public API. Set openapi.security, or state the intent per operation with an explicit [] in openapi.operationSecurity.`
|
|
674
732
|
);
|
|
675
733
|
}
|
|
734
|
+
function warnDiscardedComponents(logger, discarded) {
|
|
735
|
+
if (discarded.length === 0) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const elided = discarded.length - MAX_WARNED_OPERATIONS;
|
|
739
|
+
const listed = discarded.slice(0, MAX_WARNED_OPERATIONS).map(({ contributor, member, name }) => `${contributor} -> ${member}.${name}`).join(", ");
|
|
740
|
+
logger.warn(
|
|
741
|
+
`the served OpenAPI document already defines ${discarded.length} component entry(ies) a library also contributes: ${listed}${elided > 0 ? `, and ${elided} more` : ""}. The contributed version is not served, so a client generated from this document will not match that library's own types. What holds the name may be the application's own declaration, a core schema under openapi.includeCoreSchemas, or a contributor that sorted earlier \u2014 read the document before deciding what to remove.`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
676
744
|
function buildConfig(builder, options) {
|
|
677
745
|
builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
|
|
678
746
|
for (const server of options.servers) {
|
|
@@ -702,13 +770,19 @@ async function applyBymaxOpenApi(app) {
|
|
|
702
770
|
operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
|
|
703
771
|
});
|
|
704
772
|
const prefixes = readPathPrefixes(app);
|
|
705
|
-
const document = augmentDocument(
|
|
773
|
+
const { document, discarded } = augmentDocument(
|
|
774
|
+
generated,
|
|
775
|
+
resolved,
|
|
776
|
+
prefixes,
|
|
777
|
+
readContributions(app, handlers)
|
|
778
|
+
);
|
|
706
779
|
warnUnsecuredOperations(logger, unsecuredOperations(document, resolved, prefixes));
|
|
780
|
+
warnDiscardedComponents(logger, discarded);
|
|
707
781
|
swagger.SwaggerModule.setup(options.path, app, document, {
|
|
708
782
|
jsonDocumentUrl: options.jsonPath
|
|
709
783
|
});
|
|
710
784
|
logger.log(`OpenAPI document served at "/${options.path}" (JSON at "/${options.jsonPath}")`);
|
|
711
|
-
return { mounted: true, path: options.path };
|
|
785
|
+
return { mounted: true, path: options.path, discarded };
|
|
712
786
|
}
|
|
713
787
|
|
|
714
788
|
exports.BYMAX_OPENAPI_CONTRACT_VERSION = BYMAX_OPENAPI_CONTRACT_VERSION;
|
package/dist/openapi/index.d.cts
CHANGED
|
@@ -1,55 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/** Why the document was not mounted. */
|
|
4
|
-
type OpenApiSkipReason =
|
|
5
|
-
/** The consumer never enabled the feature. */
|
|
6
|
-
'disabled'
|
|
7
|
-
/** The runtime is production, where the document is never served. */
|
|
8
|
-
| 'production';
|
|
9
|
-
/** What {@link applyBymaxOpenApi} did, so a caller can assert on it. */
|
|
10
|
-
interface OpenApiMountOutcome {
|
|
11
|
-
/** Whether the document and its UI were mounted. */
|
|
12
|
-
mounted: boolean;
|
|
13
|
-
/** Present only when `mounted` is `false`. */
|
|
14
|
-
reason?: OpenApiSkipReason;
|
|
15
|
-
/** The route the UI was mounted at. Present only when `mounted` is `true`. */
|
|
16
|
-
path?: string;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Build and mount the OpenAPI document and its interactive UI, when the
|
|
20
|
-
* configuration and the runtime both allow it.
|
|
21
|
-
*
|
|
22
|
-
* Call it once, after `NestFactory.create` and BEFORE the application starts
|
|
23
|
-
* listening. The ordering is not a style preference: mounting the document
|
|
24
|
-
* re-registers routes on the HTTP adapter, and on Express 5 doing that against
|
|
25
|
-
* an already-initialized application replaces the router — every route the
|
|
26
|
-
* application had, including its own controllers and this package's health
|
|
27
|
-
* endpoints, stops resolving. `app.listen()` performs that initialization, so
|
|
28
|
-
* "before listening" is the whole rule.
|
|
29
|
-
*
|
|
30
|
-
* It is safe to call unconditionally: with the feature disabled, or in
|
|
31
|
-
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
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
|
-
*
|
|
41
|
-
* @param app - The created Nest application, not yet listening.
|
|
42
|
-
* @returns What happened: mounted, or skipped with a reason.
|
|
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.
|
|
47
|
-
* @example
|
|
48
|
-
* const app = await NestFactory.create(AppModule)
|
|
49
|
-
* await applyBymaxOpenApi(app)
|
|
50
|
-
* await app.listen(3000)
|
|
51
|
-
*/
|
|
52
|
-
declare function applyBymaxOpenApi(app: INestApplication): Promise<OpenApiMountOutcome>;
|
|
1
|
+
import { CustomDecorator, INestApplication } from '@nestjs/common';
|
|
53
2
|
|
|
54
3
|
/**
|
|
55
4
|
* Addresses one route handler, as `'<ControllerClassName>.<methodName>'`.
|
|
@@ -196,4 +145,99 @@ declare const BYMAX_OPENAPI_CONTRIBUTOR_METADATA = "bymax-one:openapi-contributo
|
|
|
196
145
|
*/
|
|
197
146
|
declare function BymaxOpenApiContributor(): CustomDecorator<string>;
|
|
198
147
|
|
|
199
|
-
|
|
148
|
+
/**
|
|
149
|
+
* @fileoverview Placing offered entries — this package's catalogue and every
|
|
150
|
+
* library contribution — into a document's `components`, and reporting what a
|
|
151
|
+
* contributor could not place.
|
|
152
|
+
*
|
|
153
|
+
* A module of its own because the rule here is not the document's rule: everywhere else the merge is "the existing side wins", and this is the
|
|
154
|
+
* one place that asks whether the existing side says anything at all. Keeping
|
|
155
|
+
* the question and its narrow carve-out together makes the exception legible as
|
|
156
|
+
* an exception.
|
|
157
|
+
* @layer internal
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One component entry a contributor offered and the document already declared.
|
|
162
|
+
*
|
|
163
|
+
* Carries the contributor's label because a collision is only actionable if you
|
|
164
|
+
* know who lost it: an application with several contributing libraries would
|
|
165
|
+
* otherwise be told a name was dropped without being told whose it was.
|
|
166
|
+
*/
|
|
167
|
+
interface DiscardedComponent {
|
|
168
|
+
/** The contributing class, as {@link ResolvedContribution.label} names it. */
|
|
169
|
+
readonly contributor: string;
|
|
170
|
+
/** The component member, such as `schemas` or `parameters`. */
|
|
171
|
+
readonly member: string;
|
|
172
|
+
/** The entry's name within that member. */
|
|
173
|
+
readonly name: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Why the document was not mounted. */
|
|
177
|
+
type OpenApiSkipReason =
|
|
178
|
+
/** The consumer never enabled the feature. */
|
|
179
|
+
'disabled'
|
|
180
|
+
/** The runtime is production, where the document is never served. */
|
|
181
|
+
| 'production';
|
|
182
|
+
/** What {@link applyBymaxOpenApi} did, so a caller can assert on it. */
|
|
183
|
+
interface OpenApiMountOutcome {
|
|
184
|
+
/** Whether the document and its UI were mounted. */
|
|
185
|
+
mounted: boolean;
|
|
186
|
+
/** Present only when `mounted` is `false`. */
|
|
187
|
+
reason?: OpenApiSkipReason;
|
|
188
|
+
/** The route the UI was mounted at. Present only when `mounted` is `true`. */
|
|
189
|
+
path?: string;
|
|
190
|
+
/**
|
|
191
|
+
* Component entries a contributor offered that the served document already
|
|
192
|
+
* defined with a shape of its own. Present only when `mounted` is `true`;
|
|
193
|
+
* empty when every contribution was placed.
|
|
194
|
+
*
|
|
195
|
+
* The merge prefers whatever holds the name already, so a collision is not an
|
|
196
|
+
* error and does not stop the boot. The holder is usually the application's
|
|
197
|
+
* own declaration, but it may be a core schema this package contributes under
|
|
198
|
+
* `openapi.includeCoreSchemas` or an earlier contributor's entry — the report
|
|
199
|
+
* names what was dropped and by whom, not who owns the winning definition.
|
|
200
|
+
*
|
|
201
|
+
* It is reported because it is otherwise unobservable from either side: the
|
|
202
|
+
* contributing library cannot tell its entry was dropped, and the application
|
|
203
|
+
* cannot tell one was offered.
|
|
204
|
+
*/
|
|
205
|
+
discarded?: readonly DiscardedComponent[];
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build and mount the OpenAPI document and its interactive UI, when the
|
|
209
|
+
* configuration and the runtime both allow it.
|
|
210
|
+
*
|
|
211
|
+
* Call it once, after `NestFactory.create` and BEFORE the application starts
|
|
212
|
+
* listening. The ordering is not a style preference: mounting the document
|
|
213
|
+
* re-registers routes on the HTTP adapter, and on Express 5 doing that against
|
|
214
|
+
* an already-initialized application replaces the router — every route the
|
|
215
|
+
* application had, including its own controllers and this package's health
|
|
216
|
+
* endpoints, stops resolving. `app.listen()` performs that initialization, so
|
|
217
|
+
* "before listening" is the whole rule.
|
|
218
|
+
*
|
|
219
|
+
* It is safe to call unconditionally: with the feature disabled, or in
|
|
220
|
+
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
221
|
+
*
|
|
222
|
+
* Testing this under Jest needs one flag. `@nestjs/swagger` is loaded through a
|
|
223
|
+
* dynamic `import()`, which is what keeps the peer optional for everyone who
|
|
224
|
+
* never enables the document — and Jest's module registry cannot service a
|
|
225
|
+
* dynamic import without `NODE_OPTIONS=--experimental-vm-modules`. Without it,
|
|
226
|
+
* only the *enabled* path fails, with `dynamic import callback invoked without
|
|
227
|
+
* --experimental-vm-modules`; the disabled and production paths never reach the
|
|
228
|
+
* loader and pass either way, which is what makes the omission confusing.
|
|
229
|
+
*
|
|
230
|
+
* @param app - The created Nest application, not yet listening.
|
|
231
|
+
* @returns What happened: mounted, or skipped with a reason.
|
|
232
|
+
* @throws Error When `BymaxCoreModule` is not registered, when the feature is
|
|
233
|
+
* enabled and the optional peer `@nestjs/swagger` is not installed, or when
|
|
234
|
+
* `openapi.operationSecurity` addresses an operation the generated document
|
|
235
|
+
* does not contain.
|
|
236
|
+
* @example
|
|
237
|
+
* const app = await NestFactory.create(AppModule)
|
|
238
|
+
* await applyBymaxOpenApi(app)
|
|
239
|
+
* await app.listen(3000)
|
|
240
|
+
*/
|
|
241
|
+
declare function applyBymaxOpenApi(app: INestApplication): Promise<OpenApiMountOutcome>;
|
|
242
|
+
|
|
243
|
+
export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, type DiscardedComponent, type IOpenApiContributor, type OpenApiFragment, type OpenApiFragmentObject, type OpenApiHandlerKey, type OpenApiMountOutcome, type OpenApiSkipReason, applyBymaxOpenApi };
|
package/dist/openapi/index.d.ts
CHANGED
|
@@ -1,55 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/** Why the document was not mounted. */
|
|
4
|
-
type OpenApiSkipReason =
|
|
5
|
-
/** The consumer never enabled the feature. */
|
|
6
|
-
'disabled'
|
|
7
|
-
/** The runtime is production, where the document is never served. */
|
|
8
|
-
| 'production';
|
|
9
|
-
/** What {@link applyBymaxOpenApi} did, so a caller can assert on it. */
|
|
10
|
-
interface OpenApiMountOutcome {
|
|
11
|
-
/** Whether the document and its UI were mounted. */
|
|
12
|
-
mounted: boolean;
|
|
13
|
-
/** Present only when `mounted` is `false`. */
|
|
14
|
-
reason?: OpenApiSkipReason;
|
|
15
|
-
/** The route the UI was mounted at. Present only when `mounted` is `true`. */
|
|
16
|
-
path?: string;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Build and mount the OpenAPI document and its interactive UI, when the
|
|
20
|
-
* configuration and the runtime both allow it.
|
|
21
|
-
*
|
|
22
|
-
* Call it once, after `NestFactory.create` and BEFORE the application starts
|
|
23
|
-
* listening. The ordering is not a style preference: mounting the document
|
|
24
|
-
* re-registers routes on the HTTP adapter, and on Express 5 doing that against
|
|
25
|
-
* an already-initialized application replaces the router — every route the
|
|
26
|
-
* application had, including its own controllers and this package's health
|
|
27
|
-
* endpoints, stops resolving. `app.listen()` performs that initialization, so
|
|
28
|
-
* "before listening" is the whole rule.
|
|
29
|
-
*
|
|
30
|
-
* It is safe to call unconditionally: with the feature disabled, or in
|
|
31
|
-
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
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
|
-
*
|
|
41
|
-
* @param app - The created Nest application, not yet listening.
|
|
42
|
-
* @returns What happened: mounted, or skipped with a reason.
|
|
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.
|
|
47
|
-
* @example
|
|
48
|
-
* const app = await NestFactory.create(AppModule)
|
|
49
|
-
* await applyBymaxOpenApi(app)
|
|
50
|
-
* await app.listen(3000)
|
|
51
|
-
*/
|
|
52
|
-
declare function applyBymaxOpenApi(app: INestApplication): Promise<OpenApiMountOutcome>;
|
|
1
|
+
import { CustomDecorator, INestApplication } from '@nestjs/common';
|
|
53
2
|
|
|
54
3
|
/**
|
|
55
4
|
* Addresses one route handler, as `'<ControllerClassName>.<methodName>'`.
|
|
@@ -196,4 +145,99 @@ declare const BYMAX_OPENAPI_CONTRIBUTOR_METADATA = "bymax-one:openapi-contributo
|
|
|
196
145
|
*/
|
|
197
146
|
declare function BymaxOpenApiContributor(): CustomDecorator<string>;
|
|
198
147
|
|
|
199
|
-
|
|
148
|
+
/**
|
|
149
|
+
* @fileoverview Placing offered entries — this package's catalogue and every
|
|
150
|
+
* library contribution — into a document's `components`, and reporting what a
|
|
151
|
+
* contributor could not place.
|
|
152
|
+
*
|
|
153
|
+
* A module of its own because the rule here is not the document's rule: everywhere else the merge is "the existing side wins", and this is the
|
|
154
|
+
* one place that asks whether the existing side says anything at all. Keeping
|
|
155
|
+
* the question and its narrow carve-out together makes the exception legible as
|
|
156
|
+
* an exception.
|
|
157
|
+
* @layer internal
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One component entry a contributor offered and the document already declared.
|
|
162
|
+
*
|
|
163
|
+
* Carries the contributor's label because a collision is only actionable if you
|
|
164
|
+
* know who lost it: an application with several contributing libraries would
|
|
165
|
+
* otherwise be told a name was dropped without being told whose it was.
|
|
166
|
+
*/
|
|
167
|
+
interface DiscardedComponent {
|
|
168
|
+
/** The contributing class, as {@link ResolvedContribution.label} names it. */
|
|
169
|
+
readonly contributor: string;
|
|
170
|
+
/** The component member, such as `schemas` or `parameters`. */
|
|
171
|
+
readonly member: string;
|
|
172
|
+
/** The entry's name within that member. */
|
|
173
|
+
readonly name: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Why the document was not mounted. */
|
|
177
|
+
type OpenApiSkipReason =
|
|
178
|
+
/** The consumer never enabled the feature. */
|
|
179
|
+
'disabled'
|
|
180
|
+
/** The runtime is production, where the document is never served. */
|
|
181
|
+
| 'production';
|
|
182
|
+
/** What {@link applyBymaxOpenApi} did, so a caller can assert on it. */
|
|
183
|
+
interface OpenApiMountOutcome {
|
|
184
|
+
/** Whether the document and its UI were mounted. */
|
|
185
|
+
mounted: boolean;
|
|
186
|
+
/** Present only when `mounted` is `false`. */
|
|
187
|
+
reason?: OpenApiSkipReason;
|
|
188
|
+
/** The route the UI was mounted at. Present only when `mounted` is `true`. */
|
|
189
|
+
path?: string;
|
|
190
|
+
/**
|
|
191
|
+
* Component entries a contributor offered that the served document already
|
|
192
|
+
* defined with a shape of its own. Present only when `mounted` is `true`;
|
|
193
|
+
* empty when every contribution was placed.
|
|
194
|
+
*
|
|
195
|
+
* The merge prefers whatever holds the name already, so a collision is not an
|
|
196
|
+
* error and does not stop the boot. The holder is usually the application's
|
|
197
|
+
* own declaration, but it may be a core schema this package contributes under
|
|
198
|
+
* `openapi.includeCoreSchemas` or an earlier contributor's entry — the report
|
|
199
|
+
* names what was dropped and by whom, not who owns the winning definition.
|
|
200
|
+
*
|
|
201
|
+
* It is reported because it is otherwise unobservable from either side: the
|
|
202
|
+
* contributing library cannot tell its entry was dropped, and the application
|
|
203
|
+
* cannot tell one was offered.
|
|
204
|
+
*/
|
|
205
|
+
discarded?: readonly DiscardedComponent[];
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build and mount the OpenAPI document and its interactive UI, when the
|
|
209
|
+
* configuration and the runtime both allow it.
|
|
210
|
+
*
|
|
211
|
+
* Call it once, after `NestFactory.create` and BEFORE the application starts
|
|
212
|
+
* listening. The ordering is not a style preference: mounting the document
|
|
213
|
+
* re-registers routes on the HTTP adapter, and on Express 5 doing that against
|
|
214
|
+
* an already-initialized application replaces the router — every route the
|
|
215
|
+
* application had, including its own controllers and this package's health
|
|
216
|
+
* endpoints, stops resolving. `app.listen()` performs that initialization, so
|
|
217
|
+
* "before listening" is the whole rule.
|
|
218
|
+
*
|
|
219
|
+
* It is safe to call unconditionally: with the feature disabled, or in
|
|
220
|
+
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
221
|
+
*
|
|
222
|
+
* Testing this under Jest needs one flag. `@nestjs/swagger` is loaded through a
|
|
223
|
+
* dynamic `import()`, which is what keeps the peer optional for everyone who
|
|
224
|
+
* never enables the document — and Jest's module registry cannot service a
|
|
225
|
+
* dynamic import without `NODE_OPTIONS=--experimental-vm-modules`. Without it,
|
|
226
|
+
* only the *enabled* path fails, with `dynamic import callback invoked without
|
|
227
|
+
* --experimental-vm-modules`; the disabled and production paths never reach the
|
|
228
|
+
* loader and pass either way, which is what makes the omission confusing.
|
|
229
|
+
*
|
|
230
|
+
* @param app - The created Nest application, not yet listening.
|
|
231
|
+
* @returns What happened: mounted, or skipped with a reason.
|
|
232
|
+
* @throws Error When `BymaxCoreModule` is not registered, when the feature is
|
|
233
|
+
* enabled and the optional peer `@nestjs/swagger` is not installed, or when
|
|
234
|
+
* `openapi.operationSecurity` addresses an operation the generated document
|
|
235
|
+
* does not contain.
|
|
236
|
+
* @example
|
|
237
|
+
* const app = await NestFactory.create(AppModule)
|
|
238
|
+
* await applyBymaxOpenApi(app)
|
|
239
|
+
* await app.listen(3000)
|
|
240
|
+
*/
|
|
241
|
+
declare function applyBymaxOpenApi(app: INestApplication): Promise<OpenApiMountOutcome>;
|
|
242
|
+
|
|
243
|
+
export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, type DiscardedComponent, type IOpenApiContributor, type OpenApiFragment, type OpenApiFragmentObject, type OpenApiHandlerKey, type OpenApiMountOutcome, type OpenApiSkipReason, applyBymaxOpenApi };
|
package/dist/openapi/index.mjs
CHANGED
|
@@ -132,6 +132,82 @@ function collectContributions(discovery, reflector, handlers) {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// src/openapi/openapi.shape.ts
|
|
136
|
+
var OPERATION_METHODS = [
|
|
137
|
+
"get",
|
|
138
|
+
"post",
|
|
139
|
+
"put",
|
|
140
|
+
"patch",
|
|
141
|
+
"delete",
|
|
142
|
+
"head",
|
|
143
|
+
"options",
|
|
144
|
+
"trace"
|
|
145
|
+
];
|
|
146
|
+
function asRecord(value) {
|
|
147
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
function operationsOf(item) {
|
|
153
|
+
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
154
|
+
}
|
|
155
|
+
function operationKey(method, path) {
|
|
156
|
+
return `${method.toUpperCase()} ${path}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/openapi/openapi.components.ts
|
|
160
|
+
function isEmptyRequired(key, value) {
|
|
161
|
+
return key === "required" && Array.isArray(value) && value.length === 0;
|
|
162
|
+
}
|
|
163
|
+
var IGNORABLE_KEYS = /* @__PURE__ */ new Set(["type", "properties", "description"]);
|
|
164
|
+
function describesNoShape(entry) {
|
|
165
|
+
const type = entry["type"];
|
|
166
|
+
if (type !== void 0 && type !== "object") {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const properties = entry["properties"];
|
|
170
|
+
if (!Object.hasOwn(entry, "properties") || asRecord(properties) !== properties) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(asRecord(properties)).length > 0) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return Object.entries(entry).every(
|
|
177
|
+
([key, value]) => IGNORABLE_KEYS.has(key) || isEmptyRequired(key, value)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
function placeComponentEntries(existing, member, offered, onKept) {
|
|
181
|
+
const next = new Map(Object.entries(asRecord(existing)));
|
|
182
|
+
for (const [name, contributed] of Object.entries(asRecord(offered))) {
|
|
183
|
+
const declared = next.get(name);
|
|
184
|
+
const held = asRecord(declared);
|
|
185
|
+
const replaceable = member === "schemas" && describesNoShape(held);
|
|
186
|
+
if (declared !== void 0 && !replaceable) {
|
|
187
|
+
onKept(name);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const described = held["description"];
|
|
191
|
+
next.set(
|
|
192
|
+
name,
|
|
193
|
+
described === void 0 || described === "" ? contributed : { ...asRecord(contributed), description: described }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return Object.fromEntries(next);
|
|
197
|
+
}
|
|
198
|
+
function mergeContributedComponents(merged, contributions) {
|
|
199
|
+
const discarded = [];
|
|
200
|
+
for (const contribution of contributions) {
|
|
201
|
+
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
202
|
+
const placed = placeComponentEntries(merged.get(member), member, entries, (name) => {
|
|
203
|
+
discarded.push({ contributor: contribution.label, member, name });
|
|
204
|
+
});
|
|
205
|
+
merged.set(member, placed);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return discarded;
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
// src/route-defaults.ts
|
|
136
212
|
var DEFAULT_HEALTH_PATH = "health";
|
|
137
213
|
var DEFAULT_METRICS_PATH = "metrics";
|
|
@@ -324,30 +400,6 @@ var CORE_PARAMETERS = {
|
|
|
324
400
|
}
|
|
325
401
|
};
|
|
326
402
|
|
|
327
|
-
// src/openapi/openapi.shape.ts
|
|
328
|
-
var OPERATION_METHODS = [
|
|
329
|
-
"get",
|
|
330
|
-
"post",
|
|
331
|
-
"put",
|
|
332
|
-
"patch",
|
|
333
|
-
"delete",
|
|
334
|
-
"head",
|
|
335
|
-
"options",
|
|
336
|
-
"trace"
|
|
337
|
-
];
|
|
338
|
-
function asRecord(value) {
|
|
339
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
340
|
-
return {};
|
|
341
|
-
}
|
|
342
|
-
return value;
|
|
343
|
-
}
|
|
344
|
-
function operationsOf(item) {
|
|
345
|
-
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
346
|
-
}
|
|
347
|
-
function operationKey(method, path) {
|
|
348
|
-
return `${method.toUpperCase()} ${path}`;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
403
|
// src/openapi/openapi.document.ts
|
|
352
404
|
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
353
405
|
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
@@ -539,8 +591,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
539
591
|
const components = asRecord(document.components);
|
|
540
592
|
const merged = new Map(Object.entries(components));
|
|
541
593
|
if (openapi.includeCoreSchemas) {
|
|
542
|
-
|
|
543
|
-
merged.set(
|
|
594
|
+
const keep = () => void 0;
|
|
595
|
+
merged.set(
|
|
596
|
+
"schemas",
|
|
597
|
+
placeComponentEntries(components["schemas"], "schemas", CORE_SCHEMAS, keep)
|
|
598
|
+
);
|
|
599
|
+
merged.set(
|
|
600
|
+
"parameters",
|
|
601
|
+
placeComponentEntries(components["parameters"], "parameters", CORE_PARAMETERS, keep)
|
|
602
|
+
);
|
|
544
603
|
}
|
|
545
604
|
const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
|
|
546
605
|
[METRICS_SCHEME_NAME]: {
|
|
@@ -557,11 +616,7 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
557
616
|
if (Object.keys(declaredSchemes).length > 0) {
|
|
558
617
|
merged.set("securitySchemes", declaredSchemes);
|
|
559
618
|
}
|
|
560
|
-
|
|
561
|
-
for (const [member, entries] of Object.entries(contribution.components)) {
|
|
562
|
-
merged.set(member, mergeAbsent(asRecord(merged.get(member)), entries));
|
|
563
|
-
}
|
|
564
|
-
}
|
|
619
|
+
const discarded = mergeContributedComponents(merged, contributions);
|
|
565
620
|
assertSchemesDeclared(openapi, asRecord(merged.get("securitySchemes")));
|
|
566
621
|
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
567
622
|
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
@@ -576,7 +631,10 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
576
631
|
)
|
|
577
632
|
};
|
|
578
633
|
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
579
|
-
return {
|
|
634
|
+
return {
|
|
635
|
+
document: { ...document, components: Object.fromEntries(merged), ...paths, ...security },
|
|
636
|
+
discarded
|
|
637
|
+
};
|
|
580
638
|
}
|
|
581
639
|
|
|
582
640
|
// src/optional-peer.ts
|
|
@@ -671,6 +729,16 @@ function warnUnsecuredOperations(logger, keys) {
|
|
|
671
729
|
`a client generated from the OpenAPI document will send no credentials to ${keys.length} operation(s): ${listed}${elided > 0 ? `, and ${elided} more` : ""}. They state no security requirement, the document declares no default, and other operations in it do state one \u2014 so this is more often a missing openapi.security default than a public API. Set openapi.security, or state the intent per operation with an explicit [] in openapi.operationSecurity.`
|
|
672
730
|
);
|
|
673
731
|
}
|
|
732
|
+
function warnDiscardedComponents(logger, discarded) {
|
|
733
|
+
if (discarded.length === 0) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const elided = discarded.length - MAX_WARNED_OPERATIONS;
|
|
737
|
+
const listed = discarded.slice(0, MAX_WARNED_OPERATIONS).map(({ contributor, member, name }) => `${contributor} -> ${member}.${name}`).join(", ");
|
|
738
|
+
logger.warn(
|
|
739
|
+
`the served OpenAPI document already defines ${discarded.length} component entry(ies) a library also contributes: ${listed}${elided > 0 ? `, and ${elided} more` : ""}. The contributed version is not served, so a client generated from this document will not match that library's own types. What holds the name may be the application's own declaration, a core schema under openapi.includeCoreSchemas, or a contributor that sorted earlier \u2014 read the document before deciding what to remove.`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
674
742
|
function buildConfig(builder, options) {
|
|
675
743
|
builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
|
|
676
744
|
for (const server of options.servers) {
|
|
@@ -700,13 +768,19 @@ async function applyBymaxOpenApi(app) {
|
|
|
700
768
|
operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
|
|
701
769
|
});
|
|
702
770
|
const prefixes = readPathPrefixes(app);
|
|
703
|
-
const document = augmentDocument(
|
|
771
|
+
const { document, discarded } = augmentDocument(
|
|
772
|
+
generated,
|
|
773
|
+
resolved,
|
|
774
|
+
prefixes,
|
|
775
|
+
readContributions(app, handlers)
|
|
776
|
+
);
|
|
704
777
|
warnUnsecuredOperations(logger, unsecuredOperations(document, resolved, prefixes));
|
|
778
|
+
warnDiscardedComponents(logger, discarded);
|
|
705
779
|
swagger.SwaggerModule.setup(options.path, app, document, {
|
|
706
780
|
jsonDocumentUrl: options.jsonPath
|
|
707
781
|
});
|
|
708
782
|
logger.log(`OpenAPI document served at "/${options.path}" (JSON at "/${options.jsonPath}")`);
|
|
709
|
-
return { mounted: true, path: options.path };
|
|
783
|
+
return { mounted: true, path: options.path, discarded };
|
|
710
784
|
}
|
|
711
785
|
|
|
712
786
|
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.6.
|
|
3
|
+
"version": "1.6.1",
|
|
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,7 +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.
|
|
139
|
+
"@nestjs/platform-fastify": "^11.2.4",
|
|
140
140
|
"@nestjs/swagger": "^11.4.6",
|
|
141
141
|
"@nestjs/testing": "^11.1.20",
|
|
142
142
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
"eslint-plugin-import": "^2.32.0",
|
|
157
157
|
"eslint-plugin-prettier": "^5.5.5",
|
|
158
158
|
"eslint-plugin-security": "^4.0.0",
|
|
159
|
-
"fastify": "^5.
|
|
159
|
+
"fastify": "^5.12.5",
|
|
160
160
|
"globals": "^17.6.0",
|
|
161
161
|
"husky": "^9.1.7",
|
|
162
162
|
"jest": "^30.4.2",
|