В этой части собираем 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.

Предыдущие части статьи:

Часть 1

Часть 2

Часть 3

Часть 4

Часть 5

Роутинг на уровне 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-заметки

С серверным рендерингом есть несколько отдельных моментов, которые лучше учесть сразу:

  1. Для SSR используем отдельный экземпляр ApiClient на каждый запрос и прокидываем куки из входящего HTTP-запроса.

  2. На сервере fetch должен поддерживать credentials: include. Если платформа этого не умеет, заголовок Cookie добавляем вручную при запросах к Gateway.

  3. 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 и идемпотентностью, и насколько далеко вообще пускаете фронт в свои доменные сервисы.

Ваш ждет еще финальная часть...

Комментарии (0)