UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

377 lines (373 loc) 12.4 kB
import "alepha/security"; import { $atom, $hook, $inject, $module, $state, Alepha, KIND, Primitive, createPrimitive, isTypeFile, z } from "alepha"; import { $action, AlephaServer, ServerProvider, ServerRouterProvider } from "alepha/server"; import { AlephaServerEtag } from "alepha/server/etag"; import { AlephaServerStatic, ServerStaticProvider } from "alepha/server/static"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { $logger } from "alepha/logger"; import { FileSystemProvider } from "alepha/system"; //#region ../../src/server/swagger/primitives/$swagger.ts /** * Creates an OpenAPI/Swagger documentation primitive with interactive UI. * * Automatically generates API documentation from your $action primitives and serves * an interactive Swagger UI for testing endpoints. Supports customization, tag filtering, * and OAuth configuration. * * @example * ```ts * class App { * docs = $swagger({ * prefix: "/api-docs", * info: { * title: "My API", * version: "1.0.0", * description: "REST API documentation" * }, * excludeTags: ["internal"], * ui: { root: "/swagger" } * }); * } * ``` */ const $swagger = (options = {}) => { return createPrimitive(SwaggerPrimitive, options); }; var SwaggerPrimitive = class extends Primitive {}; $swagger[KIND] = SwaggerPrimitive; //#endregion //#region ../../src/server/swagger/providers/ServerSwaggerProvider.ts /** * Swagger provider configuration atom */ const swaggerOptions = $atom({ name: "alepha.server.swagger.options", schema: z.object({ excludeKeys: z.array(z.string()).describe("Keys to exclude from swagger schema").optional() }), default: { excludeKeys: [] } }); var ServerSwaggerProvider = class { serverStaticProvider = $inject(ServerStaticProvider); serverRouterProvider = $inject(ServerRouterProvider); serverProvider = $inject(ServerProvider); alepha = $inject(Alepha); log = $logger(); options = $state(swaggerOptions); fs = $inject(FileSystemProvider); json; configure = $hook({ on: "configure", priority: "last", handler: async (alepha) => { const options = alepha.primitives($swagger)?.[0]?.options; if (!options) return; this.json = await this.setupSwaggerPlugin(options); } }); generateSwaggerDoc(options) { const json = this.configureOpenApi(this.alepha.primitives($action), options); if (options.rewrite) options.rewrite(json); return json; } async setupSwaggerPlugin(options) { if (options.disabled) return; const json = this.generateSwaggerDoc(options); const prefix = options.prefix ?? "/docs"; this.configureSwaggerApi(prefix, json); if (options.ui !== false) await this.configureSwaggerUi(prefix, options); else this.log.info(`Swagger API available at ${prefix}/json`); return json; } configureOpenApi(actions, doc) { const openApi = { openapi: "3.0.0", info: doc.info ?? { title: "API Documentation", version: "1.0.0" }, servers: doc.servers ?? [{ url: this.serverProvider.hostname }], paths: {}, components: {} }; let hasSecurity = false; const excludeTags = doc.excludeTags ?? []; const schemas = {}; const toJson = (source) => { let json; try { json = z.toJSONSchema(source); } catch { json = {}; } json.$schema = void 0; this.removePrivateFields(json, [...this.options.excludeKeys || [], "~options"]); return json; }; const schema = (source) => { const json = toJson(source); const title = (typeof source?.meta === "function" ? source.meta()?.title : void 0) ?? json.title; if (typeof title === "string") { schemas[title] = json; return { $ref: `#/components/schemas/${title}` }; } return json; }; for (const route of actions) { if (!route.options.schema) continue; const response = this.getResponseSchema(route); if (!response) continue; if (excludeTags.includes(route.group)) continue; if (route.options.hide) continue; const operation = { operationId: route.name, summary: route.options.summary, description: route.options.description, deprecated: route.options.deprecated || void 0, tags: [route.group.replaceAll(":", " / ")], responses: { [response.status]: { description: this.getStatusDescription(response.status), content: response.type ? { [response.type]: { schema: schema(response.schema) } } : void 0 } } }; if (route.middlewares.some((m) => m?.name === "$secure")) { operation.security = [{ bearerAuth: [] }]; operation.responses["401"] = { description: "Unauthorized" }; hasSecurity = true; } if (z.schema.isObject(route.options.schema.body) || z.schema.isArray(route.options.schema.body)) if (z.schema.isObject(route.options.schema.body) && this.isBodyMultipart(route.options.schema.body)) operation.requestBody = { required: true, content: { "multipart/form-data": { schema: schema(route.options.schema.body) } } }; else operation.requestBody = { required: true, content: { "application/json": { schema: schema(route.options.schema.body) } } }; if (z.schema.isObject(route.options.schema.query)) { operation.parameters ??= []; const requiredKeys = z.schema.requiredKeys(route.options.schema.query); for (const [key, value] of Object.entries(route.options.schema.query.properties)) { const param = { name: key, in: "query", required: requiredKeys.includes(key), schema: schema(value) }; const example = this.extractExample(value); if (example !== void 0) param.example = example; operation.parameters.push(param); } } if (z.schema.isObject(route.options.schema.params)) { operation.parameters ??= []; for (const [key, value] of Object.entries(route.options.schema.params.properties)) { const valueMeta = typeof value?.meta === "function" ? value.meta() ?? {} : {}; const description = typeof valueMeta.description === "string" ? valueMeta.description : void 0; const ref = schema(value); ref.description = void 0; const param = { name: key, in: "path", required: true, description, schema: ref }; const example = this.extractExample(value); if (example !== void 0) param.example = example; operation.parameters.push(param); } } if (operation.requestBody || operation.parameters?.length) operation.responses["400"] = { description: "Bad Request" }; const url = route.prefix + this.replacePathParams(route.path); openApi.paths[url] = { ...openApi.paths[url], [route.method.toLowerCase()]: operation }; } if (hasSecurity && openApi.components) openApi.components.securitySchemes = { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", description: "Enter a JWT token or API key. Both are accepted as Bearer tokens." } }; if (openApi.components) openApi.components.schemas = schemas; return JSON.parse(JSON.stringify(openApi)); } isBodyMultipart(schema) { for (const key in schema.properties) if (isTypeFile(schema.properties[key])) return true; return false; } replacePathParams(url) { return url.replace(/:\w+/g, (match) => { return `{${match.slice(1)}}`; }); } getResponseSchema(route) { const schema = route.options.schema?.response; if (!schema) return { status: 204 }; if (z.schema.isObject(schema) || z.schema.isArray(schema)) return { schema, status: 200, type: "application/json" }; if (z.schema.isString(schema)) return { schema, status: 200, type: "text/plain" }; if (z.schema.isNumber(schema) || z.schema.isInteger(schema) || z.schema.isBoolean(schema)) return { schema, status: 200, type: "application/json" }; if (isTypeFile(schema)) return { schema, status: 200, type: "application/octet-stream" }; const status = Object.keys(schema)[0]; if (status && !Number.isNaN(Number(status))) { const inner = schema[status]; return { schema: inner, status: Number(status), type: this.getContentType(inner) }; } } extractExample(schema) { const meta = typeof schema?.meta === "function" ? schema.meta() ?? {} : {}; if (Array.isArray(meta.examples) && meta.examples.length > 0) return meta.examples[0]; if (meta.example !== void 0) return meta.example; const def = z.schema.getDefault(schema); if (def !== void 0) return def; } getStatusDescription(status) { switch (status) { case 200: return "OK"; case 201: return "Created"; case 204: return "No Content"; case 400: return "Bad Request"; case 401: return "Unauthorized"; case 403: return "Forbidden"; case 404: return "Not Found"; case 500: return "Internal Server Error"; default: return ""; } } getContentType(schema) { if (!schema) return void 0; if (z.schema.isObject(schema) || z.schema.isArray(schema)) return "application/json"; if (z.schema.isString(schema)) return "text/plain"; if (z.schema.isNumber(schema) || z.schema.isInteger(schema) || z.schema.isBoolean(schema)) return "application/json"; if (isTypeFile(schema)) return "application/octet-stream"; return "application/json"; } configureSwaggerApi(prefix, json) { this.serverRouterProvider.createRoute({ method: "GET", path: `${prefix}/json`, schema: { response: z.json() }, handler: () => json }); } async configureSwaggerUi(prefix, options) { const ui = typeof options.ui === "object" ? options.ui : {}; const initializer = ` window.onload = function() { window.ui = SwaggerUIBundle({ url: "${prefix}/json", dom_id: '#swagger-ui', deepLinking: true, persistAuthorization: ${ui.persistAuthorization !== false}, presets: [ SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset ], plugins: [ SwaggerUIBundle.plugins.DownloadUrl ], layout: "BaseLayout" }); document.body.style.backgroundColor = "#f2f2f2"; const options = ${JSON.stringify(ui)}; if (options.initOAuth) { ui.initOAuth(options.initOAuth); } }; `.trim(); if (!this.alepha.isServerless()) { const dirname = fileURLToPath(import.meta.url); const root = await this.getAssetPath(ui.root, join(dirname, "../../assets/swagger-ui"), join(dirname, "../../../assets/swagger-ui"), join(dirname, "../../../../assets/swagger-ui"), join(dirname, "../../../../../assets/swagger-ui")); if (!root) { this.log.warn(`Failed to locate Swagger UI assets for path ${prefix}`); return; } await this.serverStaticProvider.createStaticServer({ path: prefix, root }); } this.serverRouterProvider.createRoute({ method: "GET", path: `${prefix}/swagger-initializer.js`, handler: ({ reply }) => { reply.headers["content-type"] = "application/javascript; charset=utf-8"; return initializer; } }); this.log.info("SwaggerUI OK", { url: `${this.serverProvider.hostname}${prefix}` }); } async getAssetPath(...paths) { for (const path of paths) { if (!path) continue; if (await this.fs.exists(path)) return path; } } removePrivateFields(obj, excludeList) { if (obj === null || typeof obj !== "object") return obj; const visited = /* @__PURE__ */ new WeakSet(); const traverse = (o) => { if (visited.has(o)) return; visited.add(o); if (Array.isArray(o)) for (let i = 0; i < o.length; i++) { const item = o[i]; if (item !== null && typeof item === "object") traverse(item); } else { for (const excludeKey of excludeList) if (excludeKey in o) delete o[excludeKey]; for (const key in o) { const item = o[key]; if (item !== null && typeof item === "object") traverse(item); } } }; traverse(obj); return obj; } }; //#endregion //#region ../../src/server/swagger/index.ts /** * Automatic API documentation generation. * * **Features:** * - Swagger/OpenAPI configuration * - Routes: `GET /swagger/ui`, `GET /swagger.json` * * @module alepha.server.swagger */ const AlephaServerSwagger = $module({ name: "alepha.server.swagger", primitives: [$swagger], services: [ServerSwaggerProvider], register: (alepha) => { alepha.with(AlephaServer); alepha.with(AlephaServerEtag); alepha.with(AlephaServerStatic); alepha.with(ServerSwaggerProvider); alepha.store.push("alepha.build.assets", "alepha"); } }); //#endregion export { $swagger, AlephaServerSwagger, ServerSwaggerProvider, SwaggerPrimitive, swaggerOptions }; //# sourceMappingURL=index.js.map