📱 Подписаться
IT и цифровая трансформация

Ecommerce на Laravel, или как мы собрали headless-слой для фронтов (6 часть)

📰 Habr 👁️ 2 просмотров

AliceInCodeLand2 часа назад

Ecommerce на Laravel, или как мы собрали headless-слой для фронтов (6 часть)

Время на прочтение18 минОхват и читатели4.3KПрограммирование*Разработка под e-commerce*Laravel*1С-Битрикс*Веб-разработка*В этой части собираем 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-заметки

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

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

Ваш ждет еще финальная часть...Теги:• headless
• laravel
• битрикс
• программирование
• веб-разработа
• gateway
• api
• sdk
• идемпотентность
• ssrХабы:• Программирование
• Разработка под e-commerce
• Laravel
• 1С-Битрикс
• Веб-разработка

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал