Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import (
"github.com/grovecj/warzone-stats-tracker/internal/config"
"github.com/grovecj/warzone-stats-tracker/internal/database"
"github.com/grovecj/warzone-stats-tracker/internal/handler"
"github.com/grovecj/warzone-stats-tracker/internal/repository"
"github.com/grovecj/warzone-stats-tracker/internal/router"
"github.com/grovecj/warzone-stats-tracker/internal/service"
"github.com/grovecj/warzone-stats-tracker/web"
)

Expand Down Expand Up @@ -62,8 +64,18 @@ func main() {
}
}

// Repositories
playerRepo := repository.NewPlayerRepo(pool)
matchRepo := repository.NewMatchRepo(pool)

// Services
playerService := service.NewPlayerService(cachedAPI, playerRepo)
matchService := service.NewMatchService(cachedAPI, matchRepo, playerRepo)

// Handlers
adminHandler := handler.NewAdminHandler(cachedAPI)
playerHandler := handler.NewPlayerHandler(playerService)
matchHandler := handler.NewMatchHandler(matchService)

// Router
rawOrigins := strings.Split(cfg.CORSAllowedOrigins, ",")
Expand All @@ -74,8 +86,10 @@ func main() {
}
}
mux := router.New(origins, staticFS, router.Deps{
AdminHandler: adminHandler,
AdminAPIKey: cfg.AdminAPIKey,
AdminHandler: adminHandler,
PlayerHandler: playerHandler,
MatchHandler: matchHandler,
AdminAPIKey: cfg.AdminAPIKey,
})

srv := &http.Server{
Expand Down
12 changes: 6 additions & 6 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,15 @@ func New(inner codclient.CodClient, cfg Config) *CachedClient {
return c
}

func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*codclient.PlayerStats, error) {
key := fmt.Sprintf("stats:%s:%s:%s", platform, gamertag, mode)
func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*codclient.PlayerStats, error) {
key := fmt.Sprintf("stats:%s:%s:%s:%s", platform, gamertag, title, mode)

if val, hit := c.get(key); hit {
slog.Debug("cache hit", "key", key)
return val.(*codclient.PlayerStats), nil
}

stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, mode)
stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, title, mode)
if err != nil {
// Only serve stale data for transient errors (API down, rate limited)
if isTransientError(err) {
Expand All @@ -84,15 +84,15 @@ func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, m
return stats, nil
}

func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]codclient.Match, error) {
key := fmt.Sprintf("matches:%s:%s", platform, gamertag)
func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]codclient.Match, error) {
key := fmt.Sprintf("matches:%s:%s:%s:%s", platform, gamertag, title, mode)

if val, hit := c.get(key); hit {
slog.Debug("cache hit", "key", key)
return val.([]codclient.Match), nil
}

matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag)
matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag, title, mode)
if err != nil {
if isTransientError(err) {
if val, ok := c.getStale(key); ok {
Expand Down
192 changes: 160 additions & 32 deletions internal/codclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,17 @@ import (
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"

"resty.dev/v3"
)

const (
defaultTitle = "mw" // Modern Warfare / Warzone title code
)

// CodClient defines the interface for interacting with the Call of Duty API.
type CodClient interface {
GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error)
GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error)
GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error)
GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error)
UpdateToken(newToken string)
}

Expand All @@ -41,6 +38,7 @@ func New(baseURL, ssoToken string) CodClient {
c.SetRetryMaxWaitTime(5 * time.Second)
c.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
c.SetHeader("Accept", "application/json")
c.SetRedirectPolicy(resty.NoRedirectPolicy())

return &client{http: c, baseURL: baseURL, token: ssoToken}
}
Expand All @@ -51,25 +49,20 @@ func (c *client) authCookie() *http.Cookie {
return &http.Cookie{Name: "ACT_SSO_COOKIE", Value: c.token}
}

