alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
590 lines (523 loc) • 15.3 kB
text/typescript
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
$atom,
$hook,
$inject,
$state,
Alepha,
isTypeFile,
type Static,
type TObject,
type TSchema,
z,
} from "alepha";
import { $logger } from "alepha/logger";
import {
$action,
type ActionPrimitive,
type RequestConfigSchema,
ServerProvider,
ServerRouterProvider,
} from "alepha/server";
import { ServerStaticProvider } from "alepha/server/static";
import { FileSystemProvider } from "alepha/system";
import {
$swagger,
type OpenApiDocument,
type OpenApiOperation,
type SwaggerPrimitiveOptions,
} from "../primitives/$swagger.ts";
// ---------------------------------------------------------------------------------------------------------------------
/**
* Swagger provider configuration atom
*/
export 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: [],
},
});
export type ServerSwaggerProviderOptions = Static<typeof swaggerOptions.schema>;
declare module "alepha" {
interface State {
[swaggerOptions.key]: ServerSwaggerProviderOptions;
}
}
// ---------------------------------------------------------------------------------------------------------------------
export class ServerSwaggerProvider {
protected readonly serverStaticProvider = $inject(ServerStaticProvider);
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
protected readonly serverProvider = $inject(ServerProvider);
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly options = $state(swaggerOptions);
protected readonly fs = $inject(FileSystemProvider);
public json?: OpenApiDocument;
protected readonly configure = $hook({
on: "configure",
priority: "last", // wait for all configurations, sometimes some actions are registered late!
handler: async (alepha) => {
const options = alepha.primitives($swagger)?.[0]?.options;
if (!options) {
return;
}
this.json = await this.setupSwaggerPlugin(options);
},
});
public generateSwaggerDoc(options: SwaggerPrimitiveOptions): OpenApiDocument {
const json = this.configureOpenApi(
this.alepha.primitives($action),
options,
);
if (options.rewrite) {
options.rewrite(json);
}
return json;
}
protected async setupSwaggerPlugin(
options: SwaggerPrimitiveOptions,
): Promise<OpenApiDocument | undefined> {
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;
}
protected configureOpenApi(
actions: ActionPrimitive<RequestConfigSchema>[],
doc: SwaggerPrimitiveOptions,
): OpenApiDocument {
const openApi: OpenApiDocument = {
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: Record<string, any> = {};
const toJson = (source: TSchema): any => {
let json: any;
try {
json = z.toJSONSchema(source as any);
} catch {
json = {};
}
// zod stamps a `$schema` dialect URL we don't want in OpenAPI fragments.
json.$schema = undefined;
this.removePrivateFields(json, [
...(this.options.excludeKeys || []),
"~options",
]);
return json;
};
const schema = (source: TSchema): any => {
const json = toJson(source);
// A titled schema is hoisted into `components/schemas` and referenced.
const title =
(typeof (source as any)?.meta === "function"
? (source as any).meta()?.title
: undefined) ?? 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: OpenApiOperation = {
operationId: route.name,
summary: route.options.summary,
description: route.options.description,
deprecated: route.options.deprecated || undefined,
tags: [route.group.replaceAll(":", " / ")],
responses: {
[response.status]: {
description: this.getStatusDescription(response.status),
content: response.type
? {
[response.type]: {
schema: schema(response.schema),
},
}
: undefined,
},
},
};
const isSecured = route.middlewares.some((m) => m?.name === "$secure");
if (isSecured) {
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: string[] = z.schema.requiredKeys(
route.options.schema.query,
);
for (const [key, value] of Object.entries(
route.options.schema.query.properties,
)) {
const param: any = {
name: key,
in: "query",
required: requiredKeys.includes(key),
schema: schema(value),
};
const example = this.extractExample(value);
if (example !== undefined) 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 as any)?.meta === "function"
? ((value as any).meta() ?? {})
: {};
const description =
typeof valueMeta.description === "string"
? valueMeta.description
: undefined;
const ref = schema(value);
ref.description = undefined;
const param: any = {
name: key,
in: "path",
required: true,
description,
schema: ref,
};
const example = this.extractExample(value);
if (example !== undefined) param.example = example;
operation.parameters.push(param);
}
}
const hasValidation =
operation.requestBody || operation.parameters?.length;
if (hasValidation) {
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));
}
public isBodyMultipart(schema: TObject): boolean {
for (const key in schema.properties) {
if (isTypeFile(schema.properties[key])) {
return true;
}
}
return false;
}
public replacePathParams(url: string): string {
return url.replace(/:\w+/g, (match) => {
const paramName = match.slice(1);
return `{${paramName}}`;
});
}
public getResponseSchema(route: ActionPrimitive<RequestConfigSchema>):
| {
type?: string;
schema?: any;
status: number;
}
| undefined {
const schema: any = 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",
};
}
// Status-code-keyed map: e.g. { 201: z.object({...}) }
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),
};
}
}
protected extractExample(schema: any): any {
const meta =
typeof schema?.meta === "function" ? (schema.meta() ?? {}) : {};
if (Array.isArray(meta.examples) && meta.examples.length > 0) {
return meta.examples[0];
}
if (meta.example !== undefined) {
return meta.example;
}
const def = z.schema.getDefault(schema);
if (def !== undefined) {
return def;
}
return undefined;
}
protected getStatusDescription(status: number): string {
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 "";
}
}
protected getContentType(schema: any): string | undefined {
if (!schema) return undefined;
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";
}
protected configureSwaggerApi(prefix: string, json: OpenApiDocument): void {
this.serverRouterProvider.createRoute({
method: "GET",
path: `${prefix}/json`,
schema: {
response: z.json(),
},
handler: () => json,
});
}
protected async configureSwaggerUi(
prefix: string,
options: SwaggerPrimitiveOptions,
): Promise<void> {
const ui = typeof options.ui === "object" ? options.ui : {};
const persistAuth = ui.persistAuthorization !== false;
const initializer = `
window.onload = function() {
window.ui = SwaggerUIBundle({
url: "${prefix}/json",
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: ${persistAuth},
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,
// TODO: this is shitty, take time to get the correct path
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}`,
});
}
protected async getAssetPath(
...paths: (string | undefined)[]
): Promise<string | undefined> {
for (const path of paths) {
if (!path) continue;
const exists = await this.fs.exists(path);
if (exists) {
return path;
}
}
}
public removePrivateFields<T extends Record<string, any>>(
obj: T,
excludeList: string[],
): T {
if (obj === null || typeof obj !== "object") return obj;
const visited = new WeakSet();
const traverse = (o: any): void => {
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;
}
}