diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 788bd51..8b826d3 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -46,20 +46,28 @@ const sidebar = [ text: 'REST', collapsed: true, items: [ + { text: 'Обзор', link: '/docs/data/rest/' }, { - text: 'Настройка', + text: 'Создание клиента', collapsed: true, 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: 'GET-хуки REST-клиента', link: '/docs/data/rest/clients/hooks' }, ], }, { text: 'Использование', collapsed: true, items: [ - { text: 'Серверные компоненты', link: '/docs/data/rest/fetching/server' }, - { text: 'Клиентские компоненты', link: '/docs/data/rest/fetching/client' }, + { text: 'Стратегии получения данных', link: '/docs/data/rest/strategies/' }, + { 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' }, ], }, ], diff --git a/docs/docs/MAP.md b/docs/docs/MAP.md index 1a6078e..2f022c8 100644 --- a/docs/docs/MAP.md +++ b/docs/docs/MAP.md @@ -28,10 +28,18 @@ ## Работа с данными - [Введение](./data/index.md) — Какие источники данных используются в проекте и как с ними работать. -- [REST: Настройка: Автоматическая генерация](./data/rest/clients/auto.md) — Генерация REST-клиента из OpenAPI-спецификации. -- [REST: Настройка: Ручное создание](./data/rest/clients/manual.md) — Создание REST-клиента вручную, когда нет OpenAPI-спецификации. -- [REST: Использование: Серверные компоненты](./data/rest/fetching/server.md) — Получение REST-данных в серверных компонентах. -- [REST: Использование: Клиентские компоненты](./data/rest/fetching/client.md) — Получение REST-данных в клиентских компонентах. +- [REST](./data/rest/index.md) — Как правильно работать с REST API в проекте. +- [REST: Создание клиента](./data/rest/clients/index.md) — Как выбрать способ создания REST-клиента и где размещать его части. +- [REST: Создание клиента: Автогенерация из OpenAPI](./data/rest/clients/auto.md) — Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen. +- [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-данными от сервера: подписки и события. ## Прикладные разделы diff --git a/docs/docs/applied/page-level.md b/docs/docs/applied/page-level.md index b3079a5..ee8137b 100644 --- a/docs/docs/applied/page-level.md +++ b/docs/docs/applied/page-level.md @@ -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 import type { ReactNode } from 'react' -import { SWRConfig } from 'swr' -import { backendApi } from 'infrastructure/backend-api' +import { SWRConfig, unstable_serialize } from 'swr' +import { + backendApi, + getCurrentUserKey, + getPostListKey, +} from 'infrastructure/backend-api' type FeedLayoutProps = { children: ReactNode @@ -119,8 +123,8 @@ export default async function FeedLayout({ children }: FeedLayoutProps) { @@ -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). ## Инициализация состояния diff --git a/docs/docs/data/index.md b/docs/docs/data/index.md index cf21259..a2682ad 100644 --- a/docs/docs/data/index.md +++ b/docs/docs/data/index.md @@ -13,7 +13,7 @@ keywords: [данные, api, rest, realtime, клиент, swr, infrastructure, - **Клиент — в `infrastructure/`.** Каждый внешний сервис — отдельный модуль слоя `infrastructure/{service-name}/`. - **Прямой `fetch` запрещён.** Запросы идут только через клиент модуля. Исключения — точечные и обоснованные. - **Источник данных диктует канал.** 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. -- **Клиенты** — как создаётся клиент REST API: - - [Автоматическая генерация](/docs/data/rest/clients/auto) — для API с OpenAPI-спецификацией, через `@gromlab/api-codegen`. +- [REST](/docs/data/rest/) — обзор раздела: создание клиента и использование. +- **Создание клиента** — как оформляется 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/fetching/server) — прямой `await` метода клиента в Server Components. - - [Клиентские компоненты](/docs/data/rest/fetching/client) — через готовые хуки модуля API; SWR с кешем, дедупликацией и ревалидацией скрыт внутри хука. + - [GET-хуки REST-клиента](/docs/data/rest/clients/hooks) — прозрачные SWR-обёртки над GET-методами клиента. +- **Использование** — как получать данные через готовый клиент: + - [Стратегии получения данных](/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 @@ -40,7 +48,8 @@ keywords: [данные, api, rest, realtime, клиент, swr, infrastructure, - Где живёт код работы с API и почему именно там. - Когда генерировать клиент автоматически, а когда писать вручную, и как структурирован каждый из вариантов. -- Как получать данные на сервере и на клиенте, чтобы не ломать кеш и не плодить лишние запросы. +- Какие GET-хуки относятся к REST-клиенту и почему они живут в `infrastructure/{service-name}/hooks/`. +- Как выбрать стратегию получения REST-данных под конкретную ситуацию. - Как подключать realtime-источники в общую модель работы с данными. - Какие правила обязательны и какие отклонения допустимы. diff --git a/docs/docs/data/rest/clients/auto.md b/docs/docs/data/rest/clients/auto.md index 2bc3ae0..41b79e8 100644 --- a/docs/docs/data/rest/clients/auto.md +++ b/docs/docs/data/rest/clients/auto.md @@ -1,279 +1,193 @@ --- -title: Автогенерация REST-клиента -description: Генерация REST-клиента из OpenAPI-спецификации. -keywords: [api, rest, openapi, codegen, генерация, клиент, api-codegen, gromlab, infrastructure, swagger-typescript-api] +title: Автогенерация из OpenAPI +description: Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen. +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 -npm install -D @gromlab/api-codegen +```text +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 { "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-метод, без бизнес-логики и композиции. -```text -src/infrastructure/ -└── pet-project-api/ - ├── 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 модуля +## Генерация + +```bash +npm run codegen:pet-store-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/`). +```text +src/infrastructure/pet-store-api/generated/ +└── pet-store-api.generated.ts +``` -Имя сгенерированного файла — `{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` -Тонкий ручной слой поверх сгенерированного кода. Делает три вещи: читает и нормализует `baseUrl`, конфигурирует `HttpClient`, создаёт **именованный инстанс** сервиса. +Сгенерированный код не должен напрямую использоваться из приложения. Сначала создаётся настроенный инстанс клиента. ```ts -// src/infrastructure/pet-project-api/client.ts -import { Api, HttpClient } from './generated/pet-project-api.generated' - -const resolvedBaseUrl = process.env.NEXT_PUBLIC_API_URL - .replace(/\/+$/, '') // убираем хвостовой слэш - .replace(/\/v1$/, '') // версия уже в путях методов — режем дубль +// src/infrastructure/pet-store-api/client.ts +import { Api, HttpClient } from './generated/pet-store-api.generated' const httpClient = new HttpClient({ + baseUrl: 'https://petstore3.swagger.io/api/v3', baseApiParams: { secure: false, headers: { 'Content-Type': 'application/json', - // кастомные заголовки API — если требуются - // 'X-App-Key': '...', }, }, }) -httpClient.baseUrl = resolvedBaseUrl - -export const petProjectApi = new Api(httpClient) +export const petStoreApi = 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 -import { petProjectApi } from 'infrastructure/pet-project-api' -import { paymentsApi } from 'infrastructure/payments-api' +// src/infrastructure/biocad-less-api/types/term.ts +import type { TermRecordItem } from '../generated/biocad-less-api.generated' -const user = await petProjectApi.user.list() -const invoice = await paymentsApi.invoices.list() -``` - -### Нормализация `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?: { +declare module '../generated/biocad-less-api.generated' { + interface TermRecordItem { + media?: { file?: string title?: string url?: string } } } -``` -### `Extended` через `Omit & {...}` — переопределение полей - -Когда автогенерация даёт `object` или общий тип, а реально структура известна — создаётся отдельный тип `UserExtended` (по имени сущности + суффикс `Extended`). - -```ts -// src/infrastructure/pet-project-api/types/user.ts -export type UserExtended = Omit & { - roles?: Array<{ _id?: string; id?: string; slug?: string; name?: string }> - tags?: Array<{ _id?: string; id?: string; slug?: string; name?: string }> +export type TermRecordItemExtended = Omit< + TermRecordItem, + 'categories' | 'tags' | 'fields' +> & { + categories?: Array<{ + _id?: string + id?: string + slug?: string + name?: string + }> + tags?: Array<{ + _id?: string + id?: string + slug?: string + name?: string + }> fields?: Record } ``` -### Реэкспорт - ```ts -// src/infrastructure/pet-project-api/types/index.ts -export type { UserExtended } from './user' +// src/infrastructure/biocad-less-api/types/index.ts +export type { TermRecordItemExtended } from './term' ``` -### Правила +`declare module` используется для добавления отсутствующих полей в generated-интерфейс. `Extended`-тип используется, когда нужно переопределить неточные поля, не трогая generated-файл. -- Расширения — **только в `types/`**, не в `client.ts` и не в сгенерированном файле. -- Один файл на сущность (имя файла — kebab-case по сущности: `user.ts`, `order.ts`, `invoice.ts`). -- При регенерации `generated/{service-name}.generated.ts` файлы в `types/` не затрагиваются. -- Если сломался `Extended`-тип после regen — синхронизировать руками. - -## Хуки для клиентских компонентов - -В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте `hooks/`, по файлу на операцию. +## Публичный API ```ts -// src/infrastructure/pet-project-api/hooks/use-user-list.hook.ts -import useSWR from 'swr' -import type { SWRConfiguration } from 'swr' -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( - ['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 - - return useSWR(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' +// src/infrastructure/pet-store-api/index.ts +export { petStoreApi } from './client' +export type { Pet } from './generated/pet-store-api.generated' export * from './hooks' ``` +Наружу импортируют только из `infrastructure/pet-store-api`, не из `generated/`. + +Если у модуля есть расширенные типы, они тоже реэкспортируются через `index.ts`: + +```ts +// src/infrastructure/biocad-less-api/index.ts +export type { TermRecordItemExtended } from './types' +``` + ## Регенерация При изменении OpenAPI-схемы: ```bash -npm run codegen:pet-project-api +npm run codegen:pet-store-api ``` Что меняется: -- `generated/{service-name}.generated.ts` — перезаписывается полностью, изменения коммитятся. -- `client.ts`, `types/`, `config/`, `index.ts` — **не трогаются** автоматически. +- `generated/pet-store-api.generated.ts` — перезаписывается генератором. +- `client.ts`, `hooks/`, `types/`, `index.ts` — не трогаются автоматически. -Поломка контракта (изменение типов в схеме) ловится TypeScript при сборке проекта. Если ломаются `Extended`-типы — синхронизировать вручную в соответствующих файлах `types/`. +Если после регенерации поменялись сигнатуры методов или типы, это исправляется в ручном коде модуля. -## Сгенерированный файл коммитится +## Следующий шаг -Файл `generated/{service-name}.generated.ts` **не добавляется в `.gitignore`** — попадает в репозиторий вместе с остальным кодом. - -Причины: - -- **Детерминированная сборка.** `npm run build` не зависит от доступности OpenAPI-схемы (обычно она на удалённом сервере). Сервис лёг — прод собирается. -- **Видимость изменений в PR.** Diff показывает, что именно поменялось в контракте API между версиями. -- **Простой онбординг.** После `git clone` IDE сразу видит типы, без предварительной генерации. -- **Фиксация версии контракта.** Пересборка старого коммита даёт ровно тот клиент, что был тогда. - -Регенерация — **ручная команда** при обновлении схемы, не хук `predev`/`prebuild`. Запускается осознанно. - -Исключение возможно, только если OpenAPI-схема лежит **в этом же репозитории** и генерация быстрая, без сети — тогда допустимо добавить сегмент `generated/` в `.gitignore` и хук `prebuild`, по аналогии со спрайтами. На практике встречается редко. +После генерации и настройки `client.ts` проверьте серверный вызов метода клиента или добавьте [GET-хук REST-клиента](/docs/data/rest/clients/hooks) для Client Components. diff --git a/docs/docs/data/rest/clients/hooks.md b/docs/docs/data/rest/clients/hooks.md new file mode 100644 index 0000000..ccd2e7c --- /dev/null +++ b/docs/docs/data/rest/clients/hooks.md @@ -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(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 ( + + {children} + + ) +} +``` + +Клиентский компонент при этом ничего не знает про 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(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). diff --git a/docs/docs/data/rest/clients/index.md b/docs/docs/data/rest/clients/index.md new file mode 100644 index 0000000..4bc6587 --- /dev/null +++ b/docs/docs/data/rest/clients/index.md @@ -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/). diff --git a/docs/docs/data/rest/clients/manual.md b/docs/docs/data/rest/clients/manual.md index beb5079..d636cfa 100644 --- a/docs/docs/data/rest/clients/manual.md +++ b/docs/docs/data/rest/clients/manual.md @@ -1,365 +1,187 @@ --- -title: Ручное создание REST-клиента -description: "Создание REST-клиента вручную, когда нет OpenAPI-спецификации." -keywords: [api, rest, клиент, ручной, fetch, infrastructure, api-клиент] +title: Ручное создание +description: Создание REST-клиента вручную, когда OpenAPI нет или он неполный. +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 src/infrastructure/ └── pet-project-api/ - ├── methods/ # методы по сущностям API - │ ├── pages.ts - │ ├── posts.ts - │ └── forms.ts - ├── hooks/ # SWR-хуки для клиентских компонентов - │ ├── use-post-detail.hook.ts - │ ├── use-post-filter.hook.ts + ├── methods/ + │ └── posts.ts + ├── hooks/ │ └── index.ts - ├── types/ # типы клиента и доменные типы - │ ├── client.ts # типы клиента: RequestOptions, ParamValue - │ ├── post.ts # доменные типы сущности post - │ ├── form.ts # доменные типы сущности form - │ └── index.ts # реэкспорт публичных типов - ├── errors/ # доменные ошибки API + ├── types/ + │ ├── client.ts + │ ├── post.ts + │ └── index.ts + ├── errors/ │ └── pet-project-api.error.ts - ├── client.ts # класс клиента: baseUrl, headers, get/post - └── index.ts # публичный API модуля + ├── client.ts + └── index.ts ``` | Файл | Роль | |------|------| -| `client.ts` | Класс `PetProjectApiClient`: `baseUrl`, общие заголовки, `buildUrl`, базовые `get`/`post` | -| `methods/{entity}.ts` | Методы по сущности, экспортируются фабрикой `{entity}Methods(client)` | -| `hooks/use-{action}.hook.ts` | SWR-хук поверх метода клиента | -| `hooks/index.ts` | Реэкспорт хуков | -| `types/client.ts` | Типы инфраструктуры клиента: `RequestOptions`, `PostOptions`, `ParamValue` | -| `types/{entity}.ts` | Доменные типы: запросы, ответы, фильтры по сущности | -| `types/index.ts` | Реэкспорт публичных типов | -| `errors/{service-name}.error.ts` | Доменный класс ошибок API | -| `index.ts` | Публичный API: инстанс клиента, хуки, доменные ошибки, типы | +| `client.ts` | Базовый транспорт и создание инстанса клиента | +| `methods/` | Методы API по сущностям | +| `types/` | DTO запросов, ответов и типы клиента | +| `errors/` | Ошибки конкретного API | +| `hooks/` | GET-хуки REST-клиента, если данные нужны в Client Components | +| `index.ts` | Публичный API REST-модуля | -`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 // src/infrastructure/pet-project-api/types/client.ts -export type ParamValue = string | number | (string | number)[] - -export type RequestOptions = { - params?: Record - headers?: Record - revalidate?: number | false -} - -export type PostOptions = RequestOptions & { - type?: 'json' | 'formdata' -} +export type QueryParams = Record ``` -## Базовый клиент +## Ошибка API -Класс с конфигурацией (`baseUrl`, общие заголовки) и базовыми методами `get` / `post`. Конкретные методы API размещаются в сегменте `methods/`, а не на самом классе — это держит `client.ts` коротким и не плодит «бога-класс». - -```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 = {}, - ) { - this.defaultHeaders = { - Accept: 'application/json', - ...defaultHeaders, - } - } - - buildUrl(path: string, params?: Record): 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(path: string, options: RequestOptions = {}): Promise { - 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 - } - - async post(path: string, body: unknown, options: PostOptions = {}): Promise { - 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 - } -} -``` - -### Ключевые требования к клиенту - -- **Класс с приватным состоянием** (`baseUrl`, `defaultHeaders`) — конфигурация инкапсулирована. -- **Типы клиента — в `types/client.ts`**, не в `client.ts`. Реализация и контракты разделены. -- **Базовые методы дженерик `` без дефолта.** Вызов без типа невозможен — потребитель обязан указать форму ответа. -- **Доменная ошибка вместо `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`. +Ошибка API тоже относится к REST-модулю. ```ts // src/infrastructure/pet-project-api/errors/pet-project-api.error.ts export class PetProjectApiError extends Error { constructor( public readonly status: number, - public readonly body: string, + message: string, ) { - super(`PetProjectApi ${status}: ${body.slice(0, 200)}`) + super(message) this.name = 'PetProjectApiError' } +} +``` - static async fromResponse(response: Response): Promise { - const body = await response.text().catch(() => '') - return new PetProjectApiError(response.status, body) +## Базовый клиент + +`client.ts` содержит только транспортную оболочку и сборку инстанса. Прямой `fetch` живёт здесь, а не в компонентах и не в методах верхних слоёв. + +```ts +// src/infrastructure/pet-project-api/client.ts +import { PetProjectApiError } from './errors/pet-project-api.error' +import type { QueryParams } from './types/client' + +export class PetProjectApiClient { + constructor( + private readonly baseUrl: string, + private readonly defaultHeaders: Record = {}, + ) {} + + async get(path: string, params: QueryParams = {}): Promise { + 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) + } + + return response.json() as Promise } } ``` -Дополнительные подклассы по необходимости: `PetProjectApiValidationError` (400), `PetProjectApiAuthError` (401/403), `PetProjectApiNotFoundError` (404). Вводятся когда у потребителя есть **разная реакция** на разные коды; иначе хватает базового класса. +Это минимальный шаблон. Авторизация, дополнительные заголовки, `next.revalidate`, `post`, `formdata` и другие детали добавляются только когда они реально нужны API. -## Доменные типы +## Методы API -Типы запросов, ответов и фильтров — по файлу на сущность. Типы должны лежать рядом по смыслу: всё, что относится к `posts`, — в `types/post.ts`. - -```ts -// src/infrastructure/pet-project-api/types/post.ts -export type Post = { - id: string - slug: string - title: string - content: string - publishedAt: string -} - -export type PostFilter = { - limit?: number - categories?: number[] -} -``` - -```ts -// src/infrastructure/pet-project-api/types/index.ts -export type * from './post' -export type * from './form' -// типы клиента — внутренние, наружу не реэкспортируются -``` - -Типы клиента (`RequestOptions`, `PostOptions`, `ParamValue`) **не реэкспортируются** через `types/index.ts` — они нужны только внутри модуля. - -## Методы - -Методы группируются по сущностям в сегменте `methods/`, экспортируются фабрикой, принимающей клиент. Это даёт **процедурное обращение** в стиле автогенерированного клиента (`petProjectApi.posts.get(slug)`), а не плоский список (`petProjectApi.getPost(slug)`). +Методы группируются по сущностям в `methods/`. Они не знают про React, SWR и UI. ```ts // src/infrastructure/pet-project-api/methods/posts.ts import type { PetProjectApiClient } from '../client' -import type { Post, PostFilter } from '../types/post' +import type { PostDto, PostListQueryDto } from '../types/post' export function postsMethods(client: PetProjectApiClient) { return { + /** GET /posts */ + list: (query: PostListQueryDto = {}) => + client.get('posts', query), + /** GET /posts/{slug} */ - get: (slug: string, options?: { revalidate?: number | false }) => - client.get(`posts/${slug}`, options), - - /** POST /posts/filter */ - filter: (body: PostFilter) => - client.post('posts/filter', body), + get: (slug: string) => + client.get(`posts/${slug}`), } } ``` -```ts -// src/infrastructure/pet-project-api/methods/forms.ts -import type { PetProjectApiClient } from '../client' -import type { Form, FormSubmissionResult } from '../types/form' +Метод возвращает DTO в форме API. Если данным нужен доменный смысл, маппинг делается выше, в `business/`. -export function formsMethods(client: PetProjectApiClient) { - return { - /** GET /forms/{id} */ - get: (id: string) => client.get
(`forms/${id}`), +## Публичный API - /** POST /forms/{id} — multipart/form-data */ - submit: (id: string, data: FormData) => - client.post(`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(...)`. +`index.ts` собирает именованный API-объект и открывает наружу только публичные части модуля. ```ts // src/infrastructure/pet-project-api/index.ts import { PetProjectApiClient } from './client' -import { pagesMethods } from './methods/pages' import { postsMethods } from './methods/posts' -import { formsMethods } from './methods/forms' -const client = new PetProjectApiClient(process.env.NEXT_PUBLIC_API_URL, { - 'X-App-Key': process.env.NEXT_PUBLIC_APP_KEY, -}) +const client = new PetProjectApiClient( + process.env.NEXT_PUBLIC_API_URL ?? '', + { 'Content-Type': 'application/json' }, +) export const petProjectApi = { - pages: pagesMethods(client), posts: postsMethods(client), - forms: formsMethods(client), } 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' ``` -## Хуки для клиентских компонентов +Внешний код импортирует только из `infrastructure/pet-project-api`, не из внутренних файлов модуля. -В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте `hooks/`, по файлу на операцию. +## Правила -```ts -// src/infrastructure/pet-project-api/hooks/use-post-detail.hook.ts -import useSWR from 'swr' -import type { SWRConfiguration } from 'swr' -import { petProjectApi } from '..' -import type { Post } from '../types/post' +- `fetch` используется только внутри базового клиента. +- DTO запросов и ответов живут в `types/`. +- `client.ts` не содержит DTO, GET-хуки и бизнес-логику. +- Методы лежат в `methods/` и возвращают DTO. +- GET-хуки добавляются отдельно в `hooks/`, если данные нужны в Client Components. +- Доменные типы и маппинг DTO живут не в REST-клиенте, а в `business/`. -/** - * Получение поста по 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(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( - ['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 или сгенерирован. +Следующий шаг: [GET-хуки REST-клиента](/docs/data/rest/clients/hooks) или [Стратегии получения данных](/docs/data/rest/strategies/). diff --git a/docs/docs/data/rest/fetching/client.md b/docs/docs/data/rest/fetching/client.md deleted file mode 100644 index 7297c65..0000000 --- a/docs/docs/data/rest/fetching/client.md +++ /dev/null @@ -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 - if (error) return - - return
{post?.title}
-} -``` - -В компоненте нет `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 - return -} -``` - -Внутри `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( - ['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 ( - 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 -} -``` - -```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
{post?.title}
-} -``` - -Для массового заполнения кеша на странице с несколькими хуками — используется `` обёртка. Серверный компонент собирает данные и передаёт сериализованную карту ключей в провайдер; все вложенные хуки сразу видят кеш. - -## Запрет прямых вызовов - -```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, не в компонент. diff --git a/docs/docs/data/rest/fetching/server.md b/docs/docs/data/rest/fetching/server.md deleted file mode 100644 index 1ebd880..0000000 --- a/docs/docs/data/rest/fetching/server.md +++ /dev/null @@ -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 ( -
    - {users.map((user) => ( -
  • {user.name}
  • - ))} -
- ) -} -``` - -## Параллельные запросы - -```tsx -export default async function DashboardPage() { - const [users, orders] = await Promise.all([ - petProjectApi.user.list(), - petProjectApi.order.list(), - ]) - - return -} -``` - -## Передача данных в клиентский компонент - -Серверный компонент получает данные и передаёт их пропсами в клиентский. На клиенте данные становятся начальным состоянием — при необходимости перезапрашиваются через 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 -} -``` - -## Запрет прямого `fetch` - -Серверный компонент тоже использует только клиент из `infrastructure/`. Прямой `fetch` в `page.tsx` или в server-action запрещён теми же правилами, что и на клиенте. diff --git a/docs/docs/data/rest/index.md b/docs/docs/data/rest/index.md new file mode 100644 index 0000000..9c43715 --- /dev/null +++ b/docs/docs/data/rest/index.md @@ -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/`. diff --git a/docs/docs/data/rest/strategies/business-composition.md b/docs/docs/data/rest/strategies/business-composition.md new file mode 100644 index 0000000..5234443 --- /dev/null +++ b/docs/docs/data/rest/strategies/business-composition.md @@ -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-модуль отвечает за смысл этих данных в продукте. diff --git a/docs/docs/data/rest/strategies/client-get-hook.md b/docs/docs/data/rest/strategies/client-get-hook.md new file mode 100644 index 0000000..314d597 --- /dev/null +++ b/docs/docs/data/rest/strategies/client-get-hook.md @@ -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('available') + const { data: pets, isLoading, error } = useGetPetList(status) + + return ( +
+
+ {statuses.map((item) => ( + + ))} +
+ + {isLoading &&
Загрузка...
} + {error &&
Ошибка загрузки
} + +
    + {pets?.map((pet) => ( +
  • {pet.name}
  • + ))} +
+
+ ) +} +``` + +Компонент выбирает параметр `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). diff --git a/docs/docs/data/rest/strategies/client-hooks-initial-data.md b/docs/docs/data/rest/strategies/client-hooks-initial-data.md new file mode 100644 index 0000000..98a72e7 --- /dev/null +++ b/docs/docs/data/rest/strategies/client-hooks-initial-data.md @@ -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 ( + + {children} + + ) +} +``` + +Если 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
Загрузка...
+ + return ( +
    + {pets?.map((pet) => ( +
  • {pet.name}
  • + ))} +
+ ) +} +``` + +Компонент не знает, что данные были запущены на сервере. Он использует обычный GET-хук REST-клиента. + +## Что важно + +- Ключ `fallback` должен совпадать с ключом GET-хука. +- Серверный код вызывает метод клиента, а не GET-хук. +- Клиентский компонент вызывает GET-хук, а не `useSWR` напрямую. +- Эта стратегия не означает ручную работу с кешем в компонентах. + +## Когда не использовать + +Если данные нужны только серверному компоненту, используйте [Серверный await](/docs/data/rest/strategies/server-await). Если данные зависят от состояния браузера, используйте [Клиентский GET-хук](/docs/data/rest/strategies/client-get-hook). diff --git a/docs/docs/data/rest/strategies/index.md b/docs/docs/data/rest/strategies/index.md new file mode 100644 index 0000000..80bef7e --- /dev/null +++ b/docs/docs/data/rest/strategies/index.md @@ -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-модуля. diff --git a/docs/docs/data/rest/strategies/parallel-server-requests.md b/docs/docs/data/rest/strategies/parallel-server-requests.md new file mode 100644 index 0000000..4470a89 --- /dev/null +++ b/docs/docs/data/rest/strategies/parallel-server-requests.md @@ -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 ( + + ) +} +``` + +## Плохо + +```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 ( + + ) +} +``` + +Во втором примере каждый следующий запрос ждёт предыдущий, хотя они независимы. + +## Зависимые запросы + +Если второй запрос зависит от результата первого, последовательный `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 +} +``` + +Не превращайте зависимый сценарий в `Promise.all` искусственно. + +## Когда выбрать другую стратегию + +Если часть данных не обязательна для первого блока UI, можно запустить промис выше и передать его ниже: [Передача промиса ниже](/docs/data/rest/strategies/pass-promise-down). diff --git a/docs/docs/data/rest/strategies/pass-promise-down.md b/docs/docs/data/rest/strategies/pass-promise-down.md new file mode 100644 index 0000000..9a1ac7f --- /dev/null +++ b/docs/docs/data/rest/strategies/pass-promise-down.md @@ -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 ( +
+

