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

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

View File

@@ -29,10 +29,13 @@ CLI утилита для автоматической генерации TypeSc
- Чтение OpenAPI спецификации - Чтение OpenAPI спецификации
- Применение кастомных шаблонов EJS - Применение кастомных шаблонов EJS
- Генерация TypeScript кода - Генерация TypeScript кода
3. **Выходные данные**: 3 файла в указанной директории: 3. **Выходные данные по умолчанию**: legacy монолитный файл `{FileName}.ts`
- `{FileName}.ts` - API endpoints с методами 4. **Выходные данные в `split` режиме**: tree-shaking friendly структура:
- `http-client.ts` - HTTP клиент с настройками - `http-client.ts` - HTTP клиент с настройками
- `data-contracts.ts` - TypeScript типы - `data-contracts.ts` - TypeScript типы
- `create-api-client.ts` - helper для привязки выбранного графа методов к клиенту
- `operations/*.ts` - один endpoint на файл
- `index.ts` - barrel exports
## Ключевые особенности ## Ключевые особенности
@@ -51,13 +54,15 @@ CLI утилита для автоматической генерации TypeSc
## Использование ## Использование
```bash ```bash
api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>] api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>] [--mode single|split|both]
``` ```
### Аргументы ### Аргументы
- `-i, --input` - путь к OpenAPI файлу (локальный или URL) - `-i, --input` - путь к OpenAPI файлу (локальный или URL)
- `-o, --output` - директория для сохранения - `-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 # Основная логика генерации ├── generator.ts # Основная логика генерации
├── templates/ # EJS шаблоны ├── templates/ # EJS шаблоны
│ ├── api.ejs │ ├── api.ejs
│ ├── operation.ejs
│ ├── create-api-client.ejs
│ ├── http-client.ejs │ ├── http-client.ejs
│ ├── data-contracts.ejs │ ├── data-contracts.ejs
│ └── ... │ └── ...
@@ -84,16 +91,24 @@ src/
- `extractRequestParams: true` - извлечение параметров запросов - `extractRequestParams: true` - извлечение параметров запросов
- `extractRequestBody: true` - извлечение тел запросов - `extractRequestBody: true` - извлечение тел запросов
- `extractEnums: true` - извлечение enum типов - `extractEnums: true` - извлечение enum типов
- `generateUnionEnums: true` в `split` режиме - enum схемы генерируются как type union без runtime-кода
## Примеры использования сгенерированного кода ## Примеры использования сгенерированного кода
```typescript ```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(); const httpClient = new HttpClient();
httpClient.setSecurityData({ token: 'jwt-token' }); httpClient.setSecurityData({ token: 'jwt-token' });
const api = new Api(httpClient); const api = createApiClient(httpClient, {
auth: {
getProfile,
login,
},
});
// Вызов API методов // Вызов API методов
const user = await api.auth.getProfile(); const user = await api.auth.getProfile();

120
README.md
View File

@@ -1,20 +1,21 @@
# @gromlab/api-codegen # @gromlab/api-codegen
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. CLI утилита для генерации TypeScript REST-клиента из OpenAPI спецификации.
## Использование ## Использование
```bash ```bash
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--swr] npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--mode single|split|both]
``` ```
**Аргументы:** **Аргументы:**
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный файл или URL) - `-i, --input <path>` - путь к OpenAPI файлу или URL
- `-o, --output <path>` - Директория для сохранения файлов - `-o, --output <path>` - директория для сохранения файлов
- `-n, --name <name>` - Имя сгенерированного файла (опционально, по умолчанию из `spec.info.title`) - `-n, --name <name>` - имя монолитного файла без `.ts`
- `--swr` - Генерировать SWR хуки для React - `--mode <mode>` - режим генерации: `single`, `split`, `both` (по умолчанию `single`)
- `--single-file` - устаревший алиас для `--mode single`
**Примеры:** ## Примеры
```bash ```bash
# Локальный файл # Локальный файл
@@ -23,33 +24,103 @@ npx @gromlab/api-codegen -i ./openapi.json -o ./src/api
# URL на спецификацию # URL на спецификацию
npx @gromlab/api-codegen -i https://httpbin.org/spec.json -o ./src/api 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 npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi
# С генерацией SWR хуков # Разложенный tree-shaking friendly клиент
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --swr 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 ```typescript
import { Api, HttpClient } from './src/api/MyApi'; import { Api, HttpClient } from './generated/MyApi';
const httpClient = new HttpClient(); const http = new HttpClient({ baseUrl: 'https://api.example.com' });
httpClient.setSecurityData({ token: 'jwt-token' }); const api = new Api(http);
const api = new Api(httpClient); const users = await api.users.getAll({});
```
// GET запрос Для `split` режима:
const user = await api.auth.getProfile();
// POST запрос ```typescript
const result = await api.auth.login({ email, password }); 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 ```bash
bun run build bun run build
``` ```
@@ -57,20 +128,9 @@ bun run build
### Тестирование ### Тестирование
```bash ```bash
# Все тесты
bun test bun test
# Юнит тесты
bun run test:unit bun run test:unit
# Интеграционные тесты
bun run test:integration bun run test:integration
# Watch режим
bun run test:watch
# С coverage
bun run test:coverage
``` ```
Подробная документация по тестированию в [`tests/README.md`](tests/README.md). Подробная документация по тестированию в [`tests/README.md`](tests/README.md).

View File

@@ -17,12 +17,9 @@
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^18.3.0",
"@types/tmp": "^0.2.6", "@types/tmp": "^0.2.6",
"execa": "^8.0.0", "execa": "^8.0.0",
"msw": "^2.0.0", "msw": "^2.0.0",
"react": "^18.3.0",
"swr": "^2.3.0",
"tmp": "^0.2.1", "tmp": "^0.2.1",
}, },
"peerDependencies": { "peerDependencies": {
@@ -125,8 +122,6 @@
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "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=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "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=="], "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=="], "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=="], "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=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], "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=="], "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=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],

View File

@@ -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 <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>Профиль пользователя</h1>
<p>Email: {data.email}</p>
<p>Имя: {data.firstName} {data.lastName}</p>
<p>Email подтверждён: {data.isEmailVerified ? 'Да' : 'Нет'}</p>
</div>
);
}
// 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 <div>Загрузка проекта...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
if (!project) return null;
return (
<div>
<h2>{project.name}</h2>
<p>{project.description}</p>
<p>Bucket: {project.s3Bucket}</p>
<p>Region: {project.s3Region}</p>
</div>
);
}
// Список с автоматической ревалидацией
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 <div>Загрузка списка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<div>
<h2>Мои проекты ({projects?.length || 0})</h2>
<button onClick={handleCreateProject}>Создать проект</button>
<ul>
{projects?.map((project) => (
<li key={project.id}>
{project.name} - {project.slug}
</li>
))}
</ul>
</div>
);
}
// ============================================
// ПРИМЕР 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 <div>Загрузка...</div>;
if (error) return <div>Ошибка</div>;
return (
<div>
<h1>{data?.firstName} {data?.lastName}</h1>
<p>{data?.email}</p>
</div>
);
}
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 <div>Загрузка...</div>;
return (
<div>
<h2>Проекты</h2>
{projects?.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}
// ============================================
// ПРИМЕР 3: УСЛОВНАЯ ЗАГРУЗКА
// ============================================
function ConditionalProfile({ userId }: { userId?: string }) {
const profileConfig = api.auth.useGetProfile();
const { data } = useSWR(
// Загружаем только если есть userId
userId ? profileConfig.path : null,
() => api.auth.getProfile()
);
return data ? <div>{data.email}</div> : 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 (
<div>
<h3>Всего проектов: {projects?.length || 0}</h3>
{firstProject && (
<div>
<h4>Первый проект:</h4>
<p>{firstProject.name}</p>
</div>
)}
</div>
);
}
// ============================================
// ПРИМЕР 5: СОЗДАНИЕ ХУКА-ОБЁРТКИ
// ============================================
// Универсальный хук для всех GET запросов
function useApiQuery<T>(
useConfigFn: () => { path: string; method: 'GET'; secure?: boolean },
apiFn: () => Promise<T>,
options?: Parameters<typeof useSWR>[2]
) {
const config = useConfigFn();
return useSWR<T>(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 (
<SWRConfig
value={{
fetcher: apiFetcher,
revalidateOnFocus: false,
dedupingInterval: 2000,
}}
>
<UserProfile />
<ProjectsList />
</SWRConfig>
);
}
export {
UserProfile,
ProjectDetails,
ProjectsList,
UserProfileWithReactQuery,
ProjectsListWithReactQuery,
ConditionalProfile,
DependentQueries,
useApiQuery,
};

View File

@@ -1,26 +1,46 @@
/** /**
* Пример использования сгенерированного 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 клиента с базовыми настройками type SecurityData = {
const httpClient = new HttpClient({ token: string;
baseUrl: 'https://cdn.example.com', // Базовый URL (уже установлен при генерации) };
const httpClient = new HttpClient<SecurityData>({
baseUrl: 'https://api.example.com',
baseApiParams: { baseApiParams: {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}, },
securityWorker: (securityData) => {
if (!securityData?.token) {
return undefined;
}
return {
headers: {
Authorization: `Bearer ${securityData.token}`,
},
};
},
}); });
// 2. Создание API клиента const api = createApiClient(httpClient, {
const api = new Api(httpClient); auth: {
register,
// 3. Пример использования login,
getProfile,
},
});
async function registerUser() { async function registerUser() {
try {
const result = await api.auth.register({ const result = await api.auth.register({
email: 'user@example.com', email: 'user@example.com',
password: 'SecurePassword123', password: 'SecurePassword123',
@@ -30,107 +50,23 @@ async function registerUser() {
console.log('Пользователь зарегистрирован:', result); console.log('Пользователь зарегистрирован:', result);
return result; return result;
} catch (error) {
console.error('Ошибка регистрации:', error);
throw error;
}
} }
async function loginUser() { async function loginUser() {
try {
const result = await api.auth.login({ const result = await api.auth.login({
email: 'user@example.com', email: 'user@example.com',
password: 'SecurePassword123', password: 'SecurePassword123',
}); });
console.log('Авторизация успешна');
// Сохраняем токен для последующих запросов
httpClient.setSecurityData({ token: result.access_token }); httpClient.setSecurityData({ token: result.access_token });
return result; return result;
} catch (error) {
console.error('Ошибка авторизации:', error);
throw error;
}
} }
async function getUserProfile() { async function getUserProfile() {
try {
const profile = await api.auth.getProfile(); const profile = await api.auth.getProfile();
console.log('Профиль пользователя:', profile); console.log('Профиль пользователя:', profile);
return profile; return profile;
} catch (error) {
console.error('Ошибка получения профиля:', error);
throw error;
}
} }
async function createProject() { export { getUserProfile, loginUser, registerUser };
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 };

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gromlab/api-codegen", "name": "@gromlab/api-codegen",
"version": "1.0.7", "version": "3.0.0",
"description": "CLI tool to generate TypeScript API client from OpenAPI specification", "description": "CLI tool to generate TypeScript API client from OpenAPI specification",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -10,6 +10,9 @@
"dist", "dist",
"package.json" "package.json"
], ],
"publishConfig": {
"access": "public"
},
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -37,12 +40,9 @@
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^18.3.0",
"@types/tmp": "^0.2.6", "@types/tmp": "^0.2.6",
"execa": "^8.0.0", "execa": "^8.0.0",
"msw": "^2.0.0", "msw": "^2.0.0",
"react": "^18.3.0",
"swr": "^2.3.0",
"tmp": "^0.2.1" "tmp": "^0.2.1"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -15,14 +15,26 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')
const program = new Command(); const program = new Command();
const translateCommanderError = (message: string): string => {
return message
.replace(/^error:/, 'ошибка:')
.replace('unknown option', 'неизвестная опция')
.replace('too many arguments', 'слишком много аргументов')
.replace('option', 'опция');
};
program program
.name('api-codegen') .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) .version(pkg.version)
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)') .option('-i, --input <path>', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
.requiredOption('-o, --output <path>', 'Output directory for generated files') .option('-o, --output <path>', 'Директория для сохранения сгенерированных файлов')
.option('-n, --name <name>', 'Name of generated file (without extension)') .option('-n, --name <name>', 'Имя монолитного клиента без расширения .ts')
.option('--swr', 'Generate SWR hooks for React') .option('--mode <mode>', 'Режим генерации: single, split, both', 'single')
.option('--single-file', 'Устаревший алиас для --mode single')
.action(async (options) => { .action(async (options) => {
try { try {
// Создание конфигурации // Создание конфигурации
@@ -30,7 +42,7 @@ program
inputPath: options.input, inputPath: options.input,
outputPath: options.output, outputPath: options.output,
fileName: options.name, 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 (!config.inputPath!.startsWith('http://') && !config.inputPath!.startsWith('https://')) {
if (!(await fileExists(config.inputPath!))) { 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); process.exit(1);
} }
} }
@@ -47,15 +59,12 @@ program
// Генерация API // Генерация API
await generate(config as GeneratorConfig); await generate(config as GeneratorConfig);
console.log(chalk.green('\n✨ API client generated successfully!\n')); console.log(chalk.green('\n✨ API клиент успешно сгенерирован!\n'));
} catch (error) { } 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(); console.error();
process.exit(1); process.exit(1);
} }
}); });
program.parse(); program.parse();

View File

@@ -1,3 +1,5 @@
export type GeneratorMode = 'single' | 'split' | 'both';
/** /**
* Конфигурация генератора API * Конфигурация генератора API
*/ */
@@ -6,10 +8,10 @@ export interface GeneratorConfig {
inputPath: string; inputPath: string;
/** Путь для сохранения сгенерированных файлов */ /** Путь для сохранения сгенерированных файлов */
outputPath: string; outputPath: string;
/** Имя сгенерированного файла (без расширения) */ /** Имя сгенерированного файла (без расширения), используется в single/both режиме */
fileName?: string; fileName?: string;
/** Генерировать SWR hooks для React */ /** Режим генерации */
useSwr?: boolean; mode?: GeneratorMode;
} }
/** /**
@@ -19,19 +21,20 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
const errors: string[] = []; const errors: string[] = [];
if (!config.inputPath) { if (!config.inputPath) {
errors.push('Input path is required (--input)'); errors.push('Не указан путь к OpenAPI спецификации (--input)');
} }
if (!config.outputPath) { 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) { 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; return true;
} }

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,10 @@ const buildGenerics = (contract) => {
const dataContractTemplates = { const dataContractTemplates = {
enum: (contract) => { 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 }`; return `enum ${contract.name} {\r\n${contract.content} \r\n }`;
}, },
interface: (contract) => { interface: (contract) => {

View File

@@ -3,23 +3,6 @@ const { apiConfig, generateResponses, config } = it;
const baseUrl = apiConfig?.baseUrl || ""; const baseUrl = apiConfig?.baseUrl || "";
%> %>
/**
* Фетчер для SWR
* Принимает URL и возвращает Promise с данными
*/
export const fetcher = <T = any>(url: string): Promise<T> => {
return fetch(url, {
headers: {
"Content-Type": "application/json",
},
}).then(res => {
if (!res.ok) {
throw new Error(`HTTP Error ${res.status}`);
}
return res.json();
});
};
export type QueryParamsType = Record<string | number, any>; export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">; export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
@@ -44,6 +27,9 @@ export interface FullRequestParams extends Omit<RequestInit, "body"> {
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path"> export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">
export interface ApiRequestClient {
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
}
export interface ApiConfig<SecurityDataType = unknown> { export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string; baseUrl?: string;
@@ -57,7 +43,7 @@ export interface HttpResponse<D extends unknown, E extends unknown = unknown> ex
error: E; error: E;
} }
type CancelToken = Symbol | string | number; export type CancelToken = Symbol | string | number;
export enum ContentType { export enum ContentType {
Json = "application/json", Json = "application/json",
@@ -67,7 +53,7 @@ export enum ContentType {
Text = "text/plain", Text = "text/plain",
} }
export class HttpClient<SecurityDataType = unknown> { export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
public baseUrl: string = "<%~ baseUrl %>"; public baseUrl: string = "<%~ baseUrl %>";
private securityData: SecurityDataType | null = null; private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"]; private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];

View File

@@ -0,0 +1,88 @@
<%
const { utils, route, config, operationName } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent } = utils;
const { parameters, path, method, payload, query, security, requestParams } = route.request;
const { type, errorType } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const routeDocs = includeFile("./route-docs", { config, route, utils });
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
const requestConfigParam = {
name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES),
optional: true,
type: "RequestParams",
defaultValue: "{}",
}
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const rawWrapperArgs = config.extractRequestParams ?
_.compact([
requestParams && {
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
optional: false,
type: getInlineParseContent(requestParams),
},
...(!requestParams ? pathParams : []),
payload,
requestConfigParam,
]) :
_.compact([
...pathParams,
query,
payload,
requestConfigParam,
])
const wrapperArgs = _
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
const requestContentKind = {
"JSON": "ContentType.Json",
"JSON_API": "ContentType.JsonApi",
"URL_ENCODED": "ContentType.UrlEncoded",
"FORM_DATA": "ContentType.FormData",
"TEXT": "ContentType.Text",
}
const responseContentKind = {
"JSON": '"json"',
"IMAGE": '"blob"',
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
%>
__HTTP_CLIENT_IMPORTS__
__DATA_CONTRACT_IMPORTS__
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
export const <%~ operationName %> = (http: ApiRequestClient, <%~ wrapperArgs %>) =>
http.request<<%~ type %>, <%~ errorType %>>({
path: `<%~ path %>`,
method: '<%~ _.upperCase(method) %>',
<%~ queryTmpl ? `query: ${queryTmpl},` : '' %>
<%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %>
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})

View File

@@ -101,40 +101,3 @@ const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %> <%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>, ...<%~ _.get(requestConfigParam, "name") %>,
})<%~ route.namespace ? ',' : '' %> })<%~ route.namespace ? ',' : '' %>
<%
// Генерируем use* функцию для GET запросов (только если включен флаг useSwr)
const isGetRequest = _.upperCase(method) === 'GET';
if (config.useSwr && isGetRequest) {
const useMethodName = 'use' + _.upperFirst(route.routeName.usage);
const argsWithoutParams = rawWrapperArgs.filter(arg => arg.name !== requestConfigParam.name);
const useWrapperArgs = _
.sortBy(argsWithoutParams, [o => o.optional])
.map(argToTmpl)
.join(', ');
// Определяем обязательные параметры для проверки
const requiredArgs = argsWithoutParams.filter(arg => !arg.optional);
const requiredArgsNames = requiredArgs.map(arg => {
// Извлекаем имя из деструктуризации типа "{ id, ...query }"
const match = arg.name.match(/^\{\s*([^,}]+)/);
return match ? match[1].trim() : arg.name;
});
// Генерируем условие для проверки всех обязательных параметров
const hasRequiredArgs = requiredArgsNames.length > 0;
const conditionCheck = hasRequiredArgs
? requiredArgsNames.join(' && ')
: 'true';
%>
/**
* SWR hook для <%~ route.routeName.usage %>
<%~ routeDocs.lines %>
*/
<% if (isValidIdentifier(useMethodName)) { %><%~ useMethodName %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ useMethodName %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ useWrapperArgs %>) => {
return useSWR<<%~ type %>>(
<%~ conditionCheck %> ? `<%~ path %>` : null,
fetcher
);
}<%~ route.namespace ? ',' : '' %>
<% } %>

