From 8ac3f83a7b3fc6c0b993f25a4005264dbdac0e75 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Tue, 27 Aug 2024 20:47:59 +0900 Subject: [PATCH] Runtime swagger document composer. --- package.json | 2 +- packages/cli/src/internal/ArgumentParser.ts | 9 ++ .../cli/src/internal/PluginConfigurator.ts | 15 ++ packages/core/package.json | 6 +- packages/fetcher/package.json | 2 +- packages/sdk/package.json | 10 +- packages/sdk/src/NestiaSwaggerComposer.ts | 138 ++++++++++++++++++ packages/sdk/src/analyses/ConfigAnalyzer.ts | 6 +- .../src/analyses/ReflectControllerAnalyzer.ts | 2 +- .../analyses/ReflectHttpOperationAnalyzer.ts | 2 +- .../ReflectWebSocketOperationAnalyzer.ts | 2 +- .../sdk/src/generates/SwaggerGenerator.ts | 6 +- .../internal/SwaggerOperationComposer.ts | 2 +- .../SwaggerOperationParameterComposer.ts | 2 +- packages/sdk/src/module.ts | 1 + packages/sdk/src/utils/VersioningStrategy.ts | 2 +- test/features/app/src/Backend.ts | 7 +- test/features/app/src/executable/server.ts | 6 + .../src/test/features/test_runtime_swagger.ts | 8 + test/features/app/tsconfig.json | 1 + test/package.json | 8 +- 21 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 packages/sdk/src/NestiaSwaggerComposer.ts create mode 100644 test/features/app/src/executable/server.ts create mode 100644 test/features/app/src/test/features/test_runtime_swagger.ts diff --git a/package.json b/package.json index 3e5708d4f..d8f37062f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@nestia/station", - "version": "3.11.3", + "version": "3.12.0-dev.20240827", "description": "Nestia station", "scripts": { "build": "node build/index.js", diff --git a/packages/cli/src/internal/ArgumentParser.ts b/packages/cli/src/internal/ArgumentParser.ts index f578877da..5415f2fa6 100644 --- a/packages/cli/src/internal/ArgumentParser.ts +++ b/packages/cli/src/internal/ArgumentParser.ts @@ -8,6 +8,7 @@ export namespace ArgumentParser { export interface IArguments { manager: "npm" | "pnpm" | "yarn"; project: string | null; + swagger: boolean; } export async function parse(pack: PackageManager): Promise { @@ -17,6 +18,10 @@ export namespace ArgumentParser { "--project [project]", "tsconfig.json file location", ); + commander.program.option( + "--swagger [boolean]", + "transform runtime swagger", + ); // INTERNAL PROCEDURES const questioned = { value: false }; @@ -90,6 +95,10 @@ export namespace ArgumentParser { ); pack.manager = options.manager; options.project ??= await configure(); + options.swagger = options.swagger + ? (options.swagger as any) === "true" + : (await select("swagger")("Transform Runtime Swagger")(["Y", "N"])) === + "Y"; if (questioned.value) console.log(""); return options as IArguments; diff --git a/packages/cli/src/internal/PluginConfigurator.ts b/packages/cli/src/internal/PluginConfigurator.ts index 7913a5caf..31a4a96b7 100644 --- a/packages/cli/src/internal/PluginConfigurator.ts +++ b/packages/cli/src/internal/PluginConfigurator.ts @@ -49,6 +49,15 @@ export namespace PluginConfigurator { p !== null && p.transform === "typia/lib/transform", ); + const swagger: boolean = + args.swagger === false + ? true + : !!plugins.find( + (p) => + typeof p === "object" && + p !== null && + p.transform === "@nestia/sdk/lib/transform", + ); if ( strictNullChecks !== false && (strict === true || strictNullChecks === true) && @@ -92,6 +101,12 @@ export namespace PluginConfigurator { "stringify": "assert" }`) as comments.CommentObject, ); + if (swagger === false) + plugins.push( + comments.parse( + `{ "transform": "@nestia/sdk/lib/transform" }`, + ) as comments.CommentObject, + ); if (typia === undefined) plugins.push( comments.parse( diff --git a/packages/core/package.json b/packages/core/package.json index 6b87d25db..957850fd3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/core", - "version": "3.11.3", + "version": "3.12.0-dev.20240827", "description": "Super-fast validation decorators of NestJS", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -36,7 +36,7 @@ }, "homepage": "https://nestia.io", "dependencies": { - "@nestia/fetcher": "^3.11.3", + "@nestia/fetcher": "^3.12.0-dev.20240827", "@nestjs/common": ">=7.0.1", "@nestjs/core": ">=7.0.1", "@samchon/openapi": "^0.4.6", @@ -53,7 +53,7 @@ "ws": "^7.5.3" }, "peerDependencies": { - "@nestia/fetcher": ">=3.11.3", + "@nestia/fetcher": ">=3.12.0-dev.20240827", "@nestjs/common": ">=7.0.1", "@nestjs/core": ">=7.0.1", "reflect-metadata": ">=0.1.12", diff --git a/packages/fetcher/package.json b/packages/fetcher/package.json index b2ad3eae5..dcdbcdb63 100644 --- a/packages/fetcher/package.json +++ b/packages/fetcher/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/fetcher", - "version": "3.11.3", + "version": "3.12.0-dev.20240827", "description": "Fetcher library of Nestia SDK", "main": "lib/index.js", "typings": "lib/index.d.ts", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a3b03e404..111cd90a1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/sdk", - "version": "3.11.3", + "version": "3.12.0-dev.20240827", "description": "Nestia SDK and Swagger generator", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -32,8 +32,8 @@ }, "homepage": "https://nestia.io", "dependencies": { - "@nestia/core": "^3.11.3", - "@nestia/fetcher": "^3.11.3", + "@nestia/core": "^3.12.0-dev.20240827", + "@nestia/fetcher": "^3.12.0-dev.20240827", "@samchon/openapi": "^0.4.6", "@wrtnio/openai-function-schema": "^0.2.3", "cli": "^1.0.1", @@ -48,8 +48,8 @@ "typia": "^6.9.0" }, "peerDependencies": { - "@nestia/core": ">=3.11.3", - "@nestia/fetcher": ">=3.11.3", + "@nestia/core": ">=3.12.0-dev.20240827", + "@nestia/fetcher": ">=3.12.0-dev.20240827", "@nestjs/common": ">=7.0.1", "@nestjs/core": ">=7.0.1", "reflect-metadata": ">=0.1.12", diff --git a/packages/sdk/src/NestiaSwaggerComposer.ts b/packages/sdk/src/NestiaSwaggerComposer.ts new file mode 100644 index 000000000..7d6ea3371 --- /dev/null +++ b/packages/sdk/src/NestiaSwaggerComposer.ts @@ -0,0 +1,138 @@ +import { INestApplication } from "@nestjs/common"; +import { OpenApi, OpenApiV3, SwaggerV2 } from "@samchon/openapi"; +import path from "path"; +import { TreeMap } from "tstl"; +import { IMetadataDictionary } from "typia/lib/schemas/metadata/IMetadataDictionary"; + +import { INestiaConfig } from "./INestiaConfig"; +import { AccessorAnalyzer } from "./analyses/AccessorAnalyzer"; +import { ConfigAnalyzer } from "./analyses/ConfigAnalyzer"; +import { PathAnalyzer } from "./analyses/PathAnalyzer"; +import { ReflectControllerAnalyzer } from "./analyses/ReflectControllerAnalyzer"; +import { TypedHttpRouteAnalyzer } from "./analyses/TypedHttpRouteAnalyzer"; +import { SwaggerGenerator } from "./generates/SwaggerGenerator"; +import { INestiaProject } from "./structures/INestiaProject"; +import { INestiaSdkInput } from "./structures/INestiaSdkInput"; +import { IReflectController } from "./structures/IReflectController"; +import { IReflectOperationError } from "./structures/IReflectOperationError"; +import { ITypedHttpRoute } from "./structures/ITypedHttpRoute"; +import { IOperationMetadata } from "./transformers/IOperationMetadata"; +import { VersioningStrategy } from "./utils/VersioningStrategy"; + +export namespace NestiaSwaggerComposer { + export const document = async ( + app: INestApplication, + config: Omit, + ): Promise => { + const input: INestiaSdkInput = await ConfigAnalyzer.application(app); + const document: OpenApi.IDocument = await SwaggerGenerator.compose({ + config, + routes: analyze(input), + document: await SwaggerGenerator.initialize(config), + }); + return config.openapi === "2.0" + ? OpenApi.downgrade(document, "2.0") + : config.openapi === "3.0" + ? OpenApi.downgrade(document, "3.0") + : document; + }; + + const analyze = (input: INestiaSdkInput): ITypedHttpRoute[] => { + // GET REFLECT CONTROLLERS + const unique: WeakSet = new WeakSet(); + const project: Omit = { + input, + checker: null!, + errors: [], + warnings: [], + }; + const controllers: IReflectController[] = project.input.controllers + .map((c) => + ReflectControllerAnalyzer.analyze({ project, controller: c, unique }), + ) + .filter((c): c is IReflectController => c !== null); + if (project.errors.length) + throw report({ type: "error", errors: project.errors }); + + // METADATA COMPONENTS + const collection: IMetadataDictionary = + TypedHttpRouteAnalyzer.dictionary(controllers); + + // CONVERT TO TYPED OPERATIONS + const globalPrefix: string = project.input.globalPrefix?.prefix ?? ""; + const routes: ITypedHttpRoute[] = []; + for (const c of controllers) + for (const o of c.operations) { + const pathList: Set = new Set(); + const versions: string[] = VersioningStrategy.merge(project)([ + ...(c.versions ?? []), + ...(o.versions ?? []), + ]); + for (const v of versions) + for (const prefix of wrapPaths(c.prefixes)) + for (const cPath of wrapPaths(c.paths)) + for (const filePath of wrapPaths(o.paths)) + pathList.add( + PathAnalyzer.join(globalPrefix, v, prefix, cPath, filePath), + ); + if (o.protocol === "http") + routes.push( + ...TypedHttpRouteAnalyzer.analyze({ + controller: c, + errors: project.errors, + dictionary: collection, + operation: o, + paths: Array.from(pathList), + }), + ); + } + AccessorAnalyzer.analyze(routes); + return routes; + }; +} + +const report = (props: { + type: "error" | "warning"; + errors: IReflectOperationError[]; +}): void => { + const map: TreeMap< + IReflectOperationError.Key, + Array + > = new TreeMap(); + for (const e of props.errors) + map.take(new IReflectOperationError.Key(e), () => []).push(...e.contents); + + const messages: string[] = []; + for (const { + first: { error }, + second: contents, + } of map) { + if (error.contents.length === 0) continue; + const location: string = path.relative(process.cwd(), error.file); + messages.push( + [ + `${location} - `, + error.class, + ...(error.function !== null ? [`.${error.function}()`] : [""]), + ...(error.from !== null ? [` from ${error.from}`] : [""]), + ":\n", + contents + .map((c) => { + if (typeof c === "string") return ` - ${c}`; + else + return [ + c.accessor + ? ` - ${c.name}: ` + : ` - ${c.name} (${c.accessor}): `, + ...c.messages.map((msg) => ` - ${msg}`), + ].join("\n"); + }) + .join("\n"), + ].join(""), + ); + } + throw new Error(`Error on NestiaSwaggerComposer.compose():\n${messages}`); +}; + +const wrapPaths = (paths: string[]): string[] => + paths.length === 0 ? [""] : paths; diff --git a/packages/sdk/src/analyses/ConfigAnalyzer.ts b/packages/sdk/src/analyses/ConfigAnalyzer.ts index d9599d5d2..892962dd7 100644 --- a/packages/sdk/src/analyses/ConfigAnalyzer.ts +++ b/packages/sdk/src/analyses/ConfigAnalyzer.ts @@ -21,7 +21,7 @@ export namespace ConfigAnalyzer { ): Promise => { return MapUtil.take(memory, config, async () => { if (typeof config.input === "function") - return analyze_application(await config.input()); + return application(await config.input()); const sources: string[] = await SourceFinder.find({ include: Array.isArray(config.input) @@ -31,7 +31,7 @@ export namespace ConfigAnalyzer { : [config.input], exclude: typeof config.input === "object" && !Array.isArray(config.input) - ? config.input.exclude ?? [] + ? (config.input.exclude ?? []) : [], filter: filter(config), }); @@ -54,7 +54,7 @@ export namespace ConfigAnalyzer { }); }; - const analyze_application = async ( + export const application = async ( app: INestApplication, ): Promise => { const container: NestContainer = (app as any).container as NestContainer; diff --git a/packages/sdk/src/analyses/ReflectControllerAnalyzer.ts b/packages/sdk/src/analyses/ReflectControllerAnalyzer.ts index e069da340..87ba992ef 100644 --- a/packages/sdk/src/analyses/ReflectControllerAnalyzer.ts +++ b/packages/sdk/src/analyses/ReflectControllerAnalyzer.ts @@ -17,7 +17,7 @@ import { ReflectWebSocketOperationAnalyzer } from "./ReflectWebSocketOperationAn export namespace ReflectControllerAnalyzer { export interface IProps { - project: INestiaProject; + project: Omit; controller: INestiaSdkInput.IController; unique: WeakSet; } diff --git a/packages/sdk/src/analyses/ReflectHttpOperationAnalyzer.ts b/packages/sdk/src/analyses/ReflectHttpOperationAnalyzer.ts index 10c53b0df..3504b21fa 100644 --- a/packages/sdk/src/analyses/ReflectHttpOperationAnalyzer.ts +++ b/packages/sdk/src/analyses/ReflectHttpOperationAnalyzer.ts @@ -18,7 +18,7 @@ import { ReflectMetadataAnalyzer } from "./ReflectMetadataAnalyzer"; export namespace ReflectHttpOperationAnalyzer { export interface IProps { - project: INestiaProject; + project: Omit; controller: IReflectController; function: Function; name: string; diff --git a/packages/sdk/src/analyses/ReflectWebSocketOperationAnalyzer.ts b/packages/sdk/src/analyses/ReflectWebSocketOperationAnalyzer.ts index e68577468..8920503cd 100644 --- a/packages/sdk/src/analyses/ReflectWebSocketOperationAnalyzer.ts +++ b/packages/sdk/src/analyses/ReflectWebSocketOperationAnalyzer.ts @@ -13,7 +13,7 @@ import { ReflectMetadataAnalyzer } from "./ReflectMetadataAnalyzer"; export namespace ReflectWebSocketOperationAnalyzer { export interface IProps { - project: INestiaProject; + project: Omit; controller: IReflectController; function: Function; name: string; diff --git a/packages/sdk/src/generates/SwaggerGenerator.ts b/packages/sdk/src/generates/SwaggerGenerator.ts index 772410fd8..fe525a820 100644 --- a/packages/sdk/src/generates/SwaggerGenerator.ts +++ b/packages/sdk/src/generates/SwaggerGenerator.ts @@ -66,7 +66,7 @@ export namespace SwaggerGenerator { }; export const compose = (props: { - config: INestiaConfig.ISwaggerConfig; + config: Omit; routes: ITypedHttpRoute[]; document: OpenApi.IDocument; }): OpenApi.IDocument => { @@ -100,7 +100,7 @@ export namespace SwaggerGenerator { }; export const initialize = async ( - config: INestiaConfig.ISwaggerConfig, + config: Omit, ): Promise => { const pack = new Singleton( async (): Promise | null> => { @@ -178,7 +178,7 @@ export namespace SwaggerGenerator { }; const fillPaths = (props: { - config: INestiaConfig.ISwaggerConfig; + config: Omit; document: OpenApi.IDocument; schema: (metadata: Metadata) => OpenApi.IJsonSchema | undefined; routes: ITypedHttpRoute[]; diff --git a/packages/sdk/src/generates/internal/SwaggerOperationComposer.ts b/packages/sdk/src/generates/internal/SwaggerOperationComposer.ts index 18a43ed58..be06a992f 100644 --- a/packages/sdk/src/generates/internal/SwaggerOperationComposer.ts +++ b/packages/sdk/src/generates/internal/SwaggerOperationComposer.ts @@ -11,7 +11,7 @@ import { SwaggerOperationResponseComposer } from "./SwaggerOperationResponseComp export namespace SwaggerOperationComposer { export const compose = (props: { - config: INestiaConfig.ISwaggerConfig; + config: Omit; document: OpenApi.IDocument; schema: (metadata: Metadata) => OpenApi.IJsonSchema | undefined; route: ITypedHttpRoute; diff --git a/packages/sdk/src/generates/internal/SwaggerOperationParameterComposer.ts b/packages/sdk/src/generates/internal/SwaggerOperationParameterComposer.ts index 93a85b9e2..a3afeb043 100644 --- a/packages/sdk/src/generates/internal/SwaggerOperationParameterComposer.ts +++ b/packages/sdk/src/generates/internal/SwaggerOperationParameterComposer.ts @@ -9,7 +9,7 @@ import { SwaggerDescriptionComposer } from "./SwaggerDescriptionComposer"; export namespace SwaggerOperationParameterComposer { export interface IProps { - config: INestiaConfig.ISwaggerConfig; + config: Omit; document: OpenApi.IDocument; schema: OpenApi.IJsonSchema; jsDocTags: IJsDocTagInfo[]; diff --git a/packages/sdk/src/module.ts b/packages/sdk/src/module.ts index 546feb81a..5dca6c117 100644 --- a/packages/sdk/src/module.ts +++ b/packages/sdk/src/module.ts @@ -1,2 +1,3 @@ export * from "./INestiaConfig"; export * from "./NestiaSdkApplication"; +export * from "./NestiaSwaggerComposer"; diff --git a/packages/sdk/src/utils/VersioningStrategy.ts b/packages/sdk/src/utils/VersioningStrategy.ts index febd9dc72..4aa4865d3 100644 --- a/packages/sdk/src/utils/VersioningStrategy.ts +++ b/packages/sdk/src/utils/VersioningStrategy.ts @@ -9,7 +9,7 @@ export namespace VersioningStrategy { value === undefined ? [] : Array.isArray(value) ? value : [value]; export const merge = - (project: INestiaProject) => + (project: Omit) => (values: Array): string[] => { if (project.input.versioning === undefined) return [""]; const set: Set = new Set(values); diff --git a/test/features/app/src/Backend.ts b/test/features/app/src/Backend.ts index 3824d52dc..d84c07868 100644 --- a/test/features/app/src/Backend.ts +++ b/test/features/app/src/Backend.ts @@ -1,5 +1,7 @@ +import { NestiaSwaggerComposer } from "@nestia/sdk"; import { INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; +import { SwaggerModule } from "@nestjs/swagger"; import { Singleton } from "tstl"; import { ApplicationModule } from "./modules/ApplicationModule"; @@ -11,7 +13,10 @@ export class Backend { ); public async open(): Promise { - return (await this.application.get()).listen(37_000); + const app: INestApplication = await this.application.get(); + const document = await NestiaSwaggerComposer.document(app, {}); + SwaggerModule.setup("api", app, document as any); + await app.listen(37_000); } public async close(): Promise { diff --git a/test/features/app/src/executable/server.ts b/test/features/app/src/executable/server.ts new file mode 100644 index 000000000..3f0c68fc9 --- /dev/null +++ b/test/features/app/src/executable/server.ts @@ -0,0 +1,6 @@ +import { Backend } from "../Backend"; + +const bootstrap = async () => { + await new Backend().open(); +}; +bootstrap(); diff --git a/test/features/app/src/test/features/test_runtime_swagger.ts b/test/features/app/src/test/features/test_runtime_swagger.ts new file mode 100644 index 000000000..95b8b6b8c --- /dev/null +++ b/test/features/app/src/test/features/test_runtime_swagger.ts @@ -0,0 +1,8 @@ +import { OpenApi } from "@samchon/openapi"; +import typia from "typia"; + +export const test_runtime_swagger = async (): Promise => { + const response: Response = await fetch("http://127.0.0.1:37000/api-json"); + const document: OpenApi.IDocument = await response.json(); + typia.assert(document); +}; diff --git a/test/features/app/tsconfig.json b/test/features/app/tsconfig.json index 3c815da8b..29708de14 100644 --- a/test/features/app/tsconfig.json +++ b/test/features/app/tsconfig.json @@ -93,6 +93,7 @@ { "transform": "typescript-transform-paths" }, { "transform": "typia/lib/transform" }, { "transform": "@nestia/core/lib/transform" }, + { "transform": "@nestia/sdk/lib/transform" }, ], } } \ No newline at end of file diff --git a/test/package.json b/test/package.json index eb624e6b5..be3edc44c 100644 --- a/test/package.json +++ b/test/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@samchon/nestia-test", - "version": "3.11.3", + "version": "3.12.0-dev.20240827", "description": "Test program of Nestia", "main": "index.js", "scripts": { @@ -26,7 +26,7 @@ }, "homepage": "https://nestia.io", "devDependencies": { - "@nestia/sdk": "^3.11.3", + "@nestia/sdk": "^3.12.0-dev.20240827", "@nestjs/swagger": "^7.1.2", "@samchon/openapi": "^0.4.6", "@types/express": "^4.17.17", @@ -40,9 +40,9 @@ }, "dependencies": { "@fastify/multipart": "^8.1.0", - "@nestia/core": "^3.11.3", + "@nestia/core": "^3.12.0-dev.20240827", "@nestia/e2e": "^0.7.0", - "@nestia/fetcher": "^3.11.3", + "@nestia/fetcher": "^3.12.0-dev.20240827", "@nestjs/common": "^10.3.5", "@nestjs/core": "^10.3.5", "@nestjs/platform-express": "^10.3.5",