Sprint 0 / #19: OpenAPI 3.1 контракт API v1 + TS-клиент и контрактные тесты #57
@@ -0,0 +1 @@
|
||||
"""Роутеры API v1."""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Аутентификация UI и API: /api/v1/auth/*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from awg_api.deps import SESSION_COOKIE_NAME, StateDep, require_token
|
||||
from awg_api.schemas import AuthLoginRequest
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
TokenDep = Annotated[None, Depends(require_token)]
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: AuthLoginRequest, state: StateDep, response: Response) -> dict[str, Any]:
|
||||
session = state.login(payload.username, payload.password)
|
||||
csrf_token = session["csrf_token"]
|
||||
if isinstance(csrf_token, str):
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME, csrf_token, httponly=True, samesite="strict", path="/"
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(state: StateDep, _: TokenDep = None) -> dict[str, Any]:
|
||||
return state.logout()
|
||||
|
||||
|
||||
@router.get("/session")
|
||||
async def get_session(state: StateDep, _: TokenDep = None) -> dict[str, Any]:
|
||||
return state.get_session()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Конфигурация AmneziaWG: чтение, атомарное применение, сухая валидация."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from awg_api.deps import StateDep, require_token
|
||||
from awg_api.schemas import ConfigUpdateRequest
|
||||
|
||||
router = APIRouter(prefix="/api/v1/config", tags=["config"], dependencies=[Depends(require_token)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_config(state: StateDep) -> dict[str, Any]:
|
||||
return state.get_config()
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]:
|
||||
return state.apply_config(payload)
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_config(payload: ConfigUpdateRequest, state: StateDep) -> dict[str, Any]:
|
||||
return state.validate_config(payload)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Устройства и журнал: /api/v1/devices, /api/v1/logs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from awg_api.deps import StateDep, require_token
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1", tags=["devices", "logs"], dependencies=[Depends(require_token)]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
async def list_devices(state: StateDep) -> dict[str, Any]:
|
||||
return state.list_devices()
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def list_logs(
|
||||
state: StateDep,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
cursor: str | None = None,
|
||||
level: str | None = None,
|
||||
q: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return state.list_logs(limit=limit, cursor=cursor, level=level, query=q)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Служебные эндпоинты: /api/v1/health, /api/v1/ready, /metrics (без аутентификации)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from awg_api.deps import StateDep
|
||||
from awg_api.errors import error_payload
|
||||
|
||||
router = APIRouter(tags=["ops"])
|
||||
|
||||
|
||||
@router.get("/api/v1/health")
|
||||
async def get_health(state: StateDep) -> dict[str, Any]:
|
||||
return state.get_health()
|
||||
|
||||
|
||||
@router.get("/api/v1/ready")
|
||||
async def get_ready(state: StateDep) -> JSONResponse:
|
||||
if state.ready:
|
||||
return JSONResponse(status_code=200, content=state.get_ready())
|
||||
checks = state.get_ready()["checks"]
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content=error_payload(
|
||||
"agent_unavailable", "Шлюз не готов обслуживать запросы", {"checks": checks}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=PlainTextResponse)
|
||||
async def get_prometheus_metrics(state: StateDep) -> PlainTextResponse:
|
||||
return PlainTextResponse(state.prometheus_text())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Маршрутизация: правила, режим, GeoIP, импорт."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from awg_api.deps import StateDep, require_token
|
||||
from awg_api.schemas import (
|
||||
GeoIpUpdateRequest,
|
||||
ImportRequest,
|
||||
ModeUpdateRequest,
|
||||
RoutingRuleCreate,
|
||||
RoutingRuleUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/routing", tags=["routing"], dependencies=[Depends(require_token)]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_rules(
|
||||
state: StateDep,
|
||||
enabled: bool | None = None,
|
||||
kind: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return state.list_rules(enabled=enabled, kind=kind)
|
||||
|
||||
|
||||
@router.post("/rules", status_code=201)
|
||||
async def create_rule(payload: RoutingRuleCreate, state: StateDep) -> dict[str, Any]:
|
||||
return state.create_rule(payload)
|
||||
|
||||
|
||||
@router.get("/rules/{rule_id}")
|
||||
async def get_rule(rule_id: str, state: StateDep) -> dict[str, Any]:
|
||||
return state.get_rule(rule_id)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_rule(rule_id: str, payload: RoutingRuleUpdate, state: StateDep) -> dict[str, Any]:
|
||||
return state.update_rule(rule_id, payload)
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}", status_code=204)
|
||||
async def delete_rule(rule_id: str, state: StateDep) -> None:
|
||||
state.delete_rule(rule_id)
|
||||
|
||||
|
||||
@router.put("/mode")
|
||||
async def put_mode(payload: ModeUpdateRequest, state: StateDep) -> dict[str, Any]:
|
||||
return state.set_mode(payload.mode)
|
||||
|
||||
|
||||
@router.post("/geoip/update")
|
||||
async def update_geoip(
|
||||
state: StateDep, payload: GeoIpUpdateRequest | None = None
|
||||
) -> dict[str, Any]:
|
||||
force = bool(payload.force) if payload is not None else False
|
||||
return state.update_geoip(force=force)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_config(payload: ImportRequest, state: StateDep) -> dict[str, Any]:
|
||||
return state.import_config(payload)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Состояние туннеля: /api/v1/status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from awg_api.deps import StateDep, require_token
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["status"], dependencies=[Depends(require_token)])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(state: StateDep) -> dict[str, Any]:
|
||||
return state.get_status()
|
||||
@@ -0,0 +1 @@
|
||||
"""WebSocket-поток метрик."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""WebSocket-поток метрик: GET /api/v1/metrics/stream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from awg_api.deps import SESSION_COOKIE_NAME, token_matches
|
||||
from awg_api.state import MockState
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
WS_ACCESS_DENIED = 4401
|
||||
MIN_INTERVAL_MS = 250
|
||||
|
||||
|
||||
def _authorized(websocket: WebSocket, token: str | None) -> bool:
|
||||
expected: str = websocket.app.state.api_token
|
||||
header = websocket.headers.get("authorization", "")
|
||||
scheme, _, credentials = header.partition(" ")
|
||||
if scheme.lower() == "bearer" and token_matches(credentials.strip(), expected):
|
||||
return True
|
||||
if token_matches(token, expected):
|
||||
return True
|
||||
session_id = websocket.cookies.get(SESSION_COOKIE_NAME)
|
||||
state: MockState = websocket.app.state.mock_state
|
||||
return token_matches(session_id, state.session_token)
|
||||
|
||||
|
||||
@router.websocket("/api/v1/metrics/stream")
|
||||
async def stream_metrics(
|
||||
websocket: WebSocket, token: str | None = None, interval_ms: int = 1000
|
||||
) -> None:
|
||||
"""Отправляет кадры ``WsFrame``: метрики до отключения клиента."""
|
||||
if not _authorized(websocket, token):
|
||||
await websocket.close(code=WS_ACCESS_DENIED)
|
||||
return
|
||||
state: MockState = websocket.app.state.mock_state
|
||||
await websocket.accept()
|
||||
interval_s = max(MIN_INTERVAL_MS, interval_ms) / 1000
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json(state.metrics_frame())
|
||||
await asyncio.sleep(interval_s)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
Reference in New Issue
Block a user