View File

@@ -30,7 +30,7 @@ export async function readJsonFile<T = unknown>(path: string): Promise<T> {
// Используем нативный fetch в Node.js 18+ // Используем нативный fetch в Node.js 18+
const response = await fetch(path); const response = await fetch(path);
if (!response.ok) { 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(); const content = await response.text();
return JSON.parse(content) as T; return JSON.parse(content) as T;
@@ -65,4 +65,3 @@ export function resolvePath(path: string): string {
} }

View File

@@ -49,23 +49,28 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'TestApi', 'split',
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
// 2. Проверка создания файла // 2. Проверка создания файла
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile); const exists = await fileExists(generatedFile);
expect(exists).toBe(true); expect(exists).toBe(true);
// 3. Проверка импорта (компиляция TypeScript) // 3. Проверка импорта (компиляция TypeScript)
const testFile = join(tempDir, 'test-import.ts'); const testFile = join(tempDir, 'test-import.ts');
const testCode = ` const testCode = `
import { Api } from '${generatedFile}'; import { createApiClient, HttpClient } from '${generatedFile}';
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
const api = new Api(); const api = createApiClient(new HttpClient(), {
users: {
getAll,
},
});
console.log('Import successful'); console.log('Import successful');
`; `;
@@ -97,24 +102,7 @@ describe('E2E Generation', () => {
test('повторная генерация (перезапись файлов)', async () => { test('повторная генерация (перезапись файлов)', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const fileName = 'TestApi'; // Первая генерация с endpoints
// Первая генерация
await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.MINIMAL,
'--output',
outputPath,
'--name',
fileName,
]);
const generatedFile = join(outputPath, `${fileName}.ts`);
const firstContent = await readTextFile(generatedFile);
// Вторая генерация (перезапись)
await execa('bun', [ await execa('bun', [
'run', 'run',
CLI_PATH, CLI_PATH,
@@ -122,8 +110,25 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
fileName, '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); const secondContent = await readTextFile(generatedFile);
@@ -134,6 +139,7 @@ describe('E2E Generation', () => {
// Файл должен существовать // Файл должен существовать
const exists = await fileExists(generatedFile); const exists = await fileExists(generatedFile);
expect(exists).toBe(true); expect(exists).toBe(true);
expect(await fileExists(staleOperationFile)).toBe(false);
}, 60000); }, 60000);
test('генерация из HTTP URL', async () => { test('генерация из HTTP URL', async () => {
@@ -147,50 +153,41 @@ describe('E2E Generation', () => {
'https://petstore3.swagger.io/api/v3/openapi.json', 'https://petstore3.swagger.io/api/v3/openapi.json',
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'PetStore', 'split',
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'PetStore.ts'); const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile); const exists = await fileExists(generatedFile);
expect(exists).toBe(true); expect(exists).toBe(true);
// Проверяем что файл не пустой // Проверяем что файл не пустой
const content = await readTextFile(generatedFile); const content = await readTextFile(join(outputPath, 'http-client.ts'));
expect(content.length).toBeGreaterThan(1000); expect(content.length).toBeGreaterThan(1000);
}, 60000); }, 60000);
test('генерация с флагом --swr', async () => { test('флаг --swr больше не поддерживается', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const { exitCode } = await execa('bun', [ try {
await execa('bun', [
'run', 'run',
CLI_PATH, CLI_PATH,
'--input', '--input',
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name',
'SwrApi',
'--swr', '--swr',
]); ]);
throw new Error('Should have thrown');
expect(exitCode).toBe(0); } catch (error: any) {
expect(error.exitCode).not.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');
}, 30000); }, 30000);
test('генерация без флага --swr не содержит хуки', async () => { test('REST генерация не содержит SWR хуки', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const { exitCode } = await execa('bun', [ const { exitCode } = await execa('bun', [
@@ -200,13 +197,13 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'NoSwrApi', 'split',
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'NoSwrApi.ts'); const generatedFile = join(outputPath, 'http-client.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем отсутствие импорта useSWR // Проверяем отсутствие импорта useSWR
@@ -229,16 +226,21 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'TestApi', 'split',
]); ]);
// Динамически импортируем сгенерированный API // Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const { Api } = await import(generatedFile); const { createApiClient, HttpClient } = await import(generatedFile);
const { getAll } = await import(join(outputPath, 'operations', 'get-all.ts'));
const api = new Api(); const api = createApiClient(new HttpClient(), {
const result = await api.users.getAll(); users: {
getAll,
},
});
const result = await api.users.getAll({});
expect(Array.isArray(result)).toBe(true); expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(2); expect(result.length).toBe(2);
@@ -254,15 +256,20 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'TestApi', 'split',
]); ]);
// Динамически импортируем сгенерированный API // Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const { Api } = await import(generatedFile); const { createApiClient, HttpClient } = await import(generatedFile);
const { create } = await import(join(outputPath, 'operations', 'create.ts'));
const api = new Api(); const api = createApiClient(new HttpClient(), {
users: {
create,
},
});
const result = await api.users.create({ const result = await api.users.create({
email: 'new@example.com', email: 'new@example.com',
password: 'password123' password: 'password123'
@@ -282,17 +289,22 @@ describe('E2E Generation', () => {
FIXTURES.VALID, FIXTURES.VALID,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'TestApi', 'split',
]); ]);
const testFile = join(tempDir, 'test-404.ts'); const testFile = join(tempDir, 'test-404.ts');
const testCode = ` const testCode = `
import { Api } from '${join(outputPath, 'TestApi.ts')}'; import { createApiClient, HttpClient } from '${join(outputPath, 'index.ts')}';
import { getById } from '${join(outputPath, 'operations', 'get-by-id.ts')}';
const api = new Api(); const api = createApiClient(new HttpClient(), {
users: {
getById,
},
});
try { try {
await api.users.getById('999'); await api.users.getById({ id: '999' });
} catch (error) { } catch (error) {
console.log('error'); console.log('error');
} }
@@ -314,13 +326,15 @@ describe('E2E Generation', () => {
FIXTURES.WITH_AUTH, FIXTURES.WITH_AUTH,
'--output', '--output',
outputPath, outputPath,
'--name', '--mode',
'AuthApi', 'split',
]); ]);
// Динамически импортируем сгенерированный API // Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'AuthApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const { Api, HttpClient } = await import(generatedFile); 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 токена // Создаем HttpClient с securityWorker для добавления Bearer токена
const httpClient = new HttpClient({ const httpClient = new HttpClient({
@@ -335,7 +349,14 @@ describe('E2E Generation', () => {
} }
}); });
const api = new Api(httpClient); const api = createApiClient(httpClient, {
auth: {
login,
},
profile: {
get,
},
});
// Логин // Логин
const loginResult = await api.auth.login({ const loginResult = await api.auth.login({

View File

@@ -28,11 +28,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
// Пытаемся скомпилировать // Пытаемся скомпилировать
const { exitCode } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]); const { exitCode } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]);
@@ -46,11 +47,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
// Проверяем с помощью TypeScript компилятора // Проверяем с помощью TypeScript компилятора
const { exitCode, stderr } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]); const { exitCode, stderr } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]);
@@ -65,16 +67,17 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем экспорты // Проверяем экспорты
expect(content).toContain('export'); expect(content).toContain('export');
expect(content).toContain('class'); expect(content).toContain('createApiClient');
}, 30000); }, 30000);
}); });
@@ -85,11 +88,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем что все основные методы есть // Проверяем что все основные методы есть
@@ -97,7 +101,7 @@ describe('Generated Client', () => {
expect(content).toContain('create'); expect(content).toContain('create');
expect(content).toContain('getById'); expect(content).toContain('getById');
expect(content).toContain('update'); expect(content).toContain('update');
expect(content).toContain('delete'); expect(content).toContain('deleteUsersId');
}, 30000); }, 30000);
test('корректные имена методов (без Controller префиксов)', async () => { test('корректные имена методов (без Controller префиксов)', async () => {
@@ -106,11 +110,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем что "Controller" удален // Проверяем что "Controller" удален
@@ -128,11 +133,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'http-client.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие HttpClient // Проверяем наличие HttpClient
@@ -148,11 +154,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.WITH_AUTH, inputPath: FIXTURES.WITH_AUTH,
outputPath, outputPath,
fileName: 'AuthApi', fileName: 'AuthApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'AuthApi.ts'); const generatedFile = join(outputPath, 'http-client.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие метода для установки токена // Проверяем наличие метода для установки токена
@@ -167,11 +174,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.MINIMAL, inputPath: FIXTURES.MINIMAL,
outputPath, outputPath,
fileName: 'MinimalApi', fileName: 'MinimalApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'MinimalApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile); const exists = await fileExists(generatedFile);
expect(exists).toBe(true); expect(exists).toBe(true);
@@ -186,11 +194,12 @@ describe('Generated Client', () => {
inputPath: FIXTURES.WITH_AUTH, inputPath: FIXTURES.WITH_AUTH,
outputPath, outputPath,
fileName: 'AuthApi', fileName: 'AuthApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'AuthApi.ts'); const generatedFile = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие методов авторизации // Проверяем наличие методов авторизации
@@ -204,16 +213,17 @@ describe('Generated Client', () => {
inputPath: FIXTURES.COMPLEX, inputPath: FIXTURES.COMPLEX,
outputPath, outputPath,
fileName: 'ComplexApi', fileName: 'ComplexApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'ComplexApi.ts'); const generatedFile = join(outputPath, 'index.ts');
const exists = await fileExists(generatedFile); const exists = await fileExists(generatedFile);
expect(exists).toBe(true); expect(exists).toBe(true);
// Проверяем что файл не пустой // Проверяем что файл не пустой
const content = await readTextFile(generatedFile); const content = await readTextFile(join(outputPath, 'operations', 'index.ts'));
expect(content.length).toBeGreaterThan(1000); expect(content.length).toBeGreaterThan(1000);
}, 30000); }, 30000);
}); });

