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.
265 lines
7.4 KiB
Go
265 lines
7.4 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
|
|
app "knowfoolery/backend/services/game-session-service/internal/application/session"
|
|
domain "knowfoolery/backend/services/game-session-service/internal/domain/session"
|
|
sharederrors "knowfoolery/backend/shared/domain/errors"
|
|
"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 game-session service HTTP endpoint handlers.
|
|
type Handler struct {
|
|
service *app.Service
|
|
validator *validation.Validator
|
|
logger *logging.Logger
|
|
metrics *sharedmetrics.Metrics
|
|
}
|
|
|
|
// NewHandler creates a new handler set.
|
|
func NewHandler(
|
|
service *app.Service,
|
|
validator *validation.Validator,
|
|
logger *logging.Logger,
|
|
metrics *sharedmetrics.Metrics,
|
|
) *Handler {
|
|
return &Handler{
|
|
service: service,
|
|
validator: validator,
|
|
logger: logger,
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
// StartSession handles POST /sessions/start.
|
|
func (h *Handler) StartSession(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
|
|
var req StartSessionRequest
|
|
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)
|
|
}
|
|
|
|
res, err := h.service.StartSession(c.Context(), app.StartSessionInput{
|
|
PlayerID: claims.UserID,
|
|
BearerToken: bearerToken(c),
|
|
PreferredTheme: req.PreferredTheme,
|
|
Difficulty: req.Difficulty,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
h.recordRequestMetric("POST", "/sessions/start", fiber.StatusOK)
|
|
return httputil.OK(c, res)
|
|
}
|
|
|
|
// EndSession handles POST /sessions/end.
|
|
func (h *Handler) EndSession(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
|
|
var req EndSessionRequest
|
|
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)
|
|
}
|
|
|
|
summary, err := h.authorizeSession(c, req.SessionID, claims)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
if summary.ID == "" {
|
|
return httputil.SendError(c, domain.ErrSessionNotFound)
|
|
}
|
|
|
|
res, err := h.service.EndSession(c.Context(), app.EndSessionInput{
|
|
SessionID: req.SessionID,
|
|
Reason: req.Reason,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
h.recordRequestMetric("POST", "/sessions/end", fiber.StatusOK)
|
|
return httputil.OK(c, res)
|
|
}
|
|
|
|
// SubmitAnswer handles POST /sessions/:id/answer.
|
|
func (h *Handler) SubmitAnswer(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
sessionID := c.Params("id")
|
|
if _, err := h.authorizeSession(c, sessionID, claims); err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
var req SubmitAnswerRequest
|
|
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)
|
|
}
|
|
|
|
res, err := h.service.SubmitAnswer(c.Context(), app.SubmitAnswerInput{
|
|
SessionID: sessionID,
|
|
Answer: req.Answer,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/sessions/{id}/answer", fiber.StatusOK)
|
|
return httputil.OK(c, res)
|
|
}
|
|
|
|
// RequestHint handles POST /sessions/:id/hint.
|
|
func (h *Handler) RequestHint(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
sessionID := c.Params("id")
|
|
if _, err := h.authorizeSession(c, sessionID, claims); err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
res, err := h.service.RequestHint(c.Context(), app.RequestHintInput{SessionID: sessionID})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/sessions/{id}/hint", fiber.StatusOK)
|
|
return httputil.OK(c, res)
|
|
}
|
|
|
|
// GetSession handles GET /sessions/:id.
|
|
func (h *Handler) GetSession(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
sessionID := c.Params("id")
|
|
summary, err := h.authorizeSession(c, sessionID, claims)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
h.recordRequestMetric("GET", "/sessions/{id}", fiber.StatusOK)
|
|
return httputil.OK(c, summary)
|
|
}
|
|
|
|
// GetCurrentQuestion handles GET /sessions/:id/question.
|
|
func (h *Handler) GetCurrentQuestion(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, sharederrors.New(sharederrors.CodeUnauthorized, "unauthorized"))
|
|
}
|
|
sessionID := c.Params("id")
|
|
summary, err := h.authorizeSession(c, sessionID, claims)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
question, err := h.service.GetCurrentQuestion(c.Context(), sessionID)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
resp := fiber.Map{
|
|
"question": question,
|
|
"attempts_used": summary.CurrentAttempts,
|
|
"attempts_remaining": 3 - summary.CurrentAttempts,
|
|
"hint_used": summary.CurrentHintUsed,
|
|
}
|
|
if resp["attempts_remaining"].(int) < 0 {
|
|
resp["attempts_remaining"] = 0
|
|
}
|
|
|
|
h.recordRequestMetric("GET", "/sessions/{id}/question", fiber.StatusOK)
|
|
return httputil.OK(c, resp)
|
|
}
|
|
|
|
func (h *Handler) authorizeSession(c fiber.Ctx, sessionID string, claims authClaims) (*app.SessionSummary, error) {
|
|
summary, err := h.service.GetSession(c.Context(), sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if claims.IsAdmin || summary.PlayerID == claims.UserID {
|
|
return summary, nil
|
|
}
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
|
|
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("game-session-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),
|
|
"game-session-service",
|
|
).Inc()
|
|
}
|
|
|
|
type authClaims struct {
|
|
UserID string
|
|
Roles []string
|
|
IsAdmin bool
|
|
}
|
|
|
|
func authClaimsFromContext(c fiber.Ctx) authClaims {
|
|
roles := zitadel.GetUserRoles(c)
|
|
claims := authClaims{
|
|
UserID: zitadel.GetUserID(c),
|
|
Roles: roles,
|
|
}
|
|
for _, role := range roles {
|
|
if role == "admin" {
|
|
claims.IsAdmin = true
|
|
break
|
|
}
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func bearerToken(c fiber.Ctx) string {
|
|
authHeader := strings.TrimSpace(c.Get("Authorization"))
|
|
if authHeader == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
|
}
|