@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
text/typescript
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;
}
}