diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7e257db --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": [] +} \ No newline at end of file diff --git a/src/modules/restful/base.ts b/src/modules/restful/base.ts new file mode 100644 index 0000000..29569f6 --- /dev/null +++ b/src/modules/restful/base.ts @@ -0,0 +1,116 @@ +import { Type } from '@nestjs/common'; +import { Routes } from '@nestjs/core'; + +import { pick } from 'lodash'; + +import { Configure } from '../config/configure'; + +import { ApiConfig, RouteOption } from './types'; +import { createRouteModuleTree, getCleanRoutes, getRoutePath } from './utils'; + +export abstract class BaseRestful { + constructor(protected configure: Configure) {} + + abstract create(_config: ApiConfig): void; + + protected config!: ApiConfig; + + protected _routes: Routes = []; + + protected _default!: string; + + protected _versions: string[] = []; + + protected _modules: { [key: string]: Type } = {}; + + get routes() { + return this._routes; + } + + get default() { + return this._default; + } + + get versions() { + return this._versions; + } + + get modules() { + return this._modules; + } + + protected createConfig(config: ApiConfig) { + if (!config.default) { + throw new Error('default api version name should be config!'); + } + + const versionMaps = Object.entries(config.versions) + .filter(([name]) => (config.default === name ? true : config.enabled.includes(name))) + .map(([name, version]) => [ + name, + { + ...pick(config, ['title', 'description', 'auth']), + ...version, + tags: Array.from(new Set([...(config.tags ?? []), ...(version.tags ?? [])])), + routes: getCleanRoutes(version.routes ?? []), + }, + ]); + config.versions = Object.fromEntries(versionMaps); + this._versions = Object.keys(config.versions); + this._default = config.default; + + if (!this._versions.includes(this._default)) { + throw new Error(`Default api version named ${this._default} not found!`); + } + this.config = config; + } + + protected async createRoutes() { + const prefix = await this.configure.get('app.prefix'); + const versionMaps = Object.entries(this.config.versions); + + this._routes = ( + await Promise.all( + versionMaps.map(async ([name, version]) => + ( + await createRouteModuleTree( + this.configure, + this._modules, + version.routes ?? [], + name, + ) + ).map((route) => ({ + ...route, + path: getRoutePath(route.path, prefix, name), + })), + ), + ) + ).reduce((o, n) => [...o, ...n], []); + const defaultVersion = this.config.versions[this._default]; + this._routes = [ + ...this._routes, + ...( + await createRouteModuleTree( + this.configure, + this._modules, + defaultVersion.routes ?? [], + ) + ).map((route) => ({ ...route, path: getRoutePath(route.path, prefix) })), + ]; + } + + protected getRouteModules(routes: RouteOption[], parent?: string) { + const result = routes + .map(({ name, children }) => { + const routeName = parent ? `${parent}.${name}` : name; + let modules: Type[] = [this._modules[routeName]]; + if (children) { + modules = [...modules, ...this.getRouteModules(children, routeName)]; + } + return modules; + }) + .reduce((o, n) => [...o, ...n], []) + .filter((i) => !!i); + return result; + } +} diff --git a/src/modules/restful/restful.ts b/src/modules/restful/restful.ts new file mode 100644 index 0000000..7ace5e9 --- /dev/null +++ b/src/modules/restful/restful.ts @@ -0,0 +1,78 @@ +import { Type } from '@nestjs/common'; +import { RouterModule } from '@nestjs/core'; + +import { omit } from 'lodash'; + +import { BaseRestful } from './base'; +import { ApiConfig, ApiDocOption, ApiDocSource, RouteOption, SwaggerOption } from './types'; +import { trimPath } from './utils'; + +export class Restful extends BaseRestful { + protected _docs!: { [version: string]: ApiDocOption }; + + protected excludeVersionModules: string[] = []; + + get docs() { + return this._docs; + } + + async create(config: ApiConfig) { + this.createConfig(config); + await this.createRoutes(); + this.createDocs(); + } + + getModuleImports() { + return [RouterModule.register(this.routes), ...Object.values(this.modules)]; + } + + protected getRouteDocs( + option: Omit, + routes: RouteOption[], + parent?: string, + ): { [key: string]: SwaggerOption } { + const mergeDoc = (vDoc: Omit, route: RouteOption) => ({ + ...vDoc, + ...route.doc, + tags: Array.from(new Set([...(vDoc.tags ?? []), ...(route.doc?.tags ?? [])])), + path: genDocPath(route.path, this.config.docuri, parent), + include: this.getRouteModules([route], parent), + }); + let routeDocs: { [key: string]: SwaggerOption } = {}; + const hasAdditional = (doc?: ApiDocSource) => + doc && Object.keys(omit(doc, 'tags')).length > 0; + for (const route of routes) { + const { name, doc, children } = route; + const moduleName = parent ? `${parent}.${name}` : name; + + if (hasAdditional(doc) || parent) { + this.excludeVersionModules.push(moduleName); + } + + if (hasAdditional(doc)) { + routeDocs[moduleName.replace(`${option.version}.`, '')] = mergeDoc(option, route); + } + if (children) { + routeDocs = { ...routeDocs, ...this.getRouteDocs(option, children, moduleName) }; + } + } + return routeDocs; + } + + protected filterExcludeModules(routeModules: Type[]) { + const excludeModules: Type[] = []; + const excludeNames = Array.from(new Set(this.excludeVersionModules)); + for (const [name, module] of Object.entries(this._modules)) { + if (excludeNames.includes(name)) { + excludeModules.push(module); + } + } + return routeModules.filter( + (module) => !excludeModules.find((emodule) => emodule === module), + ); + } +} + +export function genDocPath(routePath: string, prefix?: string, version?: string) { + return trimPath(`${prefix}${version ? `/${version.toLowerCase()}/` : '/'}${routePath}`, false); +} diff --git a/src/modules/restful/types.ts b/src/modules/restful/types.ts index 3ed5aa1..73f1df8 100644 --- a/src/modules/restful/types.ts +++ b/src/modules/restful/types.ts @@ -17,7 +17,7 @@ export interface ApiDocSource { export interface ApiConfig extends ApiDocSource { docuri?: string; default: string; - enable: string; + enabled: string[]; versions: Record; } @@ -32,3 +32,14 @@ export interface RouteOption { children?: RouteOption[]; doc?: ApiDocSource; } + +export interface SwaggerOption extends ApiDocSource { + version: string; + path: string; + include: Type[]; +} + +export interface ApiDocOption { + default?: SwaggerOption; + routes?: { [key: string]: SwaggerOption }; +} diff --git a/src/modules/restful/utils.ts b/src/modules/restful/utils.ts new file mode 100644 index 0000000..d898418 --- /dev/null +++ b/src/modules/restful/utils.ts @@ -0,0 +1,86 @@ +import { Type } from '@nestjs/common'; +import { Routes, RouteTree } from '@nestjs/core'; +import { ApiTags } from '@nestjs/swagger'; +import { camelCase, isNil, omit, trim, upperFirst } from 'lodash'; + +import { Configure } from '../config/configure'; + +import { CreateModule } from '../core/helpers'; + +import { CONTROLLER_DEPENDS } from './constants'; +import { RouteOption } from './types'; + +export const trimPath = (routePath: string, addPrefix = true) => + `${addPrefix ? '/' : ''}${trim(routePath.replace('//', '/'), '/')}`; + +export const getCleanRoutes = (data: RouteOption[]): RouteOption[] => + data.map((option) => { + const route: RouteOption = { + ...omit(option, 'children'), + path: trimPath(option.path), + }; + if (option.children && option.children.length > 0) { + route.children = getCleanRoutes(option.children); + } else { + delete route.children; + } + return route; + }); + +export function getRoutePath(routePath: string, prefix?: string, version?: string) { + const addVersion = `${version ? `/${version.toLowerCase()}/` : '/'}${routePath}`; + return isNil(prefix) ? trimPath(addVersion) : trimPath(`${prefix}${addVersion}`); +} + +export function createRouteModuleTree( + configure: Configure, + modules: { [key: string]: Type }, + routes: RouteOption[], + parentModule?: string, +): Promise { + return Promise.all( + routes.map(async ({ name, path, children, controllers, doc }) => { + const moduleName = parentModule ? `${parentModule}.${name}` : name; + if (Object.keys(modules).includes(moduleName)) { + throw new Error( + `route name must be unique in same level, ${moduleName} has exists`, + ); + } + const depends = controllers + .map((o) => Reflect.getMetadata(CONTROLLER_DEPENDS, o) || []) + .reduce((o: Type[], n) => [...o, ...n], []) + .reduce((o: Type[], n: Type) => { + if (o.find((i) => i === n)) { + return o; + } + return [...o, n]; + }, []); + + if (doc?.tags && doc.tags.length > 0) { + controllers.forEach((o) => { + !Reflect.getMetadata('swagger/apiUseTags', o) && + ApiTags( + ...doc.tags.map((tag) => (typeof tag === 'string' ? tag : tag.name)), + )(o); + }); + } + + const module = CreateModule(`${upperFirst(camelCase(name))}RouteModule`, () => ({ + controllers, + imports: depends, + })); + + modules[moduleName] = module; + const route: RouteTree = { path, module }; + if (children) { + route.children = await createRouteModuleTree( + configure, + modules, + children, + moduleName, + ); + } + return route; + }), + ); +}