feat: инициализация API CodeGen

CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
- Поддержка локальных файлов и URL для спецификаций
- Кастомизация имени выходного файла через флаг --name
- Генерация типизированного клиента с SWR хуками
- Минимальный вывод логов для лучшего UX
This commit is contained in:
2025-10-26 22:30:58 +03:00
commit 15ed8c8b8d
26 changed files with 1854 additions and 0 deletions

52
src/cli.ts Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { validateConfig, type GeneratorConfig } from './config.js';
import { generate } from './generator.js';
import { fileExists } from './utils/file.js';
const program = new Command();
program
.name('api-codegen')
.description('Generate TypeScript API client from OpenAPI specification')
.version('1.0.0')
.requiredOption('-u, --url <url>', 'Base API URL (e.g., https://api.example.com)')
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
.requiredOption('-o, --output <path>', 'Output directory for generated files')
.option('-n, --name <name>', 'Name of generated file (without extension)')
.action(async (options) => {
try {
// Создание конфигурации
const config: Partial<GeneratorConfig> = {
apiUrl: options.url,
inputPath: options.input,
outputPath: options.output,
fileName: options.name,
};
// Валидация конфигурации
validateConfig(config);
// Проверка существования входного файла (только для локальных файлов)
if (!config.inputPath!.startsWith('http://') && !config.inputPath!.startsWith('https://')) {
if (!(await fileExists(config.inputPath!))) {
console.error(chalk.red(`\n❌ Error: Input file not found: ${config.inputPath}\n`));
process.exit(1);
}
}
// Генерация API
await generate(config as GeneratorConfig);
console.log(chalk.green('\n✨ Done!\n'));
} catch (error) {
console.error(chalk.red('\n❌ Error:'), error instanceof Error ? error.message : error);
console.error();
process.exit(1);
}
});
program.parse();

53
src/config.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Конфигурация генератора API
*/
export interface GeneratorConfig {
/** Базовый URL API */
apiUrl: string;
/** Путь к файлу OpenAPI спецификации */
inputPath: string;
/** Путь для сохранения сгенерированных файлов */
outputPath: string;
/** Имя сгенерированного файла (без расширения) */
fileName?: string;
}
/**
* Валидация конфигурации генератора
*/
export function validateConfig(config: Partial<GeneratorConfig>): config is GeneratorConfig {
const errors: string[] = [];
if (!config.apiUrl) {
errors.push('API URL is required (--url)');
} else if (!isValidUrl(config.apiUrl)) {
errors.push('API URL must be a valid URL');
}
if (!config.inputPath) {
errors.push('Input path is required (--input)');
}
if (!config.outputPath) {
errors.push('Output path is required (--output)');
}
if (errors.length > 0) {
throw new Error(`Configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
}
return true;
}
/**
* Проверка валидности URL
*/
function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}

130
src/generator.ts Normal file
View File

@@ -0,0 +1,130 @@
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
import { resolve, join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { unlink } from 'fs/promises';
import type { GeneratorConfig } from './config.js';
import { ensureDir, readJsonFile, writeFileWithDirs } from './utils/file.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Генерация API клиента из OpenAPI спецификации
*/
export async function generate(config: GeneratorConfig): Promise<void> {
// Убедимся, что выходная директория существует
await ensureDir(config.outputPath);
// Путь к кастомным шаблонам
const templatesPath = resolve(__dirname, '../src/templates');
// Читаем и модифицируем OpenAPI спецификацию
const inputPath = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://')
? config.inputPath
: resolve(config.inputPath);
const spec = await readJsonFile<any>(inputPath);
// Определяем имя файла
let fileName = config.fileName;
if (!fileName) {
// Пытаемся получить имя из OpenAPI спецификации
fileName = spec.info?.title
? spec.info.title.replace(/[^a-zA-Z0-9]/g, '')
: 'Api';
}
// Добавляем или обновляем servers с baseUrl
spec.servers = [{ url: config.apiUrl }];
// Сохраняем модифицированную спецификацию во временный файл
const tempSpecPath = join(config.outputPath, '.openapi-temp.json');
await writeFileWithDirs(tempSpecPath, JSON.stringify(spec, null, 2));
try {
await swaggerGenerateApi({
input: tempSpecPath,
output: resolve(config.outputPath),
fileName: `${fileName}.ts`,
httpClientType: 'fetch',
modular: false,
templates: templatesPath,
generateClient: true,
generateRouteTypes: true,
extractRequestParams: true,
extractRequestBody: true,
extractEnums: true,
cleanOutput: true,
singleHttpClient: true,
unwrapResponseData: true,
defaultResponseAsSuccess: true,
enumNamesAsValues: false,
moduleNameFirstTag: false,
generateUnionEnums: false,
extraTemplates: [],
addReadonly: false,
sortTypes: false,
sortRoutes: false,
extractResponseError: false,
fixInvalidEnumKeyPrefix: 'KEY',
silent: false,
defaultResponseType: 'void',
typePrefix: '',
typeSuffix: '',
enumKeyPrefix: '',
enumKeySuffix: '',
extractingOptions: {
requestBodySuffix: ['Payload', 'Body', 'Input'],
requestParamsSuffix: ['Params'],
responseBodySuffix: ['Data', 'Result', 'Output'],
responseErrorSuffix: ['Error', 'Fail', 'Fails', 'ErrorData', 'HttpError', 'BadResponse'],
},
hooks: {
onFormatRouteName: (routeInfo, templateRouteName) => {
// Убираем префикс с названием контроллера из имени метода
// Например: projectControllerUpdate -> update
// authControllerLogin -> login
const controllerPattern = /^(\w+)Controller(\w+)$/;
const match = templateRouteName.match(controllerPattern);
if (match) {
const [, , methodName] = match;
// Делаем первую букву строчной
return methodName ? methodName.charAt(0).toLowerCase() + methodName.slice(1) : templateRouteName;
}
return templateRouteName;
},
onInit: (configuration, codeGenProcess) => {
// Передаём baseUrl в конфигурацию для шаблонов
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
(configuration as any).apiConfig.baseUrl = config.apiUrl;
return configuration;
},
},
});
console.log(`Generated files in ${config.outputPath}:`);
console.log(` - ${fileName}.ts (API endpoints)`);
console.log(' - http-client.ts (HTTP client)');
console.log(' - data-contracts.ts (TypeScript types)');
// Удаляем временный файл
try {
await unlink(tempSpecPath);
} catch (e) {
// Игнорируем ошибки удаления
}
} catch (error) {
console.error('❌ Generation failed:', error);
// Удаляем временный файл даже при ошибке
try {
await unlink(tempSpecPath);
} catch (e) {
// Игнорируем ошибки удаления
}
throw error;
}
}

72
src/templates/api.ejs Normal file
View File

@@ -0,0 +1,72 @@
<%
const { apiConfig, routes, utils, config } = it;
const { info, servers, externalDocs } = apiConfig;
const { _, require, formatDescription } = utils;
const server = (servers && servers[0]) || { url: "" };
const descriptionLines = _.compact([
`@title ${info.title || "No title"}`,
info.version && `@version ${info.version}`,
info.license && `@license ${_.compact([
info.license.name,
info.license.url && `(${info.license.url})`,
]).join(" ")}`,
info.termsOfService && `@termsOfService ${info.termsOfService}`,
server.url && `@baseUrl ${server.url}`,
externalDocs.url && `@externalDocs ${externalDocs.url}`,
info.contact && `@contact ${_.compact([
info.contact.name,
info.contact.email && `<${info.contact.email}>`,
info.contact.url && `(${info.contact.url})`,
]).join(" ")}`,
info.description && " ",
info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "),
]);
%>
import useSWR from "swr";
import { fetcher } from "./http-client";
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
<% if (descriptionLines.length) { %>
/**
<% descriptionLines.forEach((descriptionLine) => { %>
* <%~ descriptionLine %>
<% }) %>
*/
<% } %>
export class <%~ config.apiClassName %><SecurityDataType extends unknown><% if (!config.singleHttpClient) { %> extends HttpClient<SecurityDataType> <% } %> {
<% if(config.singleHttpClient) { %>
http: HttpClient<SecurityDataType>;
constructor (http: HttpClient<SecurityDataType>) {
this.http = http;
}
<% } %>
<% if (routes.outOfModule) { %>
<% for (const route of routes.outOfModule) { %>
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
<% } %>
<% } %>
<% if (routes.combined) { %>
<% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
<%~ moduleName %> = {
<% for (const route of combinedRoutes) { %>
<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
<% } %>
}
<% } %>
<% } %>
}

View File

@@ -0,0 +1,37 @@
<%
const { data, utils } = it;
const { formatDescription, require, _ } = utils;
const stringify = (value) => _.isObject(value) ? JSON.stringify(value) : _.isString(value) ? `"${value}"` : value;
const jsDocLines = _.compact([
data.title,
data.description && formatDescription(data.description),
!_.isUndefined(data.deprecated) && data.deprecated && '@deprecated',
!_.isUndefined(data.format) && `@format ${data.format}`,
!_.isUndefined(data.minimum) && `@min ${data.minimum}`,
!_.isUndefined(data.multipleOf) && `@multipleOf ${data.multipleOf}`,
!_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${data.exclusiveMinimum}`,
!_.isUndefined(data.maximum) && `@max ${data.maximum}`,
!_.isUndefined(data.minLength) && `@minLength ${data.minLength}`,
!_.isUndefined(data.maxLength) && `@maxLength ${data.maxLength}`,
!_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${data.exclusiveMaximum}`,
!_.isUndefined(data.maxItems) && `@maxItems ${data.maxItems}`,
!_.isUndefined(data.minItems) && `@minItems ${data.minItems}`,
!_.isUndefined(data.uniqueItems) && `@uniqueItems ${data.uniqueItems}`,
!_.isUndefined(data.default) && `@default ${stringify(data.default)}`,
!_.isUndefined(data.pattern) && `@pattern ${data.pattern}`,
!_.isUndefined(data.example) && `@example ${stringify(data.example)}`
]).join('\n').split('\n');
%>
<% if (jsDocLines.every(_.isEmpty)) { %>
<% } else if (jsDocLines.length === 1) { %>
/** <%~ jsDocLines[0] %> */
<% } else if (jsDocLines.length) { %>
/**
<% for (jsDocLine of jsDocLines) { %>
* <%~ jsDocLine %>
<% } %>
*/
<% } %>

View File

@@ -0,0 +1,40 @@
<%
const { modelTypes, utils, config } = it;
const { formatDescription, require, _, Ts } = utils;
const buildGenerics = (contract) => {
if (!contract.genericArgs || !contract.genericArgs.length) return '';
return '<' + contract.genericArgs.map(({ name, default: defaultType, extends: extendsType }) => {
return [
name,
extendsType && `extends ${extendsType}`,
defaultType && `= ${defaultType}`,
].join('')
}).join(',') + '>'
}
const dataContractTemplates = {
enum: (contract) => {
return `enum ${contract.name} {\r\n${contract.content} \r\n }`;
},
interface: (contract) => {
return `interface ${contract.name}${buildGenerics(contract)} {\r\n${contract.content}}`;
},
type: (contract) => {
return `type ${contract.name}${buildGenerics(contract)} = ${contract.content}`;
},
}
%>
<% if (config.internalTemplateOptions.addUtilRequiredKeysType) { %>
type <%~ config.Ts.CodeGenKeyword.UtilRequiredKeys %><T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
<% } %>
<% for (const contract of modelTypes) { %>
<%~ includeFile('./data-contract-jsdoc.ejs', { ...it, data: { ...contract, ...contract.typeData } }) %>
<%~ contract.internal ? '' : 'export'%> <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %>
<% } %>

View File

@@ -0,0 +1,18 @@
<%
const { contract, utils, config } = it;
const { formatDescription, require, _ } = utils;
const { name, $content } = contract;
%>
<% if (config.generateUnionEnums) { %>
export type <%~ name %> = <%~ _.map($content, ({ value }) => value).join(" | ") %>
<% } else { %>
export enum <%~ name %> {
<%~ _.map($content, ({ key, value, description }) => {
let formattedDescription = description && formatDescription(description, true);
return [
formattedDescription && `/** ${formattedDescription} */`,
`${key} = ${value}`
].filter(Boolean).join("\n");
}).join(",\n") %>
}
<% } %>

View File

@@ -0,0 +1,250 @@
<%
const { apiConfig, generateResponses, config } = it;
const baseUrl = apiConfig?.baseUrl || "";
%>
/**
* Фетчер для SWR
* Принимает URL и возвращает Promise с данными
*/
export const fetcher = <T = any>(url: string): Promise<T> => {
return fetch(url, {
headers: {
"Content-Type": "application/json",
},
}).then(res => {
if (!res.ok) {
throw new Error(`HTTP Error ${res.status}`);
}
return res.json();
});
};
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "<%~ baseUrl %>";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: 'same-origin',
headers: {},
redirect: 'follow',
referrerPolicy: 'no-referrer',
}
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
}
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob ?
property :
typeof property === "object" && property !== null ?
JSON.stringify(property) :
`${property}`
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
}
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
}
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken)
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
}
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
<% if (config.unwrapResponseData) { %>
}: FullRequestParams): Promise<T> => {
<% } else { %>
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
<% } %>
const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
},
signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
}
).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = (null as unknown) as T;
r.error = (null as unknown) as E;
const responseToParse = responseFormat ? response.clone() : response;
const data = !responseFormat ? r : await responseToParse[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
<% if (!config.disableThrowOnError) { %>
if (!response.ok) throw data;
<% } %>
<% if (config.unwrapResponseData) { %>
return data.data;
<% } else { %>
return data;
<% } %>
});
};
}

View File

@@ -0,0 +1,10 @@
<%
const { contract, utils } = it;
const { formatDescription, require, _ } = utils;
%>
export interface <%~ contract.name %> {
<% for (const field of contract.$content) { %>
<%~ includeFile('./object-field-jsdoc.ejs', { ...it, field }) %>
<%~ field.name %><%~ field.isRequired ? '' : '?' %>: <%~ field.value %><%~ field.isNullable ? ' | null' : ''%>;
<% } %>
}

View File

@@ -0,0 +1,28 @@
<%
const { field, utils } = it;
const { formatDescription, require, _ } = utils;
const comments = _.uniq(
_.compact([
field.title,
field.description,
field.deprecated && ` * @deprecated`,
!_.isUndefined(field.format) && `@format ${field.format}`,
!_.isUndefined(field.minimum) && `@min ${field.minimum}`,
!_.isUndefined(field.maximum) && `@max ${field.maximum}`,
!_.isUndefined(field.pattern) && `@pattern ${field.pattern}`,
!_.isUndefined(field.example) &&
`@example ${_.isObject(field.example) ? JSON.stringify(field.example) : field.example}`,
]).reduce((acc, comment) => [...acc, ...comment.split(/\n/g)], []),
);
%>
<% if (comments.length === 1) { %>
/** <%~ comments[0] %> */
<% } else if (comments.length) { %>
/**
<% comments.forEach(comment => { %>
* <%~ comment %>
<% }) %>
*/
<% } %>

View File

@@ -0,0 +1,140 @@
<%
const { utils, route, config } = 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 routeDocs = includeFile("./route-docs", { config, route, utils });
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 rawWrapperArgs = config.extractRequestParams ?
_.compact([
requestParams && {
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
optional: false,
type: getInlineParseContent(requestParams),
},
...(!requestParams ? pathParams : []),
payload,
requestConfigParam,
]) :
_.compact([
...pathParams,
query,
payload,
requestConfigParam,
])
const wrapperArgs = _
// Sort by optionality
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
// RequestParams["type"]
const requestContentKind = {
"JSON": "ContentType.Json",
"JSON_API": "ContentType.JsonApi",
"URL_ENCODED": "ContentType.UrlEncoded",
"FORM_DATA": "ContentType.FormData",
"TEXT": "ContentType.Text",
}
// RequestParams["format"]
const responseContentKind = {
"JSON": '"json"',
"IMAGE": '"blob"',
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
const describeReturnType = () => {
if (!config.toJS) return "";
switch(config.httpClientType) {
case HTTP_CLIENT.AXIOS: {
return `Promise<AxiosResponse<${type}>>`
}
default: {
return `Promise<HttpResponse<${type}, ${errorType}>`
}
}
}
const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
%>
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
<% if (isValidIdentifier(route.routeName.usage)) { %><%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ route.routeName.usage %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> =>
<%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({
path: `<%~ path %>`,
method: '<%~ _.upperCase(method) %>',
<%~ queryTmpl ? `query: ${queryTmpl},` : '' %>
<%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %>
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})<%~ route.namespace ? ',' : '' %>
<%
// Генерируем use* функцию для GET запросов
const isGetRequest = _.upperCase(method) === 'GET';
if (isGetRequest) {
const useMethodName = 'use' + _.upperFirst(route.routeName.usage);
const argsWithoutParams = rawWrapperArgs.filter(arg => arg.name !== requestConfigParam.name);
const useWrapperArgs = _
.sortBy(argsWithoutParams, [o => o.optional])
.map(argToTmpl)
.join(', ');
// Определяем обязательные параметры для проверки
const requiredArgs = argsWithoutParams.filter(arg => !arg.optional);
const requiredArgsNames = requiredArgs.map(arg => {
// Извлекаем имя из деструктуризации типа "{ id, ...query }"
const match = arg.name.match(/^\{\s*([^,}]+)/);
return match ? match[1].trim() : arg.name;
});
// Генерируем условие для проверки всех обязательных параметров
const hasRequiredArgs = requiredArgsNames.length > 0;
const conditionCheck = hasRequiredArgs
? requiredArgsNames.join(' && ')
: 'true';
%>
/**
* SWR hook для <%~ route.routeName.usage %>
<%~ routeDocs.lines %>
*/
<% if (isValidIdentifier(useMethodName)) { %><%~ useMethodName %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ useMethodName %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ useWrapperArgs %>) => {
return useSWR<<%~ type %>>(
<%~ conditionCheck %> ? `<%~ path %>` : null,
fetcher
);
}<%~ route.namespace ? ',' : '' %>
<% } %>

View File

@@ -0,0 +1,30 @@
<%
const { config, route, utils } = it;
const { _, formatDescription, fmtToJSDocLine, pascalCase, require } = utils;
const { raw, request, routeName } = route;
const jsDocDescription = raw.description ?
` * @description ${formatDescription(raw.description, true)}` :
fmtToJSDocLine('No description', { eol: false });
const jsDocLines = _.compact([
_.size(raw.tags) && ` * @tags ${raw.tags.join(", ")}`,
` * @name ${pascalCase(routeName.usage)}`,
raw.summary && ` * @summary ${raw.summary}`,
` * @request ${_.upperCase(request.method)}:${raw.route}`,
raw.deprecated && ` * @deprecated`,
routeName.duplicate && ` * @originalName ${routeName.original}`,
routeName.duplicate && ` * @duplicate`,
request.security && ` * @secure`,
...(config.generateResponses && raw.responsesTypes.length
? raw.responsesTypes.map(
({ type, status, description, isSuccess }) =>
` * @response \`${status}\` \`${_.replace(_.replace(type, /\/\*/g, "\\*"), /\*\//g, "*\\")}\` ${description}`,
)
: []),
]).map(str => str.trimEnd()).join("\n");
return {
description: jsDocDescription,
lines: jsDocLines,
}
%>

