Тонкий 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
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 */ }
| Переменная | Обязательна | По умолчанию | Что делает |
|---|---|---|---|
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.
Сервисы, которые и шлют события, и принимают доставки (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() — для тестов приёмника.
deduplicated: true. Не строить бизнес-логику на отсутствии дублей.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).