feat: сделать split-клиент режимом по умолчанию
- добавлен operationsTree для сборки полного клиента - удален режим генерации both - обновлена документация под npm SDK workflow - поднята версия пакета до 4.0.0
This commit is contained in:
306
README.md
306
README.md
@@ -1,49 +1,36 @@
|
|||||||
# @gromlab/api-codegen
|
# @gromlab/api-codegen
|
||||||
|
|
||||||
CLI утилита для генерации TypeScript REST-клиента из OpenAPI спецификации.
|
CLI для генерации typed TypeScript REST SDK из OpenAPI спецификации.
|
||||||
|
|
||||||
## Использование
|
Генератор рассчитан на сценарий, где REST API можно вынести в отдельный npm-пакет, а приложения будут собирать из него свои API-клиенты: полный клиент, частичный клиент или точечные вызовы отдельных операций.
|
||||||
|
|
||||||
|
## Главная Идея
|
||||||
|
|
||||||
|
По умолчанию `@gromlab/api-codegen` генерирует split-клиент.
|
||||||
|
|
||||||
|
Каждый endpoint становится отдельной typed operation-функцией. Это позволяет импортировать только нужные методы и не тащить весь API в bundle приложения.
|
||||||
|
|
||||||
|
Если нужен полный клиент, используйте `operationsTree`.
|
||||||
|
|
||||||
|
Если нужен клиент только под конкретный экран, модуль или приложение, соберите дерево операций вручную.
|
||||||
|
|
||||||
|
Если нужен максимально точечный вызов, импортируйте одну operation напрямую.
|
||||||
|
|
||||||
|
## Быстрый Старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @gromlab/api-codegen -i <INPUT> -o <OUTPUT> [-n <NAME>] [--mode single|split|both]
|
npx @gromlab/api-codegen -i ./openapi.json -o ./src/generated
|
||||||
```
|
```
|
||||||
|
|
||||||
**Аргументы:**
|
Для генерации из URL:
|
||||||
- `-i, --input <path>` - путь к OpenAPI файлу или URL
|
|
||||||
- `-o, --output <path>` - директория для сохранения файлов
|
|
||||||
- `-n, --name <name>` - имя монолитного файла без `.ts`
|
|
||||||
- `--mode <mode>` - режим генерации: `single`, `split`, `both` (по умолчанию `single`)
|
|
||||||
- `--single-file` - устаревший алиас для `--mode single`
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Локальный файл
|
npx @gromlab/api-codegen -i https://api.example.com/openapi.json -o ./src/generated
|
||||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api
|
|
||||||
|
|
||||||
# URL на спецификацию
|
|
||||||
npx @gromlab/api-codegen -i https://httpbin.org/spec.json -o ./src/api
|
|
||||||
|
|
||||||
# Монолитный файл с кастомным именем
|
|
||||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi
|
|
||||||
|
|
||||||
# Разложенный tree-shaking friendly клиент
|
|
||||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api --mode split
|
|
||||||
|
|
||||||
# Монолит и разложенный клиент одновременно
|
|
||||||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/api -n MyApi --mode both
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Структура вывода
|
По умолчанию будет создан split-клиент.
|
||||||
|
|
||||||
По умолчанию генерируется legacy монолитный клиент для обратной совместимости:
|
## Что Генерируется
|
||||||
|
|
||||||
```text
|
|
||||||
generated/
|
|
||||||
└── MyApi.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
В режиме `split` генерируется tree-shaking friendly структура:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
generated/
|
generated/
|
||||||
@@ -51,39 +38,122 @@ generated/
|
|||||||
├── data-contracts.ts
|
├── data-contracts.ts
|
||||||
├── http-client.ts
|
├── http-client.ts
|
||||||
├── index.ts
|
├── index.ts
|
||||||
|
├── operations-tree.ts
|
||||||
└── operations/
|
└── operations/
|
||||||
├── index.ts
|
├── index.ts
|
||||||
├── get-users.ts
|
├── get-users.ts
|
||||||
└── create-user.ts
|
└── create-user.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Каждый endpoint лежит в отдельном файле внутри `operations/`.
|
Основные части:
|
||||||
|
|
||||||
В режиме `both` генерируются оба варианта рядом.
|
- `http-client.ts` - fetch-based HTTP клиент, настройки авторизации, headers, baseUrl и transport customization.
|
||||||
|
- `data-contracts.ts` - TypeScript типы из OpenAPI schemas.
|
||||||
|
- `operations/*.ts` - отдельная typed function на каждый endpoint.
|
||||||
|
- `operations/index.ts` - barrel export всех операций.
|
||||||
|
- `operations-tree.ts` - дерево всех операций для сборки полного API клиента.
|
||||||
|
- `create-api-client.ts` - helper, который привязывает дерево операций к конкретному HTTP клиенту.
|
||||||
|
- `index.ts` - основная входная точка generated SDK.
|
||||||
|
|
||||||
## Пример Использования
|
## Полный Клиент
|
||||||
|
|
||||||
Для `single` режима:
|
Если приложению нужен весь API, используйте `operationsTree`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Api, HttpClient } from './generated/MyApi';
|
import { createApiClient, HttpClient, operationsTree } from './generated';
|
||||||
|
|
||||||
const http = new HttpClient({ baseUrl: 'https://api.example.com' });
|
// 1. Инициализация HTTP-клиента: baseUrl, headers, авторизация и transport-настройки.
|
||||||
const api = new Api(http);
|
const http = new HttpClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Создание API-клиента: привязываем все сгенерированные операции к HTTP-клиенту.
|
||||||
|
const api = createApiClient(http, operationsTree);
|
||||||
|
|
||||||
|
// 3. Использование API-клиента: вызываем методы из дерева операций.
|
||||||
const users = await api.users.getAll({});
|
const users = await api.users.getAll({});
|
||||||
|
const createdUser = await api.users.create({ email, password });
|
||||||
```
|
```
|
||||||
|
|
||||||
Для `split` режима:
|
`operationsTree` намеренно лежит в отдельном файле и экспортируется отдельно. Импортируя его, вы явно выбираете полный клиент со всеми операциями.
|
||||||
|
|
||||||
|
## Частичный Клиент
|
||||||
|
|
||||||
|
Если приложению нужна только часть API, соберите клиент вручную.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createApiClient, HttpClient } from './generated';
|
import { createApiClient, HttpClient } from './generated';
|
||||||
import { getAll } from './generated/operations/get-all';
|
import {
|
||||||
import { create } from './generated/operations/create';
|
v1AdminPharmaciesCreate,
|
||||||
|
v1AdminPharmaciesList,
|
||||||
|
v1AdminPharmaciesProfileUpdate,
|
||||||
|
} from './generated/operations';
|
||||||
|
|
||||||
const http = new HttpClient({
|
const http = new HttpClient({
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
securityWorker: (securityData: { token: string } | null) => {
|
});
|
||||||
|
|
||||||
|
const api = createApiClient(http, {
|
||||||
|
pharmaciesAdmin: {
|
||||||
|
list: v1AdminPharmaciesList,
|
||||||
|
create: v1AdminPharmaciesCreate,
|
||||||
|
updateProfile: v1AdminPharmaciesProfileUpdate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pharmacies = await api.pharmaciesAdmin.list({});
|
||||||
|
const createdPharmacy = await api.pharmaciesAdmin.create(payload);
|
||||||
|
await api.pharmaciesAdmin.updateProfile({ id }, payload);
|
||||||
|
```
|
||||||
|
|
||||||
|
Так можно собрать отдельный клиент для админки, публичного сайта, мобильного приложения или отдельного feature-модуля.
|
||||||
|
|
||||||
|
`operations/index.ts` реэкспортит все operation-функции, поэтому можно делать named imports из `./generated/operations`. В ESM-сборке такой импорт остается tree-shaking friendly: в клиенте используются только явно выбранные операции.
|
||||||
|
|
||||||
|
Если OpenAPI большой и полный SDK занимает десятки тысяч строк, приложение не обязано тянуть весь набор методов в свой чанк. Можно собрать минимально рабочий API-клиент под конкретный сценарий.
|
||||||
|
|
||||||
|
Если нужен максимально строгий контроль над тем, какой файл попадет в bundle, импортируйте operation напрямую из ее файла:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Точечный Вызов Operation
|
||||||
|
|
||||||
|
Если по какой-то причине нужно вызвать только одну операцию, можно не собирать API-клиент через `createApiClient` и вызвать operation напрямую.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { HttpClient } from './generated/http-client';
|
||||||
|
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
|
||||||
|
|
||||||
|
const http = new HttpClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pharmacies = await v1AdminPharmaciesList(http, {});
|
||||||
|
```
|
||||||
|
|
||||||
|
Это полезно для разовых сценариев: health-check, bootstrap-запроса, утилитного скрипта или кода, где полноценный объект `api` не нужен.
|
||||||
|
|
||||||
|
## Кастомный HTTP Клиент
|
||||||
|
|
||||||
|
`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать авторизацию, headers, baseUrl и transport behavior.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { HttpClient } from './generated';
|
||||||
|
|
||||||
|
type SecurityData = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const http = new HttpClient<SecurityData>({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
baseApiParams: {
|
||||||
|
headers: {
|
||||||
|
'X-App-Version': '1.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
securityWorker: (securityData) => {
|
||||||
if (!securityData?.token) {
|
if (!securityData?.token) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -96,44 +166,144 @@ const http = new HttpClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = createApiClient(http, {
|
http.setSecurityData({ token: 'jwt-token' });
|
||||||
users: {
|
|
||||||
getAll,
|
|
||||||
create,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const users = await api.users.getAll({});
|
|
||||||
const createdUser = await api.users.create({ email, password });
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Если нужен максимально точечный импорт, operation можно вызвать напрямую:
|
Для отдельного вызова можно передать дополнительные request params последним аргументом operation.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { HttpClient } from './generated/http-client';
|
await api.users.getAll(
|
||||||
import { getAll } from './generated/operations/get-all';
|
{},
|
||||||
|
{
|
||||||
const http = new HttpClient();
|
headers: {
|
||||||
const users = await getAll(http, {});
|
'X-Request-Id': requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Разработка
|
## API Как npm-Пакет
|
||||||
|
|
||||||
### Сборка
|
Типичный workflow:
|
||||||
|
|
||||||
|
1. Backend публикует OpenAPI спецификацию.
|
||||||
|
2. Отдельный пакет генерирует typed REST SDK в `src/generated`.
|
||||||
|
3. Пакет экспортирует generated entrypoint.
|
||||||
|
4. Приложения импортируют SDK и собирают нужные API-клиенты.
|
||||||
|
|
||||||
|
Пример структуры пакета:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/my-api/
|
||||||
|
├── package.json
|
||||||
|
└── src/
|
||||||
|
├── generated/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export * from './generated';
|
||||||
|
```
|
||||||
|
|
||||||
|
Использование в приложении:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createApiClient, HttpClient, operationsTree } from '@company/my-api';
|
||||||
|
|
||||||
|
const http = new HttpClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const api = createApiClient(http, operationsTree);
|
||||||
|
```
|
||||||
|
|
||||||
|
Если приложение хочет выбрать только часть API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createApiClient, HttpClient } from '@company/my-api';
|
||||||
|
import {
|
||||||
|
v1AdminPharmaciesList,
|
||||||
|
v1AdminPharmaciesProfileUpdate,
|
||||||
|
} from '@company/my-api/operations';
|
||||||
|
|
||||||
|
export const api = createApiClient(new HttpClient(), {
|
||||||
|
pharmaciesAdmin: {
|
||||||
|
list: v1AdminPharmaciesList,
|
||||||
|
updateProfile: v1AdminPharmaciesProfileUpdate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Режимы Генерации
|
||||||
|
|
||||||
|
### split
|
||||||
|
|
||||||
|
Режим по умолчанию.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
api-codegen -i ./openapi.json -o ./src/generated
|
||||||
```
|
```
|
||||||
|
|
||||||
### Тестирование
|
Эквивалентно:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun test
|
api-codegen -i ./openapi.json -o ./src/generated --mode split
|
||||||
bun run test:unit
|
|
||||||
bun run test:integration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробная документация по тестированию в [`tests/README.md`](tests/README.md).
|
Используйте этот режим для новых проектов, npm SDK пакетов и приложений, где важны модульность и tree-shaking.
|
||||||
|
|
||||||
|
### single
|
||||||
|
|
||||||
|
Legacy-режим, который генерирует один монолитный TypeScript файл.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated/
|
||||||
|
└── MyApi.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Используйте `single`, если проект уже завязан на старый монолитный generated-клиент.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-codegen -i <input> -o <output> [--mode split|single] [-n <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
|
||||||
|
- `-i, --input <path>` - путь к OpenAPI файлу или URL.
|
||||||
|
- `-o, --output <path>` - директория для generated файлов.
|
||||||
|
- `--mode <mode>` - режим генерации: `split` или `single`. По умолчанию `split`.
|
||||||
|
- `-n, --name <name>` - имя файла для `single` режима без расширения `.ts`.
|
||||||
|
- `--single-file` - устаревший алиас для `--mode single`.
|
||||||
|
|
||||||
|
## Примеры Команд
|
||||||
|
|
||||||
|
Split из локального файла:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-codegen -i ./openapi.json -o ./src/generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Split из URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-codegen -i https://api.example.com/openapi.json -o ./src/generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy single файл:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
|
||||||
|
```
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@gromlab/api-codegen",
|
"name": "@gromlab/api-codegen",
|
||||||
"version": "3.0.0",
|
"version": "4.0.0",
|
||||||
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ program
|
|||||||
.option('-i, --input <path>', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
|
.option('-i, --input <path>', 'Путь к OpenAPI спецификации (JSON/YAML файл или URL)')
|
||||||
.option('-o, --output <path>', 'Директория для сохранения сгенерированных файлов')
|
.option('-o, --output <path>', 'Директория для сохранения сгенерированных файлов')
|
||||||
.option('-n, --name <name>', 'Имя монолитного клиента без расширения .ts')
|
.option('-n, --name <name>', 'Имя монолитного клиента без расширения .ts')
|
||||||
.option('--mode <mode>', 'Режим генерации: single, split, both', 'single')
|
.option('--mode <mode>', 'Режим генерации: split, single', 'split')
|
||||||
.option('--single-file', 'Устаревший алиас для --mode single')
|
.option('--single-file', 'Устаревший алиас для --mode single')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type GeneratorMode = 'single' | 'split' | 'both';
|
export type GeneratorMode = 'single' | 'split';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Конфигурация генератора API
|
* Конфигурация генератора API
|
||||||
@@ -8,7 +8,7 @@ export interface GeneratorConfig {
|
|||||||
inputPath: string;
|
inputPath: string;
|
||||||
/** Путь для сохранения сгенерированных файлов */
|
/** Путь для сохранения сгенерированных файлов */
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
/** Имя сгенерированного файла (без расширения), используется в single/both режиме */
|
/** Имя сгенерированного файла (без расширения), используется в single режиме */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/** Режим генерации */
|
/** Режим генерации */
|
||||||
mode?: GeneratorMode;
|
mode?: GeneratorMode;
|
||||||
@@ -28,8 +28,8 @@ export function validateConfig(config: Partial<GeneratorConfig>): config is Gene
|
|||||||
errors.push('Не указана директория для генерации (--output)');
|
errors.push('Не указана директория для генерации (--output)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.mode && !['single', 'split', 'both'].includes(config.mode)) {
|
if (config.mode && !['single', 'split'].includes(config.mode)) {
|
||||||
errors.push('Некорректный режим генерации (--mode). Доступные значения: single, split, both');
|
errors.push('Некорректный режим генерации (--mode). Доступные значения: split, single');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type OperationFileInfo = {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OperationTreeGroup = {
|
||||||
|
moduleName: string;
|
||||||
|
operations: OperationFileInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
const RESERVED_IDENTIFIERS = new Set([
|
const RESERVED_IDENTIFIERS = new Set([
|
||||||
'break',
|
'break',
|
||||||
'case',
|
'case',
|
||||||
@@ -204,6 +209,53 @@ function createHttpClientImport(content: string): string {
|
|||||||
return imports.join('\n');
|
return imports.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createObjectPropertyKey(name: string): string {
|
||||||
|
return JSON.stringify(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOperationTreePropertyName(operationFile: OperationFileInfo): string {
|
||||||
|
return operationFile.route.routeName.usage || operationFile.operationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOperationTreeContent(configuration: GenerateApiConfiguration, operationFiles: OperationFileInfo[]): string {
|
||||||
|
const operationByRoute = new Map(operationFiles.map((operationFile) => [operationFile.route, operationFile]));
|
||||||
|
const imports = operationFiles.map(
|
||||||
|
(operationFile) => `import { ${operationFile.operationName} } from "./operations/${operationFile.fileName}";`,
|
||||||
|
);
|
||||||
|
const outOfModuleOperations = (configuration.routes.outOfModule || [])
|
||||||
|
.map((route) => operationByRoute.get(route))
|
||||||
|
.filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile));
|
||||||
|
const operationTreeGroups: OperationTreeGroup[] = (configuration.routes.combined || []).map(({ moduleName, routes }) => ({
|
||||||
|
moduleName,
|
||||||
|
operations: (routes || [])
|
||||||
|
.map((route) => operationByRoute.get(route))
|
||||||
|
.filter((operationFile): operationFile is OperationFileInfo => Boolean(operationFile)),
|
||||||
|
}));
|
||||||
|
const lines = [
|
||||||
|
...imports,
|
||||||
|
'',
|
||||||
|
'export const operationsTree = {',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const operationFile of outOfModuleOperations) {
|
||||||
|
lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { moduleName, operations } of operationTreeGroups) {
|
||||||
|
lines.push(` ${createObjectPropertyKey(moduleName)}: {`);
|
||||||
|
|
||||||
|
for (const operationFile of operations) {
|
||||||
|
lines.push(` ${createObjectPropertyKey(createOperationTreePropertyName(operationFile))}: ${operationFile.operationName},`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(' },');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('} as const;', '', 'export type OperationsTree = typeof operationsTree;');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
async function renderTemplateFile(
|
async function renderTemplateFile(
|
||||||
generatorOutput: GenerateApiOutput,
|
generatorOutput: GenerateApiOutput,
|
||||||
templatePath: string,
|
templatePath: string,
|
||||||
@@ -229,6 +281,7 @@ async function cleanTreeOutput(outputDir: string): Promise<void> {
|
|||||||
rm(join(outputDir, 'http-client.ts'), { force: true }),
|
rm(join(outputDir, 'http-client.ts'), { force: true }),
|
||||||
rm(join(outputDir, 'data-contracts.ts'), { force: true }),
|
rm(join(outputDir, 'data-contracts.ts'), { force: true }),
|
||||||
rm(join(outputDir, 'create-api-client.ts'), { force: true }),
|
rm(join(outputDir, 'create-api-client.ts'), { force: true }),
|
||||||
|
rm(join(outputDir, 'operations-tree.ts'), { force: true }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,9 +372,9 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
|
|
||||||
const outputDir = resolve(config.outputPath);
|
const outputDir = resolve(config.outputPath);
|
||||||
const outputFileName = `${fileName}.ts`;
|
const outputFileName = `${fileName}.ts`;
|
||||||
const mode = config.mode || 'single';
|
const mode = config.mode || 'split';
|
||||||
const shouldGenerateSingle = mode === 'single' || mode === 'both';
|
const shouldGenerateSingle = mode === 'single';
|
||||||
const shouldGenerateSplit = mode === 'split' || mode === 'both';
|
const shouldGenerateSplit = mode === 'split';
|
||||||
|
|
||||||
const baseGenerateOptions = {
|
const baseGenerateOptions = {
|
||||||
...(isUrl ? { url } : { input: inputPath }),
|
...(isUrl ? { url } : { input: inputPath }),
|
||||||
@@ -477,6 +530,12 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
operationExports.length ? operationExports.join('\n') : 'export {};',
|
operationExports.length ? operationExports.join('\n') : 'export {};',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await writeFormattedFile(
|
||||||
|
generatorOutput,
|
||||||
|
join(outputDir, 'operations-tree.ts'),
|
||||||
|
createOperationTreeContent(generatorOutput.configuration, operationFiles),
|
||||||
|
);
|
||||||
|
|
||||||
await writeFormattedFile(
|
await writeFormattedFile(
|
||||||
generatorOutput,
|
generatorOutput,
|
||||||
join(outputDir, 'index.ts'),
|
join(outputDir, 'index.ts'),
|
||||||
@@ -486,6 +545,9 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
'export { ContentType, HttpClient } from "./http-client";',
|
'export { ContentType, HttpClient } from "./http-client";',
|
||||||
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";',
|
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";',
|
||||||
'export type * from "./data-contracts";',
|
'export type * from "./data-contracts";',
|
||||||
|
'export { operationsTree } from "./operations-tree";',
|
||||||
|
'export type { OperationsTree } from "./operations-tree";',
|
||||||
|
'export * as operations from "./operations";',
|
||||||
'export * from "./operations";',
|
'export * from "./operations";',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe('E2E Generation', () => {
|
|||||||
// 3. Проверка импорта (компиляция TypeScript)
|
// 3. Проверка импорта (компиляция TypeScript)
|
||||||
const testFile = join(tempDir, 'test-import.ts');
|
const testFile = join(tempDir, 'test-import.ts');
|
||||||
const testCode = `
|
const testCode = `
|
||||||
import { createApiClient, HttpClient } from '${generatedFile}';
|
import { createApiClient, HttpClient, operationsTree } from '${generatedFile}';
|
||||||
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
|
import { getAll } from '${join(outputPath, 'operations', 'get-all.ts')}';
|
||||||
|
|
||||||
const api = createApiClient(new HttpClient(), {
|
const api = createApiClient(new HttpClient(), {
|
||||||
@@ -71,6 +71,8 @@ describe('E2E Generation', () => {
|
|||||||
getAll,
|
getAll,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const fullApi = createApiClient(new HttpClient(), operationsTree);
|
||||||
|
fullApi.getAll;
|
||||||
console.log('Import successful');
|
console.log('Import successful');
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -95,9 +97,10 @@ describe('E2E Generation', () => {
|
|||||||
|
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const generatedFile = join(outputPath, 'MinimalAPI.ts');
|
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||||
const exists = await fileExists(generatedFile);
|
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
||||||
expect(exists).toBe(true);
|
expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
|
||||||
|
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('повторная генерация (перезапись файлов)', async () => {
|
test('повторная генерация (перезапись файлов)', async () => {
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ describe('CLI', () => {
|
|||||||
|
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const generatedFile = join(outputPath, 'MinimalAPI.ts');
|
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||||
const exists = await fileExists(generatedFile);
|
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
||||||
expect(exists).toBe(true);
|
expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
|
||||||
|
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен генерировать монолит с кастомным именем файла', async () => {
|
test('должен генерировать монолит с кастомным именем файла', async () => {
|
||||||
@@ -60,6 +61,8 @@ describe('CLI', () => {
|
|||||||
outputPath,
|
outputPath,
|
||||||
'--name',
|
'--name',
|
||||||
customName,
|
customName,
|
||||||
|
'--mode',
|
||||||
|
'single',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
@@ -86,29 +89,30 @@ describe('CLI', () => {
|
|||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||||
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
||||||
|
expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
|
||||||
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен генерировать both режим', async () => {
|
test('должен отклонять удаленный both режим', 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.MINIMAL,
|
'--input',
|
||||||
'--output',
|
FIXTURES.MINIMAL,
|
||||||
outputPath,
|
'--output',
|
||||||
'--name',
|
outputPath,
|
||||||
'CustomApi',
|
'--mode',
|
||||||
'--mode',
|
'both',
|
||||||
'both',
|
]);
|
||||||
]);
|
throw new Error('Should have thrown');
|
||||||
|
} catch (error: any) {
|
||||||
expect(exitCode).toBe(0);
|
expect(error.exitCode).not.toBe(0);
|
||||||
expect(await fileExists(join(outputPath, 'CustomApi.ts'))).toBe(true);
|
expect(error.stderr).toContain('Некорректный режим генерации');
|
||||||
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
expect(error.stderr).toContain('split, single');
|
||||||
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен отображать версию с --version', async () => {
|
test('должен отображать версию с --version', async () => {
|
||||||
|
|||||||
@@ -45,5 +45,15 @@ describe('config', () => {
|
|||||||
|
|
||||||
expect(() => validateConfig(config)).toThrow('Ошибка конфигурации');
|
expect(() => validateConfig(config)).toThrow('Ошибка конфигурации');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('должен выбросить ошибку для удаленного both режима', () => {
|
||||||
|
const config = {
|
||||||
|
inputPath: './openapi.json',
|
||||||
|
outputPath: './output',
|
||||||
|
mode: 'both',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => validateConfig(config as Partial<GeneratorConfig>)).toThrow('Доступные значения: split, single');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('Generator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('корректная генерация', () => {
|
describe('корректная генерация', () => {
|
||||||
test('должен создать выходной файл', async () => {
|
test('должен по умолчанию создать split структуру', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
const config: GeneratorConfig = {
|
const config: GeneratorConfig = {
|
||||||
inputPath: FIXTURES.MINIMAL,
|
inputPath: FIXTURES.MINIMAL,
|
||||||
@@ -31,9 +31,10 @@ describe('Generator', () => {
|
|||||||
|
|
||||||
await generate(config);
|
await generate(config);
|
||||||
|
|
||||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
expect(await fileExists(join(outputPath, 'index.ts'))).toBe(true);
|
||||||
const exists = await fileExists(generatedFile);
|
expect(await fileExists(join(outputPath, 'http-client.ts'))).toBe(true);
|
||||||
expect(exists).toBe(true);
|
expect(await fileExists(join(outputPath, 'operations-tree.ts'))).toBe(true);
|
||||||
|
expect(await fileExists(join(outputPath, 'operations', 'index.ts'))).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен добавлять русскую подпись в generated файлы', async () => {
|
test('должен добавлять русскую подпись в generated файлы', async () => {
|
||||||
@@ -42,6 +43,7 @@ describe('Generator', () => {
|
|||||||
inputPath: FIXTURES.MINIMAL,
|
inputPath: FIXTURES.MINIMAL,
|
||||||
outputPath,
|
outputPath,
|
||||||
fileName: 'TestApi',
|
fileName: 'TestApi',
|
||||||
|
mode: 'single',
|
||||||
};
|
};
|
||||||
|
|
||||||
await generate(config);
|
await generate(config);
|
||||||
@@ -91,12 +93,77 @@ describe('Generator', () => {
|
|||||||
|
|
||||||
const indexFile = join(outputPath, 'index.ts');
|
const indexFile = join(outputPath, 'index.ts');
|
||||||
const httpClientFile = join(outputPath, 'http-client.ts');
|
const httpClientFile = join(outputPath, 'http-client.ts');
|
||||||
|
const operationsTreeFile = join(outputPath, 'operations-tree.ts');
|
||||||
const indexContent = await readTextFile(indexFile);
|
const indexContent = await readTextFile(indexFile);
|
||||||
const httpClientContent = await readTextFile(httpClientFile);
|
const httpClientContent = await readTextFile(httpClientFile);
|
||||||
|
const operationsTreeContent = await readTextFile(operationsTreeFile);
|
||||||
|
|
||||||
// Проверяем наличие основных элементов
|
// Проверяем наличие основных элементов
|
||||||
expect(indexContent).toContain('createApiClient');
|
expect(indexContent).toContain('createApiClient');
|
||||||
|
expect(indexContent).toContain('export { operationsTree } from "./operations-tree"');
|
||||||
|
expect(indexContent).toContain('export type { OperationsTree } from "./operations-tree"');
|
||||||
|
expect(indexContent).toContain('export * as operations from "./operations"');
|
||||||
expect(httpClientContent).toContain('export class HttpClient');
|
expect(httpClientContent).toContain('export class HttpClient');
|
||||||
|
expect(operationsTreeContent).toContain('export const operationsTree');
|
||||||
|
expect(operationsTreeContent).toContain('export type OperationsTree');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test('должен генерировать operationsTree с группировкой по тегам', async () => {
|
||||||
|
const inputPath = join(tempDir, 'tagged.json');
|
||||||
|
const outputPath = join(tempDir, 'output');
|
||||||
|
|
||||||
|
await Bun.write(inputPath, JSON.stringify({
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'Tagged API',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
paths: {
|
||||||
|
'/users': {
|
||||||
|
get: {
|
||||||
|
tags: ['users'],
|
||||||
|
operationId: 'UserController_getAll',
|
||||||
|
responses: {
|
||||||
|
200: { description: 'OK' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ['users'],
|
||||||
|
operationId: 'UserController_create',
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Created' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/auth/login': {
|
||||||
|
post: {
|
||||||
|
tags: ['auth'],
|
||||||
|
operationId: 'AuthController_login',
|
||||||
|
responses: {
|
||||||
|
200: { description: 'OK' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await generate({
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
fileName: 'TaggedApi',
|
||||||
|
mode: 'split',
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await readTextFile(join(outputPath, 'operations-tree.ts'));
|
||||||
|
|
||||||
|
expect(content).toContain('import { create } from "./operations/create"');
|
||||||
|
expect(content).toContain('import { getAll } from "./operations/get-all"');
|
||||||
|
expect(content).toContain('import { login } from "./operations/login"');
|
||||||
|
expect(content).toContain('users: {');
|
||||||
|
expect(content).toContain('getAll: getAll');
|
||||||
|
expect(content).toContain('create: create');
|
||||||
|
expect(content).toContain('auth: {');
|
||||||
|
expect(content).toContain('login: login');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен обработать все HTTP методы', async () => {
|
test('должен обработать все HTTP методы', async () => {
|
||||||
@@ -123,6 +190,26 @@ describe('Generator', () => {
|
|||||||
expect(content).toContain('deleteUsersId');
|
expect(content).toContain('deleteUsersId');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
test('должен генерировать индексный реэкспорт всех operations', async () => {
|
||||||
|
const outputPath = join(tempDir, 'output');
|
||||||
|
const config: GeneratorConfig = {
|
||||||
|
inputPath: FIXTURES.VALID,
|
||||||
|
outputPath,
|
||||||
|
fileName: 'TestApi',
|
||||||
|
mode: 'split',
|
||||||
|
};
|
||||||
|
|
||||||
|
await generate(config);
|
||||||
|
|
||||||
|
const content = await readTextFile(join(outputPath, 'operations', 'index.ts'));
|
||||||
|
|
||||||
|
expect(content).toContain('export { create } from "./create";');
|
||||||
|
expect(content).toContain('export { deleteUsersId } from "./delete-users-id";');
|
||||||
|
expect(content).toContain('export { getAll } from "./get-all";');
|
||||||
|
expect(content).toContain('export { getById } from "./get-by-id";');
|
||||||
|
expect(content).toContain('export { update } from "./update";');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
test('должен генерировать типы для request и response', async () => {
|
test('должен генерировать типы для request и response', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
const config: GeneratorConfig = {
|
const config: GeneratorConfig = {
|
||||||
@@ -235,7 +322,7 @@ describe('Generator', () => {
|
|||||||
|
|
||||||
// Генерация должна пройти успешно
|
// Генерация должна пройти успешно
|
||||||
await generate(config);
|
await generate(config);
|
||||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
const generatedFile = join(outputPath, 'index.ts');
|
||||||
const exists = await fileExists(generatedFile);
|
const exists = await fileExists(generatedFile);
|
||||||
expect(exists).toBe(true);
|
expect(exists).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -250,7 +337,7 @@ describe('Generator', () => {
|
|||||||
|
|
||||||
await generate(config);
|
await generate(config);
|
||||||
|
|
||||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
const generatedFile = join(outputPath, 'index.ts');
|
||||||
const exists = await fileExists(generatedFile);
|
const exists = await fileExists(generatedFile);
|
||||||
expect(exists).toBe(true);
|
expect(exists).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -285,7 +372,7 @@ describe('Generator', () => {
|
|||||||
|
|
||||||
// Генерация должна пройти успешно даже с Unicode
|
// Генерация должна пройти успешно даже с Unicode
|
||||||
await generate(config);
|
await generate(config);
|
||||||
const generatedFile = join(outputPath, 'TestApi.ts');
|
const generatedFile = join(outputPath, 'index.ts');
|
||||||
const exists = await fileExists(generatedFile);
|
const exists = await fileExists(generatedFile);
|
||||||
expect(exists).toBe(true);
|
expect(exists).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
Reference in New Issue
Block a user