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:
parent
c3c0a310a7
commit
2a95316f04
@ -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(
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user