func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) {
func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error) {
if title == "" {
title = "mw"
}
if mode == "" {
mode = "wz"
}

encodedTag := url.PathEscape(gamertag)
endpoint := fmt.Sprintf("/stats/cod/v1/title/%s/platform/%s/gamer/%s/profile/type/%s",
defaultTitle, platform, encodedTag, mode)
title, platform, encodedTag, mode)

resp, err := c.http.R().
SetContext(ctx).
SetCookie(c.authCookie()).
Get(endpoint)
resp, err := c.doRequest(ctx, endpoint)
if err != nil {
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
return nil, ErrAPIUnavailable
}

if err := c.checkResponse(resp); err != nil {
return nil, err
}

Expand All @@ -82,21 +75,20 @@ func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode st
return stats, nil
}

func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) {
func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error) {
if title == "" {
title = "mw"
}
if mode == "" {
mode = "wz"
}

encodedTag := url.PathEscape(gamertag)
endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/wz/start/0/end/0/details",
defaultTitle, platform, encodedTag)
endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/%s/start/0/end/0/details",
title, platform, encodedTag, mode)

resp, err := c.http.R().
SetContext(ctx).
SetCookie(c.authCookie()).
Get(endpoint)
resp, err := c.doRequest(ctx, endpoint)
if err != nil {
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
return nil, ErrAPIUnavailable
}

if err := c.checkResponse(resp); err != nil {
return nil, err
}

Expand Down Expand Up @@ -141,9 +133,52 @@ func (c *client) UpdateToken(newToken string) {
slog.Info("cod api sso token updated")
}

// doRequest performs a GET request, handling redirect errors as expired tokens.
func (c *client) doRequest(ctx context.Context, endpoint string) (*resty.Response, error) {
resp, err := c.http.R().
SetContext(ctx).
SetCookie(c.authCookie()).
Get(endpoint)
if err != nil {
// resty returns an error on redirect when NoRedirectPolicy is set,
// but the response is still populated
if resp != nil && resp.StatusCode() >= 300 && resp.StatusCode() < 400 {
slog.Warn("cod api redirected, token likely expired",
"status", resp.StatusCode(),
"location", resp.Header().Get("Location"))
return nil, ErrTokenExpired
}
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
return nil, ErrAPIUnavailable
}

if err := c.checkResponse(resp); err != nil {
return nil, err
}
return resp, nil
}

func (c *client) checkResponse(resp *resty.Response) error {
switch resp.StatusCode() {
case http.StatusOK:
body := resp.String()
// CoD API sometimes returns 200 with HTML (login page) instead of JSON
if len(body) > 0 && body[0] == '<' {
slog.Error("cod api returned html instead of json",
"content_type", resp.Header().Get("Content-Type"),
"body_prefix", body[:min(200, len(body))])
return ErrTokenExpired
}
// CoD API returns 200 with {"status":"error"} for business-logic errors
var envelope struct {
Status string `json:"status"`
Data struct {
Message string `json:"message"`
} `json:"data"`
}
if json.Unmarshal([]byte(body), &envelope) == nil && envelope.Status == "error" {
return c.mapAPIError(envelope.Data.Message)
}
return nil
case http.StatusUnauthorized:
return ErrTokenExpired
Expand All @@ -153,20 +188,46 @@ func (c *client) checkResponse(resp *resty.Response) error {
return ErrPlayerNotFound
case http.StatusTooManyRequests:
return ErrRateLimited
case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect:
// CoD API redirects to login/store page when token is expired
slog.Warn("cod api redirected, token likely expired",
"status", resp.StatusCode(),
"location", resp.Header().Get("Location"))
return ErrTokenExpired
default:
if resp.StatusCode() >= 500 {
return ErrAPIUnavailable
}
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), resp.String())
body := resp.String()
slog.Error("cod api unexpected status", "status", resp.StatusCode(),
"body_prefix", body[:min(200, len(body))])
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), body)
}
}

