@lenne.tech/nest-server 11.42.6 → 11.42.7
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/.claude/rules/configurable-features.md +1 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/swagger.helper.d.ts +11 -0
- package/dist/core/common/helpers/swagger.helper.js +362 -0
- package/dist/core/common/helpers/swagger.helper.js.map +1 -0
- package/dist/core/common/interfaces/swagger-setup-options.interface.d.ts +33 -0
- package/dist/core/common/interfaces/swagger-setup-options.interface.js +3 -0
- package/dist/core/common/interfaces/swagger-setup-options.interface.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +8 -14
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.42.5-to-11.42.6.md +16 -0
- package/migration-guides/11.42.6-to-11.42.7.md +115 -0
- package/package.json +4 -4
- package/src/core/common/helpers/swagger.helper.ts +529 -0
- package/src/core/common/interfaces/swagger-setup-options.interface.ts +131 -0
- package/src/index.ts +2 -0
- package/src/main.ts +9 -15
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { ModulesContainer } from '@nestjs/core';
|
|
3
|
+
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
4
|
+
|
|
5
|
+
import { resolveApiTokenConfig } from '../../modules/api-token/core-api-token.helpers';
|
|
6
|
+
import { API_TOKEN_SCOPES_KEY } from '../../modules/api-token/core-api-token.constants';
|
|
7
|
+
import { resolveBetterAuthSessionCookieName } from '../../modules/better-auth/better-auth-cookie-prefix.helper';
|
|
8
|
+
import { SKIP_TENANT_CHECK_KEY } from '../../modules/tenant/core-tenant.decorators';
|
|
9
|
+
import { mergeRolesMetadata } from '../../modules/tenant/core-tenant.helpers';
|
|
10
|
+
import { RoleEnum } from '../enums/role.enum';
|
|
11
|
+
import { ConfigService } from '../services/config.service';
|
|
12
|
+
import { isCookiesEnabled } from './cookies.helper';
|
|
13
|
+
|
|
14
|
+
import type { INestApplication, Type } from '@nestjs/common';
|
|
15
|
+
import type {
|
|
16
|
+
OpenAPIObject,
|
|
17
|
+
OperationObject,
|
|
18
|
+
ParameterObject,
|
|
19
|
+
PathItemObject,
|
|
20
|
+
ReferenceObject,
|
|
21
|
+
TagObject,
|
|
22
|
+
} from '@nestjs/swagger';
|
|
23
|
+
import type { IServerOptions } from '../interfaces/server-options.interface';
|
|
24
|
+
import type { ISwaggerApiTokenViewOptions, ISwaggerSetupOptions } from '../interfaces/swagger-setup-options.interface';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Swagger setup that describes how a route is actually reached (since 11.42.7).
|
|
28
|
+
*
|
|
29
|
+
* The plain `DocumentBuilder().addBearerAuth()` of the starters registers a scheme but attaches it to no
|
|
30
|
+
* route, so a token entered under "Authorize" is sent with almost none. And the facts a caller needs —
|
|
31
|
+
* which routes are public, which API-token scope a route releases, which routes read the tenant header —
|
|
32
|
+
* live only in decorators. This helper reads them from the same metadata the guards read, so the
|
|
33
|
+
* document cannot drift from what the server enforces:
|
|
34
|
+
*
|
|
35
|
+
* - `bearer` and the session cookie are global security requirements; public routes carry `security: []`.
|
|
36
|
+
* - With API tokens on, every operation states whether a token may call it, and with which scope
|
|
37
|
+
* (`x-api-token-scopes` plus a line in the description). `@ApiTokenScopes()` on the method replaces
|
|
38
|
+
* the class-level declaration, exactly as in `assertApiTokenScopes()`.
|
|
39
|
+
* - With multi-tenancy on, every operation that reads the tenant header documents it as a parameter.
|
|
40
|
+
* - Optionally, a second document lists only the routes an API token may call.
|
|
41
|
+
*
|
|
42
|
+
* Each operation is mapped to its handler through the default operation id (`<Controller>_<method>`),
|
|
43
|
+
* which is why an own `operationIdFactory` is not accepted.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Name of the bearer scheme. `@ApiBearerAuth()` without arguments refers to the same name. */
|
|
47
|
+
export const SWAGGER_BEARER_SCHEME = 'bearer';
|
|
48
|
+
|
|
49
|
+
/** Name of the session-cookie scheme */
|
|
50
|
+
export const SWAGGER_COOKIE_SCHEME = 'cookie';
|
|
51
|
+
|
|
52
|
+
/** Key of the reusable tenant-header parameter in `components.parameters` */
|
|
53
|
+
export const SWAGGER_TENANT_HEADER_PARAMETER = 'TenantHeader';
|
|
54
|
+
|
|
55
|
+
/** Vendor extension listing the scopes an API token needs for an operation */
|
|
56
|
+
export const SWAGGER_API_TOKEN_SCOPES_EXTENSION = 'x-api-token-scopes';
|
|
57
|
+
|
|
58
|
+
const HTTP_METHODS = ['delete', 'get', 'head', 'options', 'patch', 'post', 'put', 'trace'] as const;
|
|
59
|
+
|
|
60
|
+
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
61
|
+
|
|
62
|
+
/** An operation with the vendor extension this helper writes */
|
|
63
|
+
type EnrichedOperation = OperationObject & { [SWAGGER_API_TOKEN_SCOPES_EXTENSION]?: string[] };
|
|
64
|
+
|
|
65
|
+
interface IHandlerReference {
|
|
66
|
+
controller: Type<unknown>;
|
|
67
|
+
handler: (...args: unknown[]) => unknown;
|
|
68
|
+
handlerName: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface IOperationAccess {
|
|
72
|
+
isPublic: boolean;
|
|
73
|
+
scopes?: string[];
|
|
74
|
+
skipsTenantHeader: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface IResolvedApiTokenView {
|
|
78
|
+
description?: string;
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
jsonDocumentUrl: string;
|
|
81
|
+
path: string;
|
|
82
|
+
title?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const logger = new Logger('Swagger');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the enriched document and serve it — plus the API-token view, when configured. Both documents are
|
|
89
|
+
* built on the first request, when every module is initialised.
|
|
90
|
+
*/
|
|
91
|
+
export function setupSwagger(app: INestApplication, options: ISwaggerSetupOptions): void {
|
|
92
|
+
let document: OpenAPIObject | undefined;
|
|
93
|
+
const fullDocument = () => (document ??= buildSwaggerDocument(app, options));
|
|
94
|
+
|
|
95
|
+
SwaggerModule.setup(options.path ?? 'swagger', app, fullDocument, {
|
|
96
|
+
...options.customOptions,
|
|
97
|
+
jsonDocumentUrl: options.jsonDocumentUrl ?? '/api-docs-json',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const view = resolveApiTokenView(options.apiTokenView);
|
|
101
|
+
if (!view.enabled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let viewDocument: OpenAPIObject | undefined;
|
|
105
|
+
SwaggerModule.setup(view.path, app, () => (viewDocument ??= buildApiTokenSwaggerDocument(fullDocument(), view)), {
|
|
106
|
+
...options.customOptions,
|
|
107
|
+
jsonDocumentUrl: view.jsonDocumentUrl,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The full document, enriched with security, API-token scopes and the tenant header.
|
|
113
|
+
*
|
|
114
|
+
* @param config - server configuration; the running configuration by default
|
|
115
|
+
*/
|
|
116
|
+
export function buildSwaggerDocument(
|
|
117
|
+
app: INestApplication,
|
|
118
|
+
options: ISwaggerSetupOptions,
|
|
119
|
+
config: Partial<IServerOptions> = ConfigService.configFastButReadOnly ?? {},
|
|
120
|
+
): OpenAPIObject {
|
|
121
|
+
if ((options.documentOptions as { operationIdFactory?: unknown } | undefined)?.operationIdFactory) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'setupSwagger: an own operationIdFactory is not supported — operations are mapped by their default id',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const apiTokens = resolveApiTokenConfig(config);
|
|
127
|
+
const multiTenancy = !!config.multiTenancy && config.multiTenancy.enabled !== false;
|
|
128
|
+
const sessionCookie = resolveSessionCookieName(config);
|
|
129
|
+
|
|
130
|
+
let builder = new DocumentBuilder()
|
|
131
|
+
.setTitle(options.title)
|
|
132
|
+
.setDescription(options.description ?? '')
|
|
133
|
+
.setVersion(options.version ?? '1.0.0')
|
|
134
|
+
.addBearerAuth(
|
|
135
|
+
{
|
|
136
|
+
description: apiTokens.enabled
|
|
137
|
+
? 'Session token from sign-in, or an API token / signed assertion of an API token.'
|
|
138
|
+
: 'Session token from sign-in.',
|
|
139
|
+
scheme: 'bearer',
|
|
140
|
+
type: 'http',
|
|
141
|
+
},
|
|
142
|
+
SWAGGER_BEARER_SCHEME,
|
|
143
|
+
)
|
|
144
|
+
.addSecurityRequirements(SWAGGER_BEARER_SCHEME);
|
|
145
|
+
if (sessionCookie) {
|
|
146
|
+
builder = builder
|
|
147
|
+
.addCookieAuth(
|
|
148
|
+
sessionCookie,
|
|
149
|
+
{
|
|
150
|
+
description: `Session cookie set by sign-in (\`${sessionCookie}\`). The browser sends it by itself once you are signed in on this host.`,
|
|
151
|
+
in: 'cookie',
|
|
152
|
+
name: sessionCookie,
|
|
153
|
+
type: 'apiKey',
|
|
154
|
+
},
|
|
155
|
+
SWAGGER_COOKIE_SCHEME,
|
|
156
|
+
)
|
|
157
|
+
.addSecurityRequirements(SWAGGER_COOKIE_SCHEME);
|
|
158
|
+
}
|
|
159
|
+
if (options.configureBuilder) {
|
|
160
|
+
builder = options.configureBuilder(builder);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const document = SwaggerModule.createDocument(app, builder.build(), {
|
|
164
|
+
autoTagControllers: true,
|
|
165
|
+
deepScanRoutes: true,
|
|
166
|
+
...options.documentOptions,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const headerName = canonicalHeaderName(config.multiTenancy?.headerName ?? 'x-tenant-id');
|
|
170
|
+
if (multiTenancy) {
|
|
171
|
+
document.components = document.components ?? {};
|
|
172
|
+
document.components.parameters = {
|
|
173
|
+
...document.components.parameters,
|
|
174
|
+
[SWAGGER_TENANT_HEADER_PARAMETER]: tenantHeaderParameter(headerName, apiTokens),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handlers = indexHandlers(app);
|
|
179
|
+
forEachOperation(document, (path, method, operation) => {
|
|
180
|
+
const reference = resolveHandler(operation.operationId, handlers);
|
|
181
|
+
if (!reference) {
|
|
182
|
+
logger.warn(`No handler found for operation ${operation.operationId ?? `${method.toUpperCase()} ${path}`}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const access = describeAccess(reference, multiTenancy);
|
|
186
|
+
|
|
187
|
+
if (access.isPublic) {
|
|
188
|
+
operation.security = [];
|
|
189
|
+
}
|
|
190
|
+
if (apiTokens.enabled) {
|
|
191
|
+
if (access.scopes?.length) {
|
|
192
|
+
operation[SWAGGER_API_TOKEN_SCOPES_EXTENSION] = [...access.scopes];
|
|
193
|
+
}
|
|
194
|
+
operation.description = appendLine(operation.description, apiTokenLine(access.scopes));
|
|
195
|
+
}
|
|
196
|
+
if (multiTenancy && !access.isPublic && !access.skipsTenantHeader && !hasHeaderParameter(operation, headerName)) {
|
|
197
|
+
operation.parameters = [
|
|
198
|
+
...(operation.parameters ?? []),
|
|
199
|
+
{ $ref: `#/components/parameters/${SWAGGER_TENANT_HEADER_PARAMETER}` },
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
if (options.operationTags) {
|
|
203
|
+
const tags = options.operationTags({
|
|
204
|
+
controller: reference.controller.name,
|
|
205
|
+
handler: reference.handlerName,
|
|
206
|
+
isPublic: access.isPublic,
|
|
207
|
+
method,
|
|
208
|
+
path,
|
|
209
|
+
scopes: access.scopes,
|
|
210
|
+
tags: [...(operation.tags ?? [])],
|
|
211
|
+
});
|
|
212
|
+
if (tags) {
|
|
213
|
+
operation.tags = tags;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
document.tags = orderTags(document, options.tags);
|
|
219
|
+
return document;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The document an API-token caller needs: only the operations that release a scope, authenticated with
|
|
224
|
+
* the bearer scheme only, without the tenant header (a tenant token carries its tenant), and without the
|
|
225
|
+
* schemas no remaining operation refers to.
|
|
226
|
+
*/
|
|
227
|
+
export function buildApiTokenSwaggerDocument(
|
|
228
|
+
document: OpenAPIObject,
|
|
229
|
+
options: ISwaggerApiTokenViewOptions = {},
|
|
230
|
+
config: Partial<IServerOptions> = ConfigService.configFastButReadOnly ?? {},
|
|
231
|
+
): OpenAPIObject {
|
|
232
|
+
// A company token carries its company, so the header is noise for it. A personal (user) token
|
|
233
|
+
// without a company restriction chooses the company like a session does — without the header it is
|
|
234
|
+
// checked against its global roles and refused on company routes. Keep it while those tokens exist.
|
|
235
|
+
const keepsTenantHeader = resolveApiTokenConfig(config).userTokens;
|
|
236
|
+
const view: OpenAPIObject = structuredClone(document);
|
|
237
|
+
view.info = {
|
|
238
|
+
...view.info,
|
|
239
|
+
description: options.description ?? view.info.description,
|
|
240
|
+
title: options.title ?? `${view.info.title} — API tokens`,
|
|
241
|
+
};
|
|
242
|
+
view.security = [{ [SWAGGER_BEARER_SCHEME]: [] }];
|
|
243
|
+
|
|
244
|
+
const paths: OpenAPIObject['paths'] = {};
|
|
245
|
+
for (const [path, item] of Object.entries(view.paths)) {
|
|
246
|
+
const kept: PathItemObject = {};
|
|
247
|
+
for (const method of HTTP_METHODS) {
|
|
248
|
+
const operation = item[method] as EnrichedOperation | undefined;
|
|
249
|
+
if (!operation?.[SWAGGER_API_TOKEN_SCOPES_EXTENSION]?.length) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
operation.security = [{ [SWAGGER_BEARER_SCHEME]: [] }];
|
|
253
|
+
if (!keepsTenantHeader) {
|
|
254
|
+
operation.parameters = operation.parameters?.filter(
|
|
255
|
+
(parameter) =>
|
|
256
|
+
!isRef(parameter) || parameter.$ref !== `#/components/parameters/${SWAGGER_TENANT_HEADER_PARAMETER}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
kept[method] = operation;
|
|
260
|
+
}
|
|
261
|
+
if (Object.keys(kept).length) {
|
|
262
|
+
paths[path] = { ...pathLevelFields(item), ...kept };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
view.paths = paths;
|
|
266
|
+
|
|
267
|
+
if (view.components) {
|
|
268
|
+
const { parameters, securitySchemes } = view.components;
|
|
269
|
+
if (parameters && !keepsTenantHeader) {
|
|
270
|
+
delete parameters[SWAGGER_TENANT_HEADER_PARAMETER];
|
|
271
|
+
}
|
|
272
|
+
if (securitySchemes) {
|
|
273
|
+
view.components.securitySchemes = { [SWAGGER_BEARER_SCHEME]: securitySchemes[SWAGGER_BEARER_SCHEME] };
|
|
274
|
+
}
|
|
275
|
+
pruneUnusedSchemas(view);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const used = new Set<string>();
|
|
279
|
+
forEachOperation(view, (_path, _method, operation) => operation.tags?.forEach((tag) => used.add(tag)));
|
|
280
|
+
view.tags = view.tags?.filter((tag) => used.has(tag.name));
|
|
281
|
+
return view;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Which roles, scopes and tenant handling apply to a handler — read the way the guards read them.
|
|
286
|
+
*/
|
|
287
|
+
function describeAccess(reference: IHandlerReference, multiTenancy: boolean): IOperationAccess {
|
|
288
|
+
const handlerRoles = Reflect.getMetadata('roles', reference.handler) as string[] | undefined;
|
|
289
|
+
const classRoles = Reflect.getMetadata('roles', reference.controller) as string[] | undefined;
|
|
290
|
+
const roles = mergeRolesMetadata([handlerRoles, classRoles]);
|
|
291
|
+
// The tenant guard lets method-level system roles override class-level ones (a class @Roles(S_EVERYONE)
|
|
292
|
+
// must not make a method @Roles(S_USER) public); the role guard merges both. Public is what passes both.
|
|
293
|
+
const systemRoles = handlerRoles?.length ? handlerRoles : roles;
|
|
294
|
+
const isPublic =
|
|
295
|
+
!roles.some(Boolean) ||
|
|
296
|
+
(roles.includes(RoleEnum.S_EVERYONE) && (!multiTenancy || systemRoles.includes(RoleEnum.S_EVERYONE)));
|
|
297
|
+
|
|
298
|
+
// Same lookup as assertApiTokenScopes(): the method declaration replaces the class declaration.
|
|
299
|
+
const scopes =
|
|
300
|
+
(Reflect.getMetadata(API_TOKEN_SCOPES_KEY, reference.handler) as string[] | undefined) ??
|
|
301
|
+
(Reflect.getMetadata(API_TOKEN_SCOPES_KEY, reference.controller) as string[] | undefined);
|
|
302
|
+
|
|
303
|
+
const skipsTenantHeader = !!(
|
|
304
|
+
Reflect.getMetadata(SKIP_TENANT_CHECK_KEY, reference.handler) ??
|
|
305
|
+
Reflect.getMetadata(SKIP_TENANT_CHECK_KEY, reference.controller)
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return { isPublic, scopes: scopes?.length ? scopes : undefined, skipsTenantHeader };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Every controller class of the application, by name — the first half of a default operation id. */
|
|
312
|
+
function indexHandlers(app: INestApplication): Map<string, Type<unknown>[]> {
|
|
313
|
+
const index = new Map<string, Type<unknown>[]>();
|
|
314
|
+
for (const module of app.get(ModulesContainer).values()) {
|
|
315
|
+
for (const wrapper of module.controllers.values()) {
|
|
316
|
+
const controller = wrapper.metatype as Type<unknown> | undefined;
|
|
317
|
+
if (!controller?.name) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const known = index.get(controller.name) ?? [];
|
|
321
|
+
if (!known.includes(controller)) {
|
|
322
|
+
index.set(controller.name, [...known, controller]);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return index;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Map `<Controller>_<method>` (or `<Controller>_<method>_<version>`) back to the handler. Two different
|
|
331
|
+
* controller classes with the same name cannot be told apart by their operation ids; such operations are
|
|
332
|
+
* left as Swagger built them.
|
|
333
|
+
*/
|
|
334
|
+
function resolveHandler(
|
|
335
|
+
operationId: string | undefined,
|
|
336
|
+
index: Map<string, Type<unknown>[]>,
|
|
337
|
+
): IHandlerReference | undefined {
|
|
338
|
+
if (!operationId) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
for (let separator = operationId.indexOf('_'); separator > 0; separator = operationId.indexOf('_', separator + 1)) {
|
|
342
|
+
const controllers = index.get(operationId.slice(0, separator));
|
|
343
|
+
if (!controllers) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (controllers.length > 1) {
|
|
347
|
+
logger.warn(
|
|
348
|
+
`Controller name ${controllers[0].name} is used by ${controllers.length} classes; ${operationId} is not enriched`,
|
|
349
|
+
);
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
const controller = controllers[0];
|
|
353
|
+
// A handler serving several paths (`@Get(['a', 'b'])`) gets `<method>[<index>]` per path.
|
|
354
|
+
const rest = operationId.slice(separator + 1).replace(/\[\d+\]/g, '');
|
|
355
|
+
for (const handlerName of [rest, rest.replace(/_[^_]+$/, '')]) {
|
|
356
|
+
const handler = (controller.prototype as Record<string, unknown>)[handlerName];
|
|
357
|
+
if (typeof handler === 'function') {
|
|
358
|
+
return { controller, handler: handler as IHandlerReference['handler'], handlerName };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function resolveApiTokenView(option: ISwaggerSetupOptions['apiTokenView']): IResolvedApiTokenView {
|
|
366
|
+
const options: ISwaggerApiTokenViewOptions = typeof option === 'object' && option !== null ? option : {};
|
|
367
|
+
return {
|
|
368
|
+
description: options.description,
|
|
369
|
+
enabled: option === true || (typeof option === 'object' && option !== null && option.enabled !== false),
|
|
370
|
+
jsonDocumentUrl: options.jsonDocumentUrl ?? '/api-docs-api-tokens-json',
|
|
371
|
+
path: options.path ?? 'swagger-api-tokens',
|
|
372
|
+
title: options.title,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Name of the Better-Auth session cookie, or `undefined` when no session cookie is set. */
|
|
377
|
+
function resolveSessionCookieName(config: Partial<IServerOptions>): string | undefined {
|
|
378
|
+
const betterAuth = config.betterAuth;
|
|
379
|
+
if (betterAuth === false || (typeof betterAuth === 'object' && betterAuth?.enabled === false)) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (!isCookiesEnabled(config.cookies)) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
return resolveBetterAuthSessionCookieName(typeof betterAuth === 'object' ? (betterAuth.basePath ?? '/iam') : '/iam');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function tenantHeaderParameter(
|
|
389
|
+
name: string,
|
|
390
|
+
apiTokens: { tenantTokens: boolean; userTokens: boolean },
|
|
391
|
+
): ParameterObject {
|
|
392
|
+
return {
|
|
393
|
+
description: [
|
|
394
|
+
'Company (tenant) the request acts in.',
|
|
395
|
+
'Session callers: required on company routes; on routes open to every signed-in user it is optional and validated when sent.',
|
|
396
|
+
apiTokens.tenantTokens
|
|
397
|
+
? 'API tokens of a company carry their company and may omit it; when sent, it must name that company.'
|
|
398
|
+
: '',
|
|
399
|
+
apiTokens.userTokens
|
|
400
|
+
? 'A personal API token chooses the company like a session; one restricted to a company may omit it.'
|
|
401
|
+
: '',
|
|
402
|
+
]
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join(' '),
|
|
405
|
+
in: 'header',
|
|
406
|
+
name,
|
|
407
|
+
required: false,
|
|
408
|
+
schema: { type: 'string' },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function apiTokenLine(scopes: string[] | undefined): string {
|
|
413
|
+
return scopes?.length
|
|
414
|
+
? `**API tokens:** allowed with scope ${scopes.map((scope) => `\`${scope}\``).join(' or ')}.`
|
|
415
|
+
: '**API tokens:** not allowed on this route.';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function appendLine(description: string | undefined, line: string): string {
|
|
419
|
+
return description ? `${description}\n\n${line}` : line;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `x-tenant-id` → `X-Tenant-Id`; header names are case-insensitive, the canonical form reads better. */
|
|
423
|
+
function canonicalHeaderName(name: string): string {
|
|
424
|
+
return name
|
|
425
|
+
.split('-')
|
|
426
|
+
.map((part) => (part ? part[0].toUpperCase() + part.slice(1).toLowerCase() : part))
|
|
427
|
+
.join('-');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function hasHeaderParameter(operation: OperationObject, name: string): boolean {
|
|
431
|
+
return !!operation.parameters?.some(
|
|
432
|
+
(parameter) =>
|
|
433
|
+
!isRef(parameter) && parameter.in === 'header' && parameter.name.toLowerCase() === name.toLowerCase(),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isRef(value: unknown): value is ReferenceObject {
|
|
438
|
+
return typeof value === 'object' && value !== null && '$ref' in value;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function forEachOperation(
|
|
442
|
+
document: OpenAPIObject,
|
|
443
|
+
visit: (path: string, method: HttpMethod, operation: EnrichedOperation) => void,
|
|
444
|
+
): void {
|
|
445
|
+
for (const [path, item] of Object.entries(document.paths ?? {})) {
|
|
446
|
+
for (const method of HTTP_METHODS) {
|
|
447
|
+
const operation = item[method];
|
|
448
|
+
if (operation) {
|
|
449
|
+
visit(path, method, operation);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Fields of a path item that are not operations (summary, description, servers, shared parameters). */
|
|
456
|
+
function pathLevelFields(item: PathItemObject): PathItemObject {
|
|
457
|
+
const fields: PathItemObject = { ...item };
|
|
458
|
+
for (const method of HTTP_METHODS) {
|
|
459
|
+
delete fields[method];
|
|
460
|
+
}
|
|
461
|
+
return fields;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Tags in the configured order first, then every other tag in order of first use — each with its
|
|
466
|
+
* description, when one is configured.
|
|
467
|
+
*/
|
|
468
|
+
function orderTags(document: OpenAPIObject, configured: ISwaggerSetupOptions['tags']): TagObject[] {
|
|
469
|
+
const used: string[] = [];
|
|
470
|
+
forEachOperation(document, (_path, _method, operation) => {
|
|
471
|
+
for (const tag of operation.tags ?? []) {
|
|
472
|
+
if (!used.includes(tag)) {
|
|
473
|
+
used.push(tag);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
const existing = new Map((document.tags ?? []).map((tag) => [tag.name, tag]));
|
|
478
|
+
const ordered: TagObject[] = [];
|
|
479
|
+
for (const tag of configured ?? []) {
|
|
480
|
+
if (used.includes(tag.name)) {
|
|
481
|
+
ordered.push({ ...existing.get(tag.name), ...tag });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const name of used) {
|
|
485
|
+
if (!ordered.some((tag) => tag.name === name)) {
|
|
486
|
+
ordered.push(existing.get(name) ?? { name });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return ordered;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Drop component schemas that no operation reaches, directly or through other schemas. */
|
|
493
|
+
function pruneUnusedSchemas(document: OpenAPIObject): void {
|
|
494
|
+
const schemas = document.components?.schemas;
|
|
495
|
+
if (!schemas) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const prefix = '#/components/schemas/';
|
|
499
|
+
const reached = new Set<string>();
|
|
500
|
+
const pending: unknown[] = [
|
|
501
|
+
document.paths,
|
|
502
|
+
document.components?.parameters,
|
|
503
|
+
document.components?.requestBodies,
|
|
504
|
+
document.components?.responses,
|
|
505
|
+
];
|
|
506
|
+
while (pending.length) {
|
|
507
|
+
const value = pending.pop();
|
|
508
|
+
if (Array.isArray(value)) {
|
|
509
|
+
pending.push(...value);
|
|
510
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
511
|
+
for (const [key, child] of Object.entries(value)) {
|
|
512
|
+
if (key === '$ref' && typeof child === 'string' && child.startsWith(prefix)) {
|
|
513
|
+
const name = child.slice(prefix.length);
|
|
514
|
+
if (!reached.has(name)) {
|
|
515
|
+
reached.add(name);
|
|
516
|
+
pending.push(schemas[name]);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
pending.push(child);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const name of Object.keys(schemas)) {
|
|
525
|
+
if (!reached.has(name)) {
|
|
526
|
+
delete schemas[name];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { DocumentBuilder, SwaggerCustomOptions, SwaggerDocumentOptions } from '@nestjs/swagger';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the Swagger helper knows about one operation, handed to `operationTags`.
|
|
5
|
+
*
|
|
6
|
+
* @since 11.42.7
|
|
7
|
+
*/
|
|
8
|
+
export interface ISwaggerOperationInfo {
|
|
9
|
+
/** Class name of the controller that serves the operation */
|
|
10
|
+
controller: string;
|
|
11
|
+
|
|
12
|
+
/** Name of the handler method */
|
|
13
|
+
handler: string;
|
|
14
|
+
|
|
15
|
+
/** True when neither the role guard nor the tenant guard requires a sign-in */
|
|
16
|
+
isPublic: boolean;
|
|
17
|
+
|
|
18
|
+
/** HTTP method in lower case (`get`, `post`, ...) */
|
|
19
|
+
method: string;
|
|
20
|
+
|
|
21
|
+
/** OpenAPI path (`/drop-in/uploads/{analysisId}/status`) */
|
|
22
|
+
path: string;
|
|
23
|
+
|
|
24
|
+
/** Scopes released via `@ApiTokenScopes()`, `undefined` when API tokens are refused */
|
|
25
|
+
scopes?: string[];
|
|
26
|
+
|
|
27
|
+
/** Tags the operation carries before `operationTags` runs (usually the controller tag) */
|
|
28
|
+
tags: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The second Swagger document that lists only the routes an API token may call.
|
|
33
|
+
*
|
|
34
|
+
* Follows "presence implies enabled": `true` or `{}` switches it on, `{ enabled: false }` keeps it off.
|
|
35
|
+
*
|
|
36
|
+
* @since 11.42.7
|
|
37
|
+
*/
|
|
38
|
+
export interface ISwaggerApiTokenViewOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Description of the view. Defaults to the description of the full document.
|
|
41
|
+
*/
|
|
42
|
+
description?: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pre-configure without enabling.
|
|
46
|
+
* @default true (when the object is present)
|
|
47
|
+
*/
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Path of the JSON document.
|
|
52
|
+
* @default '/api-docs-api-tokens-json'
|
|
53
|
+
*/
|
|
54
|
+
jsonDocumentUrl?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Path of the Swagger UI. Keep it outside the path of the full UI: Swagger UI serves its assets below
|
|
58
|
+
* its own path, and a nested view would share them.
|
|
59
|
+
* @default 'swagger-api-tokens'
|
|
60
|
+
*/
|
|
61
|
+
path?: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Title of the view.
|
|
65
|
+
* @default '<title> — API tokens'
|
|
66
|
+
*/
|
|
67
|
+
title?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Options of `setupSwagger()` / `buildSwaggerDocument()`.
|
|
72
|
+
*
|
|
73
|
+
* @since 11.42.7
|
|
74
|
+
*/
|
|
75
|
+
export interface ISwaggerSetupOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Second document with only the routes released for API tokens (`@ApiTokenScopes()`), for partners who
|
|
78
|
+
* integrate with a token and do not need the management routes.
|
|
79
|
+
* @default disabled
|
|
80
|
+
*/
|
|
81
|
+
apiTokenView?: boolean | ISwaggerApiTokenViewOptions;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extend the `DocumentBuilder` before it is built: servers, contact, license, further schemes.
|
|
85
|
+
*/
|
|
86
|
+
configureBuilder?: (builder: DocumentBuilder) => DocumentBuilder;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Options for `SwaggerModule.setup()` of both documents (`swaggerOptions`, `customSiteTitle`, ...).
|
|
90
|
+
*/
|
|
91
|
+
customOptions?: Omit<SwaggerCustomOptions, 'jsonDocumentUrl' | 'yamlDocumentUrl'>;
|
|
92
|
+
|
|
93
|
+
/** Description of the document */
|
|
94
|
+
description?: string;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Options for `SwaggerModule.createDocument()`. `autoTagControllers` and `deepScanRoutes` default to `true`.
|
|
98
|
+
* An own `operationIdFactory` is refused: the helper maps each operation to its handler through the
|
|
99
|
+
* default operation id (`<Controller>_<method>`).
|
|
100
|
+
*/
|
|
101
|
+
documentOptions?: Omit<SwaggerDocumentOptions, 'operationIdFactory'>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Path of the JSON document.
|
|
105
|
+
* @default '/api-docs-json'
|
|
106
|
+
*/
|
|
107
|
+
jsonDocumentUrl?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tags by use case instead of by controller: return the tags of an operation, or `undefined` to keep
|
|
111
|
+
* the ones it has.
|
|
112
|
+
*/
|
|
113
|
+
operationTags?: (operation: ISwaggerOperationInfo) => string[] | undefined;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Path of the Swagger UI.
|
|
117
|
+
* @default 'swagger'
|
|
118
|
+
*/
|
|
119
|
+
path?: string;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Order and descriptions of the tags. Tags an operation uses but this list does not name follow at the end.
|
|
123
|
+
*/
|
|
124
|
+
tags?: { description?: string; name: string }[];
|
|
125
|
+
|
|
126
|
+
/** Title of the document */
|
|
127
|
+
title: string;
|
|
128
|
+
|
|
129
|
+
/** Version of the document, usually the version of the project's package.json */
|
|
130
|
+
version?: string;
|
|
131
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export * from './core/common/helpers/process-diagnostics.helper';
|
|
|
53
53
|
export * from './core/common/helpers/register-enum.helper';
|
|
54
54
|
export * from './core/common/helpers/scim.helper';
|
|
55
55
|
export * from './core/common/helpers/service.helper';
|
|
56
|
+
export * from './core/common/helpers/swagger.helper';
|
|
56
57
|
export * from './core/common/helpers/table.helper';
|
|
57
58
|
export * from './core/common/inputs/combined-filter.input';
|
|
58
59
|
export * from './core/common/inputs/core-input.input';
|
|
@@ -73,6 +74,7 @@ export * from './core/common/interfaces/prepare-output-options.interface';
|
|
|
73
74
|
export * from './core/common/interfaces/resolve-selector.interface';
|
|
74
75
|
export * from './core/common/interfaces/server-options.interface';
|
|
75
76
|
export * from './core/common/interfaces/service-options.interface';
|
|
77
|
+
export * from './core/common/interfaces/swagger-setup-options.interface';
|
|
76
78
|
export * from './core/common/middleware/request-context.middleware';
|
|
77
79
|
export * from './core/common/middlewares/to-lower-case.middleware';
|
|
78
80
|
export * from './core/common/middlewares/security-headers.middleware';
|