From 13e90eb3085aa123487808ad53cb4d5e6295cad5 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Thu, 5 Feb 2026 15:09:05 +0100 Subject: [PATCH] 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 --- ...cker-bkhs--api-endpoints-implementation.md | 40 ++++---- backend/src/app/api/encounters.py | 82 +++++++++++++++ backend/src/app/api/games.py | 50 ++++++++++ backend/src/app/api/pokemon.py | 43 ++++++++ backend/src/app/api/routes.py | 6 +- backend/src/app/api/runs.py | 99 +++++++++++++++++++ backend/src/app/schemas/__init__.py | 30 ++++++ backend/src/app/schemas/base.py | 10 ++ backend/src/app/schemas/encounter.py | 36 +++++++ backend/src/app/schemas/game.py | 22 +++++ backend/src/app/schemas/pokemon.py | 23 +++++ backend/src/app/schemas/run.py | 32 ++++++ 12 files changed, 452 insertions(+), 21 deletions(-) create mode 100644 backend/src/app/api/encounters.py create mode 100644 backend/src/app/api/games.py create mode 100644 backend/src/app/api/pokemon.py create mode 100644 backend/src/app/api/runs.py create mode 100644 backend/src/app/schemas/base.py create mode 100644 backend/src/app/schemas/encounter.py create mode 100644 backend/src/app/schemas/game.py create mode 100644 backend/src/app/schemas/pokemon.py create mode 100644 backend/src/app/schemas/run.py diff --git a/.beans/nuzlocke-tracker-bkhs--api-endpoints-implementation.md b/.beans/nuzlocke-tracker-bkhs--api-endpoints-implementation.md index 2a8dc75..d000153 100644 --- a/.beans/nuzlocke-tracker-bkhs--api-endpoints-implementation.md +++ b/.beans/nuzlocke-tracker-bkhs--api-endpoints-implementation.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-bkhs title: API Endpoints Implementation -status: todo +status: completed type: task priority: normal created_at: 2026-02-04T15:46:59Z -updated_at: 2026-02-04T15:47:23Z +updated_at: 2026-02-05T13:47:57Z parent: nuzlocke-tracker-f5ob blocking: - nuzlocke-tracker-8fcj @@ -15,24 +15,24 @@ blocking: Implement the REST/GraphQL API endpoints for the tracker. ## Checklist -- [ ] Reference Data endpoints (read-only for tracker): - - [ ] GET /api/games - List all games - - [ ] GET /api/games/:id - Get game details with routes - - [ ] GET /api/games/:id/routes - List routes for a game - - [ ] GET /api/routes/:id/pokemon - List available Pokémon for a route - - [ ] GET /api/pokemon/:id - Get Pokémon details -- [ ] Run Management endpoints: - - [ ] POST /api/runs - Create new run - - [ ] GET /api/runs - List all runs - - [ ] GET /api/runs/:id - Get run details with encounters - - [ ] PATCH /api/runs/:id - Update run (settings, status) - - [ ] DELETE /api/runs/:id - Delete a run -- [ ] Encounter endpoints: - - [ ] POST /api/runs/:id/encounters - Log new encounter - - [ ] PATCH /api/encounters/:id - Update encounter (status, nickname) - - [ ] DELETE /api/encounters/:id - Remove encounter -- [ ] Add request validation -- [ ] Add proper error responses +- [x] Reference Data endpoints (read-only for tracker): + - [x] GET /api/v1/games - List all games + - [x] GET /api/v1/games/:id - Get game details with routes + - [x] GET /api/v1/games/:id/routes - List routes for a game + - [x] GET /api/v1/routes/:id/pokemon - List available Pokémon for a route + - [x] GET /api/v1/pokemon/:id - Get Pokémon details +- [x] Run Management endpoints: + - [x] POST /api/v1/runs - Create new run + - [x] GET /api/v1/runs - List all runs + - [x] GET /api/v1/runs/:id - Get run details with encounters + - [x] PATCH /api/v1/runs/:id - Update run (settings, status) + - [x] DELETE /api/v1/runs/:id - Delete a run +- [x] Encounter endpoints: + - [x] POST /api/v1/runs/:id/encounters - Log new encounter + - [x] PATCH /api/v1/encounters/:id - Update encounter (status, nickname) + - [x] DELETE /api/v1/encounters/:id - Remove encounter +- [x] Add request validation +- [x] Add proper error responses ## Notes - Follow REST conventions diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py new file mode 100644 index 0000000..94f8229 --- /dev/null +++ b/backend/src/app/api/encounters.py @@ -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) diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py new file mode 100644 index 0000000..c40eb99 --- /dev/null +++ b/backend/src/app/api/games.py @@ -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() diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py new file mode 100644 index 0000000..64e5820 --- /dev/null +++ b/backend/src/app/api/pokemon.py @@ -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() diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index 94084be..3461243 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -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"]) diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py new file mode 100644 index 0000000..0dcd5cc --- /dev/null +++ b/backend/src/app/api/runs.py @@ -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) diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index e69de29..affe407 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -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", +] diff --git a/backend/src/app/schemas/base.py b/backend/src/app/schemas/base.py new file mode 100644 index 0000000..3ae4a79 --- /dev/null +++ b/backend/src/app/schemas/base.py @@ -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, + ) diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py new file mode 100644 index 0000000..255dbae --- /dev/null +++ b/backend/src/app/schemas/encounter.py @@ -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 diff --git a/backend/src/app/schemas/game.py b/backend/src/app/schemas/game.py new file mode 100644 index 0000000..d6c45eb --- /dev/null +++ b/backend/src/app/schemas/game.py @@ -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] = [] diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py new file mode 100644 index 0000000..b02d883 --- /dev/null +++ b/backend/src/app/schemas/pokemon.py @@ -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 diff --git a/backend/src/app/schemas/run.py b/backend/src/app/schemas/run.py new file mode 100644 index 0000000..f00f3b7 --- /dev/null +++ b/backend/src/app/schemas/run.py @@ -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] = []