diff --git a/README.md b/README.md
index 9151366..59c36ab 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,36 @@
# @gromlab/api-codegen
-CLI утилита для генерации TypeScript REST-клиента из OpenAPI спецификации.
+CLI для генерации typed TypeScript REST SDK из OpenAPI спецификации.
-## Использование
+Генератор рассчитан на сценарий, где REST API можно вынести в отдельный npm-пакет, а приложения будут собирать из него свои API-клиенты: полный клиент, частичный клиент или точечные вызовы отдельных операций.
+
+## Главная Идея
+
+По умолчанию `@gromlab/api-codegen` генерирует split-клиент.
+
+Каждый endpoint становится отдельной typed operation-функцией. Это позволяет импортировать только нужные методы и не тащить весь API в bundle приложения.
+
+Если нужен полный клиент, используйте `operationsTree`.
+
+Если нужен клиент только под конкретный экран, модуль или приложение, соберите дерево операций вручную.
+
+Если нужен максимально точечный вызов, импортируйте одну operation напрямую.
+
+## Быстрый Старт
```bash
-npx @gromlab/api-codegen -i -o [-n ] [--mode single|split|both]
+npx @gromlab/api-codegen -i ./openapi.json -o ./src/generated
```
-**Аргументы:**
-- `-i, --input ` - путь к OpenAPI файлу или URL
-- `-o, --output ` - директория для сохранения файлов
-- `-n, --name ` - имя монолитного файла без `.ts`
-- `--mode ` - режим генерации: `single`, `split`, `both` (по умолчанию `single`)
-- `--single-file` - устаревший алиас для `--mode single`
-
-## Примеры
+Для генерации из URL:
```bash
-# Локальный файл
-npx @gromlab/api-codegen -i ./openapi.json -o ./src/api
-
-# URL на спецификацию
-npx @gromlab/api-codegen -i https://httpbin.org/spec.json -o ./src/api
-
-# Монолитный файл с кастомным именем
-npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi
-
-# Разложенный tree-shaking friendly клиент
-npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --mode split
-
-# Монолит и разложенный клиент одновременно
-npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi --mode both
+npx @gromlab/api-codegen -i https://api.example.com/openapi.json -o ./src/generated
```
-## Структура вывода
+По умолчанию будет создан split-клиент.
-По умолчанию генерируется legacy монолитный клиент для обратной совместимости:
-
-```text
-generated/
-└── MyApi.ts
-```
-
-В режиме `split` генерируется tree-shaking friendly структура:
+## Что Генерируется
```text
generated/
@@ -51,39 +38,122 @@ generated/
├── data-contracts.ts
├── http-client.ts
├── index.ts
+├── operations-tree.ts
└── operations/
├── index.ts
├── get-users.ts
└── create-user.ts
```
-Каждый endpoint лежит в отдельном файле внутри `operations/`.
+Основные части:
-В режиме `both` генерируются оба варианта рядом.
+- `http-client.ts` - fetch-based HTTP клиент, настройки авторизации, headers, baseUrl и transport customization.
+- `data-contracts.ts` - TypeScript типы из OpenAPI schemas.
+- `operations/*.ts` - отдельная typed function на каждый endpoint.
+- `operations/index.ts` - barrel export всех операций.
+- `operations-tree.ts` - дерево всех операций для сборки полного API клиента.
+- `create-api-client.ts` - helper, который привязывает дерево операций к конкретному HTTP клиенту.
+- `index.ts` - основная входная точка generated SDK.
-## Пример Использования
+## Полный Клиент
-Для `single` режима:
+Если приложению нужен весь API, используйте `operationsTree`.
```typescript
-import { Api, HttpClient } from './generated/MyApi';
+import { createApiClient, HttpClient, operationsTree } from './generated';
-const http = new HttpClient({ baseUrl: 'https://api.example.com' });
-const api = new Api(http);
+// 1. Инициализация HTTP-клиента: baseUrl, headers, авторизация и transport-настройки.
+const http = new HttpClient({
+ baseUrl: 'https://api.example.com',
+});
+// 2. Создание API-клиента: привязываем все сгенерированные операции к HTTP-клиенту.
+const api = createApiClient(http, operationsTree);
+
+// 3. Использование API-клиента: вызываем методы из дерева операций.
const users = await api.users.getAll({});
+const createdUser = await api.users.create({ email, password });
```
-Для `split` режима:
+`operationsTree` намеренно лежит в отдельном файле и экспортируется отдельно. Импортируя его, вы явно выбираете полный клиент со всеми операциями.
+
+## Частичный Клиент
+
+Если приложению нужна только часть API, соберите клиент вручную.
```typescript
import { createApiClient, HttpClient } from './generated';
-import { getAll } from './generated/operations/get-all';
-import { create } from './generated/operations/create';
+import {
+ v1AdminPharmaciesCreate,
+ v1AdminPharmaciesList,
+ v1AdminPharmaciesProfileUpdate,
+} from './generated/operations';
const http = new HttpClient({
baseUrl: 'https://api.example.com',
- securityWorker: (securityData: { token: string } | null) => {
+});
+
+const api = createApiClient(http, {
+ pharmaciesAdmin: {
+ list: v1AdminPharmaciesList,
+ create: v1AdminPharmaciesCreate,
+ updateProfile: v1AdminPharmaciesProfileUpdate,
+ },
+});
+
+const pharmacies = await api.pharmaciesAdmin.list({});
+const createdPharmacy = await api.pharmaciesAdmin.create(payload);
+await api.pharmaciesAdmin.updateProfile({ id }, payload);
+```
+
+Так можно собрать отдельный клиент для админки, публичного сайта, мобильного приложения или отдельного feature-модуля.
+
+`operations/index.ts` реэкспортит все operation-функции, поэтому можно делать named imports из `./generated/operations`. В ESM-сборке такой импорт остается tree-shaking friendly: в клиенте используются только явно выбранные операции.
+
+Если OpenAPI большой и полный SDK занимает десятки тысяч строк, приложение не обязано тянуть весь набор методов в свой чанк. Можно собрать минимально рабочий API-клиент под конкретный сценарий.
+
+Если нужен максимально строгий контроль над тем, какой файл попадет в bundle, импортируйте operation напрямую из ее файла:
+
+```typescript
+import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
+```
+
+## Точечный Вызов Operation
+
+Если по какой-то причине нужно вызвать только одну операцию, можно не собирать API-клиент через `createApiClient` и вызвать operation напрямую.
+
+```typescript
+import { HttpClient } from './generated/http-client';
+import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
+
+const http = new HttpClient({
+ baseUrl: 'https://api.example.com',
+});
+
+const pharmacies = await v1AdminPharmaciesList(http, {});
+```
+
+Это полезно для разовых сценариев: health-check, bootstrap-запроса, утилитного скрипта или кода, где полноценный объект `api` не нужен.
+
+## Кастомный HTTP Клиент
+
+`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать авторизацию, headers, baseUrl и transport behavior.
+
+```typescript
+import { HttpClient } from './generated';
+
+type SecurityData = {
+ token: string;
+};
+
+const http = new HttpClient({
+ baseUrl: 'https://api.example.com',
+ baseApiParams: {
+ headers: {
+ 'X-App-Version': '1.0.0',
+ },
+ },
+ securityWorker: (securityData) => {
if (!securityData?.token) {
return undefined;
}
@@ -96,44 +166,144 @@ const http = new HttpClient({
},
});
-const api = createApiClient(http, {
- users: {
- getAll,
- create,
- },
-});
-
-const users = await api.users.getAll({});
-const createdUser = await api.users.create({ email, password });
+http.setSecurityData({ token: 'jwt-token' });
```
-Если нужен максимально точечный импорт, operation можно вызвать напрямую:
+Для отдельного вызова можно передать дополнительные request params последним аргументом operation.
```typescript
-import { HttpClient } from './generated/http-client';
-import { getAll } from './generated/operations/get-all';
-
-const http = new HttpClient();
-const users = await getAll(http, {});
+await api.users.getAll(
+ {},
+ {
+ headers: {
+ 'X-Request-Id': requestId,
+ },
+ },
+);
```
-## Разработка
+## API Как npm-Пакет
-### Сборка
+Типичный workflow:
+
+1. Backend публикует OpenAPI спецификацию.
+2. Отдельный пакет генерирует typed REST SDK в `src/generated`.
+3. Пакет экспортирует generated entrypoint.
+4. Приложения импортируют SDK и собирают нужные API-клиенты.
+
+Пример структуры пакета:
+
+```text
+packages/my-api/
+├── package.json
+└── src/
+ ├── generated/
+ └── index.ts
+```
+
+`src/index.ts`:
+
+```typescript
+export * from './generated';
+```
+
+Использование в приложении:
+
+```typescript
+import { createApiClient, HttpClient, operationsTree } from '@company/my-api';
+
+const http = new HttpClient({
+ baseUrl: 'https://api.example.com',
+});
+
+export const api = createApiClient(http, operationsTree);
+```
+
+Если приложение хочет выбрать только часть API:
+
+```typescript
+import { createApiClient, HttpClient } from '@company/my-api';
+import {
+ v1AdminPharmaciesList,
+ v1AdminPharmaciesProfileUpdate,
+} from '@company/my-api/operations';
+
+export const api = createApiClient(new HttpClient(), {
+ pharmaciesAdmin: {
+ list: v1AdminPharmaciesList,
+ updateProfile: v1AdminPharmaciesProfileUpdate,
+ },
+});
+```
+
+## Режимы Генерации
+
+### split
+
+Режим по умолчанию.
```bash
-bun run build
+api-codegen -i ./openapi.json -o ./src/generated
```
-### Тестирование
+Эквивалентно:
```bash
-bun test
-bun run test:unit
-bun run test:integration
+api-codegen -i ./openapi.json -o ./src/generated --mode split
```
-Подробная документация по тестированию в [`tests/README.md`](tests/README.md).
+Используйте этот режим для новых проектов, npm SDK пакетов и приложений, где важны модульность и tree-shaking.
+
+### single
+
+Legacy-режим, который генерирует один монолитный TypeScript файл.
+
+```bash
+api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
+```
+
+Результат:
+
+```text
+generated/
+└── MyApi.ts
+```
+
+Используйте `single`, если проект уже завязан на старый монолитный generated-клиент.
+
+## CLI
+
+```bash
+api-codegen -i -o [--mode split|single] [-n ]
+```
+
+Аргументы:
+
+- `-i, --input ` - путь к OpenAPI файлу или URL.
+- `-o, --output ` - директория для generated файлов.
+- `--mode ` - режим генерации: `split` или `single`. По умолчанию `split`.
+- `-n, --name ` - имя файла для `single` режима без расширения `.ts`.
+- `--single-file` - устаревший алиас для `--mode single`.
+
+## Примеры Команд
+
+Split из локального файла:
+
+```bash
+api-codegen -i ./openapi.json -o ./src/generated
+```
+
+Split из URL:
+
+```bash
+api-codegen -i https://api.example.com/openapi.json -o ./src/generated
+```
+
+Legacy single файл:
+
+```bash
+api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
+```
## Лицензия
diff --git a/package.json b/package.json
index fb5edfe..855740d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@gromlab/api-codegen",
- "version": "3.0.0",
+ "version": "4.0.0",
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
"type": "module",
"bin": {
diff --git a/src/cli.ts b/src/cli.ts
index 20f9144..195e0fe 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -33,7 +33,7 @@ program
.option('-i, --input ', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
.option('-o, --output ', 'Директория для сохранения сгенерированных файлов')
.option('-n, --name ', 'Имя монолитного клиента без расширения .ts')
- .option('--mode ', 'Режим генерации: single, split, both', 'single')
+ .option('--mode ', 'Режим генерации: split, single', 'split')
.option('--single-file', 'Устаревший алиас для --mode single')
.action(async (options) => {
try {
diff --git a/src/config.ts b/src/config.ts
index d67bea8..1c91097 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,4 +1,4 @@
-export type GeneratorMode = 'single' | 'split' | 'both';
+export type GeneratorMode = 'single' | 'split';
/**
* Конфигурация генератора API
@@ -8,7 +8,7 @@ export interface GeneratorConfig {
inputPath: string;
/** Путь для сохранения сгенерированных файлов */
outputPath: string;
- /** Имя сгенерированного файла (без расширения), используется в single/both режиме */
+ /** Имя сгенерированного файла (без расширения), используется в single режиме */
fileName?: string;
/** Режим генерации */
mode?: GeneratorMode;
@@ -28,8 +28,8 @@ export function validateConfig(config: Partial): config is Gene
errors.push('Не указана директория для генерации (--output)');
}
- if (config.mode && !['single', 'split', 'both'].includes(config.mode)) {
- errors.push('Некорректный режим генерации (--mode). Доступные значения: single, split, both');
+ if (config.mode && !['single', 'split'].includes(config.mode)) {
+ errors.push('Некорректный режим генерации (--mode). Доступные значения: split, single');
}
if (errors.length > 0) {
diff --git a/src/generator.ts b/src/generator.ts
index 3b21041..c8af9b8 100644
--- a/src/generator.ts
+++ b/src/generator.ts
@@ -38,6 +38,11 @@ type OperationFileInfo = {
fileName: string;
};
+type OperationTreeGroup = {
+ moduleName: string;
+ operations: OperationFileInfo[];
+};
+
const RESERVED_IDENTIFIERS = new Set([
'break',
'case',
@@ -204,6 +209,53 @@ function createHttpClientImport(content: string): string {
return imports.join('\n');
}
+function createObjectPropertyKey(name: string): string {
+ return JSON.stringify(name);
+}
+
+function createOperationTreePropertyName(operationFile: OperationFileInfo): string {
+ return operationFile.route.routeName.usage || operationFile.operationName;
+}
+
+function createOperationTreeContent(configuration: GenerateApiConfiguration, operationFiles: OperationFileInfo[]): string {
+ const operationByRoute = new Map(operationFiles.map((operationFile) => [operationFile.route, operationFile]));
+ const imports = operationFiles.map(
+ (operationFile) => `import { ${operationFile.operationName} } from "./operations/${operationFile.fileName}";`,
+ );
+ const outOfModuleOperations = (configuration.routes.outOfModule || [])
+ .map((route) => operationByRoute.get(route))
+ .filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile));
+ const operationTreeGroups: OperationTreeGroup[] = (configuration.routes.combined || []).map(({ moduleName, routes }) => ({
+ moduleName,
+ operations: (routes || [])
+ .map((route) => operationByRoute.get(route))
+ .filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile)),
+ }));
+ const lines = [
+ ...imports,
+ '',
+ 'export const operationsTree = {',
+ ];
+
+ for (const operationFile of outOfModuleOperations) {
+ lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
+ }
+
+ for (const { moduleName, operations } of operationTreeGroups) {
+ lines.push(` ${createObjectPropertyKey(moduleName)}: {`);
+
+ for (const operationFile of operations) {
+ lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
+ }
+
+ lines.push(' },');
+ }
+
+ lines.push('} as const;', '', 'export type OperationsTree = typeof operationsTree;');
+
+ return lines.join('\n');
+}
+
async function renderTemplateFile(
generatorOutput: GenerateApiOutput,
templatePath: string,
@@ -229,6 +281,7 @@ async function cleanTreeOutput(outputDir: string): Promise {
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 }),
+ rm(join(outputDir, 'operations-tree.ts'), { force: true }),
]);
}
@@ -319,9 +372,9 @@ export async function generate(config: GeneratorConfig): Promise {
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';
+ const mode = config.mode || 'split';
+ const shouldGenerateSingle = mode === 'single';
+ const shouldGenerateSplit = mode === 'split';
const baseGenerateOptions = {
...(isUrl ? { url } : { input: inputPath }),
@@ -477,6 +530,12 @@ export async function generate(config: GeneratorConfig): Promise {
operationExports.length ? operationExports.join('\n') : 'export {};',
);
+ await writeFormattedFile(
+ generatorOutput,
+ join(outputDir, 'operations-tree.ts'),
+ createOperationTreeContent(generatorOutput.configuration, operationFiles),
+ );
+
await writeFormattedFile(
generatorOutput,
join(outputDir, 'index.ts'),
@@ -486,6 +545,9 @@ export async function generate(config: GeneratorConfig): Promise {
'export { ContentType, HttpClient } from "./http-client";',
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";',
'export type * from "./data-contracts";',
+ 'export { operationsTree } from "./operations-tree";',
+ 'export type { OperationsTree } from "./operations-tree";',
+ 'export * as operations from "./operations";',
'export * from "./operations";',
].join('\n'),
);
diff --git a/tests/integration/e2e-generation.test.ts b/tests/integration/e2e-generation.test.ts
index 8663b05..53a4a63 100644
--- a/tests/integration/e2e-generation.test.ts
+++ b/tests/integration/e2e-generation.test.ts
@@ -63,7 +63,7 @@ describe('E2E Generation', () => {
// 3. Проверка импорта (компиляция TypeScript)
const testFile = join(tempDir, 'test-import.ts');
const testCode = `
- import { createApiClient, HttpClient } from '${generatedFile}';
+ import { createApiClient, HttpClient, operationsTree } from '${generatedFile}';
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
const api = createApiClient(new HttpClient(), {
@@ -71,6 +71,8 @@ describe('E2E Generation', () => {
getAll,
},
});
+ const fullApi = createApiClient(new HttpClient(), operationsTree);
+ fullApi.getAll;
console.log('Import successful');
`;
@@ -95,9 +97,10 @@ describe('E2E Generation', () => {
expect(exitCode).toBe(0);
- const generatedFile = join(outputPath, 'MinimalAPI.ts');
- const exists = await fileExists(generatedFile);
- expect(exists).toBe(true);
+ expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
test('повторная генерация (перезапись файлов)', async () => {
diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts
index 0824096..1ea4de1 100644
--- a/tests/unit/cli.test.ts
+++ b/tests/unit/cli.test.ts
@@ -42,9 +42,10 @@ describe('CLI', () => {
expect(exitCode).toBe(0);
- const generatedFile = join(outputPath, 'MinimalAPI.ts');
- const exists = await fileExists(generatedFile);
- expect(exists).toBe(true);
+ expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
test('должен генерировать монолит с кастомным именем файла', async () => {
@@ -60,6 +61,8 @@ describe('CLI', () => {
outputPath,
'--name',
customName,
+ '--mode',
+ 'single',
]);
expect(exitCode).toBe(0);
@@ -86,29 +89,30 @@ describe('CLI', () => {
expect(exitCode).toBe(0);
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
- test('должен генерировать both режим', async () => {
+ test('должен отклонять удаленный both режим', async () => {
const outputPath = join(tempDir, 'output');
- const { exitCode } = await execa('bun', [
- 'run',
- CLI_PATH,
- '--input',
- FIXTURES.MINIMAL,
- '--output',
- outputPath,
- '--name',
- 'CustomApi',
- '--mode',
- 'both',
- ]);
-
- expect(exitCode).toBe(0);
- expect(await fileExists(join(outputPath, 'CustomApi.ts'))).toBe(true);
- expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
- expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
+ try {
+ await execa('bun', [
+ 'run',
+ CLI_PATH,
+ '--input',
+ FIXTURES.MINIMAL,
+ '--output',
+ outputPath,
+ '--mode',
+ 'both',
+ ]);
+ throw new Error('Should have thrown');
+ } catch (error: any) {
+ expect(error.exitCode).not.toBe(0);
+ expect(error.stderr).toContain('Некорректный режим генерации');
+ expect(error.stderr).toContain('split, single');
+ }
}, 30000);
test('должен отображать версию с --version', async () => {
diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts
index fcab5ad..836c740 100644
--- a/tests/unit/config.test.ts
+++ b/tests/unit/config.test.ts
@@ -45,5 +45,15 @@ describe('config', () => {
expect(() => validateConfig(config)).toThrow('Ошибка конфигурации');
});
+
+ test('должен выбросить ошибку для удаленного both режима', () => {
+ const config = {
+ inputPath: './openapi.json',
+ outputPath: './output',
+ mode: 'both',
+ };
+
+ expect(() => validateConfig(config as Partial)).toThrow('Доступные значения: split, single');
+ });
});
});
diff --git a/tests/unit/generator.test.ts b/tests/unit/generator.test.ts
index 3738461..d2fe2df 100644
--- a/tests/unit/generator.test.ts
+++ b/tests/unit/generator.test.ts
@@ -21,7 +21,7 @@ describe('Generator', () => {
});
describe('корректная генерация', () => {
- test('должен создать выходной файл', async () => {
+ test('должен по умолчанию создать split структуру', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.MINIMAL,
@@ -31,9 +31,10 @@ describe('Generator', () => {
await generate(config);
- const generatedFile = join(outputPath, 'TestApi.ts');
- const exists = await fileExists(generatedFile);
- expect(exists).toBe(true);
+ expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
+ expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
test('должен добавлять русскую подпись в generated файлы', async () => {
@@ -42,6 +43,7 @@ describe('Generator', () => {
inputPath: FIXTURES.MINIMAL,
outputPath,
fileName: 'TestApi',
+ mode: 'single',
};
await generate(config);
@@ -91,12 +93,77 @@ describe('Generator', () => {
const indexFile = join(outputPath, 'index.ts');
const httpClientFile = join(outputPath, 'http-client.ts');
+ const operationsTreeFile = join(outputPath, 'operations-tree.ts');
const indexContent = await readTextFile(indexFile);
const httpClientContent = await readTextFile(httpClientFile);
+ const operationsTreeContent = await readTextFile(operationsTreeFile);
// Проверяем наличие основных элементов
expect(indexContent).toContain('createApiClient');
+ expect(indexContent).toContain('export { operationsTree } from "./operations-tree"');
+ expect(indexContent).toContain('export type { OperationsTree } from "./operations-tree"');
+ expect(indexContent).toContain('export * as operations from "./operations"');
expect(httpClientContent).toContain('export class HttpClient');
+ expect(operationsTreeContent).toContain('export const operationsTree');
+ expect(operationsTreeContent).toContain('export type OperationsTree');
+ }, 30000);
+
+ test('должен генерировать operationsTree с группировкой по тегам', async () => {
+ const inputPath = join(tempDir, 'tagged.json');
+ const outputPath = join(tempDir, 'output');
+
+ await Bun.write(inputPath, JSON.stringify({
+ openapi: '3.0.0',
+ info: {
+ title: 'Tagged API',
+ version: '1.0.0',
+ },
+ paths: {
+ '/users': {
+ get: {
+ tags: ['users'],
+ operationId: 'UserController_getAll',
+ responses: {
+ 200: { description: 'OK' },
+ },
+ },
+ post: {
+ tags: ['users'],
+ operationId: 'UserController_create',
+ responses: {
+ 201: { description: 'Created' },
+ },
+ },
+ },
+ '/auth/login': {
+ post: {
+ tags: ['auth'],
+ operationId: 'AuthController_login',
+ responses: {
+ 200: { description: 'OK' },
+ },
+ },
+ },
+ },
+ }));
+
+ await generate({
+ inputPath,
+ outputPath,
+ fileName: 'TaggedApi',
+ mode: 'split',
+ });
+
+ const content = await readTextFile(join(outputPath, 'operations-tree.ts'));
+
+ expect(content).toContain('import { create } from "./operations/create"');
+ expect(content).toContain('import { getAll } from "./operations/get-all"');
+ expect(content).toContain('import { login } from "./operations/login"');
+ expect(content).toContain('users: {');
+ expect(content).toContain('getAll: getAll');
+ expect(content).toContain('create: create');
+ expect(content).toContain('auth: {');
+ expect(content).toContain('login: login');
}, 30000);
test('должен обработать все HTTP методы', async () => {
@@ -123,6 +190,26 @@ describe('Generator', () => {
expect(content).toContain('deleteUsersId');
}, 30000);
+ test('должен генерировать индексный реэкспорт всех operations', async () => {
+ const outputPath = join(tempDir, 'output');
+ const config: GeneratorConfig = {
+ inputPath: FIXTURES.VALID,
+ outputPath,
+ fileName: 'TestApi',
+ mode: 'split',
+ };
+
+ await generate(config);
+
+ const content = await readTextFile(join(outputPath, 'operations', 'index.ts'));
+
+ expect(content).toContain('export { create } from "./create";');
+ expect(content).toContain('export { deleteUsersId } from "./delete-users-id";');
+ expect(content).toContain('export { getAll } from "./get-all";');
+ expect(content).toContain('export { getById } from "./get-by-id";');
+ expect(content).toContain('export { update } from "./update";');
+ }, 30000);
+
test('должен генерировать типы для request и response', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
@@ -235,7 +322,7 @@ describe('Generator', () => {
// Генерация должна пройти успешно
await generate(config);
- const generatedFile = join(outputPath, 'TestApi.ts');
+ const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile);
expect(exists).toBe(true);
}, 30000);
@@ -250,7 +337,7 @@ describe('Generator', () => {
await generate(config);
- const generatedFile = join(outputPath, 'TestApi.ts');
+ const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile);
expect(exists).toBe(true);
}, 30000);
@@ -285,7 +372,7 @@ describe('Generator', () => {
// Генерация должна пройти успешно даже с Unicode
await generate(config);
- const generatedFile = join(outputPath, 'TestApi.ts');
+ const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile);
expect(exists).toBe(true);
}, 30000);