View File

@@ -47,7 +47,7 @@ describe('CLI', () => {
expect(exists).toBe(true); expect(exists).toBe(true);
}, 30000); }, 30000);
test('должен генерировать с кастомным именем файла', async () => { test('должен генерировать монолит с кастомным именем файла', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const customName = 'CustomApi'; const customName = 'CustomApi';
@@ -69,6 +69,48 @@ describe('CLI', () => {
expect(exists).toBe(true); expect(exists).toBe(true);
}, 30000); }, 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 () => { test('должен отображать версию с --version', async () => {
const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']); const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']);
@@ -78,7 +120,7 @@ describe('CLI', () => {
test('должен отображать help с --help', async () => { test('должен отображать help с --help', async () => {
const { stdout } = await execa('bun', ['run', CLI_PATH, '--help']); 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('--input');
expect(stdout).toContain('--output'); expect(stdout).toContain('--output');
}); });
@@ -141,5 +183,26 @@ describe('CLI', () => {
expect(error.exitCode).not.toBe(0); expect(error.exitCode).not.toBe(0);
} }
}, 30000); }, 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);
}); });
}); });

View File

@@ -29,7 +29,7 @@ describe('config', () => {
outputPath: './output', outputPath: './output',
}; };
expect(() => validateConfig(config)).toThrow('Input path is required'); expect(() => validateConfig(config)).toThrow('Не указан путь к OpenAPI спецификации');
}); });
test('должен выбросить ошибку без outputPath', () => { test('должен выбросить ошибку без outputPath', () => {
@@ -37,13 +37,13 @@ describe('config', () => {
inputPath: './openapi.json', inputPath: './openapi.json',
}; };
expect(() => validateConfig(config)).toThrow('Output path is required'); expect(() => validateConfig(config)).toThrow('Не указана директория для генерации');
}); });
test('должен выбросить ошибку без обоих обязательных полей', () => { test('должен выбросить ошибку без обоих обязательных полей', () => {
const config: Partial<GeneratorConfig> = {}; const config: Partial<GeneratorConfig> = {};
expect(() => validateConfig(config)).toThrow('Configuration validation failed'); expect(() => validateConfig(config)).toThrow('Ошибка конфигурации');
}); });
}); });
}); });

