Compare commits

...

2 Commits

Author SHA1 Message Date
fe5d3ae091 feat: сделать split-клиент режимом по умолчанию
- добавлен operationsTree для сборки полного клиента
- удален режим генерации both
- обновлена документация под npm SDK workflow
- поднята версия пакета до 4.0.0
2026-06-30 10:46:15 +03:00
bf340b3dbe feat: добавить split-режим генерации REST-клиента
- добавлен режим генерации single, split и both
- добавлены отдельные operation-файлы и createApiClient
- удалена генерация SWR-хуков и зависимости React/SWR
- обновлены CLI, шаблоны, примеры, документация и тесты
- версия пакета повышена до 3.0.0
2026-06-30 07:59:52 +03:00
21 changed files with 1399 additions and 766 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();

332
README.md
View File

@@ -1,79 +1,309 @@
# @gromlab/api-codegen # @gromlab/api-codegen
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации. CLI для генерации typed TypeScript REST SDK из OpenAPI спецификации.
## Использование Генератор рассчитан на сценарий, где REST API можно вынести в отдельный npm-пакет, а приложения будут собирать из него свои API-клиенты: полный клиент, частичный клиент или точечные вызовы отдельных операций.
## Главная Идея
По умолчанию `@gromlab/api-codegen` генерирует split-клиент.
Каждый endpoint становится отдельной typed operation-функцией. Это позволяет импортировать только нужные методы и не тащить весь API в bundle приложения.
Если нужен полный клиент, используйте `operationsTree`.
Если нужен клиент только под конкретный экран, модуль или приложение, соберите дерево операций вручную.
Если нужен максимально точечный вызов, импортируйте одну operation напрямую.
## Быстрый Старт
```bash ```bash
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--swr] npx @gromlab/api-codegen -i ./openapi.json -o ./src/generated
``` ```
**Аргументы:** Для генерации из URL:
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный файл или URL)
- `-o, --output <path>` - Директория для сохранения файлов
- `-n, --name <name>` - Имя сгенерированного файла (опционально, по умолчанию из `spec.info.title`)
- `--swr` - Генерировать SWR хуки для React
**Примеры:**
```bash ```bash
# Локальный файл npx @gromlab/api-codegen -i https://api.example.com/openapi.json -o ./src/generated
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
``` ```
## Пример использования сгенерированного кода По умолчанию будет создан split-клиент.
## Что Генерируется
```text
generated/
├── create-api-client.ts
├── data-contracts.ts
├── http-client.ts
├── index.ts
├── operations-tree.ts
└── operations/
├── index.ts
├── get-users.ts
└── create-user.ts
```
Основные части:
- `http-client.ts` - fetch-based HTTP клиент, настройки авторизации, headers, baseUrl и transport customization.
- `data-contracts.ts` - TypeScript типы из OpenAPI schemas.
- `operations/*.ts` - отдельная typed function на каждый endpoint.
- `operations/index.ts` - barrel export всех операций.
- `operations-tree.ts` - дерево всех операций для сборки полного API клиента.
- `create-api-client.ts` - helper, который привязывает дерево операций к конкретному HTTP клиенту.
- `index.ts` - основная входная точка generated SDK.
## Полный Клиент
Если приложению нужен весь API, используйте `operationsTree`.
```typescript ```typescript
import { Api, HttpClient } from './src/api/MyApi'; import { createApiClient, HttpClient, operationsTree } from './generated';
const httpClient = new HttpClient(); // 1. Инициализация HTTP-клиента: baseUrl, headers, авторизация и transport-настройки.
httpClient.setSecurityData({ token: 'jwt-token' }); const http = new HttpClient({
baseUrl: 'https://api.example.com',
});
const api = new Api(httpClient); // 2. Создание API-клиента: привязываем все сгенерированные операции к HTTP-клиенту.
const api = createApiClient(http, operationsTree);
// GET запрос // 3. Использование API-клиента: вызываем методы из дерева операций.
const user = await api.auth.getProfile(); const users = await api.users.getAll({});
const createdUser = await api.users.create({ email, password });
// POST запрос
const result = await api.auth.login({ email, password });
``` ```
## Разработка `operationsTree` намеренно лежит в отдельном файле и экспортируется отдельно. Импортируя его, вы явно выбираете полный клиент со всеми операциями.
### Сборка ## Частичный Клиент
```bash
bun run build Если приложению нужна только часть API, соберите клиент вручную.
```typescript
import { createApiClient, HttpClient } from './generated';
import {
v1AdminPharmaciesCreate,
v1AdminPharmaciesList,
v1AdminPharmaciesProfileUpdate,
} from './generated/operations';
const http = new HttpClient({
baseUrl: 'https://api.example.com',
});
const api = createApiClient(http, {
pharmaciesAdmin: {
list: v1AdminPharmaciesList,
create: v1AdminPharmaciesCreate,
updateProfile: v1AdminPharmaciesProfileUpdate,
},
});
const pharmacies = await api.pharmaciesAdmin.list({});
const createdPharmacy = await api.pharmaciesAdmin.create(payload);
await api.pharmaciesAdmin.updateProfile({ id }, payload);
``` ```
### Тестирование Так можно собрать отдельный клиент для админки, публичного сайта, мобильного приложения или отдельного feature-модуля.
`operations/index.ts` реэкспортит все operation-функции, поэтому можно делать named imports из `./generated/operations`. В ESM-сборке такой импорт остается tree-shaking friendly: в клиенте используются только явно выбранные операции.
Если OpenAPI большой и полный SDK занимает десятки тысяч строк, приложение не обязано тянуть весь набор методов в свой чанк. Можно собрать минимально рабочий API-клиент под конкретный сценарий.
Если нужен максимально строгий контроль над тем, какой файл попадет в bundle, импортируйте operation напрямую из ее файла:
```typescript
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
```
## Точечный Вызов Operation
Если по какой-то причине нужно вызвать только одну операцию, можно не собирать API-клиент через `createApiClient` и вызвать operation напрямую.
```typescript
import { HttpClient } from './generated/http-client';
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
const http = new HttpClient({
baseUrl: 'https://api.example.com',
});
const pharmacies = await v1AdminPharmaciesList(http, {});
```
Это полезно для разовых сценариев: health-check, bootstrap-запроса, утилитного скрипта или кода, где полноценный объект `api` не нужен.
## Кастомный HTTP Клиент
`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать авторизацию, headers, baseUrl и transport behavior.
```typescript
import { HttpClient } from './generated';
type SecurityData = {
token: string;
};
const http = new HttpClient<SecurityData>({
baseUrl: 'https://api.example.com',
baseApiParams: {
headers: {
'X-App-Version': '1.0.0',
},
},
securityWorker: (securityData) => {
if (!securityData?.token) {
return undefined;
}
return {
headers: {
Authorization: `Bearer ${securityData.token}`,
},
};
},
});
http.setSecurityData({ token: 'jwt-token' });
```
Для отдельного вызова можно передать дополнительные request params последним аргументом operation.
```typescript
await api.users.getAll(
{},
{
headers: {
'X-Request-Id': requestId,
},
},
);
```
## API Как npm-Пакет
Типичный workflow:
1. Backend публикует OpenAPI спецификацию.
2. Отдельный пакет генерирует typed REST SDK в `src/generated`.
3. Пакет экспортирует generated entrypoint.
4. Приложения импортируют SDK и собирают нужные API-клиенты.
Пример структуры пакета:
```text
packages/my-api/
├── package.json
└── src/
├── generated/
└── index.ts
```
`src/index.ts`:
```typescript
export * from './generated';
```
Использование в приложении:
```typescript
import { createApiClient, HttpClient, operationsTree } from '@company/my-api';
const http = new HttpClient({
baseUrl: 'https://api.example.com',
});
export const api = createApiClient(http, operationsTree);
```
Если приложение хочет выбрать только часть API:
```typescript
import { createApiClient, HttpClient } from '@company/my-api';
import {
v1AdminPharmaciesList,
v1AdminPharmaciesProfileUpdate,
} from '@company/my-api/operations';
export const api = createApiClient(new HttpClient(), {
pharmaciesAdmin: {
list: v1AdminPharmaciesList,
updateProfile: v1AdminPharmaciesProfileUpdate,
},
});
```
## Режимы Генерации
### split
Режим по умолчанию.
```bash ```bash
# Все тесты api-codegen -i ./openapi.json -o ./src/generated
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). Эквивалентно:
```bash
api-codegen -i ./openapi.json -o ./src/generated --mode split
```
Используйте этот режим для новых проектов, npm SDK пакетов и приложений, где важны модульность и tree-shaking.
### single
Legacy-режим, который генерирует один монолитный TypeScript файл.
```bash
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
```
Результат:
```text
generated/
└── MyApi.ts
```
Используйте `single`, если проект уже завязан на старый монолитный generated-клиент.
## CLI
```bash
api-codegen -i <input> -o <output> [--mode split|single] [-n <name>]
```
Аргументы:
- `-i, --input <path>` - путь к OpenAPI файлу или URL.
- `-o, --output <path>` - директория для generated файлов.
- `--mode <mode>` - режим генерации: `split` или `single`. По умолчанию `split`.
- `-n, --name <name>` - имя файла для `single` режима без расширения `.ts`.
- `--single-file` - устаревший алиас для `--mode single`.
## Примеры Команд
Split из локального файла:
```bash
api-codegen -i ./openapi.json -o ./src/generated
```
Split из URL:
```bash
api-codegen -i https://api.example.com/openapi.json -o ./src/generated
```
Legacy single файл:
```bash
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
```
## Лицензия ## Лицензия

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": "4.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>', 'Режим генерации: split, single', 'split')
.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';
/** /**
* Конфигурация генератора API * Конфигурация генератора API
*/ */
@@ -6,10 +8,10 @@ export interface GeneratorConfig {
inputPath: string; inputPath: string;
/** Путь для сохранения сгенерированных файлов */ /** Путь для сохранения сгенерированных файлов */
outputPath: string; outputPath: string;
/** Имя сгенерированного файла (без расширения) */ /** Имя сгенерированного файла (без расширения), используется в single режиме */
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'].includes(config.mode)) {
errors.push('Некорректный режим генерации (--mode). Доступные значения: split, single');
} }
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,304 @@
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;
};
type OperationTreeGroup = {
moduleName: string;
operations: OperationFileInfo[];
};
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');
}
function createObjectPropertyKey(name: string): string {
return JSON.stringify(name);
}
function createOperationTreePropertyName(operationFile: OperationFileInfo): string {
return operationFile.route.routeName.usage || operationFile.operationName;
}
function createOperationTreeContent(configuration: GenerateApiConfiguration, operationFiles: OperationFileInfo[]): string {
const operationByRoute = new Map(operationFiles.map((operationFile) => [operationFile.route, operationFile]));
const imports = operationFiles.map(
(operationFile) => `import { ${operationFile.operationName} } from "./operations/${operationFile.fileName}";`,
);
const outOfModuleOperations = (configuration.routes.outOfModule || [])
.map((route) => operationByRoute.get(route))
.filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile));
const operationTreeGroups: OperationTreeGroup[] = (configuration.routes.combined || []).map(({ moduleName, routes }) => ({
moduleName,
operations: (routes || [])
.map((route) => operationByRoute.get(route))
.filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile)),
}));
const lines = [
...imports,
'',
'export const operationsTree = {',
];
for (const operationFile of outOfModuleOperations) {
lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
}
for (const { moduleName, operations } of operationTreeGroups) {
lines.push(` ${createObjectPropertyKey(moduleName)}: {`);
for (const operationFile of operations) {
lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
}
lines.push(' },');
}
lines.push('} as const;', '', 'export type OperationsTree = typeof operationsTree;');
return lines.join('\n');
}
async function renderTemplateFile(
generatorOutput: GenerateApiOutput,
templatePath: string,
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 }),
rm(join(outputDir, 'operations-tree.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 +322,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 +344,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 +365,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 || 'split';
const shouldGenerateSingle = mode === 'single';
const shouldGenerateSplit = mode === 'split';
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 +391,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 +410,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 +425,147 @@ 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, 'operations-tree.ts'),
createOperationTreeContent(generatorOutput.configuration, operationFiles),
);
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 { operationsTree } from "./operations-tree";',
'export type { OperationsTree } from "./operations-tree";',
'export * as operations from "./operations";',
'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,30 @@ 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, operationsTree } from '${generatedFile}';
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
const api = new Api(); const api = createApiClient(new HttpClient(), {
users: {
getAll,
},
});
const fullApi = createApiClient(new HttpClient(), operationsTree);
fullApi.getAll;
console.log('Import successful'); console.log('Import successful');
`; `;
@@ -90,31 +97,15 @@ describe('E2E Generation', () => {
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'MinimalAPI.ts'); expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
const exists = await fileExists(generatedFile); expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
expect(exists).toBe(true); expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000); }, 30000);
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 +113,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 +142,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 +156,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 +200,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 +229,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 +259,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 +292,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 +329,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 +352,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

@@ -42,12 +42,13 @@ describe('CLI', () => {
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'MinimalAPI.ts'); expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
const exists = await fileExists(generatedFile); expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
expect(exists).toBe(true); expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000); }, 30000);
test('должен генерировать с кастомным именем файла', async () => { test('должен генерировать монолит с кастомным именем файла', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const customName = 'CustomApi'; const customName = 'CustomApi';
@@ -60,6 +61,8 @@ describe('CLI', () => {
outputPath, outputPath,
'--name', '--name',
customName, customName,
'--mode',
'single',
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
@@ -69,6 +72,49 @@ 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-tree.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
test('должен отклонять удаленный both режим', async () => {
const outputPath = join(tempDir, 'output');
try {
await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.MINIMAL,
'--output',
outputPath,
'--mode',
'both',
]);
throw new Error('Should have thrown');
} catch (error: any) {
expect(error.exitCode).not.toBe(0);
expect(error.stderr).toContain('Некорректный режим генерации');
expect(error.stderr).toContain('split, single');
}
}, 30000);
test('должен отображать версию с --version', async () => { test('должен отображать версию с --version', async () => {
const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']); const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']);
@@ -78,7 +124,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 +187,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,23 @@ 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('Ошибка конфигурации');
});
test('должен выбросить ошибку для удаленного both режима', () => {
const config = {
inputPath: './openapi.json',
outputPath: './output',
mode: 'both',
};
expect(() => validateConfig(config as Partial<GeneratorConfig>)).toThrow('Доступные значения: split, single');
}); });
}); });
}); });

View File

@@ -21,7 +21,7 @@ describe('Generator', () => {
}); });
describe('корректная генерация', () => { describe('корректная генерация', () => {
test('должен создать выходной файл', async () => { test('должен по умолчанию создать split структуру', async () => {
const outputPath = join(tempDir, 'output'); const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = { const config: GeneratorConfig = {
inputPath: FIXTURES.MINIMAL, inputPath: FIXTURES.MINIMAL,
@@ -31,9 +31,53 @@ describe('Generator', () => {
await generate(config); await generate(config);
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
}, 30000);
test('должен добавлять русскую подпись в generated файлы', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.MINIMAL,
outputPath,
fileName: 'TestApi',
mode: 'single',
};
await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const generatedFile = join(outputPath, 'TestApi.ts');
const exists = await fileExists(generatedFile); const content = await readTextFile(generatedFile);
expect(exists).toBe(true);
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); }, 30000);
test('должен генерировать корректную структуру кода', async () => { test('должен генерировать корректную структуру кода', async () => {
@@ -42,16 +86,84 @@ describe('Generator', () => {
inputPath: FIXTURES.MINIMAL, inputPath: FIXTURES.MINIMAL,
outputPath, outputPath,
fileName: 'TestApi', fileName: 'TestApi',
mode: 'split',
}; };
await generate(config); await generate(config);
const generatedFile = join(outputPath, 'TestApi.ts'); const indexFile = join(outputPath, 'index.ts');
const content = await readTextFile(generatedFile); const httpClientFile = join(outputPath, 'http-client.ts');
const operationsTreeFile = join(outputPath, 'operations-tree.ts');
const indexContent = await readTextFile(indexFile);
const httpClientContent = await readTextFile(httpClientFile);
const operationsTreeContent = await readTextFile(operationsTreeFile);
// Проверяем наличие основных элементов // Проверяем наличие основных элементов
expect(content).toContain('export class'); expect(indexContent).toContain('createApiClient');
expect(content).toContain('HttpClient'); expect(indexContent).toContain('export { operationsTree } from "./operations-tree"');
expect(indexContent).toContain('export type { OperationsTree } from "./operations-tree"');
expect(indexContent).toContain('export * as operations from "./operations"');
expect(httpClientContent).toContain('export class HttpClient');
expect(operationsTreeContent).toContain('export const operationsTree');
expect(operationsTreeContent).toContain('export type OperationsTree');
}, 30000);
test('должен генерировать operationsTree с группировкой по тегам', async () => {
const inputPath = join(tempDir, 'tagged.json');
const outputPath = join(tempDir, 'output');
await Bun.write(inputPath, JSON.stringify({
openapi: '3.0.0',
info: {
title: 'Tagged API',
version: '1.0.0',
},
paths: {
'/users': {
get: {
tags: ['users'],
operationId: 'UserController_getAll',
responses: {
200: { description: 'OK' },
},
},
post: {
tags: ['users'],
operationId: 'UserController_create',
responses: {
201: { description: 'Created' },
},
},
},
'/auth/login': {
post: {
tags: ['auth'],
operationId: 'AuthController_login',
responses: {
200: { description: 'OK' },
},
},
},
},
}));
await generate({
inputPath,
outputPath,
fileName: 'TaggedApi',
mode: 'split',
});
const content = await readTextFile(join(outputPath, 'operations-tree.ts'));
expect(content).toContain('import { create } from "./operations/create"');
expect(content).toContain('import { getAll } from "./operations/get-all"');
expect(content).toContain('import { login } from "./operations/login"');
expect(content).toContain('users: {');
expect(content).toContain('getAll: getAll');
expect(content).toContain('create: create');
expect(content).toContain('auth: {');
expect(content).toContain('login: login');
}, 30000); }, 30000);
test('должен обработать все HTTP методы', async () => { test('должен обработать все HTTP методы', async () => {
@@ -60,12 +172,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 +187,27 @@ 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);
test('должен генерировать индексный реэкспорт всех operations', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.VALID,
outputPath,
fileName: 'TestApi',
mode: 'split',
};
await generate(config);
const content = await readTextFile(join(outputPath, 'operations', 'index.ts'));
expect(content).toContain('export { create } from "./create";');
expect(content).toContain('export { deleteUsersId } from "./delete-users-id";');
expect(content).toContain('export { getAll } from "./get-all";');
expect(content).toContain('export { getById } from "./get-by-id";');
expect(content).toContain('export { update } from "./update";');
}, 30000); }, 30000);
test('должен генерировать типы для request и response', async () => { test('должен генерировать типы для request и response', async () => {
@@ -83,11 +216,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 +236,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 +257,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 +275,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 +293,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" удален из имен методов
@@ -184,7 +322,7 @@ describe('Generator', () => {
// Генерация должна пройти успешно // Генерация должна пройти успешно
await generate(config); await generate(config);
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);
}, 30000); }, 30000);
@@ -199,7 +337,7 @@ describe('Generator', () => {
await generate(config); await generate(config);
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);
}, 30000); }, 30000);
@@ -210,11 +348,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);
// Проверяем что все контроллеры присутствуют // Проверяем что все контроллеры присутствуют
@@ -233,7 +372,7 @@ describe('Generator', () => {
// Генерация должна пройти успешно даже с Unicode // Генерация должна пройти успешно даже с Unicode
await generate(config); await generate(config);
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);
}, 30000); }, 30000);