<?php
declare(strict_types=1);
namespace GNexus\Synapse\Envelope;
use GNexus\Synapse\Exception\ValidationException;
use Psr\Log\LoggerInterface;
/**
* Сборка и локальная валидация конверта события — зеркало app/api/schemas.py
* сервера (контракт v1, docs/05-ingestion-api.md). Клиент валидирует до HTTP:
* ошибка кода видна сразу, без round-trip'а, как ValidationException со
* statusCode = null.
*/
final class EnvelopeBuilder
{
public const NAME_PATTERN = '#^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$#';
public const NAME_MAX = 64;
public const PRIORITY_VALUES = ['low', 'normal', 'high', 'critical'];
public const TTL_MAX = 604800; // 7 суток, >= 1
public const DEDUP_MAX = 255;
/**
* Собрать конверт v1; лишнего в нём нет (сервер extra="forbid").
*
* @param array<string, mixed>|null $payload JSON-объект
* @return array<string, mixed>
*/
public static function build(
string $source,
string $subject,
string $action,
string $priority = 'normal',
?array $payload = null,
?string $dedupKey = null,
?int $ttlSeconds = null,
?\DateTimeInterface $scheduledAt = null,
): array {
$errors = [];
foreach (['source' => $source, 'subject' => $subject, 'action' => $action] as $kind => $name) {
$err = self::nameError($kind, $name);
if ($err !== null) {
$errors[] = $err;
}
}
if (!in_array($priority, self::PRIORITY_VALUES, true)) {
$errors[] = sprintf(
"недопустимый priority='%s': разрешены %s",
$priority,
implode(', ', self::PRIORITY_VALUES),
);
}
if ($dedupKey === '') {
$dedupKey = null; // пустая строка = не задаём
} elseif ($dedupKey !== null && strlen($dedupKey) > self::DEDUP_MAX) {
$errors[] = sprintf('dedup_key длиннее %d символов', self::DEDUP_MAX);
}
if ($ttlSeconds !== null && ($ttlSeconds < 1 || $ttlSeconds > self::TTL_MAX)) {
$errors[] = sprintf('ttlSeconds=%d: допустимо 1..%d', $ttlSeconds, self::TTL_MAX);
}
if ($errors !== []) {
throw new ValidationException(implode('; ', $errors));
}
$envelope = [
'source' => $source,
'subject' => $subject,
'action' => $action,
];
if ($priority !== 'normal') {
$envelope['priority'] = $priority; // серверный дефолт не шлём
}
if ($payload !== null) {
if ($payload === [] || array_is_list($payload)) {
throw new ValidationException('payload должен быть словарем (JSON-объектом)');
}
$envelope['payload'] = $payload;
}
if ($dedupKey !== null) {
$envelope['dedup_key'] = $dedupKey;
}
if ($ttlSeconds !== null) {
$envelope['ttl_seconds'] = $ttlSeconds;
}
if ($scheduledAt !== null) {
$envelope['scheduled_at'] = $scheduledAt
->setTimezone(new \DateTimeZone('UTC'))
->format(DATE_ATOM);
}
return $envelope;
}
/**
* Конвенция payload.user_id (= sub gnexus-auth) — «о ком событие».
* Сервер не валидирует payload: не-строчный/пустой user_id запишет адресные
* доставки как skipped. Warning, не ошибка.
*
* @param array<string, mixed> $payload
*/
public static function warnBadUserId(array $payload, LoggerInterface $logger): void
{
$uid = $payload['user_id'] ?? null;
if ((is_string($uid) && $uid !== '') || (is_int($uid) && !is_bool($uid))) {
return;
}
$logger->warning(
'payload.user_id должен быть непустой строкой (или числом); получено {uid} — адресные доставки будут skipped',
['uid' => $uid],
);
}
private static function nameError(string $kind, string $value): ?string
{
if ($value === '') {
return sprintf("недопустимое %s='': строка 1..%d символов", $kind, self::NAME_MAX);
}
if (strlen($value) > self::NAME_MAX) {
return sprintf("недопустимое %s='%s': длина > %d", $kind, $value, self::NAME_MAX);
}
if (preg_match(self::NAME_PATTERN, $value) !== 1) {
return sprintf(
"недопустимое %s='%s': не совпадает с шаблоном %s",
$kind,
$value,
self::NAME_PATTERN,
);
}
return null;
}
}