View File

@@ -0,0 +1,43 @@
<%
const { routeInfo, utils } = it;
const {
operationId,
method,
route,
moduleName,
responsesTypes,
description,
tags,
summary,
pathArgs,
} = routeInfo;
const { _, fmtToJSDocLine, require } = utils;
const methodAliases = {
get: (pathName, hasPathInserts) =>
_.camelCase(`${pathName}_${hasPathInserts ? "detail" : "list"}`),
post: (pathName, hasPathInserts) => _.camelCase(`${pathName}_create`),
put: (pathName, hasPathInserts) => _.camelCase(`${pathName}_update`),
patch: (pathName, hasPathInserts) => _.camelCase(`${pathName}_partial_update`),
delete: (pathName, hasPathInserts) => _.camelCase(`${pathName}_delete`),
};
const createCustomOperationId = (method, route, moduleName) => {
const hasPathInserts = /\{(\w){1,}\}$/g.test(route);
const splittedRouteBySlash = _.compact(_.replace(route, /\{(\w){1,}\}/g, "").split("/"));
const routeParts = (splittedRouteBySlash.length > 1
? splittedRouteBySlash.splice(1)
: splittedRouteBySlash
).join("_");
return routeParts.length > 3 && methodAliases[method]
? methodAliases[method](routeParts, hasPathInserts)
: _.camelCase(_.lowerCase(method) + "_" + [moduleName].join("_")) || "index";
};
if (operationId)
return _.camelCase(operationId);
if (route === "/")
return _.camelCase(`${_.lowerCase(method)}Root`);
return createCustomOperationId(method, route, moduleName);
%>