// mapAPIError converts CoD API error messages into sentinel errors.
func (c *client) mapAPIError(msg string) error {
slog.Warn("cod api returned error", "message", msg)
switch {
case strings.Contains(msg, "not authenticated"):
return ErrTokenExpired
case strings.Contains(msg, "not allowed"):
return ErrPlayerNotFound
case strings.Contains(msg, "user not found"):
return ErrPlayerNotFound
case strings.Contains(msg, "rate limit"):
return ErrRateLimited
default:
return fmt.Errorf("cod api error: %s", msg)
}
}

func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag string) *PlayerStats {
stats := &PlayerStats{
Platform: platform,
Gamertag: gamertag,
Level: resp.Data.Level,
Prestige: resp.Data.Prestige,
Level: int(resp.Data.Level),
Prestige: int(resp.Data.Prestige),
}

if props, ok := resp.Data.Lifetime.All["properties"]; ok {
Expand All @@ -187,5 +248,72 @@ func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag stri
stats.DamageDone = int(props.DamageDone)
}

// Parse per-mode breakdown from resp.Data.Lifetime.Mode
stats.ModeBreakdown = c.parseModeBreakdown(resp.Data.Lifetime.Mode)

return stats
}

// parseModeBreakdown extracts per-mode stats from the API's Mode map.
func (c *client) parseModeBreakdown(modeData map[string]any) map[string]ModeStats {
if len(modeData) == 0 {
return nil
}

breakdown := make(map[string]ModeStats, len(modeData))
for modeName, modeVal := range modeData {
modeMap, ok := modeVal.(map[string]any)
if !ok {
continue
}
propsVal, ok := modeMap["properties"]
if !ok {
continue
}
props, ok := propsVal.(map[string]any)
if !ok {
continue
}

breakdown[modeName] = ModeStats{
Kills: toInt(props["kills"]),
Deaths: toInt(props["deaths"]),
KDRatio: toFloat(props["kdRatio"]),
Wins: toInt(props["wins"]),
Losses: toInt(props["losses"]),
MatchesPlayed: toInt(props["matchesPlayed"]),
ScorePerMin: toFloat(props["scorePerMinute"]),
TimePlayed: toInt(props["timePlayed"]),
TopFive: toInt(props["topFive"]),
TopTen: toInt(props["topTen"]),
TopTwentyFive: toInt(props["topTwentyFive"]),
}
}

if len(breakdown) == 0 {
return nil
}
return breakdown
}

func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
default:
return 0
}
}

func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
24 changes: 20 additions & 4 deletions internal/codclient/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,24 @@ type PlayerStats struct {
TopFive int `json:"topFive"`
TopTen int `json:"topTen"`
TopTwentyFive int `json:"topTwentyFive"`
Assists int `json:"assists"`
DamageDone int `json:"damageDone"`
Assists int `json:"assists"`
DamageDone int `json:"damageDone"`
ModeBreakdown map[string]ModeStats `json:"modeBreakdown,omitempty"`
}

// ModeStats represents per-mode statistics from the CoD API.
type ModeStats struct {
Kills int `json:"kills"`
Deaths int `json:"deaths"`
KDRatio float64 `json:"kdRatio"`
Wins int `json:"wins"`
Losses int `json:"losses"`
MatchesPlayed int `json:"matchesPlayed"`
ScorePerMin float64 `json:"scorePerMin"`
TimePlayed int `json:"timePlayed"`
TopFive int `json:"topFive"`
TopTen int `json:"topTen"`
TopTwentyFive int `json:"topTwentyFive"`
}

// Match represents a single match from the CoD API.
Expand Down Expand Up @@ -58,8 +74,8 @@ type profileResponse struct {
All map[string]statsBlock `json:"all"`
Mode map[string]any `json:"mode"`
} `json:"lifetime"`
Level int `json:"level"`
Prestige int `json:"prestige"`
Level float64 `json:"level"`
Prestige float64 `json:"prestige"`
} `json:"data"`
}

Expand Down
Loading