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);