diff --git a/AGENTS.md b/AGENTS.md index 0c4811a..041ca3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,13 @@ CLI утилита для автоматической генерации TypeSc - Чтение OpenAPI спецификации - Применение кастомных шаблонов EJS - Генерация TypeScript кода -3. **Выходные данные**: 3 файла в указанной директории: - - `{FileName}.ts` - API endpoints с методами +3. **Выходные данные по умолчанию**: legacy монолитный файл `{FileName}.ts` +4. **Выходные данные в `split` режиме**: tree-shaking friendly структура: - `http-client.ts` - HTTP клиент с настройками - `data-contracts.ts` - TypeScript типы + - `create-api-client.ts` - helper для привязки выбранного графа методов к клиенту + - `operations/*.ts` - один endpoint на файл + - `index.ts` - barrel exports ## Ключевые особенности @@ -51,13 +54,15 @@ CLI утилита для автоматической генерации TypeSc ## Использование ```bash -api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>] +api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>] [--mode single|split|both] ``` ### Аргументы - `-i, --input` - путь к OpenAPI файлу (локальный или URL) - `-o, --output` - директория для сохранения -- `-n, --name` - опциональное имя файла (по умолчанию из `spec.info.title`) +- `-n, --name` - имя монолитного файла без `.ts` +- `--mode` - режим генерации: `single` (по умолчанию), `split`, `both` +- `--single-file` - устаревший алиас для `--mode single` ## Структура проекта @@ -68,6 +73,8 @@ src/ ├── generator.ts # Основная логика генерации ├── templates/ # EJS шаблоны │ ├── api.ejs +│ ├── operation.ejs +│ ├── create-api-client.ejs │ ├── http-client.ejs │ ├── data-contracts.ejs │ └── ... @@ -84,16 +91,24 @@ src/ - `extractRequestParams: true` - извлечение параметров запросов - `extractRequestBody: true` - извлечение тел запросов - `extractEnums: true` - извлечение enum типов +- `generateUnionEnums: true` в `split` режиме - enum схемы генерируются как type union без runtime-кода ## Примеры использования сгенерированного кода ```typescript -import { Api, HttpClient } from './Api'; +import { createApiClient, HttpClient } from './generated'; +import { getProfile } from './generated/operations/get-profile'; +import { login } from './generated/operations/login'; const httpClient = new HttpClient(); httpClient.setSecurityData({ token: 'jwt-token' }); -const api = new Api(httpClient); +const api = createApiClient(httpClient, { + auth: { + getProfile, + login, + }, +}); // Вызов API методов const user = await api.auth.getProfile(); @@ -104,4 +119,4 @@ const result = await api.auth.login({ email, password }); 1. **Шаблоны** - можно модифицировать EJS шаблоны в `src/templates/` 2. **Хуки генератора** - можно добавить новые хуки в [`hooks`](src/generator.ts:75) -3. **Конфигурация** - можно расширить [`GeneratorConfig`](src/config.ts:4) для новых опций \ No newline at end of file +3. **Конфигурация** - можно расширить [`GeneratorConfig`](src/config.ts:4) для новых опций diff --git a/README.md b/README.md index 6f81fa2..9151366 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # @gromlab/api-codegen -CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. +CLI утилита для генерации TypeScript REST-клиента из OpenAPI спецификации. ## Использование ```bash -npx @gromlab/api-codegen -i -o [-n ] [--swr] +npx @gromlab/api-codegen -i -o [-n ] [--mode single|split|both] ``` **Аргументы:** -- `-i, --input ` - Путь к OpenAPI файлу (локальный файл или URL) -- `-o, --output ` - Директория для сохранения файлов -- `-n, --name ` - Имя сгенерированного файла (опционально, по умолчанию из `spec.info.title`) -- `--swr` - Генерировать SWR хуки для React +- `-i, --input ` - путь к OpenAPI файлу или URL +- `-o, --output ` - директория для сохранения файлов +- `-n, --name ` - имя монолитного файла без `.ts` +- `--mode ` - режим генерации: `single`, `split`, `both` (по умолчанию `single`) +- `--single-file` - устаревший алиас для `--mode single` -**Примеры:** +## Примеры ```bash # Локальный файл @@ -23,33 +24,103 @@ 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 -# С генерацией SWR хуков -npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --swr +# Разложенный 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 ``` -## Пример использования сгенерированного кода +## Структура вывода + +По умолчанию генерируется legacy монолитный клиент для обратной совместимости: + +```text +generated/ +└── MyApi.ts +``` + +В режиме `split` генерируется tree-shaking friendly структура: + +```text +generated/ +├── create-api-client.ts +├── data-contracts.ts +├── http-client.ts +├── index.ts +└── operations/ + ├── index.ts + ├── get-users.ts + └── create-user.ts +``` + +Каждый endpoint лежит в отдельном файле внутри `operations/`. + +В режиме `both` генерируются оба варианта рядом. + +## Пример Использования + +Для `single` режима: ```typescript -import { Api, HttpClient } from './src/api/MyApi'; +import { Api, HttpClient } from './generated/MyApi'; -const httpClient = new HttpClient(); -httpClient.setSecurityData({ token: 'jwt-token' }); +const http = new HttpClient({ baseUrl: 'https://api.example.com' }); +const api = new Api(http); -const api = new Api(httpClient); +const users = await api.users.getAll({}); +``` -// GET запрос -const user = await api.auth.getProfile(); +Для `split` режима: -// POST запрос -const result = await api.auth.login({ email, password }); +```typescript +import { createApiClient, HttpClient } from './generated'; +import { getAll } from './generated/operations/get-all'; +import { create } from './generated/operations/create'; + +const http = new HttpClient({ + baseUrl: 'https://api.example.com', + securityWorker: (securityData: { token: string } | null) => { + if (!securityData?.token) { + return undefined; + } + + return { + headers: { + Authorization: `Bearer ${securityData.token}`, + }, + }; + }, +}); + +const api = createApiClient(http, { + users: { + getAll, + create, + }, +}); + +const users = await api.users.getAll({}); +const createdUser = await api.users.create({ email, password }); +``` + +Если нужен максимально точечный импорт, operation можно вызвать напрямую: + +```typescript +import { HttpClient } from './generated/http-client'; +import { getAll } from './generated/operations/get-all'; + +const http = new HttpClient(); +const users = await getAll(http, {}); ``` ## Разработка ### Сборка + ```bash bun run build ``` @@ -57,20 +128,9 @@ bun run build ### Тестирование ```bash -# Все тесты bun test - -# Юнит тесты bun run test:unit - -# Интеграционные тесты bun run test:integration - -# Watch режим -bun run test:watch - -# С coverage -bun run test:coverage ``` Подробная документация по тестированию в [`tests/README.md`](tests/README.md). diff --git a/bun.lock b/bun.lock index fffebd7..f0dedc2 100644 --- a/bun.lock +++ b/bun.lock @@ -17,12 +17,9 @@ "@types/ejs": "^3.1.5", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", - "@types/react": "^18.3.0", "@types/tmp": "^0.2.6", "execa": "^8.0.0", "msw": "^2.0.0", - "react": "^18.3.0", - "swr": "^2.3.0", "tmp": "^0.2.1", }, "peerDependencies": { @@ -125,8 +122,6 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -175,14 +170,10 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], @@ -239,8 +230,6 @@ "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="], @@ -283,8 +272,6 @@ "swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="], - "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], - "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], @@ -305,8 +292,6 @@ "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], diff --git a/example-hooks.tsx b/example-hooks.tsx deleted file mode 100644 index 9423b09..0000000 --- a/example-hooks.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Примеры использования сгенерированных use* функций - * с SWR и React Query - */ - -import { Api, HttpClient } from './output/Api'; -import useSWR from 'swr'; -import { useQuery } from '@tanstack/react-query'; - -// ============================================ -// НАСТРОЙКА API КЛИЕНТА -// ============================================ - -const httpClient = new HttpClient({ - baseUrl: 'https://cdn.example.com', -}); - -// Устанавливаем токен (например, из localStorage) -if (typeof window !== 'undefined') { - const token = localStorage.getItem('auth_token'); - if (token) { - httpClient.setSecurityData({ token }); - } -} - -const api = new Api(httpClient); - -// ============================================ -// ПРИМЕР 1: ИСПОЛЬЗОВАНИЕ С SWR -// ============================================ - -// Простой GET запрос без параметров -function UserProfile() { - const profileConfig = api.auth.useGetProfile(); - - const { data, error, isLoading } = useSWR( - profileConfig.path, // Ключ для кеша - () => api.auth.getProfile() // Функция для загрузки данных - ); - - if (isLoading) return
Загрузка...
; - if (error) return
Ошибка: {error.message}
; - if (!data) return null; - - return ( -
-

Профиль пользователя

-

Email: {data.email}

-

Имя: {data.firstName} {data.lastName}

-

Email подтверждён: {data.isEmailVerified ? 'Да' : 'Нет'}

-
- ); -} - -// GET запрос с параметрами -function ProjectDetails({ projectId }: { projectId: string }) { - const projectConfig = api.projects.useFindOne({ id: projectId }); - - const { data: project, error, isLoading } = useSWR( - [projectConfig.path, projectId], // Составной ключ - () => api.projects.findOne({ id: projectId }) - ); - - if (isLoading) return
Загрузка проекта...
; - if (error) return
Ошибка: {error.message}
; - if (!project) return null; - - return ( -
-

{project.name}

-

{project.description}

-

Bucket: {project.s3Bucket}

-

Region: {project.s3Region}

-
- ); -} - -// Список с автоматической ревалидацией -function ProjectsList() { - const projectsConfig = api.projects.useFindAll(); - - const { data: projects, error, isLoading, mutate } = useSWR( - projectsConfig.path, - () => api.projects.findAll(), - { - refreshInterval: 5000, // Обновлять каждые 5 секунд - revalidateOnFocus: true, // Обновлять при фокусе на окно - } - ); - - const handleCreateProject = async () => { - await api.projects.create({ - name: 'Новый проект', - description: 'Описание', - }); - - // Обновляем список - mutate(); - }; - - if (isLoading) return
Загрузка списка...
; - if (error) return
Ошибка: {error.message}
; - - return ( -
-

Мои проекты ({projects?.length || 0})

- -
    - {projects?.map((project) => ( -
  • - {project.name} - {project.slug} -
  • - ))} -
-
- ); -} - -// ============================================ -// ПРИМЕР 2: ИСПОЛЬЗОВАНИЕ С REACT QUERY -// ============================================ - -function UserProfileWithReactQuery() { - const profileConfig = api.auth.useGetProfile(); - - const { data, error, isLoading } = useQuery({ - queryKey: [profileConfig.path], - queryFn: () => api.auth.getProfile(), - }); - - if (isLoading) return
Загрузка...
; - if (error) return
Ошибка
; - - return ( -
-

{data?.firstName} {data?.lastName}

-

{data?.email}

-
- ); -} - -function ProjectsListWithReactQuery() { - const projectsConfig = api.projects.useFindAll(); - - const { data: projects, isLoading } = useQuery({ - queryKey: [projectsConfig.path], - queryFn: () => api.projects.findAll(), - staleTime: 5 * 60 * 1000, // 5 минут - refetchInterval: 30000, // Обновлять каждые 30 секунд - }); - - if (isLoading) return
Загрузка...
; - - return ( -
-

Проекты

- {projects?.map((p) => ( -
{p.name}
- ))} -
- ); -} - -// ============================================ -// ПРИМЕР 3: УСЛОВНАЯ ЗАГРУЗКА -// ============================================ - -function ConditionalProfile({ userId }: { userId?: string }) { - const profileConfig = api.auth.useGetProfile(); - - const { data } = useSWR( - // Загружаем только если есть userId - userId ? profileConfig.path : null, - () => api.auth.getProfile() - ); - - return data ?
{data.email}
: null; -} - -// ============================================ -// ПРИМЕР 4: ЗАВИСИМЫЕ ЗАПРОСЫ -// ============================================ - -function DependentQueries() { - // Сначала получаем список проектов - const projectsConfig = api.projects.useFindAll(); - const { data: projects } = useSWR( - projectsConfig.path, - () => api.projects.findAll() - ); - - // Затем получаем первый проект (только когда список загружен) - const firstProjectId = projects?.[0]?.id; - const projectConfig = firstProjectId - ? api.projects.useFindOne({ id: firstProjectId }) - : null; - - const { data: firstProject } = useSWR( - projectConfig ? [projectConfig.path, firstProjectId] : null, - () => firstProjectId ? api.projects.findOne({ id: firstProjectId }) : null - ); - - return ( -
-

Всего проектов: {projects?.length || 0}

- {firstProject && ( -
-

Первый проект:

-

{firstProject.name}

-
- )} -
- ); -} - -// ============================================ -// ПРИМЕР 5: СОЗДАНИЕ ХУКА-ОБЁРТКИ -// ============================================ - -// Универсальный хук для всех GET запросов -function useApiQuery( - useConfigFn: () => { path: string; method: 'GET'; secure?: boolean }, - apiFn: () => Promise, - options?: Parameters[2] -) { - const config = useConfigFn(); - return useSWR(config.path, apiFn, options); -} - -// Использование -function MyComponent() { - const { data, error, isLoading } = useApiQuery( - api.auth.useGetProfile, - api.auth.getProfile, - { revalidateOnFocus: true } - ); - - // ... -} - -// ============================================ -// ПРИМЕР 6: ПОЛЬЗОВАТЕЛЬСКИЙ FETCHER ДЛЯ SWR -// ============================================ - -// Создаём универсальный fetcher -const apiFetcher = async (key: string | string[]) => { - const path = Array.isArray(key) ? key[0] : key; - - // Находим соответствующий метод API - // В реальном приложении можно использовать маппинг - return api.auth.getProfile(); // пример -}; - -// SWRConfig для всего приложения -import { SWRConfig } from 'swr'; - -function App() { - return ( - - - - - ); -} - -export { - UserProfile, - ProjectDetails, - ProjectsList, - UserProfileWithReactQuery, - ProjectsListWithReactQuery, - ConditionalProfile, - DependentQueries, - useApiQuery, -}; - diff --git a/example.ts b/example.ts index 3a7d9cf..5fcb250 100644 --- a/example.ts +++ b/example.ts @@ -1,136 +1,72 @@ /** - * Пример использования сгенерированного API клиента + * Пример использования сгенерированного tree-shaking friendly REST-клиента. */ +// @ts-nocheck -import { Api, HttpClient } from './output/Api'; +import { createApiClient, HttpClient } from './output'; +import { getProfile } from './output/operations/get-profile'; +import { login } from './output/operations/login'; +import { register } from './output/operations/register'; -// 1. Создание HTTP клиента с базовыми настройками -const httpClient = new HttpClient({ - baseUrl: 'https://cdn.example.com', // Базовый URL (уже установлен при генерации) +type SecurityData = { + token: string; +}; + +const httpClient = new HttpClient({ + baseUrl: 'https://api.example.com', baseApiParams: { headers: { 'Content-Type': 'application/json', }, }, + securityWorker: (securityData) => { + if (!securityData?.token) { + return undefined; + } + + return { + headers: { + Authorization: `Bearer ${securityData.token}`, + }, + }; + }, }); -// 2. Создание API клиента -const api = new Api(httpClient); - -// 3. Пример использования +const api = createApiClient(httpClient, { + auth: { + register, + login, + getProfile, + }, +}); async function registerUser() { - try { - const result = await api.auth.register({ - email: 'user@example.com', - password: 'SecurePassword123', - firstName: 'Иван', - lastName: 'Иванов', - }); + const result = await api.auth.register({ + email: 'user@example.com', + password: 'SecurePassword123', + firstName: 'Иван', + lastName: 'Иванов', + }); - console.log('Пользователь зарегистрирован:', result); - return result; - } catch (error) { - console.error('Ошибка регистрации:', error); - throw error; - } + console.log('Пользователь зарегистрирован:', result); + return result; } async function loginUser() { - try { - const result = await api.auth.login({ - email: 'user@example.com', - password: 'SecurePassword123', - }); + const result = await api.auth.login({ + email: 'user@example.com', + password: 'SecurePassword123', + }); - console.log('Авторизация успешна'); - - // Сохраняем токен для последующих запросов - httpClient.setSecurityData({ token: result.access_token }); - - return result; - } catch (error) { - console.error('Ошибка авторизации:', error); - throw error; - } + httpClient.setSecurityData({ token: result.access_token }); + + return result; } async function getUserProfile() { - try { - const profile = await api.auth.getProfile(); - console.log('Профиль пользователя:', profile); - return profile; - } catch (error) { - console.error('Ошибка получения профиля:', error); - throw error; - } + const profile = await api.auth.getProfile(); + console.log('Профиль пользователя:', profile); + return profile; } -async function createProject() { - try { - const project = await api.projects.create({ - name: 'Мой CDN проект', - description: 'Проект для хранения статических файлов', - s3Endpoint: 'https://s3.amazonaws.com', - s3Bucket: 'my-cdn-bucket', - s3Region: 'us-east-1', - s3AccessKey: 'AKIAIOSFODNN7EXAMPLE', - s3SecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - }); - - console.log('Проект создан:', project); - return project; - } catch (error) { - console.error('Ошибка создания проекта:', error); - throw error; - } -} - -async function getAllProjects() { - try { - const projects = await api.projects.findAll(); - console.log('Список проектов:', projects); - return projects; - } catch (error) { - console.error('Ошибка получения проектов:', error); - throw error; - } -} - -// Главная функция для демонстрации -async function main() { - console.log('🚀 Пример использования API клиента\n'); - - // 1. Регистрация - console.log('1. Регистрация пользователя...'); - await registerUser(); - console.log('✅ Готово\n'); - - // 2. Авторизация - console.log('2. Авторизация...'); - await loginUser(); - console.log('✅ Готово\n'); - - // 3. Получение профиля - console.log('3. Получение профиля...'); - await getUserProfile(); - console.log('✅ Готово\n'); - - // 4. Создание проекта - console.log('4. Создание проекта...'); - await createProject(); - console.log('✅ Готово\n'); - - // 5. Получение всех проектов - console.log('5. Получение списка проектов...'); - await getAllProjects(); - console.log('✅ Готово\n'); - - console.log('✨ Все операции выполнены успешно!'); -} - -// Запуск примера (раскомментируйте для выполнения) -// main().catch(console.error); - -export { registerUser, loginUser, getUserProfile, createProject, getAllProjects }; - +export { getUserProfile, loginUser, registerUser }; diff --git a/package.json b/package.json index 89b0f2a..fb5edfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gromlab/api-codegen", - "version": "1.0.7", + "version": "3.0.0", "description": "CLI tool to generate TypeScript API client from OpenAPI specification", "type": "module", "bin": { @@ -10,6 +10,9 @@ "dist", "package.json" ], + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=18" }, @@ -37,12 +40,9 @@ "@types/ejs": "^3.1.5", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", - "@types/react": "^18.3.0", "@types/tmp": "^0.2.6", "execa": "^8.0.0", "msw": "^2.0.0", - "react": "^18.3.0", - "swr": "^2.3.0", "tmp": "^0.2.1" }, "peerDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 5e93fbd..20f9144 100644 --- a/src/cli.ts +++ b/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 to OpenAPI specification file (JSON or YAML)') - .requiredOption('-o, --output ', 'Output directory for generated files') - .option('-n, --name ', 'Name of generated file (without extension)') - .option('--swr', 'Generate SWR hooks for React') + .option('-i, --input ', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)') + .option('-o, --output ', 'Директория для сохранения сгенерированных файлов') + .option('-n, --name ', 'Имя монолитного клиента без расширения .ts') + .option('--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(); - - - diff --git a/src/config.ts b/src/config.ts index 2d669b6..d67bea8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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): 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; } - - - diff --git a/src/generator.ts b/src/generator.ts index 19e3fd0..3b21041 100644 --- a/src/generator.ts +++ b/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 { + 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(); + const usedFileNames = new Set(); + + 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 { + const template = await readTextFile(templatePath); + return String(await generatorOutput.renderTemplate(template, data as Record)); +} + +async function writeFormattedFile( + generatorOutput: GenerateApiOutput, + filePath: string, + content: string, +): Promise { + const formattedContent = await generatorOutput.formatTSContent(`${GENERATED_FILE_PREFIX}\n\n${content}`); + await writeFileWithDirs(filePath, formattedContent); +} + +async function cleanTreeOutput(outputDir: string): Promise { + 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 { 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 { 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 { // Проверяем, что директория с шаблонами существует 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[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[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)}`); } } - diff --git a/src/templates/api.ejs b/src/templates/api.ejs index a5c2940..e423f49 100644 --- a/src/templates/api.ejs +++ b/src/templates/api.ejs @@ -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) { %> diff --git a/src/templates/create-api-client.ejs b/src/templates/create-api-client.ejs new file mode 100644 index 0000000..c7d9628 --- /dev/null +++ b/src/templates/create-api-client.ejs @@ -0,0 +1,44 @@ +import type { ApiRequestClient } from "./http-client"; + +export type ApiOperation = ( + client: TClient, + ...args: any[] +) => any; + +export type ApiTree = { + readonly [key: string]: ApiOperation | ApiTree; +}; + +export type BoundApi = { + readonly [K in keyof TTree]: TTree[K] extends ( + client: TClient, + ...args: infer Args + ) => infer Result + ? (...args: Args) => Result + : TTree[K] extends ApiTree + ? BoundApi + : never; +}; + +export const createApiClient = < + TClient extends ApiRequestClient, + const TTree extends ApiTree, +>( + client: TClient, + tree: TTree, +): BoundApi => { + const bindNode = (node: ApiOperation | ApiTree): 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 | ApiTree), + ]), + ); + }; + + return bindNode(tree) as BoundApi; +}; diff --git a/src/templates/data-contracts.ejs b/src/templates/data-contracts.ejs index b4df08a..80a03fa 100644 --- a/src/templates/data-contracts.ejs +++ b/src/templates/data-contracts.ejs @@ -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) => { diff --git a/src/templates/http-client.ejs b/src/templates/http-client.ejs index f0b3ac4..8a2a653 100644 --- a/src/templates/http-client.ejs +++ b/src/templates/http-client.ejs @@ -3,23 +3,6 @@ const { apiConfig, generateResponses, config } = it; const baseUrl = apiConfig?.baseUrl || ""; %> -/** - * Фетчер для SWR - * Принимает URL и возвращает Promise с данными - */ -export const fetcher = (url: string): Promise => { - 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; export type ResponseFormat = keyof Omit; @@ -44,6 +27,9 @@ export interface FullRequestParams extends Omit { export type RequestParams = Omit +export interface ApiRequestClient { + request(params: FullRequestParams): Promise; +} export interface ApiConfig { baseUrl?: string; @@ -57,7 +43,7 @@ export interface HttpResponse 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 { +export class HttpClient implements ApiRequestClient { public baseUrl: string = "<%~ baseUrl %>"; private securityData: SecurityDataType | null = null; private securityWorker?: ApiConfig["securityWorker"]; diff --git a/src/templates/operation.ejs b/src/templates/operation.ejs new file mode 100644 index 0000000..672189a --- /dev/null +++ b/src/templates/operation.ejs @@ -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") %>, + }) diff --git a/src/templates/procedure-call.ejs b/src/templates/procedure-call.ejs index c59d2ed..844df03 100644 --- a/src/templates/procedure-call.ejs +++ b/src/templates/procedure-call.ejs @@ -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 ? ',' : '' %> -<% } %> diff --git a/src/utils/file.ts b/src/utils/file.ts index 16e84ff..42ba6ae 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -30,7 +30,7 @@ export async function readJsonFile(path: string): Promise { // Используем нативный 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 { } - diff --git a/tests/integration/e2e-generation.test.ts b/tests/integration/e2e-generation.test.ts index 424305a..8663b05 100644 --- a/tests/integration/e2e-generation.test.ts +++ b/tests/integration/e2e-generation.test.ts @@ -49,23 +49,28 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - 'TestApi', + '--mode', + 'split', ]); expect(exitCode).toBe(0); // 2. Проверка создания файла - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); const exists = await fileExists(generatedFile); expect(exists).toBe(true); // 3. Проверка импорта (компиляция TypeScript) const testFile = join(tempDir, 'test-import.ts'); const testCode = ` - import { Api } from '${generatedFile}'; - - const api = new Api(); + import { createApiClient, HttpClient } from '${generatedFile}'; + import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}'; + + const api = createApiClient(new HttpClient(), { + users: { + getAll, + }, + }); console.log('Import successful'); `; @@ -97,24 +102,7 @@ describe('E2E Generation', () => { test('повторная генерация (перезапись файлов)', async () => { const outputPath = join(tempDir, 'output'); - const fileName = 'TestApi'; - - // Первая генерация - await execa('bun', [ - 'run', - CLI_PATH, - '--input', - FIXTURES.MINIMAL, - '--output', - outputPath, - '--name', - fileName, - ]); - - const generatedFile = join(outputPath, `${fileName}.ts`); - const firstContent = await readTextFile(generatedFile); - - // Вторая генерация (перезапись) + // Первая генерация с endpoints await execa('bun', [ 'run', CLI_PATH, @@ -122,8 +110,25 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - fileName, + '--mode', + 'split', + ]); + + const generatedFile = join(outputPath, 'operations', 'index.ts'); + const staleOperationFile = join(outputPath, 'operations', 'get-all.ts'); + const firstContent = await readTextFile(generatedFile); + expect(await fileExists(staleOperationFile)).toBe(true); + + // Вторая генерация (перезапись меньшей схемой) + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', ]); const secondContent = await readTextFile(generatedFile); @@ -134,6 +139,7 @@ describe('E2E Generation', () => { // Файл должен существовать const exists = await fileExists(generatedFile); expect(exists).toBe(true); + expect(await fileExists(staleOperationFile)).toBe(false); }, 60000); test('генерация из HTTP URL', async () => { @@ -147,50 +153,41 @@ describe('E2E Generation', () => { 'https://petstore3.swagger.io/api/v3/openapi.json', '--output', outputPath, - '--name', - 'PetStore', + '--mode', + 'split', ]); expect(exitCode).toBe(0); - const generatedFile = join(outputPath, 'PetStore.ts'); + const generatedFile = join(outputPath, 'index.ts'); const exists = await fileExists(generatedFile); expect(exists).toBe(true); // Проверяем что файл не пустой - const content = await readTextFile(generatedFile); + const content = await readTextFile(join(outputPath, 'http-client.ts')); expect(content.length).toBeGreaterThan(1000); }, 60000); - test('генерация с флагом --swr', async () => { + test('флаг --swr больше не поддерживается', async () => { const outputPath = join(tempDir, 'output'); - const { exitCode } = await execa('bun', [ - 'run', - CLI_PATH, - '--input', - FIXTURES.VALID, - '--output', - outputPath, - '--name', - 'SwrApi', - '--swr', - ]); - - expect(exitCode).toBe(0); - - const generatedFile = join(outputPath, 'SwrApi.ts'); - const content = await readTextFile(generatedFile); - - // Проверяем наличие импорта useSWR - expect(content).toContain('import useSWR from "swr"'); - - // Проверяем наличие use* хуков для GET запросов - expect(content).toContain('useGetAll'); - expect(content).toContain('useGetById'); + try { + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.VALID, + '--output', + outputPath, + '--swr', + ]); + throw new Error('Should have thrown'); + } catch (error: any) { + expect(error.exitCode).not.toBe(0); + } }, 30000); - test('генерация без флага --swr не содержит хуки', async () => { + test('REST генерация не содержит SWR хуки', async () => { const outputPath = join(tempDir, 'output'); const { exitCode } = await execa('bun', [ @@ -200,13 +197,13 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - 'NoSwrApi', + '--mode', + 'split', ]); expect(exitCode).toBe(0); - const generatedFile = join(outputPath, 'NoSwrApi.ts'); + const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); // Проверяем отсутствие импорта useSWR @@ -229,16 +226,21 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - 'TestApi', + '--mode', + 'split', ]); // Динамически импортируем сгенерированный API - const generatedFile = join(outputPath, 'TestApi.ts'); - const { Api } = await import(generatedFile); - - const api = new Api(); - const result = await api.users.getAll(); + const generatedFile = join(outputPath, 'index.ts'); + const { createApiClient, HttpClient } = await import(generatedFile); + const { getAll } = await import(join(outputPath, 'operations', 'get-all.ts')); + + const api = createApiClient(new HttpClient(), { + users: { + getAll, + }, + }); + const result = await api.users.getAll({}); expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(2); @@ -254,15 +256,20 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - 'TestApi', + '--mode', + 'split', ]); // Динамически импортируем сгенерированный API - const generatedFile = join(outputPath, 'TestApi.ts'); - const { Api } = await import(generatedFile); - - const api = new Api(); + const generatedFile = join(outputPath, 'index.ts'); + const { createApiClient, HttpClient } = await import(generatedFile); + const { create } = await import(join(outputPath, 'operations', 'create.ts')); + + const api = createApiClient(new HttpClient(), { + users: { + create, + }, + }); const result = await api.users.create({ email: 'new@example.com', password: 'password123' @@ -282,17 +289,22 @@ describe('E2E Generation', () => { FIXTURES.VALID, '--output', outputPath, - '--name', - 'TestApi', + '--mode', + 'split', ]); const testFile = join(tempDir, 'test-404.ts'); const testCode = ` - import { Api } from '${join(outputPath, 'TestApi.ts')}'; - - const api = new Api(); + import { createApiClient, HttpClient } from '${join(outputPath, 'index.ts')}'; + import { getById } from '${join(outputPath, 'operations', 'get-by-id.ts')}'; + + const api = createApiClient(new HttpClient(), { + users: { + getById, + }, + }); try { - await api.users.getById('999'); + await api.users.getById({ id: '999' }); } catch (error) { console.log('error'); } @@ -314,14 +326,16 @@ describe('E2E Generation', () => { FIXTURES.WITH_AUTH, '--output', outputPath, - '--name', - 'AuthApi', + '--mode', + 'split', ]); // Динамически импортируем сгенерированный API - const generatedFile = join(outputPath, 'AuthApi.ts'); - const { Api, HttpClient } = await import(generatedFile); - + const generatedFile = join(outputPath, 'index.ts'); + const { createApiClient, HttpClient } = await import(generatedFile); + const { login } = await import(join(outputPath, 'operations', 'login.ts')); + const { get } = await import(join(outputPath, 'operations', 'get.ts')); + // Создаем HttpClient с securityWorker для добавления Bearer токена const httpClient = new HttpClient({ securityWorker: (securityData: string | null) => { @@ -334,8 +348,15 @@ describe('E2E Generation', () => { } } }); - - const api = new Api(httpClient); + + const api = createApiClient(httpClient, { + auth: { + login, + }, + profile: { + get, + }, + }); // Логин const loginResult = await api.auth.login({ @@ -352,4 +373,4 @@ describe('E2E Generation', () => { expect(profile.email).toBe('test@example.com'); }, 60000); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/generated-client.test.ts b/tests/integration/generated-client.test.ts index ec6a661..a547a3d 100644 --- a/tests/integration/generated-client.test.ts +++ b/tests/integration/generated-client.test.ts @@ -28,11 +28,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); // Пытаемся скомпилировать const { exitCode } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]); @@ -46,11 +47,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); // Проверяем с помощью TypeScript компилятора const { exitCode, stderr } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]); @@ -65,16 +67,17 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем экспорты expect(content).toContain('export'); - expect(content).toContain('class'); + expect(content).toContain('createApiClient'); }, 30000); }); @@ -85,11 +88,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'operations', 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем что все основные методы есть @@ -97,7 +101,7 @@ describe('Generated Client', () => { expect(content).toContain('create'); expect(content).toContain('getById'); expect(content).toContain('update'); - expect(content).toContain('delete'); + expect(content).toContain('deleteUsersId'); }, 30000); test('корректные имена методов (без Controller префиксов)', async () => { @@ -106,11 +110,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'operations', 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем что "Controller" удален @@ -128,11 +133,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие HttpClient @@ -148,11 +154,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.WITH_AUTH, outputPath, fileName: 'AuthApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'AuthApi.ts'); + const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие метода для установки токена @@ -167,11 +174,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.MINIMAL, outputPath, fileName: 'MinimalApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'MinimalApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); const exists = await fileExists(generatedFile); expect(exists).toBe(true); @@ -186,11 +194,12 @@ describe('Generated Client', () => { inputPath: FIXTURES.WITH_AUTH, outputPath, fileName: 'AuthApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'AuthApi.ts'); + const generatedFile = join(outputPath, 'operations', 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие методов авторизации @@ -204,17 +213,18 @@ describe('Generated Client', () => { inputPath: FIXTURES.COMPLEX, outputPath, fileName: 'ComplexApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'ComplexApi.ts'); + const generatedFile = join(outputPath, 'index.ts'); const exists = await fileExists(generatedFile); expect(exists).toBe(true); // Проверяем что файл не пустой - const content = await readTextFile(generatedFile); + const content = await readTextFile(join(outputPath, 'operations', 'index.ts')); expect(content.length).toBeGreaterThan(1000); }, 30000); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index f389d13..0824096 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -47,7 +47,7 @@ describe('CLI', () => { expect(exists).toBe(true); }, 30000); - test('должен генерировать с кастомным именем файла', async () => { + test('должен генерировать монолит с кастомным именем файла', async () => { const outputPath = join(tempDir, 'output'); const customName = 'CustomApi'; @@ -69,6 +69,48 @@ describe('CLI', () => { expect(exists).toBe(true); }, 30000); + test('должен генерировать split режим', async () => { + const outputPath = join(tempDir, 'output'); + + const { exitCode } = await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', + ]); + + 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', 'index.ts'))).toBe(true); + }, 30000); + + 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); + }, 30000); + test('должен отображать версию с --version', async () => { const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']); @@ -78,7 +120,7 @@ describe('CLI', () => { test('должен отображать help с --help', async () => { const { stdout } = await execa('bun', ['run', CLI_PATH, '--help']); - expect(stdout).toContain('Generate TypeScript API client'); + expect(stdout).toContain('Генерация TypeScript API клиента'); expect(stdout).toContain('--input'); expect(stdout).toContain('--output'); }); @@ -141,5 +183,26 @@ describe('CLI', () => { expect(error.exitCode).not.toBe(0); } }, 30000); + + test('должен показывать русскую ошибку для недоступного URL', async () => { + const outputPath = join(tempDir, 'output'); + + try { + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + 'https://127.0.0.1:1/swagger.json', + '--output', + outputPath, + ]); + throw new Error('Should have thrown'); + } catch (error: any) { + expect(error.exitCode).not.toBe(0); + expect(error.stderr).toContain('Не удалось подключиться к OpenAPI спецификации'); + expect(error.stderr).toContain('Проверьте доступность URL и сетевое подключение'); + expect(error.stderr).not.toContain('Unable to connect'); + } + }, 30000); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 6cf881c..fcab5ad 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -29,7 +29,7 @@ describe('config', () => { outputPath: './output', }; - expect(() => validateConfig(config)).toThrow('Input path is required'); + expect(() => validateConfig(config)).toThrow('Не указан путь к OpenAPI спецификации'); }); test('должен выбросить ошибку без outputPath', () => { @@ -37,13 +37,13 @@ describe('config', () => { inputPath: './openapi.json', }; - expect(() => validateConfig(config)).toThrow('Output path is required'); + expect(() => validateConfig(config)).toThrow('Не указана директория для генерации'); }); test('должен выбросить ошибку без обоих обязательных полей', () => { const config: Partial = {}; - expect(() => validateConfig(config)).toThrow('Configuration validation failed'); + expect(() => validateConfig(config)).toThrow('Ошибка конфигурации'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/generator.test.ts b/tests/unit/generator.test.ts index 07c31e8..3738461 100644 --- a/tests/unit/generator.test.ts +++ b/tests/unit/generator.test.ts @@ -36,7 +36,7 @@ describe('Generator', () => { expect(exists).toBe(true); }, 30000); - test('должен генерировать корректную структуру кода', async () => { + test('должен добавлять русскую подпись в generated файлы', async () => { const outputPath = join(tempDir, 'output'); const config: GeneratorConfig = { inputPath: FIXTURES.MINIMAL, @@ -49,9 +49,54 @@ describe('Generator', () => { const generatedFile = join(outputPath, 'TestApi.ts'); const content = await readTextFile(generatedFile); + expect(content.startsWith('/* eslint-disable */\n/* tslint:disable */\n// @ts-nocheck\n\n/*')).toBe(true); + expect(content).toContain('АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'); + expect(content).toContain('Не редактируйте вручную: изменения будут перезаписаны.'); + expect(content).toContain('Генератор: @gromlab/api-codegen'); + expect(content).toContain('Репозиторий: https://gromlab.ru/gromov/api-codegen'); + expect(content).not.toContain('THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API'); + }, 30000); + + test('должен добавлять русскую подпись в split режиме', async () => { + const outputPath = join(tempDir, 'output'); + const config: GeneratorConfig = { + inputPath: FIXTURES.MINIMAL, + outputPath, + fileName: 'TestApi', + mode: 'split', + }; + + await generate(config); + + const generatedFile = join(outputPath, 'index.ts'); + const content = await readTextFile(generatedFile); + + expect(content.startsWith('/* eslint-disable */\n/* tslint:disable */\n// @ts-nocheck\n\n/*')).toBe(true); + expect(content).toContain('АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'); + expect(content).toContain('Не редактируйте вручную: изменения будут перезаписаны.'); + expect(content).toContain('Генератор: @gromlab/api-codegen'); + expect(content).not.toContain('THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API'); + }, 30000); + + test('должен генерировать корректную структуру кода', async () => { + const outputPath = join(tempDir, 'output'); + const config: GeneratorConfig = { + inputPath: FIXTURES.MINIMAL, + outputPath, + fileName: 'TestApi', + mode: 'split', + }; + + await generate(config); + + const indexFile = join(outputPath, 'index.ts'); + const httpClientFile = join(outputPath, 'http-client.ts'); + const indexContent = await readTextFile(indexFile); + const httpClientContent = await readTextFile(httpClientFile); + // Проверяем наличие основных элементов - expect(content).toContain('export class'); - expect(content).toContain('HttpClient'); + expect(indexContent).toContain('createApiClient'); + expect(httpClientContent).toContain('export class HttpClient'); }, 30000); test('должен обработать все HTTP методы', async () => { @@ -60,12 +105,13 @@ describe('Generator', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); - const content = await readTextFile(generatedFile); + const operationsIndex = join(outputPath, 'operations', 'index.ts'); + const content = await readTextFile(operationsIndex); // Проверяем что методы UserController переименованы // UserController_getAll -> getAll @@ -74,7 +120,7 @@ describe('Generator', () => { expect(content).toContain('create'); expect(content).toContain('getById'); expect(content).toContain('update'); - expect(content).toContain('delete'); + expect(content).toContain('deleteUsersId'); }, 30000); test('должен генерировать типы для request и response', async () => { @@ -83,11 +129,12 @@ describe('Generator', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'data-contracts.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие типов @@ -102,15 +149,16 @@ describe('Generator', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'data-contracts.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие enum - expect(content).toContain('UserRole'); + expect(content).toContain('type UserRole'); expect(content).toContain('admin'); expect(content).toContain('user'); expect(content).toContain('guest'); @@ -122,11 +170,12 @@ describe('Generator', () => { inputPath: FIXTURES.WITH_AUTH, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); // Проверяем наличие методов для работы с токеном @@ -139,11 +188,12 @@ describe('Generator', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); // Проверяем что baseUrl установлен @@ -156,11 +206,12 @@ describe('Generator', () => { inputPath: FIXTURES.VALID, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'operations', 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем что "Controller" удален из имен методов @@ -210,11 +261,12 @@ describe('Generator', () => { inputPath: FIXTURES.COMPLEX, outputPath, fileName: 'TestApi', + mode: 'split', }; await generate(config); - const generatedFile = join(outputPath, 'TestApi.ts'); + const generatedFile = join(outputPath, 'operations', 'index.ts'); const content = await readTextFile(generatedFile); // Проверяем что все контроллеры присутствуют @@ -238,4 +290,4 @@ describe('Generator', () => { expect(exists).toBe(true); }, 30000); }); -}); \ No newline at end of file +});