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 — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья