feat: добавить split-режим генерации REST-клиента
- добавлен режим генерации single, split и both - добавлены отдельные operation-файлы и createApiClient - удалена генерация SWR-хуков и зависимости React/SWR - обновлены CLI, шаблоны, примеры, документация и тесты - версия пакета повышена до 3.0.0
This commit is contained in:
27
AGENTS.md
27
AGENTS.md
@@ -29,10 +29,13 @@ CLI утилита для автоматической генерации TypeSc
|
||||
- Чтение OpenAPI спецификации
|
||||
- Применение кастомных шаблонов EJS
|
||||
- Генерация TypeScript кода
|
||||
3. **Выходные данные**: 3 файла в указанной директории:
|
||||
- `{FileName}.ts` - API endpoints с методами
|
||||
3. **Выходные данные по умолчанию**: legacy монолитный файл `{FileName}.ts`
|
||||
4. **Выходные данные в `split` режиме**: tree-shaking friendly структура:
|
||||
- `http-client.ts` - HTTP клиент с настройками
|
||||
- `data-contracts.ts` - TypeScript типы
|
||||
- `create-api-client.ts` - helper для привязки выбранного графа методов к клиенту
|
||||
- `operations/*.ts` - один endpoint на файл
|
||||
- `index.ts` - barrel exports
|
||||
|
||||
## Ключевые особенности
|
||||
|
||||
@@ -51,13 +54,15 @@ CLI утилита для автоматической генерации TypeSc
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>]
|
||||
api-codegen -i <путь-к-openapi> -o <выходная-директория> [-n <имя-файла>] [--mode single|split|both]
|
||||
```
|
||||
|
||||
### Аргументы
|
||||
- `-i, --input` - путь к OpenAPI файлу (локальный или URL)
|
||||
- `-o, --output` - директория для сохранения
|
||||
- `-n, --name` - опциональное имя файла (по умолчанию из `spec.info.title`)
|
||||
- `-n, --name` - имя монолитного файла без `.ts`
|
||||
- `--mode` - режим генерации: `single` (по умолчанию), `split`, `both`
|
||||
- `--single-file` - устаревший алиас для `--mode single`
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -68,6 +73,8 @@ src/
|
||||
├── generator.ts # Основная логика генерации
|
||||
├── templates/ # EJS шаблоны
|
||||
│ ├── api.ejs
|
||||
│ ├── operation.ejs
|
||||
│ ├── create-api-client.ejs
|
||||
│ ├── http-client.ejs
|
||||
│ ├── data-contracts.ejs
|
||||
│ └── ...
|
||||
@@ -84,16 +91,24 @@ src/
|
||||
- `extractRequestParams: true` - извлечение параметров запросов
|
||||
- `extractRequestBody: true` - извлечение тел запросов
|
||||
- `extractEnums: true` - извлечение enum типов
|
||||
- `generateUnionEnums: true` в `split` режиме - enum схемы генерируются как type union без runtime-кода
|
||||
|
||||
## Примеры использования сгенерированного кода
|
||||
|
||||
```typescript
|
||||
import { Api, HttpClient } from './Api';
|
||||
import { createApiClient, HttpClient } from './generated';
|
||||
import { getProfile } from './generated/operations/get-profile';
|
||||
import { login } from './generated/operations/login';
|
||||
|
||||
const httpClient = new HttpClient();
|
||||
httpClient.setSecurityData({ token: 'jwt-token' });
|
||||
|
||||
const api = new Api(httpClient);
|
||||
const api = createApiClient(httpClient, {
|
||||
auth: {
|
||||
getProfile,
|
||||
login,
|
||||
},
|
||||
});
|
||||
|
||||
// Вызов API методов
|
||||
const user = await api.auth.getProfile();
|
||||
|
||||
120
README.md
120
README.md
@@ -1,20 +1,21 @@
|
||||
# @gromlab/api-codegen
|
||||
|
||||
CLI утилита для генерации TypeScript API клиента из OpenAPI спецификации.
|
||||
CLI утилита для генерации TypeScript REST-клиента из OpenAPI спецификации.
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--swr]
|
||||
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--mode single|split|both]
|
||||
```
|
||||
|
||||
**Аргументы:**
|
||||
- `-i, --input <path>` - Путь к OpenAPI файлу (локальный файл или URL)
|
||||
- `-o, --output <path>` - Директория для сохранения файлов
|
||||
- `-n, --name <name>` - Имя сгенерированного файла (опционально, по умолчанию из `spec.info.title`)
|
||||
- `--swr` - Генерировать SWR хуки для React
|
||||
- `-i, --input <path>` - путь к OpenAPI файлу или URL
|
||||
- `-o, --output <path>` - директория для сохранения файлов
|
||||
- `-n, --name <name>` - имя монолитного файла без `.ts`
|
||||
- `--mode <mode>` - режим генерации: `single`, `split`, `both` (по умолчанию `single`)
|
||||
- `--single-file` - устаревший алиас для `--mode single`
|
||||
|
||||
**Примеры:**
|
||||
## Примеры
|
||||
|
||||
```bash
|
||||
# Локальный файл
|
||||
@@ -23,33 +24,103 @@ npx @gromlab/api-codegen -i ./openapi.json -o ./src/api
|
||||
# URL на спецификацию
|
||||
npx @gromlab/api-codegen -i https://httpbin.org/spec.json -o ./src/api
|
||||
|
||||
# С кастомным именем файла
|
||||
# Монолитный файл с кастомным именем
|
||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi
|
||||
|
||||
# С генерацией SWR хуков
|
||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --swr
|
||||
# Разложенный tree-shaking friendly клиент
|
||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --mode split
|
||||
|
||||
# Монолит и разложенный клиент одновременно
|
||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi --mode both
|
||||
```
|
||||
|
||||
## Пример использования сгенерированного кода
|
||||
## Структура вывода
|
||||
|
||||
По умолчанию генерируется legacy монолитный клиент для обратной совместимости:
|
||||
|
||||
```text
|
||||
generated/
|
||||
└── MyApi.ts
|
||||
```
|
||||
|
||||
В режиме `split` генерируется tree-shaking friendly структура:
|
||||
|
||||
```text
|
||||
generated/
|
||||
├── create-api-client.ts
|
||||
├── data-contracts.ts
|
||||
├── http-client.ts
|
||||
├── index.ts
|
||||
└── operations/
|
||||
├── index.ts
|
||||
├── get-users.ts
|
||||
└── create-user.ts
|
||||
```
|
||||
|
||||
Каждый endpoint лежит в отдельном файле внутри `operations/`.
|
||||
|
||||
В режиме `both` генерируются оба варианта рядом.
|
||||
|
||||
## Пример Использования
|
||||
|
||||
Для `single` режима:
|
||||
|
||||
```typescript
|
||||
import { Api, HttpClient } from './src/api/MyApi';
|
||||
import { Api, HttpClient } from './generated/MyApi';
|
||||
|
||||
const httpClient = new HttpClient();
|
||||
httpClient.setSecurityData({ token: 'jwt-token' });
|
||||
const http = new HttpClient({ baseUrl: 'https://api.example.com' });
|
||||
const api = new Api(http);
|
||||
|
||||
const api = new Api(httpClient);
|
||||
const users = await api.users.getAll({});
|
||||
```
|
||||
|
||||
// GET запрос
|
||||
const user = await api.auth.getProfile();
|
||||
Для `split` режима:
|
||||
|
||||
// POST запрос
|
||||
const result = await api.auth.login({ email, password });
|
||||
```typescript
|
||||
import { createApiClient, HttpClient } from './generated';
|
||||
import { getAll } from './generated/operations/get-all';
|
||||
import { create } from './generated/operations/create';
|
||||
|
||||
const http = new HttpClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
securityWorker: (securityData: { token: string } | null) => {
|
||||
if (!securityData?.token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${securityData.token}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const api = createApiClient(http, {
|
||||
users: {
|
||||
getAll,
|
||||
create,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await api.users.getAll({});
|
||||
const createdUser = await api.users.create({ email, password });
|
||||
```
|
||||
|
||||
Если нужен максимально точечный импорт, operation можно вызвать напрямую:
|
||||
|
||||
```typescript
|
||||
import { HttpClient } from './generated/http-client';
|
||||
import { getAll } from './generated/operations/get-all';
|
||||
|
||||
const http = new HttpClient();
|
||||
const users = await getAll(http, {});
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
@@ -57,20 +128,9 @@ bun run build
|
||||
### Тестирование
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
bun test
|
||||
|
||||
# Юнит тесты
|
||||
bun run test:unit
|
||||
|
||||
# Интеграционные тесты
|
||||
bun run test:integration
|
||||
|
||||
# Watch режим
|
||||
bun run test:watch
|
||||
|
||||
# С coverage
|
||||
bun run test:coverage
|
||||
```
|
||||
|
||||
Подробная документация по тестированию в [`tests/README.md`](tests/README.md).
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -17,12 +17,9 @@
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"execa": "^8.0.0",
|
||||
"msw": "^2.0.0",
|
||||
"react": "^18.3.0",
|
||||
"swr": "^2.3.0",
|
||||
"tmp": "^0.2.1",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -125,8 +122,6 @@
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
@@ -175,14 +170,10 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
@@ -239,8 +230,6 @@
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
|
||||
@@ -283,8 +272,6 @@
|
||||
|
||||
"swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
|
||||
|
||||
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="],
|
||||
@@ -305,8 +292,6 @@
|
||||
|
||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
160
example.ts
160
example.ts
@@ -1,136 +1,72 @@
|
||||
/**
|
||||
* Пример использования сгенерированного API клиента
|
||||
* Пример использования сгенерированного tree-shaking friendly REST-клиента.
|
||||
*/
|
||||
// @ts-nocheck
|
||||
|
||||
import { Api, HttpClient } from './output/Api';
|
||||
import { createApiClient, HttpClient } from './output';
|
||||
import { getProfile } from './output/operations/get-profile';
|
||||
import { login } from './output/operations/login';
|
||||
import { register } from './output/operations/register';
|
||||
|
||||
// 1. Создание HTTP клиента с базовыми настройками
|
||||
const httpClient = new HttpClient({
|
||||
baseUrl: 'https://cdn.example.com', // Базовый URL (уже установлен при генерации)
|
||||
type SecurityData = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const httpClient = new HttpClient<SecurityData>({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseApiParams: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
securityWorker: (securityData) => {
|
||||
if (!securityData?.token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${securityData.token}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Создание API клиента
|
||||
const api = new Api(httpClient);
|
||||
|
||||
// 3. Пример использования
|
||||
const api = createApiClient(httpClient, {
|
||||
auth: {
|
||||
register,
|
||||
login,
|
||||
getProfile,
|
||||
},
|
||||
});
|
||||
|
||||
async function registerUser() {
|
||||
try {
|
||||
const result = await api.auth.register({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123',
|
||||
firstName: 'Иван',
|
||||
lastName: 'Иванов',
|
||||
});
|
||||
const result = await api.auth.register({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123',
|
||||
firstName: 'Иван',
|
||||
lastName: 'Иванов',
|
||||
});
|
||||
|
||||
console.log('Пользователь зарегистрирован:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации:', error);
|
||||
throw error;
|
||||
}
|
||||
console.log('Пользователь зарегистрирован:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loginUser() {
|
||||
try {
|
||||
const result = await api.auth.login({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123',
|
||||
});
|
||||
const result = await api.auth.login({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123',
|
||||
});
|
||||
|
||||
console.log('Авторизация успешна');
|
||||
httpClient.setSecurityData({ token: result.access_token });
|
||||
|
||||
// Сохраняем токен для последующих запросов
|
||||
httpClient.setSecurityData({ token: result.access_token });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка авторизации:', error);
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getUserProfile() {
|
||||
try {
|
||||
const profile = await api.auth.getProfile();
|
||||
console.log('Профиль пользователя:', profile);
|
||||
return profile;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения профиля:', error);
|
||||
throw error;
|
||||
}
|
||||
const profile = await api.auth.getProfile();
|
||||
console.log('Профиль пользователя:', profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
try {
|
||||
const project = await api.projects.create({
|
||||
name: 'Мой CDN проект',
|
||||
description: 'Проект для хранения статических файлов',
|
||||
s3Endpoint: 'https://s3.amazonaws.com',
|
||||
s3Bucket: 'my-cdn-bucket',
|
||||
s3Region: 'us-east-1',
|
||||
s3AccessKey: 'AKIAIOSFODNN7EXAMPLE',
|
||||
s3SecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
});
|
||||
|
||||
console.log('Проект создан:', project);
|
||||
return project;
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания проекта:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllProjects() {
|
||||
try {
|
||||
const projects = await api.projects.findAll();
|
||||
console.log('Список проектов:', projects);
|
||||
return projects;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения проектов:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Главная функция для демонстрации
|
||||
async function main() {
|
||||
console.log('🚀 Пример использования API клиента\n');
|
||||
|
||||
// 1. Регистрация
|
||||
console.log('1. Регистрация пользователя...');
|
||||
await registerUser();
|
||||
console.log('✅ Готово\n');
|
||||
|
||||
// 2. Авторизация
|
||||
console.log('2. Авторизация...');
|
||||
await loginUser();
|
||||
console.log('✅ Готово\n');
|
||||
|
||||
// 3. Получение профиля
|
||||
console.log('3. Получение профиля...');
|
||||
await getUserProfile();
|
||||
console.log('✅ Готово\n');
|
||||
|
||||
// 4. Создание проекта
|
||||
console.log('4. Создание проекта...');
|
||||
await createProject();
|
||||
console.log('✅ Готово\n');
|
||||
|
||||
// 5. Получение всех проектов
|
||||
console.log('5. Получение списка проектов...');
|
||||
await getAllProjects();
|
||||
console.log('✅ Готово\n');
|
||||
|
||||
console.log('✨ Все операции выполнены успешно!');
|
||||
}
|
||||
|
||||
// Запуск примера (раскомментируйте для выполнения)
|
||||
// main().catch(console.error);
|
||||
|
||||
export { registerUser, loginUser, getUserProfile, createProject, getAllProjects };
|
||||
|
||||
export { getUserProfile, loginUser, registerUser };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gromlab/api-codegen",
|
||||
"version": "1.0.7",
|
||||
"version": "3.0.0",
|
||||
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -37,12 +40,9 @@
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"execa": "^8.0.0",
|
||||
"msw": "^2.0.0",
|
||||
"react": "^18.3.0",
|
||||
"swr": "^2.3.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
33
src/cli.ts
33
src/cli.ts
@@ -15,14 +15,26 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')
|
||||
|
||||
const program = new Command();
|
||||
|
||||
const translateCommanderError = (message: string): string => {
|
||||
return message
|
||||
.replace(/^error:/, 'ошибка:')
|
||||
.replace('unknown option', 'неизвестная опция')
|
||||
.replace('too many arguments', 'слишком много аргументов')
|
||||
.replace('option', 'опция');
|
||||
};
|
||||
|
||||
program
|
||||
.name('api-codegen')
|
||||
.description('Generate TypeScript API client from OpenAPI specification')
|
||||
.configureOutput({
|
||||
writeErr: (message) => process.stderr.write(translateCommanderError(message)),
|
||||
})
|
||||
.description('Генерация TypeScript API клиента из OpenAPI спецификации')
|
||||
.version(pkg.version)
|
||||
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
|
||||
.requiredOption('-o, --output <path>', 'Output directory for generated files')
|
||||
.option('-n, --name <name>', 'Name of generated file (without extension)')
|
||||
.option('--swr', 'Generate SWR hooks for React')
|
||||
.option('-i, --input <path>', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
|
||||
.option('-o, --output <path>', 'Директория для сохранения сгенерированных файлов')
|
||||
.option('-n, --name <name>', 'Имя монолитного клиента без расширения .ts')
|
||||
.option('--mode <mode>', 'Режим генерации: single, split, both', 'single')
|
||||
.option('--single-file', 'Устаревший алиас для --mode single')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Создание конфигурации
|
||||
@@ -30,7 +42,7 @@ program
|
||||
inputPath: options.input,
|
||||
outputPath: options.output,
|
||||
fileName: options.name,
|
||||
useSwr: options.swr || false,
|
||||
mode: options.singleFile ? 'single' : options.mode,
|
||||
};
|
||||
|
||||
// Валидация конфигурации
|
||||
@@ -39,7 +51,7 @@ program
|
||||
// Проверка существования входного файла (только для локальных файлов)
|
||||
if (!config.inputPath!.startsWith('http://') && !config.inputPath!.startsWith('https://')) {
|
||||
if (!(await fileExists(config.inputPath!))) {
|
||||
console.error(chalk.red(`\n❌ Error: Input file not found: ${config.inputPath}\n`));
|
||||
console.error(chalk.red(`\n❌ Ошибка: входной файл не найден: ${config.inputPath}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -47,15 +59,12 @@ program
|
||||
// Генерация API
|
||||
await generate(config as GeneratorConfig);
|
||||
|
||||
console.log(chalk.green('\n✨ API client generated successfully!\n'));
|
||||
console.log(chalk.green('\n✨ API клиент успешно сгенерирован!\n'));
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error:'), error instanceof Error ? error.message : error);
|
||||
console.error(chalk.red('\n❌ Ошибка:'), error instanceof Error ? error.message : error);
|
||||
console.error();
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type GeneratorMode = 'single' | 'split' | 'both';
|
||||
|
||||
/**
|
||||
* Конфигурация генератора API
|
||||
*/
|
||||
@@ -6,10 +8,10 @@ export interface GeneratorConfig {
|
||||
inputPath: string;
|
||||
/** Путь для сохранения сгенерированных файлов */
|
||||
outputPath: string;
|
||||
/** Имя сгенерированного файла (без расширения) */
|
||||
/** Имя сгенерированного файла (без расширения), используется в single/both режиме */
|
||||
fileName?: string;
|
||||
/** Генерировать SWR hooks для React */
|
||||
useSwr?: boolean;
|
||||
/** Режим генерации */
|
||||
mode?: GeneratorMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,19 +21,20 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.inputPath) {
|
||||
errors.push('Input path is required (--input)');
|
||||
errors.push('Не указан путь к OpenAPI спецификации (--input)');
|
||||
}
|
||||
|
||||
if (!config.outputPath) {
|
||||
errors.push('Output path is required (--output)');
|
||||
errors.push('Не указана директория для генерации (--output)');
|
||||
}
|
||||
|
||||
if (config.mode && !['single', 'split', 'both'].includes(config.mode)) {
|
||||
errors.push('Некорректный режим генерации (--mode). Доступные значения: single, split, both');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Configuration validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
||||
throw new Error(`Ошибка конфигурации:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
501
src/generator.ts
501
src/generator.ts
@@ -1,14 +1,251 @@
|
||||
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
|
||||
import {
|
||||
generateApi as swaggerGenerateApi,
|
||||
type GenerateApiConfiguration,
|
||||
type GenerateApiOutput,
|
||||
type ModelType,
|
||||
type ParsedRoute,
|
||||
} from 'swagger-typescript-api';
|
||||
import { resolve, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { rm } from 'fs/promises';
|
||||
import type { GeneratorConfig } from './config.js';
|
||||
import { ensureDir, readJsonFile } from './utils/file.js';
|
||||
import { ensureDir, readJsonFile, readTextFile, writeFileWithDirs } from './utils/file.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const GENERATED_FILE_PREFIX = `/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
|
||||
/*
|
||||
* ----------------------------------------------------------------------
|
||||
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
|
||||
* ## ##
|
||||
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
|
||||
* ## Для изменений перегенерируйте клиент. ##
|
||||
* ## ##
|
||||
* ## Генератор: @gromlab/api-codegen ##
|
||||
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
|
||||
* ----------------------------------------------------------------------
|
||||
*/`;
|
||||
|
||||
type OperationFileInfo = {
|
||||
route: ParsedRoute;
|
||||
operationName: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
const RESERVED_IDENTIFIERS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'enum',
|
||||
'export',
|
||||
'extends',
|
||||
'false',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'new',
|
||||
'null',
|
||||
'return',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'true',
|
||||
'try',
|
||||
'typeof',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'let',
|
||||
'static',
|
||||
'implements',
|
||||
'interface',
|
||||
'package',
|
||||
'private',
|
||||
'protected',
|
||||
'public',
|
||||
]);
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function toWords(value: string): string[] {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[^A-Za-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toCamelCase(value: string): string {
|
||||
const words = toWords(value);
|
||||
if (!words.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [firstWord = '', ...restWords] = words;
|
||||
return [
|
||||
firstWord.toLowerCase(),
|
||||
...restWords.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function toKebabCase(value: string): string {
|
||||
return toWords(value).map((word) => word.toLowerCase()).join('-');
|
||||
}
|
||||
|
||||
function createFallbackRouteName(route: ParsedRoute): string {
|
||||
return toCamelCase(`${route.raw.method} ${route.raw.route}`) || 'operation';
|
||||
}
|
||||
|
||||
function createSafeIdentifier(value: string, fallback: string): string {
|
||||
const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
||||
const safeIdentifier = identifier || fallback;
|
||||
|
||||
if (RESERVED_IDENTIFIERS.has(safeIdentifier)) {
|
||||
const safeFallback = fallback.replace(/[^A-Za-z0-9_$]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
||||
|
||||
if (safeFallback && !RESERVED_IDENTIFIERS.has(safeFallback) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safeFallback)) {
|
||||
return safeFallback;
|
||||
}
|
||||
|
||||
return `${safeIdentifier}Operation`;
|
||||
}
|
||||
|
||||
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safeIdentifier)) {
|
||||
return safeIdentifier;
|
||||
}
|
||||
|
||||
return `operation${safeIdentifier.charAt(0).toUpperCase()}${safeIdentifier.slice(1)}`;
|
||||
}
|
||||
|
||||
function createUniqueName(baseName: string, usedNames: Set<string>): string {
|
||||
let name = baseName;
|
||||
let counter = 2;
|
||||
|
||||
while (usedNames.has(name)) {
|
||||
name = `${baseName}${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
usedNames.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function getAllRoutes(configuration: GenerateApiConfiguration): ParsedRoute[] {
|
||||
return [
|
||||
...(configuration.routes.outOfModule || []),
|
||||
...(configuration.routes.combined || []).flatMap(({ routes }) => routes || []),
|
||||
];
|
||||
}
|
||||
|
||||
function createOperationFiles(routes: ParsedRoute[]): OperationFileInfo[] {
|
||||
const usedOperationNames = new Set<string>();
|
||||
const usedFileNames = new Set<string>();
|
||||
|
||||
return routes.map((route) => {
|
||||
const fallbackRouteName = createFallbackRouteName(route);
|
||||
const operationName = createUniqueName(
|
||||
createSafeIdentifier(route.routeName.usage || fallbackRouteName, fallbackRouteName),
|
||||
usedOperationNames,
|
||||
);
|
||||
const fileName = createUniqueName(toKebabCase(operationName) || toKebabCase(fallbackRouteName) || 'operation', usedFileNames);
|
||||
|
||||
return {
|
||||
route,
|
||||
operationName,
|
||||
fileName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createDataContractsImport(content: string, modelTypes: ModelType[]): string {
|
||||
const importedNames = modelTypes
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => new RegExp(`\\b${escapeRegExp(name)}\\b`).test(content))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
if (!importedNames.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `import type { ${importedNames.join(', ')} } from "../data-contracts";`;
|
||||
}
|
||||
|
||||
function createHttpClientImport(content: string): string {
|
||||
const imports = ['import type { ApiRequestClient, RequestParams } from "../http-client";'];
|
||||
|
||||
if (content.includes('ContentType.')) {
|
||||
imports.unshift('import { ContentType } from "../http-client";');
|
||||
}
|
||||
|
||||
return imports.join('\n');
|
||||
}
|
||||
|
||||
async function renderTemplateFile(
|
||||
generatorOutput: GenerateApiOutput,
|
||||
templatePath: string,
|
||||
data: unknown,
|
||||
): Promise<string> {
|
||||
const template = await readTextFile(templatePath);
|
||||
return String(await generatorOutput.renderTemplate(template, data as Record<string, unknown>));
|
||||
}
|
||||
|
||||
async function writeFormattedFile(
|
||||
generatorOutput: GenerateApiOutput,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const formattedContent = await generatorOutput.formatTSContent(`${GENERATED_FILE_PREFIX}\n\n${content}`);
|
||||
await writeFileWithDirs(filePath, formattedContent);
|
||||
}
|
||||
|
||||
async function cleanTreeOutput(outputDir: string): Promise<void> {
|
||||
await Promise.all([
|
||||
rm(join(outputDir, 'operations'), { recursive: true, force: true }),
|
||||
rm(join(outputDir, 'index.ts'), { force: true }),
|
||||
rm(join(outputDir, 'http-client.ts'), { force: true }),
|
||||
rm(join(outputDir, 'data-contracts.ts'), { force: true }),
|
||||
rm(join(outputDir, 'create-api-client.ts'), { force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
function translateGenerationErrorMessage(message: string): string {
|
||||
return message
|
||||
.replace(
|
||||
'Unable to connect. Is the computer able to access the url?',
|
||||
'Не удалось подключиться к URL. Проверьте доступность OpenAPI спецификации и сетевое подключение.',
|
||||
)
|
||||
.replace('fetch failed', 'не удалось выполнить сетевой запрос')
|
||||
.replace('request timeout', 'истекло время ожидания запроса')
|
||||
.replace('socket hang up', 'соединение было разорвано')
|
||||
.replace('ECONNREFUSED', 'соединение отклонено')
|
||||
.replace('ENOTFOUND', 'хост не найден')
|
||||
.replace('ETIMEDOUT', 'истекло время ожидания соединения');
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация API клиента из OpenAPI спецификации
|
||||
*/
|
||||
@@ -32,10 +269,21 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
||||
if (isUrl) {
|
||||
url = config.inputPath;
|
||||
// Загружаем спецификацию для получения info.title
|
||||
const response = await fetch(url);
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? translateGenerationErrorMessage(error.message) : String(error);
|
||||
throw new Error(
|
||||
`Не удалось подключиться к OpenAPI спецификации: ${url}. ` +
|
||||
`Проверьте доступность URL и сетевое подключение. Детали: ${details}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI spec from ${url}: ${response.status} ${response.statusText}`
|
||||
`Не удалось загрузить OpenAPI спецификацию из ${url}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
@@ -43,8 +291,8 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
||||
spec = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse OpenAPI spec from ${url} as JSON. ` +
|
||||
`Response starts with: "${text.slice(0, 50)}..."`
|
||||
`Не удалось распарсить OpenAPI спецификацию из ${url} как JSON. ` +
|
||||
`Начало ответа: "${text.slice(0, 50)}..."`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -64,101 +312,198 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
||||
// Проверяем, что директория с шаблонами существует
|
||||
if (!existsSync(templatesPath)) {
|
||||
throw new Error(
|
||||
`Templates directory not found: ${templatesPath}. ` +
|
||||
`Make sure the package is built correctly (run "bun run build").`
|
||||
`Директория шаблонов не найдена: ${templatesPath}. ` +
|
||||
`Проверьте, что пакет собран корректно (bun run build).`
|
||||
);
|
||||
}
|
||||
|
||||
const outputDir = resolve(config.outputPath);
|
||||
const outputFileName = `${fileName}.ts`;
|
||||
const mode = config.mode || 'single';
|
||||
const shouldGenerateSingle = mode === 'single' || mode === 'both';
|
||||
const shouldGenerateSplit = mode === 'split' || mode === 'both';
|
||||
|
||||
try {
|
||||
const { files } = await swaggerGenerateApi({
|
||||
...(isUrl ? { url } : { input: inputPath }),
|
||||
output: outputDir,
|
||||
fileName: outputFileName,
|
||||
httpClientType: 'fetch',
|
||||
modular: false,
|
||||
templates: templatesPath,
|
||||
generateClient: true,
|
||||
const baseGenerateOptions = {
|
||||
...(isUrl ? { url } : { input: inputPath }),
|
||||
httpClientType: 'fetch',
|
||||
modular: false,
|
||||
templates: templatesPath,
|
||||
generateClient: true,
|
||||
extractRequestParams: true,
|
||||
extractRequestBody: true,
|
||||
extractEnums: true,
|
||||
cleanOutput: false,
|
||||
singleHttpClient: true,
|
||||
unwrapResponseData: true,
|
||||
defaultResponseAsSuccess: true,
|
||||
enumNamesAsValues: false,
|
||||
moduleNameFirstTag: true,
|
||||
extraTemplates: [],
|
||||
addReadonly: false,
|
||||
sortTypes: false,
|
||||
sortRoutes: false,
|
||||
extractResponseError: false,
|
||||
fixInvalidEnumKeyPrefix: 'KEY',
|
||||
silent: true,
|
||||
defaultResponseType: 'void',
|
||||
typePrefix: '',
|
||||
typeSuffix: '',
|
||||
enumKeyPrefix: '',
|
||||
enumKeySuffix: '',
|
||||
extractingOptions: {
|
||||
requestBodySuffix: ['Payload', 'Body', 'Input'],
|
||||
requestParamsSuffix: ['Params'],
|
||||
responseBodySuffix: ['Data', 'Result', 'Output'],
|
||||
responseErrorSuffix: ['Error', 'Fail', 'Fails', 'ErrorData', 'HttpError', 'BadResponse'],
|
||||
},
|
||||
hooks: {
|
||||
onFormatRouteName: (_routeInfo: unknown, templateRouteName: string) => {
|
||||
// Убираем префикс с названием контроллера из имени метода
|
||||
// Например: projectControllerUpdate -> update
|
||||
// authControllerLogin -> login
|
||||
const controllerPattern = /^(\w+)Controller(\w+)$/;
|
||||
const match = templateRouteName.match(controllerPattern);
|
||||
|
||||
if (match) {
|
||||
const [, , methodName] = match;
|
||||
// Делаем первую букву строчной
|
||||
return methodName ? methodName.charAt(0).toLowerCase() + methodName.slice(1) : templateRouteName;
|
||||
}
|
||||
|
||||
return templateRouteName;
|
||||
},
|
||||
onInit: (configuration: unknown) => {
|
||||
// Получаем дефолтный baseUrl из OpenAPI спецификации
|
||||
const typedConfiguration = configuration as { apiConfig?: { baseUrl?: string } };
|
||||
const apiConfig = typedConfiguration.apiConfig || {};
|
||||
const defaultBaseUrl = spec?.servers?.[0]?.url || apiConfig.baseUrl || '';
|
||||
typedConfiguration.apiConfig = typedConfiguration.apiConfig || {};
|
||||
typedConfiguration.apiConfig.baseUrl = defaultBaseUrl;
|
||||
return typedConfiguration;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const generateSingleFile = async () => {
|
||||
const generatorOutput = await swaggerGenerateApi({
|
||||
...baseGenerateOptions,
|
||||
generateRouteTypes: true,
|
||||
extractRequestParams: true,
|
||||
extractRequestBody: true,
|
||||
extractEnums: true,
|
||||
cleanOutput: false,
|
||||
singleHttpClient: true,
|
||||
unwrapResponseData: true,
|
||||
defaultResponseAsSuccess: true,
|
||||
enumNamesAsValues: false,
|
||||
moduleNameFirstTag: true,
|
||||
generateUnionEnums: false,
|
||||
extraTemplates: [],
|
||||
addReadonly: false,
|
||||
sortTypes: false,
|
||||
sortRoutes: false,
|
||||
extractResponseError: false,
|
||||
fixInvalidEnumKeyPrefix: 'KEY',
|
||||
silent: true,
|
||||
defaultResponseType: 'void',
|
||||
typePrefix: '',
|
||||
typeSuffix: '',
|
||||
enumKeyPrefix: '',
|
||||
enumKeySuffix: '',
|
||||
extractingOptions: {
|
||||
requestBodySuffix: ['Payload', 'Body', 'Input'],
|
||||
requestParamsSuffix: ['Params'],
|
||||
responseBodySuffix: ['Data', 'Result', 'Output'],
|
||||
responseErrorSuffix: ['Error', 'Fail', 'Fails', 'ErrorData', 'HttpError', 'BadResponse'],
|
||||
},
|
||||
hooks: {
|
||||
onFormatRouteName: (routeInfo, templateRouteName) => {
|
||||
// Убираем префикс с названием контроллера из имени метода
|
||||
// Например: projectControllerUpdate -> update
|
||||
// authControllerLogin -> login
|
||||
const controllerPattern = /^(\w+)Controller(\w+)$/;
|
||||
const match = templateRouteName.match(controllerPattern);
|
||||
|
||||
if (match) {
|
||||
const [, , methodName] = match;
|
||||
// Делаем первую букву строчной
|
||||
return methodName ? methodName.charAt(0).toLowerCase() + methodName.slice(1) : templateRouteName;
|
||||
}
|
||||
|
||||
return templateRouteName;
|
||||
},
|
||||
onInit: (configuration) => {
|
||||
// Получаем дефолтный baseUrl из OpenAPI спецификации
|
||||
const apiConfig = (configuration as any).apiConfig || {};
|
||||
const defaultBaseUrl = spec?.servers?.[0]?.url || apiConfig.baseUrl || '';
|
||||
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
|
||||
(configuration as any).apiConfig.baseUrl = defaultBaseUrl;
|
||||
// Передаем флаг useSwr в шаблоны
|
||||
(configuration as any).useSwr = config.useSwr || false;
|
||||
return configuration;
|
||||
},
|
||||
},
|
||||
});
|
||||
output: false,
|
||||
fileName: outputFileName,
|
||||
} as Parameters<typeof swaggerGenerateApi>[0]) as unknown as GenerateApiOutput;
|
||||
const { files } = generatorOutput;
|
||||
|
||||
// Проверяем, что файлы были сгенерированы
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(
|
||||
'Generation completed but no files were produced. ' +
|
||||
'Check that the OpenAPI specification is valid.'
|
||||
'Генерация завершилась, но файлы не были созданы. ' +
|
||||
'Проверьте корректность OpenAPI спецификации.'
|
||||
);
|
||||
}
|
||||
|
||||
const singleFile = files.find((file) => `${file.fileName}${file.fileExtension}` === outputFileName) || files[0];
|
||||
|
||||
if (!singleFile) {
|
||||
throw new Error('Генерация завершилась, но монолитный файл не был найден.');
|
||||
}
|
||||
|
||||
await writeFormattedFile(generatorOutput, join(outputDir, outputFileName), singleFile.fileContent);
|
||||
|
||||
// Проверяем, что выходной файл существует на диске
|
||||
const outputFilePath = join(outputDir, outputFileName);
|
||||
if (!existsSync(outputFilePath)) {
|
||||
throw new Error(
|
||||
`Generation completed but output file was not created: ${outputFilePath}`
|
||||
`Генерация завершилась, но выходной файл не был создан: ${outputFilePath}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSplitClient = async () => {
|
||||
const generatorOutput = await swaggerGenerateApi({
|
||||
...baseGenerateOptions,
|
||||
generateRouteTypes: false,
|
||||
generateUnionEnums: true,
|
||||
output: false,
|
||||
fileName: 'index.ts',
|
||||
} as Parameters<typeof swaggerGenerateApi>[0]) as unknown as GenerateApiOutput;
|
||||
|
||||
await cleanTreeOutput(outputDir);
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'data-contracts.ts'),
|
||||
await renderTemplateFile(generatorOutput, join(templatesPath, 'data-contracts.ejs'), generatorOutput.configuration),
|
||||
);
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'http-client.ts'),
|
||||
await renderTemplateFile(generatorOutput, join(templatesPath, 'http-client.ejs'), generatorOutput.configuration),
|
||||
);
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'create-api-client.ts'),
|
||||
await renderTemplateFile(generatorOutput, join(templatesPath, 'create-api-client.ejs'), generatorOutput.configuration),
|
||||
);
|
||||
|
||||
const operationTemplatePath = join(templatesPath, 'operation.ejs');
|
||||
const operationFiles = createOperationFiles(getAllRoutes(generatorOutput.configuration));
|
||||
const operationExports: string[] = [];
|
||||
|
||||
for (const operationFile of operationFiles) {
|
||||
let operationContent = await renderTemplateFile(generatorOutput, operationTemplatePath, {
|
||||
...generatorOutput.configuration,
|
||||
route: operationFile.route,
|
||||
operationName: operationFile.operationName,
|
||||
});
|
||||
operationContent = operationContent
|
||||
.replace('__HTTP_CLIENT_IMPORTS__', createHttpClientImport(operationContent))
|
||||
.replace('__DATA_CONTRACT_IMPORTS__', createDataContractsImport(operationContent, generatorOutput.configuration.modelTypes));
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'operations', `${operationFile.fileName}.ts`),
|
||||
operationContent,
|
||||
);
|
||||
|
||||
operationExports.push(`export { ${operationFile.operationName} } from "./${operationFile.fileName}";`);
|
||||
}
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'operations', 'index.ts'),
|
||||
operationExports.length ? operationExports.join('\n') : 'export {};',
|
||||
);
|
||||
|
||||
await writeFormattedFile(
|
||||
generatorOutput,
|
||||
join(outputDir, 'index.ts'),
|
||||
[
|
||||
'export { createApiClient } from "./create-api-client";',
|
||||
'export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";',
|
||||
'export { ContentType, HttpClient } from "./http-client";',
|
||||
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";',
|
||||
'export type * from "./data-contracts";',
|
||||
'export * from "./operations";',
|
||||
].join('\n'),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldGenerateSingle) {
|
||||
await generateSingleFile();
|
||||
}
|
||||
|
||||
if (shouldGenerateSplit) {
|
||||
await generateSplitClient();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Generation completed')) {
|
||||
if (error instanceof Error && error.message.startsWith('Генерация завершилась')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Generation failed: ${error instanceof Error ? error.message : error}`);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Ошибка генерации: ${translateGenerationErrorMessage(message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,6 @@ const descriptionLines = _.compact([
|
||||
|
||||
%>
|
||||
|
||||
<% if (config.useSwr) { %>
|
||||
import useSWR from "swr";
|
||||
<% } %>
|
||||
|
||||
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
|
||||
|
||||
<% if (descriptionLines.length) { %>
|
||||
|
||||
44
src/templates/create-api-client.ejs
Normal file
44
src/templates/create-api-client.ejs
Normal 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>;
|
||||
};
|
||||
@@ -17,6 +17,10 @@ const buildGenerics = (contract) => {
|
||||
|
||||
const dataContractTemplates = {
|
||||
enum: (contract) => {
|
||||
if (config.generateUnionEnums) {
|
||||
return `type ${contract.name} = ${_.map(contract.$content, ({ value }) => value).join(" | ")}`;
|
||||
}
|
||||
|
||||
return `enum ${contract.name} {\r\n${contract.content} \r\n }`;
|
||||
},
|
||||
interface: (contract) => {
|
||||
|
||||
@@ -3,23 +3,6 @@ const { apiConfig, generateResponses, config } = it;
|
||||
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 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 interface ApiRequestClient {
|
||||
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
|
||||
}
|
||||
|
||||
export interface ApiConfig<SecurityDataType = unknown> {
|
||||
baseUrl?: string;
|
||||
@@ -57,7 +43,7 @@ export interface HttpResponse<D extends unknown, E extends unknown = unknown> ex
|
||||
error: E;
|
||||
}
|
||||
|
||||
type CancelToken = Symbol | string | number;
|
||||
export type CancelToken = Symbol | string | number;
|
||||
|
||||
export enum ContentType {
|
||||
Json = "application/json",
|
||||
@@ -67,7 +53,7 @@ export enum ContentType {
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
export class HttpClient<SecurityDataType = unknown> {
|
||||
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
|
||||
public baseUrl: string = "<%~ baseUrl %>";
|
||||
private securityData: SecurityDataType | null = null;
|
||||
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
|
||||
|
||||
88
src/templates/operation.ejs
Normal file
88
src/templates/operation.ejs
Normal 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") %>,
|
||||
})
|
||||
@@ -101,40 +101,3 @@ const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
||||
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
|
||||
...<%~ _.get(requestConfigParam, "name") %>,
|
||||
})<%~ route.namespace ? ',' : '' %>
|
||||
<%
|
||||
// Генерируем use* функцию для GET запросов (только если включен флаг useSwr)
|
||||
const isGetRequest = _.upperCase(method) === 'GET';
|
||||
if (config.useSwr && isGetRequest) {
|
||||
const useMethodName = 'use' + _.upperFirst(route.routeName.usage);
|
||||
const argsWithoutParams = rawWrapperArgs.filter(arg => arg.name !== requestConfigParam.name);
|
||||
const useWrapperArgs = _
|
||||
.sortBy(argsWithoutParams, [o => o.optional])
|
||||
.map(argToTmpl)
|
||||
.join(', ');
|
||||
|
||||
// Определяем обязательные параметры для проверки
|
||||
const requiredArgs = argsWithoutParams.filter(arg => !arg.optional);
|
||||
const requiredArgsNames = requiredArgs.map(arg => {
|
||||
// Извлекаем имя из деструктуризации типа "{ id, ...query }"
|
||||
const match = arg.name.match(/^\{\s*([^,}]+)/);
|
||||
return match ? match[1].trim() : arg.name;
|
||||
});
|
||||
|
||||
// Генерируем условие для проверки всех обязательных параметров
|
||||
const hasRequiredArgs = requiredArgsNames.length > 0;
|
||||
const conditionCheck = hasRequiredArgs
|
||||
? requiredArgsNames.join(' && ')
|
||||
: 'true';
|
||||
%>
|
||||
|
||||
/**
|
||||
* SWR hook для <%~ route.routeName.usage %>
|
||||
<%~ routeDocs.lines %>
|
||||
*/
|
||||
<% if (isValidIdentifier(useMethodName)) { %><%~ useMethodName %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ useMethodName %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ useWrapperArgs %>) => {
|
||||
return useSWR<<%~ type %>>(
|
||||
<%~ conditionCheck %> ? `<%~ path %>` : null,
|
||||
fetcher
|
||||
);
|
||||
}<%~ route.namespace ? ',' : '' %>
|
||||
<% } %>
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function readJsonFile<T = unknown>(path: string): Promise<T> {
|
||||
// Используем нативный fetch в Node.js 18+
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OpenAPI spec from ${path}: ${response.statusText}`);
|
||||
throw new Error(`Не удалось загрузить OpenAPI спецификацию из ${path}: ${response.statusText}`);
|
||||
}
|
||||
const content = await response.text();
|
||||
return JSON.parse(content) as T;
|
||||
@@ -65,4 +65,3 @@ export function resolvePath(path: string): string {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,23 +49,28 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'TestApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// 2. Проверка создания файла
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const exists = await fileExists(generatedFile);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// 3. Проверка импорта (компиляция TypeScript)
|
||||
const testFile = join(tempDir, 'test-import.ts');
|
||||
const testCode = `
|
||||
import { Api } from '${generatedFile}';
|
||||
import { createApiClient, HttpClient } from '${generatedFile}';
|
||||
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
|
||||
|
||||
const api = new Api();
|
||||
const api = createApiClient(new HttpClient(), {
|
||||
users: {
|
||||
getAll,
|
||||
},
|
||||
});
|
||||
console.log('Import successful');
|
||||
`;
|
||||
|
||||
@@ -97,24 +102,7 @@ describe('E2E Generation', () => {
|
||||
|
||||
test('повторная генерация (перезапись файлов)', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
const fileName = 'TestApi';
|
||||
|
||||
// Первая генерация
|
||||
await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.MINIMAL,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
fileName,
|
||||
]);
|
||||
|
||||
const generatedFile = join(outputPath, `${fileName}.ts`);
|
||||
const firstContent = await readTextFile(generatedFile);
|
||||
|
||||
// Вторая генерация (перезапись)
|
||||
// Первая генерация с endpoints
|
||||
await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
@@ -122,8 +110,25 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
fileName,
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const staleOperationFile = join(outputPath, 'operations', 'get-all.ts');
|
||||
const firstContent = await readTextFile(generatedFile);
|
||||
expect(await fileExists(staleOperationFile)).toBe(true);
|
||||
|
||||
// Вторая генерация (перезапись меньшей схемой)
|
||||
await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.MINIMAL,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
const secondContent = await readTextFile(generatedFile);
|
||||
@@ -134,6 +139,7 @@ describe('E2E Generation', () => {
|
||||
// Файл должен существовать
|
||||
const exists = await fileExists(generatedFile);
|
||||
expect(exists).toBe(true);
|
||||
expect(await fileExists(staleOperationFile)).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
test('генерация из HTTP URL', async () => {
|
||||
@@ -147,50 +153,41 @@ describe('E2E Generation', () => {
|
||||
'https://petstore3.swagger.io/api/v3/openapi.json',
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'PetStore',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const generatedFile = join(outputPath, 'PetStore.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const exists = await fileExists(generatedFile);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// Проверяем что файл не пустой
|
||||
const content = await readTextFile(generatedFile);
|
||||
const content = await readTextFile(join(outputPath, 'http-client.ts'));
|
||||
expect(content.length).toBeGreaterThan(1000);
|
||||
}, 60000);
|
||||
|
||||
test('генерация с флагом --swr', async () => {
|
||||
test('флаг --swr больше не поддерживается', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
|
||||
const { exitCode } = await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'SwrApi',
|
||||
'--swr',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const generatedFile = join(outputPath, 'SwrApi.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие импорта useSWR
|
||||
expect(content).toContain('import useSWR from "swr"');
|
||||
|
||||
// Проверяем наличие use* хуков для GET запросов
|
||||
expect(content).toContain('useGetAll');
|
||||
expect(content).toContain('useGetById');
|
||||
try {
|
||||
await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--swr',
|
||||
]);
|
||||
throw new Error('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.exitCode).not.toBe(0);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('генерация без флага --swr не содержит хуки', async () => {
|
||||
test('REST генерация не содержит SWR хуки', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
|
||||
const { exitCode } = await execa('bun', [
|
||||
@@ -200,13 +197,13 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'NoSwrApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const generatedFile = join(outputPath, 'NoSwrApi.ts');
|
||||
const generatedFile = join(outputPath, 'http-client.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем отсутствие импорта useSWR
|
||||
@@ -229,16 +226,21 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'TestApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
// Динамически импортируем сгенерированный API
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const { Api } = await import(generatedFile);
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const { createApiClient, HttpClient } = await import(generatedFile);
|
||||
const { getAll } = await import(join(outputPath, 'operations', 'get-all.ts'));
|
||||
|
||||
const api = new Api();
|
||||
const result = await api.users.getAll();
|
||||
const api = createApiClient(new HttpClient(), {
|
||||
users: {
|
||||
getAll,
|
||||
},
|
||||
});
|
||||
const result = await api.users.getAll({});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(2);
|
||||
@@ -254,15 +256,20 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'TestApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
// Динамически импортируем сгенерированный API
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const { Api } = await import(generatedFile);
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
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({
|
||||
email: 'new@example.com',
|
||||
password: 'password123'
|
||||
@@ -282,17 +289,22 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.VALID,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'TestApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
const testFile = join(tempDir, 'test-404.ts');
|
||||
const testCode = `
|
||||
import { Api } from '${join(outputPath, 'TestApi.ts')}';
|
||||
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 {
|
||||
await api.users.getById('999');
|
||||
await api.users.getById({ id: '999' });
|
||||
} catch (error) {
|
||||
console.log('error');
|
||||
}
|
||||
@@ -314,13 +326,15 @@ describe('E2E Generation', () => {
|
||||
FIXTURES.WITH_AUTH,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'AuthApi',
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
// Динамически импортируем сгенерированный API
|
||||
const generatedFile = join(outputPath, 'AuthApi.ts');
|
||||
const { Api, HttpClient } = await import(generatedFile);
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const { createApiClient, HttpClient } = await import(generatedFile);
|
||||
const { login } = await import(join(outputPath, 'operations', 'login.ts'));
|
||||
const { get } = await import(join(outputPath, 'operations', 'get.ts'));
|
||||
|
||||
// Создаем HttpClient с securityWorker для добавления Bearer токена
|
||||
const httpClient = new HttpClient({
|
||||
@@ -335,7 +349,14 @@ describe('E2E Generation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const api = new Api(httpClient);
|
||||
const api = createApiClient(httpClient, {
|
||||
auth: {
|
||||
login,
|
||||
},
|
||||
profile: {
|
||||
get,
|
||||
},
|
||||
});
|
||||
|
||||
// Логин
|
||||
const loginResult = await api.auth.login({
|
||||
|
||||
@@ -28,11 +28,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
|
||||
// Пытаемся скомпилировать
|
||||
const { exitCode } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]);
|
||||
@@ -46,11 +47,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
|
||||
// Проверяем с помощью TypeScript компилятора
|
||||
const { exitCode, stderr } = await execa('bun', ['build', generatedFile, '--outdir', tempDir]);
|
||||
@@ -65,16 +67,17 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем экспорты
|
||||
expect(content).toContain('export');
|
||||
expect(content).toContain('class');
|
||||
expect(content).toContain('createApiClient');
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -85,11 +88,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем что все основные методы есть
|
||||
@@ -97,7 +101,7 @@ describe('Generated Client', () => {
|
||||
expect(content).toContain('create');
|
||||
expect(content).toContain('getById');
|
||||
expect(content).toContain('update');
|
||||
expect(content).toContain('delete');
|
||||
expect(content).toContain('deleteUsersId');
|
||||
}, 30000);
|
||||
|
||||
test('корректные имена методов (без Controller префиксов)', async () => {
|
||||
@@ -106,11 +110,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем что "Controller" удален
|
||||
@@ -128,11 +133,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'http-client.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие HttpClient
|
||||
@@ -148,11 +154,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.WITH_AUTH,
|
||||
outputPath,
|
||||
fileName: 'AuthApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'AuthApi.ts');
|
||||
const generatedFile = join(outputPath, 'http-client.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие метода для установки токена
|
||||
@@ -167,11 +174,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.MINIMAL,
|
||||
outputPath,
|
||||
fileName: 'MinimalApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'MinimalApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const exists = await fileExists(generatedFile);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
@@ -186,11 +194,12 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.WITH_AUTH,
|
||||
outputPath,
|
||||
fileName: 'AuthApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'AuthApi.ts');
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие методов авторизации
|
||||
@@ -204,16 +213,17 @@ describe('Generated Client', () => {
|
||||
inputPath: FIXTURES.COMPLEX,
|
||||
outputPath,
|
||||
fileName: 'ComplexApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'ComplexApi.ts');
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const exists = await fileExists(generatedFile);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// Проверяем что файл не пустой
|
||||
const content = await readTextFile(generatedFile);
|
||||
const content = await readTextFile(join(outputPath, 'operations', 'index.ts'));
|
||||
expect(content.length).toBeGreaterThan(1000);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('CLI', () => {
|
||||
expect(exists).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать с кастомным именем файла', async () => {
|
||||
test('должен генерировать монолит с кастомным именем файла', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
const customName = 'CustomApi';
|
||||
|
||||
@@ -69,6 +69,48 @@ describe('CLI', () => {
|
||||
expect(exists).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать split режим', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
|
||||
const { exitCode } = await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.MINIMAL,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--mode',
|
||||
'split',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
||||
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать both режим', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
|
||||
const { exitCode } = await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
FIXTURES.MINIMAL,
|
||||
'--output',
|
||||
outputPath,
|
||||
'--name',
|
||||
'CustomApi',
|
||||
'--mode',
|
||||
'both',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(await fileExists(join(outputPath, 'CustomApi.ts'))).toBe(true);
|
||||
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test('должен отображать версию с --version', async () => {
|
||||
const { stdout } = await execa('bun', ['run', CLI_PATH, '--version']);
|
||||
|
||||
@@ -78,7 +120,7 @@ describe('CLI', () => {
|
||||
test('должен отображать help с --help', async () => {
|
||||
const { stdout } = await execa('bun', ['run', CLI_PATH, '--help']);
|
||||
|
||||
expect(stdout).toContain('Generate TypeScript API client');
|
||||
expect(stdout).toContain('Генерация TypeScript API клиента');
|
||||
expect(stdout).toContain('--input');
|
||||
expect(stdout).toContain('--output');
|
||||
});
|
||||
@@ -141,5 +183,26 @@ describe('CLI', () => {
|
||||
expect(error.exitCode).not.toBe(0);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('должен показывать русскую ошибку для недоступного URL', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
|
||||
try {
|
||||
await execa('bun', [
|
||||
'run',
|
||||
CLI_PATH,
|
||||
'--input',
|
||||
'https://127.0.0.1:1/swagger.json',
|
||||
'--output',
|
||||
outputPath,
|
||||
]);
|
||||
throw new Error('Should have thrown');
|
||||
} catch (error: any) {
|
||||
expect(error.exitCode).not.toBe(0);
|
||||
expect(error.stderr).toContain('Не удалось подключиться к OpenAPI спецификации');
|
||||
expect(error.stderr).toContain('Проверьте доступность URL и сетевое подключение');
|
||||
expect(error.stderr).not.toContain('Unable to connect');
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('config', () => {
|
||||
outputPath: './output',
|
||||
};
|
||||
|
||||
expect(() => validateConfig(config)).toThrow('Input path is required');
|
||||
expect(() => validateConfig(config)).toThrow('Не указан путь к OpenAPI спецификации');
|
||||
});
|
||||
|
||||
test('должен выбросить ошибку без outputPath', () => {
|
||||
@@ -37,13 +37,13 @@ describe('config', () => {
|
||||
inputPath: './openapi.json',
|
||||
};
|
||||
|
||||
expect(() => validateConfig(config)).toThrow('Output path is required');
|
||||
expect(() => validateConfig(config)).toThrow('Не указана директория для генерации');
|
||||
});
|
||||
|
||||
test('должен выбросить ошибку без обоих обязательных полей', () => {
|
||||
const config: Partial<GeneratorConfig> = {};
|
||||
|
||||
expect(() => validateConfig(config)).toThrow('Configuration validation failed');
|
||||
expect(() => validateConfig(config)).toThrow('Ошибка конфигурации');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe('Generator', () => {
|
||||
expect(exists).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать корректную структуру кода', async () => {
|
||||
test('должен добавлять русскую подпись в generated файлы', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
const config: GeneratorConfig = {
|
||||
inputPath: FIXTURES.MINIMAL,
|
||||
@@ -49,9 +49,54 @@ describe('Generator', () => {
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
expect(content.startsWith('/* eslint-disable */\n/* tslint:disable */\n// @ts-nocheck\n\n/*')).toBe(true);
|
||||
expect(content).toContain('АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ');
|
||||
expect(content).toContain('Не редактируйте вручную: изменения будут перезаписаны.');
|
||||
expect(content).toContain('Генератор: @gromlab/api-codegen');
|
||||
expect(content).toContain('Репозиторий: https://gromlab.ru/gromov/api-codegen');
|
||||
expect(content).not.toContain('THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API');
|
||||
}, 30000);
|
||||
|
||||
test('должен добавлять русскую подпись в split режиме', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
const config: GeneratorConfig = {
|
||||
inputPath: FIXTURES.MINIMAL,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
expect(content.startsWith('/* eslint-disable */\n/* tslint:disable */\n// @ts-nocheck\n\n/*')).toBe(true);
|
||||
expect(content).toContain('АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ');
|
||||
expect(content).toContain('Не редактируйте вручную: изменения будут перезаписаны.');
|
||||
expect(content).toContain('Генератор: @gromlab/api-codegen');
|
||||
expect(content).not.toContain('THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API');
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать корректную структуру кода', async () => {
|
||||
const outputPath = join(tempDir, 'output');
|
||||
const config: GeneratorConfig = {
|
||||
inputPath: FIXTURES.MINIMAL,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const indexFile = join(outputPath, 'index.ts');
|
||||
const httpClientFile = join(outputPath, 'http-client.ts');
|
||||
const indexContent = await readTextFile(indexFile);
|
||||
const httpClientContent = await readTextFile(httpClientFile);
|
||||
|
||||
// Проверяем наличие основных элементов
|
||||
expect(content).toContain('export class');
|
||||
expect(content).toContain('HttpClient');
|
||||
expect(indexContent).toContain('createApiClient');
|
||||
expect(httpClientContent).toContain('export class HttpClient');
|
||||
}, 30000);
|
||||
|
||||
test('должен обработать все HTTP методы', async () => {
|
||||
@@ -60,12 +105,13 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
const operationsIndex = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(operationsIndex);
|
||||
|
||||
// Проверяем что методы UserController переименованы
|
||||
// UserController_getAll -> getAll
|
||||
@@ -74,7 +120,7 @@ describe('Generator', () => {
|
||||
expect(content).toContain('create');
|
||||
expect(content).toContain('getById');
|
||||
expect(content).toContain('update');
|
||||
expect(content).toContain('delete');
|
||||
expect(content).toContain('deleteUsersId');
|
||||
}, 30000);
|
||||
|
||||
test('должен генерировать типы для request и response', async () => {
|
||||
@@ -83,11 +129,12 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'data-contracts.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие типов
|
||||
@@ -102,15 +149,16 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'data-contracts.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие enum
|
||||
expect(content).toContain('UserRole');
|
||||
expect(content).toContain('type UserRole');
|
||||
expect(content).toContain('admin');
|
||||
expect(content).toContain('user');
|
||||
expect(content).toContain('guest');
|
||||
@@ -122,11 +170,12 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.WITH_AUTH,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'http-client.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем наличие методов для работы с токеном
|
||||
@@ -139,11 +188,12 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'http-client.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем что baseUrl установлен
|
||||
@@ -156,11 +206,12 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.VALID,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем что "Controller" удален из имен методов
|
||||
@@ -210,11 +261,12 @@ describe('Generator', () => {
|
||||
inputPath: FIXTURES.COMPLEX,
|
||||
outputPath,
|
||||
fileName: 'TestApi',
|
||||
mode: 'split',
|
||||
};
|
||||
|
||||
await generate(config);
|
||||
|
||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
||||
const generatedFile = join(outputPath, 'operations', 'index.ts');
|
||||
const content = await readTextFile(generatedFile);
|
||||
|
||||
// Проверяем что все контроллеры присутствуют
|
||||
|
||||
Reference in New Issue
Block a user