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

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

View File

@@ -29,10 +29,13 @@ CLI утилита для автоматической генерации TypeSc
- Чтение OpenAPI спецификации
- Применение кастомных шаблонов 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
View File

@@ -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).

View File

@@ -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=="],

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 клиента с базовыми настройками
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',
@@ -30,107 +50,23 @@ async function registerUser() {
console.log('Пользователь зарегистрирован:', result);
return result;
} catch (error) {
console.error('Ошибка регистрации:', error);
throw error;
}
}
async function loginUser() {
try {
const result = await api.auth.login({
email: 'user@example.com',
password: 'SecurePassword123',
});
console.log('Авторизация успешна');
// Сохраняем токен для последующих запросов
httpClient.setSecurityData({ token: result.access_token });
return result;
} catch (error) {
console.error('Ошибка авторизации:', error);
throw error;
}
}
async function getUserProfile() {
try {
const profile = await api.auth.getProfile();
console.log('Профиль пользователя:', profile);
return profile;
} catch (error) {
console.error('Ошибка получения профиля:', error);
throw error;
}
}
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 };

View File

@@ -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": {

View File

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

View File

@@ -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;
}

View File

@@ -1,14 +1,251 @@
import { generateApi as swaggerGenerateApi } from 'swagger-typescript-api';
import {
generateApi as swaggerGenerateApi,
type GenerateApiConfiguration,
type GenerateApiOutput,
type ModelType,
type ParsedRoute,
} from 'swagger-typescript-api';
import { resolve, join } from 'path';
import { 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,24 +312,23 @@ 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({
const baseGenerateOptions = {
...(isUrl ? { url } : { input: inputPath }),
output: outputDir,
fileName: outputFileName,
httpClientType: 'fetch',
modular: false,
templates: templatesPath,
generateClient: true,
generateRouteTypes: true,
extractRequestParams: true,
extractRequestBody: true,
extractEnums: true,
@@ -91,7 +338,6 @@ export async function generate(config: GeneratorConfig): Promise<void> {
defaultResponseAsSuccess: true,
enumNamesAsValues: false,
moduleNameFirstTag: true,
generateUnionEnums: false,
extraTemplates: [],
addReadonly: false,
sortTypes: false,
@@ -111,7 +357,7 @@ export async function generate(config: GeneratorConfig): Promise<void> {
responseErrorSuffix: ['Error', 'Fail', 'Fails', 'ErrorData', 'HttpError', 'BadResponse'],
},
hooks: {
onFormatRouteName: (routeInfo, templateRouteName) => {
onFormatRouteName: (_routeInfo: unknown, templateRouteName: string) => {
// Убираем префикс с названием контроллера из имени метода
// Например: projectControllerUpdate -> update
// authControllerLogin -> login
@@ -126,39 +372,138 @@ export async function generate(config: GeneratorConfig): Promise<void> {
return templateRouteName;
},
onInit: (configuration) => {
onInit: (configuration: unknown) => {
// Получаем дефолтный 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 || '';
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
(configuration as any).apiConfig.baseUrl = defaultBaseUrl;
// Передаем флаг useSwr в шаблоны
(configuration as any).useSwr = config.useSwr || false;
return configuration;
typedConfiguration.apiConfig = typedConfiguration.apiConfig || {};
typedConfiguration.apiConfig.baseUrl = defaultBaseUrl;
return typedConfiguration;
},
},
});
};
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) {
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)}`);
}
}

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 (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 = {
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) => {

View File

@@ -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"];

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},` : '' %>
...<%~ _.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 ? ',' : '' %>
<% } %>

View File

@@ -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 {
}

View File

@@ -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', [
try {
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');
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({

View File

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

View File

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

View File

@@ -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('Ошибка конфигурации');
});
});
});

View File

@@ -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);
// Проверяем что все контроллеры присутствуют