@bymax-one/nest-core 1.3.0 → 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.
- package/CHANGELOG.md +151 -1
- package/README.md +200 -16
- package/dist/index.cjs +22 -9
- package/dist/index.d.cts +123 -3
- package/dist/index.d.ts +123 -3
- package/dist/index.mjs +22 -9
- package/dist/openapi/index.cjs +235 -12
- package/dist/openapi/index.d.cts +12 -2
- package/dist/openapi/index.d.ts +12 -2
- package/dist/openapi/index.mjs +236 -13
- package/package.json +1 -1
package/dist/openapi/index.cjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var common = require('@nestjs/common');
|
|
4
|
+
var core = require('@nestjs/core');
|
|
4
5
|
|
|
5
6
|
// src/openapi/openapi.bootstrap.ts
|
|
6
7
|
|
|
7
8
|
// src/core.tokens.ts
|
|
8
|
-
var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol("
|
|
9
|
+
var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
|
|
9
10
|
|
|
10
11
|
// src/runtime.environment.ts
|
|
11
12
|
var NON_PRODUCTION_ENVIRONMENTS = /* @__PURE__ */ new Set(["development", "test"]);
|
|
@@ -16,6 +17,10 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
|
|
|
16
17
|
return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
// src/route-defaults.ts
|
|
21
|
+
var DEFAULT_HEALTH_PATH = "health";
|
|
22
|
+
var DEFAULT_METRICS_PATH = "metrics";
|
|
23
|
+
|
|
19
24
|
// src/envelope/error-codes.ts
|
|
20
25
|
var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
|
|
21
26
|
var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
|
|
@@ -177,6 +182,19 @@ var CORE_PARAMETERS = {
|
|
|
177
182
|
};
|
|
178
183
|
|
|
179
184
|
// src/openapi/openapi.document.ts
|
|
185
|
+
var OPERATION_METHODS = [
|
|
186
|
+
"get",
|
|
187
|
+
"post",
|
|
188
|
+
"put",
|
|
189
|
+
"patch",
|
|
190
|
+
"delete",
|
|
191
|
+
"head",
|
|
192
|
+
"options",
|
|
193
|
+
"trace"
|
|
194
|
+
];
|
|
195
|
+
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
196
|
+
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
197
|
+
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
180
198
|
function asRecord(value) {
|
|
181
199
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
182
200
|
return {};
|
|
@@ -186,21 +204,192 @@ function asRecord(value) {
|
|
|
186
204
|
function mergeAbsent(existing, additions) {
|
|
187
205
|
return { ...additions, ...existing };
|
|
188
206
|
}
|
|
189
|
-
function
|
|
207
|
+
function operationsOf(item) {
|
|
208
|
+
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
209
|
+
}
|
|
210
|
+
function mergeResponses(existing, additions) {
|
|
211
|
+
const merged = new Map(Object.entries(existing));
|
|
212
|
+
for (const [status, contributed] of Object.entries(additions)) {
|
|
213
|
+
const current = asRecord(merged.get(status));
|
|
214
|
+
if (current["content"] === void 0 && current["$ref"] === void 0) {
|
|
215
|
+
const described = current["description"] === void 0 || current["description"] === "" ? contributed["description"] : current["description"];
|
|
216
|
+
merged.set(status, { ...current, ...contributed, description: described });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return Object.fromEntries(merged);
|
|
220
|
+
}
|
|
221
|
+
function trimSlashes(segment) {
|
|
222
|
+
return segment.split("/").filter((part) => part !== "").join("/");
|
|
223
|
+
}
|
|
224
|
+
function routePath(prefix, suffix) {
|
|
225
|
+
return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
|
|
226
|
+
}
|
|
227
|
+
function healthRoutes(options) {
|
|
228
|
+
const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
|
|
229
|
+
return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
|
|
230
|
+
}
|
|
231
|
+
function metricsRoutes(options) {
|
|
232
|
+
return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
|
|
233
|
+
}
|
|
234
|
+
function indexOwnRoutes(options, prefixes) {
|
|
235
|
+
const normalized = prefixes.map(trimSlashes);
|
|
236
|
+
const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
|
|
237
|
+
const health = expand(healthRoutes(options));
|
|
238
|
+
const metrics = expand(metricsRoutes(options));
|
|
239
|
+
return {
|
|
240
|
+
isHealth: (path) => health.includes(path),
|
|
241
|
+
isMetrics: (path) => metrics.includes(path)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function withoutDisabledRoutes(paths, options, routes) {
|
|
245
|
+
const disabled = (path) => !options.health.enabled && routes.isHealth(path) || !options.metrics.enabled && routes.isMetrics(path);
|
|
246
|
+
const kept = Object.entries(paths).map(([path, item]) => {
|
|
247
|
+
if (!disabled(path)) {
|
|
248
|
+
return [path, item];
|
|
249
|
+
}
|
|
250
|
+
const remaining = Object.fromEntries(
|
|
251
|
+
Object.entries(asRecord(item)).filter(([key]) => key !== "get")
|
|
252
|
+
);
|
|
253
|
+
return [path, operationsOf(remaining).length === 0 ? void 0 : remaining];
|
|
254
|
+
});
|
|
255
|
+
return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
|
|
256
|
+
}
|
|
257
|
+
function ownRouteSecurity(path, method, options, routes) {
|
|
258
|
+
if (method !== "get") {
|
|
259
|
+
return void 0;
|
|
260
|
+
}
|
|
261
|
+
if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
|
|
262
|
+
return [{ [METRICS_SCHEME_NAME]: [] }];
|
|
263
|
+
}
|
|
264
|
+
if (options.openapi.security.length > 0 && routes.isHealth(path)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
return void 0;
|
|
268
|
+
}
|
|
269
|
+
function operationKey(method, path) {
|
|
270
|
+
return `${method.toUpperCase()} ${path}`;
|
|
271
|
+
}
|
|
272
|
+
function coreResponses(path, options, routes) {
|
|
273
|
+
const responses = {};
|
|
274
|
+
if (options.envelope.enabled) {
|
|
275
|
+
responses["default"] = {
|
|
276
|
+
description: "Error envelope returned by every failing request.",
|
|
277
|
+
content: {
|
|
278
|
+
"application/json": { schema: { $ref: `#/components/schemas/${ERROR_ENVELOPE_SCHEMA}` } }
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (routes.isHealth(path)) {
|
|
283
|
+
responses["200"] = {
|
|
284
|
+
description: "Aggregated health report.",
|
|
285
|
+
content: {
|
|
286
|
+
"application/json": { schema: { $ref: `#/components/schemas/${HEALTH_RESPONSE_SCHEMA}` } }
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return responses;
|
|
291
|
+
}
|
|
292
|
+
function augmentOperation(operation, path, method, options, routes) {
|
|
293
|
+
const result = { ...operation };
|
|
294
|
+
if (result["security"] === void 0) {
|
|
295
|
+
const override = options.openapi.operationSecurity[operationKey(method, path)];
|
|
296
|
+
const security = override ?? ownRouteSecurity(path, method, options, routes);
|
|
297
|
+
if (security !== void 0) {
|
|
298
|
+
result["security"] = security;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (options.openapi.includeCoreSchemas) {
|
|
302
|
+
result["responses"] = mergeResponses(
|
|
303
|
+
asRecord(result["responses"]),
|
|
304
|
+
coreResponses(path, options, routes)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
function assertSchemesDeclared(openapi, schemes) {
|
|
310
|
+
const required = [...openapi.security, ...Object.values(openapi.operationSecurity).flat()];
|
|
311
|
+
const named = [...new Set(required.flatMap((requirement) => Object.keys(requirement)))];
|
|
312
|
+
const declared = Object.keys(schemes);
|
|
313
|
+
const missing = named.filter((name) => !declared.includes(name));
|
|
314
|
+
if (missing.length === 0) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
throw new Error(
|
|
318
|
+
`[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(", ")}.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
function assertScrapeSchemeIsOurs(openapi, components, contributes) {
|
|
322
|
+
if (!contributes) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const declaredByConsumer = Object.keys(openapi.securitySchemes).includes(METRICS_SCHEME_NAME);
|
|
326
|
+
const declaredByDocument = Object.keys(asRecord(components["securitySchemes"])).includes(
|
|
327
|
+
METRICS_SCHEME_NAME
|
|
328
|
+
);
|
|
329
|
+
if (!declaredByConsumer && !declaredByDocument) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
throw new Error(
|
|
333
|
+
`[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.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
function documentedOperationKeys(paths) {
|
|
337
|
+
return Object.entries(paths).flatMap(
|
|
338
|
+
([path, item]) => operationsOf(item).map(([method]) => operationKey(method, path))
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
function assertOverridesMatch(paths, openapi) {
|
|
342
|
+
const configured = Object.keys(openapi.operationSecurity);
|
|
343
|
+
const documented = documentedOperationKeys(paths);
|
|
344
|
+
const unmatched = configured.filter((key) => !documented.includes(key));
|
|
345
|
+
if (unmatched.length === 0) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
throw new Error(
|
|
349
|
+
`[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(", ")}.`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
function augmentPaths(paths, options, routes) {
|
|
353
|
+
return Object.fromEntries(
|
|
354
|
+
Object.entries(paths).map(([path, item]) => {
|
|
355
|
+
const augmented = operationsOf(item).map(([method, operation]) => [
|
|
356
|
+
method,
|
|
357
|
+
augmentOperation(asRecord(operation), path, method, options, routes)
|
|
358
|
+
]);
|
|
359
|
+
return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
function augmentDocument(document, options, pathPrefixes = [""]) {
|
|
364
|
+
const { openapi } = options;
|
|
190
365
|
const components = asRecord(document.components);
|
|
191
366
|
const merged = { ...components };
|
|
192
|
-
if (
|
|
367
|
+
if (openapi.includeCoreSchemas) {
|
|
193
368
|
merged["schemas"] = mergeAbsent(asRecord(components["schemas"]), CORE_SCHEMAS);
|
|
194
369
|
merged["parameters"] = mergeAbsent(asRecord(components["parameters"]), CORE_PARAMETERS);
|
|
195
370
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
371
|
+
const scrapeScheme = options.metrics.authToken === void 0 ? {} : {
|
|
372
|
+
[METRICS_SCHEME_NAME]: {
|
|
373
|
+
type: "http",
|
|
374
|
+
scheme: "bearer",
|
|
375
|
+
description: "Bearer token required by the metrics scrape endpoint."
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
assertScrapeSchemeIsOurs(openapi, components, options.metrics.authToken !== void 0);
|
|
379
|
+
const schemes = mergeAbsent(asRecord(components["securitySchemes"]), {
|
|
380
|
+
...openapi.securitySchemes,
|
|
381
|
+
...scrapeScheme
|
|
382
|
+
});
|
|
383
|
+
if (Object.keys(schemes).length > 0) {
|
|
384
|
+
merged["securitySchemes"] = schemes;
|
|
202
385
|
}
|
|
203
|
-
|
|
386
|
+
assertSchemesDeclared(openapi, schemes);
|
|
387
|
+
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
388
|
+
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
389
|
+
assertOverridesMatch(served, openapi);
|
|
390
|
+
const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes) };
|
|
391
|
+
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
392
|
+
return { ...document, components: merged, ...paths, ...security };
|
|
204
393
|
}
|
|
205
394
|
|
|
206
395
|
// src/optional-peer.ts
|
|
@@ -234,6 +423,35 @@ function resolveCoreOptions(app) {
|
|
|
234
423
|
throw new Error(OPTIONS_UNRESOLVED_MESSAGE, { cause });
|
|
235
424
|
}
|
|
236
425
|
}
|
|
426
|
+
function readAppConfig(app) {
|
|
427
|
+
try {
|
|
428
|
+
return app.get(core.ApplicationConfig);
|
|
429
|
+
} catch {
|
|
430
|
+
return void 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function versionSegments(versioning) {
|
|
434
|
+
if (versioning === void 0 || versioning.type !== common.VersioningType.URI) {
|
|
435
|
+
return [""];
|
|
436
|
+
}
|
|
437
|
+
const prefix = versioning.prefix === false ? "" : versioning.prefix ?? "v";
|
|
438
|
+
const declared = versioning.defaultVersion;
|
|
439
|
+
if (declared === void 0) {
|
|
440
|
+
return [""];
|
|
441
|
+
}
|
|
442
|
+
const versions = Array.isArray(declared) ? declared : [declared];
|
|
443
|
+
return versions.map(
|
|
444
|
+
(version) => version === common.VERSION_NEUTRAL ? "" : `${prefix}${String(version)}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
function readPathPrefixes(app) {
|
|
448
|
+
const config = readAppConfig(app);
|
|
449
|
+
if (config === void 0) {
|
|
450
|
+
return [""];
|
|
451
|
+
}
|
|
452
|
+
const globalPrefix = config.getGlobalPrefix();
|
|
453
|
+
return versionSegments(config.getVersioning()).map((segment) => `${globalPrefix}/${segment}`);
|
|
454
|
+
}
|
|
237
455
|
function buildConfig(builder, options) {
|
|
238
456
|
builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
|
|
239
457
|
for (const server of options.servers) {
|
|
@@ -243,7 +461,8 @@ function buildConfig(builder, options) {
|
|
|
243
461
|
}
|
|
244
462
|
async function applyBymaxOpenApi(app) {
|
|
245
463
|
const logger = new common.Logger("BymaxCoreModule");
|
|
246
|
-
const
|
|
464
|
+
const resolved = resolveCoreOptions(app);
|
|
465
|
+
const options = resolved.openapi;
|
|
247
466
|
if (isProductionRuntime()) {
|
|
248
467
|
if (options.suppressedInProduction || options.enabled) {
|
|
249
468
|
logger.warn(
|
|
@@ -257,7 +476,11 @@ async function applyBymaxOpenApi(app) {
|
|
|
257
476
|
}
|
|
258
477
|
const swagger = await loadSwagger();
|
|
259
478
|
const config = buildConfig(new swagger.DocumentBuilder(), options);
|
|
260
|
-
const document = augmentDocument(
|
|
479
|
+
const document = augmentDocument(
|
|
480
|
+
swagger.SwaggerModule.createDocument(app, config),
|
|
481
|
+
resolved,
|
|
482
|
+
readPathPrefixes(app)
|
|
483
|
+
);
|
|
261
484
|
swagger.SwaggerModule.setup(options.path, app, document, {
|
|
262
485
|
jsonDocumentUrl: options.jsonPath
|
|
263
486
|
});
|
package/dist/openapi/index.d.cts
CHANGED
|
@@ -30,10 +30,20 @@ interface OpenApiMountOutcome {
|
|
|
30
30
|
* It is safe to call unconditionally: with the feature disabled, or in
|
|
31
31
|
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
32
32
|
*
|
|
33
|
+
* Testing this under Jest needs one flag. `@nestjs/swagger` is loaded through a
|
|
34
|
+
* dynamic `import()`, which is what keeps the peer optional for everyone who
|
|
35
|
+
* never enables the document — and Jest's module registry cannot service a
|
|
36
|
+
* dynamic import without `NODE_OPTIONS=--experimental-vm-modules`. Without it,
|
|
37
|
+
* only the *enabled* path fails, with `dynamic import callback invoked without
|
|
38
|
+
* --experimental-vm-modules`; the disabled and production paths never reach the
|
|
39
|
+
* loader and pass either way, which is what makes the omission confusing.
|
|
40
|
+
*
|
|
33
41
|
* @param app - The created Nest application, not yet listening.
|
|
34
42
|
* @returns What happened: mounted, or skipped with a reason.
|
|
35
|
-
* @throws Error When `BymaxCoreModule` is not registered,
|
|
36
|
-
* enabled and the optional peer `@nestjs/swagger` is not installed
|
|
43
|
+
* @throws Error When `BymaxCoreModule` is not registered, when the feature is
|
|
44
|
+
* enabled and the optional peer `@nestjs/swagger` is not installed, or when
|
|
45
|
+
* `openapi.operationSecurity` addresses an operation the generated document
|
|
46
|
+
* does not contain.
|
|
37
47
|
* @example
|
|
38
48
|
* const app = await NestFactory.create(AppModule)
|
|
39
49
|
* await applyBymaxOpenApi(app)
|
package/dist/openapi/index.d.ts
CHANGED
|
@@ -30,10 +30,20 @@ interface OpenApiMountOutcome {
|
|
|
30
30
|
* It is safe to call unconditionally: with the feature disabled, or in
|
|
31
31
|
* production, it mounts nothing, loads no optional peer, and returns why.
|
|
32
32
|
*
|
|
33
|
+
* Testing this under Jest needs one flag. `@nestjs/swagger` is loaded through a
|
|
34
|
+
* dynamic `import()`, which is what keeps the peer optional for everyone who
|
|
35
|
+
* never enables the document — and Jest's module registry cannot service a
|
|
36
|
+
* dynamic import without `NODE_OPTIONS=--experimental-vm-modules`. Without it,
|
|
37
|
+
* only the *enabled* path fails, with `dynamic import callback invoked without
|
|
38
|
+
* --experimental-vm-modules`; the disabled and production paths never reach the
|
|
39
|
+
* loader and pass either way, which is what makes the omission confusing.
|
|
40
|
+
*
|
|
33
41
|
* @param app - The created Nest application, not yet listening.
|
|
34
42
|
* @returns What happened: mounted, or skipped with a reason.
|
|
35
|
-
* @throws Error When `BymaxCoreModule` is not registered,
|
|
36
|
-
* enabled and the optional peer `@nestjs/swagger` is not installed
|
|
43
|
+
* @throws Error When `BymaxCoreModule` is not registered, when the feature is
|
|
44
|
+
* enabled and the optional peer `@nestjs/swagger` is not installed, or when
|
|
45
|
+
* `openapi.operationSecurity` addresses an operation the generated document
|
|
46
|
+
* does not contain.
|
|
37
47
|
* @example
|
|
38
48
|
* const app = await NestFactory.create(AppModule)
|
|
39
49
|
* await applyBymaxOpenApi(app)
|
package/dist/openapi/index.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
|
|
5
6
|
// src/core.tokens.ts
|
|
6
|
-
var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol("
|
|
7
|
+
var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
|
|
7
8
|
|
|
8
9
|
// src/runtime.environment.ts
|
|
9
10
|
var NON_PRODUCTION_ENVIRONMENTS = /* @__PURE__ */ new Set(["development", "test"]);
|
|
@@ -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
|
|
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 (
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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",
|