Files
chess/internal/http/handler.go

514 lines
14 KiB
Go

// FILE: internal/http/handler.go
package http
import (
"fmt"
"strconv"
"strings"
"time"
"chess/internal/core"
"chess/internal/processor"
"chess/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
const rateLimitRate = 10 // req/sec
type HTTPHandler struct {
proc *processor.Processor
svc *service.Service
}
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
// Initialize Fiber app
app := fiber.New(fiber.Config{
ErrorHandler: customErrorHandler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
})
// Global middleware (order matters)
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "${time} ${status} ${method} ${path} ${latency}\n",
}))
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
}))
// Health check (no rate limit)
app.Get("/health", h.Health)
// API v1 routes
api := app.Group("/api/v1")
// Auth routes with specific rate limiting
auth := api.Group("/auth")
// Register: 5 req/min per IP
auth.Post("/register", limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "5 registrations per minute allowed",
})
},
}), h.RegisterHandler)
// Login: 10 req/min per IP
auth.Post("/login", limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: "10 login attempts per minute allowed",
})
},
}), h.LoginHandler)
// Create token validator closure
validateToken := svc.ValidateToken
// Current user (requires auth)
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
// Game routes with standard rate limiting
maxReq := rateLimitRate
if devMode {
maxReq = rateLimitRate * 2
}
api.Use(limiter.New(limiter.Config{
Max: maxReq,
Expiration: 1 * time.Second,
KeyGenerator: func(c *fiber.Ctx) string {
if xff := c.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return xff
}
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
Code: core.ErrRateLimitExceeded,
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
})
},
}))
// Content-Type validation for POST and PUT requests
api.Use(contentTypeValidator)
// Middleware validation for sanitization
api.Use(validationMiddleware)
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Get("/games/:gameId/board", h.GetBoard)
return app
}
// contentTypeValidator ensures POST and PUT requests have application/json
func contentTypeValidator(c *fiber.Ctx) error {
method := c.Method()
if method == fiber.MethodPost || method == fiber.MethodPut {
contentType := c.Get("Content-Type")
if contentType != "application/json" && contentType != "" {
return c.Status(fiber.StatusUnsupportedMediaType).JSON(core.ErrorResponse{
Error: "unsupported media type",
Code: core.ErrInvalidContent,
Details: "Content-Type must be application/json",
})
}
}
return c.Next()
}
// customErrorHandler provides consistent error responses
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
response := core.ErrorResponse{
Error: "internal server error",
Code: core.ErrInternalError,
}
// Check if it's a Fiber error
if e, ok := err.(*fiber.Error); ok {
code = e.Code
response.Error = e.Message
// Map HTTP status to error codes
switch code {
case fiber.StatusNotFound:
response.Code = core.ErrGameNotFound
case fiber.StatusBadRequest:
response.Code = core.ErrInvalidRequest
case fiber.StatusTooManyRequests:
response.Code = core.ErrRateLimitExceeded
}
}
return c.Status(code).JSON(response)
}
// Health check endpoint with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"time": time.Now().Unix(),
"storage": h.svc.GetStorageHealth(),
})
}
// CreateGame creates a new game with specified player types
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
// Ensure middleware validation ran
validated, ok := c.Locals("validated").(bool)
if !ok || !validated {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected",
Code: core.ErrInternalError,
})
}
// Retrieve validated parsed body
validatedBody := c.Locals("validatedBody")
if validatedBody == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation data missing",
Code: core.ErrInternalError,
})
}
var req core.CreateGameRequest
req = *(validatedBody.(*core.CreateGameRequest))
// Retrieve authenticated user ID if available
userID, _ := c.Locals("userID").(string)
// Generate game ID via service with optional user context
cmd := processor.NewCreateGameCommand(req)
cmd.UserID = userID // Add user ID to command if authenticated
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
return c.Status(fiber.StatusBadRequest).JSON(resp.Error)
}
return c.Status(fiber.StatusCreated).JSON(resp.Data)
}
// ConfigurePlayers updates player configuration mid-game
func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Ensure middleware validation ran
validated, ok := c.Locals("validated").(bool)
if !ok || !validated {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected",
Code: core.ErrInternalError,
})
}
// Retrieve validated parsed body
validatedBody := c.Locals("validatedBody")
if validatedBody == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation data missing",
Code: core.ErrInternalError,
})
}
var req core.ConfigurePlayersRequest
req = *(validatedBody.(*core.ConfigurePlayersRequest))
// Create command and execute
cmd := processor.NewConfigurePlayersCommand(gameID, req)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
statusCode := fiber.StatusBadRequest
if resp.Error.Code == core.ErrGameNotFound {
statusCode = fiber.StatusNotFound
}
return c.Status(statusCode).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// GetGame retrieves current game state
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Check for long-polling parameters
waitStr := c.Query("wait", "false")
moveCountStr := c.Query("moveCount", "-1")
// Non-wait path - existing behavior
if waitStr != "true" {
// Create command and execute
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// Long-polling path
moveCount, err := strconv.Atoi(moveCountStr)
if err != nil {
moveCount = -1
}
// First check if game exists and get current state
g, err := h.svc.GetGame(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
Code: core.ErrGameNotFound,
})
}
currentMoveCount := len(g.Moves())
// If move count already different, return immediately
if moveCount != currentMoveCount {
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// Register wait with service
ctx := c.Context()
notify := h.svc.RegisterWait(gameID, moveCount, ctx)
// Wait for notification, timeout, or client disconnect
select {
case <-notify:
// State changed or timeout, get fresh game state
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Game might have been deleted
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
case <-ctx.Done():
// Client disconnected
return nil
}
}
// MakeMove submits a move
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Ensure middleware validation ran
validated, ok := c.Locals("validated").(bool)
if !ok || !validated {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected",
Code: core.ErrInternalError,
})
}
// Retrieve validated parsed body
validatedBody := c.Locals("validatedBody")
if validatedBody == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation data missing",
Code: core.ErrInternalError,
})
}
var req core.MoveRequest
req = *(validatedBody.(*core.MoveRequest))
// Create command and execute
cmd := processor.NewMakeMoveCommand(gameID, req)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response with correct status code
if !resp.Success {
statusCode := fiber.StatusBadRequest
if resp.Error.Code == core.ErrGameNotFound {
statusCode = fiber.StatusNotFound
}
return c.Status(statusCode).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// UndoMove undoes one or more moves
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Ensure middleware validation ran
validated, ok := c.Locals("validated").(bool)
if !ok || !validated {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation bypass detected",
Code: core.ErrInternalError,
})
}
// Retrieve validated parsed body
validatedBody := c.Locals("validatedBody")
if validatedBody == nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "validation data missing",
Code: core.ErrInternalError,
})
}
var req core.UndoRequest
req = *(validatedBody.(*core.UndoRequest))
// Create command and execute
cmd := processor.NewUndoMoveCommand(gameID, req)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
statusCode := fiber.StatusBadRequest
if resp.Error.Code == core.ErrGameNotFound {
statusCode = fiber.StatusNotFound
}
return c.Status(statusCode).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// DeleteGame ends and cleans up a game
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Create command and execute
cmd := processor.NewDeleteGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.SendStatus(fiber.StatusNoContent)
}
// GetBoard returns ASCII representation of the board
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
gameID := c.Params("gameId")
// Validate UUID format
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format",
Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
// Create command and execute
cmd := processor.NewGetBoardCommand(gameID)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
}