Loading src/portal_service/api/dependencies.py 0 → 100644 +26 −0 Changes for src/portal_service/api/dependencies.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated, cast from fastapi import Depends, Request from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine from portal_service.app_state import AppState def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine async def get_database_health( db_engine: Annotated[AsyncEngine, Depends(get_db_engine)], ) -> bool: try: async with db_engine.connect() as connection: await connection.execute(text("SELECT 1")) return True except Exception: return False src/portal_service/api/health.py +17 −1 Changes for src/portal_service/api/health.py: 17 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -2,10 +2,15 @@ Health probes. GET /health/live — process is up (liveness) GET /health/ready — the database answers (readiness) """ from fastapi import APIRouter from typing import Annotated from fastapi import APIRouter, Depends, Response, status from portal_service.api.dependencies import get_database_health health_router = APIRouter(tags=["health"]) Loading @@ -13,3 +18,14 @@ health_router = APIRouter(tags=["health"]) @health_router.get("/health/live") async def live() -> dict[str, str]: return {"status": "alive"} @health_router.get("/health/ready") async def ready( response: Response, database_is_healthy: Annotated[bool, Depends(get_database_health)], ) -> dict[str, str]: if not database_is_healthy: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "unavailable"} return {"status": "ready"} src/portal_service/app_state.py 0 → 100644 +8 −0 Changes for src/portal_service/app_state.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Protocol from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] src/portal_service/main.py +22 −0 Changes for src/portal_service/main.py: 22 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -12,6 +12,10 @@ import structlog from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from portal_service.adapters.database.core import ( build_engine_and_session_maker, schema_initialization, ) from portal_service.api.exception_handlers import register_exception_handlers from portal_service.api.health import health_router from portal_service.api.middlewares import register_middlewares Loading @@ -28,7 +32,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: security_mode=settings.auth.security_mode, version=settings.app_version, ) try: db_engine, session_maker = await build_engine_and_session_maker( url=settings.postgresql_settings.url, echo=settings.postgresql_settings.echo, ) if settings.postgresql_settings.create_schema_on_startup: await schema_initialization(db_engine) logger.info("database engine initialized") except Exception as e: logger.error("database engine init failed", error=str(e)) raise app.state.db_engine = db_engine app.state.session_maker = session_maker yield await db_engine.dispose() logger.info("portal backend shutting down") Loading tests/test_misc.py +10 −1 Changes for tests/test_misc.py: 10 added lines, 1 removed line. Original line number Diff line number Diff line """Health (liveness) endpoint and request-id middleware.""" """Health (liveness, readiness) endpoints and request-id middleware.""" from uuid import UUID from fastapi import FastAPI from fastapi.testclient import TestClient Loading @@ -11,6 +12,14 @@ def test_health_live(client_none: TestClient) -> None: assert r.json() == {"status": "alive"} def test_health_ready_is_503_when_database_unreachable(app_none: FastAPI) -> None: # `with` runs the lifespan, which builds the engine (without connecting). with TestClient(app_none) as client: r = client.get("/health/ready") assert r.status_code == 503 assert r.json() == {"status": "unavailable"} def test_request_id_generated_when_absent(client_none: TestClient) -> None: r = client_none.get("/health/live") rid = r.headers["X-Request-ID"] Loading Loading
src/portal_service/api/dependencies.py 0 → 100644 +26 −0 Changes for src/portal_service/api/dependencies.py: 26 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated, cast from fastapi import Depends, Request from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine from portal_service.app_state import AppState def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine async def get_database_health( db_engine: Annotated[AsyncEngine, Depends(get_db_engine)], ) -> bool: try: async with db_engine.connect() as connection: await connection.execute(text("SELECT 1")) return True except Exception: return False
src/portal_service/api/health.py +17 −1 Changes for src/portal_service/api/health.py: 17 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -2,10 +2,15 @@ Health probes. GET /health/live — process is up (liveness) GET /health/ready — the database answers (readiness) """ from fastapi import APIRouter from typing import Annotated from fastapi import APIRouter, Depends, Response, status from portal_service.api.dependencies import get_database_health health_router = APIRouter(tags=["health"]) Loading @@ -13,3 +18,14 @@ health_router = APIRouter(tags=["health"]) @health_router.get("/health/live") async def live() -> dict[str, str]: return {"status": "alive"} @health_router.get("/health/ready") async def ready( response: Response, database_is_healthy: Annotated[bool, Depends(get_database_health)], ) -> dict[str, str]: if not database_is_healthy: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "unavailable"} return {"status": "ready"}
src/portal_service/app_state.py 0 → 100644 +8 −0 Changes for src/portal_service/app_state.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Protocol from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession]
src/portal_service/main.py +22 −0 Changes for src/portal_service/main.py: 22 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -12,6 +12,10 @@ import structlog from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from portal_service.adapters.database.core import ( build_engine_and_session_maker, schema_initialization, ) from portal_service.api.exception_handlers import register_exception_handlers from portal_service.api.health import health_router from portal_service.api.middlewares import register_middlewares Loading @@ -28,7 +32,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: security_mode=settings.auth.security_mode, version=settings.app_version, ) try: db_engine, session_maker = await build_engine_and_session_maker( url=settings.postgresql_settings.url, echo=settings.postgresql_settings.echo, ) if settings.postgresql_settings.create_schema_on_startup: await schema_initialization(db_engine) logger.info("database engine initialized") except Exception as e: logger.error("database engine init failed", error=str(e)) raise app.state.db_engine = db_engine app.state.session_maker = session_maker yield await db_engine.dispose() logger.info("portal backend shutting down") Loading
tests/test_misc.py +10 −1 Changes for tests/test_misc.py: 10 added lines, 1 removed line. Original line number Diff line number Diff line """Health (liveness) endpoint and request-id middleware.""" """Health (liveness, readiness) endpoints and request-id middleware.""" from uuid import UUID from fastapi import FastAPI from fastapi.testclient import TestClient Loading @@ -11,6 +12,14 @@ def test_health_live(client_none: TestClient) -> None: assert r.json() == {"status": "alive"} def test_health_ready_is_503_when_database_unreachable(app_none: FastAPI) -> None: # `with` runs the lifespan, which builds the engine (without connecting). with TestClient(app_none) as client: r = client.get("/health/ready") assert r.status_code == 503 assert r.json() == {"status": "unavailable"} def test_request_id_generated_when_absent(client_none: TestClient) -> None: r = client_none.get("/health/live") rid = r.headers["X-Request-ID"] Loading