"""Pydantic-модели запросов, зеркалящие схемы ``docs/openapi.yaml``. Соответствие проверяется тестом ``test_request_models_match_spec``: наборы полей, обязательность и базовые типы моделей обязаны соответствовать схемам контракта. """ from __future__ import annotations from typing import Literal, Self from pydantic import BaseModel, ConfigDict, Field, model_validator RoutingMode = Literal["geoip", "dns", "all-vpn", "direct-only"] RuleKind = Literal["domain", "cidr", "geoip", "asn"] RuleAction = Literal["vpn", "direct"] Ipv6Policy = Literal["direct", "tunnel", "block"] DnsMode = Literal["internal", "external"] GeoIpSource = Literal["ripe-stat", "cache", "none"] ImportFormat = Literal["amneziawg-conf", "wireguard-conf", "json"] class StrictModel(BaseModel): """Модель запроса: лишние поля запрещены (``additionalProperties: false`` в контракте).""" model_config = ConfigDict(extra="forbid") class GeoIpStatusIn(StrictModel): source: GeoIpSource prefix_count: int = Field(ge=0) updated_at: str | None class TunnelConfigIn(StrictModel): address: str listen_port: int = Field(ge=1, le=65535) mtu: int = Field(ge=576, le=1500) private_key: str = Field(json_schema_extra={"writeOnly": True}) dns: list[str] | None = None class PeerConfigIn(StrictModel): public_key: str preshared_key: str | None = Field(default=None, json_schema_extra={"writeOnly": True}) endpoint: str allowed_ips: list[str] persistent_keepalive: int | None = Field(default=None, ge=0, le=65535) class DnsConfigIn(StrictModel): mode: DnsMode servers: list[str] class RoutingConfigIn(StrictModel): mode: RoutingMode kill_switch: bool mtu_clamping: bool ipv6: Ipv6Policy geoip: GeoIpStatusIn class ConfigUpdateRequest(StrictModel): """Частичное обновление конфигурации (merge в текущее состояние).""" tunnel: TunnelConfigIn | None = None peers: list[PeerConfigIn] | None = None dns: DnsConfigIn | None = None routing: RoutingConfigIn | None = None @model_validator(mode="after") def _require_any_field(self) -> Self: if not self.model_fields_set: raise ValueError("нужно передать хотя бы одно поле конфигурации") return self class RoutingRuleCreate(StrictModel): name: str = Field(min_length=1, max_length=128) kind: RuleKind value: str = Field(min_length=1) action: RuleAction enabled: bool = True priority: int = Field(default=100, ge=0, le=10000) comment: str | None = None class RoutingRuleUpdate(StrictModel): name: str | None = Field(default=None, min_length=1, max_length=128) kind: RuleKind | None = None value: str | None = Field(default=None, min_length=1) action: RuleAction | None = None enabled: bool | None = None priority: int | None = Field(default=None, ge=0, le=10000) comment: str | None = None @model_validator(mode="after") def _require_any_field(self) -> Self: if not self.model_fields_set: raise ValueError("нужно передать хотя бы одно поле правила") return self class ModeUpdateRequest(StrictModel): mode: RoutingMode class GeoIpUpdateRequest(StrictModel): force: bool = False class ImportRequest(StrictModel): format: ImportFormat content: str = Field(min_length=1) apply: bool = False class AuthLoginRequest(StrictModel): username: str = Field(min_length=1) password: str = Field(min_length=1, json_schema_extra={"writeOnly": True})