@bymax-one/nest-core 1.3.1 → 1.3.2

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.
@@ -1,4 +1,5 @@
1
- import { Logger } from '@nestjs/common';
1
+ import { Logger, VersioningType, VERSION_NEUTRAL } from '@nestjs/common';
2
+ import { ApplicationConfig } from '@nestjs/core';
2
3
 
3
4
  // src/openapi/openapi.bootstrap.ts
4
5
 
@@ -14,6 +15,10 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
14
15
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
15
16
  }
16
17
 
18
+ // src/route-defaults.ts
19
+ var DEFAULT_HEALTH_PATH = "health";
20
+ var DEFAULT_METRICS_PATH = "metrics";
21
+
17
22
  // src/envelope/error-codes.ts
18
23
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
19
24
  var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
@@ -175,6 +180,19 @@ var CORE_PARAMETERS = {
175
180
  };
176
181
 
177
182
  // src/openapi/openapi.document.ts
183
+ var OPERATION_METHODS = [
184
+ "get",
185
+ "post",
186
+ "put",
187
+ "patch",
188
+ "delete",
189
+ "head",
190
+ "options",
191
+ "trace"
192
+ ];
193
+ var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
194
+ var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
195
+ var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
178
196
  function asRecord(value) {
179
197
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
180
198
  return {};
@@ -184,21 +202,192 @@ function asRecord(value) {
184
202
  function mergeAbsent(existing, additions) {
185
203
  return { ...additions, ...existing };
186
204
  }
187
- function augmentDocument(document, options) {
205
+ function operationsOf(item) {
206
+ return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
207
+ }
208
+ function mergeResponses(existing, additions) {
209
+ const merged = new Map(Object.entries(existing));
210
+ for (const [status, contributed] of Object.entries(additions)) {
211
+ const current = asRecord(merged.get(status));
212
+ if (current["content"] === void 0 && current["$ref"] === void 0) {
213
+ const described = current["description"] === void 0 || current["description"] === "" ? contributed["description"] : current["description"];
214
+ merged.set(status, { ...current, ...contributed, description: described });
215
+ }
216
+ }
217
+ return Object.fromEntries(merged);
218
+ }
219
+ function trimSlashes(segment) {
220
+ return segment.split("/").filter((part) => part !== "").join("/");
221
+ }
222
+ function routePath(prefix, suffix) {
223
+ return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
224
+ }
225
+ function healthRoutes(options) {
226
+ const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
227
+ return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
228
+ }
229
+ function metricsRoutes(options) {
230
+ return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
231
+ }
232
+ function indexOwnRoutes(options, prefixes) {
233
+ const normalized = prefixes.map(trimSlashes);
234
+ const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
235
+ const health = expand(healthRoutes(options));
236
+ const metrics = expand(metricsRoutes(options));
237
+ return {
238
+ isHealth: (path) => health.includes(path),
239
+ isMetrics: (path) => metrics.includes(path)
240
+ };
241
+ }
242
+ function withoutDisabledRoutes(paths, options, routes) {
243
+ const disabled = (path) => !options.health.enabled && routes.isHealth(path) || !options.metrics.enabled && routes.isMetrics(path);
244
+ const kept = Object.entries(paths).map(([path, item]) => {
245
+ if (!disabled(path)) {
246
+ return [path, item];
247
+ }
248
+ const remaining = Object.fromEntries(
249
+ Object.entries(asRecord(item)).filter(([key]) => key !== "get")
250
+ );
251
+ return [path, operationsOf(remaining).length === 0 ? void 0 : remaining];
252
+ });
253
+ return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
254
+ }
255
+ function ownRouteSecurity(path, method, options, routes) {
256
+ if (method !== "get") {
257
+ return void 0;
258
+ }
259
+ if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
260
+ return [{ [METRICS_SCHEME_NAME]: [] }];
261
+ }
262
+ if (options.openapi.security.length > 0 && routes.isHealth(path)) {
263
+ return [];
264
+ }
265
+ return void 0;
266
+ }
267
+ function operationKey(method, path) {
268
+ return `${method.toUpperCase()} ${path}`;
269
+ }
270
+ function coreResponses(path, options, routes) {
271
+ const responses = {};
272
+ if (options.envelope.enabled) {
273
+ responses["default"] = {
274
+ description: "Error envelope returned by every failing request.",
275
+ content: {
276
+ "application/json": { schema: { $ref: `#/components/schemas/${ERROR_ENVELOPE_SCHEMA}` } }
277
+ }
278
+ };
279
+ }
280
+ if (routes.isHealth(path)) {
281
+ responses["200"] = {
282
+ description: "Aggregated health report.",
283
+ content: {
284
+ "application/json": { schema: { $ref: `#/components/schemas/${HEALTH_RESPONSE_SCHEMA}` } }
285
+ }
286
+ };
287
+ }
288
+ return responses;
289
+ }
290
+ function augmentOperation(operation, path, method, options, routes) {
291
+ const result = { ...operation };
292
+ if (result["security"] === void 0) {
293
+ const override = options.openapi.operationSecurity[operationKey(method, path)];
294
+ const security = override ?? ownRouteSecurity(path, method, options, routes);
295
+ if (security !== void 0) {
296
+ result["security"] = security;
297
+ }
298
+ }
299
+ if (options.openapi.includeCoreSchemas) {
300
+ result["responses"] = mergeResponses(
301
+ asRecord(result["responses"]),
302
+ coreResponses(path, options, routes)
303
+ );
304
+ }
305
+ return result;
306
+ }
307
+ function assertSchemesDeclared(openapi, schemes) {
308
+ const required = [...openapi.security, ...Object.values(openapi.operationSecurity).flat()];
309
+ const named = [...new Set(required.flatMap((requirement) => Object.keys(requirement)))];
310
+ const declared = Object.keys(schemes);
311
+ const missing = named.filter((name) => !declared.includes(name));
312
+ if (missing.length === 0) {
313
+ return;
314
+ }
315
+ throw new Error(
316
+ `[BymaxCoreModule] openapi security names ${missing.length} scheme(s) that the document does not define: ${missing.join(", ")}. Declare them in openapi.securitySchemes, or drop the requirement. The document defines: ${declared.length === 0 ? "(none)" : declared.join(", ")}.`
317
+ );
318
+ }
319
+ function assertScrapeSchemeIsOurs(openapi, components, contributes) {
320
+ if (!contributes) {
321
+ return;
322
+ }
323
+ const declaredByConsumer = Object.keys(openapi.securitySchemes).includes(METRICS_SCHEME_NAME);
324
+ const declaredByDocument = Object.keys(asRecord(components["securitySchemes"])).includes(
325
+ METRICS_SCHEME_NAME
326
+ );
327
+ if (!declaredByConsumer && !declaredByDocument) {
328
+ return;
329
+ }
330
+ throw new Error(
331
+ `[BymaxCoreModule] the security scheme "${METRICS_SCHEME_NAME}" is reserved: this package contributes it to document the bearer token the scrape endpoint checks, and it is already defined ${declaredByConsumer ? "in openapi.securitySchemes" : "by the generated document"}. Rename yours, or unset metrics.authToken if the endpoint is not protected.`
332
+ );
333
+ }
334
+ function documentedOperationKeys(paths) {
335
+ return Object.entries(paths).flatMap(
336
+ ([path, item]) => operationsOf(item).map(([method]) => operationKey(method, path))
337
+ );
338
+ }
339
+ function assertOverridesMatch(paths, openapi) {
340
+ const configured = Object.keys(openapi.operationSecurity);
341
+ const documented = documentedOperationKeys(paths);
342
+ const unmatched = configured.filter((key) => !documented.includes(key));
343
+ if (unmatched.length === 0) {
344
+ return;
345
+ }
346
+ throw new Error(
347
+ `[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
348
+ );
349
+ }
350
+ function augmentPaths(paths, options, routes) {
351
+ return Object.fromEntries(
352
+ Object.entries(paths).map(([path, item]) => {
353
+ const augmented = operationsOf(item).map(([method, operation]) => [
354
+ method,
355
+ augmentOperation(asRecord(operation), path, method, options, routes)
356
+ ]);
357
+ return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
358
+ })
359
+ );
360
+ }
361
+ function augmentDocument(document, options, pathPrefixes = [""]) {
362
+ const { openapi } = options;
188
363
  const components = asRecord(document.components);
189
364
  const merged = { ...components };
190
- if (options.includeCoreSchemas) {
365
+ if (openapi.includeCoreSchemas) {
191
366
  merged["schemas"] = mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS);
192
367
  merged["parameters"] = mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS);
193
368
  }
194
- const securitySchemeNames = Object.keys(options.securitySchemes);
195
- if (securitySchemeNames.length > 0) {
196
- merged["securitySchemes"] = mergeAbsent(
197
- asRecord(components["securitySchemes"]),
198
- options.securitySchemes
199
- );
369
+ const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
370
+ [METRICS_SCHEME_NAME]: {
371
+ type: "http",
372
+ scheme: "bearer",
373
+ description: "Bearer token required by the metrics scrape endpoint."
374
+ }
375
+ };
376
+ assertScrapeSchemeIsOurs(openapi, components, options.metrics.authToken !== void 0);
377
+ const schemes = mergeAbsent(asRecord(components["securitySchemes"]), {
378
+ ...openapi.securitySchemes,
379
+ ...scrapeScheme
380
+ });
381
+ if (Object.keys(schemes).length > 0) {
382
+ merged["securitySchemes"] = schemes;
200
383
  }
201
- return { ...document, components: merged };
384
+ assertSchemesDeclared(openapi, schemes);
385
+ const routes = indexOwnRoutes(options, pathPrefixes);
386
+ const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
387
+ assertOverridesMatch(served, openapi);
388
+ const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes) };
389
+ const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
390
+ return { ...document, components: merged, ...paths, ...security };
202
391
  }
203
392
 
204
393
  // src/optional-peer.ts
@@ -232,6 +421,35 @@ function resolveCoreOptions(app) {
232
421
  throw new Error(OPTIONS_UNRESOLVED_MESSAGE, { cause });
233
422
  }
234
423
  }
424
+ function readAppConfig(app) {
425
+ try {
426
+ return app.get(ApplicationConfig);
427
+ } catch {
428
+ return void 0;
429
+ }
430
+ }
431
+ function versionSegments(versioning) {
432
+ if (versioning === void 0 || versioning.type !== VersioningType.URI) {
433
+ return [""];
434
+ }
435
+ const prefix = versioning.prefix === false ? "" : versioning.prefix ?? "v";
436
+ const declared = versioning.defaultVersion;
437
+ if (declared === void 0) {
438
+ return [""];
439
+ }
440
+ const versions = Array.isArray(declared) ? declared : [declared];
441
+ return versions.map(
442
+ (version) => version === VERSION_NEUTRAL ? "" : `${prefix}${String(version)}`
443
+ );
444
+ }
445
+ function readPathPrefixes(app) {
446
+ const config = readAppConfig(app);
447
+ if (config === void 0) {
448
+ return [""];
449
+ }
450
+ const globalPrefix = config.getGlobalPrefix();
451
+ return versionSegments(config.getVersioning()).map((segment) => `${globalPrefix}/${segment}`);
452
+ }
235
453
  function buildConfig(builder, options) {
236
454
  builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
237
455
  for (const server of options.servers) {
@@ -241,7 +459,8 @@ function buildConfig(builder, options) {
241
459
  }
242
460
  async function applyBymaxOpenApi(app) {
243
461
  const logger = new Logger("BymaxCoreModule");
244
- const options = resolveCoreOptions(app).openapi;
462
+ const resolved = resolveCoreOptions(app);
463
+ const options = resolved.openapi;
245
464
  if (isProductionRuntime()) {
246
465
  if (options.suppressedInProduction || options.enabled) {
247
466
  logger.warn(
@@ -255,7 +474,11 @@ async function applyBymaxOpenApi(app) {
255
474
  }
256
475
  const swagger = await loadSwagger();
257
476
  const config = buildConfig(new swagger.DocumentBuilder(), options);
258
- const document = augmentDocument(swagger.SwaggerModule.createDocument(app, config), options);
477
+ const document = augmentDocument(
478
+ swagger.SwaggerModule.createDocument(app, config),
479
+ resolved,
480
+ readPathPrefixes(app)
481
+ );
259
482
  swagger.SwaggerModule.setup(options.path, app, document, {
260
483
  jsonDocumentUrl: options.jsonPath
261
484
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
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",