From f22c3de16ae7d4b5021a8bc3b8e2a82b5a003940 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:14:20 +0000 Subject: [PATCH 1/3] Initial plan From 10c5017fb37d7116c887b46b1b8f0f13243c8838 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:22:39 +0000 Subject: [PATCH 2/3] Implement V2 player handler with clean JSON response structure Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- README.md | 3 + internal/ports/v2_player.go | 195 ++++++++++++++++++ internal/ports/v2_player_converters.go | 114 +++++++++++ internal/ports/v2_player_converters_test.go | 209 +++++++++++++++++++ internal/ports/v2_player_test.go | 216 ++++++++++++++++++++ main.go | 14 ++ 6 files changed, 751 insertions(+) create mode 100644 internal/ports/v2_player.go create mode 100644 internal/ports/v2_player_converters.go create mode 100644 internal/ports/v2_player_converters_test.go create mode 100644 internal/ports/v2_player_test.go diff --git a/README.md b/README.md index 26432bcc..2bd51187 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ echo 'export HYPIXEL_API_KEY=' > cmd/.env ### Test it ```bash curl 'localhost:8123/v1/playerdata?uuid=' + +# Or use the new V2 endpoint with cleaner JSON structure: +curl 'localhost:8123/v2/player/' ``` ## Creator info diff --git a/internal/ports/v2_player.go b/internal/ports/v2_player.go new file mode 100644 index 00000000..d6f5ddd0 --- /dev/null +++ b/internal/ports/v2_player.go @@ -0,0 +1,195 @@ +package ports + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/logging" + "github.com/Amund211/flashlight/internal/ratelimiting" + "github.com/Amund211/flashlight/internal/reporting" + "github.com/Amund211/flashlight/internal/strutils" +) + +func MakeGetV2PlayerHandler( + getAndPersistPlayerWithCache app.GetAndPersistPlayerWithCache, + allowedOrigins *DomainSuffixes, + rootLogger *slog.Logger, + sentryMiddleware func(http.HandlerFunc) http.HandlerFunc, +) http.HandlerFunc { + ipLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(8), + ratelimiting.BurstSize(480), + ) + ipRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + ipLimiter, + ratelimiting.IPKeyFunc, + ) + userIDLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(2), + ratelimiting.BurstSize(120), + ) + userIDRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + // NOTE: Rate limiting based on user controlled value + userIDLimiter, + ratelimiting.UserIDKeyFunc, + ) + + makeOnLimitExceeded := func(rateLimiter ratelimiting.RequestRateLimiter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := logging.FromContext(ctx) + + statusCode := http.StatusTooManyRequests + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errorData, err := PlayerToV2PlayerErrorResponseData("Rate limit exceeded") + if err != nil { + w.Write([]byte(`{"success":false,"cause":"Rate limit exceeded"}`)) + } else { + w.Write(errorData) + } + + logger.Info("Returning response", "statusCode", statusCode, "reason", "ratelimit exceeded", "key", rateLimiter.KeyFor(r)) + } + } + + middleware := ComposeMiddlewares( + logging.NewRequestLoggerMiddleware(rootLogger), + sentryMiddleware, + reporting.NewAddMetaMiddleware("v2-player"), + BuildCORSMiddleware(allowedOrigins), + NewRateLimitMiddleware(ipRateLimiter, makeOnLimitExceeded(ipRateLimiter)), + NewRateLimitMiddleware(userIDRateLimiter, makeOnLimitExceeded(userIDRateLimiter)), + ) + + handler := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rawUUID := r.PathValue("uuid") + userID := r.Header.Get("X-User-Id") + ctx = reporting.SetUserIDInContext(ctx, userID) + if userID == "" { + userID = "" + } + ctx = logging.AddMetaToContext(ctx, + slog.String("userId", userID), + slog.String("uuid", rawUUID), + ) + logger := logging.FromContext(ctx) + + ctx = reporting.AddExtrasToContext(ctx, + map[string]string{ + "rawUUID": rawUUID, + }, + ) + + uuid, err := strutils.NormalizeUUID(rawUUID) + if err != nil { + statusCode := http.StatusBadRequest + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errorData, marshalErr := PlayerToV2PlayerErrorResponseData("Invalid UUID") + if marshalErr != nil { + w.Write([]byte(`{"success":false,"cause":"Invalid UUID"}`)) + } else { + w.Write(errorData) + } + + logger.Info("Returning response", "statusCode", statusCode, "reason", "invalid uuid") + return + } + + ctx = reporting.AddExtrasToContext(ctx, + map[string]string{ + "uuid": uuid, + }, + ) + + player, err := getAndPersistPlayerWithCache(ctx, uuid) + if errors.Is(err, domain.ErrPlayerNotFound) { + v2ResponseData, err := PlayerToV2PlayerResponseData(nil) + if err != nil { + logger.Error("Failed to convert player to V2 response", "error", err) + err = fmt.Errorf("failed to convert player to V2 response: %w", err) + reporting.Report(ctx, err) + statusCode := writeV2ErrorResponse(ctx, w, err) + logger.Info("Returning response", "statusCode", statusCode, "reason", "error") + return + } + + statusCode := 404 + logger.Info("Returning response", "statusCode", statusCode, "reason", "player not found") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(v2ResponseData) + return + } + + if err != nil { + // NOTE: GetAndPersistPlayerWithCache implementations handle their own error reporting + logger.Error("Error getting player data", "error", err) + statusCode := writeV2ErrorResponse(ctx, w, err) + logger.Info("Returning response", "statusCode", statusCode, "reason", "error") + return + } + + v2ResponseData, err := PlayerToV2PlayerResponseData(player) + if err != nil { + logger.Error("Failed to convert player to V2 response", "error", err) + + err = fmt.Errorf("failed to convert player to V2 response: %w", err) + reporting.Report(ctx, err) + + statusCode := writeV2ErrorResponse(ctx, w, err) + logger.Info("Returning response", "statusCode", statusCode, "reason", "error") + return + } + + logger.Info("Got V2 player data", "contentLength", len(v2ResponseData), "statusCode", 200) + + statusCode := 200 + logger.Info("Returning response", "statusCode", statusCode, "reason", "success") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(v2ResponseData) + } + + return middleware(handler) +} + +func writeV2ErrorResponse(ctx context.Context, w http.ResponseWriter, responseError error) int { + w.Header().Set("Content-Type", "application/json") + + // Unknown error: default to 500 + statusCode := http.StatusInternalServerError + cause := "Internal server error" + + if errors.Is(responseError, domain.ErrTemporarilyUnavailable) { + statusCode = http.StatusServiceUnavailable + cause = "Service temporarily unavailable" + } + + errorData, err := PlayerToV2PlayerErrorResponseData(cause) + if err != nil { + logging.FromContext(ctx).Error("Failed to marshal V2 error response", "error", err) + reporting.Report(ctx, fmt.Errorf("failed to marshal V2 error response: %w", err), map[string]string{ + "responseError": responseError.Error(), + }) + w.WriteHeader(statusCode) + w.Write([]byte(`{"success":false,"cause":"Internal server error"}`)) + return statusCode + } + + w.WriteHeader(statusCode) + w.Write(errorData) + + return statusCode +} \ No newline at end of file diff --git a/internal/ports/v2_player_converters.go b/internal/ports/v2_player_converters.go new file mode 100644 index 00000000..be9eaab4 --- /dev/null +++ b/internal/ports/v2_player_converters.go @@ -0,0 +1,114 @@ +package ports + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Amund211/flashlight/internal/domain" +) + +// V2 Player response structures that closely match the domain structs with json tags + +type V2PlayerResponse struct { + Success bool `json:"success"` + Player *V2Player `json:"player"` + Cause *string `json:"cause,omitempty"` +} + +type V2Player struct { + QueriedAt time.Time `json:"queriedAt"` + UUID string `json:"uuid"` + + Displayname *string `json:"displayname,omitempty"` + LastLogin *time.Time `json:"lastLogin,omitempty"` + LastLogout *time.Time `json:"lastLogout,omitempty"` + + MissingBedwarsStats bool `json:"missingBedwarsStats"` + Experience float64 `json:"experience"` + + Solo V2GamemodeStats `json:"solo"` + Doubles V2GamemodeStats `json:"doubles"` + Threes V2GamemodeStats `json:"threes"` + Fours V2GamemodeStats `json:"fours"` + Overall V2GamemodeStats `json:"overall"` +} + +type V2GamemodeStats struct { + Winstreak *int `json:"winstreak,omitempty"` + GamesPlayed int `json:"gamesPlayed"` + Wins int `json:"wins"` + Losses int `json:"losses"` + BedsBroken int `json:"bedsBroken"` + BedsLost int `json:"bedsLost"` + FinalKills int `json:"finalKills"` + FinalDeaths int `json:"finalDeaths"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` +} + +func domainGamemodeStatsToV2(stats *domain.GamemodeStatsPIT) V2GamemodeStats { + return V2GamemodeStats{ + Winstreak: stats.Winstreak, + GamesPlayed: stats.GamesPlayed, + Wins: stats.Wins, + Losses: stats.Losses, + BedsBroken: stats.BedsBroken, + BedsLost: stats.BedsLost, + FinalKills: stats.FinalKills, + FinalDeaths: stats.FinalDeaths, + Kills: stats.Kills, + Deaths: stats.Deaths, + } +} + +func domainPlayerToV2Player(player *domain.PlayerPIT) *V2Player { + if player == nil { + return nil + } + + return &V2Player{ + QueriedAt: player.QueriedAt, + UUID: player.UUID, + Displayname: player.Displayname, + LastLogin: player.LastLogin, + LastLogout: player.LastLogout, + MissingBedwarsStats: player.MissingBedwarsStats, + Experience: player.Experience, + Solo: domainGamemodeStatsToV2(&player.Solo), + Doubles: domainGamemodeStatsToV2(&player.Doubles), + Threes: domainGamemodeStatsToV2(&player.Threes), + Fours: domainGamemodeStatsToV2(&player.Fours), + Overall: domainGamemodeStatsToV2(&player.Overall), + } +} + +func PlayerToV2PlayerResponseData(player *domain.PlayerPIT) ([]byte, error) { + response := V2PlayerResponse{ + Success: true, + } + + // Always set the Player field, even if it's nil - this will ensure "player":null in JSON + response.Player = domainPlayerToV2Player(player) + + data, err := json.Marshal(response) + if err != nil { + return []byte{}, fmt.Errorf("failed to marshal V2 player response: %w", err) + } + + return data, nil +} + +func PlayerToV2PlayerErrorResponseData(cause string) ([]byte, error) { + response := V2PlayerResponse{ + Success: false, + Cause: &cause, + } + + data, err := json.Marshal(response) + if err != nil { + return []byte{}, fmt.Errorf("failed to marshal V2 player error response: %w", err) + } + + return data, nil +} \ No newline at end of file diff --git a/internal/ports/v2_player_converters_test.go b/internal/ports/v2_player_converters_test.go new file mode 100644 index 00000000..1b705ad6 --- /dev/null +++ b/internal/ports/v2_player_converters_test.go @@ -0,0 +1,209 @@ +package ports + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/domaintest" + "github.com/stretchr/testify/require" +) + +func TestDomainGamemodeStatsToV2(t *testing.T) { + t.Parallel() + + t.Run("with winstreak", func(t *testing.T) { + t.Parallel() + + winstreak := 15 + domainStats := &domain.GamemodeStatsPIT{ + Winstreak: &winstreak, + GamesPlayed: 100, + Wins: 80, + Losses: 20, + BedsBroken: 150, + BedsLost: 30, + FinalKills: 200, + FinalDeaths: 25, + Kills: 500, + Deaths: 100, + } + + v2Stats := domainGamemodeStatsToV2(domainStats) + + require.Equal(t, &winstreak, v2Stats.Winstreak) + require.Equal(t, 100, v2Stats.GamesPlayed) + require.Equal(t, 80, v2Stats.Wins) + require.Equal(t, 20, v2Stats.Losses) + require.Equal(t, 150, v2Stats.BedsBroken) + require.Equal(t, 30, v2Stats.BedsLost) + require.Equal(t, 200, v2Stats.FinalKills) + require.Equal(t, 25, v2Stats.FinalDeaths) + require.Equal(t, 500, v2Stats.Kills) + require.Equal(t, 100, v2Stats.Deaths) + }) + + t.Run("without winstreak", func(t *testing.T) { + t.Parallel() + + domainStats := &domain.GamemodeStatsPIT{ + Winstreak: nil, + GamesPlayed: 50, + Wins: 40, + Losses: 10, + BedsBroken: 75, + BedsLost: 15, + FinalKills: 100, + FinalDeaths: 12, + Kills: 250, + Deaths: 50, + } + + v2Stats := domainGamemodeStatsToV2(domainStats) + + require.Nil(t, v2Stats.Winstreak) + require.Equal(t, 50, v2Stats.GamesPlayed) + require.Equal(t, 40, v2Stats.Wins) + require.Equal(t, 10, v2Stats.Losses) + require.Equal(t, 75, v2Stats.BedsBroken) + require.Equal(t, 15, v2Stats.BedsLost) + require.Equal(t, 100, v2Stats.FinalKills) + require.Equal(t, 12, v2Stats.FinalDeaths) + require.Equal(t, 250, v2Stats.Kills) + require.Equal(t, 50, v2Stats.Deaths) + }) +} + +func TestDomainPlayerToV2Player(t *testing.T) { + t.Parallel() + + t.Run("nil player", func(t *testing.T) { + t.Parallel() + + result := domainPlayerToV2Player(nil) + require.Nil(t, result) + }) + + t.Run("full player", func(t *testing.T) { + t.Parallel() + + const UUID = "01234567-89ab-cdef-0123-456789abcdef" + now := time.Now() + displayname := "TestPlayer" + lastLogin := now.Add(-1 * time.Hour) + lastLogout := now.Add(-30 * time.Minute) + + domainPlayer := &domain.PlayerPIT{ + QueriedAt: now, + UUID: UUID, + Displayname: &displayname, + LastLogin: &lastLogin, + LastLogout: &lastLogout, + MissingBedwarsStats: true, + Experience: 1500.75, + Solo: domain.GamemodeStatsPIT{ + Winstreak: func() *int { i := 5; return &i }(), + GamesPlayed: 50, + Wins: 40, + }, + Doubles: domain.GamemodeStatsPIT{ + GamesPlayed: 25, + Wins: 20, + }, + Threes: domain.GamemodeStatsPIT{ + GamesPlayed: 10, + Wins: 8, + }, + Fours: domain.GamemodeStatsPIT{ + GamesPlayed: 5, + Wins: 4, + }, + Overall: domain.GamemodeStatsPIT{ + GamesPlayed: 90, + Wins: 72, + }, + } + + v2Player := domainPlayerToV2Player(domainPlayer) + + require.NotNil(t, v2Player) + require.Equal(t, now, v2Player.QueriedAt) + require.Equal(t, UUID, v2Player.UUID) + require.Equal(t, &displayname, v2Player.Displayname) + require.Equal(t, &lastLogin, v2Player.LastLogin) + require.Equal(t, &lastLogout, v2Player.LastLogout) + require.Equal(t, true, v2Player.MissingBedwarsStats) + require.Equal(t, 1500.75, v2Player.Experience) + + require.Equal(t, 50, v2Player.Solo.GamesPlayed) + require.Equal(t, 40, v2Player.Solo.Wins) + require.NotNil(t, v2Player.Solo.Winstreak) + require.Equal(t, 5, *v2Player.Solo.Winstreak) + + require.Equal(t, 25, v2Player.Doubles.GamesPlayed) + require.Equal(t, 20, v2Player.Doubles.Wins) + require.Nil(t, v2Player.Doubles.Winstreak) + }) +} + +func TestPlayerToV2PlayerResponseData(t *testing.T) { + t.Parallel() + + t.Run("success with player", func(t *testing.T) { + t.Parallel() + + const UUID = "01234567-89ab-cdef-0123-456789abcdef" + now := time.Now() + player := domaintest.NewPlayerBuilder(UUID, now).WithExperience(1000).BuildPtr() + + data, err := PlayerToV2PlayerResponseData(player) + require.NoError(t, err) + + var response V2PlayerResponse + err = json.Unmarshal(data, &response) + require.NoError(t, err) + + require.True(t, response.Success) + require.NotNil(t, response.Player) + require.Nil(t, response.Cause) + require.Equal(t, UUID, response.Player.UUID) + require.Equal(t, 1000.0, response.Player.Experience) + }) + + t.Run("nil player", func(t *testing.T) { + t.Parallel() + + data, err := PlayerToV2PlayerResponseData(nil) + require.NoError(t, err) + + var response V2PlayerResponse + err = json.Unmarshal(data, &response) + require.NoError(t, err) + + require.True(t, response.Success) + require.Nil(t, response.Player) + require.Nil(t, response.Cause) + }) +} + +func TestPlayerToV2PlayerErrorResponseData(t *testing.T) { + t.Parallel() + + t.Run("error response", func(t *testing.T) { + t.Parallel() + + cause := "Something went wrong" + data, err := PlayerToV2PlayerErrorResponseData(cause) + require.NoError(t, err) + + var response V2PlayerResponse + err = json.Unmarshal(data, &response) + require.NoError(t, err) + + require.False(t, response.Success) + require.Nil(t, response.Player) + require.NotNil(t, response.Cause) + require.Equal(t, cause, *response.Cause) + }) +} \ No newline at end of file diff --git a/internal/ports/v2_player_test.go b/internal/ports/v2_player_test.go new file mode 100644 index 00000000..76c9f732 --- /dev/null +++ b/internal/ports/v2_player_test.go @@ -0,0 +1,216 @@ +package ports + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/domaintest" + "github.com/stretchr/testify/require" +) + +func TestMakeGetV2PlayerHandler(t *testing.T) { + t.Parallel() + + const UUID = "01234567-89ab-cdef-0123-456789abcdef" + + now := time.Now() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + sentryMiddleware := func(next http.HandlerFunc) http.HandlerFunc { + return next + } + allowedOrigins, _ := NewDomainSuffixes("example.com") + + t.Run("success", func(t *testing.T) { + t.Parallel() + + player := domaintest.NewPlayerBuilder(UUID, now).WithExperience(1000).BuildPtr() + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + return player, nil + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/"+UUID, nil) + req.SetPathValue("uuid", UUID) + + getV2PlayerHandler(w, req) + + resp := w.Result() + + require.Equal(t, 200, resp.StatusCode) + body := w.Body.String() + + require.Contains(t, body, UUID) + require.Contains(t, body, `1000`) + require.Contains(t, body, `"success":true`) + require.Contains(t, body, `"player":{`) + + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) + + t.Run("client error: invalid uuid", func(t *testing.T) { + t.Parallel() + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + t.Helper() + t.Fatal("should not be called") + return nil, nil + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/1234-1234-1234", nil) + req.SetPathValue("uuid", "1234-1234-1234") + + getV2PlayerHandler(w, req) + + resp := w.Result() + require.Equal(t, 400, resp.StatusCode) + + body := w.Body.String() + require.Contains(t, body, `"success":false`) + require.Contains(t, body, `"cause":"Invalid UUID"`) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) + + t.Run("player not found", func(t *testing.T) { + t.Parallel() + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + return nil, fmt.Errorf("%w: couldn't find him", domain.ErrPlayerNotFound) + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/"+UUID, nil) + req.SetPathValue("uuid", UUID) + + getV2PlayerHandler(w, req) + + resp := w.Result() + require.Equal(t, 404, resp.StatusCode) + + body := w.Body.String() + require.Contains(t, body, `"success":true`) + require.Contains(t, body, `"player":null`) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) + + t.Run("provider temporarily unavailable", func(t *testing.T) { + t.Parallel() + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + return nil, fmt.Errorf("%w: hypixel down", domain.ErrTemporarilyUnavailable) + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/"+UUID, nil) + req.SetPathValue("uuid", UUID) + + getV2PlayerHandler(w, req) + + resp := w.Result() + require.Equal(t, 503, resp.StatusCode) + + body := w.Body.String() + require.Contains(t, body, `"success":false`) + require.Contains(t, body, `"cause":"Service temporarily unavailable"`) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) + + t.Run("internal server error", func(t *testing.T) { + t.Parallel() + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + return nil, fmt.Errorf("some unknown error") + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/"+UUID, nil) + req.SetPathValue("uuid", UUID) + + getV2PlayerHandler(w, req) + + resp := w.Result() + require.Equal(t, 500, resp.StatusCode) + + body := w.Body.String() + require.Contains(t, body, `"success":false`) + require.Contains(t, body, `"cause":"Internal server error"`) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) + + t.Run("player with all fields", func(t *testing.T) { + t.Parallel() + + displayname := "TestPlayer" + lastLogin := now.Add(-1 * time.Hour) + lastLogout := now.Add(-30 * time.Minute) + + player := &domain.PlayerPIT{ + QueriedAt: now, + UUID: UUID, + Displayname: &displayname, + LastLogin: &lastLogin, + LastLogout: &lastLogout, + MissingBedwarsStats: false, + Experience: 2500.5, + Solo: domain.GamemodeStatsPIT{ + Winstreak: func() *int { i := 10; return &i }(), + GamesPlayed: 100, + Wins: 80, + Losses: 20, + BedsBroken: 150, + BedsLost: 30, + FinalKills: 200, + FinalDeaths: 25, + Kills: 500, + Deaths: 100, + }, + Doubles: domain.GamemodeStatsPIT{ + Winstreak: nil, + GamesPlayed: 50, + Wins: 40, + Losses: 10, + BedsBroken: 75, + BedsLost: 15, + FinalKills: 100, + FinalDeaths: 12, + Kills: 250, + Deaths: 50, + }, + // Other gamemodes with default values + Threes: domain.GamemodeStatsPIT{}, + Fours: domain.GamemodeStatsPIT{}, + Overall: domain.GamemodeStatsPIT{}, + } + + getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + return player, nil + }, allowedOrigins, logger, sentryMiddleware) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v2/player/"+UUID, nil) + req.SetPathValue("uuid", UUID) + + getV2PlayerHandler(w, req) + + resp := w.Result() + require.Equal(t, 200, resp.StatusCode) + + body := w.Body.String() + require.Contains(t, body, `"success":true`) + require.Contains(t, body, `"displayname":"TestPlayer"`) + require.Contains(t, body, `"experience":2500.5`) + require.Contains(t, body, `"missingBedwarsStats":false`) + require.Contains(t, body, `"winstreak":10`) + require.Contains(t, body, `"gamesPlayed":100`) + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }) +} \ No newline at end of file diff --git a/main.go b/main.go index 1ec9555a..d92b4d39 100644 --- a/main.go +++ b/main.go @@ -237,6 +237,20 @@ func main() { ), ) + http.HandleFunc( + "OPTIONS /v2/player/{uuid}", + ports.BuildCORSHandler(allowedOrigins), + ) + http.HandleFunc( + "GET /v2/player/{uuid}", + ports.MakeGetV2PlayerHandler( + getAndPersistPlayerWithCache, + allowedOrigins, + logger.With("port", "v2-player"), + sentryMiddleware, + ), + ) + // TODO: Remove handleFunc( "GET /playerdata", From 421cae0109e082ba386ba062d2b15dbf6eecb956 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Oct 2025 22:43:14 +0000 Subject: [PATCH 3/3] Refactor V2 player handler: use v2 suffix naming, simplify to overall stats only, fix route definitions Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- internal/ports/{v2_player.go => player_v2.go} | 12 ++--- ..._converters.go => player_v2_converters.go} | 50 ++++++++----------- ...s_test.go => player_v2_converters_test.go} | 39 +++++++-------- .../{v2_player_test.go => player_v2_test.go} | 31 ++++++++---- main.go | 8 +-- 5 files changed, 69 insertions(+), 71 deletions(-) rename internal/ports/{v2_player.go => player_v2.go} (94%) rename internal/ports/{v2_player_converters.go => player_v2_converters.go} (57%) rename internal/ports/{v2_player_converters_test.go => player_v2_converters_test.go} (81%) rename internal/ports/{v2_player_test.go => player_v2_test.go} (87%) diff --git a/internal/ports/v2_player.go b/internal/ports/player_v2.go similarity index 94% rename from internal/ports/v2_player.go rename to internal/ports/player_v2.go index d6f5ddd0..06c4b44b 100644 --- a/internal/ports/v2_player.go +++ b/internal/ports/player_v2.go @@ -15,7 +15,7 @@ import ( "github.com/Amund211/flashlight/internal/strutils" ) -func MakeGetV2PlayerHandler( +func MakeGetPlayerV2Handler( getAndPersistPlayerWithCache app.GetAndPersistPlayerWithCache, allowedOrigins *DomainSuffixes, rootLogger *slog.Logger, @@ -49,7 +49,7 @@ func MakeGetV2PlayerHandler( w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - errorData, err := PlayerToV2PlayerErrorResponseData("Rate limit exceeded") + errorData, err := PlayerToPlayerErrorResponseDataV2("Rate limit exceeded") if err != nil { w.Write([]byte(`{"success":false,"cause":"Rate limit exceeded"}`)) } else { @@ -96,7 +96,7 @@ func MakeGetV2PlayerHandler( w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - errorData, marshalErr := PlayerToV2PlayerErrorResponseData("Invalid UUID") + errorData, marshalErr := PlayerToPlayerErrorResponseDataV2("Invalid UUID") if marshalErr != nil { w.Write([]byte(`{"success":false,"cause":"Invalid UUID"}`)) } else { @@ -115,7 +115,7 @@ func MakeGetV2PlayerHandler( player, err := getAndPersistPlayerWithCache(ctx, uuid) if errors.Is(err, domain.ErrPlayerNotFound) { - v2ResponseData, err := PlayerToV2PlayerResponseData(nil) + v2ResponseData, err := PlayerToPlayerResponseDataV2(nil) if err != nil { logger.Error("Failed to convert player to V2 response", "error", err) err = fmt.Errorf("failed to convert player to V2 response: %w", err) @@ -141,7 +141,7 @@ func MakeGetV2PlayerHandler( return } - v2ResponseData, err := PlayerToV2PlayerResponseData(player) + v2ResponseData, err := PlayerToPlayerResponseDataV2(player) if err != nil { logger.Error("Failed to convert player to V2 response", "error", err) @@ -177,7 +177,7 @@ func writeV2ErrorResponse(ctx context.Context, w http.ResponseWriter, responseEr cause = "Service temporarily unavailable" } - errorData, err := PlayerToV2PlayerErrorResponseData(cause) + errorData, err := PlayerToPlayerErrorResponseDataV2(cause) if err != nil { logging.FromContext(ctx).Error("Failed to marshal V2 error response", "error", err) reporting.Report(ctx, fmt.Errorf("failed to marshal V2 error response: %w", err), map[string]string{ diff --git a/internal/ports/v2_player_converters.go b/internal/ports/player_v2_converters.go similarity index 57% rename from internal/ports/v2_player_converters.go rename to internal/ports/player_v2_converters.go index be9eaab4..c2cb6640 100644 --- a/internal/ports/v2_player_converters.go +++ b/internal/ports/player_v2_converters.go @@ -8,15 +8,15 @@ import ( "github.com/Amund211/flashlight/internal/domain" ) -// V2 Player response structures that closely match the domain structs with json tags +// Player V2 response structures that closely match the domain structs with json tags -type V2PlayerResponse struct { - Success bool `json:"success"` - Player *V2Player `json:"player"` - Cause *string `json:"cause,omitempty"` +type PlayerResponseV2 struct { + Success bool `json:"success"` + Player *PlayerV2 `json:"player"` + Cause *string `json:"cause,omitempty"` } -type V2Player struct { +type PlayerV2 struct { QueriedAt time.Time `json:"queriedAt"` UUID string `json:"uuid"` @@ -24,17 +24,13 @@ type V2Player struct { LastLogin *time.Time `json:"lastLogin,omitempty"` LastLogout *time.Time `json:"lastLogout,omitempty"` - MissingBedwarsStats bool `json:"missingBedwarsStats"` - Experience float64 `json:"experience"` + MissingBedwarsStats bool `json:"missingBedwarsStats"` + Experience int64 `json:"experience"` - Solo V2GamemodeStats `json:"solo"` - Doubles V2GamemodeStats `json:"doubles"` - Threes V2GamemodeStats `json:"threes"` - Fours V2GamemodeStats `json:"fours"` - Overall V2GamemodeStats `json:"overall"` + Overall GamemodeStatsV2 `json:"overall"` } -type V2GamemodeStats struct { +type GamemodeStatsV2 struct { Winstreak *int `json:"winstreak,omitempty"` GamesPlayed int `json:"gamesPlayed"` Wins int `json:"wins"` @@ -47,8 +43,8 @@ type V2GamemodeStats struct { Deaths int `json:"deaths"` } -func domainGamemodeStatsToV2(stats *domain.GamemodeStatsPIT) V2GamemodeStats { - return V2GamemodeStats{ +func domainGamemodeStatsToV2(stats *domain.GamemodeStatsPIT) GamemodeStatsV2 { + return GamemodeStatsV2{ Winstreak: stats.Winstreak, GamesPlayed: stats.GamesPlayed, Wins: stats.Wins, @@ -62,12 +58,12 @@ func domainGamemodeStatsToV2(stats *domain.GamemodeStatsPIT) V2GamemodeStats { } } -func domainPlayerToV2Player(player *domain.PlayerPIT) *V2Player { +func domainPlayerToPlayerV2(player *domain.PlayerPIT) *PlayerV2 { if player == nil { return nil } - return &V2Player{ + return &PlayerV2{ QueriedAt: player.QueriedAt, UUID: player.UUID, Displayname: player.Displayname, @@ -75,39 +71,35 @@ func domainPlayerToV2Player(player *domain.PlayerPIT) *V2Player { LastLogout: player.LastLogout, MissingBedwarsStats: player.MissingBedwarsStats, Experience: player.Experience, - Solo: domainGamemodeStatsToV2(&player.Solo), - Doubles: domainGamemodeStatsToV2(&player.Doubles), - Threes: domainGamemodeStatsToV2(&player.Threes), - Fours: domainGamemodeStatsToV2(&player.Fours), Overall: domainGamemodeStatsToV2(&player.Overall), } } -func PlayerToV2PlayerResponseData(player *domain.PlayerPIT) ([]byte, error) { - response := V2PlayerResponse{ +func PlayerToPlayerResponseDataV2(player *domain.PlayerPIT) ([]byte, error) { + response := PlayerResponseV2{ Success: true, } // Always set the Player field, even if it's nil - this will ensure "player":null in JSON - response.Player = domainPlayerToV2Player(player) + response.Player = domainPlayerToPlayerV2(player) data, err := json.Marshal(response) if err != nil { - return []byte{}, fmt.Errorf("failed to marshal V2 player response: %w", err) + return []byte{}, fmt.Errorf("failed to marshal player V2 response: %w", err) } return data, nil } -func PlayerToV2PlayerErrorResponseData(cause string) ([]byte, error) { - response := V2PlayerResponse{ +func PlayerToPlayerErrorResponseDataV2(cause string) ([]byte, error) { + response := PlayerResponseV2{ Success: false, Cause: &cause, } data, err := json.Marshal(response) if err != nil { - return []byte{}, fmt.Errorf("failed to marshal V2 player error response: %w", err) + return []byte{}, fmt.Errorf("failed to marshal player V2 error response: %w", err) } return data, nil diff --git a/internal/ports/v2_player_converters_test.go b/internal/ports/player_v2_converters_test.go similarity index 81% rename from internal/ports/v2_player_converters_test.go rename to internal/ports/player_v2_converters_test.go index 1b705ad6..5ad8283b 100644 --- a/internal/ports/v2_player_converters_test.go +++ b/internal/ports/player_v2_converters_test.go @@ -75,13 +75,13 @@ func TestDomainGamemodeStatsToV2(t *testing.T) { }) } -func TestDomainPlayerToV2Player(t *testing.T) { +func TestDomainPlayerToPlayerV2(t *testing.T) { t.Parallel() t.Run("nil player", func(t *testing.T) { t.Parallel() - result := domainPlayerToV2Player(nil) + result := domainPlayerToPlayerV2(nil) require.Nil(t, result) }) @@ -101,7 +101,7 @@ func TestDomainPlayerToV2Player(t *testing.T) { LastLogin: &lastLogin, LastLogout: &lastLogout, MissingBedwarsStats: true, - Experience: 1500.75, + Experience: 1500, Solo: domain.GamemodeStatsPIT{ Winstreak: func() *int { i := 5; return &i }(), GamesPlayed: 50, @@ -125,7 +125,7 @@ func TestDomainPlayerToV2Player(t *testing.T) { }, } - v2Player := domainPlayerToV2Player(domainPlayer) + v2Player := domainPlayerToPlayerV2(domainPlayer) require.NotNil(t, v2Player) require.Equal(t, now, v2Player.QueriedAt) @@ -134,20 +134,15 @@ func TestDomainPlayerToV2Player(t *testing.T) { require.Equal(t, &lastLogin, v2Player.LastLogin) require.Equal(t, &lastLogout, v2Player.LastLogout) require.Equal(t, true, v2Player.MissingBedwarsStats) - require.Equal(t, 1500.75, v2Player.Experience) + require.Equal(t, int64(1500), v2Player.Experience) - require.Equal(t, 50, v2Player.Solo.GamesPlayed) - require.Equal(t, 40, v2Player.Solo.Wins) - require.NotNil(t, v2Player.Solo.Winstreak) - require.Equal(t, 5, *v2Player.Solo.Winstreak) - - require.Equal(t, 25, v2Player.Doubles.GamesPlayed) - require.Equal(t, 20, v2Player.Doubles.Wins) - require.Nil(t, v2Player.Doubles.Winstreak) + require.Equal(t, 90, v2Player.Overall.GamesPlayed) + require.Equal(t, 72, v2Player.Overall.Wins) + require.Nil(t, v2Player.Overall.Winstreak) }) } -func TestPlayerToV2PlayerResponseData(t *testing.T) { +func TestPlayerToPlayerResponseDataV2(t *testing.T) { t.Parallel() t.Run("success with player", func(t *testing.T) { @@ -157,10 +152,10 @@ func TestPlayerToV2PlayerResponseData(t *testing.T) { now := time.Now() player := domaintest.NewPlayerBuilder(UUID, now).WithExperience(1000).BuildPtr() - data, err := PlayerToV2PlayerResponseData(player) + data, err := PlayerToPlayerResponseDataV2(player) require.NoError(t, err) - var response V2PlayerResponse + var response PlayerResponseV2 err = json.Unmarshal(data, &response) require.NoError(t, err) @@ -168,16 +163,16 @@ func TestPlayerToV2PlayerResponseData(t *testing.T) { require.NotNil(t, response.Player) require.Nil(t, response.Cause) require.Equal(t, UUID, response.Player.UUID) - require.Equal(t, 1000.0, response.Player.Experience) + require.Equal(t, int64(1000), response.Player.Experience) }) t.Run("nil player", func(t *testing.T) { t.Parallel() - data, err := PlayerToV2PlayerResponseData(nil) + data, err := PlayerToPlayerResponseDataV2(nil) require.NoError(t, err) - var response V2PlayerResponse + var response PlayerResponseV2 err = json.Unmarshal(data, &response) require.NoError(t, err) @@ -187,17 +182,17 @@ func TestPlayerToV2PlayerResponseData(t *testing.T) { }) } -func TestPlayerToV2PlayerErrorResponseData(t *testing.T) { +func TestPlayerToPlayerErrorResponseDataV2(t *testing.T) { t.Parallel() t.Run("error response", func(t *testing.T) { t.Parallel() cause := "Something went wrong" - data, err := PlayerToV2PlayerErrorResponseData(cause) + data, err := PlayerToPlayerErrorResponseDataV2(cause) require.NoError(t, err) - var response V2PlayerResponse + var response PlayerResponseV2 err = json.Unmarshal(data, &response) require.NoError(t, err) diff --git a/internal/ports/v2_player_test.go b/internal/ports/player_v2_test.go similarity index 87% rename from internal/ports/v2_player_test.go rename to internal/ports/player_v2_test.go index 76c9f732..3310fd88 100644 --- a/internal/ports/v2_player_test.go +++ b/internal/ports/player_v2_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestMakeGetV2PlayerHandler(t *testing.T) { +func TestMakeGetPlayerV2Handler(t *testing.T) { t.Parallel() const UUID = "01234567-89ab-cdef-0123-456789abcdef" @@ -33,7 +33,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { player := domaintest.NewPlayerBuilder(UUID, now).WithExperience(1000).BuildPtr() - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { return player, nil }, allowedOrigins, logger, sentryMiddleware) @@ -59,7 +59,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { t.Run("client error: invalid uuid", func(t *testing.T) { t.Parallel() - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { t.Helper() t.Fatal("should not be called") return nil, nil @@ -83,7 +83,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { t.Run("player not found", func(t *testing.T) { t.Parallel() - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { return nil, fmt.Errorf("%w: couldn't find him", domain.ErrPlayerNotFound) }, allowedOrigins, logger, sentryMiddleware) @@ -105,7 +105,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { t.Run("provider temporarily unavailable", func(t *testing.T) { t.Parallel() - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { return nil, fmt.Errorf("%w: hypixel down", domain.ErrTemporarilyUnavailable) }, allowedOrigins, logger, sentryMiddleware) @@ -127,7 +127,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { t.Run("internal server error", func(t *testing.T) { t.Parallel() - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { return nil, fmt.Errorf("some unknown error") }, allowedOrigins, logger, sentryMiddleware) @@ -160,7 +160,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { LastLogin: &lastLogin, LastLogout: &lastLogout, MissingBedwarsStats: false, - Experience: 2500.5, + Experience: 2500, Solo: domain.GamemodeStatsPIT{ Winstreak: func() *int { i := 10; return &i }(), GamesPlayed: 100, @@ -188,10 +188,21 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { // Other gamemodes with default values Threes: domain.GamemodeStatsPIT{}, Fours: domain.GamemodeStatsPIT{}, - Overall: domain.GamemodeStatsPIT{}, + Overall: domain.GamemodeStatsPIT{ + Winstreak: func() *int { i := 10; return &i }(), + GamesPlayed: 100, + Wins: 80, + Losses: 20, + BedsBroken: 150, + BedsLost: 30, + FinalKills: 200, + FinalDeaths: 25, + Kills: 500, + Deaths: 100, + }, } - getV2PlayerHandler := MakeGetV2PlayerHandler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { + getV2PlayerHandler := MakeGetPlayerV2Handler(func(ctx context.Context, uuid string) (*domain.PlayerPIT, error) { return player, nil }, allowedOrigins, logger, sentryMiddleware) @@ -207,7 +218,7 @@ func TestMakeGetV2PlayerHandler(t *testing.T) { body := w.Body.String() require.Contains(t, body, `"success":true`) require.Contains(t, body, `"displayname":"TestPlayer"`) - require.Contains(t, body, `"experience":2500.5`) + require.Contains(t, body, `"experience":2500`) require.Contains(t, body, `"missingBedwarsStats":false`) require.Contains(t, body, `"winstreak":10`) require.Contains(t, body, `"gamesPlayed":100`) diff --git a/main.go b/main.go index d92b4d39..9cd7c6d8 100644 --- a/main.go +++ b/main.go @@ -237,16 +237,16 @@ func main() { ), ) - http.HandleFunc( + handleFunc( "OPTIONS /v2/player/{uuid}", ports.BuildCORSHandler(allowedOrigins), ) - http.HandleFunc( + handleFunc( "GET /v2/player/{uuid}", - ports.MakeGetV2PlayerHandler( + ports.MakeGetPlayerV2Handler( getAndPersistPlayerWithCache, allowedOrigins, - logger.With("port", "v2-player"), + logger.With("port", "player-v2"), sentryMiddleware, ), )