Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ echo 'export HYPIXEL_API_KEY=<your-key-here>' > cmd/.env
### Test it
```bash
curl 'localhost:8123/v1/playerdata?uuid=<some-uuid>'

# Or use the new V2 endpoint with cleaner JSON structure:
curl 'localhost:8123/v2/player/<some-uuid>'
```

## Creator info
Expand Down
195 changes: 195 additions & 0 deletions internal/ports/player_v2.go
Original file line number Diff line number Diff line change
@@ -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 = "<missing>"
}
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
}
106 changes: 106 additions & 0 deletions internal/ports/player_v2_converters.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading