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:
82
backend/src/app/api/encounters.py
Normal file
82
backend/src/app/api/encounters.py
Normal 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)
|
||||
50
backend/src/app/api/games.py
Normal file
50
backend/src/app/api/games.py
Normal 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()
|
||||
43
backend/src/app/api/pokemon.py
Normal file
43
backend/src/app/api/pokemon.py
Normal 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()
|
||||
@@ -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"])
|
||||
|
||||
99
backend/src/app/api/runs.py
Normal file
99
backend/src/app/api/runs.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
10
backend/src/app/schemas/base.py
Normal file
10
backend/src/app/schemas/base.py
Normal 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,
|
||||
)
|
||||
36
backend/src/app/schemas/encounter.py
Normal file
36
backend/src/app/schemas/encounter.py
Normal 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
|
||||
22
backend/src/app/schemas/game.py
Normal file
22
backend/src/app/schemas/game.py
Normal 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] = []
|
||||
23
backend/src/app/schemas/pokemon.py
Normal file
23
backend/src/app/schemas/pokemon.py
Normal 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
|
||||
32
backend/src/app/schemas/run.py
Normal file
32
backend/src/app/schemas/run.py
Normal 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] = []
|
||||
Reference in New Issue
Block a user