import assert from "assert"; import inflection from "inflection"; import { diff, group, sort, unique } from "radashi"; import { apiParamToTsCode, apiParamToTsCodeAsObject, apiParamTypeToTsType, unwrapPromiseOnce, } from "../../api/code-converters"; import type { ExtendedApi } from "../../api/decorators"; import { Sonamu } from "../../api/sonamu"; import type { EntityNamesRecord } from "../../entity/entity-manager"; import { Naite } from "../../naite/naite"; import type { TemplateOptions } from "../../types/types"; import { type ApiParam, ApiParamType } from "../../types/types"; import { assertDefined } from "../../utils/utils"; import { Template } from "../template"; import { zodTypeToTsTypeDef } from "../zod-converter"; export class Template__service extends Template { constructor() { super("service"); } getTargetAndPath(names: EntityNamesRecord) { return { target: ":target/src/services", path: `${names.fs}/${names.fs}.service.ts`, }; } render({ namesRecord }: TemplateOptions["service"]) { Naite.t("render", { namesRecord }); const { syncer: { apis }, } = Sonamu; const apisForThisModel = apis.filter( (api) => api.modelName === `${namesRecord.capital}Model` || api.modelName === `${namesRecord.capital}Frame`, ); // 서비스 TypeSource const { lines, importKeys } = this.getTypeSource(apisForThisModel); // AxiosProgressEvent 있는지 확인 const hasAxiosProgressEvent = apis.find((api) => (api.options.clients ?? []).includes("axios-multipart"), ); return { ...this.getTargetAndPath(namesRecord), body: lines.join("\n"), importKeys: importKeys.filter((key) => ["ListResult"].includes(key) === false), customHeaders: [ `import { z } from 'zod';`, `import qs from "qs";`, `import useSWR, { type SWRResponse } from "swr";`, `import { fetch, ListResult, SWRError, SwrOptions, handleConditional, swrPostFetcher, EventHandlers, SSEStreamOptions, useSSEStream } from '../sonamu.shared';`, ...(hasAxiosProgressEvent ? [`import { type AxiosProgressEvent } from 'axios';`] : []), ], }; } getTypeSource(apis: ExtendedApi[]): { lines: string[]; importKeys: string[]; } { const importKeys: string[] = []; // 제네릭에서 선언한 타입, importKeys에서 제외 필요 let typeParamNames: string[] = []; const groups = group(apis, (api) => api.modelName); const body = Object.keys(groups) .map((modelName) => { const methods = groups[modelName]; assert(methods); const methodCodes = methods .map((api) => { // 컨텍스트 제외된 파라미터 리스트 const paramsWithoutContext = api.parameters.filter( (param) => !ApiParamType.isContext(param.type) && !ApiParamType.isRefKnex(param.type) && !(param.optional === true && param.name.startsWith("_")), // _로 시작하는 파라미터는 제외 ); // 파라미터 타입 정의 const typeParametersAsTsType = api.typeParameters .map((typeParam) => { return apiParamTypeToTsType(typeParam, importKeys); }) .join(", "); const typeParamsDef = typeParametersAsTsType ? `<${typeParametersAsTsType}>` : ""; typeParamNames = typeParamNames.concat( api.typeParameters.map((typeParam) => typeParam.id), ); // 파라미터 정의 const paramsDef = apiParamToTsCode(paramsWithoutContext, importKeys); // 파라미터 정의 (객체 형태) const paramsDefAsObject = apiParamToTsCodeAsObject(paramsWithoutContext, importKeys); // 리턴 타입 정의 const returnTypeDef = apiParamTypeToTsType( assertDefined(unwrapPromiseOnce(api.returnType)), importKeys, ); // 페이로드 데이터 정의 const payloadDef = `{ ${paramsWithoutContext.map((param) => param.name).join(", ")} }`; // 기본 URL const apiBaseUrl = `${Sonamu.config.api.route.prefix}${api.path}`; const clients = api.options.clients ?? []; return [ // 클라이언트별로 생성 ...sort(clients, (client) => (client === "swr" ? 0 : 1)).map((client) => { switch (client) { case "axios": return this.renderAxios( api, apiBaseUrl, typeParamsDef, paramsDef, returnTypeDef, payloadDef, ); case "axios-multipart": return this.renderAxiosMultipart( api, apiBaseUrl, typeParamsDef, paramsDef, returnTypeDef, paramsWithoutContext, ); case "swr": return this.renderSwr( api, apiBaseUrl, typeParamsDef, paramsDef, returnTypeDef, payloadDef, ); case "window-fetch": return this.renderWindowFetch( api, apiBaseUrl, typeParamsDef, paramsDef, payloadDef, ); default: return `// Not supported ${inflection.camelize(client, true)} yet.`; } }), // 스트리밍인 경우 ...(api.streamOptions ? [this.renderStream(api, apiBaseUrl, paramsDefAsObject)] : []), ].join("\n"); }) .join("\n\n"); return `export namespace ${modelName.replace(/Model$/, "Service").replace(/Frame$/, "Service")} { ${methodCodes} }`; }) .join("\n\n"); return { lines: [body], importKeys: diff(unique(importKeys), typeParamNames), }; } renderAxios( api: ExtendedApi, apiBaseUrl: string, typeParamsDef: string, paramsDef: string, returnTypeDef: string, payloadDef: string, ) { const methodNameAxios = api.options.resourceName ? `get${inflection.camelize(api.options.resourceName)}` : api.methodName; if (api.options.httpMethod === "GET") { return ` export async function ${methodNameAxios}${typeParamsDef}(${paramsDef}): Promise<${returnTypeDef}> { return fetch({ method: "GET", url: \`${apiBaseUrl}?\${qs.stringify(${payloadDef})}\`, ${api.options.timeout ? `signal: AbortSignal.timeout(${api.options.timeout}),` : ""} }); } `.trim(); } else { return ` export async function ${methodNameAxios}${typeParamsDef}(${paramsDef}): Promise<${returnTypeDef}> { return fetch({ method: '${api.options.httpMethod}', url: \`${apiBaseUrl}\`, data: ${payloadDef}, ${api.options.timeout ? `signal: AbortSignal.timeout(${api.options.timeout}),` : ""} }); } `.trim(); } } renderAxiosMultipart( api: ExtendedApi, apiBaseUrl: string, typeParamsDef: string, paramsDef: string, returnTypeDef: string, paramsWithoutContext: ApiParam[], ) { const isMultiple = api.uploadOptions?.mode === "multiple"; const fileParamName = isMultiple ? "files" : "file"; const fileParamType = isMultiple ? "File[]" : "File"; const formDataDef = isMultiple ? [ `${fileParamName}.forEach(f => { formData.append("${fileParamName}", f) } ); `, ...paramsWithoutContext.map( (param) => `formData.append('${param.name}', String(${param.name}));`, ), ].join("\n") : [ `formData.append("${fileParamName}", ${fileParamName});`, ...paramsWithoutContext.map( (param) => `formData.append('${param.name}', String(${param.name}));`, ), ].join("\n"); const paramsDefComma = paramsDef !== "" ? ", " : ""; return ` export async function ${api.methodName}${typeParamsDef}( ${paramsDef}${paramsDefComma} ${fileParamName}: ${fileParamType}, onUploadProgress?: (pe:AxiosProgressEvent) => void ): Promise<${returnTypeDef}> { const formData = new FormData(); ${formDataDef} return fetch({ method: 'POST', url: \`${apiBaseUrl}\`, onUploadProgress, data: formData, ${api.options.timeout ? `signal: AbortSignal.timeout(${api.options.timeout}),` : ""} }); } `.trim(); } renderSwr( api: ExtendedApi, apiBaseUrl: string, typeParamsDef: string, paramsDef: string, returnTypeDef: string, payloadDef: string, ) { const methodNameSwr = api.options.resourceName ? `use${inflection.camelize(api.options.resourceName)}` : `use${inflection.camelize(api.methodName)}`; return ` export function ${inflection.camelize(methodNameSwr, true)}${typeParamsDef}(${[ paramsDef, "swrOptions?: SwrOptions", ] .filter((p) => p !== "") .join(",")}, ): SWRResponse<${returnTypeDef}, SWRError> { return useSWR(handleConditional([ \`${apiBaseUrl}\`, ${payloadDef}, ], swrOptions?.conditional)${api.options.httpMethod === "POST" ? ", swrPostFetcher" : ""}${ api.options.timeout ? `, { loadingTimeout: ${api.options.timeout} }` : "" }); }`; } renderWindowFetch( api: ExtendedApi, apiBaseUrl: string, typeParamsDef: string, paramsDef: string, payloadDef: string, ) { return ` export async function ${api.methodName}${typeParamsDef}(${paramsDef}): Promise { return window.fetch(\`${apiBaseUrl}?\${qs.stringify(${payloadDef})}\`${ api.options.timeout ? `, { signal: AbortSignal.timeout(${api.options.timeout}) }` : "" }); } `.trim(); } renderStream(api: ExtendedApi, apiBaseUrl: string, paramsDefAsObject: string) { if (!api.streamOptions) { return "// streamOptions not found"; } const methodNameStream = api.options.resourceName ? `use${inflection.camelize(api.options.resourceName)}` : `use${inflection.camelize(api.methodName)}`; const methodNameStreamCamelized = inflection.camelize(methodNameStream, true); const eventsTypeDef = zodTypeToTsTypeDef(api.streamOptions.events); return ` export function ${methodNameStreamCamelized}( params: ${paramsDefAsObject}, handlers: EventHandlers<${eventsTypeDef} & { end?: () => void }>, options: SSEStreamOptions) { return useSSEStream<${eventsTypeDef}>(\`${apiBaseUrl}\`, params, handlers, options); }`; } }