Add REST API endpoints for games, runs, and encounters

Implement 13 endpoints: read-only reference data (games, routes, pokemon),
run CRUD with cascading deletes, and encounter management. Uses Pydantic v2
with camelCase alias generation to match frontend types, and nested response
schemas for detail views.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-05 15:09:05 +01:00
parent cfd4c51514
commit 13e90eb308
12 changed files with 452 additions and 21 deletions

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.schemas.encounter import EncounterCreate, EncounterResponse, EncounterUpdate
router = APIRouter()
@router.post(
"/runs/{run_id}/encounters",
response_model=EncounterResponse,
status_code=201,
)
async def create_encounter(
run_id: int,
data: EncounterCreate,
session: AsyncSession = Depends(get_session),
):
# Validate run exists
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Validate route exists
route = await session.get(Route, data.route_id)
if route is None:
raise HTTPException(status_code=404, detail="Route not found")
# Validate pokemon exists
pokemon = await session.get(Pokemon, data.pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
encounter = Encounter(
run_id=run_id,
route_id=data.route_id,
pokemon_id=data.pokemon_id,
nickname=data.nickname,
status=data.status,
catch_level=data.catch_level,
)
session.add(encounter)
await session.commit()
await session.refresh(encounter)
return encounter
@router.patch("/encounters/{encounter_id}", response_model=EncounterResponse)
async def update_encounter(
encounter_id: int,
data: EncounterUpdate,
session: AsyncSession = Depends(get_session),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(encounter, field, value)
await session.commit()
await session.refresh(encounter)
return encounter
@router.delete("/encounters/{encounter_id}", status_code=204)
async def delete_encounter(
encounter_id: int, session: AsyncSession = Depends(get_session)
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
await session.delete(encounter)
await session.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.game import Game
from app.models.route import Route
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
router = APIRouter()
@router.get("", response_model=list[GameResponse])
async def list_games(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Game).order_by(Game.id))
return result.scalars().all()
@router.get("/{game_id}", response_model=GameDetailResponse)
async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(Game)
.where(Game.id == game_id)
.options(selectinload(Game.routes))
)
game = result.scalar_one_or_none()
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Sort routes by order for the response
game.routes.sort(key=lambda r: r.order)
return game
@router.get("/{game_id}/routes", response_model=list[RouteResponse])
async def list_game_routes(
game_id: int, session: AsyncSession = Depends(get_session)
):
# Verify game exists
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
result = await session.execute(
select(Route)
.where(Route.game_id == game_id)
.order_by(Route.order)
)
return result.scalars().all()

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_session
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.pokemon import PokemonResponse, RouteEncounterDetailResponse
router = APIRouter()
@router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def get_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
return pokemon
@router.get(
"/routes/{route_id}/pokemon",
response_model=list[RouteEncounterDetailResponse],
)
async def list_route_encounters(
route_id: int, session: AsyncSession = Depends(get_session)
):
# Verify route exists
route = await session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=404, detail="Route not found")
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.route_id == route_id)
.options(joinedload(RouteEncounter.pokemon))
.order_by(RouteEncounter.encounter_rate.desc())
)
return result.scalars().unique().all()

View File

