import { readFile } from 'fs/promises'; import { toTitle } from '@silverhand/essentials'; import { load } from 'js-yaml'; import type { IMiddleware } from 'koa-router'; import type Router from 'koa-router'; import type { OpenAPIV3 } from 'openapi-types'; import { ZodObject, ZodOptional } from 'zod'; import type { WithGuardConfig } from '@/middleware/koa-guard'; import { isGuardMiddleware } from '@/middleware/koa-guard'; import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination'; import assertThat from '@/utils/assert-that'; import { translationSchemas, zodTypeToSwagger } from '@/utils/zod'; import type { AnonymousRouter } from './types'; type RouteObject = { path: string; method: OpenAPIV3.HttpMethods; operation: OpenAPIV3.OperationObject; }; type MethodMap = { [key in OpenAPIV3.HttpMethods]?: OpenAPIV3.OperationObject; }; export const paginationParameters: OpenAPIV3.ParameterObject[] = [ { name: 'page', in: 'query', required: false, schema: { type: 'integer', minimum: 1, default: 1, }, }, { name: 'page_size', in: 'query', required: false, schema: { type: 'integer', minimum: 1, default: fallbackDefaultPageSize, }, }, ]; // Parameter serialization: https://swagger.io/docs/specification/serialization const buildParameters = ( zodParameters: unknown, inWhere: 'path' | 'query' ): OpenAPIV3.ParameterObject[] => { if (!zodParameters) { return []; } assertThat(zodParameters instanceof ZodObject, 'swagger.not_supported_zod_type_for_params'); return Object.entries(zodParameters.shape).map(([key, value]) => ({ name: key, in: inWhere, required: !(value instanceof ZodOptional), schema: zodTypeToSwagger(value), })); }; const buildTag = (path: string) => { const root = path.split('/')[1]; if (root?.startsWith('.')) { return root; } return toTitle(root ?? 'General'); }; export const defaultResponses: OpenAPIV3.ResponsesObject = { '200': { description: 'OK', }, }; const buildOperation = ( stack: IMiddleware[], path: string, customResponses?: OpenAPIV3.ResponsesObject ): OpenAPIV3.OperationObject => { const guard = stack.find((function_): function_ is WithGuardConfig => isGuardMiddleware(function_) ); const pathParameters = buildParameters(guard?.config.params, 'path'); const hasPagination = stack.some((function_) => isPaginationMiddleware(function_)); const queryParameters = [ ...buildParameters(guard?.config.query, 'query'), ...(hasPagination ? paginationParameters : []), ]; const body = guard?.config.body; const requestBody = body && { required: true, content: { 'application/json': { schema: zodTypeToSwagger(body), }, }, }; return { tags: [buildTag(path)], parameters: [...pathParameters, ...queryParameters], requestBody, responses: customResponses ?? defaultResponses, }; }; // Keep using `any` to accept various custom context types. // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function swaggerRoutes>( router: T, allRouters: R[] ) { router.get('/swagger.json', async (ctx, next) => { // Use `as` here since we'll check typing with integration tests // eslint-disable-next-line no-restricted-syntax const additionalSwagger = load( await readFile('static/yaml/additional-swagger.yaml', { encoding: 'utf8' }) ) as OpenAPIV3.Document; const routes = allRouters.flatMap((router) => router.stack.flatMap(({ path: routerPath, stack, methods }) => methods .map((method) => method.toLowerCase()) // There is no need to show the HEAD method. .filter((method): method is OpenAPIV3.HttpMethods => method !== 'head') .map((httpMethod) => { const path = `/api${routerPath}`; const additionalPathItem = additionalSwagger.paths[path] ?? {}; const additionalResponses = additionalPathItem[httpMethod]?.responses; return { path, method: httpMethod, operation: buildOperation(stack, routerPath, additionalResponses), }; }) ) ); const pathMap = new Map(); // Group routes by path for (const { path, method, operation } of routes) { pathMap.set(path, { ...pathMap.get(path), [method]: operation }); } const document: OpenAPIV3.Document = { openapi: '3.0.1', info: { title: 'Logto Core', version: '0.1.0', }, paths: Object.fromEntries(pathMap), components: { schemas: translationSchemas }, }; ctx.body = document; return next(); }); }