Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.contracts import CheckRequest, ErrorBody, ErrorEnvelope, Pending, Verdict
|
||||
from app.db import TaskStatus
|
||||
from app.rate_limit import RateLimited
|
||||
from app.repository import ConflictError
|
||||
from app.service import CapabilityUnavailable, SafetyService, TaskFailed
|
||||
|
||||
CHECK_ADAPTER = TypeAdapter(CheckRequest)
|
||||
MAX_BODY = 16_384
|
||||
|
||||
|
||||
def error(
|
||||
status: int, code: str, request_id: str, details: dict[str, object] | None = None
|
||||
) -> JSONResponse:
|
||||
body = ErrorEnvelope(
|
||||
error=ErrorBody(
|
||||
code=code,
|
||||
message={
|
||||
"validation_error": "Request is invalid",
|
||||
"service_unauthorized": "Service authentication failed",
|
||||
}.get(code, "Request could not be completed"),
|
||||
request_id=request_id,
|
||||
details=details or {},
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=status, content=body.model_dump(mode="json"))
|
||||
|
||||
|
||||
def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
app = FastAPI(title="HAN Message Safety", version="2.0.0", docs_url=None, redoc_url=None)
|
||||
|
||||
async def authenticate(
|
||||
request: Request,
|
||||
provided: Annotated[str | None, Header(alias="X-Service-Token")] = None,
|
||||
) -> None:
|
||||
if not provided or not hmac.compare_digest(provided.encode(), token.encode()):
|
||||
request.state.auth_failed = True
|
||||
raise PermissionError
|
||||
|
||||
@app.exception_handler(PermissionError)
|
||||
async def auth_error(request: Request, _: PermissionError) -> JSONResponse:
|
||||
return error(401, "service_unauthorized", request.state.request_id)
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_context(request: Request, call_next):
|
||||
supplied = request.headers.get("X-Request-ID")
|
||||
try:
|
||||
request.state.request_id = str(uuid.UUID(supplied)) if supplied else str(uuid.uuid4())
|
||||
except ValueError:
|
||||
request.state.request_id = str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready() -> JSONResponse:
|
||||
mode = "mock" if service.mode.mock else "standard"
|
||||
components = {
|
||||
"postgres": "ok",
|
||||
"redis": "degraded",
|
||||
"s3_quarantine": "bypassed"
|
||||
if service.mode.mock
|
||||
else ("ok" if service.files_ready else "down"),
|
||||
"worker": "bypassed" if service.mode.mock else "ok",
|
||||
"antivirus": "bypassed"
|
||||
if service.mode.mock
|
||||
else ("ok" if service.files_ready else "down"),
|
||||
"dns": "bypassed" if service.mode.mock else ("ok" if service.links_ready else "down"),
|
||||
"rules": "bypassed" if service.mode.mock else "ok",
|
||||
}
|
||||
capabilities = {
|
||||
"text": "ready",
|
||||
"links": "bypassed"
|
||||
if service.mode.mock
|
||||
else ("ready" if service.links_ready else "unavailable"),
|
||||
"files": "bypassed"
|
||||
if service.mode.mock
|
||||
else ("ready" if service.files_ready else "unavailable"),
|
||||
"worker": "bypassed" if service.mode.mock else "ready",
|
||||
}
|
||||
body: dict[str, object] = {
|
||||
"status": "degraded"
|
||||
if service.mode.mock or "degraded" in components.values()
|
||||
else "ok",
|
||||
"processing_mode": mode,
|
||||
"config_version": service.config.version,
|
||||
"components": components,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
if service.mode.mock:
|
||||
body["mock_policy"] = {
|
||||
"text": "allow" if service.mode.text_free else "deny",
|
||||
"file": "allow" if service.mode.file_free else "deny",
|
||||
}
|
||||
return JSONResponse(content=body)
|
||||
|
||||
@app.post("/internal/safety/v2/messages/check", dependencies=[Depends(authenticate)])
|
||||
async def check(request: Request) -> JSONResponse:
|
||||
content_type = request.headers.get("content-type", "").lower().replace(" ", "")
|
||||
if content_type not in {"application/json", "application/json;charset=utf-8"}:
|
||||
return error(
|
||||
400,
|
||||
"validation_error",
|
||||
request.state.request_id,
|
||||
{"field": "content-type", "constraint": "application/json; charset=utf-8"},
|
||||
)
|
||||
body = await request.body()
|
||||
if len(body) > MAX_BODY:
|
||||
return error(
|
||||
400,
|
||||
"validation_error",
|
||||
request.state.request_id,
|
||||
{"field": "body", "constraint": "max_bytes"},
|
||||
)
|
||||
try:
|
||||
payload = CHECK_ADAPTER.validate_json(body, strict=True)
|
||||
result = await service.check(payload)
|
||||
except (ValidationError, json.JSONDecodeError, ValueError):
|
||||
return error(
|
||||
400,
|
||||
"validation_error",
|
||||
request.state.request_id,
|
||||
{"field": "body", "constraint": "strict_dto"},
|
||||
)
|
||||
except ConflictError:
|
||||
message_id = "unknown"
|
||||
try:
|
||||
message_id = str(json.loads(body).get("message_id", "unknown"))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return error(
|
||||
409,
|
||||
"safety_request_conflict",
|
||||
request.state.request_id,
|
||||
{"message_id": message_id, "terminal": True, "retryable": False},
|
||||
)
|
||||
except CapabilityUnavailable as exc:
|
||||
return error(
|
||||
503,
|
||||
"dependency_unavailable",
|
||||
request.state.request_id,
|
||||
{"dependency_category": exc.category, "terminal": False, "retryable": True},
|
||||
)
|
||||
except RateLimited as exc:
|
||||
response = error(
|
||||
429,
|
||||
"rate_limit_exceeded",
|
||||
request.state.request_id,
|
||||
{"retryable": True, "retry_after_sec": exc.retry_after},
|
||||
)
|
||||
response.headers["Retry-After"] = str(exc.retry_after)
|
||||
return response
|
||||
except TaskFailed as exc:
|
||||
return error(
|
||||
503,
|
||||
"task_failed",
|
||||
request.state.request_id,
|
||||
{
|
||||
"task_id": str(exc.task_id),
|
||||
"task_status": "failed",
|
||||
"terminal": True,
|
||||
"retryable": False,
|
||||
},
|
||||
)
|
||||
status = 202 if isinstance(result, Pending) else (200 if result.verdict == "allow" else 403)
|
||||
response = JSONResponse(status_code=status, content=result.model_dump(mode="json"))
|
||||
if isinstance(result, Pending):
|
||||
response.headers["Location"] = f"/internal/safety/v2/messages/tasks/{result.task_id}"
|
||||
response.headers["Retry-After"] = str(result.poll_after_ms // 1000)
|
||||
return response
|
||||
|
||||
@app.get("/internal/safety/v2/messages/tasks/{task_id}", dependencies=[Depends(authenticate)])
|
||||
async def get_task(request: Request, task_id: str) -> JSONResponse:
|
||||
try:
|
||||
parsed = uuid.UUID(task_id)
|
||||
except ValueError:
|
||||
return error(
|
||||
400,
|
||||
"validation_error",
|
||||
request.state.request_id,
|
||||
{"field": "task_id", "constraint": "uuid"},
|
||||
)
|
||||
task = await service.repository.task(parsed)
|
||||
if not task:
|
||||
return error(404, "task_not_found", request.state.request_id)
|
||||
if task.status == TaskStatus.failed:
|
||||
return error(
|
||||
503,
|
||||
"task_failed",
|
||||
request.state.request_id,
|
||||
{
|
||||
"task_id": str(task.id),
|
||||
"task_status": "failed",
|
||||
"terminal": True,
|
||||
"retryable": False,
|
||||
},
|
||||
)
|
||||
if task.status in {TaskStatus.pending, TaskStatus.processing}:
|
||||
result: Pending | Verdict = Pending(
|
||||
config_version=task.config_version,
|
||||
task_id=task.id,
|
||||
expires_at=task.expires_at,
|
||||
rules_version=task.rules_version,
|
||||
)
|
||||
else:
|
||||
result = service._verdict(
|
||||
task.status == TaskStatus.allowed,
|
||||
task.processing_mode,
|
||||
task.rule_id or "safety.all_checks_passed",
|
||||
task.rules_version,
|
||||
config_version=task.config_version,
|
||||
)
|
||||
status = 202 if isinstance(result, Pending) else (200 if result.verdict == "allow" else 403)
|
||||
response = JSONResponse(status_code=status, content=result.model_dump(mode="json"))
|
||||
if isinstance(result, Pending):
|
||||
response.headers["Location"] = str(request.url.path)
|
||||
response.headers["Retry-After"] = "2"
|
||||
return response
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user