v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
+161
-21
@@ -1,9 +1,12 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
@@ -166,11 +169,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
||||
// Register game routes with auth middleware
|
||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||
api.Get("/games/:gameId/history", h.GetGameHistory)
|
||||
api.Get("/games/:gameId/pgn", h.GetGamePGN)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign)
|
||||
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
|
||||
|
||||
@@ -508,6 +514,58 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// Resign ends the game in the opponent's favor.
|
||||
func (h *HTTPHandler) Resign(c *fiber.Ctx) error {
|
||||
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
|
||||
return processor.NewResignCommand(gameID, *body.(*core.ResignRequest))
|
||||
})
|
||||
}
|
||||
|
||||
// Draw offers, accepts, or declines a draw by agreement.
|
||||
func (h *HTTPHandler) Draw(c *fiber.Ctx) error {
|
||||
return h.executeWithBody(c, func(gameID string, body any) processor.Command {
|
||||
return processor.NewDrawCommand(gameID, *body.(*core.DrawRequest))
|
||||
})
|
||||
}
|
||||
|
||||
// executeWithBody runs a game command built from the validated request body,
|
||||
// with the caller's user ID for slot authorization.
|
||||
func (h *HTTPHandler) executeWithBody(c *fiber.Ctx, build func(gameID string, body any) processor.Command) error {
|
||||
gameID := c.Params("gameId")
|
||||
if !isValidUUID(gameID) {
|
||||
return invalidGameID(c)
|
||||
}
|
||||
body := c.Locals("validatedBody")
|
||||
if validated, _ := c.Locals("validated").(bool); !validated || body == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
cmd := build(gameID, body)
|
||||
cmd.UserID, _ = c.Locals("userID").(string)
|
||||
|
||||
resp := h.proc.Execute(cmd)
|
||||
if !resp.Success {
|
||||
return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
|
||||
}
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// statusForCode maps processor error codes to HTTP statuses.
|
||||
func statusForCode(code string) int {
|
||||
switch code {
|
||||
case core.ErrGameNotFound:
|
||||
return fiber.StatusNotFound
|
||||
case core.ErrUnauthorized:
|
||||
return fiber.StatusForbidden
|
||||
case core.ErrConflict:
|
||||
return fiber.StatusConflict
|
||||
case core.ErrInternalError:
|
||||
return fiber.StatusInternalServerError
|
||||
}
|
||||
return fiber.StatusBadRequest
|
||||
}
|
||||
|
||||
// DeleteGame unloads a live game while retaining its durable history.
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
@@ -563,34 +621,105 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
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",
|
||||
})
|
||||
return invalidGameID(c)
|
||||
}
|
||||
|
||||
history, err := h.svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game history not found", Code: core.ErrGameNotFound,
|
||||
})
|
||||
default:
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load game history", Code: core.ErrInternalError,
|
||||
})
|
||||
return storedGameError(c, err)
|
||||
}
|
||||
body, err := c.App().Config().JSONEncoder(history)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sendRevalidated(c, fiber.MIMEApplicationJSON, body)
|
||||
}
|
||||
|
||||
// GetGamePGN exports a stored game as a PGN attachment; ?ply=N stops after N
|
||||
// plies. Same visibility as the history.
|
||||
func (h *HTTPHandler) GetGamePGN(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
if !isValidUUID(gameID) {
|
||||
return invalidGameID(c)
|
||||
}
|
||||
ply, err := queryInt(c, "ply", -1, 0, 1_000_000)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pgn, err := h.svc.GetGamePGN(gameID, ply)
|
||||
if err != nil {
|
||||
return storedGameError(c, err)
|
||||
}
|
||||
// The name is built from a date and the hexadecimal game ID only.
|
||||
c.Set(fiber.HeaderContentDisposition, `attachment; filename="`+pgn.Filename+`"`)
|
||||
return sendRevalidated(c, "application/x-chess-pgn; charset=utf-8", []byte(pgn.Text))
|
||||
}
|
||||
|
||||
// sendRevalidated sends a stored-game representation with a strong ETag and
|
||||
// answers a matching If-None-Match with 304. Stored games still change
|
||||
// (moves, undo after a result), so caches must revalidate on every use
|
||||
// rather than keep a copy for a fixed time.
|
||||
func sendRevalidated(c *fiber.Ctx, contentType string, body []byte) error {
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
c.Set(fiber.HeaderETag, etag)
|
||||
c.Set(fiber.HeaderCacheControl, "private, no-cache")
|
||||
if etagMatches(c.Get(fiber.HeaderIfNoneMatch), etag) {
|
||||
return c.SendStatus(fiber.StatusNotModified)
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, contentType)
|
||||
return c.Send(body)
|
||||
}
|
||||
|
||||
// etagMatches applies the weak comparison If-None-Match requires (RFC 9110
|
||||
// section 13.1.2) to a comma-separated header value.
|
||||
func etagMatches(header, etag string) bool {
|
||||
for _, candidate := range strings.Split(header, ",") {
|
||||
candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
|
||||
if candidate == "*" || candidate == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return c.JSON(history)
|
||||
return false
|
||||
}
|
||||
|
||||
func invalidGameID(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
func storedGameError(c *fiber.Ctx, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game history not found", Code: core.ErrGameNotFound,
|
||||
})
|
||||
case errors.Is(err, service.ErrPlyOutOfRange):
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid ply", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
case errors.Is(err, service.ErrNotation):
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "game cannot be exported as PGN", Code: core.ErrInternalError,
|
||||
})
|
||||
default:
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load game history", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
|
||||
// pickers. The extra row used to compute nextOffset stays internal.
|
||||
// pickers. The extra row used to detect a next page stays internal.
|
||||
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(string)
|
||||
if !ok || userID == "" {
|
||||
@@ -612,9 +741,20 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
games, err := h.svc.GetUserGames(userID, limit, offset)
|
||||
games, err := h.svc.GetUserGames(userID, service.UserGamesOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Cursor: c.Query("cursor"),
|
||||
Color: c.Query("color"),
|
||||
Status: c.Query("status"),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidListQuery):
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid list query", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,20 @@ package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
nethttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
"chess/internal/server/storage"
|
||||
"chess/internal/server/storage/pgtest"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
|
||||
@@ -63,7 +70,7 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRoutesAreUnversioned(t *testing.T) {
|
||||
func TestAPIRoutes(t *testing.T) {
|
||||
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -74,9 +81,6 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
|
||||
registered := make(map[string]bool)
|
||||
for _, route := range app.GetRoutes(true) {
|
||||
registered[route.Method+" "+route.Path] = true
|
||||
if strings.HasPrefix(route.Path, "/api/v1") {
|
||||
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /health",
|
||||
@@ -87,19 +91,124 @@ func TestAPIRoutesAreUnversioned(t *testing.T) {
|
||||
"POST /api/games",
|
||||
"GET /api/games/:gameId",
|
||||
"GET /api/games/:gameId/history",
|
||||
"GET /api/games/:gameId/pgn",
|
||||
"POST /api/games/:gameId/moves",
|
||||
"POST /api/games/:gameId/resign",
|
||||
"POST /api/games/:gameId/draw",
|
||||
"GET /api/users/me/games",
|
||||
} {
|
||||
if !registered[want] {
|
||||
t.Errorf("route %s is not registered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil))
|
||||
func TestETagMatches(t *testing.T) {
|
||||
const etag = `"0123abcd"`
|
||||
for header, want := range map[string]bool{
|
||||
"": false,
|
||||
`"0123abcd"`: true,
|
||||
`W/"0123abcd"`: true,
|
||||
`"other", "0123abcd"`: true,
|
||||
`"other",W/"0123abcd" `: true,
|
||||
"*": true,
|
||||
`"0123abc"`: false,
|
||||
`0123abcd`: false,
|
||||
} {
|
||||
if got := etagMatches(header, etag); got != want {
|
||||
t.Errorf("etagMatches(%q) = %v, want %v", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredGameEndpoints(t *testing.T) {
|
||||
store, err := storage.NewStore(pgtest.DSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusNotFound {
|
||||
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode)
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { svc.Shutdown(time.Second) })
|
||||
|
||||
gameID := uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pos, _ := chess.ParseFEN(chess.StartFEN)
|
||||
for i, uci := range []string{"e2e4", "e7e5"} {
|
||||
m, err := pos.ParseUCI(uci)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := pos.Play(m)
|
||||
turn := core.ColorWhite
|
||||
if i == 1 {
|
||||
turn = core.ColorBlack
|
||||
}
|
||||
if err := svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
||||
ExpectedFEN: pos.FEN(), ExpectedState: core.StateOngoing, ExpectedTurn: turn,
|
||||
MoveUCI: uci, NewFEN: next.FEN(), State: core.StateOngoing,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pos = next
|
||||
}
|
||||
|
||||
app := NewFiberApp(nil, svc, Options{DevMode: true})
|
||||
get := func(path string, header ...string) (*nethttp.Response, string) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest("GET", path, nil)
|
||||
for i := 0; i+1 < len(header); i += 2 {
|
||||
request.Header.Set(header[i], header[i+1])
|
||||
}
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return response, string(body)
|
||||
}
|
||||
|
||||
response, body := get("/api/games/" + gameID + "/history")
|
||||
etag := response.Header.Get("ETag")
|
||||
if response.StatusCode != 200 || etag == "" || response.Header.Get("Cache-Control") != "private, no-cache" ||
|
||||
!strings.Contains(body, `"san":"e4"`) || !strings.Contains(body, `"pgnResult":"*"`) ||
|
||||
!strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("history: %d %v %s", response.StatusCode, response.Header, body)
|
||||
}
|
||||
if response, body = get("/api/games/"+gameID+"/history", "If-None-Match", etag); response.StatusCode != 304 || body != "" {
|
||||
t.Fatalf("revalidated history: %d %q", response.StatusCode, body)
|
||||
}
|
||||
|
||||
response, body = get("/api/games/" + gameID + "/pgn")
|
||||
if response.StatusCode != 200 || response.Header.Get("Content-Type") != "application/x-chess-pgn; charset=utf-8" ||
|
||||
!strings.HasPrefix(response.Header.Get("Content-Disposition"), `attachment; filename="chess-`) ||
|
||||
!strings.HasSuffix(body, "\n\n1. e4 e5 *\n") {
|
||||
t.Fatalf("pgn: %d %v\n%s", response.StatusCode, response.Header, body)
|
||||
}
|
||||
if response, body = get("/api/games/" + gameID + "/pgn?ply=1"); response.StatusCode != 200 ||
|
||||
!strings.HasSuffix(body, "\n\n1. e4 *\n") ||
|
||||
!strings.Contains(response.Header.Get("Content-Disposition"), "-ply1.pgn") {
|
||||
t.Fatalf("pgn ply 1: %d %v\n%s", response.StatusCode, response.Header, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]int{
|
||||
"/api/games/" + gameID + "/pgn?ply=3": 400,
|
||||
"/api/games/" + gameID + "/pgn?ply=-1": 400,
|
||||
"/api/games/" + gameID + "/pgn?ply=x": 400,
|
||||
"/api/games/" + uuid.NewString() + "/pgn": 404,
|
||||
"/api/games/" + uuid.NewString() + "/history": 404,
|
||||
"/api/games/not-a-uuid/pgn": 400,
|
||||
} {
|
||||
if response, body := get(path); response.StatusCode != want {
|
||||
t.Errorf("GET %s = %d %s, want %d", path, response.StatusCode, body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ func validationMiddleware(c *fiber.Ctx) error {
|
||||
requestType = &core.MoveRequest{}
|
||||
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
||||
requestType = &core.UndoRequest{}
|
||||
case strings.HasSuffix(path, "/resign") && method == fiber.MethodPost:
|
||||
requestType = &core.ResignRequest{}
|
||||
case strings.HasSuffix(path, "/draw") && method == fiber.MethodPost:
|
||||
requestType = &core.DrawRequest{}
|
||||
default:
|
||||
return c.Next() // No validation for unknown endpoints
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user