@@ -1,6 +1,10 @@
from fastapi import APIRouter
from app.api import health
from app.api import encounters, games, health, pokemon, runs
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(games.router, prefix="/games", tags=["games"])
api_router.include_router(pokemon.router, tags=["pokemon"])
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
api_router.include_router(encounters.router, tags=["encounters"])

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
router = APIRouter()
@router.post("", response_model=RunResponse, status_code=201)
async def create_run(
data: RunCreate, session: AsyncSession = Depends(get_session)
):
# Validate game exists
game = await session.get(Game, data.game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
run = NuzlockeRun(
game_id=data.game_id,
name=data.name,
status="active",
rules=data.rules,
)
session.add(run)
await session.commit()
await session.refresh(run)
return run
@router.get("", response_model=list[RunResponse])
async def list_runs(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
)
return result.scalars().all()
@router.get("/{run_id}", response_model=RunDetailResponse)
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run_id)
.options(
joinedload(NuzlockeRun.game),
selectinload(NuzlockeRun.encounters)
.joinedload(Encounter.pokemon),
selectinload(NuzlockeRun.encounters)
.joinedload(Encounter.route),
)
)
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
return run
@router.patch("/{run_id}", response_model=RunResponse)
async def update_run(
run_id: int,
data: RunUpdate,
session: AsyncSession = Depends(get_session),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(run, field, value)
await session.commit()
await session.refresh(run)
return run
@router.delete("/{run_id}", status_code=204)
async def delete_run(
run_id: int, session: AsyncSession = Depends(get_session)
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Delete associated encounters first
encounters = await session.execute(
select(Encounter).where(Encounter.run_id == run_id)
)
for enc in encounters.scalars():
await session.delete(enc)
await session.delete(run)
await session.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,30 @@
from app.schemas.encounter import (
EncounterCreate,
EncounterDetailResponse,
EncounterResponse,
EncounterUpdate,
)
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
from app.schemas.pokemon import (
PokemonResponse,
RouteEncounterDetailResponse,
RouteEncounterResponse,
)
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
__all__ = [
"EncounterCreate",
"EncounterDetailResponse",
"EncounterResponse",
"EncounterUpdate",
"GameDetailResponse",
"GameResponse",
"RouteResponse",
"PokemonResponse",
"RouteEncounterDetailResponse",
"RouteEncounterResponse",
"RunCreate",
"RunDetailResponse",
"RunResponse",
"RunUpdate",
]

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class CamelModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)

View File

@@ -0,0 +1,36 @@
from datetime import datetime
from app.schemas.base import CamelModel
from app.schemas.game import RouteResponse
from app.schemas.pokemon import PokemonResponse
class EncounterCreate(CamelModel):
route_id: int
pokemon_id: int
nickname: str | None = None
status: str
catch_level: int | None = None
class EncounterUpdate(CamelModel):
nickname: str | None = None
status: str | None = None
faint_level: int | None = None
class EncounterResponse(CamelModel):
id: int
run_id: int
route_id: int
pokemon_id: int
nickname: str | None
status: str
catch_level: int | None
faint_level: int | None
caught_at: datetime
class EncounterDetailResponse(EncounterResponse):
pokemon: PokemonResponse
route: RouteResponse

View File

@@ -0,0 +1,22 @@
from app.schemas.base import CamelModel
class RouteResponse(CamelModel):
id: int
name: str
game_id: int
order: int
class GameResponse(CamelModel):
id: int
name: str
slug: str
generation: int
region: str
box_art_url: str | None
release_year: int | None
class GameDetailResponse(GameResponse):
routes: list[RouteResponse] = []

View File

@@ -0,0 +1,23 @@
from app.schemas.base import CamelModel
class PokemonResponse(CamelModel):
id: int
national_dex: int
name: str
types: list[str]
sprite_url: str | None
class RouteEncounterResponse(CamelModel):
id: int
route_id: int
pokemon_id: int
encounter_method: str
encounter_rate: int
min_level: int
max_level: int
class RouteEncounterDetailResponse(RouteEncounterResponse):
pokemon: PokemonResponse

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from app.schemas.base import CamelModel
from app.schemas.encounter import EncounterDetailResponse
from app.schemas.game import GameResponse
class RunCreate(CamelModel):
game_id: int
name: str
rules: dict = {}
class RunUpdate(CamelModel):
name: str | None = None
status: str | None = None
rules: dict | None = None
class RunResponse(CamelModel):
id: int
game_id: int
name: str
status: str
rules: dict
started_at: datetime
completed_at: datetime | None
class RunDetailResponse(RunResponse):
game: GameResponse
encounters: list[EncounterDetailResponse] = []