@forinda/kickjs-swagger 2.0.1 → 2.1.0
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/dist/index.d.mts +179 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +497 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -12
- package/dist/decorators.d.ts +0 -33
- package/dist/decorators.d.ts.map +0 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -345
- package/dist/openapi-builder.d.ts +0 -36
- package/dist/openapi-builder.d.ts.map +0 -1
- package/dist/schema-parser.d.ts +0 -42
- package/dist/schema-parser.d.ts.map +0 -1
- package/dist/swagger.adapter.d.ts +0 -44
- package/dist/swagger.adapter.d.ts.map +0 -1
- package/dist/ui.d.ts +0 -5
- package/dist/ui.d.ts.map +0 -1
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
|
|
2
|
+
import { AdapterContext, AppAdapter } from "@forinda/kickjs";
|
|
3
|
+
|
|
4
|
+
//#region src/schema-parser.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Interface for converting validation library schemas to JSON Schema.
|
|
7
|
+
*
|
|
8
|
+
* KickJS ships with a Zod parser by default. To use a different validation
|
|
9
|
+
* library (Yup, Joi, Valibot, ArkType, etc.), implement this interface and
|
|
10
|
+
* pass it to the SwaggerAdapter.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import Joi from 'joi'
|
|
15
|
+
* import joiToJson from 'joi-to-json'
|
|
16
|
+
*
|
|
17
|
+
* const joiParser: SchemaParser = {
|
|
18
|
+
* name: 'joi',
|
|
19
|
+
* supports: (schema) => Joi.isSchema(schema),
|
|
20
|
+
* toJsonSchema: (schema) => joiToJson(schema),
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* new SwaggerAdapter({ schemaParser: joiParser })
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
interface SchemaParser {
|
|
27
|
+
/** Human-readable name for logging/debugging */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/**
|
|
30
|
+
* Return true if this parser can handle the given schema object.
|
|
31
|
+
* Called before `toJsonSchema` to allow graceful fallback.
|
|
32
|
+
*/
|
|
33
|
+
supports(schema: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Convert a validation schema to a JSON Schema object.
|
|
36
|
+
* Should return a plain object conforming to JSON Schema draft-07 or later.
|
|
37
|
+
* Must not include the top-level `$schema` key — the builder adds it.
|
|
38
|
+
*/
|
|
39
|
+
toJsonSchema(schema: unknown): Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Default schema parser for Zod v4+.
|
|
43
|
+
* Uses Zod's built-in `.toJSONSchema()` instance method.
|
|
44
|
+
*/
|
|
45
|
+
declare const zodSchemaParser: SchemaParser;
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/decorators.d.ts
|
|
48
|
+
interface ApiOperationOptions {
|
|
49
|
+
summary?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
operationId?: string;
|
|
52
|
+
deprecated?: boolean;
|
|
53
|
+
}
|
|
54
|
+
interface ApiResponseOptions {
|
|
55
|
+
status: number;
|
|
56
|
+
description?: string;
|
|
57
|
+
schema?: any;
|
|
58
|
+
/** Schema name in components/schemas (e.g., 'UserResponse', 'ErrorBody'). Auto-generated from handler name if omitted. */
|
|
59
|
+
name?: string;
|
|
60
|
+
}
|
|
61
|
+
/** Attach operation metadata to a route handler */
|
|
62
|
+
declare function ApiOperation(options: ApiOperationOptions): MethodDecorator;
|
|
63
|
+
/** Document a response status. Can be stacked multiple times. */
|
|
64
|
+
declare function ApiResponse(options: ApiResponseOptions): MethodDecorator;
|
|
65
|
+
/** Apply OpenAPI tags at class or method level */
|
|
66
|
+
declare function ApiTags(...tags: string[]): ClassDecorator & MethodDecorator;
|
|
67
|
+
/** Mark endpoint as requiring Bearer token auth */
|
|
68
|
+
declare function ApiBearerAuth(name?: string): ClassDecorator & MethodDecorator;
|
|
69
|
+
/** Exclude a controller or method from the OpenAPI spec */
|
|
70
|
+
declare function ApiExclude(): ClassDecorator & MethodDecorator;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/openapi-builder.d.ts
|
|
73
|
+
interface OpenAPIInfo {
|
|
74
|
+
title: string;
|
|
75
|
+
version: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
}
|
|
78
|
+
interface SwaggerOptions {
|
|
79
|
+
info?: Partial<OpenAPIInfo>;
|
|
80
|
+
servers?: {
|
|
81
|
+
url: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
}[];
|
|
84
|
+
bearerAuth?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Pluggable schema parser for converting validation schemas to JSON Schema.
|
|
87
|
+
* Defaults to `zodSchemaParser` which handles Zod v4+ schemas.
|
|
88
|
+
*
|
|
89
|
+
* Override this to use Yup, Joi, Valibot, ArkType, or any other library.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* new SwaggerAdapter({
|
|
94
|
+
* schemaParser: myYupParser,
|
|
95
|
+
* })
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
schemaParser?: SchemaParser;
|
|
99
|
+
}
|
|
100
|
+
/** Register a controller for OpenAPI introspection (called by Application during route mounting) */
|
|
101
|
+
declare function registerControllerForDocs(controllerClass: any, mountPath: string): void;
|
|
102
|
+
/** Clear all registered routes (for HMR) */
|
|
103
|
+
declare function clearRegisteredRoutes(): void;
|
|
104
|
+
/** Build a full OpenAPI 3.0.3 spec from registered controllers and their decorators */
|
|
105
|
+
declare function buildOpenAPISpec(options?: SwaggerOptions): any;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/swagger.adapter.d.ts
|
|
108
|
+
interface SwaggerAdapterOptions extends SwaggerOptions {
|
|
109
|
+
/** Path to serve Swagger UI (default: '/docs') */
|
|
110
|
+
docsPath?: string;
|
|
111
|
+
/** Path to serve ReDoc (default: '/redoc') */
|
|
112
|
+
redocPath?: string;
|
|
113
|
+
/** Path to serve the raw JSON spec (default: '/openapi.json') */
|
|
114
|
+
specPath?: string;
|
|
115
|
+
/** Other adapters to discover (e.g., WsAdapter for WebSocket server URLs) */
|
|
116
|
+
adapters?: any[];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Swagger adapter — auto-generates OpenAPI spec from decorators and serves docs.
|
|
120
|
+
*
|
|
121
|
+
* Assets are served locally from `swagger-ui-dist` (npm dependency) —
|
|
122
|
+
* no CDN required, works fully offline.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* bootstrap({
|
|
127
|
+
* modules,
|
|
128
|
+
* adapters: [
|
|
129
|
+
* new SwaggerAdapter({
|
|
130
|
+
* info: { title: 'My API', version: '1.0.0' },
|
|
131
|
+
* }),
|
|
132
|
+
* ],
|
|
133
|
+
* })
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* Endpoints:
|
|
137
|
+
* GET /docs — Swagger UI (local assets, no CDN)
|
|
138
|
+
* GET /redoc — ReDoc (CDN — no local package available)
|
|
139
|
+
* GET /openapi.json — Raw OpenAPI 3.0.3 spec
|
|
140
|
+
*/
|
|
141
|
+
declare class SwaggerAdapter implements AppAdapter {
|
|
142
|
+
private options;
|
|
143
|
+
name: string;
|
|
144
|
+
constructor(options?: SwaggerAdapterOptions);
|
|
145
|
+
/** Auto-detect server URLs from the running HTTP server and peer adapters */
|
|
146
|
+
afterStart({
|
|
147
|
+
server
|
|
148
|
+
}: AdapterContext): void;
|
|
149
|
+
/** Collect controller metadata as routes are mounted */
|
|
150
|
+
onRouteMount(controllerClass: any, mountPath: string): void;
|
|
151
|
+
beforeMount({
|
|
152
|
+
app
|
|
153
|
+
}: AdapterContext): void;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/ui.d.ts
|
|
157
|
+
/**
|
|
158
|
+
* Generate Swagger UI HTML using local assets from swagger-ui-dist.
|
|
159
|
+
*
|
|
160
|
+
* Assets are served from `/_swagger-assets/` by the adapter's Express
|
|
161
|
+
* static middleware. Falls back to CDN if the local path is not provided.
|
|
162
|
+
* This ensures Swagger UI works fully offline in development.
|
|
163
|
+
*
|
|
164
|
+
* @param specUrl - Path to the OpenAPI JSON spec (e.g., '/openapi.json')
|
|
165
|
+
* @param title - Page title
|
|
166
|
+
* @param assetsPath - Base path for local swagger-ui-dist assets (e.g., '/_swagger-assets')
|
|
167
|
+
*/
|
|
168
|
+
declare function swaggerUIHtml(specUrl: string, title?: string, assetsPath?: string): string;
|
|
169
|
+
/**
|
|
170
|
+
* Generate ReDoc HTML.
|
|
171
|
+
*
|
|
172
|
+
* ReDoc doesn't publish a standalone npm package suitable for local serving,
|
|
173
|
+
* so it still loads from CDN. If offline support for ReDoc is needed,
|
|
174
|
+
* vendor the standalone bundle into the package's public/ directory.
|
|
175
|
+
*/
|
|
176
|
+
declare function redocHtml(specUrl: string, title?: string): string;
|
|
177
|
+
//#endregion
|
|
178
|
+
export { ApiBearerAuth, ApiExclude, ApiOperation, type ApiOperationOptions, ApiResponse, type ApiResponseOptions, ApiTags, type OpenAPIInfo, type SchemaParser, SwaggerAdapter, type SwaggerAdapterOptions, type SwaggerOptions, buildOpenAPISpec, clearRegisteredRoutes, redocHtml, registerControllerForDocs, swaggerUIHtml, zodSchemaParser };
|
|
179
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/schema-parser.ts","../src/decorators.ts","../src/openapi-builder.ts","../src/swagger.adapter.ts","../src/ui.ts"],"mappings":";;;;;;;AAqBA;;;;;;;;;;;;AAsBA;;;;;;UAtBiB,YAAA;;WAEN,IAAA;ECXyB;;;;EDiBlC,QAAA,CAAS,MAAA;ECdT;;;;AAIF;EDiBE,YAAA,CAAa,MAAA,YAAkB,MAAA;AAAA;;;;;cAOpB,eAAA,EAAiB,YAAA;;;UC/Bb,mBAAA;EACf,OAAA;EACA,WAAA;EACA,WAAA;EACA,UAAA;AAAA;AAAA,UAGe,kBAAA;EACf,MAAA;EACA,WAAA;EACA,MAAA;EDqB4B;ECnB5B,IAAA;AAAA;;iBAIc,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,eAAA;AAhB5D;AAAA,iBAuBgB,WAAA,CAAY,OAAA,EAAS,kBAAA,GAAqB,eAAA;;iBAY1C,OAAA,CAAA,GAAW,IAAA,aAAiB,cAAA,GAAiB,eAAA;;iBAW7C,aAAA,CAAc,IAAA,YAAsB,cAAA,GAAiB,eAAA;;iBAWrD,UAAA,CAAA,GAAc,cAAA,GAAiB,eAAA;;;UCxD9B,WAAA;EACf,KAAA;EACA,OAAA;EACA,WAAA;AAAA;AAAA,UAGe,cAAA;EACf,IAAA,GAAO,OAAA,CAAQ,WAAA;EACf,OAAA;IAAY,GAAA;IAAa,WAAA;EAAA;EACzB,UAAA;EFcqC;;AAOvC;;;;;;;;AC/BA;;;ECwBE,YAAA,GAAe,YAAA;AAAA;;iBAWD,yBAAA,CAA0B,eAAA,OAAsB,SAAA;;iBAKhD,qBAAA,CAAA;;iBAKA,gBAAA,CAAiB,OAAA,GAAS,cAAA;;;UCjCzB,qBAAA,SAA8B,cAAA;EHH9B;EGKf,QAAA;;EAEA,SAAA;EHLS;EGOT,QAAA;EHDS;EGGT,QAAA;AAAA;;;;AHWF;;;;;;;;AC/BA;;;;;;;;;;AAOA;;cEuCa,cAAA,YAA0B,UAAA;EAAA,QAGjB,OAAA;EAFpB,IAAA;cAEoB,OAAA,GAAS,qBAAA;EFvC7B;EE0CA,UAAA,CAAA;IAAa;EAAA,GAAU,cAAA;EFxCnB;EEmEJ,YAAA,CAAa,eAAA,OAAsB,SAAA;EAInC,WAAA,CAAA;IAAc;EAAA,GAAO,cAAA;AAAA;;;;;;AH1EvB;;;;;;;;iBIAgB,aAAA,CAAc,OAAA,UAAiB,KAAA,WAAoB,UAAA;;;;AJsBnE;;;;iBI0BgB,SAAA,CAAU,OAAA,UAAiB,KAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forinda/kickjs-swagger v2.1.0
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) Felix Orinda
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { Logger, METADATA, getClassMeta, getClassMetaOrUndefined, getMethodMeta, getMethodMetaOrUndefined, hasClassMeta, joinPaths, pushMethodMeta, setClassMeta, setMethodMeta } from "@forinda/kickjs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import express, { Router } from "express";
|
|
15
|
+
//#region src/schema-parser.ts
|
|
16
|
+
/**
|
|
17
|
+
* Default schema parser for Zod v4+.
|
|
18
|
+
* Uses Zod's built-in `.toJSONSchema()` instance method.
|
|
19
|
+
*/
|
|
20
|
+
const zodSchemaParser = {
|
|
21
|
+
name: "zod",
|
|
22
|
+
supports(schema) {
|
|
23
|
+
return schema != null && typeof schema === "object" && typeof schema.safeParse === "function" && typeof schema.toJSONSchema === "function";
|
|
24
|
+
},
|
|
25
|
+
toJsonSchema(schema) {
|
|
26
|
+
const { $schema: _, ...rest } = schema.toJSONSchema();
|
|
27
|
+
return rest;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/decorators.ts
|
|
32
|
+
const SWAGGER_KEYS = {
|
|
33
|
+
OPERATION: Symbol("kick:swagger:operation"),
|
|
34
|
+
RESPONSES: Symbol("kick:swagger:responses"),
|
|
35
|
+
TAGS: Symbol("kick:swagger:tags"),
|
|
36
|
+
BEARER_AUTH: Symbol("kick:swagger:bearer"),
|
|
37
|
+
EXCLUDE: Symbol("kick:swagger:exclude")
|
|
38
|
+
};
|
|
39
|
+
/** Attach operation metadata to a route handler */
|
|
40
|
+
function ApiOperation(options) {
|
|
41
|
+
return (target, propertyKey) => {
|
|
42
|
+
setMethodMeta(SWAGGER_KEYS.OPERATION, options, target.constructor, propertyKey);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Document a response status. Can be stacked multiple times. */
|
|
46
|
+
function ApiResponse(options) {
|
|
47
|
+
return (target, propertyKey) => {
|
|
48
|
+
pushMethodMeta(SWAGGER_KEYS.RESPONSES, target.constructor, propertyKey, options);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Apply OpenAPI tags at class or method level */
|
|
52
|
+
function ApiTags(...tags) {
|
|
53
|
+
return (target, propertyKey) => {
|
|
54
|
+
if (propertyKey) setMethodMeta(SWAGGER_KEYS.TAGS, tags, target.constructor, propertyKey);
|
|
55
|
+
else setClassMeta(SWAGGER_KEYS.TAGS, tags, target);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Mark endpoint as requiring Bearer token auth */
|
|
59
|
+
function ApiBearerAuth(name = "BearerAuth") {
|
|
60
|
+
return (target, propertyKey) => {
|
|
61
|
+
if (propertyKey) setMethodMeta(SWAGGER_KEYS.BEARER_AUTH, name, target.constructor, propertyKey);
|
|
62
|
+
else setClassMeta(SWAGGER_KEYS.BEARER_AUTH, name, target);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Exclude a controller or method from the OpenAPI spec */
|
|
66
|
+
function ApiExclude() {
|
|
67
|
+
return (target, propertyKey) => {
|
|
68
|
+
if (propertyKey) setMethodMeta(SWAGGER_KEYS.EXCLUDE, true, target.constructor, propertyKey);
|
|
69
|
+
else setClassMeta(SWAGGER_KEYS.EXCLUDE, true, target);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/openapi-builder.ts
|
|
74
|
+
const registeredRoutes = [];
|
|
75
|
+
/** Register a controller for OpenAPI introspection (called by Application during route mounting) */
|
|
76
|
+
function registerControllerForDocs(controllerClass, mountPath) {
|
|
77
|
+
registeredRoutes.push({
|
|
78
|
+
controllerClass,
|
|
79
|
+
mountPath
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Clear all registered routes (for HMR) */
|
|
83
|
+
function clearRegisteredRoutes() {
|
|
84
|
+
registeredRoutes.length = 0;
|
|
85
|
+
}
|
|
86
|
+
/** Build a full OpenAPI 3.0.3 spec from registered controllers and their decorators */
|
|
87
|
+
function buildOpenAPISpec(options = {}) {
|
|
88
|
+
const parser = options.schemaParser ?? zodSchemaParser;
|
|
89
|
+
/** Convert a validation schema to JSON Schema using the configured parser */
|
|
90
|
+
const toJsonSchema = (schema) => {
|
|
91
|
+
try {
|
|
92
|
+
if (!parser.supports(schema)) return null;
|
|
93
|
+
return parser.toJsonSchema(schema);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const componentSchemas = {};
|
|
99
|
+
let schemaCounter = 0;
|
|
100
|
+
/**
|
|
101
|
+
* Register a schema in components.schemas and return a $ref pointer.
|
|
102
|
+
* If the schema has a title/label, use that as the name. Otherwise generate one.
|
|
103
|
+
*/
|
|
104
|
+
const registerSchema = (jsonSchema, hint) => {
|
|
105
|
+
let name = jsonSchema.title || jsonSchema.label || hint || "";
|
|
106
|
+
if (!name) name = `Schema${++schemaCounter}`;
|
|
107
|
+
name = name.replace(/[^a-zA-Z0-9]/g, "");
|
|
108
|
+
if (!componentSchemas[name]) {
|
|
109
|
+
const clean = { ...jsonSchema };
|
|
110
|
+
delete clean.title;
|
|
111
|
+
delete clean.label;
|
|
112
|
+
delete clean.$schema;
|
|
113
|
+
componentSchemas[name] = clean;
|
|
114
|
+
}
|
|
115
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
116
|
+
};
|
|
117
|
+
const spec = {
|
|
118
|
+
openapi: "3.0.3",
|
|
119
|
+
info: {
|
|
120
|
+
title: options.info?.title || "API",
|
|
121
|
+
version: options.info?.version || "1.0.0",
|
|
122
|
+
...options.info?.description ? { description: options.info.description } : {}
|
|
123
|
+
},
|
|
124
|
+
paths: {},
|
|
125
|
+
components: {
|
|
126
|
+
schemas: {},
|
|
127
|
+
securitySchemes: {}
|
|
128
|
+
},
|
|
129
|
+
tags: []
|
|
130
|
+
};
|
|
131
|
+
if (options.servers) spec.servers = options.servers;
|
|
132
|
+
const allTags = /* @__PURE__ */ new Set();
|
|
133
|
+
const securitySchemes = {};
|
|
134
|
+
for (const { controllerClass, mountPath } of registeredRoutes) {
|
|
135
|
+
if (hasClassMeta(SWAGGER_KEYS.EXCLUDE, controllerClass)) continue;
|
|
136
|
+
const routes = getClassMeta(METADATA.ROUTES, controllerClass, []);
|
|
137
|
+
const classTags = getClassMeta(SWAGGER_KEYS.TAGS, controllerClass, []);
|
|
138
|
+
const classAuth = getClassMetaOrUndefined(SWAGGER_KEYS.BEARER_AUTH, controllerClass);
|
|
139
|
+
for (const route of routes) {
|
|
140
|
+
if (getMethodMetaOrUndefined(SWAGGER_KEYS.EXCLUDE, controllerClass, route.handlerName)) continue;
|
|
141
|
+
const fullPath = joinPaths(mountPath, route.path);
|
|
142
|
+
const openApiPath = fullPath.replace(/:([a-zA-Z_]+)/g, "{$1}");
|
|
143
|
+
const method = route.method.toLowerCase();
|
|
144
|
+
const operation = getMethodMeta(SWAGGER_KEYS.OPERATION, controllerClass, route.handlerName, {});
|
|
145
|
+
const responses = getMethodMeta(SWAGGER_KEYS.RESPONSES, controllerClass, route.handlerName, []);
|
|
146
|
+
const methodTags = getMethodMeta(SWAGGER_KEYS.TAGS, controllerClass, route.handlerName, []);
|
|
147
|
+
const methodAuth = getMethodMetaOrUndefined(SWAGGER_KEYS.BEARER_AUTH, controllerClass, route.handlerName);
|
|
148
|
+
const tags = methodTags.length > 0 ? methodTags : classTags;
|
|
149
|
+
tags.forEach((t) => allTags.add(t));
|
|
150
|
+
const op = {
|
|
151
|
+
...tags.length > 0 ? { tags } : {},
|
|
152
|
+
...operation.summary ? { summary: operation.summary } : {},
|
|
153
|
+
...operation.description ? { description: operation.description } : {},
|
|
154
|
+
...operation.operationId ? { operationId: operation.operationId } : {},
|
|
155
|
+
...operation.deprecated ? { deprecated: true } : {},
|
|
156
|
+
parameters: [],
|
|
157
|
+
responses: {}
|
|
158
|
+
};
|
|
159
|
+
const paramMatches = fullPath.match(/:([a-zA-Z_]+)/g) || [];
|
|
160
|
+
for (const match of paramMatches) {
|
|
161
|
+
const paramName = match.slice(1);
|
|
162
|
+
let schema = { type: "string" };
|
|
163
|
+
if (route.validation?.params) {
|
|
164
|
+
const jsonSchema = toJsonSchema(route.validation.params);
|
|
165
|
+
if (jsonSchema?.properties && typeof jsonSchema.properties === "object") {
|
|
166
|
+
const props = jsonSchema.properties;
|
|
167
|
+
if (props[paramName]) schema = props[paramName];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
op.parameters.push({
|
|
171
|
+
name: paramName,
|
|
172
|
+
in: "path",
|
|
173
|
+
required: true,
|
|
174
|
+
schema
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (route.validation?.query) {
|
|
178
|
+
const jsonSchema = toJsonSchema(route.validation.query);
|
|
179
|
+
if (jsonSchema?.properties && typeof jsonSchema.properties === "object") {
|
|
180
|
+
const required = Array.isArray(jsonSchema.required) ? jsonSchema.required : [];
|
|
181
|
+
for (const [name, propSchema] of Object.entries(jsonSchema.properties)) op.parameters.push({
|
|
182
|
+
name,
|
|
183
|
+
in: "query",
|
|
184
|
+
required: required.includes(name),
|
|
185
|
+
schema: propSchema
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const queryParamsConfig = getMethodMetaOrUndefined(METADATA.QUERY_PARAMS, controllerClass, route.handlerName);
|
|
190
|
+
if (queryParamsConfig) {
|
|
191
|
+
if (queryParamsConfig.filterable?.length) op.parameters.push({
|
|
192
|
+
name: "filter",
|
|
193
|
+
in: "query",
|
|
194
|
+
required: false,
|
|
195
|
+
description: `Filter fields: ${queryParamsConfig.filterable.join(", ")}. Format: \`field:operator:value\`. Operators: eq, neq, gt, gte, lt, lte, contains, starts, ends, in, between`,
|
|
196
|
+
schema: {
|
|
197
|
+
type: "array",
|
|
198
|
+
items: { type: "string" }
|
|
199
|
+
},
|
|
200
|
+
style: "form",
|
|
201
|
+
explode: true
|
|
202
|
+
});
|
|
203
|
+
if (queryParamsConfig.sortable?.length) op.parameters.push({
|
|
204
|
+
name: "sort",
|
|
205
|
+
in: "query",
|
|
206
|
+
required: false,
|
|
207
|
+
description: `Sort fields: ${queryParamsConfig.sortable.join(", ")}. Format: \`field:asc\` or \`field:desc\``,
|
|
208
|
+
schema: {
|
|
209
|
+
type: "array",
|
|
210
|
+
items: { type: "string" }
|
|
211
|
+
},
|
|
212
|
+
style: "form",
|
|
213
|
+
explode: true
|
|
214
|
+
});
|
|
215
|
+
if (queryParamsConfig.searchable?.length) op.parameters.push({
|
|
216
|
+
name: "q",
|
|
217
|
+
in: "query",
|
|
218
|
+
required: false,
|
|
219
|
+
description: `Search across: ${queryParamsConfig.searchable.join(", ")}`,
|
|
220
|
+
schema: { type: "string" }
|
|
221
|
+
});
|
|
222
|
+
op.parameters.push({
|
|
223
|
+
name: "page",
|
|
224
|
+
in: "query",
|
|
225
|
+
required: false,
|
|
226
|
+
description: "Page number (default: 1)",
|
|
227
|
+
schema: {
|
|
228
|
+
type: "integer",
|
|
229
|
+
minimum: 1,
|
|
230
|
+
default: 1
|
|
231
|
+
}
|
|
232
|
+
}, {
|
|
233
|
+
name: "limit",
|
|
234
|
+
in: "query",
|
|
235
|
+
required: false,
|
|
236
|
+
description: "Items per page (default: 20, max: 100)",
|
|
237
|
+
schema: {
|
|
238
|
+
type: "integer",
|
|
239
|
+
minimum: 1,
|
|
240
|
+
maximum: 100,
|
|
241
|
+
default: 20
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (op.parameters.length === 0) delete op.parameters;
|
|
246
|
+
if (route.validation?.body && [
|
|
247
|
+
"post",
|
|
248
|
+
"put",
|
|
249
|
+
"patch"
|
|
250
|
+
].includes(method)) {
|
|
251
|
+
const bodySchema = toJsonSchema(route.validation.body);
|
|
252
|
+
if (bodySchema) {
|
|
253
|
+
const ref = registerSchema(bodySchema, route.validation.name || `${route.handlerName}Body`);
|
|
254
|
+
op.requestBody = {
|
|
255
|
+
required: true,
|
|
256
|
+
content: { "application/json": { schema: ref } }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const fileUpload = getMethodMetaOrUndefined(METADATA.FILE_UPLOAD, controllerClass, route.handlerName);
|
|
261
|
+
if (fileUpload) {
|
|
262
|
+
const fieldName = fileUpload.fieldName ?? "file";
|
|
263
|
+
const properties = {};
|
|
264
|
+
if (fileUpload.mode === "array") properties[fieldName] = {
|
|
265
|
+
type: "array",
|
|
266
|
+
items: {
|
|
267
|
+
type: "string",
|
|
268
|
+
format: "binary"
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
else if (fileUpload.mode !== "none") properties[fieldName] = {
|
|
272
|
+
type: "string",
|
|
273
|
+
format: "binary"
|
|
274
|
+
};
|
|
275
|
+
op.requestBody = {
|
|
276
|
+
required: true,
|
|
277
|
+
content: { "multipart/form-data": { schema: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties
|
|
280
|
+
} } }
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (responses.length > 0) for (const resp of responses) op.responses[String(resp.status)] = {
|
|
284
|
+
description: resp.description || "",
|
|
285
|
+
...resp.schema ? (() => {
|
|
286
|
+
const converted = typeof resp.schema === "function" || typeof resp.schema === "object" ? toJsonSchema(resp.schema) : null;
|
|
287
|
+
const schemaName = resp.name || `${route.handlerName}Response${resp.status}`;
|
|
288
|
+
const finalSchema = converted ? registerSchema(converted, schemaName) : typeof resp.schema === "object" ? resp.schema : void 0;
|
|
289
|
+
return finalSchema ? { content: { "application/json": { schema: finalSchema } } } : {};
|
|
290
|
+
})() : {}
|
|
291
|
+
};
|
|
292
|
+
else {
|
|
293
|
+
const defaultStatus = method === "post" ? "201" : method === "delete" ? "204" : "200";
|
|
294
|
+
op.responses[defaultStatus] = { description: "Successful operation" };
|
|
295
|
+
if (route.validation?.body) op.responses["422"] = { description: "Validation error" };
|
|
296
|
+
}
|
|
297
|
+
const authName = methodAuth || classAuth;
|
|
298
|
+
if (authName) {
|
|
299
|
+
op.security = [{ [authName]: [] }];
|
|
300
|
+
securitySchemes[authName] = {
|
|
301
|
+
type: "http",
|
|
302
|
+
scheme: "bearer",
|
|
303
|
+
bearerFormat: "JWT"
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!spec.paths[openApiPath]) spec.paths[openApiPath] = {};
|
|
307
|
+
spec.paths[openApiPath][method] = op;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
spec.tags = Array.from(allTags).map((name) => ({ name }));
|
|
311
|
+
spec.components.securitySchemes = securitySchemes;
|
|
312
|
+
if (options.bearerAuth) {
|
|
313
|
+
if (!securitySchemes.BearerAuth) spec.components.securitySchemes.BearerAuth = {
|
|
314
|
+
type: "http",
|
|
315
|
+
scheme: "bearer",
|
|
316
|
+
bearerFormat: "JWT"
|
|
317
|
+
};
|
|
318
|
+
spec.security = [{ BearerAuth: [] }];
|
|
319
|
+
}
|
|
320
|
+
spec.components.schemas = componentSchemas;
|
|
321
|
+
if (Object.keys(spec.components.schemas).length === 0) delete spec.components.schemas;
|
|
322
|
+
if (Object.keys(spec.components.securitySchemes).length === 0) delete spec.components.securitySchemes;
|
|
323
|
+
if (Object.keys(spec.components).length === 0) delete spec.components;
|
|
324
|
+
return spec;
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/ui.ts
|
|
328
|
+
/** Escape a string for safe HTML attribute/content interpolation */
|
|
329
|
+
function escapeHtml(str) {
|
|
330
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Generate Swagger UI HTML using local assets from swagger-ui-dist.
|
|
334
|
+
*
|
|
335
|
+
* Assets are served from `/_swagger-assets/` by the adapter's Express
|
|
336
|
+
* static middleware. Falls back to CDN if the local path is not provided.
|
|
337
|
+
* This ensures Swagger UI works fully offline in development.
|
|
338
|
+
*
|
|
339
|
+
* @param specUrl - Path to the OpenAPI JSON spec (e.g., '/openapi.json')
|
|
340
|
+
* @param title - Page title
|
|
341
|
+
* @param assetsPath - Base path for local swagger-ui-dist assets (e.g., '/_swagger-assets')
|
|
342
|
+
*/
|
|
343
|
+
function swaggerUIHtml(specUrl, title = "API Docs", assetsPath) {
|
|
344
|
+
const safeTitle = escapeHtml(title);
|
|
345
|
+
const safeUrl = JSON.stringify(specUrl).replace(/</g, "\\u003c");
|
|
346
|
+
return `<!DOCTYPE html>
|
|
347
|
+
<html lang="en">
|
|
348
|
+
<head>
|
|
349
|
+
<meta charset="UTF-8">
|
|
350
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
351
|
+
<title>${safeTitle}</title>
|
|
352
|
+
<link rel="stylesheet" href="${assetsPath ? `${assetsPath}/swagger-ui.css` : "https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"}">
|
|
353
|
+
</head>
|
|
354
|
+
<body>
|
|
355
|
+
<div id="swagger-ui"></div>
|
|
356
|
+
<script src="${assetsPath ? `${assetsPath}/swagger-ui-bundle.js` : "https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"}"><\/script>
|
|
357
|
+
<script src="${assetsPath ? `${assetsPath}/swagger-ui-standalone-preset.js` : "https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"}"><\/script>
|
|
358
|
+
<script>
|
|
359
|
+
SwaggerUIBundle({
|
|
360
|
+
url: ${safeUrl},
|
|
361
|
+
dom_id: '#swagger-ui',
|
|
362
|
+
deepLinking: true,
|
|
363
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
364
|
+
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
|
365
|
+
layout: 'StandaloneLayout',
|
|
366
|
+
});
|
|
367
|
+
<\/script>
|
|
368
|
+
</body>
|
|
369
|
+
</html>`;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Generate ReDoc HTML.
|
|
373
|
+
*
|
|
374
|
+
* ReDoc doesn't publish a standalone npm package suitable for local serving,
|
|
375
|
+
* so it still loads from CDN. If offline support for ReDoc is needed,
|
|
376
|
+
* vendor the standalone bundle into the package's public/ directory.
|
|
377
|
+
*/
|
|
378
|
+
function redocHtml(specUrl, title = "API Docs") {
|
|
379
|
+
return `<!DOCTYPE html>
|
|
380
|
+
<html lang="en">
|
|
381
|
+
<head>
|
|
382
|
+
<meta charset="UTF-8">
|
|
383
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
384
|
+
<title>${escapeHtml(title)}</title>
|
|
385
|
+
</head>
|
|
386
|
+
<body>
|
|
387
|
+
<redoc spec-url="${escapeHtml(specUrl)}"></redoc>
|
|
388
|
+
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"><\/script>
|
|
389
|
+
</body>
|
|
390
|
+
</html>`;
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/swagger.adapter.ts
|
|
394
|
+
const log = Logger.for("SwaggerAdapter");
|
|
395
|
+
/**
|
|
396
|
+
* Resolve the absolute path to swagger-ui-dist's static assets.
|
|
397
|
+
* Uses createRequire to find it relative to this package (works with pnpm).
|
|
398
|
+
*/
|
|
399
|
+
function getSwaggerUiDistPath() {
|
|
400
|
+
return dirname(createRequire(import.meta.url).resolve("swagger-ui-dist/package.json"));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Swagger adapter — auto-generates OpenAPI spec from decorators and serves docs.
|
|
404
|
+
*
|
|
405
|
+
* Assets are served locally from `swagger-ui-dist` (npm dependency) —
|
|
406
|
+
* no CDN required, works fully offline.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* ```ts
|
|
410
|
+
* bootstrap({
|
|
411
|
+
* modules,
|
|
412
|
+
* adapters: [
|
|
413
|
+
* new SwaggerAdapter({
|
|
414
|
+
* info: { title: 'My API', version: '1.0.0' },
|
|
415
|
+
* }),
|
|
416
|
+
* ],
|
|
417
|
+
* })
|
|
418
|
+
* ```
|
|
419
|
+
*
|
|
420
|
+
* Endpoints:
|
|
421
|
+
* GET /docs — Swagger UI (local assets, no CDN)
|
|
422
|
+
* GET /redoc — ReDoc (CDN — no local package available)
|
|
423
|
+
* GET /openapi.json — Raw OpenAPI 3.0.3 spec
|
|
424
|
+
*/
|
|
425
|
+
var SwaggerAdapter = class {
|
|
426
|
+
name = "SwaggerAdapter";
|
|
427
|
+
constructor(options = {}) {
|
|
428
|
+
this.options = options;
|
|
429
|
+
}
|
|
430
|
+
/** Auto-detect server URLs from the running HTTP server and peer adapters */
|
|
431
|
+
afterStart({ server }) {
|
|
432
|
+
const addr = server?.address?.();
|
|
433
|
+
if (!addr || typeof addr !== "object") return;
|
|
434
|
+
const host = addr.address === "::" || addr.address === "0.0.0.0" ? "localhost" : addr.address;
|
|
435
|
+
if (!this.options.servers || this.options.servers.length === 0) this.options.servers = [{
|
|
436
|
+
url: `http://${host}:${addr.port}`,
|
|
437
|
+
description: "HTTP server"
|
|
438
|
+
}];
|
|
439
|
+
const wsAdapter = this.options.adapters?.find((a) => a.name === "WsAdapter" && typeof a.getStats === "function");
|
|
440
|
+
if (wsAdapter) {
|
|
441
|
+
const stats = wsAdapter.getStats();
|
|
442
|
+
for (const namespace of Object.keys(stats.namespaces || {})) this.options.servers.push({
|
|
443
|
+
url: `ws://${host}:${addr.port}${namespace}`,
|
|
444
|
+
description: `WebSocket: ${namespace}`
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Collect controller metadata as routes are mounted */
|
|
449
|
+
onRouteMount(controllerClass, mountPath) {
|
|
450
|
+
registerControllerForDocs(controllerClass, mountPath);
|
|
451
|
+
}
|
|
452
|
+
beforeMount({ app }) {
|
|
453
|
+
clearRegisteredRoutes();
|
|
454
|
+
const docsPath = this.options.docsPath ?? "/docs";
|
|
455
|
+
const redocPath = this.options.redocPath ?? "/redoc";
|
|
456
|
+
const specPath = this.options.specPath ?? "/openapi.json";
|
|
457
|
+
let uiDistAvailable = false;
|
|
458
|
+
const docsRouter = Router();
|
|
459
|
+
const swaggerAssetsPath = "/_swagger-assets";
|
|
460
|
+
try {
|
|
461
|
+
const swaggerDistDir = getSwaggerUiDistPath();
|
|
462
|
+
docsRouter.use(swaggerAssetsPath, express.static(swaggerDistDir));
|
|
463
|
+
uiDistAvailable = true;
|
|
464
|
+
} catch {
|
|
465
|
+
log.warn("swagger-ui-dist not found — Swagger UI will load from CDN (requires internet).");
|
|
466
|
+
}
|
|
467
|
+
docsRouter.use((_req, res, next) => {
|
|
468
|
+
res.setHeader("Content-Security-Policy", [
|
|
469
|
+
"default-src 'self'",
|
|
470
|
+
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.redoc.ly https://cdn.jsdelivr.net",
|
|
471
|
+
"style-src 'self' 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com",
|
|
472
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
473
|
+
"img-src 'self' data: https://unpkg.com",
|
|
474
|
+
"connect-src 'self'"
|
|
475
|
+
].join("; "));
|
|
476
|
+
next();
|
|
477
|
+
});
|
|
478
|
+
docsRouter.get(specPath, (_req, res) => {
|
|
479
|
+
const spec = buildOpenAPISpec(this.options);
|
|
480
|
+
res.json(spec);
|
|
481
|
+
});
|
|
482
|
+
docsRouter.get(docsPath, (_req, res) => {
|
|
483
|
+
res.type("html").send(swaggerUIHtml(specPath, this.options.info?.title, uiDistAvailable ? swaggerAssetsPath : void 0));
|
|
484
|
+
});
|
|
485
|
+
docsRouter.get(redocPath, (_req, res) => {
|
|
486
|
+
res.type("html").send(redocHtml(specPath, this.options.info?.title));
|
|
487
|
+
});
|
|
488
|
+
app.use(docsRouter);
|
|
489
|
+
log.info(`Swagger UI: ${docsPath}`);
|
|
490
|
+
log.info(`ReDoc: ${redocPath}`);
|
|
491
|
+
log.info(`OpenAPI spec: ${specPath}`);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
//#endregion
|
|
495
|
+
export { ApiBearerAuth, ApiExclude, ApiOperation, ApiResponse, ApiTags, SwaggerAdapter, buildOpenAPISpec, clearRegisteredRoutes, redocHtml, registerControllerForDocs, swaggerUIHtml, zodSchemaParser };
|
|
496
|
+
|
|
497
|
+
//# sourceMappingURL=index.mjs.map
|