View File

@@ -0,0 +1,24 @@
<%
const { route, utils, config } = it;
const { _, pascalCase, require } = utils;
const { query, payload, pathParams, headers } = route.request;
const routeDocs = includeFile("./route-docs", { config, route, utils });
const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
const routeNamespace = pascalCase(route.routeName.usage);
%>
/**
<%~ routeDocs.description %>
<%~ routeDocs.lines %>
*/
export namespace <% if (isValidIdentifier(routeNamespace)) { %><%~ routeNamespace %><% } else { %>"<%~ routeNamespace %>"<% } %> {
export type RequestParams = <%~ (pathParams && pathParams.type) || '{}' %>;
export type RequestQuery = <%~ (query && query.type) || '{}' %>;
export type RequestBody = <%~ (payload && payload.type) || 'never' %>;
export type RequestHeaders = <%~ (headers && headers.type) || '{}' %>;
export type ResponseBody = <%~ route.response.type %>;
}

View File

@@ -0,0 +1,32 @@
<%
const { utils, config, routes, modelTypes } = it;
const { _, pascalCase } = utils;
const dataContracts = config.modular ? _.map(modelTypes, "name") : [];
%>
<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
<% } %>
<%
/* TODO: outOfModule, combined should be attributes of route, which will allow to avoid duplication of code */
%>
<% if (routes.outOfModule) { %>
<% for (const { routes: outOfModuleRoutes = [] } of routes.outOfModule) { %>
<% for (const route of outOfModuleRoutes) { %>
<%~ includeFile('./route-type.ejs', { ...it, route }) %>
<% } %>
<% } %>
<% } %>
<% if (routes.combined) { %>
<% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
export namespace <%~ pascalCase(moduleName) %> {
<% for (const route of combinedRoutes) { %>
<%~ includeFile('./route-type.ejs', { ...it, route }) %>
<% } %>
}
<% } %>
<% } %>

