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