feat: добавить split-режим генерации REST-клиента

- добавлен режим генерации single, split и both
- добавлены отдельные operation-файлы и createApiClient
- удалена генерация SWR-хуков и зависимости React/SWR
- обновлены CLI, шаблоны, примеры, документация и тесты
- версия пакета повышена до 3.0.0
This commit is contained in:
2026-06-30 07:59:52 +03:00
parent 961c7f0ec1
commit bf340b3dbe
21 changed files with 1029 additions and 732 deletions

View File

@@ -15,14 +15,26 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')
const program = new Command();
const translateCommanderError = (message: string): string => {
return message
.replace(/^error:/, 'ошибка:')
.replace('unknown option', 'неизвестная опция')
.replace('too many arguments', 'слишком много аргументов')
.replace('option', 'опция');
};
program
.name('api-codegen')
.description('Generate TypeScript API client from OpenAPI specification')
.configureOutput({
writeErr: (message) => process.stderr.write(translateCommanderError(message)),
})
.description('Генерация TypeScript API клиента из OpenAPI спецификации')
.version(pkg.version)
.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)')
.option('--swr', 'Generate SWR hooks for React')
.option('-i, --input <path>', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
.option('-o, --output <path>', 'Директория для сохранения сгенерированных файлов')
.option('-n, --name <name>', 'Имя монолитного клиента без расширения .ts')
.option('--mode <mode>', 'Режим генерации: single, split, both', 'single')
.option('--single-file', 'Устаревший алиас для --mode single')
.action(async (options) => {
try {
// Создание конфигурации
@@ -30,7 +42,7 @@ program
inputPath: options.input,
outputPath: options.output,
fileName: options.name,
useSwr: options.swr || false,
mode: options.singleFile ? 'single' : options.mode,
};
// Валидация конфигурации
@@ -39,7 +51,7 @@ program
// Проверка существования входного файла (только для локальных файлов)
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`));
console.error(chalk.red(`\n❌ Ошибка: входной файл не найден: ${config.inputPath}\n`));
process.exit(1);
}
}
@@ -47,15 +59,12 @@ program
// Генерация API
await generate(config as GeneratorConfig);
console.log(chalk.green('\n✨ API client generated successfully!\n'));
console.log(chalk.green('\n✨ API клиент успешно сгенерирован!\n'));
} catch (error) {
console.error(chalk.red('\n❌ Error:'), error instanceof Error ? error.message : error);
console.error(chalk.red('\n❌ Ошибка:'), error instanceof Error ? error.message : error);
console.error();
process.exit(1);
}
});
program.parse();

View File

@@ -1,3 +1,5 @@
export type GeneratorMode = 'single' | 'split' | 'both';
/**
* Конфигурация генератора API
*/
@@ -6,10 +8,10 @@ export interface GeneratorConfig {
inputPath: string;
/** Путь для сохранения сгенерированных файлов */
outputPath: string;
/** Имя сгенерированного файла (без расширения) */
/** Имя сгенерированного файла (без расширения), используется в single/both режиме */
fileName?: string;
/** Генерировать SWR hooks для React */
useSwr?: boolean;
/** Режим генерации */
mode?: GeneratorMode;
}
/**
@@ -19,19 +21,20 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
const errors: string[] = [];
if (!config.inputPath) {
errors.push('Input path is required (--input)');
errors.push('Не указан путь к OpenAPI спецификации (--input)');
}
if (!config.outputPath) {
errors.push('Output path is required (--output)');
errors.push('Не указана директория для генерации (--output)');
}
if (config.mode && !['single', 'split', 'both'].includes(config.mode)) {
errors.push('Некорректный режим генерации (--mode). Доступные значения: single, split, both');
}
if (errors.length > 0) {
throw new Error(`Configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
throw new Error(`Ошибка конфигурации:\n${errors.map(e => ` - ${e}`).join('\n')}`);
}
return true;
}

View File

@@ -1,14 +1,251 @@
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
import {
generateApi as swaggerGenerateApi,
type GenerateApiConfiguration,
type GenerateApiOutput,
type ModelType,
type ParsedRoute,
} from 'swagger-typescript-api';
import { resolve, join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import type { GeneratorConfig } from './config.js';
import { ensureDir, readJsonFile } from './utils/file.js';
import { ensureDir, readJsonFile, readTextFile, writeFileWithDirs } from './utils/file.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const GENERATED_FILE_PREFIX = `/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/`;
type OperationFileInfo = {
route: ParsedRoute;
operationName: string;
fileName: string;
};
const RESERVED_IDENTIFIERS = new Set([
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'null',
'return',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'let',
'static',
'implements',
'interface',
'package',
'private',
'protected',
'public',
]);
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toWords(value: string): string[] {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^A-Za-z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
}
function toCamelCase(value: string): string {
const words = toWords(value);
if (!words.length) {
return '';
}
const [firstWord = '', ...restWords] = words;
return [
firstWord.toLowerCase(),
...restWords.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()),
].join('');
}
function toKebabCase(value: string): string {
return toWords(value).map((word) => word.toLowerCase()).join('-');
}
function createFallbackRouteName(route: ParsedRoute): string {
return toCamelCase(`${route.raw.method} ${route.raw.route}`) || 'operation';
}
function createSafeIdentifier(value: string, fallback: string): string {
const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
const safeIdentifier = identifier || fallback;
if (RESERVED_IDENTIFIERS.has(safeIdentifier)) {
const safeFallback = fallback.replace(/[^A-Za-z0-9_$]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
if (safeFallback && !RESERVED_IDENTIFIERS.has(safeFallback) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safeFallback)) {
return safeFallback;
}
return `${safeIdentifier}Operation`;
}
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safeIdentifier)) {
return safeIdentifier;
}
return `operation${safeIdentifier.charAt(0).toUpperCase()}${safeIdentifier.slice(1)}`;
}
function createUniqueName(baseName: string, usedNames: Set<string>): string {
let name = baseName;
let counter = 2;
while (usedNames.has(name)) {
name = `${baseName}${counter}`;
counter += 1;
}
usedNames.add(name);
return name;
}
function getAllRoutes(configuration: GenerateApiConfiguration): ParsedRoute[] {
return [
...(configuration.routes.outOfModule || []),
...(configuration.routes.combined || []).flatMap(({ routes }) => routes || []),
];
}
function createOperationFiles(routes: ParsedRoute[]): OperationFileInfo[] {
const usedOperationNames = new Set<string>();
const usedFileNames = new Set<string>();
return routes.map((route) => {
const fallbackRouteName = createFallbackRouteName(route);
const operationName = createUniqueName(
createSafeIdentifier(route.routeName.usage || fallbackRouteName, fallbackRouteName),
usedOperationNames,
);
const fileName = createUniqueName(toKebabCase(operationName) || toKebabCase(fallbackRouteName) || 'operation', usedFileNames);
return {
route,
operationName,
fileName,
};
});
}
function createDataContractsImport(content: string, modelTypes: ModelType[]): string {
const importedNames = modelTypes
.map(({ name }) => name)
.filter((name) => new RegExp(`\\b${escapeRegExp(name)}\\b`).test(content))
.sort((a, b) => a.localeCompare(b));
if (!importedNames.length) {
return '';
}
return `import type { ${importedNames.join(', ')} } from "../data-contracts";`;
}
function createHttpClientImport(content: string): string {
const imports = ['import type { ApiRequestClient, RequestParams } from "../http-client";'];
if (content.includes('ContentType.')) {
imports.unshift('import { ContentType } from "../http-client";');
}
return imports.join('\n');
}
async function renderTemplateFile(
generatorOutput: GenerateApiOutput,
templatePath: string,
data: unknown,
): Promise<string> {
const template = await readTextFile(templatePath);
return String(await generatorOutput.renderTemplate(template, data as Record<string, unknown>));
}
async function writeFormattedFile(
generatorOutput: GenerateApiOutput,
filePath: string,
content: string,
): Promise<void> {
const formattedContent = await generatorOutput.formatTSContent(`${GENERATED_FILE_PREFIX}\n\n${content}`);
await writeFileWithDirs(filePath, formattedContent);
}
async function cleanTreeOutput(outputDir: string): Promise<void> {
await Promise.all([
rm(join(outputDir, 'operations'), { recursive: true, force: true }),
rm(join(outputDir, 'index.ts'), { force: true }),
rm(join(outputDir, 'http-client.ts'), { force: true }),
rm(join(outputDir, 'data-contracts.ts'), { force: true }),
rm(join(outputDir, 'create-api-client.ts'), { force: true }),
]);
}
function translateGenerationErrorMessage(message: string): string {
return message
.replace(
'Unable to connect. Is the computer able to access the url?',
'Не удалось подключиться к URL. Проверьте доступность OpenAPI спецификации и сетевое подключение.',
)
.replace('fetch failed', 'не удалось выполнить сетевой запрос')
.replace('request timeout', 'истекло время ожидания запроса')
.replace('socket hang up', 'соединение было разорвано')
.replace('ECONNREFUSED', 'соединение отклонено')
.replace('ENOTFOUND', 'хост не найден')
.replace('ETIMEDOUT', 'истекло время ожидания соединения');
}
/**
* Генерация API клиента из OpenAPI спецификации
*/
@@ -32,10 +269,21 @@ export async function generate(config: GeneratorConfig): Promise<void> {
if (isUrl) {
url = config.inputPath;
// Загружаем спецификацию для получения info.title
const response = await fetch(url);
let response: Response;
try {
response = await fetch(url);
} catch (error) {
const details = error instanceof Error ? translateGenerationErrorMessage(error.message) : String(error);
throw new Error(
`Не удалось подключиться к OpenAPI спецификации: ${url}. ` +
`Проверьте доступность URL и сетевое подключение. Детали: ${details}`,
);
}
if (!response.ok) {
throw new Error(
`Failed to fetch OpenAPI spec from ${url}: ${response.status} ${response.statusText}`
`Не удалось загрузить OpenAPI спецификацию из ${url}: ${response.status} ${response.statusText}`
);
}
const text = await response.text();
@@ -43,8 +291,8 @@ export async function generate(config: GeneratorConfig): Promise<void> {
spec = JSON.parse(text);
} catch {
throw new Error(
`Failed to parse OpenAPI spec from ${url} as JSON. ` +
`Response starts with: "${text.slice(0, 50)}..."`
`Не удалось распарсить OpenAPI спецификацию из ${url} как JSON. ` +
`Начало ответа: "${text.slice(0, 50)}..."`
);
}
} else {
@@ -64,101 +312,198 @@ export async function generate(config: GeneratorConfig): Promise<void> {
// Проверяем, что директория с шаблонами существует
if (!existsSync(templatesPath)) {
throw new Error(
`Templates directory not found: ${templatesPath}. ` +
`Make sure the package is built correctly (run "bun run build").`
`Директория шаблонов не найдена: ${templatesPath}. ` +
`Проверьте, что пакет собран корректно (bun run build).`
);
}
const outputDir = resolve(config.outputPath);
const outputFileName = `${fileName}.ts`;
const mode = config.mode || 'single';
const shouldGenerateSingle = mode === 'single' || mode === 'both';
const shouldGenerateSplit = mode === 'split' || mode === 'both';
try {
const { files } = await swaggerGenerateApi({
...(isUrl ? { url } : { input: inputPath }),
output: outputDir,
fileName: outputFileName,
httpClientType: 'fetch',
modular: false,
templates: templatesPath,
generateClient: true,
const baseGenerateOptions = {
...(isUrl ? { url } : { input: inputPath }),
httpClientType: 'fetch',
modular: false,
templates: templatesPath,
generateClient: true,
extractRequestParams: true,
extractRequestBody: true,
extractEnums: true,
cleanOutput: false,
singleHttpClient: true,
unwrapResponseData: true,
defaultResponseAsSuccess: true,
enumNamesAsValues: false,
moduleNameFirstTag: true,
extraTemplates: [],
addReadonly: false,
sortTypes: false,
sortRoutes: false,
extractResponseError: false,
fixInvalidEnumKeyPrefix: 'KEY',
silent: true,
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: unknown, templateRouteName: string) => {
// Убираем префикс с названием контроллера из имени метода
// Например: 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: unknown) => {
// Получаем дефолтный baseUrl из OpenAPI спецификации
const typedConfiguration = configuration as { apiConfig?: { baseUrl?: string } };
const apiConfig = typedConfiguration.apiConfig || {};
const defaultBaseUrl = spec?.servers?.[0]?.url || apiConfig.baseUrl || '';
typedConfiguration.apiConfig = typedConfiguration.apiConfig || {};
typedConfiguration.apiConfig.baseUrl = defaultBaseUrl;
return typedConfiguration;
},
},
};
const generateSingleFile = async () => {
const generatorOutput = await swaggerGenerateApi({
...baseGenerateOptions,
generateRouteTypes: true,
extractRequestParams: true,
extractRequestBody: true,
extractEnums: true,
cleanOutput: false,
singleHttpClient: true,
unwrapResponseData: true,
defaultResponseAsSuccess: true,
enumNamesAsValues: false,
moduleNameFirstTag: true,
generateUnionEnums: false,
extraTemplates: [],
addReadonly: false,
sortTypes: false,
sortRoutes: false,
extractResponseError: false,
fixInvalidEnumKeyPrefix: 'KEY',
silent: true,
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) => {
// Получаем дефолтный baseUrl из OpenAPI спецификации
const apiConfig = (configuration as any).apiConfig || {};
const defaultBaseUrl = spec?.servers?.[0]?.url || apiConfig.baseUrl || '';
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
(configuration as any).apiConfig.baseUrl = defaultBaseUrl;
// Передаем флаг useSwr в шаблоны
(configuration as any).useSwr = config.useSwr || false;
return configuration;
},
},
});
output: false,
fileName: outputFileName,
} as Parameters<typeof swaggerGenerateApi>[0]) as unknown as GenerateApiOutput;
const { files } = generatorOutput;
// Проверяем, что файлы были сгенерированы
if (!files || files.length === 0) {
throw new Error(
'Generation completed but no files were produced. ' +
'Check that the OpenAPI specification is valid.'
'Генерация завершилась, но файлы не были созданы. ' +
'Проверьте корректность OpenAPI спецификации.'
);
}
const singleFile = files.find((file) => `${file.fileName}${file.fileExtension}` === outputFileName) || files[0];
if (!singleFile) {
throw new Error('Генерация завершилась, но монолитный файл не был найден.');
}
await writeFormattedFile(generatorOutput, join(outputDir, outputFileName), singleFile.fileContent);
// Проверяем, что выходной файл существует на диске
const outputFilePath = join(outputDir, outputFileName);
if (!existsSync(outputFilePath)) {
throw new Error(
`Generation completed but output file was not created: ${outputFilePath}`
`Генерация завершилась, но выходной файл не был создан: ${outputFilePath}`
);
}
};
const generateSplitClient = async () => {
const generatorOutput = await swaggerGenerateApi({
...baseGenerateOptions,
generateRouteTypes: false,
generateUnionEnums: true,
output: false,
fileName: 'index.ts',
} as Parameters<typeof swaggerGenerateApi>[0]) as unknown as GenerateApiOutput;
await cleanTreeOutput(outputDir);
await writeFormattedFile(
generatorOutput,
join(outputDir, 'data-contracts.ts'),
await renderTemplateFile(generatorOutput, join(templatesPath, 'data-contracts.ejs'), generatorOutput.configuration),
);
await writeFormattedFile(
generatorOutput,
join(outputDir, 'http-client.ts'),
await renderTemplateFile(generatorOutput, join(templatesPath, 'http-client.ejs'), generatorOutput.configuration),
);
await writeFormattedFile(
generatorOutput,
join(outputDir, 'create-api-client.ts'),
await renderTemplateFile(generatorOutput, join(templatesPath, 'create-api-client.ejs'), generatorOutput.configuration),
);
const operationTemplatePath = join(templatesPath, 'operation.ejs');
const operationFiles = createOperationFiles(getAllRoutes(generatorOutput.configuration));
const operationExports: string[] = [];
for (const operationFile of operationFiles) {
let operationContent = await renderTemplateFile(generatorOutput, operationTemplatePath, {
...generatorOutput.configuration,
route: operationFile.route,
operationName: operationFile.operationName,
});
operationContent = operationContent
.replace('__HTTP_CLIENT_IMPORTS__', createHttpClientImport(operationContent))
.replace('__DATA_CONTRACT_IMPORTS__', createDataContractsImport(operationContent, generatorOutput.configuration.modelTypes));
await writeFormattedFile(
generatorOutput,
join(outputDir, 'operations', `${operationFile.fileName}.ts`),
operationContent,
);
operationExports.push(`export { ${operationFile.operationName} } from "./${operationFile.fileName}";`);
}
await writeFormattedFile(
generatorOutput,
join(outputDir, 'operations', 'index.ts'),
operationExports.length ? operationExports.join('\n') : 'export {};',
);
await writeFormattedFile(
generatorOutput,
join(outputDir, 'index.ts'),
[
'export { createApiClient } from "./create-api-client";',
'export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";',
'export { ContentType, HttpClient } from "./http-client";',
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";',
'export type * from "./data-contracts";',
'export * from "./operations";',
].join('\n'),
);
};
try {
if (shouldGenerateSingle) {
await generateSingleFile();
}
if (shouldGenerateSplit) {
await generateSplitClient();
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('Generation completed')) {
if (error instanceof Error && error.message.startsWith('Генерация завершилась')) {
throw error;
}
throw new Error(`Generation failed: ${error instanceof Error ? error.message : error}`);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Ошибка генерации: ${translateGenerationErrorMessage(message)}`);
}
}

View File

@@ -26,10 +26,6 @@ const descriptionLines = _.compact([
%>
<% if (config.useSwr) { %>
import useSWR from "swr";
<% } %>
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
<% if (descriptionLines.length) { %>

View File

@@ -0,0 +1,44 @@
import type { ApiRequestClient } from "./http-client";
export type ApiOperation<TClient extends ApiRequestClient = ApiRequestClient> = (
client: TClient,
...args: any[]
) => any;
export type ApiTree<TClient extends ApiRequestClient = ApiRequestClient> = {
readonly [key: string]: ApiOperation<TClient> | ApiTree<TClient>;
};
export type BoundApi<TTree, TClient extends ApiRequestClient> = {
readonly [K in keyof TTree]: TTree[K] extends (
client: TClient,
...args: infer Args
) => infer Result
? (...args: Args) => Result
: TTree[K] extends ApiTree<TClient>
? BoundApi<TTree[K], TClient>
: never;
};
export const createApiClient = <
TClient extends ApiRequestClient,
const TTree extends ApiTree<TClient>,
>(
client: TClient,
tree: TTree,
): BoundApi<TTree, TClient> => {
const bindNode = (node: ApiOperation<TClient> | ApiTree<TClient>): unknown => {
if (typeof node === "function") {
return (...args: unknown[]) => node(client, ...args);
}
return Object.fromEntries(
Object.entries(node).map(([key, value]) => [
key,
bindNode(value as ApiOperation<TClient> | ApiTree<TClient>),
]),
);
};
return bindNode(tree) as BoundApi<TTree, TClient>;
};

View File

@@ -17,6 +17,10 @@ const buildGenerics = (contract) => {
const dataContractTemplates = {
enum: (contract) => {
if (config.generateUnionEnums) {
return `type ${contract.name} = ${_.map(contract.$content, ({ value }) => value).join(" | ")}`;
}
return `enum ${contract.name} {\r\n${contract.content} \r\n }`;
},
interface: (contract) => {

View File

@@ -3,23 +3,6 @@ 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">;
@@ -44,6 +27,9 @@ export interface FullRequestParams extends Omit<RequestInit, "body"> {
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">
export interface ApiRequestClient {
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
}
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
@@ -57,7 +43,7 @@ export interface HttpResponse<D extends unknown, E extends unknown = unknown> ex
error: E;
}
type CancelToken = Symbol | string | number;
export type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
@@ -67,7 +53,7 @@ export enum ContentType {
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
public baseUrl: string = "<%~ baseUrl %>";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];

View File

@@ -0,0 +1,88 @@
<%
const { utils, route, config, operationName } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent } = utils;
const { parameters, path, method, payload, query, security, requestParams } = route.request;
const { type, errorType } = 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 = _
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
const requestContentKind = {
"JSON": "ContentType.Json",
"JSON_API": "ContentType.JsonApi",
"URL_ENCODED": "ContentType.UrlEncoded",
"FORM_DATA": "ContentType.FormData",
"TEXT": "ContentType.Text",
}
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;
%>
__HTTP_CLIENT_IMPORTS__
__DATA_CONTRACT_IMPORTS__
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
export const <%~ operationName %> = (http: ApiRequestClient, <%~ wrapperArgs %>) =>
http.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") %>,
})

View File

@@ -101,40 +101,3 @@ const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})<%~ route.namespace ? ',' : '' %>
<%
// Генерируем use* функцию для GET запросов (только если включен флаг useSwr)
const isGetRequest = _.upperCase(method) === 'GET';
if (config.useSwr && 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

@@ -30,7 +30,7 @@ export async function readJsonFile<T = unknown>(path: string): Promise<T> {
// Используем нативный fetch в Node.js 18+
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to fetch OpenAPI spec from ${path}: ${response.statusText}`);
throw new Error(`Не удалось загрузить OpenAPI спецификацию из ${path}: ${response.statusText}`);
}
const content = await response.text();
return JSON.parse(content) as T;
@@ -65,4 +65,3 @@ export function resolvePath(path: string): string {
}