From 6c8384ce7a36a867c90d7402a5a9f6e7ab174dbc Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Tue, 10 Feb 2026 18:31:53 +0300 Subject: [PATCH] feat: command validation --- internal/model/rest/command.go | 28 +++++++++--- internal/router/command_test.go | 65 +++++++++++++++++++++++---- internal/router/handler/command.go | 64 +++++++++++++++++++++----- internal/router/router.go | 16 +++++-- internal/router/router_helper_test.go | 11 +++++ internal/router/validator.go | 24 ++++++++++ 6 files changed, 178 insertions(+), 30 deletions(-) diff --git a/internal/model/rest/command.go b/internal/model/rest/command.go index d0afd33..1a675a9 100644 --- a/internal/model/rest/command.go +++ b/internal/model/rest/command.go @@ -3,15 +3,15 @@ package rest import "encoding/json" type Command struct { - Actor string `json:"actor" binding:"required,notblank"` - Commands []json.RawMessage `json:"cmd" binding:"min=1"` + Actor string `json:"actor" binding:"required,notblank"` + Commands []json.RawMessage `json:"cmd" binding:"min=1"` } type CommandType string const ( - CommandTypeRaceQuit CommandType = "quit" - CommandTypeRaceVote CommandType = "vote" + CommandTypeRaceQuit CommandType = "raceQuit" + CommandTypeRaceVote CommandType = "raceVote" CommandTypeRaceRelation CommandType = "declarePeace" CommandTypeShipClassCreate CommandType = "shipClassCreate" CommandTypeShipClassMerge CommandType = "shipClassMerge" @@ -36,16 +36,30 @@ const ( ) type CommandMeta struct { - Type CommandType `json:"@type"` + Type CommandType `json:"@type" binding:"required,notblank"` } -type CommandVote struct { +type CommandRaceQuit struct { + CommandMeta +} + +type CommandRaceVote struct { CommandMeta Recipient string `json:"recipient" binding:"required,notblank"` } -type CommandUpdateRelation struct { +type CommandRaceRelation struct { CommandMeta Opponent string `json:"recipient" binding:"required,notblank"` Relation string `json:"relation" binding:"required,notblank"` } + +type CommandShipClassCreate struct { + CommandMeta + Name string `json:"name" binding:"required,notblank"` + Drive float64 `json:"drive" binding:"eq=0|gte=1"` + Armament int `json:"armament" binding:"ammoWeapons=Weapons"` + Weapons float64 `json:"weapons" binding:"ammoWeapons=Armament"` + Shields float64 `json:"shields" binding:"eq=0|gte=1"` + Cargo float64 `json:"cargo" binding:"eq=0|gte=1"` +} diff --git a/internal/router/command_test.go b/internal/router/command_test.go index fb293b6..973ec78 100644 --- a/internal/router/command_test.go +++ b/internal/router/command_test.go @@ -10,13 +10,17 @@ import ( "github.com/stretchr/testify/assert" ) +var ( + commandNoErrorCode = http.StatusNoContent +) + func TestCommand(t *testing.T) { r := setupRouter() - payload := rest.Command{ + payload := &rest.Command{ Actor: "SomeRace", Commands: []json.RawMessage{ - encodeCommand(&rest.CommandVote{ + encodeCommand(&rest.CommandRaceVote{ CommandMeta: rest.CommandMeta{Type: rest.CommandTypeRaceVote}, Recipient: "AnotherRace", }), @@ -27,7 +31,7 @@ func TestCommand(t *testing.T) { req, _ := http.NewRequest("PUT", "/api/v1/command", asBody(payload)) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code, w.Body) + assert.Equal(t, commandNoErrorCode, w.Code, w.Body) // error: notblank validator payload.Actor = "" @@ -45,7 +49,7 @@ func TestCommand(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code, w.Body) // error: no commands - payload = rest.Command{ + payload = &rest.Command{ Actor: "SomeRace", } @@ -56,10 +60,53 @@ func TestCommand(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code, w.Body) } -func encodeCommand(cmd any) json.RawMessage { - v, err := json.Marshal(cmd) - if err != nil { - panic(err) +func TestCommandShipClassCreate(t *testing.T) { + r := setupRouter() + + for _, tc := range []struct { + D float64 + A int + W, S, C float64 + expectStatus int + description string + }{ + {1, 0, 0, 0, 0, commandNoErrorCode, "Simple Drone"}, + {1, 1, 1, 0, 0, commandNoErrorCode, "Armed Drone"}, + {1, 0, 0, 1, 0, commandNoErrorCode, "Shielded Drone"}, + {1, 0, 0, 0, 1, commandNoErrorCode, "Carrying Drone"}, + {-0.5, 0, 0, 0, 0, http.StatusBadRequest, "Drive less than 0"}, + {0.9, 0, 0, 0, 0, http.StatusBadRequest, "Drive less than 1"}, + {1, 1, 0, 0, 0, http.StatusBadRequest, "Ammo without Weapons"}, + {1, 0, 1, 0, 0, http.StatusBadRequest, "Weapons without Ammo"}, + {1, -1, 1, 0, 0, http.StatusBadRequest, "Ammo less than 0"}, + {1, 1, 0.9, 0, 0, http.StatusBadRequest, "Weapons less than 1"}, + {1, 1, -0.5, 0, 0, http.StatusBadRequest, "Weapons less than 0"}, + {1, 0, 0, -0.5, 0, http.StatusBadRequest, "Shields less than 0"}, + {1, 0, 0, 0.9, 0, http.StatusBadRequest, "Shields less than 1"}, + {1, 0, 0, 0, -0.5, http.StatusBadRequest, "Cargo less than 0"}, + {1, 0, 0, 0, 0.9, http.StatusBadRequest, "Cargo less than 1"}, + } { + t.Run(tc.description, func(t *testing.T) { + payload := &rest.Command{ + Actor: "SomeRace", + Commands: []json.RawMessage{ + encodeCommand(&rest.CommandShipClassCreate{ + CommandMeta: rest.CommandMeta{Type: rest.CommandTypeShipClassCreate}, + Name: "Ship", + Drive: tc.D, + Armament: tc.A, + Weapons: tc.W, + Shields: tc.S, + Cargo: tc.C, + }), + }, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/v1/command", asBody(payload)) + r.ServeHTTP(w, req) + + assert.Equal(t, tc.expectStatus, w.Code, w.Body) + }) } - return v } diff --git a/internal/router/handler/command.go b/internal/router/handler/command.go index ea47c5e..aa960c6 100644 --- a/internal/router/handler/command.go +++ b/internal/router/handler/command.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" + "github.com/go-playground/validator/v10" "github.com/iliadenisov/galaxy/internal/controller" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" "github.com/iliadenisov/galaxy/internal/model/rest" ) @@ -43,27 +45,67 @@ func parseCommand(actor string, c json.RawMessage) (Command, error) { return nil, err } switch t := meta.Type; t { + case rest.CommandTypeRaceQuit: + return commandRaceQuit(actor) case rest.CommandTypeRaceVote: - return giveVotes(actor, c) + return commandRaceVote(actor, c) case rest.CommandTypeRaceRelation: - return updateRelation(actor, c) + return commandRaceRelation(actor, c) + case rest.CommandTypeShipClassCreate: + return commandShipClassCreate(actor, c) default: return nil, fmt.Errorf("unknown comman type: %s", t) } } -func giveVotes(actor string, c json.RawMessage) (Command, error) { - var v rest.CommandVote - if err := json.Unmarshal(c, &v); err != nil { - return nil, err - } - return func(c controller.Ctrl) error { return c.RaceVote(actor, v.Recipient) }, nil +func commandRaceQuit(actor string) (Command, error) { + return func(c controller.Ctrl) error { return c.RaceQuit(actor) }, nil } -func updateRelation(actor string, c json.RawMessage) (Command, error) { - var v rest.CommandUpdateRelation +func commandRaceVote(actor string, c json.RawMessage) (Command, error) { + var v rest.CommandRaceVote if err := json.Unmarshal(c, &v); err != nil { return nil, err } - return func(c controller.Ctrl) error { return c.RaceRelation(actor, v.Opponent, v.Relation) }, nil + if err := validateCommand(v); err != nil { + return nil, err + } + return func(c controller.Ctrl) error { + return c.RaceVote(actor, v.Recipient) + }, nil +} + +func commandRaceRelation(actor string, c json.RawMessage) (Command, error) { + var v rest.CommandRaceRelation + if err := json.Unmarshal(c, &v); err != nil { + return nil, err + } + if err := validateCommand(v); err != nil { + return nil, err + } + return func(c controller.Ctrl) error { + return c.RaceRelation(actor, v.Opponent, v.Relation) + }, nil +} + +func commandShipClassCreate(actor string, c json.RawMessage) (Command, error) { + v := new(rest.CommandShipClassCreate) + if err := json.Unmarshal(c, v); err != nil { + return nil, err + } + if err := validateCommand(v); err != nil { + return nil, err + } + return func(c controller.Ctrl) error { + return c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo) + }, nil +} + +func validateCommand(v any) error { + if ve, ok := binding.Validator.Engine().(*validator.Validate); ok { + if err := ve.Struct(v); err != nil { + return err + } + } + return nil } diff --git a/internal/router/router.go b/internal/router/router.go index d56cbaf..556b42f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,6 +2,7 @@ package router import ( "fmt" + "io" "net/http" "os" @@ -25,6 +26,7 @@ func (r Router) Run() error { } func NewRouter() Router { + gin.SetMode(gin.ReleaseMode) return NewRouterExecutor(handler.NewDefaultExecutor()) } @@ -33,17 +35,25 @@ func NewRouterExecutor(executor handler.CommandExecutor) Router { } func setupRouter(executor handler.CommandExecutor) *gin.Engine { - gin.SetMode(gin.ReleaseMode) r := gin.New() // Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release. - r.Use(gin.LoggerWithFormatter(logFormatter)) + logConfig := &gin.LoggerConfig{Formatter: logFormatter} + if gin.Mode() != gin.DebugMode { + logConfig.Output = io.Discard + } + r.Use(gin.LoggerWithConfig(*logConfig)) // Recovery middleware recovers from any panics and writes a 500 if there was one. r.Use(gin.CustomRecovery(recoveryHandler)) if v, ok := binding.Validator.Engine().(*validator.Validate); ok { - v.RegisterValidation("notblank", notBlankStringValidator) + if err := v.RegisterValidation("notblank", notBlankStringValidator); err != nil { + panic(err) + } + if err := v.RegisterValidation("ammoWeapons", armamentWithWeaponsValidator); err != nil { + panic(err) + } } groupV1 := r.Group("/api/v1") diff --git a/internal/router/router_helper_test.go b/internal/router/router_helper_test.go index 3566a55..c1e0688 100644 --- a/internal/router/router_helper_test.go +++ b/internal/router/router_helper_test.go @@ -1,6 +1,8 @@ package router_test import ( + "encoding/json" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/iliadenisov/galaxy/internal/model/rest" @@ -24,5 +26,14 @@ func (e *dummyExecutor) GameState() (rest.StateResponse, error) { } func setupRouter() *gin.Engine { + gin.SetMode(gin.TestMode) return router.SetupRouter(&dummyExecutor{}) } + +func encodeCommand(cmd any) json.RawMessage { + v, err := json.Marshal(cmd) + if err != nil { + panic(err) + } + return v +} diff --git a/internal/router/validator.go b/internal/router/validator.go index ff66895..97a4354 100644 --- a/internal/router/validator.go +++ b/internal/router/validator.go @@ -15,3 +15,27 @@ var notBlankStringValidator validator.Func = func(fl validator.FieldLevel) bool } return true } + +var armamentWithWeaponsValidator validator.Func = func(fl validator.FieldLevel) bool { + var v, compareTo float64 + + f := fl.Parent().FieldByName(fl.Param()) + + if f.CanFloat() { + compareTo = f.Float() + } else if f.CanInt() { + compareTo = float64(f.Int()) + } else { + return false + } + + if fl.Field().CanFloat() { + v = fl.Field().Float() + } else if fl.Field().CanInt() { + v = float64(fl.Field().Int()) + } else { + return false + } + + return (v == 0 && compareTo == 0) || (v >= 1 && compareTo >= 1) +}