Использование кастомных шаблонов и конфигов для 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",
}

Теперь когда генератор видит наши шаблоны, давайте и мы на них взглянем:

9bb99e8ee59013f469de7e471f5221be.png

Это набор шаблонов, которые мы скачали из репозитория 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>`
        }
        default: {
          return `Promise`
        }
    }
}

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,
    );
},

И теперь нужная мне функция также генерируется:

e126c4c3e5f52a98bd558d1b7e6662de.png

А теперь бонус — подключение кастомного конфига. В предыдущей статье я писал, что опция --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.

© Habrahabr.ru