2026-02-05 15:13:06 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-03-06 16:09:15 +08:00
|
|
|
from contextlib import asynccontextmanager
|
2026-03-27 14:04:49 +08:00
|
|
|
from typing import Any, AsyncGenerator
|
2026-03-06 16:09:15 +08:00
|
|
|
|
2026-02-05 15:13:06 +08:00
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
from fastapi.responses import JSONResponse
|
2026-03-06 16:09:15 +08:00
|
|
|
from pydantic import BaseModel
|
2026-02-05 15:13:06 +08:00
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
|
|
|
|
|
from core.config.settings import config
|
2026-03-27 14:04:49 +08:00
|
|
|
from core.http.errors import ApiProblemError
|
2026-02-05 15:13:06 +08:00
|
|
|
from core.http.response import build_problem_details
|
2026-02-25 17:04:17 +08:00
|
|
|
from core.logging import configure_logging, get_logger, log_service_banner
|
2026-03-06 17:28:17 +08:00
|
|
|
from services.base import close_registered_services, initialize_registered_services
|
2026-02-05 15:13:06 +08:00
|
|
|
from v1.router import router as mobile_router
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 16:09:15 +08:00
|
|
|
class HealthResponse(BaseModel):
|
|
|
|
|
status: str
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 15:13:06 +08:00
|
|
|
configure_logging(config)
|
|
|
|
|
|
2026-02-25 17:04:17 +08:00
|
|
|
log_service_banner(
|
|
|
|
|
service_name=config.runtime.service_name,
|
|
|
|
|
environment=config.runtime.environment,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-06 16:09:15 +08:00
|
|
|
logger = get_logger("api.app")
|
2026-03-06 17:28:17 +08:00
|
|
|
SERVICE_STARTUP_ORDER = ["redis", "supabase"]
|
2026-03-06 16:09:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
2026-03-06 17:28:17 +08:00
|
|
|
initialized, services = await initialize_registered_services(SERVICE_STARTUP_ORDER)
|
2026-03-06 16:09:15 +08:00
|
|
|
if not initialized:
|
2026-03-06 17:28:17 +08:00
|
|
|
logger.error("Service initialization failed, aborting startup")
|
|
|
|
|
raise RuntimeError("Service initialization failed")
|
|
|
|
|
logger.info("Base services initialized", services=SERVICE_STARTUP_ORDER)
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
closed = await close_registered_services(services)
|
|
|
|
|
if not closed:
|
|
|
|
|
logger.warning("Failed to close all base services")
|
|
|
|
|
logger.info("Base services closed", services=SERVICE_STARTUP_ORDER)
|
2026-03-06 16:09:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
2026-02-05 15:13:06 +08:00
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=config.cors.allow_origins,
|
|
|
|
|
allow_credentials=config.cors.allow_credentials,
|
|
|
|
|
allow_methods=config.cors.allow_methods,
|
|
|
|
|
allow_headers=config.cors.allow_headers,
|
|
|
|
|
)
|
|
|
|
|
app.include_router(mobile_router)
|
|
|
|
|
|
2026-02-25 17:04:17 +08:00
|
|
|
logger.info(
|
|
|
|
|
"Web application initialized",
|
|
|
|
|
environment=config.runtime.environment,
|
|
|
|
|
debug=config.runtime.debug,
|
|
|
|
|
log_level=config.runtime.log_level,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-05 15:13:06 +08:00
|
|
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
|
|
|
async def health() -> HealthResponse:
|
|
|
|
|
return HealthResponse(status="ok")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_http_error_response(
|
|
|
|
|
request: Request,
|
|
|
|
|
exc: Exception,
|
|
|
|
|
status_code: int,
|
|
|
|
|
detail: object,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
instance = request.url.path
|
|
|
|
|
detail_text = detail if isinstance(detail, str) else "Request failed"
|
2026-03-27 14:04:49 +08:00
|
|
|
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
|
2026-02-05 15:13:06 +08:00
|
|
|
logger.warning(
|
|
|
|
|
"HTTP error",
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
detail=detail_text,
|
|
|
|
|
detail_extra=detail,
|
|
|
|
|
path=request.url.path,
|
|
|
|
|
method=request.method,
|
|
|
|
|
)
|
|
|
|
|
problem = build_problem_details(
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
detail=detail_text,
|
|
|
|
|
instance=instance,
|
2026-03-27 14:04:49 +08:00
|
|
|
code=error_code,
|
|
|
|
|
params=error_params,
|
2026-02-05 15:13:06 +08:00
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
content=problem.model_dump(),
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
|
|
|
async def http_exception_handler(
|
|
|
|
|
request: Request,
|
|
|
|
|
exc: HTTPException,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
return _build_http_error_response(
|
|
|
|
|
request=request,
|
|
|
|
|
exc=exc,
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
detail=exc.detail,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
|
|
|
async def starlette_http_exception_handler(
|
|
|
|
|
request: Request,
|
|
|
|
|
exc: StarletteHTTPException,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
return _build_http_error_response(
|
|
|
|
|
request=request,
|
|
|
|
|
exc=exc,
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
detail=exc.detail,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
|
|
|
async def validation_exception_handler(
|
|
|
|
|
request: Request,
|
|
|
|
|
exc: RequestValidationError,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
instance = request.url.path
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Request validation error",
|
|
|
|
|
path=request.url.path,
|
|
|
|
|
method=request.method,
|
|
|
|
|
errors=exc.errors(),
|
|
|
|
|
)
|
|
|
|
|
problem = build_problem_details(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="Invalid request",
|
|
|
|
|
instance=instance,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=422,
|
|
|
|
|
content=problem.model_dump(),
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
|
|
|
async def unhandled_exception_handler(
|
|
|
|
|
request: Request,
|
|
|
|
|
exc: Exception,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
instance = request.url.path
|
|
|
|
|
logger.exception(
|
|
|
|
|
"Unhandled error",
|
|
|
|
|
path=request.url.path,
|
|
|
|
|
method=request.method,
|
|
|
|
|
)
|
|
|
|
|
problem = build_problem_details(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Internal Server Error",
|
|
|
|
|
instance=instance,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=500,
|
|
|
|
|
content=problem.model_dump(),
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
2026-03-27 14:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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",
|
|
|
|
|
)
|