feat: command validation

This commit is contained in:
IliaDenisov
2026-02-10 18:31:53 +03:00
parent b5400bd61e
commit 6c8384ce7a
6 changed files with 178 additions and 30 deletions
+56 -9
View File
@@ -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
}
+53 -11
View File
@@ -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
}
+13 -3
View File
@@ -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")
+11
View File
@@ -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
}
+24
View File
@@ -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)
}