You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
210 lines
5.9 KiB
Go
210 lines
5.9 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
|
|
app "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
|
|
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
|
sharederrors "knowfoolery/backend/shared/domain/errors"
|
|
sharedtypes "knowfoolery/backend/shared/domain/types"
|
|
"knowfoolery/backend/shared/infra/auth/zitadel"
|
|
"knowfoolery/backend/shared/infra/observability/logging"
|
|
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
|
"knowfoolery/backend/shared/infra/utils/httputil"
|
|
"knowfoolery/backend/shared/infra/utils/validation"
|
|
)
|
|
|
|
// Handler implements leaderboard HTTP endpoints.
|
|
type Handler struct {
|
|
service *app.Service
|
|
validator *validation.Validator
|
|
logger *logging.Logger
|
|
metrics *sharedmetrics.Metrics
|
|
updateRequireAuth bool
|
|
defaultPageSize int
|
|
maxPageSize int
|
|
}
|
|
|
|
// NewHandler creates handler set.
|
|
func NewHandler(
|
|
service *app.Service,
|
|
validator *validation.Validator,
|
|
logger *logging.Logger,
|
|
metrics *sharedmetrics.Metrics,
|
|
updateRequireAuth bool,
|
|
defaultPageSize int,
|
|
maxPageSize int,
|
|
) *Handler {
|
|
if defaultPageSize <= 0 {
|
|
defaultPageSize = 20
|
|
}
|
|
if maxPageSize <= 0 {
|
|
maxPageSize = 100
|
|
}
|
|
return &Handler{
|
|
service: service,
|
|
validator: validator,
|
|
logger: logger,
|
|
metrics: metrics,
|
|
updateRequireAuth: updateRequireAuth,
|
|
defaultPageSize: defaultPageSize,
|
|
maxPageSize: maxPageSize,
|
|
}
|
|
}
|
|
|
|
// GetTop10 handles GET /leaderboard/top10.
|
|
func (h *Handler) GetTop10(c fiber.Ctx) error {
|
|
result, err := h.service.GetTop10(c.Context(), app.GetTopInput{
|
|
CompletionType: strings.TrimSpace(c.Query("completion_type")),
|
|
Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))),
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("GET", "/leaderboard/top10", fiber.StatusOK)
|
|
return httputil.OK(c, result)
|
|
}
|
|
|
|
// GetPlayerRanking handles GET /leaderboard/players/:id.
|
|
func (h *Handler) GetPlayerRanking(c fiber.Ctx) error {
|
|
playerID := strings.TrimSpace(c.Params("id"))
|
|
claims := authClaimsFromContext(c)
|
|
if !claims.IsAdmin && claims.UserID != playerID {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
page := atoiWithDefault(c.Query("page"), 1)
|
|
pageSize := atoiWithDefault(c.Query("page_size"), h.defaultPageSize)
|
|
if pageSize > h.maxPageSize {
|
|
pageSize = h.maxPageSize
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = h.defaultPageSize
|
|
}
|
|
|
|
result, err := h.service.GetPlayerRanking(c.Context(), app.GetPlayerRankingInput{
|
|
PlayerID: playerID,
|
|
Pagination: sharedtypes.Pagination{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("GET", "/leaderboard/players/{id}", fiber.StatusOK)
|
|
return httputil.OK(c, result)
|
|
}
|
|
|
|
// GetStats handles GET /leaderboard/stats.
|
|
func (h *Handler) GetStats(c fiber.Ctx) error {
|
|
stats, err := h.service.GetGlobalStats(c.Context(), app.GetStatsInput{
|
|
CompletionType: strings.TrimSpace(c.Query("completion_type")),
|
|
Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))),
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("GET", "/leaderboard/stats", fiber.StatusOK)
|
|
return httputil.OK(c, stats)
|
|
}
|
|
|
|
// Update handles POST /leaderboard/update.
|
|
func (h *Handler) Update(c fiber.Ctx) error {
|
|
if h.updateRequireAuth {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" || (!claims.IsAdmin && !claims.IsService) {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeForbidden, "forbidden"))
|
|
}
|
|
}
|
|
|
|
var req UpdateLeaderboardRequest
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err))
|
|
}
|
|
if err := h.validator.Validate(req); err != nil {
|
|
return httputil.SendError(c, err)
|
|
}
|
|
completedAt, err := time.Parse(time.RFC3339, req.CompletedAt)
|
|
if err != nil {
|
|
return httputil.SendError(
|
|
c,
|
|
sharederrors.Wrap(sharederrors.CodeValidationFailed, "completed_at must be RFC3339", err),
|
|
)
|
|
}
|
|
|
|
entry, err := h.service.UpdateScore(c.Context(), app.UpdateScoreInput{
|
|
SessionID: req.SessionID,
|
|
PlayerID: req.PlayerID,
|
|
PlayerName: req.PlayerName,
|
|
TotalScore: req.TotalScore,
|
|
QuestionsAsked: req.QuestionsAsked,
|
|
QuestionsCorrect: req.QuestionsCorrect,
|
|
HintsUsed: req.HintsUsed,
|
|
DurationSeconds: req.DurationSeconds,
|
|
CompletedAt: completedAt,
|
|
CompletionType: req.CompletionType,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/leaderboard/update", fiber.StatusOK)
|
|
return httputil.OK(c, entry)
|
|
}
|
|
|
|
func (h *Handler) sendMappedError(c fiber.Ctx, err error) error {
|
|
var domainErr *sharederrors.DomainError
|
|
if errors.As(err, &domainErr) {
|
|
return httputil.SendError(c, domainErr)
|
|
}
|
|
if h.logger != nil {
|
|
h.logger.WithError(err).Error("leaderboard-service internal error")
|
|
}
|
|
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err))
|
|
}
|
|
|
|
func (h *Handler) recordRequestMetric(method, endpoint string, status int) {
|
|
if h.metrics == nil {
|
|
return
|
|
}
|
|
h.metrics.HTTPRequestsTotal.WithLabelValues(
|
|
method, endpoint, strconv.Itoa(status), "leaderboard-service",
|
|
).Inc()
|
|
}
|
|
|
|
type authClaims struct {
|
|
UserID string
|
|
IsAdmin bool
|
|
IsService bool
|
|
}
|
|
|
|
func authClaimsFromContext(c fiber.Ctx) authClaims {
|
|
roles := zitadel.GetUserRoles(c)
|
|
claims := authClaims{UserID: zitadel.GetUserID(c)}
|
|
for _, role := range roles {
|
|
if role == "admin" {
|
|
claims.IsAdmin = true
|
|
}
|
|
if role == "service" {
|
|
claims.IsService = true
|
|
}
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func atoiWithDefault(v string, d int) int {
|
|
if strings.TrimSpace(v) == "" {
|
|
return d
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return d
|
|
}
|
|
return n
|
|
}
|