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] = []