feat: http router, command api

This commit is contained in:
Ilia Denisov
2026-01-07 13:52:20 +02:00
parent c9ed52b268
commit 1b0ab7a079
9 changed files with 404 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
package rest
/*
Full list of requirements must be updated when adding new command:
required_without_all=Vote DeclarePeace DeclareWar
| excluded_with=Vote DeclarePeace DeclareWar
*/
type Command struct {
Race string `json:"race" binding:"required,notblank"`
Vote *CommandVote `json:"vote" binding:"required_without_all=DeclarePeace DeclareWar,excluded_with=DeclarePeace DeclareWar"`
DeclarePeace *CommandDeclarePeace `json:"declarePeace" binding:"required_without_all=Vote DeclareWar,excluded_with=Vote DeclareWar"`
DeclareWar *CommandDeclareWar `json:"declareWar" binding:"required_without_all=Vote DeclarePeace,excluded_with=Vote DeclarePeace"`
}
type CommandVote struct {
Recipient string `json:"recipient" binding:"required,notblank"`
}
type CommandDeclarePeace struct {
Opponent string `json:"recipient" binding:"required,notblank"`
}
type CommandDeclareWar struct {
Opponent string `json:"recipient" binding:"required,notblank"`
}
+49
View File
@@ -0,0 +1,49 @@
package router
import (
"errors"
"net/http"
"github.com/iliadenisov/galaxy/internal/game"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/rest"
)
var (
ErrCommandNotProcessed = errors.New("command was not processed by executor")
)
func CommandHandler(c *gin.Context, executor Executor) {
var cmd rest.Command
if err := c.ShouldBindJSON(&cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := executor(cmd)
switch {
case err == nil:
c.Status(http.StatusOK)
case errors.Is(err, ErrCommandNotProcessed):
c.Status(http.StatusInternalServerError)
default:
// TODO: separate bad value errors and game state errors
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
func Execute(cmd rest.Command) error {
p := func(p *controller.Param) {
// TODO: initialize base controller settings
p.StoragePath = ""
}
switch {
case cmd.DeclareWar != nil:
return game.DeclareWar(p, cmd.Race, cmd.DeclareWar.Opponent)
case cmd.DeclarePeace != nil:
return game.DeclarePeace(p, cmd.Race, cmd.DeclareWar.Opponent)
default:
return ErrCommandNotProcessed
}
}
+28
View File
@@ -0,0 +1,28 @@
package router
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// LimitMiddleware limits number of concurrent connections using a buffered channel with limit spaces
func LimitMiddleware(limit int) gin.HandlerFunc {
if limit <= 0 {
panic("limit must be greater than 0")
}
semaphore := make(chan bool, limit)
t := time.NewTimer(time.Millisecond * 100)
return func(c *gin.Context) {
t.Reset(time.Millisecond * 100)
select {
case semaphore <- true:
c.Next()
<-semaphore
case <-t.C:
c.Status(http.StatusGatewayTimeout)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/iliadenisov/galaxy/internal/model/rest"
)
type Executor func(rest.Command) error
type Router struct {
r *gin.Engine
executor Executor
}
func (r Router) Run() error {
return r.r.Run()
}
func NewRouter() Router {
return NewRouterExecutor(Execute)
}
func NewRouterExecutor(executor Executor) Router {
return Router{r: setupRouter(executor)}
}
func setupRouter(executor Executor) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("notblank", notBlankStringValidator)
}
r.PUT("/api/v1/command", LimitMiddleware(1), func(ctx *gin.Context) { CommandHandler(ctx, executor) })
return r
}
+10
View File
@@ -0,0 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/model/rest"
)
func SetupRouter() *gin.Engine {
return setupRouter(func(c rest.Command) error { return nil })
}
+126
View File
@@ -0,0 +1,126 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/model/rest"
"github.com/iliadenisov/galaxy/internal/router"
"github.com/stretchr/testify/assert"
)
func TestRouter(t *testing.T) {
r := router.SetupRouter()
exampleCommand := rest.Command{
Race: "SomeRace",
Vote: &rest.CommandVote{
Recipient: "AnotherRace",
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand))
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code, w.Body)
// error: notblank validator
exampleCommand.Race = ""
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
exampleCommand.Race = " "
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: no commands
exampleCommand = rest.Command{
Race: "SomeRace",
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: more than one command
exampleCommand = rest.Command{
Race: "SomeRace",
Vote: &rest.CommandVote{
Recipient: "AnotherRace",
},
DeclarePeace: &rest.CommandDeclarePeace{
Opponent: "OpponentRace",
},
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func TestLimitConnections(t *testing.T) {
r := limitTestingRouter()
wg := sync.WaitGroup{}
lock := sync.WaitGroup{}
lock.Add(1)
for range 1000 {
wg.Go(func() {
w := httptest.NewRecorder()
lock.Wait()
req, _ := http.NewRequest("GET", "/limited", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code, w.Body)
})
}
lock.Done()
wg.Wait()
}
func cmdBody(cmd rest.Command) *strings.Reader {
commandJson, _ := json.Marshal(cmd)
return strings.NewReader(string(commandJson))
}
func limitTestingRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
counter := atomic.Int32{}
r.GET("/limited",
// limiting all ingoing connections
router.LimitMiddleware(1),
// storing counter value and testing increment after executing Next handlers
func(c *gin.Context) {
expected := counter.Load() + 1
c.Next()
current := counter.Load()
if current != expected {
c.String(http.StatusConflict, "expected: %d, got: %d", expected, current)
}
},
// increment counter
func(c *gin.Context) {
counter.Add(1)
c.Status(http.StatusOK)
})
return r
}
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"strings"
"github.com/go-playground/validator/v10"
)
var notBlankStringValidator validator.Func = func(fl validator.FieldLevel) bool {
s, ok := fl.Field().Interface().(string)
if ok {
if len(strings.TrimSpace(s)) == 0 {
return false
}
}
return true
}