swagger-typescript-api - это мощный инструмент для генерации кода на основе OpenApi-контактов, о процессе работы с которым я рассказывал в предыдущей статье. Там же я упомянул, что его можно кастомизировать под нужды конкретного проекта с помощью своих шаблонов.
Именно кастомные шаблоны и бонусом, кастомная конфигурация, будут раскрыты в текущей статье. Поехали!
Чтобы использовать кастомные шаблоны, предварительно нужно сделать следующее:
Скопировать из репозитория swagger-typescript-api шаблоны в ваш проект:
Из /templates/default для генерации общего файла с API и типами;
Из /templates/modular для генерации отдельных файлов с API и типами;
Из /templates/base шаблоны которые используются в первых двух вариантах;
Добавить флаг
--templates PATH_TO_YOUR_TEMPLATESдля команды кодогенерации;И непосредственно модифицировать шаблоны.
Именно так написано в доке к инструменту. Однако есть ряд не очевидных моментов. Рассмотрим все поэтапно на примере моей задачи.
Как я писал в предыдущей статье, мы используем кастомный хук для работы с запросами. Ему нужно скармливать функцию, которая создает новый экземпляр класса API и вызывает нужный метод. Выглядит это следующим образом:
const [getContactDataPhones, contactDataPhonesListRd] = useFetchApiCallRd( getContactDataApi, 'phonesApiGetPhones', );
Нас интересует функция getContactDataApi:
export const getContactDataApi = ( config: ApiConfig, params: RequestParams, ) => ({ emailsApiGetEmails: (query: { pfpId: number; }) => new ContactDataApi(config).emails.getEmails(query, params), phonesApiGetPhones: (query?: { pfpId?: number; leadId?: number; phoneType?: IPhoneType[]; isFormal?: boolean; }) => ( new ContactDataApi(config).phones.getPhones(query, params) ), phonesApiPostPhones: (data: INewPhone) => new ContactDataApi(config).phones.postPhones(data, params), phonesApiGetMaskedPhone: (phoneNumber: string) => ( new ContactDataApi(config).phones.getMaskedPhone(phoneNumber, params) ), phonesApiPatchPhone: (contactDataId: string, data: IUpdatePhone) => ( new ContactDataApi(config).phones.patchPhone(contactDataId, data, params) ), phonesApiPostMaskPhone: (data: IMaskedPhoneRq) => new ContactDataApi(config).phones.postMaskPhone(data, params), });
Кастомный хук предоставляет нам контроль над API с помощью этой не хитрой функции, которая по сути принимает базовую конфигурацию клиента через хук и создает мапу методов, из которой хук извлекает нужный. И остается только вызвать его, когда нам это потребуется.
Так вот эту функцию мы писали руками. Пришло время это исправить с помощью кастомных шаблонов.
Прежде всего добавим к команде кодогенерации путь до скачанных шаблонов:
{ "swagger:generate-contact-data": "sta --path swagger/contact-data.yaml --output src/services/contact-data/ --api-class-name ContactDataApi --responses --type-prefix I --templates templates/default", }
Теперь когда генератор видит наши шаблоны, давайте и мы на них взглянем:

Это набор шаблонов, которые мы скачали из репозитория swagger-typescript-api. Каждый шаблон отвечает за свою часть кода. Мы можем изменять их и даже добавлять в них свои подшаблоны с помощью директивы includeFile(pathToTemplate, payload). Если подшаблонов в основные шаблоны мы можем подключить сколько угодно, то основные шаблоны генератор ожидает определенные:
api.ejs- отвечает за класс API;data-contracts.ejs- отвечает за типы;http-client.ejs- отвечает за http-клиент;procedure-call.ejs- отвечает за методы внутри класса API;route-docs.ejs- отвечает за JSDOC для каждого метода внутри класса API;route-name.ejs- отвечает за имя метода внутри класса API;route-type.ejs- (`--route-types option`) отвечает за тип метода внутри класса API;data-contract-jsdoc.ejs- отвечает за JSDOC для типов.
Соответственно, чтобы изменить http-клиент, нужно добавить его шаблон в папку, которую вы указываете при выполнении команды, иначе генератор использует шаблон по умолчанию.
Мне же нужно было доработать шаблон api.ejs. Я добавил следующую запись:
export const get<%~ config.apiClassName %> = (config: ApiConfig, params: RequestParams) => ({ <% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %> <% for (const route of combinedRoutes) { %> <%~ includeFile('./procedure-call-getter.ejs', { ...it, moduleName, route }) %> <% } %> <% } %>
Я не гуру по ejs-шаблонам и написал это по аналогии с уже существующим кодом. Так что, думаю, любой желающий сможет разобраться при желании.
Как видите, я добавил свой подшаблон procedure-call-getter.ejs. Он сделан по аналогии с procedure-call.ejs. Вот что я там указал:
<% const { utils, route, config, moduleName } = it; const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route; const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils; const { parameters, path, method, payload, query, formData, security, requestParams } = route.request; const { type, errorType, contentTypes } = route.response; const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants; const queryName = (query && query.name) || "query"; const pathParams = _.values(parameters); const pathParamsNames = _.map(pathParams, "name"); const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH; const requestConfigParam = { name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES), optional: true, type: "RequestParams", defaultValue: "{}", } const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`; const argToName = ({ name }) => name; const rawWrapperArgs = config.extractRequestParams ? _.compact([ requestParams && { name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName, optional: false, type: getInlineParseContent(requestParams), }, ...(!requestParams ? pathParams : []), payload, ]) : _.compact([ ...pathParams, query, payload, ]) const wrapperArgs = _ // Sort by optionality .sortBy(rawWrapperArgs, [o => o.optional]) .map(argToTmpl) .join(', ') const wrapperArgsNames = rawWrapperArgs .map(argToName) .join(', ') const describeReturnType = () => { if (!config.toJS) return ""; switch(config.httpClientType) { case HTTP_CLIENT.AXIOS: { return `Promise<AxiosResponse<${type}>>` } default: { return `Promise<HttpResponse<${type}, ${errorType}>` } } } const capitalizeFirstLetter = (string) => { return string.charAt(0).toUpperCase() + string.slice(1); } const unCapitalizeFirstLetter = (string) => { return string.charAt(0).toLowerCase() + string.slice(1); } %> <%~ unCapitalizeFirstLetter(config.apiClassName) %><%~ capitalizeFirstLetter(route.routeName.usage) %>: (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => { return new <%~ config.apiClassName %>(config).<%~ moduleName %>.<%~ route.routeName.usage %>( <% if (wrapperArgs.length) { %> <%~ wrapperArgsNames %>, <% } %> params, ); },
Большая часть скопирована из procedure-call.ejs, а я добавил только:
const capitalizeFirstLetter = (string) => { return string.charAt(0).toUpperCase() + string.slice(1); } const unCapitalizeFirstLetter = (string) => { return string.charAt(0).toLowerCase() + string.slice(1); } %> <%~ unCapitalizeFirstLetter(config.apiClassName) %><%~ capitalizeFirstLetter(route.routeName.usage) %>: (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => { return new <%~ config.apiClassName %>(config).<%~ moduleName %>.<%~ route.routeName.usage %>( <% if (wrapperArgs.length) { %> <%~ wrapperArgsNames %>, <% } %> params, ); },
И теперь нужная мне функция также генерируется:

А теперь бонус - подключение кастомного конфига. В предыдущей статье я писал, что опция --type-prefix или --type-suffix применяется и к типам, и к интерфейсам, и даже к енамам, что меня лично не устраивает, так как я люблю их разделять, как раз используя различные префиксы и суффиксы. Также я написал, что эту проблему можно решить с помощью кастомных шаблонов. К сожалению, я ошибся.
Я искал альтернативные пути решения этой проблемы, но, к сожалению, их нет. Возможно я позже сделаю вклад в этот проект, так как уже основательно разобрался, как он работает. И реализовать этот функционал возможно.
А пока я с этим разбирался, я узнал, как использовать кастомную конфигурацию для кодогенерации. Это может быть полезным, если вам понадобиться, например, заменить типы для дат или чего-то еще.
В документации хорошо описано, что можно сделать с помощью кастомного конфига, но нет примеров, как использовать его с помощью флага --custom-config. А это оказывается вставит в тупик некоторых пользователей.
Все просто! Достаточно передать путь до файла с вашим конфигом через эту опцию:
{ "swagger:generate-contact-data": "sta --path swagger/contact-data.yaml --output src/services/contact-data/ --api-class-name ContactDataApi --responses --type-prefix I --templates templates/default --custom-config generator.config.js" }
А вот пример конфига:
module.exports = { hooks: { onPreBuildRoutePath: (routePath) => void 0, onBuildRoutePath: (routeData) => void 0, onInsertPathParam: (pathParam) => void 0, onCreateComponent: (schema) => schema, onPreParseSchema: (originalSchema, typeName, schemaType) => void 0, onParseSchema: (originalSchema, parsedSchema) => parsedSchema, onCreateRoute: (routeData) => routeData, onInit: (config, codeGenProcess) => config, onPrepareConfig: (apiConfig) => apiConfig, onCreateRequestParams: (rawType) => {}, onCreateRouteName: () => {}, onFormatTypeName: (typeName, rawTypeName, schemaType) => {}, onFormatRouteName: (routeInfo, templateRouteName) => {}, }, codeGenConstructs: (struct) => ({ Keyword: { Number: 'number', String: 'string', Boolean: 'boolean', Any: 'any', Void: 'void', Unknown: 'unknown', Null: 'null', Undefined: 'undefined', Object: 'object', File: 'File', Date: 'Date', Type: 'type', Enum: 'enum', Interface: 'interface', Array: 'Array', Record: 'Record', Intersection: '&', Union: '|', }, CodeGenKeyword: { UtilRequiredKeys: 'UtilRequiredKeys', }, /** * $A[] or Array<$A> */ ArrayType: (content) => { if (this.anotherArrayType) { return `Array<${content}>`; } return `(${content})[]`; }, /** * "$A" */ StringValue: (content) => `"${content}"`, /** * $A */ BooleanValue: (content) => `${content}`, /** * $A */ NumberValue: (content) => `${content}`, /** * $A */ NullValue: (content) => content, /** * $A1 | $A2 */ UnionType: (contents) => _.join(_.uniq(contents), ` | `), /** * ($A1) */ ExpressionGroup: (content) => (content ? `(${content})` : ''), /** * $A1 & $A2 */ IntersectionType: (contents) => _.join(_.uniq(contents), ` & `), /** * Record<$A1, $A2> */ RecordType: (key, value) => `Record<${key}, ${value}>`, /** * readonly $key?:$value */ TypeField: ({ readonly, key, optional, value }) => _.compact([readonly && 'readonly ', key, optional && '?', ': ', value]).join(''), /** * [key: $A1]: $A2 */ InterfaceDynamicField: (key, value) => `[key: ${key}]: ${value}`, /** * $A1 = $A2 */ EnumField: (key, value) => `${key} = ${value}`, /** * $A0.key = $A0.value, * $A1.key = $A1.value, * $AN.key = $AN.value, */ EnumFieldsWrapper: (contents) => _.map(contents, ({ key, value }) => ` ${key} = ${value}`).join(',\n'), /** * {\n $A \n} */ ObjectWrapper: (content) => `{\n${content}\n}`, /** * /** $A *\/ */ MultilineComment: (contents, formatFn) => [ ...(contents.length === 1 ? [`/** ${contents[0]} */`] : ['/**', ...contents.map((content) => ` * ${content}`), ' */']), ].map((part) => `${formatFn ? formatFn(part) : part}\n`), /** * $A1<...$A2.join(,)> */ TypeWithGeneric: (typeName, genericArgs) => { return `${typeName}${genericArgs.length ? `<${genericArgs.join(',')}>` : ''}`; }, }), };
Всем спасибо за внимание!
P.S. Если вам будет интересно, что за кастомный хук мы используем для запросов, то напишите в комментариях. Там действительно есть на что посмотреть. В этом хуке используем всю силу Typescript Generics.