View File

@@ -0,0 +1,15 @@
<%
const { contract, utils } = it;
const { formatDescription, require, _ } = utils;
%>
<% if (contract.$content.length) { %>
export type <%~ contract.name %> = {
<% for (const field of contract.$content) { %>
<%~ includeFile('./object-field-jsdoc.ejs', { ...it, field }) %>
<%~ field.field %>;
<% } %>
}<%~ utils.isNeedToAddNull(contract) ? ' | null' : ''%>
<% } else { %>
export type <%~ contract.name %> = Record<string, any>;
<% } %>

66
src/utils/file.ts Normal file
View File

@@ -0,0 +1,66 @@
import { mkdir, writeFile, readFile, access } from 'fs/promises';
import { dirname, join } from 'path';
import { constants } from 'fs';
/**
* Проверка существования файла
*/
export async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Чтение файла как текст
*/
export async function readTextFile(path: string): Promise<string> {
return await readFile(path, 'utf-8');
}
/**
* Чтение JSON файла
*/
export async function readJsonFile<T = unknown>(path: string): Promise<T> {
// Проверяем, является ли путь URL
if (path.startsWith('http://') || path.startsWith('https://')) {
// Используем нативный fetch в Node.js 18+
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to fetch OpenAPI spec from ${path}: ${response.statusText}`);
}
const content = await response.text();
return JSON.parse(content) as T;
}
// Иначе читаем из локального файла
const content = await readTextFile(path);
return JSON.parse(content) as T;
}
/**
* Запись файла с автоматическим созданием директорий
*/
export async function writeFileWithDirs(path: string, content: string): Promise<void> {
const dir = dirname(path);
await mkdir(dir, { recursive: true });
await writeFile(path, content, 'utf-8');
}
/**
* Создание директории (рекурсивно)
*/
export async function ensureDir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}
/**
* Получение абсолютного пути
*/
export function resolvePath(path: string): string {
return join(process.cwd(), path);
}