Использование кастомных шаблонов и конфигов для swagger-typescript-api

от автора

swagger-typescript-api — это мощный инструмент для генерации кода на основе OpenApi-контактов, о процессе работы с которым я рассказывал в предыдущей статье. Там же я упомянул, что его можно кастомизировать под нужды конкретного проекта с помощью своих шаблонов.

Именно кастомные шаблоны и бонусом, кастомная конфигурация, будут раскрыты в текущей статье. Поехали!

Чтобы использовать кастомные шаблоны, предварительно нужно сделать следующее:

  1. Скопировать из репозитория swagger-typescript-api шаблоны в ваш проект:

    1. Из  /templates/default для генерации общего файла с API и типами;

    2. Из /templates/modular для генерации отдельных файлов с API и типами;

    3. Из /templates/base шаблоны которые используются в первых двух вариантах;

  2. Добавить флаг --templates PATH_TO_YOUR_TEMPLATES для команды кодогенерации;

  3. И непосредственно модифицировать шаблоны.

Именно так написано в доке к инструменту. Однако есть ряд не очевидных моментов. Рассмотрим все поэтапно на примере моей задачи.

Как я писал в предыдущей статье, мы используем кастомный хук для работы с запросами. Ему нужно скармливать функцию, которая создает новый экземпляр класса 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.


ссылка на оригинал статьи https://habr.com/ru/articles/802097/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *