feat: добавить split-режим генерации REST-клиента
- добавлен режим генерации single, split и both - добавлены отдельные operation-файлы и createApiClient - удалена генерация SWR-хуков и зависимости React/SWR - обновлены CLI, шаблоны, примеры, документация и тесты - версия пакета повышена до 3.0.0
This commit is contained in:
33
src/cli.ts
33
src/cli.ts
@@ -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();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
501
src/generator.ts
501
src/generator.ts
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) { %>
|
||||
|
||||
44
src/templates/create-api-client.ejs
Normal file
44
src/templates/create-api-client.ejs
Normal 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>;
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
88
src/templates/operation.ejs
Normal file
88
src/templates/operation.ejs
Normal 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") %>,
|
||||
})
|
||||
@@ -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 ? ',' : '' %>
|
||||
<% } %>
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user