- ошибки парсинга успешных ответов проброшены в onError - добавлена защита от перезаписи явного Authorization - обновлены тесты, README и примеры кастомизации
472 lines
18 KiB
Markdown
472 lines
18 KiB
Markdown
# @gromlab/api-codegen
|
||
|
||
CLI для генерации typed TypeScript REST SDK из OpenAPI спецификации.
|
||
|
||
Генератор рассчитан на сценарий, где REST API можно вынести в отдельный npm-пакет, а приложения будут собирать из него свои API-клиенты: полный клиент, частичный клиент или точечные вызовы отдельных операций.
|
||
|
||
## Главная Идея
|
||
|
||
По умолчанию `@gromlab/api-codegen` генерирует split-клиент.
|
||
|
||
Каждый endpoint становится отдельной typed operation-функцией. Это позволяет импортировать только нужные методы и не тащить весь API в bundle приложения.
|
||
|
||
Если нужен полный клиент, используйте `operationsTree`.
|
||
|
||
Если нужен клиент только под конкретный экран, модуль или приложение, соберите дерево операций вручную.
|
||
|
||
Если нужен максимально точечный вызов, импортируйте одну operation напрямую.
|
||
|
||
## Быстрый Старт
|
||
|
||
```bash
|
||
npx @gromlab/api-codegen -i ./openapi.json -o ./src/generated
|
||
```
|
||
|
||
Для генерации из URL:
|
||
|
||
```bash
|
||
npx @gromlab/api-codegen -i https://api.example.com/openapi.json -o ./src/generated
|
||
```
|
||
|
||
По умолчанию будет создан split-клиент.
|
||
|
||
## Что Генерируется
|
||
|
||
```text
|
||
generated/
|
||
├── create-api-client.ts
|
||
├── data-contracts.ts
|
||
├── http-client.ts
|
||
├── index.ts
|
||
├── operations-tree.ts
|
||
└── operations/
|
||
├── index.ts
|
||
├── get-users.ts
|
||
└── create-user.ts
|
||
```
|
||
|
||
Основные части:
|
||
|
||
- `http-client.ts` - fetch-based HTTP клиент, настройки авторизации, headers, baseUrl и transport customization.
|
||
- `data-contracts.ts` - TypeScript типы из OpenAPI schemas.
|
||
- `operations/*.ts` - отдельная typed function на каждый endpoint.
|
||
- `operations/index.ts` - barrel export всех операций.
|
||
- `operations-tree.ts` - дерево всех операций для сборки полного API клиента.
|
||
- `create-api-client.ts` - helper, который привязывает дерево операций к конкретному HTTP клиенту.
|
||
- `index.ts` - основная входная точка generated SDK.
|
||
|
||
## Полный Клиент
|
||
|
||
Если приложению нужен весь API, используйте `operationsTree`.
|
||
|
||
```typescript
|
||
import { createApiClient, HttpClient, operationsTree } from './generated';
|
||
|
||
// 1. Инициализация HTTP-клиента: baseUrl, headers, авторизация и transport-настройки.
|
||
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 createdUser = await api.users.create({ email, password });
|
||
```
|
||
|
||
`operationsTree` намеренно лежит в отдельном файле и экспортируется отдельно. Импортируя его, вы явно выбираете полный клиент со всеми операциями.
|
||
|
||
## Частичный Клиент
|
||
|
||
Если приложению нужна только часть API, соберите клиент вручную.
|
||
|
||
```typescript
|
||
import { createApiClient, HttpClient } from './generated';
|
||
import {
|
||
v1AdminPharmaciesCreate,
|
||
v1AdminPharmaciesList,
|
||
v1AdminPharmaciesProfileUpdate,
|
||
} from './generated/operations';
|
||
|
||
const http = new HttpClient({
|
||
baseUrl: 'https://api.example.com',
|
||
});
|
||
|
||
const api = createApiClient(http, {
|
||
pharmaciesAdmin: {
|
||
list: v1AdminPharmaciesList,
|
||
create: v1AdminPharmaciesCreate,
|
||
updateProfile: v1AdminPharmaciesProfileUpdate,
|
||
},
|
||
});
|
||
|
||
const pharmacies = await api.pharmaciesAdmin.list({});
|
||
const createdPharmacy = await api.pharmaciesAdmin.create(payload);
|
||
await api.pharmaciesAdmin.updateProfile({ id }, payload);
|
||
```
|
||
|
||
Так можно собрать отдельный клиент для админки, публичного сайта, мобильного приложения или отдельного feature-модуля.
|
||
|
||
`operations/index.ts` реэкспортит все operation-функции, поэтому можно делать named imports из `./generated/operations`. В ESM-сборке такой импорт остается tree-shaking friendly: в клиенте используются только явно выбранные операции.
|
||
|
||
Если OpenAPI большой и полный SDK занимает десятки тысяч строк, приложение не обязано тянуть весь набор методов в свой чанк. Можно собрать минимально рабочий API-клиент под конкретный сценарий.
|
||
|
||
Если нужен максимально строгий контроль над тем, какой файл попадет в bundle, импортируйте operation напрямую из ее файла:
|
||
|
||
```typescript
|
||
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
|
||
```
|
||
|
||
## Точечный Вызов Operation
|
||
|
||
Если по какой-то причине нужно вызвать только одну операцию, можно не собирать API-клиент через `createApiClient` и вызвать operation напрямую.
|
||
|
||
```typescript
|
||
import { HttpClient } from './generated/http-client';
|
||
import { v1AdminPharmaciesList } from './generated/operations/v1-admin-pharmacies-list';
|
||
|
||
const http = new HttpClient({
|
||
baseUrl: 'https://api.example.com',
|
||
});
|
||
|
||
const pharmacies = await v1AdminPharmaciesList(http, {});
|
||
```
|
||
|
||
Это полезно для разовых сценариев: health-check, bootstrap-запроса, утилитного скрипта или кода, где полноценный объект `api` не нужен.
|
||
|
||
## Кастомный HTTP Клиент
|
||
|
||
`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать `baseUrl`, headers, авторизацию, timeout и transport behavior.
|
||
|
||
```typescript
|
||
import { ApiError, HttpClient } from './generated';
|
||
|
||
const http = new HttpClient({
|
||
baseUrl: 'https://api.example.com',
|
||
credentials: 'include',
|
||
timeout: 10000,
|
||
headers: {
|
||
'X-App-Version': '1.0.0',
|
||
},
|
||
onRequest: (params) => {
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
if (!params.secure || !token) {
|
||
return params;
|
||
}
|
||
|
||
const headers = new Headers(params.headers);
|
||
|
||
if (!headers.has('Authorization')) {
|
||
headers.set('Authorization', `Bearer ${token}`);
|
||
}
|
||
|
||
return {
|
||
...params,
|
||
headers,
|
||
};
|
||
},
|
||
onResponse: (response) => response,
|
||
onError: async (error, context) => {
|
||
if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) {
|
||
await refreshToken();
|
||
return context.retry();
|
||
}
|
||
|
||
throw error;
|
||
},
|
||
});
|
||
```
|
||
|
||
`onRequest` вызывается перед `fetch`, `onResponse` после успешного ответа, `onError` для HTTP-ошибок, network errors и ошибок парсинга. Для защищенных endpoints generated operation передает `secure: true`, поэтому авторизацию можно добавлять только там, где она нужна.
|
||
|
||
`onError` должен либо бросить ошибку, либо вернуть fallback-значение, либо вернуть результат `context.retry()`. Если вернуть `undefined`, ошибка будет считаться обработанной, а вызывающий код получит `undefined` вместо исключения.
|
||
|
||
### Опции HttpClient
|
||
|
||
`HttpClient` принимает плоский конфиг. Стандартные `fetch`-опции можно задавать прямо в конструкторе вместе с кастомными hooks клиента.
|
||
|
||
| Опция | Тип | Назначение |
|
||
| --- | --- | --- |
|
||
| `baseUrl` | `string` | Базовый URL API. По умолчанию берется из `servers[0].url` OpenAPI спецификации. |
|
||
| `headers` | `HeadersInit` | Заголовки по умолчанию для всех запросов. |
|
||
| `credentials` | `RequestCredentials` | Политика отправки cookies/auth credentials: `omit`, `same-origin`, `include`. |
|
||
| `mode` | `RequestMode` | Fetch request mode, например `cors`, `same-origin`, `no-cors`. |
|
||
| `cache` | `RequestCache` | Fetch cache policy. |
|
||
| `redirect` | `RequestRedirect` | Поведение при redirect: `follow`, `error`, `manual`. |
|
||
| `referrer` | `string` | Значение referrer для запросов. |
|
||
| `referrerPolicy` | `ReferrerPolicy` | Политика referrer. |
|
||
| `integrity` | `string` | Subresource integrity value. |
|
||
| `keepalive` | `boolean` | Позволяет запросу пережить unload страницы. |
|
||
| `secure` | `boolean` | Дефолтный маркер защищенного запроса. Обычно выставляется generated operation по OpenAPI `security`. |
|
||
| `type` | `ContentType` | Дефолтный `Content-Type` для body. Обычно выставляется generated operation. |
|
||
| `format` | `ResponseFormat` | Дефолтный способ парсинга ответа: `json`, `text`, `blob`, `formData`, `arrayBuffer`. |
|
||
| `timeout` | `number` | Таймаут запроса в миллисекундах. Работает через `AbortSignal`. |
|
||
| `customFetch` | `typeof fetch` | Замена стандартного `fetch`, например для тестов, SSR или custom transport. |
|
||
| `paramsSerializer` | `(query) => string` | Кастомная сериализация query params в URL. |
|
||
| `responseParser` | `(response, format) => unknown` | Кастомный парсинг response body. Ошибки парсинга успешных ответов попадают в `onError`. |
|
||
| `onRequest` | `(params, context) => params` | Request interceptor перед вызовом `fetch`. |
|
||
| `onResponse` | `(response, context) => response` | Response interceptor после успешного HTTP ответа. |
|
||
| `onError` | `(error, context) => result` | Error interceptor для HTTP ошибок, network errors, ошибок парсинга, retry и refresh-token сценариев. Должен вернуть fallback/retry или бросить ошибку. |
|
||
|
||
Также можно использовать другие стандартные поля `RequestInit`, доступные в вашей TypeScript/Runtime среде, если они не относятся к конкретному request body или cancellation lifecycle.
|
||
|
||
Эти поля не задаются на уровне конструктора и передаются конкретной operation или самому `request`:
|
||
|
||
| Поле | Почему не в конструкторе |
|
||
| --- | --- |
|
||
| `path` | Генерируется из OpenAPI path для конкретной operation. |
|
||
| `method` | Генерируется из OpenAPI HTTP метода. |
|
||
| `query` | Уникален для конкретного вызова operation. |
|
||
| `body` | Уникален для конкретного запроса. |
|
||
| `signal` | Один общий `AbortSignal` может случайно отменить группу независимых запросов. |
|
||
| `cancelToken` | Всегда относится к одному конкретному запросу. |
|
||
|
||
### Рецепты Кастомизации
|
||
|
||
Авторизация через `onRequest` без перезаписи явно переданного `Authorization`:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
onRequest: (params) => {
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
if (!params.secure || !token) {
|
||
return params;
|
||
}
|
||
|
||
const headers = new Headers(params.headers);
|
||
|
||
if (!headers.has('Authorization')) {
|
||
headers.set('Authorization', `Bearer ${token}`);
|
||
}
|
||
|
||
return {
|
||
...params,
|
||
headers,
|
||
};
|
||
},
|
||
});
|
||
```
|
||
|
||
Refresh token и повтор запроса:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
onError: async (error, context) => {
|
||
if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) {
|
||
await refreshToken();
|
||
return context.retry();
|
||
}
|
||
|
||
throw error;
|
||
},
|
||
});
|
||
```
|
||
|
||
Логирование ответов через `onResponse`:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
onResponse: (response, context) => {
|
||
console.log(context.request.method, context.url, response.status);
|
||
return response;
|
||
},
|
||
});
|
||
```
|
||
|
||
Кастомная сериализация query params:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
paramsSerializer: (query) => {
|
||
const params = new URLSearchParams();
|
||
|
||
Object.entries(query).forEach(([key, value]) => {
|
||
if (Array.isArray(value)) {
|
||
params.set(key, value.join(','));
|
||
return;
|
||
}
|
||
|
||
if (value !== undefined) {
|
||
params.set(key, String(value));
|
||
}
|
||
});
|
||
|
||
return params.toString();
|
||
},
|
||
});
|
||
```
|
||
|
||
Замена транспорта через `customFetch`:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
customFetch: async (url, init) => {
|
||
console.log('request', url);
|
||
return fetch(url, init);
|
||
},
|
||
});
|
||
```
|
||
|
||
Кастомный парсинг ответа:
|
||
|
||
```typescript
|
||
const http = new HttpClient({
|
||
responseParser: async (response) => {
|
||
if (response.status === 204) {
|
||
return undefined;
|
||
}
|
||
|
||
const contentType = response.headers.get('content-type') || '';
|
||
|
||
if (contentType.includes('json')) {
|
||
return response.json();
|
||
}
|
||
|
||
return response.text();
|
||
},
|
||
});
|
||
```
|
||
|
||
Для отдельного вызова можно передать дополнительные request params последним аргументом operation.
|
||
|
||
```typescript
|
||
await api.users.getAll(
|
||
{},
|
||
{
|
||
headers: {
|
||
'X-Request-Id': requestId,
|
||
},
|
||
},
|
||
);
|
||
```
|
||
|
||
## API Как npm-Пакет
|
||
|
||
Типичный workflow:
|
||
|
||
1. Backend публикует OpenAPI спецификацию.
|
||
2. Отдельный пакет генерирует typed REST SDK в `src/generated`.
|
||
3. Пакет экспортирует generated entrypoint.
|
||
4. Приложения импортируют SDK и собирают нужные API-клиенты.
|
||
|
||
Пример структуры пакета:
|
||
|
||
```text
|
||
packages/my-api/
|
||
├── package.json
|
||
└── src/
|
||
├── generated/
|
||
└── index.ts
|
||
```
|
||
|
||
`src/index.ts`:
|
||
|
||
```typescript
|
||
export * from './generated';
|
||
```
|
||
|
||
Использование в приложении:
|
||
|
||
```typescript
|
||
import { createApiClient, HttpClient, operationsTree } from '@company/my-api';
|
||
|
||
const http = new HttpClient({
|
||
baseUrl: 'https://api.example.com',
|
||
});
|
||
|
||
export const api = createApiClient(http, operationsTree);
|
||
```
|
||
|
||
Если приложение хочет выбрать только часть API:
|
||
|
||
```typescript
|
||
import { createApiClient, HttpClient } from '@company/my-api';
|
||
import {
|
||
v1AdminPharmaciesList,
|
||
v1AdminPharmaciesProfileUpdate,
|
||
} from '@company/my-api/operations';
|
||
|
||
export const api = createApiClient(new HttpClient(), {
|
||
pharmaciesAdmin: {
|
||
list: v1AdminPharmaciesList,
|
||
updateProfile: v1AdminPharmaciesProfileUpdate,
|
||
},
|
||
});
|
||
```
|
||
|
||
## Режимы Генерации
|
||
|
||
### split
|
||
|
||
Режим по умолчанию.
|
||
|
||
```bash
|
||
api-codegen -i ./openapi.json -o ./src/generated
|
||
```
|
||
|
||
Эквивалентно:
|
||
|
||
```bash
|
||
api-codegen -i ./openapi.json -o ./src/generated --mode split
|
||
```
|
||
|
||
Используйте этот режим для новых проектов, npm SDK пакетов и приложений, где важны модульность и tree-shaking.
|
||
|
||
### single
|
||
|
||
Legacy-режим, который генерирует один монолитный TypeScript файл.
|
||
|
||
```bash
|
||
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
|
||
```
|
||
|
||
Результат:
|
||
|
||
```text
|
||
generated/
|
||
└── MyApi.ts
|
||
```
|
||
|
||
Используйте `single`, если проект уже завязан на старый монолитный generated-клиент.
|
||
|
||
## CLI
|
||
|
||
```bash
|
||
api-codegen -i <input> -o <output> [--mode split|single] [-n <name>]
|
||
```
|
||
|
||
Аргументы:
|
||
|
||
- `-i, --input <path>` - путь к OpenAPI файлу или URL.
|
||
- `-o, --output <path>` - директория для generated файлов.
|
||
- `--mode <mode>` - режим генерации: `split` или `single`. По умолчанию `split`.
|
||
- `-n, --name <name>` - имя файла для `single` режима без расширения `.ts`.
|
||
- `--single-file` - устаревший алиас для `--mode single`.
|
||
|
||
## Примеры Команд
|
||
|
||
Split из локального файла:
|
||
|
||
```bash
|
||
api-codegen -i ./openapi.json -o ./src/generated
|
||
```
|
||
|
||
Split из URL:
|
||
|
||
```bash
|
||
api-codegen -i https://api.example.com/openapi.json -o ./src/generated
|
||
```
|
||
|
||
Legacy single файл:
|
||
|
||
```bash
|
||
api-codegen -i ./openapi.json -o ./src/generated --mode single -n MyApi
|
||
```
|
||
|
||
## Лицензия
|
||
|
||
MIT
|