Newer
Older
gn-synapse-client-py / src / gnexus_synapse / envelope.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 1 day ago 5 KB Initial client library skeleton (v0.1.0)
"""Сборка и локальная валидация конверта события — зеркало app/api/schemas.py сервера.

Клиент валидирует конверт до HTTP: ошибка кода видна сразу, без round-trip'а,
и приходит как SynapseValidationError со статусом None. Правила держим один
в один с сервером — контракт v1: docs/05-ingestion-api.md.
"""

from __future__ import annotations

import logging
import re
from datetime import UTC, datetime
from typing import Any, Final

from .exceptions import SynapseValidationError

NAME_PATTERN: Final = r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$"
NAME_MAX: Final = 64
PRIORITY_VALUES: Final = ("low", "normal", "high", "critical")
TTL_MAX: Final = 604800  # 7 суток, ge=1
DEDUP_MAX: Final = 255

_NAME_RE = re.compile(NAME_PATTERN)


def validate_name(kind: str, value: str) -> None:
    """Один символ имени конверта (source/subject/action)."""
    if not isinstance(value, str) or len(value) == 0 or len(value) > NAME_MAX:
        raise ValueError(f"недопустимое {kind}={value!r}: строка 1..{NAME_MAX} символов")
    if _NAME_RE.fullmatch(value) is None:
        raise ValueError(
            f"недопустимое {kind}={value!r}: не совпадает с шаблоном {NAME_PATTERN}"
        )


def validate_names(source: str, subject: str, action: str) -> None:
    validate_name("source", source)
    validate_name("subject", subject)
    validate_name("action", action)


def warn_bad_user_id(payload: dict[str, Any], logger: logging.Logger) -> None:
    """Конвенция payload.user_id (= sub gnexus-auth) — «о ком событие».

    Сервер payload не валидирует: не-строчный/пустой user_id не ломает приём,
    но правила без user_id-целей доставят событие «только админу», а целевые
    каналы запишут skipped. Поэтому warning, не ошибка.
    """
    uid = payload.get("user_id")
    if isinstance(uid, str) and uid:
        return
    if isinstance(uid, int) and not isinstance(uid, bool):  # сервер сам приводит к строке
        return
    logger.warning(
        "payload.user_id должен быть непустой строкой (или числом); "
        "получено %r — адресные доставки будут skipped",
        uid,
    )


def build_envelope(
    source: str,
    subject: str,
    action: str,
    *,
    priority: str = "normal",
    payload: dict[str, Any] | None = None,
    dedup_key: str | None = None,
    ttl_seconds: int | None = None,
    scheduled_at: datetime | None = None,
) -> dict[str, Any]:
    """Собрать конверт v1; лишнего в нём нет (сервер extra='forbid')."""
    errors: list[str] = []
    try:
        validate_names(source, subject, action)
    except ValueError as ex:
        errors.append(str(ex))
    if priority not in PRIORITY_VALUES:
        errors.append(
            f"недопустимый priority={priority!r}: разрешены {', '.join(PRIORITY_VALUES)}"
        )
    if dedup_key is not None and not isinstance(dedup_key, str):
        errors.append("dedup_key должен быть строкой или None")
    elif dedup_key == "":  # пустая строка = не задаём
        dedup_key = None
    elif dedup_key is not None and len(dedup_key) > DEDUP_MAX:
        errors.append(f"dedup_key длиннее {DEDUP_MAX} символов")
    if ttl_seconds is not None:
        if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int):
            errors.append("ttl_seconds должен быть целым числом или None")
        elif not 1 <= ttl_seconds <= TTL_MAX:
            errors.append(f"ttl_seconds={ttl_seconds}: допустимо 1..{TTL_MAX}")
    if errors:
        raise SynapseValidationError("; ".join(errors))

    envelope: dict[str, Any] = {
        "source": source,
        "subject": subject,
        "action": action,
    }
    if priority != "normal":
        envelope["priority"] = priority
    if payload is not None:
        if not isinstance(payload, dict):
            raise SynapseValidationError("payload должен быть словарем (JSON-объектом)")
        if payload:
            envelope["payload"] = payload
    if dedup_key is not None:
        envelope["dedup_key"] = dedup_key
    if ttl_seconds is not None:
        envelope["ttl_seconds"] = ttl_seconds
    if scheduled_at is not None:
        if not isinstance(scheduled_at, datetime):
            raise SynapseValidationError("scheduled_at должен быть datetime или None")
        # сервер ждёт ISO-8601: наивное время считаем UTC (сразу предупреждаем —
        # локальное время источника почти наверняка ошибка)
        if scheduled_at.tzinfo is None:
            scheduled_at = scheduled_at.replace(tzinfo=UTC)
        envelope["scheduled_at"] = scheduled_at.isoformat()
    return envelope