alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
377 lines (373 loc) • 12.4 kB
JavaScript
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