Add FastAPI lifespan hooks and fix Phase 1 gaps

- Add lifespan context manager to app/main.py with startup/shutdown hooks
- Wire startup: init_db(), init_redis(), CardService.load_all()
- Wire shutdown: close_db(), close_redis()
- Add /health/ready endpoint for readiness checks
- Add CORS middleware with configurable origins
- Disable docs in production (only available in dev)

- Export get_session_dependency from app/db/__init__.py for FastAPI DI
- Add game_cache_ttl_seconds to Settings (configurable, was hardcoded)
- Fix datetime.utcnow() deprecation (4 occurrences) -> datetime.now(UTC)
- Update test to match S3 image URL (was placeholder CDN)

All 974 tests passing.
This commit is contained in:
Cal Corum 2026-01-27 15:37:19 -06:00
parent c3c0a310a7
commit 2a95316f04
9 changed files with 190 additions and 17 deletions

View File

@ -161,6 +161,12 @@ class Settings(BaseSettings):
le=600, le=600,
description="Turn timeout in seconds (2 minutes default)", description="Turn timeout in seconds (2 minutes default)",
) )
game_cache_ttl_seconds: int = Field(
default=86400, # 24 hours
ge=3600,
le=604800, # 1 week max
description="Game state cache TTL in Redis (seconds)",
)
# Card Data # Card Data
card_data_path: str = Field( card_data_path: str = Field(

View File

@ -13,8 +13,17 @@ Usage:
async with get_session() as session: async with get_session() as session:
user = await session.get(User, user_id) user = await session.get(User, user_id)
# For FastAPI dependency injection:
from app.db import get_session_dependency
from fastapi import Depends
@app.get("/users/{user_id}")
async def get_user(session: AsyncSession = Depends(get_session_dependency)):
...
Exports: Exports:
- get_session: Async context manager for database sessions - get_session: Async context manager for database sessions
- get_session_dependency: FastAPI dependency for sessions
- get_engine: Get the async engine instance - get_engine: Get the async engine instance
- Base: Declarative base class for models - Base: Declarative base class for models
- init_db: Initialize database (create tables) - init_db: Initialize database (create tables)
@ -22,11 +31,18 @@ Exports:
""" """
from app.db.base import Base from app.db.base import Base
from app.db.session import close_db, get_engine, get_session, init_db from app.db.session import (
close_db,
get_engine,
get_session,
get_session_dependency,
init_db,
)
__all__ = [ __all__ = [
"Base", "Base",
"get_session", "get_session",
"get_session_dependency",
"get_engine", "get_engine",
"init_db", "init_db",
"close_db", "close_db",

View File

@ -12,7 +12,7 @@ Example:
) )
""" """
from datetime import datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
@ -111,7 +111,7 @@ class Collection(Base):
# When first obtained # When first obtained
obtained_at: Mapped[datetime] = mapped_column( obtained_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=datetime.utcnow, default=lambda: datetime.now(UTC),
nullable=False, nullable=False,
doc="When card was first added to collection", doc="When card was first added to collection",
) )

View File

@ -25,7 +25,7 @@ Example:
) )
""" """
from datetime import datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
@ -156,13 +156,13 @@ class ActiveGame(Base):
# Timing # Timing
started_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=datetime.utcnow, default=lambda: datetime.now(UTC),
nullable=False, nullable=False,
doc="When the game started", doc="When the game started",
) )
last_action_at: Mapped[datetime] = mapped_column( last_action_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=datetime.utcnow, default=lambda: datetime.now(UTC),
nullable=False, nullable=False,
doc="Timestamp of last action", doc="Timestamp of last action",
) )
@ -287,7 +287,7 @@ class GameHistory(Base):
# Timestamp # Timestamp
played_at: Mapped[datetime] = mapped_column( played_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=datetime.utcnow, default=lambda: datetime.now(UTC),
nullable=False, nullable=False,
doc="When the game was completed", doc="When the game was completed",
) )

View File

