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.