@Eugene Sukhodolskiy Eugene Sukhodolskiy authored 13 hours ago
examples feat: DX v0.1.2 — userId-параметр, waitForStatus, parseEventType/ack 13 hours ago
src feat: DX v0.1.2 — userId-параметр, waitForStatus, parseEventType/ack 13 hours ago
tests feat: DX v0.1.2 — userId-параметр, waitForStatus, parseEventType/ack 13 hours ago
.gitignore Initial client library skeleton (v0.1.0) 1 day ago
README.md feat: DX v0.1.2 — userId-параметр, waitForStatus, parseEventType/ack 13 hours ago
composer.json Initial client library skeleton (v0.1.0) 1 day ago
phpunit.xml.dist Initial client library skeleton (v0.1.0) 1 day ago
README.md

gnexus-synapse-client-php

Тонкий PHP-клиент Synapse (централизованный хаб уведомлений Gnexus) для сервисов экосистемы: gnexus-auth (Laravel), Navi-инстансы и другие PHP-сервисы. Обе стороны: отправка событий (Ingestion) и приём s2s-доставок (проверка подписи webhook'а). Порядок интеграции — docs/09-integration-guide.md в репо gn-synapse.

Клиент берёт на себя весь повторяемый шаблон: env-конфиг, сборку и локальную валидацию конверта, типизированные исключения по HTTP-статусам, fire-and-forget emit, batch, статус события. Ретраев и локальных очередей нет — Synapse принимает событие с ответом 202 мгновенно, всё остальное делает его воркер.

Транспорт инжектный (PSR-18 + PSR-17, например Guzzle — в require-dev для тестов и примеров, жёсткой зависимости нет).

Установка

В composer.json сервиса:

{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://git.gnexus.space/git/root/gn-synapse-client-php.git"
        }
    ]
}
composer require gnexus/synapse-client:^0.1.0

Quickstart

use GNexus\Synapse\Config\SynapseConfig;
use GNexus\Synapse\SynapseClient;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

$client = new SynapseClient(
    SynapseConfig::fromGlobals(),               // SYNAPSE_URL, SYNAPSE_API_KEY, ...
    new Client(['timeout' => 10.0]),            // PSR-18 (таймаут задаёт потребитель)
    new HttpFactory(),                          // PSR-17 RequestFactory
    new HttpFactory(),                          // PSR-17 StreamFactory
);

// Обычная отправка: событие обязательно дойти — ошибка бросает исключение
$event = $client->send('bugtrail', 'test', 'failed', 'high',
    ['user_id' => 'u-42'], dedupKey: "failed-42");
$event->id;        // uuid
$event->status;    // 'queued' — 202, воркер заберёт позже

// Fire-and-forget: упавший Synapse не должен ломать основной путь сервиса
$client->emit(null, 'user', 'password_changed', payload: ['user_id' => $sub]);
// → SentEvent|null; SynapseException и транспортные сбои ловятся,
//   пишутся в $logger->warning('synapse emit failed: ...', ...) и глотаются.

// Статус и доставки
$status = $client->status($event->id);
foreach ($status->deliveries as $d) { /* channel, target, status, attempts, error */ }

Конфигурация (env)

