Newer
Older
gn-synapse-client-php / src / Envelope / EnvelopeBuilder.php
@Eugene Sukhodolskiy Eugene Sukhodolskiy 1 day ago 4 KB Initial client library skeleton (v0.1.0)
<?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;
    }
}