feat: command validation
This commit is contained in:
@@ -10,8 +10,8 @@ type Command struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user