Переменная Обязательна По умолчанию Что делает
SYNAPSE_URL да — База Synapse (http://localhost:8013)
SYNAPSE_API_KEY да (для send/emit) — Ключ источника (syn_...)
SYNAPSE_TIMEOUT нет 10.0 Секунды (используется как fromGlobals()->timeoutSeconds)
SYNAPSE_DEFAULT_SOURCE нет — source по умолчанию: $client->send(null, ...)

SynapseConfig валидирует всё на конструировании (fail fast): пустой URL, схема не http(s), таймаут ≤ 0, ключ-пустая-строка → ConfigurationException. Send/sendBatch при отсутствии ключа тоже бросает ConfigurationException до HTTP.

default_source — смягчение антивспуфинга сервера: 403 получают вызовы, где source конверта не совпадает с источником ключа; с SYNAPSE_DEFAULT_SOURCE это ошибка конфигурации, а не кода.

Методы

  • send(...): SentEvent — конверт валидируется локально (без HTTP): имена ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ 1..64, priority ∈ low|normal|high|critical, ttlSeconds 1..604800, dedupKey ≤255 ('' → null), payload — assoc-массив. normal priority в конверт не попадает, null-поля не шлются (scheduledAt → UTC DATE_ATOM). Ошибки валидации — локальный ValidationException (statusCode = null: это ошибка кода, не Synapse).
  • emit(..., bool $throw = false): ?SentEvent — то же, но любые SynapseException/транспортные ошибки ловятся → $logger->warning(...) → null (throw: true → перебрасывает).
  • sendBatch(array $envelopes): BatchResult — каждый элемент прогоняется через EnvelopeBuilder::build(); локально битые — BatchRejection(index, detail) без HTTP; ответ сервера 202 и 422 («ни одного не принято») парсится одинаково — index серверного отклонения ремапится на исходную позицию; 5xx/транспорт — исключение целиком.
  • status(string $eventId): EventStatus — uuid-проверка, 404 → NotFoundException.
  • waitForStatus(string $eventId, array $statuses = ['done', 'failed'], float $timeoutSeconds = 30, float $intervalSeconds = 1): EventStatus — поллинг до целевого статуса (для приёмки/тестов, не бизнес-кода); морг транспорта не прерывает ожидание; не дождались — StatusTimeoutException.
  • send(..., ?string $userId = null) / emit(..., ?string $userId = null) — конвенция payload.user_id сразу параметром (перебивает payload'овский).
  • health(): array, ready(): array — /api/healthz и /api/readyz, без ключа.

Исключения (GNexus\Synapse\Exception\)

Класс Код Когда
SynapseException — База: .detail, .statusCode
ConfigurationException — Битый конфиг / нет ключа (до HTTP)
TransportException — Сеть упала (.transportError)
AuthException 401 Ключ нет/отозван/источник архивен
ForbiddenException 403 source ≠ источник ключа (антивспуфинг)
NotFoundException 404 Статус чужого/несуществующего события
ValidationException 422 / null Незарегистрированный тип или битый конверт (null = локально)
ServerException 5xx Synapse упал
StatusTimeoutException — waitForStatus не дождался целевого статуса за timeout
InvalidWebhookException — приём s2s: подпись/freshness/не-JSON — не прошло проверку WebhookVerifier

Ловить — одну SynapseException.

Приём s2s-доставки (webhook-приёмник)

Сервисы, которые и шлют события, и принимают доставки (Navi, …), используют тот же пакет для второй стороны — GNexus\Synapse\Webhook\WebhookVerifier:

use GNexus\Synapse\Webhook\WebhookVerifier;

public function synapseWebhook(Request $r): JsonResponse
{
    $envelope = WebhookVerifier::verify(
        $r->getContent(),                // raw body, до любого парсинга
        $r->headers->all(),
        env('S2S_SECRET_MY_REF'),        // per-target, docs/05
    );
    // $envelope['event_id'], $envelope['subject'], $envelope['payload'] ...
    return response()->json(['received' => true]);
}

Один вход: нет/битая подпись, чужой секрет, replay (тело старше maxSkew, дефолт 300 с), не-JSON — всё ловится одним InvalidWebhookException. Схема — одна на всю экосистему (зеркало WebhookSignature.php gnexus-auth и app/signature.py Synapse; перекрёстно проверено), сравнение через hash_equals. makeSignature() — для тестов приёмника.

Оговорки

  • Дедуп best-effort в окне 24ч: повтор возвращает первое событие с deduplicated: true. Не строить бизнес-логику на отсутствии дублей.
  • Инжектный PSR-18 без таймаута висит вечно (какой бы ни был timeoutSeconds) — в Guzzle и аналогах явно задавайте таймаут (Laravel-binding с таймаутом — examples/laravel/README.md).
  • scheduled_at — резерв MVP: принимается, не отправляется.
  • payload.user_id — непустая строка (uuid gnexus-auth); остальное — предупреждение в лог (сервер запишет address-доставки со статусом skipped).

Разработка

composer install
vendor/bin/phpunit

Интеграционный смок против живого стека — examples/plain-php/smoke.php.

Лицензия: проприетарная (внутри экосистемы Gnexus).