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/player_v2.go b/internal/ports/player_v2.go new file mode 100644 index 00000000..06c4b44b --- /dev/null +++ b/internal/ports/player_v2.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 MakeGetPlayerV2Handler( + 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 := PlayerToPlayerErrorResponseDataV2("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 := PlayerToPlayerErrorResponseDataV2("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 := 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) + 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 := PlayerToPlayerResponseDataV2(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 := 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{ + "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/player_v2_converters.go b/internal/ports/player_v2_converters.go new file mode 100644 index 00000000..c2cb6640 --- /dev/null +++ b/internal/ports/player_v2_converters.go @@ -0,0 +1,106 @@ +package ports + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Amund211/flashlight/internal/domain" +) + +// Player V2 response structures that closely match the domain structs with json tags + +type PlayerResponseV2 struct { + Success bool `json:"success"` + Player *PlayerV2 `json:"player"` + Cause *string `json:"cause,omitempty"` +} + +type PlayerV2 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 int64 `json:"experience"` + + Overall GamemodeStatsV2 `json:"overall"` +} + +type GamemodeStatsV2 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) GamemodeStatsV2 { + return GamemodeStatsV2{ + 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 domainPlayerToPlayerV2(player *domain.PlayerPIT) *PlayerV2 { + if player == nil { + return nil + } + + return &PlayerV2{ + QueriedAt: player.QueriedAt, + UUID: player.UUID, + Displayname: player.Displayname, + LastLogin: player.LastLogin, + LastLogout: player.LastLogout, + MissingBedwarsStats: player.MissingBedwarsStats, + Experience: player.Experience, + Overall: domainGamemodeStatsToV2(&player.Overall), + } +} + +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 = domainPlayerToPlayerV2(player) + + data, err := json.Marshal(response) + if err != nil { + return []byte{}, fmt.Errorf("failed to marshal player V2 response: %w", err) + } + + return data, nil +} + +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 player V2 error response: %w", err) + } + + return data, nil +} \ No newline at end of file diff --git a/internal/ports/player_v2_converters_test.go b/internal/ports/player_v2_converters_test.go new file mode 100644 index 00000000..5ad8283b --- /dev/null +++ b/internal/ports/player_v2_converters_test.go @@ -0,0 +1,204 @@ +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 TestDomainPlayerToPlayerV2(t *testing.T) { + t.Parallel() + + t.Run("nil player", func(t *testing.T) { + t.Parallel() + + result := domainPlayerToPlayerV2(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, + 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 := domainPlayerToPlayerV2(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, int64(1500), v2Player.Experience) + + require.Equal(t, 90, v2Player.Overall.GamesPlayed) + require.Equal(t, 72, v2Player.Overall.Wins) + require.Nil(t, v2Player.Overall.Winstreak) + }) +} + +func TestPlayerToPlayerResponseDataV2(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 := PlayerToPlayerResponseDataV2(player) + require.NoError(t, err) + + var response PlayerResponseV2 + 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, int64(1000), response.Player.Experience) + }) + + t.Run("nil player", func(t *testing.T) { + t.Parallel() + + data, err := PlayerToPlayerResponseDataV2(nil) + require.NoError(t, err) + + var response PlayerResponseV2 + 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 TestPlayerToPlayerErrorResponseDataV2(t *testing.T) { + t.Parallel() + + t.Run("error response", func(t *testing.T) { + t.Parallel() + + cause := "Something went wrong" + data, err := PlayerToPlayerErrorResponseDataV2(cause) + require.NoError(t, err) + + var response PlayerResponseV2 + 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/player_v2_test.go b/internal/ports/player_v2_test.go new file mode 100644 index 00000000..3310fd88 --- /dev/null +++ b/internal/ports/player_v2_test.go @@ -0,0 +1,227 @@ +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 TestMakeGetPlayerV2Handler(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 := MakeGetPlayerV2Handler(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 := MakeGetPlayerV2Handler(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 := MakeGetPlayerV2Handler(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 := MakeGetPlayerV2Handler(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 := MakeGetPlayerV2Handler(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, + 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{ + 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 := MakeGetPlayerV2Handler(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`) + 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..9cd7c6d8 100644 --- a/main.go +++ b/main.go @@ -237,6 +237,20 @@ func main() { ), ) + handleFunc( + "OPTIONS /v2/player/{uuid}", + ports.BuildCORSHandler(allowedOrigins), + ) + handleFunc( + "GET /v2/player/{uuid}", + ports.MakeGetPlayerV2Handler( + getAndPersistPlayerWithCache, + allowedOrigins, + logger.With("port", "player-v2"), + sentryMiddleware, + ), + ) + // TODO: Remove handleFunc( "GET /playerdata",