docs: переработать раздел REST-клиента и стратегий получения данных
- Добавлен обзор REST с разделением на «Создание клиента» и «Использование» - Добавлена страница создания клиента с описанием структуры модуля - Переписана автогенерация: npx без --swr, расширения типов вынесены в types/ - Ручной клиент сокращён до шаблона по файлам - Добавлены GET-хуки REST-клиента с контрактом useGet..., key-функциями и isReady - Добавлена страница выбора стратегий с приоритетом ISR перед SSR - Добавлены стратегии: серверный await, параллельные запросы, передача промиса, начальные данные для клиентских хуков, клиентский GET-хук, business-композиция - Уточнено влияние серверного await и SWR fallback на режим рендера - Удалены устаревшие страницы fetching/server.md и fetching/client.md - Обновлён generate-llms.ts: очистка stale-файлов перед копированием - Обновлены сайдбар, MAP.md, data/index.md, page-level.md
This commit is contained in:
@@ -46,20 +46,28 @@ const sidebar = [
|
|||||||
text: 'REST',
|
text: 'REST',
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
|
{ text: 'Обзор', link: '/docs/data/rest/' },
|
||||||
{
|
{
|
||||||
text: 'Настройка',
|
text: 'Создание клиента',
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Автоматическая генерация', link: '/docs/data/rest/clients/auto' },
|
{ text: 'Обзор', link: '/docs/data/rest/clients/' },
|
||||||
|
{ text: 'Автогенерация из OpenAPI', link: '/docs/data/rest/clients/auto' },
|
||||||
{ text: 'Ручное создание', link: '/docs/data/rest/clients/manual' },
|
{ text: 'Ручное создание', link: '/docs/data/rest/clients/manual' },
|
||||||
|
{ text: 'GET-хуки REST-клиента', link: '/docs/data/rest/clients/hooks' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Использование',
|
text: 'Использование',
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Серверные компоненты', link: '/docs/data/rest/fetching/server' },
|
{ text: 'Стратегии получения данных', link: '/docs/data/rest/strategies/' },
|
||||||
{ text: 'Клиентские компоненты', link: '/docs/data/rest/fetching/client' },
|
{ text: 'Серверный await', link: '/docs/data/rest/strategies/server-await' },
|
||||||
|
{ text: 'Параллельные серверные запросы', link: '/docs/data/rest/strategies/parallel-server-requests' },
|
||||||
|
{ text: 'Передача промиса ниже', link: '/docs/data/rest/strategies/pass-promise-down' },
|
||||||
|
{ text: 'Начальные данные для клиентских хуков', link: '/docs/data/rest/strategies/client-hooks-initial-data' },
|
||||||
|
{ text: 'Клиентский GET-хук', link: '/docs/data/rest/strategies/client-get-hook' },
|
||||||
|
{ text: 'Business-композиция', link: '/docs/data/rest/strategies/business-composition' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -28,10 +28,18 @@
|
|||||||
## Работа с данными
|
## Работа с данными
|
||||||
|
|
||||||
- [Введение](./data/index.md) — Какие источники данных используются в проекте и как с ними работать.
|
- [Введение](./data/index.md) — Какие источники данных используются в проекте и как с ними работать.
|
||||||
- [REST: Настройка: Автоматическая генерация](./data/rest/clients/auto.md) — Генерация REST-клиента из OpenAPI-спецификации.
|
- [REST](./data/rest/index.md) — Как правильно работать с REST API в проекте.
|
||||||
- [REST: Настройка: Ручное создание](./data/rest/clients/manual.md) — Создание REST-клиента вручную, когда нет OpenAPI-спецификации.
|
- [REST: Создание клиента](./data/rest/clients/index.md) — Как выбрать способ создания REST-клиента и где размещать его части.
|
||||||
- [REST: Использование: Серверные компоненты](./data/rest/fetching/server.md) — Получение REST-данных в серверных компонентах.
|
- [REST: Создание клиента: Автогенерация из OpenAPI](./data/rest/clients/auto.md) — Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen.
|
||||||
- [REST: Использование: Клиентские компоненты](./data/rest/fetching/client.md) — Получение REST-данных в клиентских компонентах.
|
- [REST: Создание клиента: Ручное создание](./data/rest/clients/manual.md) — Создание REST-клиента вручную, когда OpenAPI нет или он неполный.
|
||||||
|
- [REST: Создание клиента: GET-хуки REST-клиента](./data/rest/clients/hooks.md) — Прозрачные SWR-обёртки над GET-методами REST-клиента.
|
||||||
|
- [REST: Использование: Стратегии получения данных](./data/rest/strategies/index.md) — Как выбрать способ получения REST-данных в зависимости от места и сценария.
|
||||||
|
- [REST: Использование: Серверный await](./data/rest/strategies/server-await.md) — Получение REST-данных на сервере прямым await метода клиента.
|
||||||
|
- [REST: Использование: Параллельные серверные запросы](./data/rest/strategies/parallel-server-requests.md) — Как запускать независимые REST-запросы на сервере без waterfall.
|
||||||
|
- [REST: Использование: Передача промиса ниже](./data/rest/strategies/pass-promise-down.md) — Как запускать серверный REST-запрос выше и ожидать его во вложенном server-компоненте.
|
||||||
|
- [REST: Использование: Начальные данные для клиентских хуков](./data/rest/strategies/client-hooks-initial-data.md) — Как передать серверный промис в SWR fallback, чтобы клиентские GET-хуки получили начальные данные.
|
||||||
|
- [REST: Использование: Клиентский GET-хук](./data/rest/strategies/client-get-hook.md) — Получение REST-данных в Client Components через готовые GET-хуки REST-клиента.
|
||||||
|
- [REST: Использование: Business-композиция](./data/rest/strategies/business-composition.md) — Когда REST-данные нужно объединить или интерпретировать в бизнес-модуле.
|
||||||
- [Realtime](./data/realtime.md) — Работа с push-данными от сервера: подписки и события.
|
- [Realtime](./data/realtime.md) — Работа с push-данными от сервера: подписки и события.
|
||||||
|
|
||||||
## Прикладные разделы
|
## Прикладные разделы
|
||||||
|
|||||||
@@ -98,14 +98,18 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Если данные нужны нескольким клиентским SWR-хукам, файл роутинга может обернуть дерево в `SWRConfig` и передать `fallback`. Запросы стартуют на сервере, а клиентские хуки получают готовые данные из кеша.
|
Если данные нужны нескольким клиентским SWR-хукам, файл роутинга может обернуть дерево в `SWRConfig` и передать `fallback`. Запросы стартуют на сервере, а клиентские хуки получают данные из кеша.
|
||||||
|
|
||||||
Ключи `fallback` должны совпадать с ключами внутри готовых SWR-хуков.
|
Ключи `fallback` должны совпадать с ключами внутри GET-хуков REST-клиента. Для array-key используется `unstable_serialize`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { SWRConfig } from 'swr'
|
import { SWRConfig, unstable_serialize } from 'swr'
|
||||||
import { backendApi } from 'infrastructure/backend-api'
|
import {
|
||||||
|
backendApi,
|
||||||
|
getCurrentUserKey,
|
||||||
|
getPostListKey,
|
||||||
|
} from 'infrastructure/backend-api'
|
||||||
|
|
||||||
type FeedLayoutProps = {
|
type FeedLayoutProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -119,8 +123,8 @@ export default async function FeedLayout({ children }: FeedLayoutProps) {
|
|||||||
<SWRConfig
|
<SWRConfig
|
||||||
value={{
|
value={{
|
||||||
fallback: {
|
fallback: {
|
||||||
'/api/user': userPromise,
|
[unstable_serialize(getCurrentUserKey())]: userPromise,
|
||||||
'/api/posts': postsPromise,
|
[unstable_serialize(getPostListKey())]: postsPromise,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -130,7 +134,7 @@ export default async function FeedLayout({ children }: FeedLayoutProps) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробнее о серверных запросах и SWR-кеше: [REST → Серверные компоненты](/docs/data/rest/fetching/server), [REST → Клиентские компоненты](/docs/data/rest/fetching/client).
|
Подробнее о стратегиях запросов и начальных данных для клиентских хуков: [REST → Стратегии получения данных](/docs/data/rest/strategies/), [REST → Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data).
|
||||||
|
|
||||||
## Инициализация состояния
|
## Инициализация состояния
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ keywords: [данные, api, rest, realtime, клиент, swr, infrastructure,
|
|||||||
- **Клиент — в `infrastructure/`.** Каждый внешний сервис — отдельный модуль слоя `infrastructure/{service-name}/`.
|
- **Клиент — в `infrastructure/`.** Каждый внешний сервис — отдельный модуль слоя `infrastructure/{service-name}/`.
|
||||||
- **Прямой `fetch` запрещён.** Запросы идут только через клиент модуля. Исключения — точечные и обоснованные.
|
- **Прямой `fetch` запрещён.** Запросы идут только через клиент модуля. Исключения — точечные и обоснованные.
|
||||||
- **Источник данных диктует канал.** REST, realtime и т.п. — независимые подразделы, у каждого своя модель клиента и своё потребление.
|
- **Источник данных диктует канал.** REST, realtime и т.п. — независимые подразделы, у каждого своя модель клиента и своё потребление.
|
||||||
- **Серверные и клиентские компоненты потребляют по-разному.** Server Components — прямой `await` метода клиента, клиентские — через готовые хуки модуля API (`useUserList`, `usePostDetail` и т.п.). SWR инкапсулирован в хуке, компонент про него не знает.
|
- **Серверные и клиентские компоненты потребляют по-разному.** Server Components — прямой `await` метода клиента, клиентские — через готовые GET-хуки REST-клиента (`useGetUserList`, `useGetPostDetail` и т.п.). SWR инкапсулирован в хуке, компонент про него не знает.
|
||||||
|
|
||||||
## Карта раздела
|
## Карта раздела
|
||||||
|
|
||||||
@@ -21,12 +21,20 @@ keywords: [данные, api, rest, realtime, клиент, swr, infrastructure,
|
|||||||
|
|
||||||
Канал «запрос-ответ» по HTTP. Покрывает большинство API.
|
Канал «запрос-ответ» по HTTP. Покрывает большинство API.
|
||||||
|
|
||||||
- **Клиенты** — как создаётся клиент REST API:
|
- [REST](/docs/data/rest/) — обзор раздела: создание клиента и использование.
|
||||||
- [Автоматическая генерация](/docs/data/rest/clients/auto) — для API с OpenAPI-спецификацией, через `@gromlab/api-codegen`.
|
- **Создание клиента** — как оформляется REST API в проекте:
|
||||||
|
- [Обзор](/docs/data/rest/clients/) — когда нужен клиент и как выбрать подход.
|
||||||
|
- [Автогенерация из OpenAPI](/docs/data/rest/clients/auto) — для API с OpenAPI-спецификацией, через `@gromlab/api-codegen`.
|
||||||
- [Ручное создание](/docs/data/rest/clients/manual) — для API без схемы, клиент пишется и поддерживается руками.
|
- [Ручное создание](/docs/data/rest/clients/manual) — для API без схемы, клиент пишется и поддерживается руками.
|
||||||
- **Получение данных** — как клиент используется в приложении:
|
- [GET-хуки REST-клиента](/docs/data/rest/clients/hooks) — прозрачные SWR-обёртки над GET-методами клиента.
|
||||||
- [Серверные компоненты](/docs/data/rest/fetching/server) — прямой `await` метода клиента в Server Components.
|
- **Использование** — как получать данные через готовый клиент:
|
||||||
- [Клиентские компоненты](/docs/data/rest/fetching/client) — через готовые хуки модуля API; SWR с кешем, дедупликацией и ревалидацией скрыт внутри хука.
|
- [Стратегии получения данных](/docs/data/rest/strategies/) — как выбрать способ получения данных под ситуацию.
|
||||||
|
- [Серверный await](/docs/data/rest/strategies/server-await) — прямой `await` метода клиента в Server Components.
|
||||||
|
- [Параллельные серверные запросы](/docs/data/rest/strategies/parallel-server-requests) — запуск независимых серверных запросов без waterfall.
|
||||||
|
- [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down) — серверный стриминг через промис и `Suspense`.
|
||||||
|
- [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data) — серверный промис в `SWRConfig fallback`.
|
||||||
|
- [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook) — получение данных в Client Components через готовый GET-хук.
|
||||||
|
- [Business-композиция](/docs/data/rest/strategies/business-composition) — доменная интерпретация и композиция REST-данных.
|
||||||
|
|
||||||
### Realtime
|
### Realtime
|
||||||
|
|
||||||
@@ -40,7 +48,8 @@ keywords: [данные, api, rest, realtime, клиент, swr, infrastructure,
|
|||||||
|
|
||||||
- Где живёт код работы с API и почему именно там.
|
- Где живёт код работы с API и почему именно там.
|
||||||
- Когда генерировать клиент автоматически, а когда писать вручную, и как структурирован каждый из вариантов.
|
- Когда генерировать клиент автоматически, а когда писать вручную, и как структурирован каждый из вариантов.
|
||||||
- Как получать данные на сервере и на клиенте, чтобы не ломать кеш и не плодить лишние запросы.
|
- Какие GET-хуки относятся к REST-клиенту и почему они живут в `infrastructure/{service-name}/hooks/`.
|
||||||
|
- Как выбрать стратегию получения REST-данных под конкретную ситуацию.
|
||||||
- Как подключать realtime-источники в общую модель работы с данными.
|
- Как подключать realtime-источники в общую модель работы с данными.
|
||||||
- Какие правила обязательны и какие отклонения допустимы.
|
- Какие правила обязательны и какие отклонения допустимы.
|
||||||
|
|
||||||
|
|||||||
@@ -1,279 +1,193 @@
|
|||||||
---
|
---
|
||||||
title: Автогенерация REST-клиента
|
title: Автогенерация из OpenAPI
|
||||||
description: Генерация REST-клиента из OpenAPI-спецификации.
|
description: Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen.
|
||||||
keywords: [api, rest, openapi, codegen, генерация, клиент, api-codegen, gromlab, infrastructure, swagger-typescript-api]
|
keywords: [rest, openapi, api-codegen, автогенерация, generated, npx]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Автогенерация REST-клиента
|
# Автогенерация из OpenAPI
|
||||||
|
|
||||||
Генерация REST-клиента из OpenAPI-спецификации.
|
Автогенерация используется, когда у API есть актуальная OpenAPI-спецификация. Генератор создаёт TypeScript-клиент, типы и методы API, а разработчик вручную добавляет настройку клиента и GET-хуки.
|
||||||
|
|
||||||
В примерах ниже используется условный API `pet-project-api` (kebab-case в путях) / `petProjectApi` (camelCase в коде). В реальном проекте имена выбираются по конкретному API.
|
## Пример API
|
||||||
|
|
||||||
## Установка
|
В примерах используется Swagger Petstore:
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
npm install -D @gromlab/api-codegen
|
https://petstore3.swagger.io/api/v3/openapi.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт генерации в `package.json` — по одному на каждый API:
|
Имена модуля:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/infrastructure/pet-store-api/
|
||||||
|
petStoreApi
|
||||||
|
pet-store-api.generated.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Скрипт генерации
|
||||||
|
|
||||||
|
`@gromlab/api-codegen` не устанавливается в `devDependencies`. Используем `npx @gromlab/api-codegen@latest`, чтобы запускать свежую версию.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"codegen:pet-project-api": "api-codegen --config src/infrastructure/pet-project-api/config/pet-project-api.config.ts"
|
"codegen:pet-store-api": "npx @gromlab/api-codegen@latest -i https://petstore3.swagger.io/api/v3/openapi.json -o src/infrastructure/pet-store-api/generated -n pet-store-api.generated"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Конфиг и опции — в репозитории [@gromlab/api-codegen](https://gromlab.ru/gromov/api-codegen).
|
Параметры:
|
||||||
|
|
||||||
## Структура модуля
|
- `-i` — путь к OpenAPI-спецификации: URL или локальный файл.
|
||||||
|
- `-o` — директория для сгенерированного файла.
|
||||||
|
- `-n` — имя сгенерированного файла без `.ts`.
|
||||||
|
|
||||||
Клиент кладётся в слой `infrastructure/` отдельным модулем по имени API (kebab-case):
|
Ключ `--swr` не используется. GET-хуки REST-клиента пишутся вручную, чтобы сохранить проектный контракт: один GET-хук = один GET-метод, без бизнес-логики и композиции.
|
||||||
|
|
||||||
|
## Генерация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run codegen:pet-store-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый результат:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/infrastructure/
|
src/infrastructure/pet-store-api/generated/
|
||||||
└── pet-project-api/
|
└── pet-store-api.generated.ts
|
||||||
├── generated/ # сегмент сгенерированного кода
|
|
||||||
│ └── pet-project-api.generated.ts # сгенерировано — не править
|
|
||||||
├── types/ # расширения сгенерированных типов
|
|
||||||
│ ├── user.ts # declare module + Extended-тип
|
|
||||||
│ └── index.ts # реэкспорт расширений
|
|
||||||
├── hooks/ # SWR-хуки для клиентских компонентов
|
|
||||||
│ ├── use-user-list.hook.ts
|
|
||||||
│ ├── use-user-detail.hook.ts
|
|
||||||
│ └── index.ts # реэкспорт хуков
|
|
||||||
├── config/ # конфиги модуля
|
|
||||||
│ └── pet-project-api.config.ts # конфиг генерации клиента
|
|
||||||
├── client.ts # настройка HttpClient, инстанс Api
|
|
||||||
└── index.ts # публичный API модуля
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Файл | Роль | Кто правит |
|
Сгенерированный файл не правится руками и коммитится в репозиторий.
|
||||||
|------|------|-----------|
|
|
||||||
| `generated/{service-name}.generated.ts` | Сгенерированный код: типы, `class Api`, `class HttpClient` | codegen, не править |
|
|
||||||
| `types/{сущность}.ts` | `declare module` + `Extended`-типы по сущности | разработчик |
|
|
||||||
| `types/index.ts` | Реэкспорт публичных расширений | разработчик |
|
|
||||||
| `hooks/use-{action}.hook.ts` | SWR-хук поверх метода клиента | разработчик |
|
|
||||||
| `hooks/index.ts` | Реэкспорт хуков | разработчик |
|
|
||||||
| `config/{service-name}.config.ts` | Параметры генерации для конкретного API | разработчик |
|
|
||||||
| `client.ts` | `baseUrl` из env, конфиг `HttpClient`, инстанс `new Api(...)` | разработчик |
|
|
||||||
| `index.ts` | Публичный API: инстанс сервиса, расширенные типы, хуки | разработчик |
|
|
||||||
|
|
||||||
`client.ts` и `index.ts` — единственные корневые файлы модуля. Все остальные файлы живут в сегментах (`generated/`, `types/`, `hooks/`, `config/`).
|
## Проверка методов
|
||||||
|
|
||||||
Имя сгенерированного файла — `{service-name}.generated.ts` (имя сервиса в kebab-case + суффикс `.generated.ts`). Суффикс сигнализирует «не править руками».
|
После генерации откройте `generated/pet-store-api.generated.ts` и проверьте фактические имена методов.
|
||||||
|
|
||||||
|
Для Petstore нужны GET-операции вида:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
petStoreApi.pet.findPetsByStatus(...)
|
||||||
|
petStoreApi.pet.getPetById(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Точные сигнатуры зависят от OpenAPI-схемы и версии генератора. В рабочих задачах всегда сверяйтесь с generated-файлом.
|
||||||
|
|
||||||
## `client.ts`
|
## `client.ts`
|
||||||
|
|
||||||
Тонкий ручной слой поверх сгенерированного кода. Делает три вещи: читает и нормализует `baseUrl`, конфигурирует `HttpClient`, создаёт **именованный инстанс** сервиса.
|
Сгенерированный код не должен напрямую использоваться из приложения. Сначала создаётся настроенный инстанс клиента.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/client.ts
|
// src/infrastructure/pet-store-api/client.ts
|
||||||
import { Api, HttpClient } from './generated/pet-project-api.generated'
|
import { Api, HttpClient } from './generated/pet-store-api.generated'
|
||||||
|
|
||||||
const resolvedBaseUrl = process.env.NEXT_PUBLIC_API_URL
|
|
||||||
.replace(/\/+$/, '') // убираем хвостовой слэш
|
|
||||||
.replace(/\/v1$/, '') // версия уже в путях методов — режем дубль
|
|
||||||
|
|
||||||
const httpClient = new HttpClient({
|
const httpClient = new HttpClient({
|
||||||
|
baseUrl: 'https://petstore3.swagger.io/api/v3',
|
||||||
baseApiParams: {
|
baseApiParams: {
|
||||||
secure: false,
|
secure: false,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
// кастомные заголовки API — если требуются
|
|
||||||
// 'X-App-Key': '...',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient.baseUrl = resolvedBaseUrl
|
export const petStoreApi = new Api(httpClient)
|
||||||
|
|
||||||
export const petProjectApi = new Api(httpClient)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Имя инстанса = имя сервиса
|
В реальном проекте вместо строки `baseUrl` обычно берётся из env-переменной, нормализуется в `client.ts` и передаётся в `HttpClient` через конфиг.
|
||||||
|
|
||||||
Инстанс называется по имени API в camelCase, не унифицированно `api`/`client`. Это даёт **процедурное обращение** и однозначность при работе с несколькими сервисами:
|
`client.ts` не содержит расширения типов, `declare module` и `Extended`-типы. Он только настраивает транспорт и экспортирует инстанс клиента.
|
||||||
|
|
||||||
```ts
|
## Расширение сгенерированных типов
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
const user = await petProjectApi.user.getUser(id)
|
Сгенерированный файл не правится руками. Если OpenAPI-спецификация неполная или генератор дал слишком общий тип (`object`, `unknown`, отсутствующее поле), расширения живут в `types/`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/infrastructure/biocad-less-api/
|
||||||
|
├── generated/
|
||||||
|
│ └── biocad-less-api.generated.ts
|
||||||
|
├── types/
|
||||||
|
│ ├── term.ts
|
||||||
|
│ └── index.ts
|
||||||
|
├── client.ts
|
||||||
|
└── index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
При нескольких API — каждый со своим именем:
|
Пример расширения generated-типа:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
// src/infrastructure/biocad-less-api/types/term.ts
|
||||||
import { paymentsApi } from 'infrastructure/payments-api'
|
import type { TermRecordItem } from '../generated/biocad-less-api.generated'
|
||||||
|
|
||||||
const user = await petProjectApi.user.list()
|
declare module '../generated/biocad-less-api.generated' {
|
||||||
const invoice = await paymentsApi.invoices.list()
|
interface TermRecordItem {
|
||||||
```
|
media?: {
|
||||||
|
|
||||||
### Нормализация `baseUrl`
|
|
||||||
|
|
||||||
`@gromlab/api-codegen` может включать версию (`/v1`) в `baseUrl` сгенерированного кода, а пути методов уже содержат её — отсюда дубль. Стандартный приём:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
.replace(/\/+$/, '') // хвостовой слэш
|
|
||||||
.replace(/\/v1$/, '') // версия (если фигурирует в путях)
|
|
||||||
```
|
|
||||||
|
|
||||||
Подгоняется под конкретный API: если версия в путях не повторяется — второй `replace` не нужен.
|
|
||||||
|
|
||||||
## Расширения типов
|
|
||||||
|
|
||||||
Автогенерация не покрывает все реальные поля API: иногда тип `object`, иногда поле просто отсутствует. Расширения живут в `types/`, по файлу на сущность.
|
|
||||||
|
|
||||||
Две техники:
|
|
||||||
|
|
||||||
### `declare module` — добавление полей
|
|
||||||
|
|
||||||
Дополняет существующий интерфейс из `generated.ts`. Сама сгенерированная декларация не трогается.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/types/user.ts
|
|
||||||
import type { User } from '../generated/pet-project-api.generated'
|
|
||||||
|
|
||||||
declare module '../generated/pet-project-api.generated' {
|
|
||||||
interface User {
|
|
||||||
avatar?: {
|
|
||||||
file?: string
|
file?: string
|
||||||
title?: string
|
title?: string
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
### `Extended` через `Omit & {...}` — переопределение полей
|
export type TermRecordItemExtended = Omit<
|
||||||
|
TermRecordItem,
|
||||||
Когда автогенерация даёт `object` или общий тип, а реально структура известна — создаётся отдельный тип `UserExtended` (по имени сущности + суффикс `Extended`).
|
'categories' | 'tags' | 'fields'
|
||||||
|
> & {
|
||||||
```ts
|
categories?: Array<{
|
||||||
// src/infrastructure/pet-project-api/types/user.ts
|
_id?: string
|
||||||
export type UserExtended = Omit<User, 'roles' | 'tags' | 'fields'> & {
|
id?: string
|
||||||
roles?: Array<{ _id?: string; id?: string; slug?: string; name?: string }>
|
slug?: string
|
||||||
tags?: Array<{ _id?: string; id?: string; slug?: string; name?: string }>
|
name?: string
|
||||||
|
}>
|
||||||
|
tags?: Array<{
|
||||||
|
_id?: string
|
||||||
|
id?: string
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
}>
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Реэкспорт
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/types/index.ts
|
// src/infrastructure/biocad-less-api/types/index.ts
|
||||||
export type { UserExtended } from './user'
|
export type { TermRecordItemExtended } from './term'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Правила
|
`declare module` используется для добавления отсутствующих полей в generated-интерфейс. `Extended`-тип используется, когда нужно переопределить неточные поля, не трогая generated-файл.
|
||||||
|
|
||||||
- Расширения — **только в `types/`**, не в `client.ts` и не в сгенерированном файле.
|
## Публичный API
|
||||||
- Один файл на сущность (имя файла — kebab-case по сущности: `user.ts`, `order.ts`, `invoice.ts`).
|
|
||||||
- При регенерации `generated/{service-name}.generated.ts` файлы в `types/` не затрагиваются.
|
|
||||||
- Если сломался `Extended`-тип после regen — синхронизировать руками.
|
|
||||||
|
|
||||||
## Хуки для клиентских компонентов
|
|
||||||
|
|
||||||
В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте `hooks/`, по файлу на операцию.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/hooks/use-user-list.hook.ts
|
// src/infrastructure/pet-store-api/index.ts
|
||||||
import useSWR from 'swr'
|
export { petStoreApi } from './client'
|
||||||
import type { SWRConfiguration } from 'swr'
|
export type { Pet } from './generated/pet-store-api.generated'
|
||||||
import { petProjectApi } from '../client'
|
|
||||||
import type { User } from '../generated/pet-project-api.generated'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получение списка пользователей.
|
|
||||||
*/
|
|
||||||
export const useUserList = (
|
|
||||||
query?: { limit?: number; offset?: number },
|
|
||||||
config?: SWRConfiguration,
|
|
||||||
) => {
|
|
||||||
return useSWR<User[]>(
|
|
||||||
['pet-project-api', 'user', 'list', query],
|
|
||||||
() => petProjectApi.user.list(query ?? {}),
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/hooks/use-user-detail.hook.ts
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import type { SWRConfiguration } from 'swr'
|
|
||||||
import { petProjectApi } from '../client'
|
|
||||||
import type { UserExtended } from '../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получение пользователя по идентификатору.
|
|
||||||
*/
|
|
||||||
export const useUserDetail = (
|
|
||||||
id: string | null,
|
|
||||||
config?: SWRConfiguration,
|
|
||||||
) => {
|
|
||||||
const key = id ? ['pet-project-api', 'user', 'detail', id] : null
|
|
||||||
const fetcher = () => petProjectApi.user.getUser(id!) as Promise<UserExtended>
|
|
||||||
|
|
||||||
return useSWR<UserExtended>(key, fetcher, config)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/hooks/index.ts
|
|
||||||
export { useUserList } from './use-user-list.hook'
|
|
||||||
export { useUserDetail } from './use-user-detail.hook'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Правила хуков
|
|
||||||
|
|
||||||
- **Один файл — один хук**, имя файла `use-{action}.hook.ts` ([Именование](/docs/basics/naming)).
|
|
||||||
- **Тонкая обёртка над SWR.** Внутри — построение ключа, fetcher через метод клиента, возврат `useSWR(...)`. Никакой бизнес-логики.
|
|
||||||
- **Ключ начинается с имени сервиса** (`['pet-project-api', ...]`) — изолирует кеш между разными API.
|
|
||||||
- **Условный ключ для опциональных параметров:** `id ? [...key, id] : null`. `null` приостанавливает запрос, пока параметры не готовы.
|
|
||||||
- **Параметр `config?: SWRConfiguration`** — даёт потребителю переопределить ревалидацию, `fallbackData`, `suspense` и т.п. без обёрток.
|
|
||||||
|
|
||||||
## Публичный API модуля
|
|
||||||
|
|
||||||
Из `index.ts` экспортируются инстанс, расширенные типы и хуки. Сырые типы из `generated/` экспортируются по необходимости — точечно.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/index.ts
|
|
||||||
export { petProjectApi } from './client'
|
|
||||||
export type { UserExtended } from './types'
|
|
||||||
export * from './hooks'
|
export * from './hooks'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Наружу импортируют только из `infrastructure/pet-store-api`, не из `generated/`.
|
||||||
|
|
||||||
|
Если у модуля есть расширенные типы, они тоже реэкспортируются через `index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/biocad-less-api/index.ts
|
||||||
|
export type { TermRecordItemExtended } from './types'
|
||||||
|
```
|
||||||
|
|
||||||
## Регенерация
|
## Регенерация
|
||||||
|
|
||||||
При изменении OpenAPI-схемы:
|
При изменении OpenAPI-схемы:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run codegen:pet-project-api
|
npm run codegen:pet-store-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Что меняется:
|
Что меняется:
|
||||||
|
|
||||||
- `generated/{service-name}.generated.ts` — перезаписывается полностью, изменения коммитятся.
|
- `generated/pet-store-api.generated.ts` — перезаписывается генератором.
|
||||||
- `client.ts`, `types/`, `config/`, `index.ts` — **не трогаются** автоматически.
|
- `client.ts`, `hooks/`, `types/`, `index.ts` — не трогаются автоматически.
|
||||||
|
|
||||||
Поломка контракта (изменение типов в схеме) ловится TypeScript при сборке проекта. Если ломаются `Extended`-типы — синхронизировать вручную в соответствующих файлах `types/`.
|
Если после регенерации поменялись сигнатуры методов или типы, это исправляется в ручном коде модуля.
|
||||||
|
|
||||||
## Сгенерированный файл коммитится
|
## Следующий шаг
|
||||||
|
|
||||||
Файл `generated/{service-name}.generated.ts` **не добавляется в `.gitignore`** — попадает в репозиторий вместе с остальным кодом.
|
После генерации и настройки `client.ts` проверьте серверный вызов метода клиента или добавьте [GET-хук REST-клиента](/docs/data/rest/clients/hooks) для Client Components.
|
||||||
|
|
||||||
Причины:
|
|
||||||
|
|
||||||
- **Детерминированная сборка.** `npm run build` не зависит от доступности OpenAPI-схемы (обычно она на удалённом сервере). Сервис лёг — прод собирается.
|
|
||||||
- **Видимость изменений в PR.** Diff показывает, что именно поменялось в контракте API между версиями.
|
|
||||||
- **Простой онбординг.** После `git clone` IDE сразу видит типы, без предварительной генерации.
|
|
||||||
- **Фиксация версии контракта.** Пересборка старого коммита даёт ровно тот клиент, что был тогда.
|
|
||||||
|
|
||||||
Регенерация — **ручная команда** при обновлении схемы, не хук `predev`/`prebuild`. Запускается осознанно.
|
|
||||||
|
|
||||||
Исключение возможно, только если OpenAPI-схема лежит **в этом же репозитории** и генерация быстрая, без сети — тогда допустимо добавить сегмент `generated/` в `.gitignore` и хук `prebuild`, по аналогии со спрайтами. На практике встречается редко.
|
|
||||||
|
|||||||
206
docs/docs/data/rest/clients/hooks.md
Normal file
206
docs/docs/data/rest/clients/hooks.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
title: GET-хуки REST-клиента
|
||||||
|
description: Прозрачные SWR-обёртки над GET-методами REST-клиента.
|
||||||
|
keywords: [rest, swr, get-хуки, client components, infrastructure]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GET-хуки REST-клиента
|
||||||
|
|
||||||
|
GET-хуки REST-клиента — прозрачные SWR-обёртки над GET-методами API-клиента. Они нужны, чтобы Client Components получали данные с кешированием, дедупликацией и ревалидацией, не работая с `useSWR` напрямую.
|
||||||
|
|
||||||
|
## Где лежат
|
||||||
|
|
||||||
|
GET-хуки принадлежат REST-клиенту конкретного сервиса и живут рядом с ним:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/infrastructure/
|
||||||
|
└── pet-store-api/
|
||||||
|
├── client.ts
|
||||||
|
├── generated/
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-get-pet-list.hook.ts
|
||||||
|
│ ├── use-get-pet-detail.hook.ts
|
||||||
|
│ └── index.ts
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Контракт
|
||||||
|
|
||||||
|
- Один GET-хук = один GET-метод клиента.
|
||||||
|
- Имя GET-хука начинается с `useGet`: `useGetPetList`, `useGetPetDetail`.
|
||||||
|
- Имя файла начинается с `use-get`: `use-get-pet-list.hook.ts`.
|
||||||
|
- Хук принимает только параметры GET-метода и `config?: SWRConfiguration`.
|
||||||
|
- Что передали хуку, то он передаёт в GET-метод.
|
||||||
|
- Внутри только SWR-механика: key, fetcher, `useSWR`, `config`.
|
||||||
|
- Хук возвращает тип ответа API: generated-тип или DTO из `types/`.
|
||||||
|
- Хук не объединяет несколько запросов.
|
||||||
|
- Хук не маппит DTO в доменную модель.
|
||||||
|
- Хук не вычисляет бизнес-флаги: `isAuth`, `canEdit`, `hasAccess`, `hasPets`.
|
||||||
|
- Хук не вызывает тосты, модалки, редиректы и не пишет UI-состояние.
|
||||||
|
|
||||||
|
## Пример списка
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-store-api/hooks/use-get-pet-list.hook.ts
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import type { SWRConfiguration } from 'swr'
|
||||||
|
import { petStoreApi } from '../client'
|
||||||
|
import type { Pet } from '../generated/pet-store-api.generated'
|
||||||
|
|
||||||
|
export type PetStatus = 'available' | 'pending' | 'sold'
|
||||||
|
|
||||||
|
export const getPetListKey = (status: PetStatus) =>
|
||||||
|
['pet-store-api', 'pet', 'list', status] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка питомцев по статусу.
|
||||||
|
*/
|
||||||
|
export const useGetPetList = (status: PetStatus | null, config?: SWRConfiguration) => {
|
||||||
|
const isReady = status !== null
|
||||||
|
const key = isReady ? getPetListKey(status) : null
|
||||||
|
const fetcher = () => petStoreApi.pet.findPetsByStatus({ status })
|
||||||
|
|
||||||
|
return useSWR<Pet[]>(key, fetcher, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Функция `getPetListKey` нужна, чтобы один и тот же SWR-ключ использовался внутри GET-хука и при передаче начальных данных через `SWRConfig fallback`.
|
||||||
|
|
||||||
|
Пример начальных данных для клиентского хука:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { SWRConfig, unstable_serialize } from 'swr'
|
||||||
|
import {
|
||||||
|
getPetListKey,
|
||||||
|
petStoreApi,
|
||||||
|
} from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
export default function PetsLayout({ children }: { children: ReactNode }) {
|
||||||
|
const petsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fallback: {
|
||||||
|
[unstable_serialize(getPetListKey('available'))]: petsPromise,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SWRConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиентский компонент при этом ничего не знает про preload/fallback и продолжает вызывать обычный хук:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data: pets } = useGetPetList('available')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример detail-запроса
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-store-api/hooks/use-get-pet-detail.hook.ts
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import type { SWRConfiguration } from 'swr'
|
||||||
|
import { petStoreApi } from '../client'
|
||||||
|
import type { Pet } from '../generated/pet-store-api.generated'
|
||||||
|
|
||||||
|
export const getPetDetailKey = (id: number) =>
|
||||||
|
['pet-store-api', 'pet', 'detail', id] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение питомца по идентификатору.
|
||||||
|
*/
|
||||||
|
export const useGetPetDetail = (id: number | null, config?: SWRConfiguration) => {
|
||||||
|
const isReady = id !== null
|
||||||
|
const key = isReady ? getPetDetailKey(id) : null
|
||||||
|
const fetcher = () => petStoreApi.pet.getPetById(id)
|
||||||
|
|
||||||
|
return useSWR<Pet>(key, fetcher, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отложенный запрос через `null`
|
||||||
|
|
||||||
|
GET-хук может принимать `null` для обязательного параметра. `null` означает, что параметр ещё не готов и запрос выполнять нельзя.
|
||||||
|
|
||||||
|
Внутри хука это выражается через `isReady`: если параметр не готов, ключ SWR становится `null`, и SWR не вызывает fetcher.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const isReady = id !== null
|
||||||
|
const key = isReady ? getPetDetailKey(id) : null
|
||||||
|
```
|
||||||
|
|
||||||
|
`null` не передаётся в метод клиента. Key-функция принимает только готовые параметры, поэтому её можно безопасно использовать для начальных данных через `SWRConfig fallback`.
|
||||||
|
|
||||||
|
Для числовых идентификаторов не используйте проверку `if (id)`: значение `0` тоже валидное число. Проверяйте явно: `id !== null`.
|
||||||
|
|
||||||
|
## Экспорт
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-store-api/hooks/index.ts
|
||||||
|
export { getPetListKey, useGetPetList } from './use-get-pet-list.hook'
|
||||||
|
export type { PetStatus } from './use-get-pet-list.hook'
|
||||||
|
export { getPetDetailKey, useGetPetDetail } from './use-get-pet-detail.hook'
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-store-api/index.ts
|
||||||
|
export { petStoreApi } from './client'
|
||||||
|
export type { Pet } from './generated/pet-store-api.generated'
|
||||||
|
export * from './hooks'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Где заканчивается infrastructure
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Хорошо: infrastructure, прозрачный GET-хук
|
||||||
|
const { data: pets } = useGetPetList('available')
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Хорошо: business, доменная интерпретация
|
||||||
|
export const useAvailablePets = () => {
|
||||||
|
const query = useGetPetList('available')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
hasPets: Boolean(query.data?.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasPets` — не часть GET-запроса, поэтому он не добавляется в `useGetPetList`.
|
||||||
|
|
||||||
|
## Что запрещено
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Плохо — useSWR в компоненте
|
||||||
|
const { data } = useSWR(
|
||||||
|
['pet-store-api', 'pet', 'list', status],
|
||||||
|
() => petStoreApi.pet.findPetsByStatus({ status }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Плохо — несколько GET внутри infrastructure-хука
|
||||||
|
export const usePetDashboard = () => {
|
||||||
|
const available = useGetPetList('available')
|
||||||
|
const sold = useGetPetList('sold')
|
||||||
|
|
||||||
|
return { available, sold }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плохо — бизнес-флаг внутри GET-хука REST-клиента
|
||||||
|
export const useGetPetList = (status: PetStatus) => {
|
||||||
|
const query = useSWR(...)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
hasPets: Boolean(query.data?.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробное потребление таких хуков описано в стратегии [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook).
|
||||||
75
docs/docs/data/rest/clients/index.md
Normal file
75
docs/docs/data/rest/clients/index.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: Создание клиента
|
||||||
|
description: Из чего состоит REST-клиент и какие части нужно подготовить перед использованием API.
|
||||||
|
keywords: [rest, клиент, infrastructure, методы, openapi, get-хуки, swr]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Создание клиента
|
||||||
|
|
||||||
|
REST-клиент — это infrastructure-модуль, через который проект работает с внешним REST API.
|
||||||
|
|
||||||
|
На этом этапе нужно подготовить клиент сервиса: создать оболочку клиента, получить методы API и добавить GET-хуки для клиентских компонентов.
|
||||||
|
|
||||||
|
## Из чего состоит клиент
|
||||||
|
|
||||||
|
REST-клиент состоит из трёх основных частей:
|
||||||
|
|
||||||
|
1. **Клиент** — самописная оболочка над транспортом.
|
||||||
|
2. **Методы** — сгенерированные из OpenAPI или написанные вручную вызовы API.
|
||||||
|
3. **GET-хуки** — SWR-обёртки для GET-запросов.
|
||||||
|
|
||||||
|
Эти части живут в одном REST-модуле, потому что относятся к одному внешнему сервису.
|
||||||
|
|
||||||
|
## Клиент
|
||||||
|
|
||||||
|
Клиент — ручной слой, который настраивает работу с API: `baseUrl`, заголовки, авторизацию, обработку ошибок и создание инстанса сервиса.
|
||||||
|
|
||||||
|
Даже если методы генерируются из OpenAPI, `client.ts` остаётся ручным файлом проекта.
|
||||||
|
|
||||||
|
`client.ts` — только сборочная точка клиента. В нём не размещаются DTO, `declare module`, `Extended`-типы, GET-хуки и бизнес-логика.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
Методы описывают конкретные запросы к API.
|
||||||
|
|
||||||
|
Они появляются одним из двух способов:
|
||||||
|
|
||||||
|
- генерируются из OpenAPI в `generated/`;
|
||||||
|
- создаются вручную в `methods/`.
|
||||||
|
|
||||||
|
Подробности:
|
||||||
|
|
||||||
|
- [Автогенерация из OpenAPI](/docs/data/rest/clients/auto)
|
||||||
|
- [Ручное создание](/docs/data/rest/clients/manual)
|
||||||
|
|
||||||
|
## GET-хуки
|
||||||
|
|
||||||
|
Для GET-запросов добавляются GET-хуки REST-клиента.
|
||||||
|
|
||||||
|
Это прозрачные SWR-обёртки над GET-методами клиента. Они живут в `hooks/` этого же REST-модуля и нужны для использования данных в Client Components.
|
||||||
|
|
||||||
|
GET-хуки именуются с префиксом `useGet`: `useGetPetList`, `useGetPetDetail`, `useGetCurrentUser`.
|
||||||
|
|
||||||
|
Подробности:
|
||||||
|
|
||||||
|
- [GET-хуки REST-клиента](/docs/data/rest/clients/hooks)
|
||||||
|
|
||||||
|
## Структура модуля
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/infrastructure/{service-name}/
|
||||||
|
├── client.ts # самописная оболочка и инстанс клиента
|
||||||
|
├── generated/ или methods/ # методы API
|
||||||
|
├── hooks/ # GET-хуки REST-клиента
|
||||||
|
├── types/ # DTO, типы API и расширения типов
|
||||||
|
├── errors/ # ошибки API, если нужны
|
||||||
|
└── index.ts # публичный API
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts` — единственная точка входа в REST-модуль для внешнего кода.
|
||||||
|
|
||||||
|
## Что делаем дальше
|
||||||
|
|
||||||
|
1. Создайте методы клиента: [Автогенерация из OpenAPI](/docs/data/rest/clients/auto) или [Ручное создание](/docs/data/rest/clients/manual).
|
||||||
|
2. Добавьте GET-хуки для GET-запросов: [GET-хуки REST-клиента](/docs/data/rest/clients/hooks).
|
||||||
|
3. После создания клиента переходите к [Стратегиям получения данных](/docs/data/rest/strategies/).
|
||||||
@@ -1,365 +1,187 @@
|
|||||||
---
|
---
|
||||||
title: Ручное создание REST-клиента
|
title: Ручное создание
|
||||||
description: "Создание REST-клиента вручную, когда нет OpenAPI-спецификации."
|
description: Создание REST-клиента вручную, когда OpenAPI нет или он неполный.
|
||||||
keywords: [api, rest, клиент, ручной, fetch, infrastructure, api-клиент]
|
keywords: [rest, ручной клиент, fetch, methods, dto, errors, infrastructure]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ручное создание REST-клиента
|
# Ручное создание
|
||||||
|
|
||||||
Создание REST-клиента вручную, когда нет OpenAPI-спецификации.
|
Ручной REST-клиент используется, когда у API нет OpenAPI-спецификации или она недостаточно точная для автогенерации.
|
||||||
|
|
||||||
В примерах ниже используется условный API `pet-project-api` / `petProjectApi`. В реальном проекте имена выбираются по конкретному API.
|
Задача ручного клиента — дать такую же точку входа, как у автогенерированного клиента: именованный API-объект, методы по сущностям, DTO и GET-хуки рядом с клиентом.
|
||||||
|
|
||||||
## Структура модуля
|
## Что нужно создать
|
||||||
|
|
||||||
Клиент живёт в слое `infrastructure/` отдельным модулем по имени API (kebab-case):
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/infrastructure/
|
src/infrastructure/
|
||||||
└── pet-project-api/
|
└── pet-project-api/
|
||||||
├── methods/ # методы по сущностям API
|
├── methods/
|
||||||
│ ├── pages.ts
|
│ └── posts.ts
|
||||||
│ ├── posts.ts
|
├── hooks/
|
||||||
│ └── forms.ts
|
|
||||||
├── hooks/ # SWR-хуки для клиентских компонентов
|
|
||||||
│ ├── use-post-detail.hook.ts
|
|
||||||
│ ├── use-post-filter.hook.ts
|
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── types/ # типы клиента и доменные типы
|
├── types/
|
||||||
│ ├── client.ts # типы клиента: RequestOptions, ParamValue
|
│ ├── client.ts
|
||||||
│ ├── post.ts # доменные типы сущности post
|
│ ├── post.ts
|
||||||
│ ├── form.ts # доменные типы сущности form
|
│ └── index.ts
|
||||||
│ └── index.ts # реэкспорт публичных типов
|
├── errors/
|
||||||
├── errors/ # доменные ошибки API
|
|
||||||
│ └── pet-project-api.error.ts
|
│ └── pet-project-api.error.ts
|
||||||
├── client.ts # класс клиента: baseUrl, headers, get/post
|
├── client.ts
|
||||||
└── index.ts # публичный API модуля
|
└── index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
| Файл | Роль |
|
| Файл | Роль |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `client.ts` | Класс `PetProjectApiClient`: `baseUrl`, общие заголовки, `buildUrl`, базовые `get`/`post` |
|
| `client.ts` | Базовый транспорт и создание инстанса клиента |
|
||||||
| `methods/{entity}.ts` | Методы по сущности, экспортируются фабрикой `{entity}Methods(client)` |
|
| `methods/` | Методы API по сущностям |
|
||||||
| `hooks/use-{action}.hook.ts` | SWR-хук поверх метода клиента |
|
| `types/` | DTO запросов, ответов и типы клиента |
|
||||||
| `hooks/index.ts` | Реэкспорт хуков |
|
| `errors/` | Ошибки конкретного API |
|
||||||
| `types/client.ts` | Типы инфраструктуры клиента: `RequestOptions`, `PostOptions`, `ParamValue` |
|
| `hooks/` | GET-хуки REST-клиента, если данные нужны в Client Components |
|
||||||
| `types/{entity}.ts` | Доменные типы: запросы, ответы, фильтры по сущности |
|
| `index.ts` | Публичный API REST-модуля |
|
||||||
| `types/index.ts` | Реэкспорт публичных типов |
|
|
||||||
| `errors/{service-name}.error.ts` | Доменный класс ошибок API |
|
|
||||||
| `index.ts` | Публичный API: инстанс клиента, хуки, доменные ошибки, типы |
|
|
||||||
|
|
||||||
`methods/`, `hooks/`, `types/`, `errors/` — сегменты модуля по канону SLM. `client.ts` и `index.ts` — единственные корневые файлы.
|
## DTO и типы API
|
||||||
|
|
||||||
## Типы клиента
|
DTO запросов и ответов живут в `types/`. `client.ts` не содержит DTO и доменные типы.
|
||||||
|
|
||||||
Типы, описывающие саму инфраструктуру запросов (опции, параметры) — выносятся в `types/client.ts`. Это держит `client.ts` коротким и не смешивает декларации типов с реализацией класса.
|
```ts
|
||||||
|
// src/infrastructure/pet-project-api/types/post.ts
|
||||||
|
export type PostDto = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostListQueryDto = {
|
||||||
|
limit?: number
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-project-api/types/index.ts
|
||||||
|
export type { PostDto, PostListQueryDto } from './post'
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы, которые нужны только базовому транспорту, можно держать отдельно:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/types/client.ts
|
// src/infrastructure/pet-project-api/types/client.ts
|
||||||
export type ParamValue = string | number | (string | number)[]
|
export type QueryParams = Record<string, string | number | boolean>
|
||||||
|
|
||||||
export type RequestOptions = {
|
|
||||||
params?: Record<string, ParamValue>
|
|
||||||
headers?: Record<string, string>
|
|
||||||
revalidate?: number | false
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PostOptions = RequestOptions & {
|
|
||||||
type?: 'json' | 'formdata'
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Базовый клиент
|
## Ошибка API
|
||||||
|
|
||||||
Класс с конфигурацией (`baseUrl`, общие заголовки) и базовыми методами `get` / `post`. Конкретные методы API размещаются в сегменте `methods/`, а не на самом классе — это держит `client.ts` коротким и не плодит «бога-класс».
|
Ошибка API тоже относится к REST-модулю.
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/client.ts
|
|
||||||
import { PetProjectApiError } from './errors/pet-project-api.error'
|
|
||||||
import type { ParamValue, RequestOptions, PostOptions } from './types/client'
|
|
||||||
|
|
||||||
export class PetProjectApiClient {
|
|
||||||
constructor(
|
|
||||||
private readonly baseUrl: string,
|
|
||||||
private readonly defaultHeaders: Record<string, string> = {},
|
|
||||||
) {
|
|
||||||
this.defaultHeaders = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...defaultHeaders,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUrl(path: string, params?: Record<string, ParamValue>): string {
|
|
||||||
const base = this.baseUrl.replace(/\/+$/, '')
|
|
||||||
const tail = path.replace(/^\/+/, '')
|
|
||||||
const url = `${base}/${tail}`
|
|
||||||
|
|
||||||
if (!params) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = new URLSearchParams()
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach((v) => search.append(key, String(v)))
|
|
||||||
} else {
|
|
||||||
search.set(key, String(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${url}?${search}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
||||||
const { params, headers, revalidate } = options
|
|
||||||
const response = await fetch(this.buildUrl(path, params), {
|
|
||||||
headers: { ...this.defaultHeaders, ...headers },
|
|
||||||
...(revalidate !== undefined && { next: { revalidate } }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw await PetProjectApiError.fromResponse(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
async post<T>(path: string, body: unknown, options: PostOptions = {}): Promise<T> {
|
|
||||||
const { params, headers, revalidate, type = 'json' } = options
|
|
||||||
const isJson = type === 'json'
|
|
||||||
|
|
||||||
const response = await fetch(this.buildUrl(path, params), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...(isJson && { 'Content-Type': 'application/json' }),
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
body: isJson ? JSON.stringify(body) : (body as BodyInit),
|
|
||||||
...(revalidate !== undefined && { next: { revalidate } }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw await PetProjectApiError.fromResponse(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключевые требования к клиенту
|
|
||||||
|
|
||||||
- **Класс с приватным состоянием** (`baseUrl`, `defaultHeaders`) — конфигурация инкапсулирована.
|
|
||||||
- **Типы клиента — в `types/client.ts`**, не в `client.ts`. Реализация и контракты разделены.
|
|
||||||
- **Базовые методы дженерик `<T>` без дефолта.** Вызов без типа невозможен — потребитель обязан указать форму ответа.
|
|
||||||
- **Доменная ошибка вместо `null`.** При не-`ok` бросается `PetProjectApiError`. Возврат `null` глотает причины (404 vs 500 vs 401) — не использовать.
|
|
||||||
- **Дефолт POST — `json`.** `formdata` указывается явно, на конкретных методах (загрузка файлов, отправка форм).
|
|
||||||
- **Нормализация слэшей** в `buildUrl` — `baseUrl` без хвостового `/`, `path` без ведущего `/`.
|
|
||||||
- **`async/await`**, не `.then()` — линейное чтение, простая обработка ошибок.
|
|
||||||
- **Поддержка `next.revalidate`** — клиент знает о Next.js App Router и пробрасывает кеш-флаги.
|
|
||||||
|
|
||||||
## Доменная ошибка
|
|
||||||
|
|
||||||
Сетевая ошибка превращается в класс ошибки модуля. Наружу не выходит сырой `Response`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/errors/pet-project-api.error.ts
|
// src/infrastructure/pet-project-api/errors/pet-project-api.error.ts
|
||||||
export class PetProjectApiError extends Error {
|
export class PetProjectApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
public readonly body: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(`PetProjectApi ${status}: ${body.slice(0, 200)}`)
|
super(message)
|
||||||
this.name = 'PetProjectApiError'
|
this.name = 'PetProjectApiError'
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromResponse(response: Response): Promise<PetProjectApiError> {
|
|
||||||
const body = await response.text().catch(() => '')
|
|
||||||
return new PetProjectApiError(response.status, body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Дополнительные подклассы по необходимости: `PetProjectApiValidationError` (400), `PetProjectApiAuthError` (401/403), `PetProjectApiNotFoundError` (404). Вводятся когда у потребителя есть **разная реакция** на разные коды; иначе хватает базового класса.
|
## Базовый клиент
|
||||||
|
|
||||||
## Доменные типы
|
`client.ts` содержит только транспортную оболочку и сборку инстанса. Прямой `fetch` живёт здесь, а не в компонентах и не в методах верхних слоёв.
|
||||||
|
|
||||||
Типы запросов, ответов и фильтров — по файлу на сущность. Типы должны лежать рядом по смыслу: всё, что относится к `posts`, — в `types/post.ts`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/types/post.ts
|
// src/infrastructure/pet-project-api/client.ts
|
||||||
export type Post = {
|
import { PetProjectApiError } from './errors/pet-project-api.error'
|
||||||
id: string
|
import type { QueryParams } from './types/client'
|
||||||
slug: string
|
|
||||||
title: string
|
export class PetProjectApiClient {
|
||||||
content: string
|
constructor(
|
||||||
publishedAt: string
|
private readonly baseUrl: string,
|
||||||
|
private readonly defaultHeaders: Record<string, string> = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get<T>(path: string, params: QueryParams = {}): Promise<T> {
|
||||||
|
const base = `${this.baseUrl.replace(/\/+$/, '')}/`
|
||||||
|
const url = new URL(path.replace(/^\/+/, ''), base)
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...this.defaultHeaders,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new PetProjectApiError(response.status, response.statusText)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PostFilter = {
|
return response.json() as Promise<T>
|
||||||
limit?: number
|
}
|
||||||
categories?: number[]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
Это минимальный шаблон. Авторизация, дополнительные заголовки, `next.revalidate`, `post`, `formdata` и другие детали добавляются только когда они реально нужны API.
|
||||||
// src/infrastructure/pet-project-api/types/index.ts
|
|
||||||
export type * from './post'
|
|
||||||
export type * from './form'
|
|
||||||
// типы клиента — внутренние, наружу не реэкспортируются
|
|
||||||
```
|
|
||||||
|
|
||||||
Типы клиента (`RequestOptions`, `PostOptions`, `ParamValue`) **не реэкспортируются** через `types/index.ts` — они нужны только внутри модуля.
|
## Методы API
|
||||||
|
|
||||||
## Методы
|
Методы группируются по сущностям в `methods/`. Они не знают про React, SWR и UI.
|
||||||
|
|
||||||
Методы группируются по сущностям в сегменте `methods/`, экспортируются фабрикой, принимающей клиент. Это даёт **процедурное обращение** в стиле автогенерированного клиента (`petProjectApi.posts.get(slug)`), а не плоский список (`petProjectApi.getPost(slug)`).
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/methods/posts.ts
|
// src/infrastructure/pet-project-api/methods/posts.ts
|
||||||
import type { PetProjectApiClient } from '../client'
|
import type { PetProjectApiClient } from '../client'
|
||||||
import type { Post, PostFilter } from '../types/post'
|
import type { PostDto, PostListQueryDto } from '../types/post'
|
||||||
|
|
||||||
export function postsMethods(client: PetProjectApiClient) {
|
export function postsMethods(client: PetProjectApiClient) {
|
||||||
return {
|
return {
|
||||||
|
/** GET /posts */
|
||||||
|
list: (query: PostListQueryDto = {}) =>
|
||||||
|
client.get<PostDto[]>('posts', query),
|
||||||
|
|
||||||
/** GET /posts/{slug} */
|
/** GET /posts/{slug} */
|
||||||
get: (slug: string, options?: { revalidate?: number | false }) =>
|
get: (slug: string) =>
|
||||||
client.get<Post>(`posts/${slug}`, options),
|
client.get<PostDto>(`posts/${slug}`),
|
||||||
|
|
||||||
/** POST /posts/filter */
|
|
||||||
filter: (body: PostFilter) =>
|
|
||||||
client.post<Post[]>('posts/filter', body),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
Метод возвращает DTO в форме API. Если данным нужен доменный смысл, маппинг делается выше, в `business/`.
|
||||||
// src/infrastructure/pet-project-api/methods/forms.ts
|
|
||||||
import type { PetProjectApiClient } from '../client'
|
|
||||||
import type { Form, FormSubmissionResult } from '../types/form'
|
|
||||||
|
|
||||||
export function formsMethods(client: PetProjectApiClient) {
|
## Публичный API
|
||||||
return {
|
|
||||||
/** GET /forms/{id} */
|
|
||||||
get: (id: string) => client.get<Form>(`forms/${id}`),
|
|
||||||
|
|
||||||
/** POST /forms/{id} — multipart/form-data */
|
`index.ts` собирает именованный API-объект и открывает наружу только публичные части модуля.
|
||||||
submit: (id: string, data: FormData) =>
|
|
||||||
client.post<FormSubmissionResult>(`forms/${id}`, data, { type: 'formdata' }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Правила методов
|
|
||||||
|
|
||||||
- **Группировка по сущности** (`pages`, `posts`, `forms`), не плоский список.
|
|
||||||
- **Имя метода — глагол действия**: `get`, `list`, `filter`, `create`, `update`, `delete`, `submit`. Не `getPost`/`getPosts` — сущность уже в имени группы.
|
|
||||||
- **Типы запросов и ответов — в `types/{entity}.ts`**, импортируются в файл методов. В `methods/` лежит только композиция вызовов клиента, без объявлений типов.
|
|
||||||
- **Фабрика принимает клиент** — это даёт тестируемость (моковый клиент в юнит-тестах) и единый источник конфигурации.
|
|
||||||
- **Никаких знаний об UI.** Клиент не знает про React, SWR, тосты — только данные и ошибки.
|
|
||||||
|
|
||||||
## Сборка инстанса
|
|
||||||
|
|
||||||
Группы методов соединяются в один объект на уровне `index.ts`. Это даёт процедурный доступ `petProjectApi.posts.get(...)`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/infrastructure/pet-project-api/index.ts
|
// src/infrastructure/pet-project-api/index.ts
|
||||||
import { PetProjectApiClient } from './client'
|
import { PetProjectApiClient } from './client'
|
||||||
import { pagesMethods } from './methods/pages'
|
|
||||||
import { postsMethods } from './methods/posts'
|
import { postsMethods } from './methods/posts'
|
||||||
import { formsMethods } from './methods/forms'
|
|
||||||
|
|
||||||
const client = new PetProjectApiClient(process.env.NEXT_PUBLIC_API_URL, {
|
const client = new PetProjectApiClient(
|
||||||
'X-App-Key': process.env.NEXT_PUBLIC_APP_KEY,
|
process.env.NEXT_PUBLIC_API_URL ?? '',
|
||||||
})
|
{ 'Content-Type': 'application/json' },
|
||||||
|
)
|
||||||
|
|
||||||
export const petProjectApi = {
|
export const petProjectApi = {
|
||||||
pages: pagesMethods(client),
|
|
||||||
posts: postsMethods(client),
|
posts: postsMethods(client),
|
||||||
forms: formsMethods(client),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PetProjectApiError } from './errors/pet-project-api.error'
|
export { PetProjectApiError } from './errors/pet-project-api.error'
|
||||||
export type { Post, PostFilter, Page, Form } from './types'
|
export type { PostDto, PostListQueryDto } from './types'
|
||||||
export * from './hooks'
|
export * from './hooks'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Хуки для клиентских компонентов
|
Внешний код импортирует только из `infrastructure/pet-project-api`, не из внутренних файлов модуля.
|
||||||
|
|
||||||
В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте `hooks/`, по файлу на операцию.
|
## Правила
|
||||||
|
|
||||||
```ts
|
- `fetch` используется только внутри базового клиента.
|
||||||
// src/infrastructure/pet-project-api/hooks/use-post-detail.hook.ts
|
- DTO запросов и ответов живут в `types/`.
|
||||||
import useSWR from 'swr'
|
- `client.ts` не содержит DTO, GET-хуки и бизнес-логику.
|
||||||
import type { SWRConfiguration } from 'swr'
|
- Методы лежат в `methods/` и возвращают DTO.
|
||||||
import { petProjectApi } from '..'
|
- GET-хуки добавляются отдельно в `hooks/`, если данные нужны в Client Components.
|
||||||
import type { Post } from '../types/post'
|
- Доменные типы и маппинг DTO живут не в REST-клиенте, а в `business/`.
|
||||||
|
|
||||||
/**
|
Следующий шаг: [GET-хуки REST-клиента](/docs/data/rest/clients/hooks) или [Стратегии получения данных](/docs/data/rest/strategies/).
|
||||||
* Получение поста по slug.
|
|
||||||
*/
|
|
||||||
export const usePostDetail = (
|
|
||||||
slug: string | null,
|
|
||||||
config?: SWRConfiguration,
|
|
||||||
) => {
|
|
||||||
const key = slug ? ['pet-project-api', 'post', 'detail', slug] : null
|
|
||||||
const fetcher = () => petProjectApi.posts.get(slug!)
|
|
||||||
|
|
||||||
return useSWR<Post>(key, fetcher, config)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/hooks/use-post-filter.hook.ts
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import type { SWRConfiguration } from 'swr'
|
|
||||||
import { petProjectApi } from '..'
|
|
||||||
import type { Post, PostFilter } from '../types/post'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получение списка постов по фильтру.
|
|
||||||
*/
|
|
||||||
export const usePostFilter = (
|
|
||||||
filter: PostFilter,
|
|
||||||
config?: SWRConfiguration,
|
|
||||||
) => {
|
|
||||||
return useSWR<Post[]>(
|
|
||||||
['pet-project-api', 'post', 'filter', filter],
|
|
||||||
() => petProjectApi.posts.filter(filter),
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/hooks/index.ts
|
|
||||||
export { usePostDetail } from './use-post-detail.hook'
|
|
||||||
export { usePostFilter } from './use-post-filter.hook'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Правила хуков
|
|
||||||
|
|
||||||
- **Один файл — один хук**, имя файла `use-{action}.hook.ts` ([Именование](/docs/basics/naming)).
|
|
||||||
- **Тонкая обёртка над SWR.** Внутри — построение ключа, fetcher через метод клиента, возврат `useSWR(...)`. Никакой бизнес-логики.
|
|
||||||
- **Ключ начинается с имени сервиса** (`['pet-project-api', ...]`) — изолирует кеш между разными API.
|
|
||||||
- **Условный ключ для опциональных параметров:** `id ? [...key, id] : null`. `null` приостанавливает запрос, пока параметры не готовы.
|
|
||||||
- **Параметр `config?: SWRConfiguration`** — даёт потребителю переопределить ревалидацию, `fallbackData`, `suspense` и т.п. без обёрток.
|
|
||||||
|
|
||||||
## Запрет прямого `fetch`
|
|
||||||
|
|
||||||
В коде приложения (слои выше `infrastructure`) прямые вызовы `fetch` к API запрещены. Все запросы идут через клиент.
|
|
||||||
|
|
||||||
Исключение допускается точечно — например, разовая отладочная проверка эндпоинта в скрипте — и требует обоснования в коде (комментарий с причиной).
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
const post = await petProjectApi.posts.get('my-post')
|
|
||||||
const list = await petProjectApi.posts.filter({ limit: 10, categories: [1, 2] })
|
|
||||||
const form = await petProjectApi.forms.get('contact')
|
|
||||||
```
|
|
||||||
|
|
||||||
Стиль вызовов совпадает с автогенерированным клиентом — потребитель не различает, ручной API или сгенерирован.
|
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
---
|
|
||||||
title: Клиентские компоненты
|
|
||||||
description: Получение REST-данных в клиентских компонентах.
|
|
||||||
keywords: [swr, клиентские компоненты, useSWR, хук, мутация, useSWRMutation, кеш, ревалидация]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Клиентские компоненты
|
|
||||||
|
|
||||||
Получение REST-данных в клиентских компонентах.
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
- **Только готовые хуки.** В компоненте — `usePostDetail(slug)`, не `useSWR(['post', slug], () => api.posts.get(slug))`.
|
|
||||||
- **`useSWR` пишется один раз — в `hooks/`** модуля API. В клиентских компонентах никогда напрямую.
|
|
||||||
- **Прямой вызов методов клиента в `useEffect` запрещён.** Это потеря кеша, повторные запросы и гонки.
|
|
||||||
- **Мутации — через `useSWRMutation`**, тоже инкапсулированный в хуке. В компоненте вызывается готовый `trigger`.
|
|
||||||
|
|
||||||
## Чтение
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { usePostDetail } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export function PostView({ slug }: { slug: string }) {
|
|
||||||
const { data: post, error, isLoading } = usePostDetail(slug)
|
|
||||||
|
|
||||||
if (isLoading) return <Spinner />
|
|
||||||
if (error) return <ErrorView error={error} />
|
|
||||||
|
|
||||||
return <article>{post?.title}</article>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
В компоненте нет `useSWR`, нет ключей, нет fetcher — только готовый хук.
|
|
||||||
|
|
||||||
## Параметризованный запрос
|
|
||||||
|
|
||||||
Хук сам обрабатывает «нет параметра — нет запроса». В компоненте можно безопасно передавать `null`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useUserDetail } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export function UserProfile({ userId }: { userId: string | null }) {
|
|
||||||
const { data: user } = useUserDetail(userId)
|
|
||||||
|
|
||||||
if (!userId) return <EmptyState />
|
|
||||||
return <UserCard user={user} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Внутри `useUserDetail` ключ становится `null`, когда `userId` не задан, и SWR не делает запрос — это поведение зашито в хук, потребитель об этом не думает.
|
|
||||||
|
|
||||||
## Мутации
|
|
||||||
|
|
||||||
Мутации тоже оборачиваются в хук модуля API:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/infrastructure/pet-project-api/hooks/use-create-user.hook.ts
|
|
||||||
import useSWRMutation from 'swr/mutation'
|
|
||||||
import { mutate } from 'swr'
|
|
||||||
import { petProjectApi } from '..'
|
|
||||||
import type { User, UserCreateInput } from '../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создание пользователя с инвалидацией списка.
|
|
||||||
*/
|
|
||||||
export const useCreateUser = () => {
|
|
||||||
return useSWRMutation<User, Error, [string, string, string], UserCreateInput>(
|
|
||||||
['pet-project-api', 'user', 'create'],
|
|
||||||
(_key, { arg }) => petProjectApi.user.create(arg),
|
|
||||||
{
|
|
||||||
onSuccess: () => mutate(['pet-project-api', 'user', 'list']),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useCreateUser } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export function CreateUserForm() {
|
|
||||||
const { trigger, isMutating } = useCreateUser()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
onSubmit={(input) => trigger(input)}
|
|
||||||
disabled={isMutating}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
В компоненте — снова только хук. Логика инвалидации кеша зашита внутрь, потребитель её не дублирует.
|
|
||||||
|
|
||||||
## Передача config из компонента
|
|
||||||
|
|
||||||
Каждый хук принимает второй (или третий) параметр `config?: SWRConfiguration` — он пробрасывается в `useSWR`. Это даёт потребителю точечно настроить ревалидацию, `fallbackData`, `suspense` и т.п.:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { usePostDetail } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export function PostView({ slug, initialPost }: Props) {
|
|
||||||
const { data: post } = usePostDetail(slug, { fallbackData: initialPost })
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Начальное состояние с сервера
|
|
||||||
|
|
||||||
Если данные пришли из серверного компонента (см. [Серверные компоненты](/docs/data/rest/fetching/server)) — передаются в `fallbackData` через `config` хука:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// page.tsx (server)
|
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { slug: string } }) {
|
|
||||||
const initialPost = await petProjectApi.posts.get(params.slug)
|
|
||||||
return <PostView slug={params.slug} initialPost={initialPost} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// post-view.tsx ('use client')
|
|
||||||
import { usePostDetail } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export function PostView({ slug, initialPost }: Props) {
|
|
||||||
const { data: post } = usePostDetail(slug, { fallbackData: initialPost })
|
|
||||||
return <article>{post?.title}</article>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Для массового заполнения кеша на странице с несколькими хуками — используется `<SWRConfig fallback>` обёртка. Серверный компонент собирает данные и передаёт сериализованную карту ключей в провайдер; все вложенные хуки сразу видят кеш.
|
|
||||||
|
|
||||||
## Запрет прямых вызовов
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Плохо — прямой fetch в обход клиента
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/users').then(...)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Плохо — клиент без SWR: нет кеша, нет дедупликации
|
|
||||||
useEffect(() => {
|
|
||||||
petProjectApi.user.list().then(setUsers)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Плохо — useSWR в компоненте: SWR должен быть в хуке модуля
|
|
||||||
const { data } = useSWR(
|
|
||||||
['pet-project-api', 'user', 'list'],
|
|
||||||
() => petProjectApi.user.list(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Хорошо — готовый хук модуля
|
|
||||||
const { data } = useUserList()
|
|
||||||
```
|
|
||||||
|
|
||||||
Если для нужной операции хука ещё нет — он добавляется в `hooks/` модуля API, не в компонент.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
title: Серверные компоненты
|
|
||||||
description: Получение REST-данных в серверных компонентах.
|
|
||||||
keywords: [server components, rsc, серверные компоненты, fetch, api, app router, прямой вызов]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Серверные компоненты
|
|
||||||
|
|
||||||
Получение REST-данных в серверных компонентах.
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
- **Прямой `await` метода клиента.** Никаких хуков, обёрток состояний, `useEffect` — серверный компонент не имеет жизненного цикла React-клиента.
|
|
||||||
- **Ошибки бросаются.** Не оборачивать `try/catch` без необходимости — Next.js поднимет ближайший `error.tsx`.
|
|
||||||
- **Параллельные запросы — через `Promise.all`.** Последовательный `await` за `await` блокирует рендер.
|
|
||||||
|
|
||||||
## Шаблон
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/app/(routes)/users/page.tsx
|
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
|
||||||
const users = await petProjectApi.user.list()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
{users.map((user) => (
|
|
||||||
<li key={user.id}>{user.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Параллельные запросы
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export default async function DashboardPage() {
|
|
||||||
const [users, orders] = await Promise.all([
|
|
||||||
petProjectApi.user.list(),
|
|
||||||
petProjectApi.order.list(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return <Dashboard users={users} orders={orders} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Передача данных в клиентский компонент
|
|
||||||
|
|
||||||
Серверный компонент получает данные и передаёт их пропсами в клиентский. На клиенте данные становятся начальным состоянием — при необходимости перезапрашиваются через SWR (см. [Клиентские компоненты](/docs/data/rest/fetching/client)).
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// page.tsx (server)
|
|
||||||
import { petProjectApi } from 'infrastructure/pet-project-api'
|
|
||||||
import { UsersList } from 'widgets/users-list'
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
|
||||||
const initialUsers = await petProjectApi.user.list()
|
|
||||||
return <UsersList initialUsers={initialUsers} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Запрет прямого `fetch`
|
|
||||||
|
|
||||||
Серверный компонент тоже использует только клиент из `infrastructure/`. Прямой `fetch` в `page.tsx` или в server-action запрещён теми же правилами, что и на клиенте.
|
|
||||||
74
docs/docs/data/rest/index.md
Normal file
74
docs/docs/data/rest/index.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: REST
|
||||||
|
description: Как правильно работать с REST API в проекте.
|
||||||
|
keywords: [rest, api, данные, infrastructure, клиент, swr, стратегии]
|
||||||
|
---
|
||||||
|
|
||||||
|
# REST
|
||||||
|
|
||||||
|
Раздел описывает, как правильно работать с REST API в проекте: создать клиент сервиса и выбрать способ получения данных в приложении.
|
||||||
|
|
||||||
|
REST в проекте проходит через два главных этапа:
|
||||||
|
|
||||||
|
1. Создание клиента.
|
||||||
|
2. Использование.
|
||||||
|
|
||||||
|
## 1. Создание клиента
|
||||||
|
|
||||||
|
На этом этапе внешний API оформляется как модуль слоя `infrastructure/`.
|
||||||
|
|
||||||
|
Клиент отвечает за:
|
||||||
|
|
||||||
|
- генерацию или ручное описание методов API;
|
||||||
|
- настройку `baseUrl`;
|
||||||
|
- заголовки и авторизацию;
|
||||||
|
- обработку ошибок;
|
||||||
|
- кастомизацию и расширение типов;
|
||||||
|
- GET-хуки для клиентских компонентов;
|
||||||
|
- публичный API модуля.
|
||||||
|
|
||||||
|
Если у API есть OpenAPI-спецификация — клиент генерируется автоматически. Если OpenAPI нет или он неполный — клиент создаётся вручную.
|
||||||
|
|
||||||
|
GET-хуки относятся к клиенту, потому что это прозрачные SWR-обёртки над GET-методами этого клиента.
|
||||||
|
|
||||||
|
Подробнее:
|
||||||
|
|
||||||
|
- [Создание клиента](/docs/data/rest/clients/)
|
||||||
|
- [Автогенерация из OpenAPI](/docs/data/rest/clients/auto)
|
||||||
|
- [Ручное создание](/docs/data/rest/clients/manual)
|
||||||
|
- [GET-хуки REST-клиента](/docs/data/rest/clients/hooks)
|
||||||
|
|
||||||
|
## 2. Использование
|
||||||
|
|
||||||
|
После создания клиента нужно определить рендер страницы и выбрать, как получать данные в конкретном месте приложения.
|
||||||
|
|
||||||
|
Раздел использования отвечает на вопросы:
|
||||||
|
|
||||||
|
- как понять, можно ли сохранить static/ISR;
|
||||||
|
- когда страница становится dynamic/SSR;
|
||||||
|
- когда получать данные через серверный `await`;
|
||||||
|
- когда запускать несколько серверных запросов параллельно;
|
||||||
|
- когда передавать промис ниже по дереву;
|
||||||
|
- когда передавать начальные данные клиентским GET-хукам;
|
||||||
|
- когда использовать GET-хук в клиентском компоненте;
|
||||||
|
- когда выносить композицию и бизнес-смысл в `business/`.
|
||||||
|
|
||||||
|
Подробнее:
|
||||||
|
|
||||||
|
- [Стратегии получения данных](/docs/data/rest/strategies/)
|
||||||
|
- [Серверный await](/docs/data/rest/strategies/server-await)
|
||||||
|
- [Параллельные серверные запросы](/docs/data/rest/strategies/parallel-server-requests)
|
||||||
|
- [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down)
|
||||||
|
- [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data)
|
||||||
|
- [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook)
|
||||||
|
- [Business-композиция](/docs/data/rest/strategies/business-composition)
|
||||||
|
|
||||||
|
## Как читать раздел
|
||||||
|
|
||||||
|
Если API ещё не подключён — начните с [Создания клиента](/docs/data/rest/clients/).
|
||||||
|
|
||||||
|
Если клиент уже есть, но непонятно как получить данные — начните со [Стратегий получения данных](/docs/data/rest/strategies/).
|
||||||
|
|
||||||
|
Если данные нужны в Client Component — сначала проверьте, есть ли [GET-хук REST-клиента](/docs/data/rest/clients/hooks).
|
||||||
|
|
||||||
|
Если в коде появляется бизнес-смысл вроде `isAuth`, `canEdit`, `hasAccess` — это уже не REST-клиент, а `business/`.
|
||||||
121
docs/docs/data/rest/strategies/business-composition.md
Normal file
121
docs/docs/data/rest/strategies/business-composition.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
title: Business-композиция
|
||||||
|
description: Когда REST-данные нужно объединить или интерпретировать в бизнес-модуле.
|
||||||
|
keywords: [rest, business, композиция, hooks, domain, isAuth]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Business-композиция
|
||||||
|
|
||||||
|
Business-композиция используется, когда простого GET-метода или прозрачного GET-хука недостаточно: нужно объединить несколько источников, преобразовать DTO или вычислить доменное состояние.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Нужно объединить несколько GET-запросов.
|
||||||
|
- Нужно вычислить `isAuth`, `canEdit`, `hasAccess`, `hasPets`.
|
||||||
|
- Нужно преобразовать DTO в доменную модель.
|
||||||
|
- Нужно спрятать бизнес-сценарий за доменным API.
|
||||||
|
|
||||||
|
Такая логика не пишется в `infrastructure/`. REST-клиент остаётся прозрачным адаптером к API.
|
||||||
|
|
||||||
|
## Пример поверх одного GET-хука
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/business/pets/hooks/use-available-pets.hook.ts
|
||||||
|
import { useGetPetList } from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменный список доступных питомцев.
|
||||||
|
*/
|
||||||
|
export const useAvailablePets = () => {
|
||||||
|
const query = useGetPetList('available')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
hasPets: Boolean(query.data?.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useGetPetList` — infrastructure-хук. `hasPets` — бизнес-интерпретация, поэтому она появляется в `business/pets`.
|
||||||
|
|
||||||
|
## Пример композиции нескольких GET-хуков
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/business/pets/hooks/use-pets-dashboard.hook.ts
|
||||||
|
import { useGetPetList } from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные dashboard по питомцам.
|
||||||
|
*/
|
||||||
|
export const usePetsDashboard = () => {
|
||||||
|
const availablePets = useGetPetList('available')
|
||||||
|
const pendingPets = useGetPetList('pending')
|
||||||
|
const soldPets = useGetPetList('sold')
|
||||||
|
|
||||||
|
return {
|
||||||
|
availablePets,
|
||||||
|
pendingPets,
|
||||||
|
soldPets,
|
||||||
|
total:
|
||||||
|
(availablePets.data?.length ?? 0) +
|
||||||
|
(pendingPets.data?.length ?? 0) +
|
||||||
|
(soldPets.data?.length ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Композиция нескольких запросов не добавляется в `infrastructure/pet-store-api/hooks/`, потому что это уже сценарий потребления данных.
|
||||||
|
|
||||||
|
## Пример auth-состояния
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/business/auth/hooks/use-auth-state.hook.ts
|
||||||
|
import { useGetCurrentUser } from 'infrastructure/backend-api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние авторизации текущего пользователя.
|
||||||
|
*/
|
||||||
|
export const useAuthState = () => {
|
||||||
|
const currentUser = useGetCurrentUser()
|
||||||
|
const user = currentUser.data
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentUser,
|
||||||
|
user,
|
||||||
|
isAuth: Boolean(user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`isAuth` не является частью REST-клиента. Это доменный смысл результата запроса.
|
||||||
|
|
||||||
|
## Где размещать
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/business/
|
||||||
|
└── pets/
|
||||||
|
├── hooks/
|
||||||
|
│ └── use-available-pets.hook.ts
|
||||||
|
├── mappers/
|
||||||
|
│ └── map-pet-dto-to-pet.ts
|
||||||
|
├── types/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Модуль `business/` экспортирует наружу готовый доменный API через `index.ts`.
|
||||||
|
|
||||||
|
## Что запрещено
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Плохо — business-смысл внутри infrastructure-хука
|
||||||
|
export const useGetPetList = (status: PetStatus) => {
|
||||||
|
const query = useSWR(...)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
hasPets: Boolean(query.data?.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
REST-модуль отвечает за доступ к API. Business-модуль отвечает за смысл этих данных в продукте.
|
||||||
89
docs/docs/data/rest/strategies/client-get-hook.md
Normal file
89
docs/docs/data/rest/strategies/client-get-hook.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
title: Клиентский GET-хук
|
||||||
|
description: Получение REST-данных в Client Components через готовые GET-хуки REST-клиента.
|
||||||
|
keywords: [rest, client components, swr, get-хук, client state]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Клиентский GET-хук
|
||||||
|
|
||||||
|
Клиентский GET-хук используется, когда данные зависят от состояния браузера: вкладки, фильтра, поиска, пагинации, модалки или действия пользователя.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Запрос зависит от client state.
|
||||||
|
- Данные не обязательны для первого HTML.
|
||||||
|
- Пользователь меняет параметры запроса на клиенте.
|
||||||
|
- Нужны SWR-кеширование, дедупликация и ревалидация.
|
||||||
|
|
||||||
|
## Пример с вкладками
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useGetPetList } from 'infrastructure/pet-store-api'
|
||||||
|
import type { PetStatus } from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
const statuses: PetStatus[] = ['available', 'pending', 'sold']
|
||||||
|
|
||||||
|
export function PetTabs() {
|
||||||
|
const [status, setStatus] = useState<PetStatus>('available')
|
||||||
|
const { data: pets, isLoading, error } = useGetPetList(status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
{statuses.map((item) => (
|
||||||
|
<button key={item} type="button" onClick={() => setStatus(item)}>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <div>Загрузка...</div>}
|
||||||
|
{error && <div>Ошибка загрузки</div>}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{pets?.map((pet) => (
|
||||||
|
<li key={pet.id}>{pet.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Компонент выбирает параметр `status`, но не знает про SWR-ключ и fetcher. Запрос выполняет готовый GET-хук REST-клиента.
|
||||||
|
|
||||||
|
## Если хука нет
|
||||||
|
|
||||||
|
Хук добавляется в REST-модуль сервиса:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/infrastructure/pet-store-api/hooks/use-get-pet-list.hook.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Не создавайте локальный `useSWR` в компоненте.
|
||||||
|
|
||||||
|
## Плохо
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Плохо — прямой вызов клиента в useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
petStoreApi.pet.findPetsByStatus({ status }).then(setPets)
|
||||||
|
}, [status])
|
||||||
|
|
||||||
|
// Плохо — useSWR в компоненте
|
||||||
|
const { data } = useSWR(
|
||||||
|
['pet-store-api', 'pet', 'list', status],
|
||||||
|
() => petStoreApi.pet.findPetsByStatus({ status }),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Такой код теряет единое место для ключей, дублирует fetcher и разносит инфраструктурные детали по UI.
|
||||||
|
|
||||||
|
## Когда выбрать другую стратегию
|
||||||
|
|
||||||
|
- Данные нужны до первого HTML — [Серверный await](/docs/data/rest/strategies/server-await).
|
||||||
|
- Клиентский хук должен получить начальные данные сразу — [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data).
|
||||||
|
- Нужно вычислить бизнес-состояние — [Business-композиция](/docs/data/rest/strategies/business-composition).
|
||||||
109
docs/docs/data/rest/strategies/client-hooks-initial-data.md
Normal file
109
docs/docs/data/rest/strategies/client-hooks-initial-data.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
title: Начальные данные для клиентских хуков
|
||||||
|
description: Как дать клиентским GET-хукам начальные REST-данные.
|
||||||
|
keywords: [rest, swr, fallback, initial data, client hooks, unstable_serialize, isr, ssr]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Начальные данные для клиентских хуков
|
||||||
|
|
||||||
|
Как дать клиентским GET-хукам начальные REST-данные.
|
||||||
|
|
||||||
|
Эта стратегия используется, когда данные должны быть запущены на сервере, но потребляться на клиенте через GET-хуки REST-клиента.
|
||||||
|
|
||||||
|
Технически это делается через `SWRConfig fallback`: сервер передаёт промис в fallback, а клиентский хук использует тот же SWR-ключ.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Внутри страницы есть Client Components с GET-хуками.
|
||||||
|
- Нужно начать загрузку данных на сервере раньше.
|
||||||
|
- Клиентский компонент должен остаться обычным потребителем `useGetPetList(...)`.
|
||||||
|
- Не нужно писать отдельный prop-drilling для начальных данных.
|
||||||
|
|
||||||
|
## Рендер страницы
|
||||||
|
|
||||||
|
Перед этой стратегией сначала определите рендер маршрута. Серверный preload для `fallback` подчиняется тем же правилам, что и любой серверный запрос в `page.tsx` или `layout.tsx`.
|
||||||
|
|
||||||
|
Если данные общие и могут обновляться по интервалу, сохраняйте static/ISR. Если preload зависит от cookie, headers, `searchParams`, `no-store` или персональных данных пользователя, маршрут становится dynamic/SSR.
|
||||||
|
|
||||||
|
`SWRConfig fallback` не должен быть причиной отключать ISR на всякий случай. Он только передаёт клиентскому GET-хуку данные, которые уже были запущены на сервере.
|
||||||
|
|
||||||
|
## Ключ хука
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/pet-store-api/hooks/use-get-pet-list.hook.ts
|
||||||
|
export const getPetListKey = (status: PetStatus) =>
|
||||||
|
['pet-store-api', 'pet', 'list', status] as const
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключ экспортируется из REST-модуля, потому что он нужен и GET-хуку, и серверному `SWRConfig fallback`.
|
||||||
|
|
||||||
|
## Пример layout
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/(routes)/pets/layout.tsx
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { SWRConfig, unstable_serialize } from 'swr'
|
||||||
|
import {
|
||||||
|
getPetListKey,
|
||||||
|
petStoreApi,
|
||||||
|
} from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
type PetsLayoutProps = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PetsLayout({ children }: PetsLayoutProps) {
|
||||||
|
const availablePetsPromise = petStoreApi.pet.findPetsByStatus({
|
||||||
|
status: 'available',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fallback: {
|
||||||
|
[unstable_serialize(getPetListKey('available'))]: availablePetsPromise,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SWRConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если GET-хук использует array-key, ключ для `fallback` сериализуется через `unstable_serialize`.
|
||||||
|
|
||||||
|
## Клиентский компонент
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useGetPetList } from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
export function PetList() {
|
||||||
|
const { data: pets, isLoading } = useGetPetList('available')
|
||||||
|
|
||||||
|
if (isLoading) return <div>Загрузка...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{pets?.map((pet) => (
|
||||||
|
<li key={pet.id}>{pet.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Компонент не знает, что данные были запущены на сервере. Он использует обычный GET-хук REST-клиента.
|
||||||
|
|
||||||
|
## Что важно
|
||||||
|
|
||||||
|
- Ключ `fallback` должен совпадать с ключом GET-хука.
|
||||||
|
- Серверный код вызывает метод клиента, а не GET-хук.
|
||||||
|
- Клиентский компонент вызывает GET-хук, а не `useSWR` напрямую.
|
||||||
|
- Эта стратегия не означает ручную работу с кешем в компонентах.
|
||||||
|
|
||||||
|
## Когда не использовать
|
||||||
|
|
||||||
|
Если данные нужны только серверному компоненту, используйте [Серверный await](/docs/data/rest/strategies/server-await). Если данные зависят от состояния браузера, используйте [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook).
|
||||||
100
docs/docs/data/rest/strategies/index.md
Normal file
100
docs/docs/data/rest/strategies/index.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: Стратегии получения данных
|
||||||
|
description: Как выбрать получение REST-данных с учётом рендера страницы.
|
||||||
|
keywords: [rest, стратегии, render, isr, ssr, server components, swr, promise, business]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Стратегии получения данных
|
||||||
|
|
||||||
|
Как выбрать получение REST-данных с учётом рендера страницы.
|
||||||
|
|
||||||
|
Перед выбором стратегии должен быть создан REST-клиент сервиса. Если клиента ещё нет, начните с раздела [Создание клиента](/docs/data/rest/clients/).
|
||||||
|
|
||||||
|
## Сначала определите рендер страницы
|
||||||
|
|
||||||
|
В Next.js выбор начинается не с `await`, `Suspense` или SWR. Сначала нужно понять, какой рендер получится у маршрута: static/ISR или dynamic/SSR.
|
||||||
|
|
||||||
|
Next.js может перевести страницу в dynamic rendering автоматически, если в маршруте используются API текущего запроса. Поэтому первый вопрос такой:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Можно ли сохранить ISR, или странице нужны данные на каждый request?
|
||||||
|
```
|
||||||
|
|
||||||
|
ISR — приоритет. Если данные общие для пользователей и их можно обновлять с интервалом, не переводите страницу в SSR без необходимости.
|
||||||
|
|
||||||
|
SSR/dynamic rendering выбирается только когда данные действительно зависят от текущего request или должны пересчитываться на каждый запрос.
|
||||||
|
|
||||||
|
## Что переводит страницу в dynamic rendering
|
||||||
|
|
||||||
|
Проверьте, нужны ли странице API и настройки, которые делают маршрут динамическим:
|
||||||
|
|
||||||
|
- `cookies()` — данные зависят от cookie текущего пользователя.
|
||||||
|
- `headers()` — данные зависят от request headers.
|
||||||
|
- `draftMode()` — нужен preview/draft-режим.
|
||||||
|
- `searchParams` в `page.tsx` — данные зависят от query string.
|
||||||
|
- `cache: 'no-store'` или `revalidate: 0` в методе клиента — запрос нельзя кешировать.
|
||||||
|
- `connection()` — рендер явно ждёт request.
|
||||||
|
- `export const dynamic = 'force-dynamic'` — SSR включён вручную.
|
||||||
|
|
||||||
|
Если ничего из этого не нужно, сначала проектируйте страницу как static/ISR. Серверный `await` сам по себе не означает SSR: режим зависит от кеширования запроса и dynamic API маршрута.
|
||||||
|
|
||||||
|
## Рендер перед стратегией
|
||||||
|
|
||||||
|
| Рендер | Когда подходит | Что выбирать дальше |
|
||||||
|
|--------|----------------|---------------------|
|
||||||
|
| Static/ISR | Данные общие и могут обновляться по интервалу | Серверные стратегии: `await`, `Promise.all`, передача промиса ниже, SWR `fallback` |
|
||||||
|
| SSR/dynamic | Данные зависят от request, пользователя или должны быть свежими на каждый запрос | Серверные стратегии с учётом блокировки первого HTML |
|
||||||
|
| После гидрации | Данные зависят от вкладки, фильтра, поиска, пагинации или действия пользователя | Клиентский GET-хук |
|
||||||
|
|
||||||
|
## Как выбрать стратегию
|
||||||
|
|
||||||
|
Когда режим рендера понятен, выбирайте конкретный способ получения данных:
|
||||||
|
|
||||||
|
| Ситуация после выбора рендера | Стратегия | Где читать |
|
||||||
|
|-------------------------------|-----------|------------|
|
||||||
|
| Данные обязательны для первого HTML, SEO, `notFound()` или `redirect()` | Серверный `await` | [Серверный await](/docs/data/rest/strategies/server-await) |
|
||||||
|
| Несколько независимых данных нужны до рендера | Запуск промисов + `Promise.all` | [Параллельные серверные запросы](/docs/data/rest/strategies/parallel-server-requests) |
|
||||||
|
| Часть UI можно загрузить отдельно | Передача промиса ниже + `Suspense` | [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down) |
|
||||||
|
| Client Component должен получить данные сразу из SWR | Начальные данные для клиентских хуков | [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data) |
|
||||||
|
| Данные зависят от client state | Клиентский GET-хук | [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook) |
|
||||||
|
| Нужно объединить несколько запросов или вычислить `isAuth`, `canEdit`, `hasPets` | Business-композиция | [Business-композиция](/docs/data/rest/strategies/business-composition) |
|
||||||
|
|
||||||
|
## Правило выбора
|
||||||
|
|
||||||
|
Не выбирайте стратегию по любимому инструменту. Выбирайте её по двум вопросам:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Можно ли сохранить ISR?
|
||||||
|
Где нужны данные и что должно произойти до первого HTML?
|
||||||
|
```
|
||||||
|
|
||||||
|
Если данные можно кешировать между пользователями — сохраняйте static/ISR. Если данные request-specific — используйте SSR/dynamic rendering. Если данные зависят от состояния браузера — используйте GET-хук REST-клиента. Если простой GET превращается в доменный сценарий — переходите в `business/`.
|
||||||
|
|
||||||
|
## Общие запреты
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Плохо — SSR включён на всякий случай
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// Плохо — ISR отключён без требования к свежести на каждый request
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
|
// Плохо — прямой fetch в компоненте
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/pets').then(...)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Плохо — useSWR в компоненте
|
||||||
|
const { data } = useSWR(
|
||||||
|
['pet-store-api', 'pet', 'list', status],
|
||||||
|
() => petStoreApi.pet.findPetsByStatus({ status }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Плохо — бизнес-флаг внутри GET-хука REST-клиента
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
hasPets: Boolean(query.data?.length),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не отключайте ISR без причины. В компонентах используются готовые методы клиента или готовые хуки. SWR-ключи, fetcher и транспорт остаются внутри REST-модуля.
|
||||||
82
docs/docs/data/rest/strategies/parallel-server-requests.md
Normal file
82
docs/docs/data/rest/strategies/parallel-server-requests.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Параллельные серверные запросы
|
||||||
|
description: Как запускать независимые REST-запросы на сервере без waterfall.
|
||||||
|
keywords: [rest, promise.all, параллельные запросы, server components]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Параллельные серверные запросы
|
||||||
|
|
||||||
|
Если серверному компоненту нужно несколько независимых данных, запускайте запросы до ожидания результата. Последовательный `await` создаёт waterfall и замедляет рендер.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Запросы независимы друг от друга.
|
||||||
|
- Все данные нужны текущему серверному компоненту перед возвратом UI.
|
||||||
|
- Нельзя или не нужно стримить часть UI отдельно.
|
||||||
|
|
||||||
|
## Хорошо
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { petStoreApi } from 'infrastructure/pet-store-api'
|
||||||
|
import { PetsDashboardScreen } from 'screens/pets-dashboard'
|
||||||
|
|
||||||
|
export default async function PetsDashboardPage() {
|
||||||
|
const availablePetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
|
||||||
|
const pendingPetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'pending' })
|
||||||
|
const soldPetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'sold' })
|
||||||
|
|
||||||
|
const [availablePets, pendingPets, soldPets] = await Promise.all([
|
||||||
|
availablePetsPromise,
|
||||||
|
pendingPetsPromise,
|
||||||
|
soldPetsPromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PetsDashboardScreen
|
||||||
|
availablePets={availablePets}
|
||||||
|
pendingPets={pendingPets}
|
||||||
|
soldPets={soldPets}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Плохо
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default async function PetsDashboardPage() {
|
||||||
|
const availablePets = await petStoreApi.pet.findPetsByStatus({ status: 'available' })
|
||||||
|
const pendingPets = await petStoreApi.pet.findPetsByStatus({ status: 'pending' })
|
||||||
|
const soldPets = await petStoreApi.pet.findPetsByStatus({ status: 'sold' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PetsDashboardScreen
|
||||||
|
availablePets={availablePets}
|
||||||
|
pendingPets={pendingPets}
|
||||||
|
soldPets={soldPets}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Во втором примере каждый следующий запрос ждёт предыдущий, хотя они независимы.
|
||||||
|
|
||||||
|
## Зависимые запросы
|
||||||
|
|
||||||
|
Если второй запрос зависит от результата первого, последовательный `await` допустим:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default async function OrderPage({ params }: OrderPageProps) {
|
||||||
|
const { id } = await params
|
||||||
|
const order = await petStoreApi.store.getOrderById(Number(id))
|
||||||
|
const pet = await petStoreApi.pet.getPetById(order.petId)
|
||||||
|
|
||||||
|
return <OrderScreen order={order} pet={pet} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не превращайте зависимый сценарий в `Promise.all` искусственно.
|
||||||
|
|
||||||
|
## Когда выбрать другую стратегию
|
||||||
|
|
||||||
|
Если часть данных не обязательна для первого блока UI, можно запустить промис выше и передать его ниже: [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down).
|
||||||
62
docs/docs/data/rest/strategies/pass-promise-down.md
Normal file
62
docs/docs/data/rest/strategies/pass-promise-down.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Передача промиса ниже
|
||||||
|
description: Как запускать серверный REST-запрос выше и ожидать его во вложенном server-компоненте.
|
||||||
|
keywords: [rest, promise, suspense, streaming, server components]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Передача промиса ниже
|
||||||
|
|
||||||
|
Серверный компонент может запустить запрос и передать промис вложенному server-компоненту. Это полезно, когда часть UI можно загрузить отдельно через `Suspense`.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Верхняя часть страницы может отрендериться без этих данных.
|
||||||
|
- Данные нужны только вложенному server-компоненту.
|
||||||
|
- Нужна `Suspense`-граница и серверный стриминг.
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/(routes)/pets/page.tsx
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import { petStoreApi } from 'infrastructure/pet-store-api'
|
||||||
|
import { PetListSection } from 'widgets/pet-list-section'
|
||||||
|
import { PetListSkeleton } from 'widgets/pet-list-section'
|
||||||
|
import type { Pet } from 'infrastructure/pet-store-api'
|
||||||
|
|
||||||
|
export default function PetsPage() {
|
||||||
|
const petsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Питомцы</h1>
|
||||||
|
<Suspense fallback={<PetListSkeleton />}>
|
||||||
|
<AvailablePets petsPromise={petsPromise} />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function AvailablePets({ petsPromise }: { petsPromise: Promise<Pet[]> }) {
|
||||||
|
const pets = await petsPromise
|
||||||
|
|
||||||
|
return <PetListSection pets={pets} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос стартует в `PetsPage`, но ожидание происходит внутри `AvailablePets`. `Suspense` управляет fallback для этой части UI.
|
||||||
|
|
||||||
|
## Граница стратегии
|
||||||
|
|
||||||
|
Эта стратегия остаётся серверной. Не используйте её как замену GET-хукам в Client Components.
|
||||||
|
|
||||||
|
Если данные должны попасть в клиентский SWR-хук, используйте [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data).
|
||||||
|
|
||||||
|
## Что не делать
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Плохо — передавать промис в произвольный клиентский компонент без ясной стратегии
|
||||||
|
return <PetListClient petsPromise={petsPromise} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Для клиентского потребления есть отдельная стратегия через `SWRConfig fallback` и готовые GET-хуки REST-клиента.
|
||||||
88
docs/docs/data/rest/strategies/server-await.md
Normal file
88
docs/docs/data/rest/strategies/server-await.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
title: Серверный await
|
||||||
|
description: Получение REST-данных на сервере до первого HTML.
|
||||||
|
keywords: [rest, server components, await, nextjs, isr, ssr, notFound, redirect]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Серверный await
|
||||||
|
|
||||||
|
Получение REST-данных на сервере до первого HTML.
|
||||||
|
|
||||||
|
Серверный `await` — базовая стратегия для данных, которые нужны до рендера страницы или серверного блока.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- Данные нужны для первого HTML.
|
||||||
|
- Данные влияют на `metadata`.
|
||||||
|
- По результату запроса нужно вызвать `notFound()` или `redirect()`.
|
||||||
|
- Компонент серверный и данные не зависят от состояния браузера.
|
||||||
|
|
||||||
|
## Влияние на рендер
|
||||||
|
|
||||||
|
Серверный `await` сам по себе не означает SSR. В App Router страница может остаться static/ISR, если маршрут не использует dynamic API и запросы можно кешировать.
|
||||||
|
|
||||||
|
ISR — приоритет для общих данных. Если список или детальная страница могут обновляться по интервалу, сохраняйте кеширование и не добавляйте `no-store`, `revalidate: 0` или `force-dynamic` без требования.
|
||||||
|
|
||||||
|
SSR/dynamic rendering нужен, когда данные зависят от текущего request: cookie, headers, `searchParams`, preview-режим или персональные данные пользователя.
|
||||||
|
|
||||||
|
## Пример страницы списка
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/(routes)/pets/page.tsx
|
||||||
|
import { petStoreApi } from 'infrastructure/pet-store-api'
|
||||||
|
import { PetsScreen } from 'screens/pets'
|
||||||
|
|
||||||
|
export default async function PetsPage() {
|
||||||
|
const pets = await petStoreApi.pet.findPetsByStatus({
|
||||||
|
status: 'available',
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PetsScreen pets={pets} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`page.tsx` получает данные первого рендера и передаёт их ниже. UI страницы остаётся в `screens/`, а не пишется прямо в `app/`.
|
||||||
|
|
||||||
|
## Пример детальной страницы
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/(routes)/pets/[id]/page.tsx
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { petStoreApi } from 'infrastructure/pet-store-api'
|
||||||
|
import { PetDetailScreen } from 'screens/pet-detail'
|
||||||
|
|
||||||
|
type PetPageProps = {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PetPage({ params }: PetPageProps) {
|
||||||
|
const { id } = await params
|
||||||
|
const pet = await petStoreApi.pet.getPetById(Number(id)).catch(() => null)
|
||||||
|
|
||||||
|
if (!pet) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PetDetailScreen pet={pet} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Обработка 404 зависит от API-клиента и класса ошибок. В примере показана идея: решение о `notFound()` принимается на уровне маршрута, а не внутри REST-клиента.
|
||||||
|
|
||||||
|
## Что не делать
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Плохо — хуки нельзя вызывать в Server Component
|
||||||
|
const { data } = useGetPetList('available')
|
||||||
|
|
||||||
|
// Плохо — прямой fetch в обход клиента
|
||||||
|
const response = await fetch('https://petstore3.swagger.io/api/v3/pet/findByStatus')
|
||||||
|
```
|
||||||
|
|
||||||
|
Если данные нужны на сервере, вызывайте метод REST-клиента напрямую.
|
||||||
|
|
||||||
|
## Когда выбрать другую стратегию
|
||||||
|
|
||||||
|
- Несколько независимых запросов — [Параллельные серверные запросы](/docs/data/rest/strategies/parallel-server-requests).
|
||||||
|
- Часть UI можно грузить отдельно — [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down).
|
||||||
|
- Данные нужны клиентскому хуку сразу после гидрации — [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data).
|
||||||
@@ -227,7 +227,6 @@ const copyDirSync = (
|
|||||||
const srcPath = path.join(src, entry.name);
|
const srcPath = path.join(src, entry.name);
|
||||||
const destPath = path.join(dest, entry.name);
|
const destPath = path.join(dest, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
fs.mkdirSync(destPath, { recursive: true });
|
|
||||||
count += copyDirSync(srcPath, destPath, filter);
|
count += copyDirSync(srcPath, destPath, filter);
|
||||||
} else if (entry.isFile() && filter(entry.name)) {
|
} else if (entry.isFile() && filter(entry.name)) {
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
@@ -250,6 +249,8 @@ const copyMdFiles = (): void => {
|
|||||||
const destDir = path.join(PUBLIC_DIR, 'docs');
|
const destDir = path.join(PUBLIC_DIR, 'docs');
|
||||||
if (!fs.existsSync(srcDir)) return;
|
if (!fs.existsSync(srcDir)) return;
|
||||||
|
|
||||||
|
fs.rmSync(destDir, { recursive: true, force: true });
|
||||||
|
|
||||||
const copied = copyDirSync(
|
const copied = copyDirSync(
|
||||||
srcDir,
|
srcDir,
|
||||||
destDir,
|
destDir,
|
||||||
@@ -321,6 +322,8 @@ const transformLinksInDir = (rootDir: string): void => {
|
|||||||
* в архив как есть.
|
* в архив как есть.
|
||||||
*/
|
*/
|
||||||
const buildZip = (): void => {
|
const buildZip = (): void => {
|
||||||
|
fs.rmSync(path.resolve(PUBLIC_DIR, 'nextjs-style-guide'), { recursive: true, force: true });
|
||||||
|
|
||||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nsg-'));
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nsg-'));
|
||||||
const stage = path.join(tmpRoot, 'nextjs-style-guide');
|
const stage = path.join(tmpRoot, 'nextjs-style-guide');
|
||||||
fs.mkdirSync(stage, { recursive: true });
|
fs.mkdirSync(stage, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user