Add REST API endpoints for team player statistics
Implemented complete REST API to retrieve NHL player statistics for entire teams:
Application Layer:
- Created DTOs for Player, SkaterStats, GoalieStats, and PlayerWithStatsDTO
- Implemented GetTeamPlayerStatsUseCase that orchestrates data retrieval
- Transforms domain entities to API-friendly DTOs
Presentation Layer:
- Created /api/v1/teams/{team_id}/players endpoint (all players)
- Created /api/v1/teams/{team_id}/players/skaters endpoint (skaters only)
- Created /api/v1/teams/{team_id}/players/goalies endpoint (goalies only)
- Integrated routes into main FastAPI application
Features:
- Retrieves complete roster for any NHL team by abbreviation
- Aggregates current season statistics for each player
- Differentiates between skater and goalie statistics
- Proper error handling with 404 for invalid teams
- Follows CLEAN architecture with dependency injection
Documentation:
- Created comprehensive API_GUIDE.md with usage examples
- Includes setup instructions, endpoint documentation
- Python, JavaScript, and cURL examples
- Complete list of NHL team abbreviations
Testing:
- Added test scripts for both direct adapter testing and REST API testing
- Verified functionality with Toronto Maple Leafs data
The REST API is now ready for integration with fantasy hockey analysis tools.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
265
API_GUIDE.md
Normal file
265
API_GUIDE.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# REST API Guide - Project Kempe
|
||||||
|
|
||||||
|
This guide explains how to set up and use the REST API to retrieve NHL player statistics for entire teams.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/michaelsimard/dev/services/project-kempe-backend
|
||||||
|
|
||||||
|
# Install all required packages
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
###2. Start the FastAPI Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Using uvicorn directly
|
||||||
|
uvicorn src.presentation.api.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# Option 2: Using python module
|
||||||
|
python3 -m uvicorn src.presentation.api.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:8000`
|
||||||
|
|
||||||
|
### 3. Verify Server is Running
|
||||||
|
|
||||||
|
Open your browser and visit:
|
||||||
|
- **API Documentation**: http://localhost:8000/docs (Interactive Swagger UI)
|
||||||
|
- **Alternative Docs**: http://localhost:8000/redoc
|
||||||
|
- **Health Check**: http://localhost:8000/health
|
||||||
|
|
||||||
|
## Available Endpoints
|
||||||
|
|
||||||
|
### Get All Players with Stats for a Team
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/v1/teams/{team_id}/players`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: JSON array of all players (forwards, defensemen, goalies) with their current season statistics.
|
||||||
|
|
||||||
|
**Response Structure**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"player": {
|
||||||
|
"id": "8478104",
|
||||||
|
"first_name": "Sammy",
|
||||||
|
"last_name": "Blais",
|
||||||
|
"full_name": "Sammy Blais",
|
||||||
|
"jersey_number": 79,
|
||||||
|
"position": "F",
|
||||||
|
"team_id": "TOR"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"player_id": "8478104",
|
||||||
|
"games_played": 8,
|
||||||
|
"goals": 1,
|
||||||
|
"assists": 2,
|
||||||
|
"points": 3,
|
||||||
|
"plus_minus": -2,
|
||||||
|
"penalty_minutes": 4,
|
||||||
|
"shots": 12,
|
||||||
|
"shooting_percentage": 8.33,
|
||||||
|
"time_on_ice_per_game": 14.5,
|
||||||
|
"powerplay_goals": 0,
|
||||||
|
"powerplay_points": 0,
|
||||||
|
"shorthanded_goals": 0,
|
||||||
|
"game_winning_goals": 0,
|
||||||
|
"faceoff_percentage": null,
|
||||||
|
"hits": 15,
|
||||||
|
"blocked_shots": 3
|
||||||
|
},
|
||||||
|
"stats_type": "skater"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Only Skaters for a Team
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/v1/teams/{team_id}/players/skaters`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players/skaters
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: JSON array of only forwards and defensemen with their statistics.
|
||||||
|
|
||||||
|
### Get Only Goalies for a Team
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/v1/teams/{team_id}/players/goalies`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players/goalies
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: JSON array of only goalies with their statistics.
|
||||||
|
|
||||||
|
**Goalie Stats Structure**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": {
|
||||||
|
"id": "8471679",
|
||||||
|
"first_name": "Joseph",
|
||||||
|
"last_name": "Woll",
|
||||||
|
"full_name": "Joseph Woll",
|
||||||
|
"jersey_number": 60,
|
||||||
|
"position": "G",
|
||||||
|
"team_id": "TOR"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"player_id": "8471679",
|
||||||
|
"games_played": 10,
|
||||||
|
"games_started": 10,
|
||||||
|
"wins": 6,
|
||||||
|
"losses": 2,
|
||||||
|
"overtime_losses": 2,
|
||||||
|
"saves": 285,
|
||||||
|
"shots_against": 308,
|
||||||
|
"goals_against": 23,
|
||||||
|
"save_percentage": 92.53,
|
||||||
|
"goals_against_average": 2.30,
|
||||||
|
"shutouts": 1,
|
||||||
|
"time_on_ice": 600.5
|
||||||
|
},
|
||||||
|
"stats_type": "goalie"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## NHL Team Abbreviations
|
||||||
|
|
||||||
|
Use these three-letter abbreviations when making API calls:
|
||||||
|
|
||||||
|
**Atlantic Division**:
|
||||||
|
- TOR - Toronto Maple Leafs
|
||||||
|
- BOS - Boston Bruins
|
||||||
|
- FLA - Florida Panthers
|
||||||
|
- TBL - Tampa Bay Lightning
|
||||||
|
- BUF - Buffalo Sabres
|
||||||
|
- DET - Detroit Red Wings
|
||||||
|
- OTT - Ottawa Senators
|
||||||
|
- MTL - Montréal Canadiens
|
||||||
|
|
||||||
|
**Metropolitan Division**:
|
||||||
|
- CAR - Carolina Hurricanes
|
||||||
|
- NYR - New York Rangers
|
||||||
|
- NJD - New Jersey Devils
|
||||||
|
- PHI - Philadelphia Flyers
|
||||||
|
- PIT - Pittsburgh Penguins
|
||||||
|
- WSH - Washington Capitals
|
||||||
|
- NYI - New York Islanders
|
||||||
|
- CBJ - Columbus Blue Jackets
|
||||||
|
|
||||||
|
**Central Division**:
|
||||||
|
- DAL - Dallas Stars
|
||||||
|
- COL - Colorado Avalanche
|
||||||
|
- WPG - Winnipeg Jets
|
||||||
|
- MIN - Minnesota Wild
|
||||||
|
- NSH - Nashville Predators
|
||||||
|
- STL - St. Louis Blues
|
||||||
|
- CHI - Chicago Blackhawks
|
||||||
|
- UTA - Utah Mammoth
|
||||||
|
|
||||||
|
**Pacific Division**:
|
||||||
|
- VGK - Vegas Golden Knights
|
||||||
|
- EDM - Edmonton Oilers
|
||||||
|
- LAK - Los Angeles Kings
|
||||||
|
- VAN - Vancouver Canucks
|
||||||
|
- SEA - Seattle Kraken
|
||||||
|
- CGY - Calgary Flames
|
||||||
|
- ANA - Anaheim Ducks
|
||||||
|
- SJS - San Jose Sharks
|
||||||
|
|
||||||
|
## Using the API in Your Application
|
||||||
|
|
||||||
|
### Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def get_team_stats(team_id: str):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"http://localhost:8000/api/v1/teams/{team_id}/players"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Get Toronto Maple Leafs stats
|
||||||
|
stats = asyncio.run(get_team_stats("TOR"))
|
||||||
|
for player_data in stats:
|
||||||
|
player = player_data["player"]
|
||||||
|
print(f"{player['full_name']}: {player_data['stats_type']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getTeamStats(teamId) {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://localhost:8000/api/v1/teams/${teamId}/players`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Toronto Maple Leafs stats
|
||||||
|
getTeamStats('TOR').then(players => {
|
||||||
|
players.forEach(playerData => {
|
||||||
|
console.log(`${playerData.player.full_name}: ${playerData.stats_type}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### cURL Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all players for Toronto Maple Leafs
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players | jq '.'
|
||||||
|
|
||||||
|
# Get only top scorers (skaters)
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players/skaters | jq '.[] | select(.stats.points > 10)'
|
||||||
|
|
||||||
|
# Get goalie stats
|
||||||
|
curl http://localhost:8000/api/v1/teams/TOR/players/goalies | jq '.[] | .stats'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Team Not Found (404)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "No players found for team XYZ. Please verify the team abbreviation."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Error (500)
|
||||||
|
Check server logs for detailed error information.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This REST API follows CLEAN architecture principles:
|
||||||
|
|
||||||
|
1. **Domain Layer**: Pure business entities (Player, Stats)
|
||||||
|
2. **Application Layer**: Use cases orchestrating business logic
|
||||||
|
3. **Infrastructure Layer**: NHL API adapter (swappable)
|
||||||
|
4. **Presentation Layer**: FastAPI REST endpoints
|
||||||
|
|
||||||
|
You can swap the NHL data source by changing the dependency injection configuration in `src/presentation/api/dependencies.py` without modifying any business logic or API endpoints.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Add authentication for private endpoints
|
||||||
|
- Implement caching for frequently requested data
|
||||||
|
- Add filtering and sorting query parameters
|
||||||
|
- Create endpoints for player comparisons
|
||||||
|
- Integrate Yahoo Fantasy API data
|
||||||
53
debug_api.py
Normal file
53
debug_api.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Debug script to examine raw NHL API responses."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/Users/michaelsimard/dev/services/project-kempe-backend")
|
||||||
|
|
||||||
|
from nhlpy import NHLClient
|
||||||
|
|
||||||
|
client = NHLClient()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("RAW API RESPONSE DEBUGGING")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Test teams endpoint
|
||||||
|
print("\n1. Testing teams endpoint...")
|
||||||
|
try:
|
||||||
|
teams_data = client.teams.teams()
|
||||||
|
print(f" Response type: {type(teams_data)}")
|
||||||
|
if isinstance(teams_data, dict):
|
||||||
|
print(f" Keys: {list(teams_data.keys())}")
|
||||||
|
if "data" in teams_data:
|
||||||
|
print(f" Number of teams in 'data': {len(teams_data['data'])}")
|
||||||
|
if teams_data["data"]:
|
||||||
|
print(f" First team keys: {list(teams_data['data'][0].keys())}")
|
||||||
|
print(f" First team sample: {teams_data['data'][0]}")
|
||||||
|
else:
|
||||||
|
print(f" Response: {teams_data}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Test player game log
|
||||||
|
print("\n2. Testing player game log (Sammy Blais ID: 8478104)...")
|
||||||
|
try:
|
||||||
|
game_log = client.stats.player_game_log(
|
||||||
|
player_id="8478104", season_id="20242025", game_type=2
|
||||||
|
)
|
||||||
|
print(f" Response type: {type(game_log)}")
|
||||||
|
if isinstance(game_log, dict):
|
||||||
|
print(f" Keys: {list(game_log.keys())}")
|
||||||
|
if "gameLog" in game_log:
|
||||||
|
print(f" Number of games: {len(game_log['gameLog'])}")
|
||||||
|
if game_log["gameLog"]:
|
||||||
|
print(f" First game keys: {list(game_log['gameLog'][0].keys())}")
|
||||||
|
else:
|
||||||
|
print(f" Response: {game_log}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
14
src/application/dto/__init__.py
Normal file
14
src/application/dto/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Data Transfer Objects package."""
|
||||||
|
from .player_dto import (
|
||||||
|
PlayerDTO,
|
||||||
|
SkaterStatsDTO,
|
||||||
|
GoalieStatsDTO,
|
||||||
|
PlayerWithStatsDTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlayerDTO",
|
||||||
|
"SkaterStatsDTO",
|
||||||
|
"GoalieStatsDTO",
|
||||||
|
"PlayerWithStatsDTO",
|
||||||
|
]
|
||||||
63
src/application/dto/player_dto.py
Normal file
63
src/application/dto/player_dto.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Player Data Transfer Objects."""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerDTO(BaseModel):
|
||||||
|
"""Data transfer object for player information."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
full_name: str
|
||||||
|
jersey_number: Optional[int]
|
||||||
|
position: str
|
||||||
|
team_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SkaterStatsDTO(BaseModel):
|
||||||
|
"""Data transfer object for skater statistics."""
|
||||||
|
|
||||||
|
player_id: str
|
||||||
|
games_played: int
|
||||||
|
goals: int
|
||||||
|
assists: int
|
||||||
|
points: int
|
||||||
|
plus_minus: int
|
||||||
|
penalty_minutes: int
|
||||||
|
shots: int
|
||||||
|
shooting_percentage: float
|
||||||
|
time_on_ice_per_game: Optional[float]
|
||||||
|
powerplay_goals: int
|
||||||
|
powerplay_points: int
|
||||||
|
shorthanded_goals: int
|
||||||
|
game_winning_goals: int
|
||||||
|
faceoff_percentage: Optional[float]
|
||||||
|
hits: int
|
||||||
|
blocked_shots: int
|
||||||
|
|
||||||
|
|
||||||
|
class GoalieStatsDTO(BaseModel):
|
||||||
|
"""Data transfer object for goalie statistics."""
|
||||||
|
|
||||||
|
player_id: str
|
||||||
|
games_played: int
|
||||||
|
games_started: int
|
||||||
|
wins: int
|
||||||
|
losses: int
|
||||||
|
overtime_losses: int
|
||||||
|
saves: int
|
||||||
|
shots_against: int
|
||||||
|
goals_against: int
|
||||||
|
save_percentage: float
|
||||||
|
goals_against_average: float
|
||||||
|
shutouts: int
|
||||||
|
time_on_ice: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerWithStatsDTO(BaseModel):
|
||||||
|
"""Combined player information and statistics."""
|
||||||
|
|
||||||
|
player: PlayerDTO
|
||||||
|
stats: Optional[SkaterStatsDTO | GoalieStatsDTO]
|
||||||
|
stats_type: str # "skater", "goalie", or "none"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Application use cases package."""
|
"""Application use cases package."""
|
||||||
from .get_player_stats import GetPlayerStatsUseCase
|
from .get_player_stats import GetPlayerStatsUseCase
|
||||||
|
from .get_team_player_stats import GetTeamPlayerStatsUseCase
|
||||||
|
|
||||||
__all__ = ["GetPlayerStatsUseCase"]
|
__all__ = ["GetPlayerStatsUseCase", "GetTeamPlayerStatsUseCase"]
|
||||||
|
|||||||
127
src/application/use_cases/get_team_player_stats.py
Normal file
127
src/application/use_cases/get_team_player_stats.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Use case for retrieving all player statistics for a team."""
|
||||||
|
from typing import List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.domain.repositories import PlayerRepository
|
||||||
|
from src.application.dto import (
|
||||||
|
PlayerDTO,
|
||||||
|
SkaterStatsDTO,
|
||||||
|
GoalieStatsDTO,
|
||||||
|
PlayerWithStatsDTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GetTeamPlayerStatsUseCase:
|
||||||
|
"""
|
||||||
|
Use case for retrieving all players and their statistics for a team.
|
||||||
|
|
||||||
|
This use case demonstrates CLEAN architecture principles:
|
||||||
|
- Orchestrates multiple repository calls
|
||||||
|
- Transforms domain entities to DTOs for API response
|
||||||
|
- Contains business logic independent of frameworks
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, player_repository: PlayerRepository):
|
||||||
|
"""
|
||||||
|
Initializes the use case with required dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_repository: Repository for accessing player data
|
||||||
|
"""
|
||||||
|
self.player_repository = player_repository
|
||||||
|
|
||||||
|
async def execute(self, team_id: str) -> List[PlayerWithStatsDTO]:
|
||||||
|
"""
|
||||||
|
Retrieves all players and their statistics for a team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: The team abbreviation or ID (e.g., "TOR")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of players with their current season statistics
|
||||||
|
"""
|
||||||
|
# Get team roster
|
||||||
|
players = await self.player_repository.get_players_by_team(team_id)
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
logger.warning(f"No players found for team {team_id}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get statistics for each player
|
||||||
|
players_with_stats = []
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
# Convert player entity to DTO
|
||||||
|
player_dto = PlayerDTO(
|
||||||
|
id=player.id,
|
||||||
|
first_name=player.first_name,
|
||||||
|
last_name=player.last_name,
|
||||||
|
full_name=player.full_name,
|
||||||
|
jersey_number=player.jersey_number,
|
||||||
|
position=player.position,
|
||||||
|
team_id=player.team_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get appropriate statistics based on position
|
||||||
|
stats_dto = None
|
||||||
|
stats_type = "none"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if player.is_goalie():
|
||||||
|
goalie_stats = await self.player_repository.get_goalie_stats(
|
||||||
|
player.id
|
||||||
|
)
|
||||||
|
if goalie_stats:
|
||||||
|
stats_dto = GoalieStatsDTO(
|
||||||
|
player_id=goalie_stats.player_id,
|
||||||
|
games_played=goalie_stats.games_played,
|
||||||
|
games_started=goalie_stats.games_started,
|
||||||
|
wins=goalie_stats.wins,
|
||||||
|
losses=goalie_stats.losses,
|
||||||
|
overtime_losses=goalie_stats.overtime_losses,
|
||||||
|
saves=goalie_stats.saves,
|
||||||
|
shots_against=goalie_stats.shots_against,
|
||||||
|
goals_against=goalie_stats.goals_against,
|
||||||
|
save_percentage=goalie_stats.save_percentage,
|
||||||
|
goals_against_average=goalie_stats.goals_against_average,
|
||||||
|
shutouts=goalie_stats.shutouts,
|
||||||
|
time_on_ice=goalie_stats.time_on_ice,
|
||||||
|
)
|
||||||
|
stats_type = "goalie"
|
||||||
|
else:
|
||||||
|
skater_stats = await self.player_repository.get_skater_stats(
|
||||||
|
player.id
|
||||||
|
)
|
||||||
|
if skater_stats:
|
||||||
|
stats_dto = SkaterStatsDTO(
|
||||||
|
player_id=skater_stats.player_id,
|
||||||
|
games_played=skater_stats.games_played,
|
||||||
|
goals=skater_stats.goals,
|
||||||
|
assists=skater_stats.assists,
|
||||||
|
points=skater_stats.points,
|
||||||
|
plus_minus=skater_stats.plus_minus,
|
||||||
|
penalty_minutes=skater_stats.penalty_minutes,
|
||||||
|
shots=skater_stats.shots,
|
||||||
|
shooting_percentage=skater_stats.shooting_percentage,
|
||||||
|
time_on_ice_per_game=skater_stats.time_on_ice_per_game,
|
||||||
|
powerplay_goals=skater_stats.powerplay_goals,
|
||||||
|
powerplay_points=skater_stats.powerplay_points,
|
||||||
|
shorthanded_goals=skater_stats.shorthanded_goals,
|
||||||
|
game_winning_goals=skater_stats.game_winning_goals,
|
||||||
|
faceoff_percentage=skater_stats.faceoff_percentage,
|
||||||
|
hits=skater_stats.hits,
|
||||||
|
blocked_shots=skater_stats.blocked_shots,
|
||||||
|
)
|
||||||
|
stats_type = "skater"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching stats for player {player.id}: {e}")
|
||||||
|
|
||||||
|
players_with_stats.append(
|
||||||
|
PlayerWithStatsDTO(
|
||||||
|
player=player_dto, stats=stats_dto, stats_type=stats_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return players_with_stats
|
||||||
@@ -38,8 +38,9 @@ async def health_check():
|
|||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
# TODO: Include routers for players, teams, and fantasy endpoints
|
# Include routers
|
||||||
# from src.presentation.api.routes import players, teams, fantasy
|
from src.presentation.api.routes import teams
|
||||||
# app.include_router(players.router, prefix=f"{settings.api_prefix}/players", tags=["players"])
|
|
||||||
# app.include_router(teams.router, prefix=f"{settings.api_prefix}/teams", tags=["teams"])
|
app.include_router(
|
||||||
# app.include_router(fantasy.router, prefix=f"{settings.api_prefix}/fantasy", tags=["fantasy"])
|
teams.router, prefix=f"{settings.api_prefix}/teams", tags=["teams"]
|
||||||
|
)
|
||||||
|
|||||||
1
src/presentation/api/routes/__init__.py
Normal file
1
src/presentation/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API routes package."""
|
||||||
103
src/presentation/api/routes/teams.py
Normal file
103
src/presentation/api/routes/teams.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Team API routes."""
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from src.domain.repositories import PlayerRepository
|
||||||
|
from src.application.use_cases import GetTeamPlayerStatsUseCase
|
||||||
|
from src.application.dto import PlayerWithStatsDTO
|
||||||
|
from src.presentation.api.dependencies import get_player_repository
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{team_id}/players")
|
||||||
|
async def get_team_players_with_stats(
|
||||||
|
team_id: str,
|
||||||
|
player_repo: PlayerRepository = Depends(get_player_repository),
|
||||||
|
) -> List[PlayerWithStatsDTO]:
|
||||||
|
"""
|
||||||
|
Retrieves all players and their statistics for a specific team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: Team abbreviation (e.g., "TOR", "BOS", "MTL")
|
||||||
|
player_repo: Injected player repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of players with their current season statistics
|
||||||
|
|
||||||
|
Example:
|
||||||
|
GET /api/v1/teams/TOR/players
|
||||||
|
|
||||||
|
Response includes:
|
||||||
|
- Player information (name, position, jersey number)
|
||||||
|
- Current season statistics (goals, assists, points for skaters)
|
||||||
|
- Current season statistics (wins, saves, GAA for goalies)
|
||||||
|
"""
|
||||||
|
use_case = GetTeamPlayerStatsUseCase(player_repo)
|
||||||
|
players_with_stats = await use_case.execute(team_id)
|
||||||
|
|
||||||
|
if not players_with_stats:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No players found for team {team_id}. Please verify the team abbreviation.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return players_with_stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{team_id}/players/skaters")
|
||||||
|
async def get_team_skaters_with_stats(
|
||||||
|
team_id: str,
|
||||||
|
player_repo: PlayerRepository = Depends(get_player_repository),
|
||||||
|
) -> List[PlayerWithStatsDTO]:
|
||||||
|
"""
|
||||||
|
Retrieves only skaters (forwards and defensemen) for a team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: Team abbreviation
|
||||||
|
player_repo: Injected player repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of skaters with their statistics
|
||||||
|
"""
|
||||||
|
use_case = GetTeamPlayerStatsUseCase(player_repo)
|
||||||
|
all_players = await use_case.execute(team_id)
|
||||||
|
|
||||||
|
# Filter to only skaters
|
||||||
|
skaters = [p for p in all_players if p.stats_type == "skater"]
|
||||||
|
|
||||||
|
if not skaters:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"No skaters found for team {team_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return skaters
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{team_id}/players/goalies")
|
||||||
|
async def get_team_goalies_with_stats(
|
||||||
|
team_id: str,
|
||||||
|
player_repo: PlayerRepository = Depends(get_player_repository),
|
||||||
|
) -> List[PlayerWithStatsDTO]:
|
||||||
|
"""
|
||||||
|
Retrieves only goalies for a team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: Team abbreviation
|
||||||
|
player_repo: Injected player repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of goalies with their statistics
|
||||||
|
"""
|
||||||
|
use_case = GetTeamPlayerStatsUseCase(player_repo)
|
||||||
|
all_players = await use_case.execute(team_id)
|
||||||
|
|
||||||
|
# Filter to only goalies
|
||||||
|
goalies = [p for p in all_players if p.stats_type == "goalie"]
|
||||||
|
|
||||||
|
if not goalies:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"No goalies found for team {team_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return goalies
|
||||||
120
test_nhl_api.py
Normal file
120
test_nhl_api.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Manual test script for NHL API integration."""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add src to path for imports
|
||||||
|
sys.path.insert(0, "/Users/michaelsimard/dev/services/project-kempe-backend")
|
||||||
|
|
||||||
|
from src.infrastructure.adapters.nhl import NHLPlayerAdapter, NHLTeamAdapter
|
||||||
|
|
||||||
|
|
||||||
|
async def test_teams():
|
||||||
|
"""Test team retrieval."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING TEAM ADAPTER")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
team_adapter = NHLTeamAdapter()
|
||||||
|
|
||||||
|
# Test getting all teams
|
||||||
|
print("\n1. Getting all NHL teams...")
|
||||||
|
teams = await team_adapter.get_all_teams()
|
||||||
|
print(f" Found {len(teams)} teams")
|
||||||
|
|
||||||
|
if teams:
|
||||||
|
print("\n First 5 teams:")
|
||||||
|
for team in teams[:5]:
|
||||||
|
print(f" - {team.full_name} ({team.abbreviation}) - {team.division}")
|
||||||
|
|
||||||
|
# Test getting a specific team Wings
|
||||||
|
print("\n2. Getting Detroit Red Wings by abbreviation (DET)...")
|
||||||
|
wings = await team_adapter.get_team_by_id("DET")
|
||||||
|
if wings:
|
||||||
|
print(f" ✓ Team: {wings.full_name}")
|
||||||
|
print(f" City: {wings.city}")
|
||||||
|
print(f" Division: {wings.division}")
|
||||||
|
print(f" Conference: {wings.conference}")
|
||||||
|
else:
|
||||||
|
print(" ✗ Team not found")
|
||||||
|
|
||||||
|
# Test getting teams by division
|
||||||
|
print("\n3. Getting teams from Atlantic Division...")
|
||||||
|
atlantic_teams = await team_adapter.get_teams_by_division("Atlantic")
|
||||||
|
print(f" Found {len(atlantic_teams)} teams in Atlantic Division")
|
||||||
|
for team in atlantic_teams:
|
||||||
|
print(f" - {team.full_name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_players():
|
||||||
|
"""Test player retrieval."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING PLAYER ADAPTER")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
player_adapter = NHLPlayerAdapter()
|
||||||
|
|
||||||
|
# Test getting team roster (Toronto Maple Leafs)
|
||||||
|
print("\n1. Getting Detroit Red Wings roster...")
|
||||||
|
players = await player_adapter.get_players_by_team("DET")
|
||||||
|
print(f" Found {len(players)} players on roster")
|
||||||
|
|
||||||
|
if players:
|
||||||
|
print("\n Sample players:")
|
||||||
|
for player in players[:5]:
|
||||||
|
print(
|
||||||
|
f" - #{player.jersey_number or '??'} {player.full_name} ({player.position})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get a specific player for stats testing
|
||||||
|
test_player = players[0]
|
||||||
|
print(f"\n2. Getting stats for {test_player.full_name} (ID: {test_player.id})...")
|
||||||
|
|
||||||
|
if test_player.is_goalie():
|
||||||
|
stats = await player_adapter.get_goalie_stats(test_player.id)
|
||||||
|
if stats:
|
||||||
|
print(f" ✓ Goalie Stats:")
|
||||||
|
print(f" Games Played: {stats.games_played}")
|
||||||
|
print(f" Wins: {stats.wins}")
|
||||||
|
print(f" Save %: {stats.save_percentage:.1f}%")
|
||||||
|
print(f" GAA: {stats.goals_against_average:.2f}")
|
||||||
|
else:
|
||||||
|
print(" ✗ No stats found")
|
||||||
|
else:
|
||||||
|
stats = await player_adapter.get_skater_stats(test_player.id)
|
||||||
|
if stats:
|
||||||
|
print(f" ✓ Skater Stats:")
|
||||||
|
print(f" Games Played: {stats.games_played}")
|
||||||
|
print(f" Goals: {stats.goals}")
|
||||||
|
print(f" Assists: {stats.assists}")
|
||||||
|
print(f" Points: {stats.points}")
|
||||||
|
print(f" Plus/Minus: {stats.plus_minus:+d}")
|
||||||
|
else:
|
||||||
|
print(" ✗ No stats found")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("NHL API INTEGRATION TEST")
|
||||||
|
print(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await test_teams()
|
||||||
|
await test_players()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL TESTS COMPLETED")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
176
test_rest_api.py
Normal file
176
test_rest_api.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Test script for REST API endpoints."""
|
||||||
|
import sys
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Test base URL
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_health_check():
|
||||||
|
"""Test health check endpoint."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING HEALTH CHECK")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(f"{BASE_URL}/health")
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
print(f"Response: {response.json()}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_root():
|
||||||
|
"""Test root endpoint."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING ROOT ENDPOINT")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(f"{BASE_URL}/")
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
data = response.json()
|
||||||
|
print(f"App Name: {data.get('name')}")
|
||||||
|
print(f"Version: {data.get('version')}")
|
||||||
|
print(f"Status: {data.get('status')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_players():
|
||||||
|
"""Test getting team players with stats."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING TEAM PLAYERS ENDPOINT")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
team_id = "TOR"
|
||||||
|
print(f"\nFetching players for team: {team_id}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(f"{BASE_URL}/api/v1/teams/{team_id}/players")
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Total players: {len(data)}")
|
||||||
|
|
||||||
|
# Show first 3 players
|
||||||
|
print("\nFirst 3 players:")
|
||||||
|
for player_data in data[:3]:
|
||||||
|
player = player_data["player"]
|
||||||
|
stats = player_data["stats"]
|
||||||
|
stats_type = player_data["stats_type"]
|
||||||
|
|
||||||
|
print(f"\n #{player.get('jersey_number', '??')} {player['full_name']}")
|
||||||
|
print(f" Position: {player['position']}")
|
||||||
|
|
||||||
|
if stats_type == "skater" and stats:
|
||||||
|
print(f" GP: {stats['games_played']}, G: {stats['goals']}, A: {stats['assists']}, P: {stats['points']}")
|
||||||
|
elif stats_type == "goalie" and stats:
|
||||||
|
print(f" GP: {stats['games_played']}, W: {stats['wins']}, SV%: {stats['save_percentage']:.3f}")
|
||||||
|
else:
|
||||||
|
print(" No stats available")
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_skaters():
|
||||||
|
"""Test getting team skaters only."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING TEAM SKATERS ENDPOINT")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
team_id = "TOR"
|
||||||
|
print(f"\nFetching skaters for team: {team_id}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(f"{BASE_URL}/api/v1/teams/{team_id}/players/skaters")
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Total skaters: {len(data)}")
|
||||||
|
|
||||||
|
# Show top 5 scorers
|
||||||
|
sorted_skaters = sorted(
|
||||||
|
data,
|
||||||
|
key=lambda x: x["stats"]["points"] if x["stats"] else 0,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nTop 5 scorers:")
|
||||||
|
for player_data in sorted_skaters[:5]:
|
||||||
|
player = player_data["player"]
|
||||||
|
stats = player_data["stats"]
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
print(f" {player['full_name']}: {stats['points']} pts ({stats['goals']}G, {stats['assists']}A)")
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_goalies():
|
||||||
|
"""Test getting team goalies only."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TESTING TEAM GOALIES ENDPOINT")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
team_id = "TOR"
|
||||||
|
print(f"\nFetching goalies for team: {team_id}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(f"{BASE_URL}/api/v1/teams/{team_id}/players/goalies")
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Total goalies: {len(data)}")
|
||||||
|
|
||||||
|
print("\nGoalie stats:")
|
||||||
|
for player_data in data:
|
||||||
|
player = player_data["player"]
|
||||||
|
stats = player_data["stats"]
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
print(f" {player['full_name']}:")
|
||||||
|
print(f" Record: {stats['wins']}-{stats['losses']}-{stats['overtime_losses']}")
|
||||||
|
print(f" SV%: {stats['save_percentage']:.3f}, GAA: {stats['goals_against_average']:.2f}")
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("REST API INTEGRATION TEST")
|
||||||
|
print(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"Base URL: {BASE_URL}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await test_health_check()
|
||||||
|
await test_root()
|
||||||
|
await test_team_players()
|
||||||
|
await test_skaters()
|
||||||
|
await test_team_goalies()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL TESTS COMPLETED")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
print("\n✗ ERROR: Cannot connect to server.")
|
||||||
|
print("Make sure the FastAPI server is running:")
|
||||||
|
print(" uvicorn src.presentation.api.main:app --reload")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user