В этой части собираем headless-слой для фронтов: Gateway, композицию API, SDK, ETag, SSR, идемпотентность и единые правила работы с запросами.
Привет, хабровчане. Это снова Алиса, снова Laravel, Bitrix и попытка не превратить фронтенд в распределенный монолит. К этому моменту у нас уже есть быстрые доменные сервисы: каталог, корзина, цены, заказы, интеграции. Но фронту от этого не сильно легче. Ему все еще приходится ходить в десяток ручек, собирать ответы, следить за авторизацией и одинаково обрабатывать ошибки.
Поэтому поверх доменных сервисов появляется Headless API Gateway — тонкий слой, который работает как BFF для фронтов.
Он берет на себя JWT-cookie, CORS, rate-limit, кэширование, единый формат ошибок и композицию сценариев вроде листинга, карточки товара или чекаута. При этом Gateway не дублирует бизнес-логику. Его задача — валидировать входящие запросы, сходить в нужные сервисы, собрать ответ и вернуть фронту компактный JSON с ETag и нормальными HTTP-заголовками.
Дальше собираем это на Laravel: CORS, middleware для JWT-cookie, rate-limit, единый формат ошибок, композиционные ручки для фронтов, кэш-заголовки и роутинг через Nginx.
Предыдущие части статьи:
Роутинг на уровне Nginx
Gateway слушает /api/*, Bitrix — остальное, SSO-cookie летит в оба направления:
Скрытый текст
map $http_x_request_id $req_id { default $http_x_request_id; "" $request_id; } server { # ... add_header X-Request-Id $req_id; location ~ ^/api/ { proxy_set_header X-Request-Id $req_id; proxy_pass http://gateway_upstream; } location /bitrix/ { proxy_set_header X-Request-Id $req_id; proxy_pass http://bitrix_upstream; } location / { proxy_pass http://frontend_upstream; } }
CORS: строгий белый список и поддержка cookie
Начинаем с CORS. Gateway должен принимать запросы только от доверенных фронтов и при этом поддерживать cookie, ETag и служебные заголовки вроде If-Match и Idempotency-Key. Поэтому список origin’ов держим в конфиге, а не открываем все через *.
Скрытый текст
config/cors.php (фрагмент): <?php return [ 'paths' => ['api/*'], 'allowed_methods' => ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], 'allowed_origins' => explode(',', env('CORS_ORIGINS', 'https://www.example.ru,https://admin.example.ru')), 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Request-Id', 'If-None-Match', 'If-Match', 'Idempotency-Key'], 'exposed_headers' => ['X-Request-Id', 'ETag', 'Retry-After'], 'supports_credentials' => true, 'max_age' => 600, ];
Аутентификация: JWT в HttpOnly-cookie и RS256-проверка
Middleware забирает JWT из cookie sid, проверяет подпись через JWK и авторизует пользователя в контексте запроса.
Дополнительно прокидываем X-Request-Id, чтобы связывать запросы, ошибки и логи между сервисами.
Скрытый текст
<?php namespace App\Http\Middleware; use Closure; use Firebase\JWT\JWK; use Firebase\JWT\JWT; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; final class JwtCookieAuth { public function handle(Request $request, Closure $next): Response { $rid = $request->headers->get('X-Request-Id') ?: Str::uuid()->toString(); $request->headers->set('X-Request-Id', $rid); Log::withContext(['request_id' => $rid]); $jwt = (string) $request->cookie('sid', ''); if ($jwt !== '') { try { $jwks = JWK::parseKeySet(\json_decode((string) file_get_contents(storage_path('jwks.json')), true)); $claims = (array) JWT::decode($jwt, $jwks); // создаем легкий "пользователь" по claim’ам $user = new class($claims) implements Authenticatable { public function __construct(private array $c) {} public function getAuthIdentifierName(): string { return 'sub'; } public function getAuthIdentifier(): mixed { return $this->c['sub'] ?? null; } public function getAuthPassword(): string { return ''; } public function getRememberToken(): ?string { return null; } public function setRememberToken($value): void {} public function getRememberTokenName(): string { return ''; } public function __get($key) { return $this->c[$key] ?? null; } }; auth()->setUser($user); } catch (\Throwable $e) { // не валим запрос - считаем анонимом } } /** @var Response $response */ $response = $next($request); $response->headers->set('X-Request-Id', $rid); return $response; } }
Подключаем в app/Http/Kernel.php перед роутами /api.
Единый формат ошибок
Ошибки приводим к одному формату через исключения и глобальные обработчики. Фронту не приходится угадывать, что автор хотел сказать.
Скрытый текст
<?php namespace App\Exceptions; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; final class Handler extends ExceptionHandler { public function render($request, Throwable $e) { $rid = (string) $request->header('X-Request-Id', ''); $code = 500; $err = 'internal_error'; if ($e instanceof HttpExceptionInterface) { $code = $e->getStatusCode(); } if ($e instanceof \Illuminate\Validation\ValidationException) { return response()->json([ 'ok' => false, 'error' => 'validation_failed', 'details' => $e->errors(), 'request_id' => $rid, ], 422); } return response()->json([ 'ok' => false, 'error' => $err, 'message' => app()->isProduction() ? null : $e->getMessage(), 'request_id' => $rid, ], $code); } }
Rate-limit по пользователю и IP
Глобально придерживаемся простого правила: стабильность важнее пиковых burst-запросов. Ограничиваем слишком агрессивный трафик, но не мешаем обычной работе фронтов и мобильных клиентов.
Скрытый текст
<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Symfony\Component\HttpFoundation\Response; final class ThrottlePerUser { public function handle(Request $request, Closure $next): Response { $uid = $request->user()?->getAuthIdentifier() ?: 'anon:' . $request->ip(); $key = 'rl:' . \sha1((string) $uid . '|' . $request->path()); $ok = RateLimiter::attempt($key, $perMinute = 120, static function (): void {}); if (! $ok) { return response()->json([ 'ok' => false, 'error' => 'rate_limited', 'request_id' => $request->header('X-Request-Id'), ], 429)->withHeaders(['Retry-After' => '30']); } return $next($request); } }
Композиционные ручки: страница товара и листинг
Gateway не хранит данные у себя. Его задача — собрать ответы доменных сервисов и отдать фронту один JSON вместо цепочки запросов.
Таймауты держим короткими. Если какой-то сервис не успевает ответить, используем fallback из кэша, чтобы фронт не зависал целиком.
Скрытый текст
Контроллер Product Page:
<?php namespace App\Http\Controllers\Gateway; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; final class ProductPageController { public function show(Request $r, int $id): JsonResponse { $r->validate(['id' => ['integer','min:1']]); $cacheKey = 'page:product:' . $id; $etag = '"' . \sha1($cacheKey . '|' . (string) Cache::get($cacheKey . ':v', '0')) . '"'; if ($r->headers->get('If-None-Match') === $etag) { return response()->json(null, 304)->withHeaders(['ETag' => $etag]); } // параллельные запросы к доменным API $product = Http::timeout(0.8)->get(env('CATALOG_URL') . "/api/v1/products/{$id}")->json('data'); $price = Http::timeout(0.6)->get(env('PRICING_URL') . '/api/v1/pricing', [ 'ptype' => 'BASE', 'product_ids' => [$id], 'currency' => 'RUB', ])->json('prices.0'); $stock = Http::timeout(0.6)->get(env('INVENTORY_URL') . '/api/v1/inventory', [ 'product_ids' => [$id], ])->json('items.0'); $media = [ 'card' => env('MEDIA_PUBLIC') . "/media/{$product['media_id']}/card", 'cover'=> env('MEDIA_PUBLIC') . "/media/{$product['media_id']}/cover", ]; $payload = [ 'ok' => true, 'product' => $product, 'price' => $price, 'stock' => $stock, 'media' => $media, ]; $resp = response()->json($payload, 200) ->withHeaders([ 'Cache-Control' => 'public, max-age=10', 'ETag' => $etag, 'X-Request-Id' => $r->header('X-Request-Id'), ]); return $resp; } }
Контроллер Listing Page:
<?php namespace App\Http\Controllers\Gateway; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; final class ListingController { public function search(Request $r): JsonResponse { $data = $r->validate([ 'q' => ['nullable','string','max:128'], 'brand' => ['array'], 'brand.*' => ['string','max:64'], 'category' => ['array'], 'category.*' => ['string','max:64'], 'page' => ['nullable','integer','min:1'], 'per_page' => ['nullable','integer','min:1','max:60'], ]); $catalog = Http::timeout(0.8)->get(env('CATALOG_URL') . '/api/v1/catalog/search', $data)->json(); // обогащаем опционально ценами батчом $ids = \array_map(static fn ($h) => (int) $h['_source']['id'], (array) ($catalog['hits']['hits'] ?? [])); $prices = $ids ? Http::timeout(0.6)->get(env('PRICING_URL') . '/api/v1/pricing', [ 'ptype' => 'BASE', 'currency' => 'RUB', 'product_ids' => $ids, ])->json('prices') : []; $byId = []; foreach ((array) $prices as $p) { $byId[(int) $p['product_id']] = $p; } foreach ($catalog['hits']['hits'] ?? [] as &$hit) { $pid = (int) $hit['_source']['id']; $hit['_source']['price'] = $byId[$pid] ?? null; } return response()->json([ 'ok' => true, 'list' => $catalog['hits']['hits'] ?? [], 'aggs' => $catalog['aggregations'] ?? new \stdClass(), 'page' => $catalog['page'] ?? 1, 'per' => $catalog['per'] ?? 24, ])->withHeaders(['Cache-Control' => 'public, max-age=5']); } }
Маршруты гейтвея:
<?php use App\Http\Controllers\Gateway\ListingController; use App\Http\Controllers\Gateway\ProductPageController; use Illuminate\Support\Facades\Route; Route::middleware(['jwt.cookie', 'throttle.peruser'])->prefix('api')->group(function (): void { Route::get('/page/product/{id}', [ProductPageController::class, 'show'])->whereNumber('id'); Route::get('/listing', [ListingController::class, 'search']); });
Версионирование и кэш
Для публичных GET поддерживаем ETag и If-None-Match, а также выставляем короткий Cache-Control.
Версионирование делаем либо через Accept-Version: 2025-09-01, либо через /api/v1. Главное — выбрать один подход и не смешивать их внутри проекта.
В ответах держим стабильные поля и типы. Новые данные добавляем только расширением схемы, без ломающих изменений для фронтов и SDK.
Безопасность
В CORS_ORIGINS держим только доверенные домены фронтов. Для SPA включаем credentials: include и SameSite=Lax.
Входные данные валидируем на всех слоях, а размеры batch-запросов ограничиваем отдельно.
Для POST, PATCH и DELETE используем double-submit CSRF: токен хранится в куках и дублируется в заголовке.
Для корректной работы прокси и CDN добавляем Vary: Origin.
Наблюдаемость
Gateway — первое место, куда обычно смотрит on-call при проблемах на фронте. X-Request-Id и перехват ошибок у нас уже есть, поэтому дальше добавляем нормальные метрики и трассировку.
В трейсы прокидываем gateway=1, чтобы быстро отделять gateway-запросы от доменных сервисов.
По маршрутам собираем метрики типа gateway_http_duration_seconds{route,code}, отдельно считаем ETag cache hit и следим за таймаутами downstream API. Плюс держим топ медленных сервисов и маршрутов, иначе поиск одного тормозящего вызова быстро станет археологией по логам.
Зачем отдельный Gateway
Фронту удобнее работать сценариями, когда один экран — один endpoint: листинг, карточка товара, чекаут.
Gateway прячет внутренние контракты и дает нам пространство для миграций. Можно менять доменный сервис, формат ответа или источник данных, не заставляя фронт каждый раз догонять эти изменения.
В этом же слое удобно держать кэш, rate-limit, A/B-тесты и фича-флаги на уровне готового ответа страницы. Фронт получает стабильный API, а backend — меньше связности между сервисами и клиентами.
SDK-ядро и адаптеры фронтов
Gateway дал фронтам единый API. Теперь важно не растащить одну и ту же транспортную логику по всем проектам.
Фронт не должен знать, как устроены внутренние сервисы. Ему достаточно уметь работать с JWT в HttpOnly-cookie, учитывать ETag, отправлять идемпотентные POST и одинаково обрабатывать ошибки.
Для этого собираем SDK-ядро на TypeScript и тонкие адаптеры под UI-стеки: хуки для React, composables для Vue. Бизнес-логику в SDK не кладем, только транспорт, типы, кэш, ретраи и удобные методы для вызовов.
Показываем пример опорной реализации: контракт ошибок, клиент с ретраями и идемпотентностью, кэш по ETag, CSRF для мутаций и примеры адаптеров. Это рабочая основа, которая закрывает большую часть задач фронта.
Договоренности с Gateway
SDK работает по тем же правилам, которые уже заданы в Gateway:
аутентификация — JWT в
HttpOnly-cookie sid, клиентский код не читает токен напрямую;CSRF для SPA — double-submit: токен лежит в куке csrf и дублируется в заголовке
X-CSRF;ошибки — единый формат ответа:
{ "ok": false, "error": "validation_failed", "details": {...}, "request_id": "..." }кэш GET-запросов — через
ETagиIf-None-Match;идемпотентность мутаций — через заголовок
Idempotency-Key.
SDK-ядро: клиент, типы и утилиты
Типы ответов и ошибок:
// sdk/core/types.ts export type ApiOk<T> = { ok: true } & T; export interface ApiErrorShape { ok: false; error: string; // e.g. "validation_failed", "rate_limited", "internal_error" message?: string | null; // в dev details?: unknown; // ошибки валидации request_id?: string; status?: number; // HTTP код (добавим на клиенте) } export class ApiError extends Error { public readonly shape: ApiErrorShape; constructor(shape: ApiErrorShape) { super(shape.error); this.shape = shape; } }
Ключи идемпотентности и backoff:
// sdk/core/idem.ts export function idemKey(seed: string, payload?: unknown): string { const base = seed + '|' + (payload ? JSON.stringify(payload) : ''); return 'idem_' + hash(base); } // дешевый хэш без криптографии function hash(s: string): string { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return ('0000000' + (h >>> 0).toString(16)).slice(-8); } // экспоненциальный бэкофф с джиттером export const backoff = (attempt: number) => Math.min(3000, Math.round((2 ** attempt) * 100 + Math.random() * 100));
Клиент с ETag-кэшем, ретраями и CSRF:
Скрытый текст
// sdk/core/client.ts export interface ClientOptions { baseUrl: string; // https://api.example.ru timeoutMs?: number; // по умолчанию 3000 csrfCookie?: string; // имя cookie с CSRF, по умолчанию "csrf" etagCache?: Map<string, { etag: string; data: unknown; ts: number }>; } export class ApiClient { private readonly base: string; private readonly timeout: number; private readonly csrfCookie: string; private readonly etags: Map<string, { etag: string; data: unknown; ts: number }>; constructor(opts: ClientOptions) { this.base = opts.baseUrl.replace(/\/+$/, ''); this.timeout = opts.timeoutMs ?? 3000; this.csrfCookie = opts.csrfCookie ?? 'csrf'; this.etags = opts.etagCache ?? new Map(); } // Примеры, остальное по аналогии async getProductPage(id: number) { return this.get<ApiOk<{ product: any; price: any; stock: any; media: { card: string; cover: string }; }>>(`/api/page/product/${id}`); } async searchListing(params: Record<string, unknown>) { return this.get<ApiOk<{ list: any[]; aggs: unknown; page: number; per: number }>>('/api/listing', params); } async cartGet() { return this.get<ApiOk<{ cart: unknown }>>('/api/v1/cart/'); } async cartAdd(line: { sku: string; qty: number; attrs?: Record<string, string> }) { const key = idemKey('cart:add', line); return this.post<ApiOk<{ cart: unknown }>>('/api/v1/cart/', line, key); } async cartPatch(lineId: string, qty: number, revision: number) { return this.patch<ApiOk<{ cart: unknown }>>(`/api/v1/cart/${encodeURIComponent(lineId)}`, { qty }, { ifMatch: revision }); } async createOrder(payload: Record<string, unknown>) { const key = idemKey('order:create', payload); return this.post<ApiOk<{ order_uuid: string; number_hint: string }>>('/api/v1/orders', payload, key); } // базовые HTTP операции private async get<T>(path: string, query?: Record<string, unknown>): Promise<T> { const url = new URL(this.base + path); if (query) Object.entries(query).forEach(([k, v]) => v !== undefined && url.searchParams.append(k, String(v))); const cacheKey = url.toString(); const cached = this.etags.get(cacheKey); const headers: Record<string, string> = {}; if (cached?.etag) headers['If-None-Match'] = cached.etag; const res = await this.fetchWithRetry(url.toString(), { method: 'GET', headers }, /* retry */ 2); if (res.status === 304 && cached) { return cached.data as T; } const data = await this.parse<T>(res); const etag = res.headers.get('ETag'); if (etag) this.etags.set(cacheKey, { etag, data, ts: Date.now() }); return data; } private async post<T>(path: string, body: unknown, idem?: string): Promise<T> { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'X-CSRF': this.readCookie(this.csrfCookie) ?? '', }; if (idem) headers['Idempotency-Key'] = idem; const res = await this.fetchWithRetry(this.base + path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(body), }, /* retry */ idem ? 2 : 0); return this.parse<T>(res); } private async patch<T>(path: string, body: unknown, opts?: { ifMatch?: number }): Promise<T> { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'X-CSRF': this.readCookie(this.csrfCookie) ?? '', }; if (opts?.ifMatch !== undefined) headers['If-Match'] = String(opts.ifMatch); const res = await this.fetchWithRetry(this.base + path, { method: 'PATCH', credentials: 'include', headers, body: JSON.stringify(body), }, /* retry */ 0); return this.parse<T>(res); } private async fetchWithRetry(url: string, init: RequestInit, retries: number): Promise<Response> { // Всегда шлем cookie const req: RequestInit = { ...init, credentials: 'include', signal: undefined }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), this.timeout); req.signal = ctrl.signal; try { const res = await fetch(url, req); if (this.shouldRetry(res) && retries > 0) { await this.sleep(backoff(3 - retries)); return this.fetchWithRetry(url, init, retries - 1); } return res; } catch (e) { if (retries > 0) { await this.sleep(backoff(3 - retries)); return this.fetchWithRetry(url, init, retries - 1); } throw e; } finally { clearTimeout(timer); } } private shouldRetry(res: Response): boolean { if (res.status >= 500) return true; if (res.status === 429) return true; return false; } private async parse<T>(res: Response): Promise<T> { const contentType = res.headers.get('Content-Type') || ''; const json = contentType.includes('application/json') ? await res.json() : null; if (res.ok && json?.ok !== false) { return json as T; } const shape: ApiErrorShape = { ok: false, error: (json?.error ?? 'http_' + res.status) as string, message: json?.message ?? null, details: json?.details, request_id: json?.request_id ?? res.headers.get('X-Request-Id') ?? undefined, status: res.status, }; throw new ApiError(shape); } private readCookie(name: string): string | null { if (typeof document === 'undefined') return null; // на SSR берем из заголовков/контекста const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; } private sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } }
В SSR cookie не читаем через document.cookie. Их нужно прокидывать в конструктор клиента или использовать обертки фреймворка, которые умеют передавать куки во время серверного рендера.
Vue composable для карточки и корзины
Для Vue делаем два composable: один для карточки товара, второй — для корзины. Они инкапсулируют загрузку данных, ошибки, повторные запросы и работу с ревизиями корзины.
Скрытый текст
// sdk/vue/useProductPage.ts import { ref, onMounted, watch } from 'vue'; import type { ApiClient, ApiError } from '../core/client'; export function useProductPage(client: ApiClient, idRef: { value: number }) { const data = ref<any | null>(null); const loading = ref(true); const err = ref<ApiError | null>(null); const load = async (id: number) => { loading.value = true; err.value = null; try { data.value = await client.getProductPage(id); } catch (e: any) { err.value = e; } finally { loading.value = false; } }; onMounted(() => load(idRef.value)); watch(idRef, (id) => load(id)); return { data, loading, err, reload: () => load(idRef.value) }; } // sdk/vue/useCart.ts import { ref } from "vue"; import type { ApiClient, ApiError } from "../core/client"; export function useCart(client: ApiClient) { const cart = ref<any | null>(null); const loading = ref(false); const err = ref<ApiError | null>(null); const revision = ref<number>(0); async function refresh() { loading.value = true; err.value = null; try { const res: any = await client.cartGet(); cart.value = res.cart; revision.value = Number(res.cart?.revision ?? 0); } catch (e: any) { err.value = e as ApiError; } finally { loading.value = false; } } async function add( sku: string, qty: number, attrs?: Record<string, string> ) { err.value = null; const res: any = await client.cartAdd({ sku, qty, attrs }); cart.value = res.cart; revision.value = Number(res.cart?.revision ?? revision.value); } async function setQty(lineId: string, qty: number) { err.value = null; try { const res: any = await client.cartPatch(lineId, qty, revision.value); cart.value = res.cart; revision.value = Number(res.cart?.revision ?? revision.value); } catch (e: any) { const apiErr = e as ApiError; // Конфликт ревизий: обновимся и повторим один раз if (apiErr?.shape?.status === 409) { await refresh(); const res2: any = await client.cartPatch(lineId, qty, revision.value); cart.value = res2.cart; revision.value = Number(res2.cart?.revision ?? revision.value); } else { throw e; } } } return { cart, loading, err, revision, refresh, add, setQty }; }
SSR-заметки
С серверным рендерингом есть несколько отдельных моментов, которые лучше учесть сразу:
Для SSR используем отдельный экземпляр
ApiClientна каждый запрос и прокидываем куки из входящего HTTP-запроса.На сервере fetch должен поддерживать
credentials: include. Если платформа этого не умеет, заголовокCookieдобавляем вручную при запросах к Gateway.ETag-кэш на сервере держим через LRU в рамках текущего запроса. Глобально такой кэш лучше не хранить, чтобы не смешивать данные разных пользователей.
Обработка ошибок на UI
На фронте ошибки тоже приводим к единому сценарию:
ApiError.shape.errorиспользуем как машинное имя и маппим на локализованные сообщения;shape.request_idпоказываем в подробностях ошибки, чтобы связать экран пользователя с логами и трейсами;при 401 и 403 предлагаем повторный вход;
при 429 показываем
Retry-Afterиз заголовка ответа.
Безопасность
SDK не читает и не хранит JWT. Токен живет только в HttpOnly-cookie, поэтому фронт не может случайно положить его в LocalStorage, отправить в логи или утянуть сторонним скриптом.
Для всех мутаций автоматически добавляем X-CSRF из cookie csrf. В SSR cookie берем из входящего HTTP-запроса и прокидываем дальше в Gateway.
Клиентский кэш тоже держим максимально простым. В LocalStorage и Cache Storage не складываем персональные данные, корзины или пользовательские профили. Кэшируем только GET-ответы и только те данные, которые безопасно переиспользовать между запросами.
Где заканчивается SDK
SDK отвечает только за транспортный слой: запросы, типы, кэш, ошибки, CSRF, ретраи и идемпотентность. Логика интерфейса остается на фронте, и SDK не решает, как показывать карточку товара, когда скрывать кнопку покупки или какой текст выводить при ошибке.
Это разделение потом сильно упрощает жизнь. Backend спокойно добавляет новые сценарии через Gateway, а у фронта еще один метод и типизированный ответ, без копирования низкоуровневой логики по проектам.
Минимальная интеграция в приложении
Скрытый текст
// initSdk.ts import { ApiClient } from "./sdk/core/client"; export const api = new ApiClient({ baseUrl: "https://api.example.ru", timeoutMs: 3000, }); <!-- ProductPage.vue --> <script setup lang="ts"> import { toRef } from "vue"; import { api } from "./initSdk"; import { useProductPage } from "./sdk/vue/useProductPage"; import { useCart } from "./sdk/vue/useCart"; const props = defineProps<{ id: number }>(); const { data, loading, err } = useProductPage(api, toRef(props, "id")); const { add } = useCart(api); function addToCart() { if (!data.value) return; // Пример: добавляем 1 единицу выбранного SKU (предполагаем product.sku) const sku = data.value.product?.sku ?? String(props.id); void add(sku, 1); } </script> <template> <section> <div v-if="loading">Загрузка…</div> <div v-else-if="err"> Ошибка: {{ err.shape.error }} · {{ err.shape.request_id }} </div> <article v-else-if="data"> <img :src="data.media.card" :alt="data.product?.name || 'Товар'" style="max-width: 360px" /> <h1>{{ data.product?.name }}</h1> <p v-if="data.price"> Цена: {{ data.price.amount }} {{ data.price.currency }} </p> <p> Наличие: <strong>{{ data.stock?.in_stock ? "в наличии" : "нет на складе" }}</strong> </p> <button type="button" @click="addToCart">Добавить в корзину</button> </article> </section> </template> <!-- CartPage.vue --> <script setup lang="ts"> import { onMounted, ref, computed } from "vue"; import { api } from "./initSdk"; import { useCart } from "./sdk/vue/useCart"; const { cart, loading, err, refresh, setQty } = useCart(api); const pending = ref<Record<string, number>>({}); onMounted(() => { void refresh(); }); const lines = computed(() => (cart.value?.lines ?? []) as Array<any>); const totalQty = computed(() => lines.value.reduce((s, l) => s + Number(l.qty || 0), 0) ); const currency = computed(() => cart.value?.currency ?? "RUB"); function onChangeQuantity(lineId: string, current: number) { pending.value[lineId] = current; } async function applyQuantity(lineId: string) { const qty = Number(pending.value[lineId]); if (Number.isFinite(qty) && qty >= 0) { await setQty(lineId, qty); } } </script> <template> <section> <h2>Корзина</h2> <div v-if="loading">Загрузка…</div> <div v-else-if="err"> Ошибка: {{ err.shape.error }} · {{ err.shape.request_id }} </div> <table v-else-if="lines.length" cellpadding="6" cellspacing="0" border="0"> <thead> <tr> <th align="left">Позиция</th> <th align="right">Кол-во</th> <th align="center">Действия</th> </tr> </thead> <tbody> <tr v-for="ln in lines" :key="ln.lineId"> <td> <div><strong>{{ ln.sku }}</strong></div> <small v-if="ln.attrs"> <span v-for="(v, k) in ln.attrs" :key="k">{{ k }}: {{ v }} </span> </small> </td> <td align="right" style="white-space: nowrap"> <input type="number" min="0" step="1" :value="ln.qty" @input="onChangeQuantity(ln.lineId, ($event.target as HTMLInputElement).valueAsNumber)" style="width: 80px" /> </td> <td align="center"> <button type="button" @click="applyQuantity(ln.lineId)">Обновить</button> </td> </tr> </tbody> </table> <div v-else>Корзина пуста</div> <footer style="margin-top: 12px"> <div>Всего позиций: {{ lines.length }}</div> <div>Суммарное количество: {{ totalQty }}</div> <div>Валюта: {{ currency }}</div> </footer> </section> </template>
Наблюдаемость во фронте
На клиенте тоже собираем базовые метрики. На каждый показ ошибки отправляем код, request_id и маршрут страницы.
Отдельно считаем долю ответов 304 на ключевых страницах — так проще понять, насколько полезен ETag и работает ли кэш как задумано.
Повторы запросов после конфликтов ревизий корзины тоже логируем. По этим метрикам быстро видно, где UI слишком часто пересобирает состояние или где пользователи работают с несколькими вкладками одновременно.
Что в итоге
После Gateway и SDK у фронта появляется один понятный способ работать с API без копирования fetch-оберток между проектами, ручной сборки заголовков, возни с куками, CSRF, retry и обработкой ошибок. Так мы получаем:
единый способ работы с API через Gateway;
одинаковое поведение ошибок, ретраев и мутаций на всех фронтах;
предсказуемую идемпотентность без дублей заказов при плохой сети или повторных запросах;
ETagи кэширование GET без отдельного ручного менеджмента;тонкие адаптеры под React, Vue и SSR без дублирования транспортной логики;
возможность спокойно менять внутренние сервисы, не переписывая клиентский код.
Хочу сразу сказать, что мы не пытались придумать идеальную архитектуру. У каждого проекта будут свои компромиссы: где-то хватит одного Gateway, где-то все упрется в ERP, а где-то SDK окажется лишним слоем. Поэтому особенно интересно сравнить подходы: как вы решаете API-композицию, что делаете с ETag и идемпотентностью, и насколько далеко вообще пускаете фронт в свои доменные сервисы.
Ваш ждет еще финальная часть...