# 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` сервиса:

```json
{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://git.gnexus.space/git/root/gn-synapse-client-php.git"
        }
    ]
}
```

```bash
composer require gnexus/synapse-client:^0.1.0
```

## Quickstart

```php
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`.
- `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 упал |
| `InvalidWebhookException` | — | приём s2s: подпись/freshness/не-JSON — не прошло проверку `WebhookVerifier` |

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

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

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

```php
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`).

## Разработка

```bash
composer install
vendor/bin/phpunit
```

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

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