v0.1.1 api with fiber added, basic functionlity tested
This commit is contained in:
316
internal/transport/http/game_handler.go
Normal file
316
internal/transport/http/game_handler.go
Normal file
@ -0,0 +1,316 @@
|
||||
// FILE: internal/transport/http/game_handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
var req CreateGameRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
gameID := uuid.New().String()
|
||||
|
||||
// Create game with proper type conversion
|
||||
var fenArray []string
|
||||
if req.FEN != "" {
|
||||
fenArray = []string{req.FEN}
|
||||
}
|
||||
|
||||
err := h.svc.NewGame(
|
||||
gameID,
|
||||
core.PlayerType(req.White),
|
||||
core.PlayerType(req.Black),
|
||||
fenArray...,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "failed to create game",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build response - cache game instance
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Execute computer move if computer starts
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Log error but return game created successfully
|
||||
fmt.Printf("Warning: failed to execute initial computer move: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state, executing computer move if needed
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Auto-execute computer move if it's computer's turn
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(ErrorResponse{
|
||||
Error: "failed to execute computer move",
|
||||
Code: ErrInternalError,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// MakeMove submits a human player move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req MoveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
// Check game state BEFORE making move
|
||||
if g.State() != core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Move rejected - game over (state: %s)\n", g.State())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "game is over",
|
||||
Code: ErrGameOver,
|
||||
Details: fmt.Sprintf("game state: %s", g.State()),
|
||||
})
|
||||
}
|
||||
|
||||
// Verify it's human's turn
|
||||
currentPlayer := g.NextPlayer()
|
||||
if currentPlayer.Type != core.PlayerHuman {
|
||||
fmt.Printf("DEBUG: Move rejected - not human turn (current: %v, turn: %c)\n",
|
||||
currentPlayer.Type, g.NextTurn())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "not human player's turn",
|
||||
Code: ErrNotHumanTurn,
|
||||
Details: fmt.Sprintf("current turn: %c", g.NextTurn()),
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Attempting human move %s for game %s\n", req.Move, gameID)
|
||||
|
||||
// Make human move
|
||||
if err := h.svc.MakeHumanMove(gameID, req.Move); err != nil {
|
||||
fmt.Printf("DEBUG: Move failed: %v\n", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid move",
|
||||
Code: ErrInvalidMove,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get updated game state - refresh g
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Include human move info from LastResult
|
||||
if result := g.LastResult(); result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Human move successful, new state: %s, next turn: %c\n",
|
||||
g.State(), g.NextTurn())
|
||||
|
||||
// Execute computer response if needed
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Executing computer response\n")
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Computer move failed, but human move succeeded
|
||||
fmt.Printf("Warning: computer move failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req UndoRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
// Body parsing failed, use default
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if req.Count < 1 {
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if err := h.svc.Undo(gameID, req.Count); err != nil {
|
||||
// Determine if game not found or invalid undo
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "cannot undo moves",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Return updated game state
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
if err := h.svc.DeleteGame(gameID); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := h.svc.GetCurrentBoard(gameID)
|
||||
|
||||
// Generate ASCII board
|
||||
ascii := h.generateASCIIBoard(b)
|
||||
|
||||
return c.JSON(BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
Board: ascii,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper: Build standard game response - FIXED to use GetPlayer()
|
||||
func (h *HTTPHandler) buildGameResponse(gameID string, g *game.Game) GameResponse {
|
||||
whitePlayer := g.GetPlayer(core.ColorWhite)
|
||||
blackPlayer := g.GetPlayer(core.ColorBlack)
|
||||
|
||||
return GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: colorToString(g.NextTurn()),
|
||||
State: stateToString(g.State()),
|
||||
Moves: g.Moves(),
|
||||
Players: PlayersInfo{
|
||||
White: PlayerType(whitePlayer.Type),
|
||||
Black: PlayerType(blackPlayer.Type),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Execute computer move and update response - FIXED to accept game instance
|
||||
func (h *HTTPHandler) executeComputerMove(gameID string, g *game.Game, response *GameResponse) error {
|
||||
result, err := h.svc.MakeComputerMove(gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh game state after computer move
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
|
||||
// Update response fields
|
||||
response.FEN = g.CurrentFEN()
|
||||
response.Turn = colorToString(g.NextTurn())
|
||||
response.State = stateToString(g.State())
|
||||
response.Moves = g.Moves()
|
||||
|
||||
// Add computer move info
|
||||
if result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper: Generate ASCII board representation
|
||||
func (h *HTTPHandler) generateASCIIBoard(b *board.Board) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" a b c d e f g h\n")
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
sb.WriteString(fmt.Sprintf("%d ", 8-r))
|
||||
for f := 0; f < 8; f++ {
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
148
internal/transport/http/handler.go
Normal file
148
internal/transport/http/handler.go
Normal file
@ -0,0 +1,148 @@
|
||||
// FILE: internal/transport/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type HTTPHandler struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewHTTPHandler(svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(svc *service.Service, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(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,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes with rate limiting
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Rate limiter: 1/10 req/sec per IP with expiry
|
||||
maxReq := 1
|
||||
if devMode {
|
||||
maxReq = 10
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq, // Allow requests per second
|
||||
Expiration: 1 * time.Second, // Per second
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP from X-Forwarded-For chain
|
||||
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(ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
Storage: nil, // Use in-memory storage (default)
|
||||
SkipFailedRequests: false,
|
||||
SkipSuccessfulRequests: false,
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Register game routes
|
||||
api.Post("/games", h.CreateGame)
|
||||
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 requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: 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 := ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: 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 = ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
119
internal/transport/http/types.go
Normal file
119
internal/transport/http/types.go
Normal file
@ -0,0 +1,119 @@
|
||||
// FILE: internal/transport/http/types.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
)
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
White PlayerType `json:"white"` // 0=human, 1=computer
|
||||
Black PlayerType `json:"black"` // 0=human, 1=computer
|
||||
FEN string `json:"fen,omitempty"`
|
||||
}
|
||||
|
||||
type MoveRequest struct {
|
||||
Move string `json:"move"` // UCI format: "e2e4"
|
||||
}
|
||||
|
||||
type UndoRequest struct {
|
||||
Count int `json:"count,omitempty"` // default: 1
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersInfo `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type PlayersInfo struct {
|
||||
White PlayerType `json:"white"`
|
||||
Black PlayerType `json:"black"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
Player string `json:"player"` // "w" or "b"
|
||||
Score int `json:"score,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
type BoardResponse struct {
|
||||
FEN string `json:"fen"`
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Custom type for JSON marshaling of PlayerType
|
||||
type PlayerType core.PlayerType
|
||||
|
||||
func (p PlayerType) MarshalJSON() ([]byte, error) {
|
||||
// Map to int for JSON: 0=human, 1=computer
|
||||
return []byte(string('0' + p)), nil
|
||||
}
|
||||
|
||||
func (p *PlayerType) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 1 && data[0] >= '0' && data[0] <= '1' {
|
||||
*p = PlayerType(data[0] - '0')
|
||||
return nil
|
||||
}
|
||||
// Also accept string format for compatibility
|
||||
str := string(data)
|
||||
if str == `"human"` || str == "human" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == `"computer"` || str == "computer" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
} else if str == "0" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == "1" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func colorToString(c core.Color) string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func stateToString(s core.State) string {
|
||||
switch s {
|
||||
case core.StateOngoing:
|
||||
return "ongoing"
|
||||
case core.StateWhiteWins:
|
||||
return "white_wins"
|
||||
case core.StateBlackWins:
|
||||
return "black_wins"
|
||||
case core.StateDraw:
|
||||
return "draw"
|
||||
case core.StateStalemate:
|
||||
return "stalemate"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
Reference in New Issue
Block a user