View File

@@ -36,7 +36,7 @@ describe('Generator', () => {
expect(exists).toBe(true); expect(exists).toBe(true);
}, 30000); }, 30000);
test('должен генерировать корректную структуру кода', async () => { test('должен добавлять русскую подпись в generated файлы', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = { const config: GeneratorConfig = {
inputPath: FIXTURES.MINIMAL, inputPath: FIXTURES.MINIMAL,
@@ -49,9 +49,54 @@ describe('Generator', () => {
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'TestApi.ts');
const content = await readTextFile(generatedFile); 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(indexContent).toContain('createApiClient');
expect(content).toContain('HttpClient'); expect(httpClientContent).toContain('export class HttpClient');
}, 30000); }, 30000);
test('должен обработать все HTTP методы', async () => { test('должен обработать все HTTP методы', async () => {
@@ -60,12 +105,13 @@ describe('Generator', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const operationsIndex = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(operationsIndex);
// Проверяем что методы UserController переименованы // Проверяем что методы UserController переименованы
// UserController_getAll -> getAll // UserController_getAll -> getAll
@@ -74,7 +120,7 @@ describe('Generator', () => {
expect(content).toContain('create'); expect(content).toContain('create');
expect(content).toContain('getById'); expect(content).toContain('getById');
expect(content).toContain('update'); expect(content).toContain('update');
expect(content).toContain('delete'); expect(content).toContain('deleteUsersId');
}, 30000); }, 30000);
test('должен генерировать типы для request и response', async () => { test('должен генерировать типы для request и response', async () => {
@@ -83,11 +129,12 @@ describe('Generator', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'data-contracts.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие типов // Проверяем наличие типов
@@ -102,15 +149,16 @@ describe('Generator', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'data-contracts.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие enum // Проверяем наличие enum
expect(content).toContain('UserRole'); expect(content).toContain('type UserRole');
expect(content).toContain('admin'); expect(content).toContain('admin');
expect(content).toContain('user'); expect(content).toContain('user');
expect(content).toContain('guest'); expect(content).toContain('guest');
@@ -122,11 +170,12 @@ describe('Generator', () => {
inputPath: FIXTURES.WITH_AUTH, inputPath: FIXTURES.WITH_AUTH,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'http-client.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем наличие методов для работы с токеном // Проверяем наличие методов для работы с токеном
@@ -139,11 +188,12 @@ describe('Generator', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'http-client.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем что baseUrl установлен // Проверяем что baseUrl установлен
@@ -156,11 +206,12 @@ describe('Generator', () => {
inputPath: FIXTURES.VALID, inputPath: FIXTURES.VALID,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем что "Controller" удален из имен методов // Проверяем что "Controller" удален из имен методов
@@ -210,11 +261,12 @@ describe('Generator', () => {
inputPath: FIXTURES.COMPLEX, inputPath: FIXTURES.COMPLEX,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'operations', 'index.ts');
const content = await readTextFile(generatedFile); const content = await readTextFile(generatedFile);
// Проверяем что все контроллеры присутствуют // Проверяем что все контроллеры присутствуют