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