UNPKG

@udraft/core

Version:

uDraft is a language and stack agnostic code-generation tool that simplifies full-stack development by converting a single YAML file into code for rapid development.

345 lines (301 loc) 10.4 kB
import * as Case from "case"; import * as path from "path"; import { UFeature } from "../entities/feature"; import { UModel } from "../entities/model"; import { UModule } from "../entities/module"; import { URenderer } from "../entities/renderer"; import { $attr } from "../shortcuts/queries"; import { RenderContent, RenderPath, RenderSelection } from "../types/renderer"; import { _http, _rootModule } from "../shortcuts/attributes"; import TSClassRenderer from "./ts-class-renderer"; import { addPackageJsonDependency } from "../helpers/package"; // Default Axios-based API client code for TypeScript const DEFAULT_API_CLIENT_CODE = ` import { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; export class ApiClient { private axiosInstance: AxiosInstance; constructor(axiosInstance: AxiosInstance) { this.axiosInstance = axiosInstance; } async request( path: string, options: { method: string, contentType?: string, queryParameters?: any, data?: any, } ): Promise<any> { try { const config: AxiosRequestConfig = { url: path, method: options.method.toLowerCase() as any, headers: options.contentType ? { 'Content-Type': options.contentType } : undefined, params: options.queryParameters, data: options.data, }; const response = await this.axiosInstance.request(config); return response.data; } catch (error: any) { if (error && error.response) { throw new ApiException(error.response.status, error.message || 'Unknown error'); } throw error; } } } export class ApiResponse<T> { data?: T; error?: string; statusCode?: number; private constructor(data?: T, error?: string, statusCode?: number) { this.data = data; this.error = error; this.statusCode = statusCode; } static success<T>(data: T): ApiResponse<T> { return new ApiResponse<T>(data, undefined, 200); } static error<T>(statusCode?: number, message?: string): ApiResponse<T> { return new ApiResponse<T>(undefined, message || 'Unknown error', statusCode); } get isSuccess(): boolean { return this.error === undefined; } } export class ApiException extends Error { code?: number; constructor(code: number | undefined, message: string) { super(message); this.code = code; Object.setPrototypeOf(this, ApiException.prototype); } } `.trim(); const KEYS = { packageJson: "packageJson", baseApiClient: "ApiClient", }; export default class TSApiCLientRenderer extends URenderer { private _serviceDir: string = "src/apis"; private _baseClientPath: string = "src/core/api-client.ts"; private _where?: (module: UModule, feature: UFeature) => boolean; private _updatePackageJson = false; constructor(options?: { serviceDir?: string; baseClientPath?: string; where?: (module: UModule, feature: UFeature) => boolean; updatePackageJson?: boolean; }) { super("ts@api-client"); if (options?.serviceDir) this._serviceDir = options.serviceDir; if (options?.baseClientPath) this._baseClientPath = options.baseClientPath; if (options?.updatePackageJson) this._updatePackageJson = options.updatePackageJson; this._where = options?.where; } $moduleServiceName(module: UModule) { return `${Case.pascal(module.$name())}Api`; } $fileName(module: UModule, extension = true) { return `${Case.kebab(module.$name())}-api${extension ? ".ts" : ""}`; } private resolveFeatureRoute(module: UModule, feature: UFeature): string { const moduleConfig = $attr(module, _http()); const featureConfig = $attr(feature, _http()); return ( (moduleConfig?.url || "") + "/" + (featureConfig?.url || "") ).replace(/\/{2,}/, "/"); } private getHttpMethod(feature: UFeature): string { return ($attr(feature, _http())?.method || "get").toUpperCase(); } async select(): Promise<RenderSelection> { const modules: UModule[] = []; const features: UFeature[] = []; this.$features( (m, f) => !!$attr(f, _http()) && (this._where ? this._where(m, f) : true) ).forEach((feature) => { const mod = $attr(feature, _rootModule()); if (mod && !modules.find((m) => m.$name() == mod.$name())) modules.push(mod); if (!features.find((f) => f.$name() == feature.$name())) features.push(feature); }); const paths: RenderPath[] = [ { key: KEYS.baseApiClient, path: this._baseClientPath, meta: { isBase: true }, }, ]; if (this._updatePackageJson) paths.push({ key: KEYS.packageJson, path: "package.json", }); modules.forEach((module) => { if (module.$features().length > 0) { paths.push({ key: this.$moduleServiceName(module), meta: { module: module.$name() }, path: path.join(this._serviceDir, this.$fileName(module)), }); } }); return { paths, modules, features, }; } async render(): Promise<RenderContent[]> { const output: RenderContent[] = []; if (this._updatePackageJson) output.push({ key: KEYS.packageJson, content: addPackageJsonDependency( this.$content(KEYS.packageJson)?.content || "", [{ name: "axios", version: "^1.4.0" }] ), }); // Generate base API client if missing const baseApiClientPath = this.$path(KEYS.baseApiClient); const baseApiClient = this.$content(KEYS.baseApiClient); if (!baseApiClient?.content) { output.push({ key: KEYS.baseApiClient, content: DEFAULT_API_CLIENT_CODE, meta: { isBase: true }, }); } // Generate module APIs const modules = this.$selection().modules || []; for (const module of modules) { const features = module.$features(this.$selection().features); if (features.length === 0) continue; const serviceName = this.$moduleServiceName(module); const modulePath = this.$path(serviceName); const tsClassRenderer = this.$draft().$requireRenderer<TSClassRenderer>( this, "ts@classes" ); if (!modulePath) continue; let imports = `import { ApiClient, ApiResponse, ApiException } from "${this.$resolveRelativePath( modulePath.path, baseApiClientPath?.path + "" ).replace(/\.ts$/, "")}";`; let importedModels: string[] = []; let methods: string[] = []; const importModel = (model: UModel) => { const modelKey = tsClassRenderer.$key(model); const modelPath = tsClassRenderer.$path(modelKey)?.path; if (importedModels.includes(modelKey)) return; imports += `\nimport { ${tsClassRenderer.$className( model )} } from "${this.$resolveRelativePath( modulePath.path, modelPath + "" ).replace(/\.ts$/, "")}";`; }; features.forEach((feature) => { const methodName = Case.camel(feature.$name()); const inputModel = feature.$input(); const outputModel = feature.$output(); const httpConfig = $attr(feature, _http()); const contentType = httpConfig?.contentType; const params = httpConfig?.params || {}; const method = this.getHttpMethod(feature); const route = this.resolveFeatureRoute(module, feature); // Process route with path parameters let processedRoute = route.replace(/{([^}]+)}/g, (match, param) => { const fieldName = params[param]; return inputModel && fieldName ? `\${${Case.camel(inputModel.$name())}.${fieldName}}` : match; }); // Build query parameters const queryParamEntries = (inputModel?.$fields() || []) .filter( (field) => !Object.values(params).some((n) => n == field.$name()) ) .map( (field) => `'${tsClassRenderer.$fieldName(field)}': ${Case.camel( inputModel?.$name() + "" )}.${tsClassRenderer.$fieldName(field)}` ); const queryParamsCode = queryParamEntries.length > 0 ? `queryParameters: {${queryParamEntries.join(", ")}}` : ""; // Build data based on content type // And handle input model let inputType = "void"; let dataCode = ""; if (inputModel) { const inputClassName = tsClassRenderer.$className(inputModel); importModel(inputModel); inputType = inputClassName; const inputVar = Case.camel(inputModel.$name()); dataCode = `data: ${inputVar}.toJson(),`; } // Handle output model let outputType = "void"; if (outputModel) { const outputClassName = tsClassRenderer.$className(outputModel); importModel(outputModel); outputType = outputClassName; } // Build method parameters const methodParams = inputModel ? `${Case.camel(inputModel.$name())}: ${inputType} ` : ""; const sendQuery = httpConfig?.noBody !== undefined ? httpConfig.noBody : ["GET", "SEARCH", "DELETE"].includes(method); // Build method implementation methods.push(` async ${methodName}(${methodParams}): Promise<ApiResponse<${outputType}>> { try { ${outputModel ? "const response = " : ""}await this._client.request( \`${processedRoute}\`,{ method: '${method}',${ contentType ? `\n contentType: '${contentType}',` : "" }${ sendQuery && queryParamsCode ? `\n ${queryParamsCode},` : "" }${!sendQuery ? `\n ${dataCode}` : ""} }); return ApiResponse.success(${ outputModel ? `${tsClassRenderer.$className(outputModel)}.fromJson(response)` : "null" }); } catch (e) { if(e instanceof ApiException) return ApiResponse.error(e.code, e.message); else throw e; } }`); }); const content = ` ${imports} export default class ${serviceName} { constructor( private _client: ApiClient, ){} ${methods.join("\n")} } `.trim(); output.push({ key: serviceName, content, meta: modulePath.meta, }); } return output; } }