feat(backend): 重构 HTTP 错误处理为 RFC7807 标准并优化多个 service
This commit is contained in:
@@ -20,6 +20,16 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli
|
||||
- Use project logging (`core.logging`), never `print()` in runtime code.
|
||||
- HTTP errors must follow RFC 7807 (`application/problem+json`).
|
||||
|
||||
## HTTP Error Contract (Must)
|
||||
|
||||
- Backend must construct error payload using RFC7807 fields plus stable extension fields: `code` and optional `params`.
|
||||
- `code` must be machine-readable `UPPER_SNAKE_CASE`; do not use free-text `detail` as the only contract.
|
||||
- Error code registry source of truth: `docs/protocols/common/http-error-codes.md`.
|
||||
- Any create/modify/deprecate of error codes must update `docs/protocols/common/http-error-codes.md` in the same change.
|
||||
- Keep response media type as `application/problem+json`.
|
||||
- Long-term layering target: HTTP transport details stay in routers/global handlers; service/repository/dependencies should raise domain errors (`ApiProblemError` or domain-specific exceptions), not `HTTPException`.
|
||||
- When refactoring existing code, prefer replacing `HTTPException` in service/repository/dependencies with `ApiProblemError` while preserving status/code semantics.
|
||||
|
||||
## Configuration & Secrets
|
||||
|
||||
- Read env only through `core.config.settings` (`Settings` / `config`).
|
||||
|
||||
+37
-1
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from core.config.settings import config
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.http.response import build_problem_details
|
||||
from core.logging import configure_logging, get_logger, log_service_banner
|
||||
from services.base import close_registered_services, initialize_registered_services
|
||||
@@ -79,6 +80,19 @@ def _build_http_error_response(
|
||||
) -> JSONResponse:
|
||||
instance = request.url.path
|
||||
detail_text = detail if isinstance(detail, str) else "Request failed"
|
||||
error_code: str | None = None
|
||||
error_params: dict[str, Any] | None = None
|
||||
|
||||
if isinstance(detail, dict):
|
||||
raw_detail = detail.get("detail")
|
||||
raw_code = detail.get("code")
|
||||
raw_params = detail.get("params")
|
||||
if isinstance(raw_detail, str) and raw_detail.strip():
|
||||
detail_text = raw_detail
|
||||
if isinstance(raw_code, str) and raw_code.strip():
|
||||
error_code = raw_code
|
||||
if isinstance(raw_params, dict):
|
||||
error_params = raw_params
|
||||
logger.warning(
|
||||
"HTTP error",
|
||||
status_code=status_code,
|
||||
@@ -91,6 +105,8 @@ def _build_http_error_response(
|
||||
status_code=status_code,
|
||||
detail=detail_text,
|
||||
instance=instance,
|
||||
code=error_code,
|
||||
params=error_params,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
@@ -170,3 +186,23 @@ async def unhandled_exception_handler(
|
||||
content=problem.model_dump(),
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(ApiProblemError)
|
||||
async def api_problem_exception_handler(
|
||||
request: Request,
|
||||
exc: ApiProblemError,
|
||||
) -> JSONResponse:
|
||||
instance = request.url.path
|
||||
problem = build_problem_details(
|
||||
status_code=exc.status_code,
|
||||
detail=exc.detail,
|
||||
instance=instance,
|
||||
code=exc.code,
|
||||
params=exc.params,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=problem.model_dump(),
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ApiProblemError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int,
|
||||
detail: str | dict[str, Any],
|
||||
code: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
resolved_detail = detail
|
||||
resolved_code = code
|
||||
resolved_params = params
|
||||
|
||||
if isinstance(detail, dict):
|
||||
payload = detail
|
||||
resolved_code = resolved_code or str(
|
||||
payload.get("code") or "INTERNAL_ERROR"
|
||||
)
|
||||
resolved_detail = str(payload.get("detail") or "Request failed")
|
||||
raw_params = payload.get("params")
|
||||
if resolved_params is None and isinstance(raw_params, dict):
|
||||
resolved_params = raw_params
|
||||
|
||||
if not isinstance(resolved_detail, str):
|
||||
resolved_detail = str(resolved_detail)
|
||||
if not resolved_code or not isinstance(resolved_code, str):
|
||||
resolved_code = "INTERNAL_ERROR"
|
||||
|
||||
super().__init__(resolved_detail)
|
||||
self.status_code = status_code
|
||||
self.code = resolved_code
|
||||
self.detail = resolved_detail
|
||||
self.params = resolved_params
|
||||
|
||||
|
||||
def problem_payload(
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"code": code, "detail": detail}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
return payload
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -11,6 +12,8 @@ class ProblemDetails(BaseModel):
|
||||
status: int
|
||||
detail: str
|
||||
instance: str | None = None
|
||||
code: str | None = None
|
||||
params: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def build_problem_details(
|
||||
@@ -20,6 +23,8 @@ def build_problem_details(
|
||||
type_value: str = "about:blank",
|
||||
title: str | None = None,
|
||||
instance: str | None = None,
|
||||
code: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> ProblemDetails:
|
||||
resolved_title = title or HTTPStatus(status_code).phrase
|
||||
return ProblemDetails(
|
||||
@@ -28,4 +33,6 @@ def build_problem_details(
|
||||
status=status_code,
|
||||
detail=detail,
|
||||
instance=instance,
|
||||
code=code,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -7,10 +7,9 @@ from pathlib import Path
|
||||
|
||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from core.automation.scheduler import run_automation_scheduler_scan
|
||||
from core.config.settings import config
|
||||
from core.config.initial.init_data import initialize_data
|
||||
from core.config.settings import config
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger("core.runtime.cli")
|
||||
@@ -107,10 +106,6 @@ async def bootstrap() -> bool:
|
||||
|
||||
|
||||
async def run_automation_scheduler_forever() -> None:
|
||||
if config.runtime.environment == "dev":
|
||||
logger.info("Automation scheduler skipped in dev environment")
|
||||
return
|
||||
|
||||
if not config.automation_scheduler.enabled:
|
||||
logger.info("Automation scheduler disabled by config")
|
||||
return
|
||||
|
||||
@@ -5,10 +5,10 @@ from decimal import Decimal
|
||||
from typing import Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.http.errors import ApiProblemError
|
||||
from models.agent_chat_message import AgentChatMessage
|
||||
from models.agent_chat_session import AgentChatSession
|
||||
from models.system_agents import SystemAgents
|
||||
@@ -39,14 +39,22 @@ class AgentRepository:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
stmt = select(AgentChatSession.user_id).where(
|
||||
AgentChatSession.id == session_uuid
|
||||
)
|
||||
owner_id = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if owner_id is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
code="AGENT_SESSION_NOT_FOUND",
|
||||
detail="Session not found",
|
||||
)
|
||||
return str(owner_id)
|
||||
|
||||
async def create_session_for_user(
|
||||
@@ -55,14 +63,20 @@ class AgentRepository:
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid user_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_USER_ID_INVALID",
|
||||
detail="Invalid user_id",
|
||||
) from exc
|
||||
session_uuid = None
|
||||
if session_id is not None:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid session_id"
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
session = AgentChatSession(
|
||||
@@ -84,7 +98,11 @@ class AgentRepository:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
session = await self._session.get(AgentChatSession, session_uuid)
|
||||
if session is not None:
|
||||
await self._session.delete(session)
|
||||
@@ -103,7 +121,11 @@ class AgentRepository:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
stmt = (
|
||||
select(AgentChatSession)
|
||||
@@ -112,7 +134,11 @@ class AgentRepository:
|
||||
)
|
||||
session_row = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if session_row is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
code="AGENT_SESSION_NOT_FOUND",
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
next_seq = int(session_row.message_count or 0) + 1
|
||||
if not _has_title(session_row.title):
|
||||
@@ -144,7 +170,11 @@ class AgentRepository:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
before_start = (
|
||||
datetime.combine(before, time.min, tzinfo=timezone.utc)
|
||||
@@ -224,7 +254,11 @@ class AgentRepository:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
safe_user_limit = max(int(user_message_limit), 1)
|
||||
message_stmt = (
|
||||
@@ -265,7 +299,11 @@ class AgentRepository:
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid user_id") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_USER_ID_INVALID",
|
||||
detail="Invalid user_id",
|
||||
) from exc
|
||||
stmt = (
|
||||
select(AgentChatSession.id)
|
||||
.where(AgentChatSession.user_id == user_uuid)
|
||||
|
||||
@@ -9,6 +9,7 @@ from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from ag_ui.core import RunAgentInput
|
||||
from core.http.errors import problem_payload
|
||||
from core.agentscope.events import to_sse_event
|
||||
from core.agentscope.schemas.agui_input import (
|
||||
parse_run_input,
|
||||
@@ -131,11 +132,17 @@ async def enqueue_run(
|
||||
try:
|
||||
request = parse_run_input(request.model_dump(by_alias=True, exclude_none=True))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=problem_payload(code="AGENT_RUN_INPUT_INVALID", detail=str(exc)),
|
||||
) from exc
|
||||
try:
|
||||
validate_run_request_messages_contract(request)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=problem_payload(code="AGENT_RUN_MESSAGES_INVALID", detail=str(exc)),
|
||||
) from exc
|
||||
task = await service.enqueue_run(
|
||||
run_input=request,
|
||||
current_user=current_user,
|
||||
@@ -188,11 +195,23 @@ async def stream_events(
|
||||
if last_event_id is not None and (
|
||||
len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Invalid Last-Event-ID")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_INVALID_LAST_EVENT_ID",
|
||||
detail="Invalid Last-Event-ID",
|
||||
),
|
||||
)
|
||||
|
||||
sse_slot_acquired = await _acquire_sse_slot(user_id=str(current_user.id))
|
||||
if not sse_slot_acquired:
|
||||
raise HTTPException(status_code=429, detail="Too many SSE connections")
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SSE_CONNECTION_LIMIT",
|
||||
detail="Too many SSE connections",
|
||||
),
|
||||
)
|
||||
|
||||
async def _event_iter() -> AsyncIterator[str]:
|
||||
cursor = last_event_id
|
||||
@@ -283,9 +302,22 @@ async def upload_attachment(
|
||||
) -> AttachmentUploadResponse:
|
||||
payload = await file.read()
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty attachment")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_EMPTY",
|
||||
detail="Empty attachment",
|
||||
),
|
||||
)
|
||||
if len(payload) > _MAX_ATTACHMENT_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Attachment too large")
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_TOO_LARGE",
|
||||
detail="Attachment too large",
|
||||
params={"maxBytes": _MAX_ATTACHMENT_UPLOAD_BYTES},
|
||||
),
|
||||
)
|
||||
attachment = await service.upload_attachment(
|
||||
thread_id=thread_id,
|
||||
filename=file.filename,
|
||||
@@ -330,7 +362,13 @@ async def transcribe(
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Unsupported audio format")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="AGENT_AUDIO_UNSUPPORTED_FORMAT",
|
||||
detail="Unsupported audio format",
|
||||
),
|
||||
)
|
||||
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
@@ -343,7 +381,14 @@ async def transcribe(
|
||||
and declared_length
|
||||
> _MAX_TRANSCRIBE_AUDIO_BYTES + _MULTIPART_OVERHEAD_BYTES
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Audio file too large")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="AGENT_AUDIO_TOO_LARGE",
|
||||
detail="Audio file too large",
|
||||
params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES},
|
||||
),
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
|
||||
temp_path = tmp_file.name
|
||||
@@ -356,16 +401,35 @@ async def transcribe(
|
||||
break
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Audio file too large")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="AGENT_AUDIO_TOO_LARGE",
|
||||
detail="Audio file too large",
|
||||
params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES},
|
||||
),
|
||||
)
|
||||
if len(header) < _WAV_HEADER_MIN_BYTES:
|
||||
required = _WAV_HEADER_MIN_BYTES - len(header)
|
||||
header.extend(chunk[:required])
|
||||
tmp_file.write(chunk)
|
||||
|
||||
if total_bytes == 0:
|
||||
raise HTTPException(status_code=400, detail="Empty audio file")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="AGENT_AUDIO_EMPTY",
|
||||
detail="Empty audio file",
|
||||
),
|
||||
)
|
||||
if not _looks_like_wav_header(bytes(header)):
|
||||
raise HTTPException(status_code=400, detail="Unsupported audio format")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="AGENT_AUDIO_UNSUPPORTED_FORMAT",
|
||||
detail="Unsupported audio format",
|
||||
),
|
||||
)
|
||||
|
||||
transcript = await asr_service.transcribe_file(
|
||||
temp_path, audio.filename or "unknown"
|
||||
@@ -376,7 +440,13 @@ async def transcribe(
|
||||
except HTTPException:
|
||||
raise
|
||||
except RuntimeError:
|
||||
raise HTTPException(status_code=502, detail="ASR service unavailable")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ASR_UNAVAILABLE",
|
||||
detail="ASR service unavailable",
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await audio.close()
|
||||
if temp_path:
|
||||
|
||||
+134
-29
@@ -6,9 +6,9 @@ import hashlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ag_ui.core import RunAgentInput
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from core.auth.models import CurrentUser
|
||||
from core.agentscope.caches.context_messages_cache import (
|
||||
create_context_messages_cache,
|
||||
@@ -48,7 +48,10 @@ logger = get_logger(__name__)
|
||||
|
||||
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
|
||||
if owner_id != str(current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
|
||||
)
|
||||
|
||||
|
||||
class AgentService:
|
||||
@@ -86,7 +89,10 @@ class AgentService:
|
||||
try:
|
||||
runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(code="AGENT_PAYLOAD_INVALID", detail=str(exc)),
|
||||
) from exc
|
||||
|
||||
if runtime_config is None:
|
||||
from v1.agent.system_agents_config import (
|
||||
@@ -97,7 +103,7 @@ class AgentService:
|
||||
|
||||
try:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
except HTTPException as exc:
|
||||
except ApiProblemError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
try:
|
||||
@@ -249,9 +255,12 @@ class AgentService:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail="Attachment storage unavailable",
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
|
||||
detail="Attachment storage unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -268,12 +277,25 @@ class AgentService:
|
||||
)
|
||||
)
|
||||
if len(user_attachments) > MAX_ATTACHMENTS_PER_MESSAGE:
|
||||
raise HTTPException(status_code=422, detail="Too many attachments")
|
||||
except HTTPException:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENTS_TOO_MANY",
|
||||
detail="Too many attachments",
|
||||
params={"max": MAX_ATTACHMENTS_PER_MESSAGE},
|
||||
),
|
||||
)
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Failed to parse signed URL", url=url, error=str(exc))
|
||||
raise HTTPException(status_code=422, detail="Invalid signed image url")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SIGNED_IMAGE_URL_INVALID",
|
||||
detail="Invalid signed image url",
|
||||
),
|
||||
)
|
||||
|
||||
metadata: AgentChatMessageMetadata | None = None
|
||||
if user_attachments:
|
||||
@@ -295,7 +317,7 @@ class AgentService:
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
except HTTPException as exc:
|
||||
except ApiProblemError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
try:
|
||||
@@ -311,19 +333,48 @@ class AgentService:
|
||||
else:
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Attachment storage unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
|
||||
detail="Attachment storage unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if not isinstance(content_type, str):
|
||||
raise HTTPException(status_code=422, detail="Unsupported attachment type")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_UNSUPPORTED_TYPE",
|
||||
detail="Unsupported attachment type",
|
||||
),
|
||||
)
|
||||
mime_type = content_type.lower()
|
||||
if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
|
||||
raise HTTPException(status_code=422, detail="Unsupported attachment type")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_UNSUPPORTED_TYPE",
|
||||
detail="Unsupported attachment type",
|
||||
),
|
||||
)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty attachment")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_EMPTY",
|
||||
detail="Empty attachment",
|
||||
),
|
||||
)
|
||||
if len(payload) > MAX_ATTACHMENT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Attachment too large")
|
||||
raise ApiProblemError(
|
||||
status_code=413,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_TOO_LARGE",
|
||||
detail="Attachment too large",
|
||||
params={"maxBytes": MAX_ATTACHMENT_BYTES},
|
||||
),
|
||||
)
|
||||
|
||||
suffix = mime_to_suffix(mime_type)
|
||||
checksum = hashlib.sha1(payload).hexdigest()[:16]
|
||||
@@ -356,7 +407,13 @@ class AgentService:
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to upload attachment")
|
||||
raise ApiProblemError(
|
||||
status_code=502,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_UPLOAD_FAILED",
|
||||
detail="Failed to upload attachment",
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"bucket": bucket_name,
|
||||
@@ -373,19 +430,35 @@ class AgentService:
|
||||
current_user: CurrentUser,
|
||||
) -> dict[str, str]:
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Attachment storage unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
|
||||
detail="Attachment storage unavailable",
|
||||
),
|
||||
)
|
||||
normalized_bucket = bucket.strip()
|
||||
if normalized_bucket != config.storage.attachment.bucket:
|
||||
raise HTTPException(status_code=422, detail="Invalid attachment bucket")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_BUCKET_INVALID",
|
||||
detail="Invalid attachment bucket",
|
||||
),
|
||||
)
|
||||
|
||||
normalized_path = path.strip()
|
||||
expected_prefix = f"agent-inputs/{current_user.id}/"
|
||||
if not is_safe_attachment_path(
|
||||
normalized_path, expected_prefix=expected_prefix
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Invalid attachment path scope")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_PATH_SCOPE_INVALID",
|
||||
detail="Invalid attachment path scope",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
signed_url = await self._attachment_storage.create_signed_url(
|
||||
@@ -402,7 +475,13 @@ class AgentService:
|
||||
"user_id": str(current_user.id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to generate signed URL")
|
||||
raise ApiProblemError(
|
||||
status_code=502,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SIGNED_URL_GENERATION_FAILED",
|
||||
detail="Failed to generate signed URL",
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"bucket": normalized_bucket,
|
||||
@@ -525,25 +604,51 @@ class AgentService:
|
||||
current_user: CurrentUser,
|
||||
) -> tuple[str, str]:
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Attachment storage unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
|
||||
detail="Attachment storage unavailable",
|
||||
),
|
||||
)
|
||||
parsed = urlparse(url)
|
||||
expected_host = urlparse(config.supabase.url).netloc
|
||||
if parsed.netloc != expected_host:
|
||||
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_HOST")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="INVALID_BINARY_URL_HOST",
|
||||
detail="Invalid binary url host",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
bucket, path = self._attachment_storage.parse_signed_url(url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid signed image url"
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SIGNED_IMAGE_URL_INVALID",
|
||||
detail="Invalid signed image url",
|
||||
),
|
||||
) from exc
|
||||
|
||||
if bucket != config.storage.attachment.bucket:
|
||||
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="INVALID_BINARY_URL_BUCKET",
|
||||
detail="Invalid binary url bucket",
|
||||
),
|
||||
)
|
||||
|
||||
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||
if not is_safe_attachment_path(path, expected_prefix=expected_prefix):
|
||||
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_PATH_SCOPE")
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="INVALID_BINARY_URL_PATH_SCOPE",
|
||||
detail="Invalid binary url path scope",
|
||||
),
|
||||
)
|
||||
return bucket, path
|
||||
|
||||
@@ -6,9 +6,9 @@ from typing import Any, cast
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from fastapi import HTTPException
|
||||
from supabase import AuthError
|
||||
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from services.base.supabase import supabase_service
|
||||
from v1.auth.schemas import (
|
||||
@@ -26,6 +26,15 @@ logger = get_logger("v1.auth.gateway")
|
||||
AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable"
|
||||
|
||||
|
||||
def _auth_error(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
) -> ApiProblemError:
|
||||
return ApiProblemError(status_code=status_code, code=code, detail=detail)
|
||||
|
||||
|
||||
class SupabaseAuthGateway(AuthServiceGateway):
|
||||
def __init__(self) -> None:
|
||||
self._user_lookup_cache_ttl_seconds: int = 60
|
||||
@@ -50,11 +59,16 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
except AuthError as exc:
|
||||
logger.warning("Send otp failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
raise _auth_error(
|
||||
status_code=503,
|
||||
code="AUTH_SERVICE_UNAVAILABLE",
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(status_code=429, detail="Too many requests") from exc
|
||||
raise _auth_error(
|
||||
status_code=429,
|
||||
code="AUTH_TOO_MANY_REQUESTS",
|
||||
detail="Too many requests",
|
||||
) from exc
|
||||
|
||||
async def create_phone_session(
|
||||
self, request: PhoneSessionCreateRequest
|
||||
@@ -68,16 +82,23 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
try:
|
||||
verify_otp = cast(Any, client.auth.verify_otp)
|
||||
response = await asyncio.to_thread(verify_otp, payload)
|
||||
return _map_auth_response(response, "Invalid verification code")
|
||||
return _map_auth_response(
|
||||
response,
|
||||
"Invalid verification code",
|
||||
"AUTH_VERIFICATION_CODE_INVALID",
|
||||
)
|
||||
except AuthError as exc:
|
||||
logger.warning("Create phone session failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
raise _auth_error(
|
||||
status_code=503,
|
||||
code="AUTH_SERVICE_UNAVAILABLE",
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid verification code"
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code="AUTH_VERIFICATION_CODE_INVALID",
|
||||
detail="Invalid verification code",
|
||||
) from exc
|
||||
|
||||
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
|
||||
@@ -87,21 +108,32 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
client.auth.refresh_session,
|
||||
request.refresh_token,
|
||||
)
|
||||
return _map_auth_response(response, "Invalid refresh token")
|
||||
return _map_auth_response(
|
||||
response,
|
||||
"Invalid refresh token",
|
||||
"AUTH_REFRESH_TOKEN_INVALID",
|
||||
)
|
||||
except AuthError as exc:
|
||||
logger.warning("Refresh failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
raise _auth_error(
|
||||
status_code=503,
|
||||
code="AUTH_SERVICE_UNAVAILABLE",
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code="AUTH_REFRESH_TOKEN_INVALID",
|
||||
detail="Invalid refresh token",
|
||||
) from exc
|
||||
|
||||
async def delete_session(self, refresh_token: str | None) -> None:
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code="AUTH_REFRESH_TOKEN_MISSING",
|
||||
detail="Missing refresh token",
|
||||
)
|
||||
client = self._get_client()
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
@@ -110,7 +142,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
)
|
||||
session = getattr(response, "session", None)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code="AUTH_REFRESH_TOKEN_INVALID",
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
client.auth.set_session,
|
||||
str(session.access_token),
|
||||
@@ -120,28 +156,43 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
||||
except AuthError as exc:
|
||||
logger.warning("Logout failed", error_type=type(exc).__name__)
|
||||
if _is_auth_upstream_unavailable(exc):
|
||||
raise HTTPException(
|
||||
raise _auth_error(
|
||||
status_code=503,
|
||||
code="AUTH_SERVICE_UNAVAILABLE",
|
||||
detail=AUTH_UNAVAILABLE_DETAIL,
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code="AUTH_REFRESH_TOKEN_INVALID",
|
||||
detail="Invalid refresh token",
|
||||
) from exc
|
||||
|
||||
async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse:
|
||||
normalized_phone = _normalize_phone(phone)
|
||||
if not normalized_phone:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
await self._refresh_user_lookup_cache_if_needed()
|
||||
|
||||
user = self._users_by_phone.get(normalized_phone)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
user_phone = _normalize_phone(getattr(user, "phone", ""))
|
||||
if not user_phone:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _auth_error(
|
||||
status_code=404,
|
||||
code="AUTH_USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return UserByPhoneResponse(
|
||||
id=str(getattr(user, "id", "")),
|
||||
@@ -237,15 +288,25 @@ def _is_auth_upstream_unavailable(exc: AuthError) -> bool:
|
||||
return any(token in code or token in message for token in indicators)
|
||||
|
||||
|
||||
def _map_auth_response(response: object, failure_message: str) -> SessionResponse:
|
||||
def _map_auth_response(
|
||||
response: object, failure_message: str, failure_code: str
|
||||
) -> SessionResponse:
|
||||
session = getattr(response, "session", None)
|
||||
user = getattr(response, "user", None)
|
||||
if session is None or user is None:
|
||||
raise HTTPException(status_code=401, detail=failure_message)
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code=failure_code,
|
||||
detail=failure_message,
|
||||
)
|
||||
|
||||
phone = _normalize_phone(getattr(user, "phone", None))
|
||||
if not phone:
|
||||
raise HTTPException(status_code=401, detail=failure_message)
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code=failure_code,
|
||||
detail=failure_message,
|
||||
)
|
||||
|
||||
try:
|
||||
auth_user = AuthUser(id=str(user.id), phone=str(phone))
|
||||
@@ -254,7 +315,11 @@ def _map_auth_response(response: object, failure_message: str) -> SessionRespons
|
||||
"Auth response returned invalid phone format",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise HTTPException(status_code=401, detail=failure_message) from exc
|
||||
raise _auth_error(
|
||||
status_code=401,
|
||||
code=failure_code,
|
||||
detail=failure_message,
|
||||
) from exc
|
||||
return SessionResponse(
|
||||
access_token=str(session.access_token),
|
||||
refresh_token=str(session.refresh_token),
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections import deque
|
||||
from time import monotonic
|
||||
|
||||
from fastapi import HTTPException
|
||||
from core.http.errors import ApiProblemError
|
||||
|
||||
from core.logging import get_logger
|
||||
from services.base.redis import get_or_init_redis_client
|
||||
@@ -39,7 +39,7 @@ async def enforce_rate_limit(
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
return
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
@@ -63,7 +63,11 @@ async def _enforce_rate_limit_with_redis(
|
||||
client = await get_or_init_redis_client()
|
||||
current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await]
|
||||
if int(current) > limit:
|
||||
raise HTTPException(status_code=429, detail="Too many requests")
|
||||
raise ApiProblemError(
|
||||
status_code=429,
|
||||
code="AUTH_TOO_MANY_REQUESTS",
|
||||
detail="Too many requests",
|
||||
)
|
||||
|
||||
|
||||
async def _enforce_rate_limit_in_memory(
|
||||
@@ -81,7 +85,11 @@ async def _enforce_rate_limit_in_memory(
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= limit:
|
||||
raise HTTPException(status_code=429, detail="Too many requests")
|
||||
raise ApiProblemError(
|
||||
status_code=429,
|
||||
code="AUTH_TOO_MANY_REQUESTS",
|
||||
detail="Too many requests",
|
||||
)
|
||||
bucket.append(now)
|
||||
_CALL_COUNT += 1
|
||||
if _CALL_COUNT % _CLEANUP_INTERVAL == 0:
|
||||
|
||||
@@ -6,8 +6,9 @@ from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import status
|
||||
from schemas.enums import ScheduleType
|
||||
from core.http.errors import ApiProblemError
|
||||
from schemas.domain.automation import (
|
||||
AutomationJob as AutomationJobSchema,
|
||||
MessageContextConfig,
|
||||
@@ -32,26 +33,29 @@ if TYPE_CHECKING:
|
||||
logger = get_logger("v1.automation_jobs.service")
|
||||
|
||||
|
||||
class AutomationJobLimitExceeded(HTTPException):
|
||||
class AutomationJobLimitExceeded(ApiProblemError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="AUTOMATION_JOB_LIMIT_EXCEEDED",
|
||||
detail="Maximum of 3 user jobs allowed",
|
||||
)
|
||||
|
||||
|
||||
class SystemJobModificationForbidden(HTTPException):
|
||||
class SystemJobModificationForbidden(ApiProblemError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN",
|
||||
detail="System job cannot be modified",
|
||||
)
|
||||
|
||||
|
||||
class AutomationJobNotFound(HTTPException):
|
||||
class AutomationJobNotFound(ApiProblemError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="AUTOMATION_JOB_NOT_FOUND",
|
||||
detail="Automation job not found",
|
||||
)
|
||||
|
||||
@@ -219,8 +223,9 @@ class AutomationJobsService:
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to create automation job", owner_id=str(owner_id))
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
@@ -244,8 +249,9 @@ class AutomationJobsService:
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to update automation job", job_id=str(job_id))
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
@@ -261,7 +267,8 @@ class AutomationJobsService:
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to delete automation job", job_id=str(job_id))
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from core.logging import get_logger
|
||||
from models.friendships import Friendship
|
||||
from models.inbox_messages import InboxMessage
|
||||
@@ -60,8 +60,12 @@ class FriendshipService(BaseService):
|
||||
target_user_id = request.target_user_id
|
||||
|
||||
if user_id == target_user_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot send friend request to yourself"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_SELF_NOT_ALLOWED",
|
||||
detail="Cannot send friend request to yourself",
|
||||
),
|
||||
)
|
||||
|
||||
existing = await self._repository.get_friendship_between_users(
|
||||
@@ -70,17 +74,28 @@ class FriendshipService(BaseService):
|
||||
if existing:
|
||||
match existing.status:
|
||||
case FriendshipStatus.ACCEPTED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Already friends with this user"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_ALREADY_ACCEPTED",
|
||||
detail="Already friends with this user",
|
||||
),
|
||||
)
|
||||
case FriendshipStatus.BLOCKED:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail="Cannot send friend request to blocked user",
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_BLOCKED",
|
||||
detail="Cannot send friend request to blocked user",
|
||||
),
|
||||
)
|
||||
case FriendshipStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Friend request already sent"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_ALREADY_SENT",
|
||||
detail="Friend request already sent",
|
||||
),
|
||||
)
|
||||
case _:
|
||||
# DECLINED, CANCELED - 允许重新发送
|
||||
@@ -91,8 +106,12 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -113,8 +132,12 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -132,12 +155,22 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_FOUND",
|
||||
detail="Friend request not found",
|
||||
),
|
||||
)
|
||||
|
||||
recipient_id = (
|
||||
friendship.user_low_id
|
||||
@@ -153,18 +186,35 @@ class FriendshipService(BaseService):
|
||||
"friendship_id": str(friendship_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to accept this request"
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_FORBIDDEN",
|
||||
detail="Not authorized to accept this request",
|
||||
params={"action": "accept"},
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.status != FriendshipStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail="Friend request is not pending")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_PENDING",
|
||||
detail="Friend request is not pending",
|
||||
),
|
||||
)
|
||||
|
||||
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||
user_id, friendship_id
|
||||
)
|
||||
if inbox is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_INBOX_MESSAGE_NOT_FOUND",
|
||||
detail="Inbox message not found",
|
||||
),
|
||||
)
|
||||
|
||||
friendship.status = FriendshipStatus.ACCEPTED
|
||||
inbox.status = InboxMessageStatus.ACCEPTED
|
||||
@@ -173,14 +223,22 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
sender_id = friendship.initiator_id
|
||||
if sender_id is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -210,12 +268,22 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_FOUND",
|
||||
detail="Friend request not found",
|
||||
),
|
||||
)
|
||||
|
||||
recipient_id = (
|
||||
friendship.user_low_id
|
||||
@@ -231,12 +299,23 @@ class FriendshipService(BaseService):
|
||||
"friendship_id": str(friendship_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to decline this request"
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_FORBIDDEN",
|
||||
detail="Not authorized to decline this request",
|
||||
params={"action": "decline"},
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.status != FriendshipStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail="Friend request is not pending")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_PENDING",
|
||||
detail="Friend request is not pending",
|
||||
),
|
||||
)
|
||||
|
||||
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||
user_id, friendship_id
|
||||
@@ -250,14 +329,22 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
sender_id = friendship.initiator_id
|
||||
if sender_id is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -287,12 +374,22 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_FOUND",
|
||||
detail="Friend request not found",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.initiator_id != user_id:
|
||||
logger.warning(
|
||||
@@ -302,12 +399,23 @@ class FriendshipService(BaseService):
|
||||
"friendship_id": str(friendship_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to cancel this request"
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_FORBIDDEN",
|
||||
detail="Not authorized to cancel this request",
|
||||
params={"action": "cancel"},
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.status != FriendshipStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail="Friend request is not pending")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_PENDING",
|
||||
detail="Friend request is not pending",
|
||||
),
|
||||
)
|
||||
|
||||
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||
friendship.user_high_id, friendship_id
|
||||
@@ -321,15 +429,23 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
sender = await self._user_repository.get_by_user_id(user_id)
|
||||
recipient_id = friendship.user_high_id
|
||||
if recipient_id is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
recipient = await self._user_repository.get_by_user_id(recipient_id)
|
||||
|
||||
@@ -359,8 +475,12 @@ class FriendshipService(BaseService):
|
||||
user_id, InboxMessageStatus.PENDING
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
candidate_inbox = [
|
||||
@@ -423,22 +543,43 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_NOT_FOUND",
|
||||
detail="Friend request not found",
|
||||
),
|
||||
)
|
||||
|
||||
# Determine sender and recipient based on current user
|
||||
# initiator_id is the sender
|
||||
initiator_id = friendship.initiator_id
|
||||
if initiator_id is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid friendship data")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_DATA_INVALID",
|
||||
detail="Invalid friendship data",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.user_low_id != user_id and friendship.user_high_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to view this request"
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=problem_payload(
|
||||
code="FRIEND_REQUEST_FORBIDDEN",
|
||||
detail="Not authorized to view this request",
|
||||
params={"action": "view"},
|
||||
),
|
||||
)
|
||||
|
||||
sender = await self._user_repository.get_by_user_id(initiator_id)
|
||||
@@ -478,8 +619,12 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
outgoing = await self._repository.get_outgoing_requests(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if not outgoing:
|
||||
@@ -515,8 +660,12 @@ class FriendshipService(BaseService):
|
||||
try:
|
||||
friendships = await self._repository.get_friends_list(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if not friendships:
|
||||
@@ -552,16 +701,30 @@ class FriendshipService(BaseService):
|
||||
user_id, friend_id
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_NOT_FOUND",
|
||||
detail="Friendship not found",
|
||||
),
|
||||
)
|
||||
|
||||
if friendship.status != FriendshipStatus.ACCEPTED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Can only remove accepted friendships"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED",
|
||||
detail="Can only remove accepted friendships",
|
||||
),
|
||||
)
|
||||
|
||||
friendship.deleted_at = datetime.now(timezone.utc)
|
||||
@@ -570,8 +733,12 @@ class FriendshipService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
|
||||
detail="Friendship service unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -5,11 +5,11 @@ from uuid import UUID
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from models.inbox_messages import InboxMessage
|
||||
from v1.inbox_messages.repository import InboxMessageRepository
|
||||
@@ -25,6 +25,10 @@ if TYPE_CHECKING:
|
||||
logger = get_logger("v1.inbox_messages.service")
|
||||
|
||||
|
||||
def _inbox_error(*, status_code: int, code: str, detail: str) -> ApiProblemError:
|
||||
return ApiProblemError(status_code=status_code, code=code, detail=detail)
|
||||
|
||||
|
||||
class InboxMessageService(BaseService):
|
||||
_repository: InboxMessageRepository
|
||||
_session: AsyncSession
|
||||
@@ -48,8 +52,10 @@ class InboxMessageService(BaseService):
|
||||
messages = await self._repository.list_by_recipient(user_id, is_read)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Failed to list inbox messages", user_id=str(user_id))
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Inbox message store unavailable"
|
||||
raise _inbox_error(
|
||||
status_code=503,
|
||||
code="INBOX_MESSAGE_STORE_UNAVAILABLE",
|
||||
detail="Inbox message store unavailable",
|
||||
)
|
||||
|
||||
return [self._to_response(message) for message in messages]
|
||||
@@ -60,7 +66,11 @@ class InboxMessageService(BaseService):
|
||||
try:
|
||||
updated = await self._repository.mark_as_read(message_id, user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
raise _inbox_error(
|
||||
status_code=404,
|
||||
code="INBOX_MESSAGE_NOT_FOUND",
|
||||
detail="Inbox message not found",
|
||||
)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
@@ -69,8 +79,10 @@ class InboxMessageService(BaseService):
|
||||
message_id=str(message_id),
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Inbox message store unavailable"
|
||||
raise _inbox_error(
|
||||
status_code=503,
|
||||
code="INBOX_MESSAGE_STORE_UNAVAILABLE",
|
||||
detail="Inbox message store unavailable",
|
||||
)
|
||||
|
||||
return self._to_response(updated)
|
||||
|
||||
@@ -2,11 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from models.memories import Memory
|
||||
from schemas.enums import MemoryType
|
||||
@@ -19,6 +19,15 @@ if TYPE_CHECKING:
|
||||
logger = get_logger("v1.memories.service")
|
||||
|
||||
|
||||
def _memories_error(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
) -> ApiProblemError:
|
||||
return ApiProblemError(status_code=status_code, code=code, detail=detail)
|
||||
|
||||
|
||||
class MemoriesService(BaseService):
|
||||
"""Memories service handling user/work memory operations.
|
||||
|
||||
@@ -52,7 +61,11 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
return None
|
||||
@@ -65,7 +78,11 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
return None
|
||||
@@ -83,7 +100,11 @@ class MemoriesService(BaseService):
|
||||
owner_id=user_id
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
return {
|
||||
"user_memory": self._parse_user_content(user_memory)
|
||||
@@ -104,7 +125,11 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
try:
|
||||
@@ -116,8 +141,10 @@ class MemoriesService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Memories service unavailable"
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -129,8 +156,10 @@ class MemoriesService(BaseService):
|
||||
await self._session.refresh(memory)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Memories service unavailable"
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -150,7 +179,11 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
try:
|
||||
@@ -162,8 +195,10 @@ class MemoriesService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Memories service unavailable"
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -175,8 +210,10 @@ class MemoriesService(BaseService):
|
||||
await self._session.refresh(memory)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Memories service unavailable"
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -194,10 +231,18 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
raise HTTPException(status_code=404, detail="User memory not found")
|
||||
raise _memories_error(
|
||||
status_code=404,
|
||||
code="MEMORIES_USER_NOT_FOUND",
|
||||
detail="User memory not found",
|
||||
)
|
||||
|
||||
try:
|
||||
update_data: dict = {}
|
||||
@@ -218,7 +263,11 @@ class MemoriesService(BaseService):
|
||||
await self._session.refresh(memory)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"user_memory_patched",
|
||||
@@ -235,10 +284,18 @@ class MemoriesService(BaseService):
|
||||
try:
|
||||
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
if memory is None:
|
||||
raise HTTPException(status_code=404, detail="Work memory not found")
|
||||
raise _memories_error(
|
||||
status_code=404,
|
||||
code="MEMORIES_WORK_NOT_FOUND",
|
||||
detail="Work memory not found",
|
||||
)
|
||||
|
||||
try:
|
||||
update_data: dict = {}
|
||||
@@ -259,7 +316,11 @@ class MemoriesService(BaseService):
|
||||
await self._session.refresh(memory)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"work_memory_patched",
|
||||
@@ -284,4 +345,8 @@ class MemoriesService(BaseService):
|
||||
memory_type=memory_type,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Memories service unavailable")
|
||||
raise _memories_error(
|
||||
status_code=503,
|
||||
code="MEMORIES_SERVICE_UNAVAILABLE",
|
||||
detail="Memories service unavailable",
|
||||
)
|
||||
|
||||
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Protocol, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from core.logging import get_logger
|
||||
from models.inbox_messages import InboxMessage
|
||||
from models.schedule_items import ScheduleItem
|
||||
@@ -95,7 +95,13 @@ class ScheduleItemService(BaseService):
|
||||
normalized_end_at = self._to_utc(request.end_at)
|
||||
|
||||
if normalized_end_at and normalized_end_at <= normalized_start_at:
|
||||
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_INVALID_TIME_RANGE",
|
||||
detail="end_at must be after start_at",
|
||||
),
|
||||
)
|
||||
|
||||
data = {
|
||||
"owner_id": user_id,
|
||||
@@ -125,8 +131,12 @@ class ScheduleItemService(BaseService):
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to create schedule item")
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
return self._to_response(item)
|
||||
@@ -138,12 +148,22 @@ class ScheduleItemService(BaseService):
|
||||
item = await self._repository.get_by_id(item_id)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Failed to get schedule item", item_id=str(item_id))
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Schedule item not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_NOT_FOUND",
|
||||
detail="Schedule item not found",
|
||||
),
|
||||
)
|
||||
|
||||
is_owner = item.owner_id == user_id
|
||||
permission = 1
|
||||
@@ -162,7 +182,13 @@ class ScheduleItemService(BaseService):
|
||||
try:
|
||||
existing = await self._repository.get_by_item_id(item_id, user_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Schedule item not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_NOT_FOUND",
|
||||
detail="Schedule item not found",
|
||||
),
|
||||
)
|
||||
|
||||
# Build update dict from non-null fields
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
@@ -187,12 +213,20 @@ class ScheduleItemService(BaseService):
|
||||
update_data["end_at"] = next_end
|
||||
if next_end is not None:
|
||||
if not isinstance(next_start, datetime):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="start_at must include timezone"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED",
|
||||
detail="start_at must include timezone",
|
||||
),
|
||||
)
|
||||
if next_end <= next_start:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="end_at must be after start_at"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_INVALID_TIME_RANGE",
|
||||
detail="end_at must be after start_at",
|
||||
),
|
||||
)
|
||||
|
||||
if not update_data:
|
||||
@@ -206,12 +240,22 @@ class ScheduleItemService(BaseService):
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to update schedule item", item_id=str(item_id))
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Schedule item not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_NOT_FOUND",
|
||||
detail="Schedule item not found",
|
||||
),
|
||||
)
|
||||
|
||||
return self._to_response(item)
|
||||
|
||||
@@ -221,7 +265,13 @@ class ScheduleItemService(BaseService):
|
||||
try:
|
||||
existing = await self._repository.get_by_item_id(item_id, user_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Schedule item not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_NOT_FOUND",
|
||||
detail="Schedule item not found",
|
||||
),
|
||||
)
|
||||
|
||||
title = existing.title
|
||||
await self._repository.delete_subscriptions_by_item_id(item_id)
|
||||
@@ -231,8 +281,12 @@ class ScheduleItemService(BaseService):
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to delete schedule item", item_id=str(item_id))
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
async def list_by_date_range(
|
||||
@@ -244,7 +298,13 @@ class ScheduleItemService(BaseService):
|
||||
normalized_end_at = self._to_utc_required(request.end_at)
|
||||
|
||||
if normalized_end_at <= normalized_start_at:
|
||||
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_INVALID_TIME_RANGE",
|
||||
detail="end_at must be after start_at",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
archived_count = await self._repository.archive_expired_subscribed_items(
|
||||
@@ -275,8 +335,12 @@ class ScheduleItemService(BaseService):
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to list schedule items")
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
async def list_paginated(
|
||||
@@ -288,9 +352,23 @@ class ScheduleItemService(BaseService):
|
||||
) -> tuple[list[ScheduleItemResponse], int]:
|
||||
user_id = self.require_user_id()
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="page must be >= 1")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_PAGE_INVALID",
|
||||
detail="page must be >= 1",
|
||||
params={"min": 1, "page": page},
|
||||
),
|
||||
)
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="page_size must be 1..100")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_PAGE_SIZE_INVALID",
|
||||
detail="page_size must be 1..100",
|
||||
params={"min": 1, "max": 100, "page_size": page_size},
|
||||
),
|
||||
)
|
||||
try:
|
||||
items, total = await self._repository.list_paginated(
|
||||
user_id,
|
||||
@@ -304,8 +382,12 @@ class ScheduleItemService(BaseService):
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
return [self._to_response(item) for item in items], total
|
||||
|
||||
@@ -317,23 +399,39 @@ class ScheduleItemService(BaseService):
|
||||
try:
|
||||
item = await self._repository.get_by_id(item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Schedule item not found")
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_NOT_FOUND",
|
||||
detail="Schedule item not found",
|
||||
),
|
||||
)
|
||||
|
||||
inviter_permission = SubscriptionPermission.OWNER
|
||||
if item.owner_id != user_id:
|
||||
inviter_sub = await self._repository.get_subscription(item_id, user_id)
|
||||
if inviter_sub is None:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail="You don't have permission to share this calendar",
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_SHARE_FORBIDDEN",
|
||||
detail="You don't have permission to share this calendar",
|
||||
params={"reason": "not_subscriber"},
|
||||
),
|
||||
)
|
||||
inviter_permission = SubscriptionPermission(inviter_sub.permission)
|
||||
|
||||
request_permission = request._permission_value()
|
||||
if request_permission > inviter_permission:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=403,
|
||||
detail=f"You can only share with permissions up to {inviter_permission}",
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED",
|
||||
detail=(
|
||||
f"You can only share with permissions up to {inviter_permission}"
|
||||
),
|
||||
params={"max_permission": int(inviter_permission)},
|
||||
),
|
||||
)
|
||||
|
||||
target_user = await self._auth_gateway.get_user_by_phone(request.phone)
|
||||
@@ -348,9 +446,12 @@ class ScheduleItemService(BaseService):
|
||||
item_id, recipient_id, SubscriptionStatus.PENDING
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail="User already has an active subscription to this calendar",
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE",
|
||||
detail="User already has an active subscription to this calendar",
|
||||
),
|
||||
)
|
||||
else:
|
||||
await self._repository.create_subscription(
|
||||
@@ -368,14 +469,20 @@ class ScheduleItemService(BaseService):
|
||||
)
|
||||
if existing_msg:
|
||||
if existing_msg.status == InboxMessageStatus.ACCEPTED:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail="User already subscribed to this calendar",
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED",
|
||||
detail="User already subscribed to this calendar",
|
||||
),
|
||||
)
|
||||
elif existing_msg.status == InboxMessageStatus.PENDING:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail="User already has a pending invitation to this calendar",
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_INVITE_ALREADY_PENDING",
|
||||
detail="User already has a pending invitation to this calendar",
|
||||
),
|
||||
)
|
||||
elif existing_msg.status == InboxMessageStatus.REJECTED:
|
||||
existing_msg.status = InboxMessageStatus.PENDING
|
||||
@@ -400,20 +507,30 @@ class ScheduleItemService(BaseService):
|
||||
self._session.add(message)
|
||||
|
||||
await self._session.commit()
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to share schedule item", item_id=str(item_id))
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Schedule item store unavailable"
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
|
||||
detail="Schedule item store unavailable",
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
await self._session.rollback()
|
||||
logger.exception(
|
||||
"Auth lookup returned invalid user id", phone=request.phone
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE",
|
||||
detail="Auth lookup unavailable",
|
||||
),
|
||||
)
|
||||
|
||||
return ScheduleItemShareResponse(message="Calendar invitation sent")
|
||||
|
||||
@@ -460,8 +577,12 @@ class ScheduleItemService(BaseService):
|
||||
item_id, user_id
|
||||
)
|
||||
if inbox is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No pending invitation found"
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND",
|
||||
detail="No pending invitation found",
|
||||
),
|
||||
)
|
||||
|
||||
content = inbox.content or {}
|
||||
@@ -487,12 +608,18 @@ class ScheduleItemService(BaseService):
|
||||
await self._session.commit()
|
||||
|
||||
return {"message": "Subscription accepted"}
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except Exception:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to accept subscription")
|
||||
raise HTTPException(status_code=503, detail="Failed to accept subscription")
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED",
|
||||
detail="Failed to accept subscription",
|
||||
),
|
||||
)
|
||||
|
||||
async def reject_subscription(self, item_id: UUID) -> dict:
|
||||
user_id = self.require_user_id()
|
||||
@@ -502,8 +629,12 @@ class ScheduleItemService(BaseService):
|
||||
item_id, user_id
|
||||
)
|
||||
if inbox is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No pending invitation found"
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND",
|
||||
detail="No pending invitation found",
|
||||
),
|
||||
)
|
||||
|
||||
existing = await self._repository.get_subscription(item_id, user_id)
|
||||
@@ -516,12 +647,18 @@ class ScheduleItemService(BaseService):
|
||||
await self._session.commit()
|
||||
|
||||
return {"message": "Subscription rejected"}
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except Exception:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to reject subscription")
|
||||
raise HTTPException(status_code=503, detail="Failed to reject subscription")
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED",
|
||||
detail="Failed to reject subscription",
|
||||
),
|
||||
)
|
||||
|
||||
async def _notify_subscribers(
|
||||
self,
|
||||
@@ -560,13 +697,23 @@ class ScheduleItemService(BaseService):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="datetime must include timezone"
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED",
|
||||
detail="datetime must include timezone",
|
||||
),
|
||||
)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
def _to_utc_required(self, dt: datetime) -> datetime:
|
||||
normalized = self._to_utc(dt)
|
||||
if normalized is None:
|
||||
raise HTTPException(status_code=400, detail="datetime is required")
|
||||
raise ApiProblemError(
|
||||
status_code=400,
|
||||
detail=problem_payload(
|
||||
code="SCHEDULE_ITEM_DATETIME_REQUIRED",
|
||||
detail="datetime is required",
|
||||
),
|
||||
)
|
||||
return normalized
|
||||
|
||||
+133
-29
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from models.todos import Todo
|
||||
from schemas.enums import TodoStatus
|
||||
@@ -29,6 +29,21 @@ if TYPE_CHECKING:
|
||||
logger = get_logger("v1.todo.service")
|
||||
|
||||
|
||||
def _todo_error(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
params: dict[str, object] | None = None,
|
||||
) -> ApiProblemError:
|
||||
return ApiProblemError(
|
||||
status_code=status_code,
|
||||
code=code,
|
||||
detail=detail,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
class TodoService(BaseService):
|
||||
"""Todo service handling todo CRUD operations.
|
||||
|
||||
@@ -84,7 +99,11 @@ class TodoService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"todo_created",
|
||||
@@ -102,10 +121,18 @@ class TodoService(BaseService):
|
||||
try:
|
||||
todo = await self._repository.get_by_id(todo_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
if todo is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
raise _todo_error(
|
||||
status_code=404,
|
||||
code="TODO_NOT_FOUND",
|
||||
detail="Todo not found",
|
||||
)
|
||||
|
||||
if todo.owner_id != user_id:
|
||||
logger.warning(
|
||||
@@ -115,8 +142,11 @@ class TodoService(BaseService):
|
||||
"todo_id": str(todo_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to access this todo"
|
||||
raise _todo_error(
|
||||
status_code=403,
|
||||
code="TODO_ACCESS_FORBIDDEN",
|
||||
detail="Not authorized to access this todo",
|
||||
params={"action": "access"},
|
||||
)
|
||||
|
||||
return await self._to_response(todo)
|
||||
@@ -127,10 +157,18 @@ class TodoService(BaseService):
|
||||
try:
|
||||
todo = await self._repository.get_by_id(todo_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
if todo is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
raise _todo_error(
|
||||
status_code=404,
|
||||
code="TODO_NOT_FOUND",
|
||||
detail="Todo not found",
|
||||
)
|
||||
|
||||
if todo.owner_id != user_id:
|
||||
logger.warning(
|
||||
@@ -140,8 +178,11 @@ class TodoService(BaseService):
|
||||
"todo_id": str(todo_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to update this todo"
|
||||
raise _todo_error(
|
||||
status_code=403,
|
||||
code="TODO_ACCESS_FORBIDDEN",
|
||||
detail="Not authorized to update this todo",
|
||||
params={"action": "update"},
|
||||
)
|
||||
|
||||
completed_at = None
|
||||
@@ -174,7 +215,11 @@ class TodoService(BaseService):
|
||||
await self._session.refresh(todo)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"todo_updated",
|
||||
@@ -192,10 +237,18 @@ class TodoService(BaseService):
|
||||
try:
|
||||
todo = await self._repository.get_by_id(todo_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
if todo is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
raise _todo_error(
|
||||
status_code=404,
|
||||
code="TODO_NOT_FOUND",
|
||||
detail="Todo not found",
|
||||
)
|
||||
|
||||
if todo.owner_id != user_id:
|
||||
logger.warning(
|
||||
@@ -205,8 +258,11 @@ class TodoService(BaseService):
|
||||
"todo_id": str(todo_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to complete this todo"
|
||||
raise _todo_error(
|
||||
status_code=403,
|
||||
code="TODO_ACCESS_FORBIDDEN",
|
||||
detail="Not authorized to complete this todo",
|
||||
params={"action": "complete"},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -219,7 +275,11 @@ class TodoService(BaseService):
|
||||
await self._session.refresh(todo)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"todo_completed",
|
||||
@@ -237,10 +297,18 @@ class TodoService(BaseService):
|
||||
try:
|
||||
todo = await self._repository.get_by_id(todo_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
if todo is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
raise _todo_error(
|
||||
status_code=404,
|
||||
code="TODO_NOT_FOUND",
|
||||
detail="Todo not found",
|
||||
)
|
||||
|
||||
if todo.owner_id != user_id:
|
||||
logger.warning(
|
||||
@@ -250,8 +318,11 @@ class TodoService(BaseService):
|
||||
"todo_id": str(todo_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to delete this todo"
|
||||
raise _todo_error(
|
||||
status_code=403,
|
||||
code="TODO_ACCESS_FORBIDDEN",
|
||||
detail="Not authorized to delete this todo",
|
||||
params={"action": "delete"},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -259,7 +330,11 @@ class TodoService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"todo_deleted",
|
||||
@@ -279,15 +354,26 @@ class TodoService(BaseService):
|
||||
try:
|
||||
for item in request.items:
|
||||
if item.id in seen_ids:
|
||||
raise HTTPException(status_code=400, detail="Duplicate todo id")
|
||||
raise _todo_error(
|
||||
status_code=400,
|
||||
code="TODO_REORDER_DUPLICATE_ID",
|
||||
detail="Duplicate todo id",
|
||||
)
|
||||
seen_ids.add(item.id)
|
||||
|
||||
todo = await self._repository.get_by_id(item.id)
|
||||
if todo is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
raise _todo_error(
|
||||
status_code=404,
|
||||
code="TODO_NOT_FOUND",
|
||||
detail="Todo not found",
|
||||
)
|
||||
if todo.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to reorder this todo"
|
||||
raise _todo_error(
|
||||
status_code=403,
|
||||
code="TODO_ACCESS_FORBIDDEN",
|
||||
detail="Not authorized to reorder this todo",
|
||||
params={"action": "reorder"},
|
||||
)
|
||||
|
||||
original_priorities.add(todo.priority)
|
||||
@@ -314,7 +400,11 @@ class TodoService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
async def list_todos(
|
||||
self,
|
||||
@@ -328,10 +418,20 @@ class TodoService(BaseService):
|
||||
try:
|
||||
status_enum = TodoStatus(status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status value")
|
||||
raise _todo_error(
|
||||
status_code=400,
|
||||
code="TODO_STATUS_INVALID",
|
||||
detail="Invalid status value",
|
||||
params={"status": status},
|
||||
)
|
||||
|
||||
if priority is not None and (priority < 1 or priority > 4):
|
||||
raise HTTPException(status_code=400, detail="Invalid priority value")
|
||||
raise _todo_error(
|
||||
status_code=400,
|
||||
code="TODO_PRIORITY_INVALID",
|
||||
detail="Invalid priority value",
|
||||
params={"priority": priority, "min": 1, "max": 4},
|
||||
)
|
||||
|
||||
try:
|
||||
todos = await self._repository.list_by_owner(
|
||||
@@ -340,7 +440,11 @@ class TodoService(BaseService):
|
||||
priority=priority,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Todo service unavailable")
|
||||
raise _todo_error(
|
||||
status_code=503,
|
||||
code="TODO_SERVICE_UNAVAILABLE",
|
||||
detail="Todo service unavailable",
|
||||
)
|
||||
|
||||
return [await self._to_response(todo) for todo in todos]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth.jwt_verifier import (
|
||||
@@ -14,6 +14,7 @@ from core.auth.jwt_verifier import (
|
||||
from core.auth.models import CurrentUser
|
||||
from core.config.settings import config
|
||||
from core.db import get_db
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from services.base.supabase import supabase_service
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
@@ -44,7 +45,11 @@ def get_jwt_verifier() -> JwtVerifier:
|
||||
)
|
||||
if not issuer or not jwt_secret:
|
||||
logger.error("JWT validation failed: verifier config not configured")
|
||||
raise HTTPException(status_code=503, detail="JWT verifier not configured")
|
||||
raise ApiProblemError(
|
||||
status_code=503,
|
||||
code="JWT_VERIFIER_NOT_CONFIGURED",
|
||||
detail="JWT verifier not configured",
|
||||
)
|
||||
_jwt_verifier = JwtVerifier(
|
||||
issuer=issuer,
|
||||
jwt_secret=jwt_secret,
|
||||
@@ -90,16 +95,24 @@ async def get_current_user(
|
||||
) -> CurrentUser:
|
||||
if not authorization:
|
||||
logger.warning("JWT validation failed: missing authorization header")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
raise ApiProblemError(
|
||||
status_code=401,
|
||||
code="AUTH_UNAUTHORIZED",
|
||||
detail="Unauthorized",
|
||||
)
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
logger.warning("JWT validation failed: invalid authorization scheme")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
raise ApiProblemError(
|
||||
status_code=401,
|
||||
code="AUTH_UNAUTHORIZED",
|
||||
detail="Unauthorized",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = get_jwt_verifier().verify(token)
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
raise
|
||||
except TokenValidationError as exc:
|
||||
logger.warning(
|
||||
@@ -109,20 +122,32 @@ async def get_current_user(
|
||||
)
|
||||
fallback_user = await _verify_user_with_supabase(token)
|
||||
if fallback_user is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized") from exc
|
||||
raise ApiProblemError(
|
||||
status_code=401,
|
||||
code="AUTH_UNAUTHORIZED",
|
||||
detail="Unauthorized",
|
||||
) from exc
|
||||
logger.info("JWT fallback validation succeeded", user_id=str(fallback_user.id))
|
||||
return fallback_user
|
||||
|
||||
subject = payload.get("sub")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
logger.warning("JWT validation failed: missing or invalid subject claim")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
raise ApiProblemError(
|
||||
status_code=401,
|
||||
code="AUTH_UNAUTHORIZED",
|
||||
detail="Unauthorized",
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = UUID(subject)
|
||||
except ValueError:
|
||||
logger.warning("JWT validation failed: invalid UUID in subject")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
raise ApiProblemError(
|
||||
status_code=401,
|
||||
code="AUTH_UNAUTHORIZED",
|
||||
detail="Unauthorized",
|
||||
)
|
||||
|
||||
logger.debug("JWT validation successful", user_id=str(user_id))
|
||||
phone = payload.get("phone") if isinstance(payload.get("phone"), str) else None
|
||||
|
||||
+111
-22
@@ -4,7 +4,6 @@ import re
|
||||
from typing import TYPE_CHECKING, Protocol, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.agentscope.caches.user_context_cache import (
|
||||
@@ -13,6 +12,7 @@ from core.agentscope.caches.user_context_cache import (
|
||||
from core.auth.models import CurrentUser
|
||||
from core.config.settings import config
|
||||
from core.db.base_service import BaseService
|
||||
from core.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from schemas.shared.user import UserContext, parse_profile_settings
|
||||
from services.base.supabase import supabase_service
|
||||
@@ -29,6 +29,21 @@ logger = get_logger("v1.users.service")
|
||||
_PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$")
|
||||
|
||||
|
||||
def _user_error(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
params: dict[str, object] | None = None,
|
||||
) -> ApiProblemError:
|
||||
return ApiProblemError(
|
||||
status_code=status_code,
|
||||
code=code,
|
||||
detail=detail,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def _mime_to_suffix(mime_type: str) -> str:
|
||||
"""Convert MIME type to file suffix."""
|
||||
mapping = {
|
||||
@@ -62,7 +77,7 @@ class AuthLookupAdapter:
|
||||
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
||||
try:
|
||||
return await self._gateway.search_user_ids_by_phone(query, limit=limit)
|
||||
except HTTPException:
|
||||
except ApiProblemError:
|
||||
return []
|
||||
|
||||
|
||||
@@ -102,10 +117,18 @@ class UserService(BaseService):
|
||||
try:
|
||||
user = await self._repository.get_by_user_id(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _user_error(
|
||||
status_code=404,
|
||||
code="USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
phone = self._current_user.phone if self._current_user else None
|
||||
return UserContext(
|
||||
id=str(user.id),
|
||||
@@ -122,10 +145,18 @@ class UserService(BaseService):
|
||||
try:
|
||||
profile = await self._repository.get_by_user_id(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _user_error(
|
||||
status_code=404,
|
||||
code="USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
return UserContext(
|
||||
id=str(profile.id),
|
||||
username=profile.username,
|
||||
@@ -145,17 +176,29 @@ class UserService(BaseService):
|
||||
}
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
raise _user_error(
|
||||
status_code=400,
|
||||
code="USER_UPDATE_FIELDS_EMPTY",
|
||||
detail="No fields to update",
|
||||
)
|
||||
|
||||
try:
|
||||
user = await self._repository.update_by_user_id(user_id, update_data)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _user_error(
|
||||
status_code=404,
|
||||
code="USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
try:
|
||||
await self._user_context_cache.invalidate_user(user_id=user_id)
|
||||
@@ -186,25 +229,39 @@ class UserService(BaseService):
|
||||
user_id = self.require_user_id()
|
||||
|
||||
if not isinstance(content_type, str):
|
||||
raise HTTPException(status_code=422, detail="Unsupported image type")
|
||||
raise _user_error(
|
||||
status_code=422,
|
||||
code="USER_AVATAR_UNSUPPORTED_TYPE",
|
||||
detail="Unsupported image type",
|
||||
)
|
||||
|
||||
mime_type = content_type.lower()
|
||||
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
if mime_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
raise _user_error(
|
||||
status_code=422,
|
||||
code="USER_AVATAR_UNSUPPORTED_TYPE",
|
||||
detail="Unsupported image type. Allowed: JPEG, PNG, WebP",
|
||||
params={"allowed": ["image/jpeg", "image/png", "image/webp"]},
|
||||
)
|
||||
|
||||
max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024
|
||||
if len(payload) > max_size_bytes:
|
||||
raise HTTPException(
|
||||
raise _user_error(
|
||||
status_code=413,
|
||||
detail=f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB",
|
||||
code="USER_AVATAR_TOO_LARGE",
|
||||
detail=(
|
||||
f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB"
|
||||
),
|
||||
params={"max_size_mb": config.storage.avatar.max_size_mb},
|
||||
)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty image")
|
||||
raise _user_error(
|
||||
status_code=422,
|
||||
code="USER_AVATAR_EMPTY",
|
||||
detail="Empty image",
|
||||
)
|
||||
|
||||
suffix = _mime_to_suffix(mime_type)
|
||||
path = f"{user_id}/avatar.{suffix}"
|
||||
@@ -227,7 +284,11 @@ class UserService(BaseService):
|
||||
"user_id": str(user_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to upload avatar")
|
||||
raise _user_error(
|
||||
status_code=502,
|
||||
code="USER_AVATAR_UPLOAD_FAILED",
|
||||
detail="Failed to upload avatar",
|
||||
)
|
||||
|
||||
public_url = f"{config.supabase.public_url}/storage/v1/object/public/{bucket_name}/{stored_path}"
|
||||
|
||||
@@ -237,10 +298,18 @@ class UserService(BaseService):
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _user_error(
|
||||
status_code=404,
|
||||
code="USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
try:
|
||||
await self._user_context_cache.invalidate_user(user_id=user_id)
|
||||
@@ -257,10 +326,18 @@ class UserService(BaseService):
|
||||
try:
|
||||
user = await self._repository.get_by_username(username)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise _user_error(
|
||||
status_code=404,
|
||||
code="USER_NOT_FOUND",
|
||||
detail="User not found",
|
||||
)
|
||||
return UserContext(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
@@ -288,7 +365,11 @@ class UserService(BaseService):
|
||||
|
||||
async def _search_by_phone(self, phone: str) -> list[UserContext]:
|
||||
if self._auth_gateway is None:
|
||||
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_AUTH_LOOKUP_UNAVAILABLE",
|
||||
detail="Auth lookup unavailable",
|
||||
)
|
||||
|
||||
user_id_values = await self._auth_gateway.search_user_ids_by_phone(
|
||||
phone, limit=20
|
||||
@@ -308,7 +389,11 @@ class UserService(BaseService):
|
||||
try:
|
||||
users_by_id = await self._repository.get_by_user_ids(user_ids)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
results: list[UserContext] = []
|
||||
for user_id in user_ids:
|
||||
@@ -330,7 +415,11 @@ class UserService(BaseService):
|
||||
try:
|
||||
users = await self._repository.search_users(query, limit=20)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
raise _user_error(
|
||||
status_code=503,
|
||||
code="USER_STORE_UNAVAILABLE",
|
||||
detail="User store unavailable",
|
||||
)
|
||||
|
||||
return [
|
||||
UserContext(
|
||||
|
||||
@@ -12,6 +12,8 @@ def test_problem_details_defaults() -> None:
|
||||
assert result.status == 401
|
||||
assert result.detail == "Unauthorized"
|
||||
assert result.instance is None
|
||||
assert result.code is None
|
||||
assert result.params is None
|
||||
|
||||
|
||||
def test_problem_details_overrides() -> None:
|
||||
@@ -21,6 +23,8 @@ def test_problem_details_overrides() -> None:
|
||||
type_value="https://example.com/problems/conflict",
|
||||
title="Conflict",
|
||||
instance="/api/mobile/auth/signup",
|
||||
code="AUTH_CONFLICT",
|
||||
params={"field": "email"},
|
||||
)
|
||||
|
||||
assert result.type == "https://example.com/problems/conflict"
|
||||
@@ -28,3 +32,5 @@ def test_problem_details_overrides() -> None:
|
||||
assert result.status == 409
|
||||
assert result.detail == "Conflict"
|
||||
assert result.instance == "/api/mobile/auth/signup"
|
||||
assert result.code == "AUTH_CONFLICT"
|
||||
assert result.params == {"field": "email"}
|
||||
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from core.http.errors import ApiProblemError
|
||||
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.auth.schemas import (
|
||||
@@ -101,7 +101,7 @@ class TestSupabaseAuthGateway:
|
||||
return_value=SimpleNamespace(session=None, user=None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await sut.refresh_session(SessionRefreshRequest(refresh_token="bad"))
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from core.http.errors import ApiProblemError
|
||||
|
||||
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||
from v1.automation_jobs.service import (
|
||||
@@ -203,7 +203,7 @@ class TestCreate:
|
||||
repository.count_user_jobs.return_value = 0
|
||||
repository.create.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
with pytest.raises(ApiProblemError) as exc:
|
||||
await service.create(owner_id, data)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
@@ -316,7 +316,7 @@ class TestUpdate:
|
||||
repository.get_by_id.return_value = job
|
||||
repository.update.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
with pytest.raises(ApiProblemError) as exc:
|
||||
await service.update(
|
||||
job.id,
|
||||
owner_id,
|
||||
@@ -391,7 +391,7 @@ class TestDelete:
|
||||
repository.get_by_id.return_value = job
|
||||
repository.soft_delete.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
with pytest.raises(ApiProblemError) as exc:
|
||||
await service.delete(job.id, owner_id)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from core.http.errors import ApiProblemError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from models.friendships import Friendship, FriendshipStatus
|
||||
@@ -293,7 +293,7 @@ class TestSendRequest:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.send_request(
|
||||
FriendRequestCreate(target_user_id=current_user.id, content=None)
|
||||
)
|
||||
@@ -322,7 +322,7 @@ class TestSendRequest:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.send_request(
|
||||
FriendRequestCreate(target_user_id=USER_B, content=None)
|
||||
)
|
||||
@@ -351,7 +351,7 @@ class TestSendRequest:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.send_request(
|
||||
FriendRequestCreate(target_user_id=USER_B, content=None)
|
||||
)
|
||||
@@ -411,7 +411,7 @@ class TestAcceptRequest:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.accept_request(uuid4())
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
@@ -447,7 +447,7 @@ class TestAcceptRequest:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.accept_request(friendship.id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
@@ -669,7 +669,7 @@ class TestRemoveFriend:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.remove_friend(uuid4())
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@@ -3,10 +3,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.http.errors import ApiProblemError
|
||||
from models.inbox_messages import (
|
||||
InboxMessage,
|
||||
InboxMessageStatus as InboxMessageModelStatus,
|
||||
@@ -109,11 +109,12 @@ async def test_mark_as_read_raises_404_when_message_missing() -> None:
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.mark_as_read(message_id)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Inbox message not found"
|
||||
assert exc_info.value.code == "INBOX_MESSAGE_NOT_FOUND"
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -133,9 +134,10 @@ async def test_mark_as_read_store_error_returns_503() -> None:
|
||||
current_user=CurrentUser(id=user_id),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.mark_as_read(message_id)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Inbox message store unavailable"
|
||||
assert exc_info.value.code == "INBOX_MESSAGE_STORE_UNAVAILABLE"
|
||||
session.rollback.assert_awaited_once()
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from core.http.errors import ApiProblemError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
@@ -198,7 +198,7 @@ async def test_create_invalid_end_at(
|
||||
inbox_repository=mock_inbox_repository,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.create(request)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -234,7 +234,7 @@ async def test_get_by_id_not_found(
|
||||
inbox_repository=mock_inbox_repository,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.get_by_id(uuid4())
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
@@ -489,7 +489,7 @@ async def test_list_by_date_range_rolls_back_when_query_fails_after_archive(
|
||||
inbox_repository=mock_inbox_repository,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.list_by_date_range(
|
||||
request=ScheduleItemListRequest(
|
||||
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from core.http.errors import ApiProblemError
|
||||
|
||||
from core.auth.jwt_verifier import TokenValidationError
|
||||
import v1.users.dependencies as deps
|
||||
@@ -49,7 +49,7 @@ async def test_get_current_user_raises_401_when_fallback_fails(monkeypatch) -> N
|
||||
|
||||
monkeypatch.setattr(deps, "_verify_user_with_supabase", _fallback)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
with pytest.raises(ApiProblemError) as exc:
|
||||
await deps.get_current_user(authorization="Bearer invalid-token")
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user