From de3c838d5063561fb1bc9271c725e36d65c9b770 Mon Sep 17 00:00:00 2001 From: Abdessamad ANSSEM Date: Sat, 14 Dec 2024 14:17:09 +0100 Subject: [PATCH] classic league implementation is done --- client/client.go | 2 + dockerfile | 34 --------- endpoints/bootstrap.go | 1 + endpoints/leagues.go | 140 ++++++++++++++++++++++++++++++++++++++ endpoints/leagues_test.go | 88 ++++++++++++++++++++++++ models/league.go | 125 ++++++++++++++++++++++++++++++++++ 6 files changed, 356 insertions(+), 34 deletions(-) delete mode 100644 dockerfile create mode 100644 endpoints/leagues_test.go diff --git a/client/client.go b/client/client.go index 2ff6688..c939c76 100644 --- a/client/client.go +++ b/client/client.go @@ -26,6 +26,7 @@ type Client struct { Fixtures *endpoints.FixtureService Teams *endpoints.TeamService Managers *endpoints.ManagerService + Leagues *endpoints.LeagueService } func NewClient(opts ...Option) *Client { @@ -58,6 +59,7 @@ func NewClient(opts ...Option) *Client { c.Managers = endpoints.NewManagerService(c, c.Bootstrap) // standalone services c.Fixtures = endpoints.NewFixtureService(c) + c.Leagues = endpoints.NewLeagueService(c) return c } diff --git a/dockerfile b/dockerfile deleted file mode 100644 index 0f9386f..0000000 --- a/dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -# Stage 1: Build the Go application -FROM golang:1.23 AS builder - -# Set the Current Working Directory inside the container -WORKDIR /app - -# Copy go.mod and go.sum files to the working directory -COPY go.mod go.sum ./ - -# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed -RUN go mod download - -# Copy the entire project into the container -COPY . . - -# Build the Go app (only testing with player example) -RUN go build -o myapp ./examples/players/main.go - - -# Stage 2: Create a minimal image -FROM alpine:latest - -# Install necessary CA certificates for HTTPS requests -RUN apk --no-cache add ca-certificates - -# Set the Current Working Directory inside the container -WORKDIR /root/ - -# Copy the Pre-built binary file from the previous stage -COPY --from=builder /app/myapp . - -# Command to run the executable -CMD ["./myapp"] - diff --git a/endpoints/bootstrap.go b/endpoints/bootstrap.go index ba5915f..2821dd5 100644 --- a/endpoints/bootstrap.go +++ b/endpoints/bootstrap.go @@ -22,6 +22,7 @@ var ( gameweeksCacheTTL = 3 * time.Minute // Gameweeks status might change more often settingsCacheTTL = 24 * time.Hour // Game settings rarely change managerCacheTTL = 5 * time.Minute // Managers data updates frequently + leagueCacheTTL = 5 * time.Minute // Leagues update frequently ) func init() { diff --git a/endpoints/leagues.go b/endpoints/leagues.go index a29b68f..aa16a1f 100644 --- a/endpoints/leagues.go +++ b/endpoints/leagues.go @@ -1 +1,141 @@ package endpoints + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/AbdoAnss/go-fantasy-pl/api" + "github.com/AbdoAnss/go-fantasy-pl/models" +) + +const ( + classicLeagueEndpoint = "/leagues-classic/%d/standings/?page_standings=%d" + h2hLeagueEndpoint = "/leagues-h2h-matches/league/%d/" + maxPageCache = 3 // Only cache first 3 pages +) + +type LeagueService struct { + client api.Client +} + +func NewLeagueService(client api.Client) *LeagueService { + return &LeagueService{ + client: client, + } +} + +func (ls *LeagueService) GetClassicLeagueStandings(id, page int) (*models.ClassicLeague, error) { + // Only cache first few pages to prevent memory bloat + useCache := page <= maxPageCache + + if useCache { + cacheKey := fmt.Sprintf("classic_league_%d_page_%d", id, page) + if cached, found := sharedCache.Get(cacheKey); found { + if league, ok := cached.(*models.ClassicLeague); ok { + return league, nil + } + } + } + + endpoint := fmt.Sprintf(classicLeagueEndpoint, id, page) + resp, err := ls.client.Get(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to get league standings: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, fmt.Errorf("league with ID %d not found", id) + default: + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var league models.ClassicLeague + if err := json.Unmarshal(body, &league); err != nil { + return nil, fmt.Errorf("failed to decode league data: %w", err) + } + + if err := ls.validateLeague(&league); err != nil { + return nil, err + } + + if useCache { + cacheKey := fmt.Sprintf("classic_league_%d_page_%d", id, page) + sharedCache.Set(cacheKey, &league, leagueCacheTTL) + } + + return &league, nil +} + +/* +func (ls *LeagueService) GetH2HLeague(id int) (*models.H2HLeague, error) { + cacheKey := fmt.Sprintf("h2h_league_%d", id) + if cached, found := sharedCache.Get(cacheKey); found { + if league, ok := cached.(*models.H2HLeague); ok { + return league, nil + } + } + + endpoint := fmt.Sprintf(h2hLeagueEndpoint, id) + resp, err := ls.client.Get(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to get H2H league: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // Continue processing + case http.StatusNotFound: + return nil, fmt.Errorf("H2H league with ID %d not found", id) + default: + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var league models.H2HLeague + if err := json.Unmarshal(body, &league); err != nil { + return nil, fmt.Errorf("failed to decode H2H league data: %w", err) + } + + sharedCache.Set(cacheKey, &league, leagueCacheTTL) + return &league, nil +} +*/ + +func (ls *LeagueService) validateLeague(league *models.ClassicLeague) error { + if league == nil { + return fmt.Errorf("received nil league data") + } + if league.League.ID == 0 { + return fmt.Errorf("invalid league ID") + } + return nil +} + +func (ls *LeagueService) GetTotalPages(league *models.ClassicLeague) int { + if league == nil || len(league.Standings.Results) == 0 { + return 0 + } + + totalEntries := len(league.Standings.Results) + if league.League.GetMaxEntries() > 0 { + totalEntries = league.League.GetMaxEntries() + } + + entriesPerPage := 50 // FPL default + return (totalEntries + entriesPerPage - 1) / entriesPerPage +} diff --git a/endpoints/leagues_test.go b/endpoints/leagues_test.go new file mode 100644 index 0000000..32ed817 --- /dev/null +++ b/endpoints/leagues_test.go @@ -0,0 +1,88 @@ +package endpoints_test + +import ( + "testing" + + "github.com/AbdoAnss/go-fantasy-pl/client" + "github.com/stretchr/testify/assert" +) + +var testLeagueClient *client.Client + +func init() { + testLeagueClient = client.NewClient() +} + +func TestLeagueEndpoints(t *testing.T) { + t.Run("GetClassicLeague", func(t *testing.T) { + // INPT Fantasy LeagueID + leagueID := 1185652 + page := 1 + + league, err := testLeagueClient.Leagues.GetClassicLeagueStandings(leagueID, page) + assert.NoError(t, err, "expected no error when getting classic league") + assert.NotNil(t, league, "expected league to be returned") + + // Log league details + t.Logf("\nLeague Details:") + t.Logf("League: %s", league.GetLeagueInfo()) + t.Logf("Created: %s", league.League.GetCreationDate()) + t.Logf("Type: %s", league.League.LeagueType) + t.Logf("Last Updated: %s", league.GetUpdateTime()) + t.Logf("Max Entries: %d", league.League.GetMaxEntries()) + + // Log standings + t.Logf("\nTop 4 Managers:") + for _, manager := range league.GetTopManagers(4) { + t.Logf("%s", manager.GetManagerInfo()) + t.Logf(" Points: %d (GW: %d)", manager.Total, manager.EventTotal) + t.Logf(" Rank: %d %s", manager.Rank, manager.GetRankChangeString()) + } + + // Log pagination + t.Logf("\nPagination:") + t.Logf("Current: %s", league.Standings.GetPageInfo()) + t.Logf("Has Previous: %v", league.Standings.HasPreviousPage()) + t.Logf("Has Next: %v", league.Standings.HasNext) + }) + + t.Run("ValidateLeagueData", func(t *testing.T) { + leagueID := 1185652 + league, err := testLeagueClient.Leagues.GetClassicLeagueStandings(leagueID, 1) + assert.NoError(t, err) + + // Validate league structure + assert.Equal(t, leagueID, league.League.ID) + assert.NotEmpty(t, league.League.Name) + assert.NotZero(t, league.League.Created) + + // Validate standings + topManagers := league.GetTopManagers(1) + assert.NotEmpty(t, topManagers) + assert.Greater(t, topManagers[0].Entry, 0) + assert.NotEmpty(t, topManagers[0].GetManagerInfo()) + assert.GreaterOrEqual(t, topManagers[0].Total, 0) + }) + + t.Run("GetNonExistentLeague", func(t *testing.T) { + league, err := testLeagueClient.Leagues.GetClassicLeagueStandings(99999999, 1) + assert.Error(t, err) + assert.Nil(t, league) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("CacheConsistency", func(t *testing.T) { + leagueID := 1185652 + + league1, err := testLeagueClient.Leagues.GetClassicLeagueStandings(leagueID, 1) + assert.NoError(t, err) + + league2, err := testLeagueClient.Leagues.GetClassicLeagueStandings(leagueID, 1) + assert.NoError(t, err) + + assert.Equal(t, league1.GetLeagueInfo(), league2.GetLeagueInfo()) + assert.Equal(t, + league1.GetTopManagers(1)[0].GetManagerInfo(), + league2.GetTopManagers(1)[0].GetManagerInfo()) + }) +} diff --git a/models/league.go b/models/league.go index 2640e7f..4d27a90 100644 --- a/models/league.go +++ b/models/league.go @@ -1 +1,126 @@ package models + +import ( + "fmt" + "time" +) + +type ClassicLeague struct { + NewEntries NewEntries `json:"new_entries"` + LastUpdatedData time.Time `json:"last_updated_data"` + League League `json:"league"` + Standings Standings `json:"standings"` +} + +type NewEntries struct { + HasNext bool `json:"has_next"` + Page int `json:"page"` + Results []interface{} `json:"results"` // Assuming results can be of various types +} + +// League represents the league details. +type League struct { + ID int `json:"id"` + Name string `json:"name"` + Created time.Time `json:"created"` + Closed bool `json:"closed"` + MaxEntries *int `json:"max_entries"` // Pointer to allow null + LeagueType string `json:"league_type"` + Scoring string `json:"scoring"` + AdminEntry *int `json:"admin_entry"` // Pointer to allow null + StartEvent int `json:"start_event"` + CodePrivacy string `json:"code_privacy"` + HasCup bool `json:"has_cup"` + CupLeague int `json:"cup_league"` + Rank *int `json:"rank"` // Pointer to allow null +} + +// Standings represents the standings section of the Classic League. +type Standings struct { + HasNext bool `json:"has_next"` + Page int `json:"page"` + Results []LeagueManager `json:"results"` +} + +// Player represents an individual player's standings information. +type LeagueManager struct { + ID int `json:"id"` + EventTotal int `json:"event_total"` + PlayerName string `json:"player_name"` + Rank int `json:"rank"` + LastRank int `json:"last_rank"` + RankSort int `json:"rank_sort"` + Total int `json:"total"` + Entry int `json:"entry"` + EntryName string `json:"entry_name"` + HasPlayed bool `json:"has_played"` +} + +func (cl *ClassicLeague) GetLeagueInfo() string { + return fmt.Sprintf("%s (ID: %d)", cl.League.Name, cl.League.ID) +} + +func (cl *ClassicLeague) GetUpdateTime() string { + return cl.LastUpdatedData.Format(time.RFC822) +} + +func (cl *ClassicLeague) GetTopManagers(n int) []LeagueManager { + if n > len(cl.Standings.Results) { + n = len(cl.Standings.Results) + } + return cl.Standings.Results[:n] +} + +// League methods +func (l *League) GetMaxEntries() int { + if l.MaxEntries == nil { + return 0 + } + return *l.MaxEntries +} + +func (l *League) GetAdminEntry() int { + if l.AdminEntry == nil { + return 0 + } + return *l.AdminEntry +} + +func (l *League) GetRank() int { + if l.Rank == nil { + return 0 + } + return *l.Rank +} + +func (l *League) GetCreationDate() string { + return l.Created.Format("2006-01-02") +} + +// LeagueManager methods +func (lm *LeagueManager) GetManagerInfo() string { + return fmt.Sprintf("%s (%s)", lm.EntryName, lm.PlayerName) +} + +func (lm *LeagueManager) GetRankChange() int { + return lm.LastRank - lm.Rank +} + +func (lm *LeagueManager) GetRankChangeString() string { + change := lm.GetRankChange() + if change > 0 { + return fmt.Sprintf("↑%d", change) + } else if change < 0 { + return fmt.Sprintf("↓%d", -change) + } + return "→" +} + +// Standings methods +func (s *Standings) GetPageInfo() string { + return fmt.Sprintf("Page %d", s.Page) +} + +func (s *Standings) HasPreviousPage() bool { + return s.Page > 1 +}