@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 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
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.0...HEAD
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 }` | The UI and the document are served at `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. |
@@ -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
- merged.set("schemas", mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS));
545
- merged.set("parameters", mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS));
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
- for (const contribution of contributions) {
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 { ...document, components: Object.fromEntries(merged), ...paths, ...security };
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(generated, resolved, prefixes, readContributions(app, handlers));
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;
@@ -1,55 +1,4 @@
1
- import { INestApplication, CustomDecorator } from '@nestjs/common';
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
- export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, type IOpenApiContributor, type OpenApiFragment, type OpenApiFragmentObject, type OpenApiHandlerKey, type OpenApiMountOutcome, type OpenApiSkipReason, applyBymaxOpenApi };
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 };
@@ -1,55 +1,4 @@
1
- import { INestApplication, CustomDecorator } from '@nestjs/common';
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
- export { BYMAX_OPENAPI_CONTRACT_VERSION, BYMAX_OPENAPI_CONTRIBUTOR_METADATA, BymaxOpenApiContributor, type IOpenApiContributor, type OpenApiFragment, type OpenApiFragmentObject, type OpenApiHandlerKey, type OpenApiMountOutcome, type OpenApiSkipReason, applyBymaxOpenApi };
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 };
@@ -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
- merged.set("schemas", mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS));
543
- merged.set("parameters", mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS));
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
- for (const contribution of contributions) {
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 { ...document, components: Object.fromEntries(merged), ...paths, ...security };
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(generated, resolved, prefixes, readContributions(app, handlers));
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.0",
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.1.29",
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.11.3",
159
+ "fastify": "^5.12.5",
160
160
  "globals": "^17.6.0",
161
161
  "husky": "^9.1.7",
162
162
  "jest": "^30.4.2",