@ -1,15 +1,168 @@
"""Mantimon TCG - FastAPI Application Entry Point.""" """Mantimon TCG - FastAPI Application Entry Point.
This module configures and starts the FastAPI application with:
- Database initialization and cleanup
- Redis connection management
- Card service loading
- CORS middleware
- API routers
Usage:
uvicorn app.main:app --reload
"""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db import close_db, init_db
from app.db.redis import close_redis, init_redis
from app.services import get_card_service
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan context manager.
Handles startup and shutdown events:
- Startup: Initialize DB, Redis, and load cards
- Shutdown: Close DB and Redis connections
Args:
app: The FastAPI application instance.
Yields:
None - Control returns to the application during its lifetime.
"""
# === STARTUP ===
logger.info("Starting Mantimon TCG server...")
# Initialize database connection pool
logger.info("Initializing database...")
await init_db()
logger.info("Database initialized")
# Initialize Redis connection pool
logger.info("Initializing Redis...")
await init_redis()
logger.info("Redis initialized")
# Load card definitions into memory
logger.info("Loading card definitions...")
card_service = get_card_service()
await card_service.load_all()
card_count = len(card_service.get_all_cards())
logger.info(f"Loaded {card_count} card definitions")
logger.info("Mantimon TCG server started successfully")
yield # Application runs here
# === SHUTDOWN ===
logger.info("Shutting down Mantimon TCG server...")
# Close Redis connections
logger.info("Closing Redis connections...")
await close_redis()
logger.info("Redis connections closed")
# Close database connections
logger.info("Closing database connections...")
await close_db()
logger.info("Database connections closed")
logger.info("Mantimon TCG server shutdown complete")
# Create FastAPI application with lifespan
app = FastAPI( app = FastAPI(
title="Mantimon TCG", title="Mantimon TCG",
description="A home-rule-modified Pokemon Trading Card Game", description="A home-rule-modified Pokemon Trading Card Game API",
version="0.1.0", version="0.1.0",
lifespan=lifespan,
docs_url="/docs" if settings.is_development else None,
redoc_url="/redoc" if settings.is_development else None,
) )
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# === Health Check Endpoints ===
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, str]: async def health_check() -> dict[str, str]:
"""Health check endpoint.""" """Basic health check endpoint.
Returns:
Health status indicating the server is running.
"""
return {"status": "healthy"} return {"status": "healthy"}
@app.get("/health/ready")
async def readiness_check() -> dict[str, str | int]:
"""Readiness check with service status.
Verifies that all required services are available:
- Database connection
- Redis connection
- Card service loaded
Returns:
Detailed status of each service.
"""
from app.db import get_engine
from app.db.redis import get_pool
status: dict[str, str | int] = {"status": "ready"}
# Check database
try:
get_engine() # Raises RuntimeError if not initialized
status["database"] = "connected"
except RuntimeError:
status["database"] = "not initialized"
status["status"] = "not ready"
# Check Redis
try:
get_pool() # Raises RuntimeError if not initialized
status["redis"] = "connected"
except RuntimeError:
status["redis"] = "not initialized"
status["status"] = "not ready"
# Check card service
card_service = get_card_service()
card_count = len(card_service.get_all_cards())
if card_count > 0:
status["cards_loaded"] = card_count
else:
status["cards_loaded"] = 0
status["status"] = "not ready"
return status
# === API Routers ===
# TODO: Add routers in Phase 2
# from app.api import auth, games, cards, decks, campaign
# app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
# app.include_router(cards.router, prefix="/api/cards", tags=["cards"])
# app.include_router(decks.router, prefix="/api/decks", tags=["decks"])
# app.include_router(games.router, prefix="/api/games", tags=["games"])
# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"])

View File

@ -41,7 +41,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# TODO: Update CDN_BASE_URL when CDN is configured # TODO: Update CDN_BASE_URL when CDN is configured
CDN_BASE_URL = "https://cdn.mantimon.com/cards" CDN_BASE_URL = "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/"
class SetInfo(BaseModel): class SetInfo(BaseModel):

View File

@ -49,6 +49,7 @@ from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.models.game_state import GameState from app.core.models.game_state import GameState
from app.db.models import ActiveGame, GameType from app.db.models import ActiveGame, GameType
from app.db.redis import RedisHelper, redis_helper from app.db.redis import RedisHelper, redis_helper
@ -59,9 +60,6 @@ logger = logging.getLogger(__name__)
# Redis key patterns # Redis key patterns
GAME_KEY_PREFIX = "game:" GAME_KEY_PREFIX = "game:"
# Cache TTL for game state (24 hours - games should complete or be cleaned up)
GAME_CACHE_TTL = 60 * 60 * 24 # 24 hours in seconds
class GameStateManager: class GameStateManager:
"""Manages game state persistence across Redis and Postgres. """Manages game state persistence across Redis and Postgres.
@ -111,7 +109,7 @@ class GameStateManager:
""" """
key = self._game_key(game.game_id) key = self._game_key(game.game_id)
data = game.model_dump(mode="json") data = game.model_dump(mode="json")
await self.redis.set_json(key, data, expire_seconds=GAME_CACHE_TTL) await self.redis.set_json(key, data, expire_seconds=settings.game_cache_ttl_seconds)
logger.debug(f"Cached game state: {game.game_id}") logger.debug(f"Cached game state: {game.game_id}")
async def load_from_cache(self, game_id: str) -> GameState | None: async def load_from_cache(self, game_id: str) -> GameState | None:

View File

@ -30,7 +30,7 @@ from app.core.enums import CardType, PokemonVariant
from app.core.models.card import CardDefinition from app.core.models.card import CardDefinition
# TODO: Update CDN_BASE_URL when CDN is configured # TODO: Update CDN_BASE_URL when CDN is configured
CDN_BASE_URL = "https://cdn.mantimon.com/cards" CDN_BASE_URL = "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/"
# Paths # Paths
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent

View File

@ -226,7 +226,7 @@ class TestTransformPokemonCard:
assert len(result["attacks"]) == 1 assert len(result["attacks"]) == 1
assert result["illustrator"] == "Narumi Sato" assert result["illustrator"] == "Narumi Sato"
assert result["image_path"] == "pokemon/a1/001-bulbasaur.webp" assert result["image_path"] == "pokemon/a1/001-bulbasaur.webp"
assert "cdn.mantimon.com" in result["image_url"] assert "mantipocket.s3" in result["image_url"]
def test_ex_pokemon(self): def test_ex_pokemon(self):
"""Test transforming an EX Pokemon card. """Test transforming an EX Pokemon card.