Питомцы

+ }> + + +
+ ) +} + +async function AvailablePets({ petsPromise }: { petsPromise: Promise }) { + const pets = await petsPromise + + return +} +``` + +Запрос стартует в `PetsPage`, но ожидание происходит внутри `AvailablePets`. `Suspense` управляет fallback для этой части UI. + +## Граница стратегии + +Эта стратегия остаётся серверной. Не используйте её как замену GET-хукам в Client Components. + +Если данные должны попасть в клиентский SWR-хук, используйте [Начальные данные для клиентских хуков](/docs/data/rest/strategies/client-hooks-initial-data). + +## Что не делать + +```tsx +// Плохо — передавать промис в произвольный клиентский компонент без ясной стратегии +return +``` + +Для клиентского потребления есть отдельная стратегия через `SWRConfig fallback` и готовые GET-хуки REST-клиента. diff --git a/docs/docs/data/rest/strategies/server-await.md b/docs/docs/data/rest/strategies/server-await.md new file mode 100644 index 0000000..850862b --- /dev/null +++ b/docs/docs/data/rest/strategies/server-await.md @@ -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 +} +``` + +`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 +} +``` + +Обработка 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). diff --git a/generate-llms.ts b/generate-llms.ts index ec65183..2644ca4 100644 --- a/generate-llms.ts +++ b/generate-llms.ts @@ -227,7 +227,6 @@ const copyDirSync = ( const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { - fs.mkdirSync(destPath, { recursive: true }); count += copyDirSync(srcPath, destPath, filter); } else if (entry.isFile() && filter(entry.name)) { fs.mkdirSync(dest, { recursive: true }); @@ -250,6 +249,8 @@ const copyMdFiles = (): void => { const destDir = path.join(PUBLIC_DIR, 'docs'); if (!fs.existsSync(srcDir)) return; + fs.rmSync(destDir, { recursive: true, force: true }); + const copied = copyDirSync( srcDir, destDir, @@ -321,6 +322,8 @@ const transformLinksInDir = (rootDir: string): 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 stage = path.join(tmpRoot, 'nextjs-style-guide'); fs.mkdirSync(stage, { recursive: true });