"""Сборка и локальная валидация конверта события — зеркало 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