feat: init api

This commit is contained in:
Ilia Denisov
2026-01-08 13:32:40 +02:00
parent 204d3df8cf
commit 972cfd82be
12 changed files with 240 additions and 108 deletions
+5 -3
View File
@@ -35,12 +35,14 @@ type Param struct {
StoragePath string StoragePath string
} }
func NewController(configure func(*Param)) (*Controller, error) { type Config func(*Param)
func NewController(config Config) (*Controller, error) {
c := &Param{ c := &Param{
StoragePath: ".", StoragePath: ".",
} }
if configure != nil { if config != nil {
configure(c) config(c)
} }
r, err := repo.NewFileRepo(c.StoragePath) r, err := repo.NewFileRepo(c.StoragePath)
if err != nil { if err != nil {
+15
View File
@@ -0,0 +1,15 @@
package rest
import "github.com/google/uuid"
type Init struct {
Races []Race `json:"races" binding:"required,gte=10"`
}
type Race struct {
Name string `json:"name" binding:"required,notblank"`
}
type InitResponse struct {
UUID uuid.UUID `json:"uuid"`
}
+71
View File
@@ -0,0 +1,71 @@
package router_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/iliadenisov/galaxy/internal/model/rest"
"github.com/iliadenisov/galaxy/internal/router"
"github.com/stretchr/testify/assert"
)
func TestCommand(t *testing.T) {
r := router.SetupRouter()
payload := rest.Command{
Race: "SomeRace",
Vote: &rest.CommandVote{
Recipient: "AnotherRace",
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/command", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
// error: notblank validator
payload.Race = ""
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
payload.Race = " "
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: no commands
payload = rest.Command{
Race: "SomeRace",
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: more than one command
payload = rest.Command{
Race: "SomeRace",
Vote: &rest.CommandVote{
Recipient: "AnotherRace",
},
DeclarePeace: &rest.CommandDeclarePeace{
Opponent: "OpponentRace",
},
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/command", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
@@ -1,26 +1,29 @@
package router package handler
import ( import (
"errors" "errors"
"net/http" "net/http"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/game" "github.com/iliadenisov/galaxy/internal/game"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/model/rest" "github.com/iliadenisov/galaxy/internal/model/rest"
) )
type CommandExecutor func(controller.Config, rest.Command) error
var ( var (
ErrCommandNotProcessed = errors.New("command was not processed by executor") ErrCommandNotProcessed = errors.New("command was not processed by executor")
) )
func CommandHandler(c *gin.Context, executor Executor) { func CommandHandler(c *gin.Context, config controller.Config, executor CommandExecutor) {
var cmd rest.Command var cmd rest.Command
if err := c.ShouldBindJSON(&cmd); err != nil { if err := c.ShouldBindJSON(&cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
err := executor(cmd) err := executor(config, cmd)
switch { switch {
case err == nil: case err == nil:
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -32,13 +35,12 @@ func CommandHandler(c *gin.Context, executor Executor) {
} }
} }
func Execute(cmd rest.Command) error { func ExecuteCommand(config controller.Config, cmd rest.Command) error {
p := param()
switch { switch {
case cmd.DeclareWar != nil: case cmd.DeclareWar != nil:
return game.DeclareWar(p, cmd.Race, cmd.DeclareWar.Opponent) return game.DeclareWar(config, cmd.Race, cmd.DeclareWar.Opponent)
case cmd.DeclarePeace != nil: case cmd.DeclarePeace != nil:
return game.DeclarePeace(p, cmd.Race, cmd.DeclareWar.Opponent) return game.DeclarePeace(config, cmd.Race, cmd.DeclareWar.Opponent)
default: default:
return ErrCommandNotProcessed return ErrCommandNotProcessed
} }
@@ -1,4 +1,4 @@
package router package handler
import ( import (
"errors" "errors"
@@ -6,19 +6,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
e "github.com/iliadenisov/galaxy/internal/error" e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/game"
"github.com/iliadenisov/galaxy/internal/model/rest"
) )
func StatusHandler(c *gin.Context) { func transformError(c *gin.Context, err error) bool {
g, err := game.LoadState(param())
if err == nil { if err == nil {
c.JSON(http.StatusOK, rest.Status{ return false
Turn: g.Age,
Players: len(g.Race),
})
return
} }
var ge = new(e.GenericError) var ge = new(e.GenericError)
@@ -30,8 +22,10 @@ func StatusHandler(c *gin.Context) {
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code}) c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
} }
return } else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return true
} }
+33
View File
@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/game"
"github.com/iliadenisov/galaxy/internal/model/rest"
)
func InitHandler(c *gin.Context, config controller.Config) {
var init rest.Init
if err := c.ShouldBindJSON(&init); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
races := make([]string, len(init.Races))
for i := range init.Races {
races[i] = init.Races[i].Name
}
uuid, err := game.GenerateGame(config, races)
if transformError(c, err) {
return
}
c.JSON(http.StatusCreated, rest.InitResponse{
UUID: uuid,
})
}
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/game"
"github.com/iliadenisov/galaxy/internal/model/rest"
)
func StatusHandler(c *gin.Context, config controller.Config) {
g, err := game.LoadState(config)
if transformError(c, err) {
return
}
c.JSON(http.StatusOK, rest.Status{
Turn: g.Age,
Players: len(g.Race),
})
}
+55
View File
@@ -0,0 +1,55 @@
package router_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/rest"
"github.com/iliadenisov/galaxy/internal/router"
"github.com/iliadenisov/galaxy/internal/util"
"github.com/stretchr/testify/assert"
)
func TestInit(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouterConfig(func(p *controller.Param) { p.StoragePath = root })
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
var initResponse rest.InitResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
assert.NoError(t, uuid.Validate(initResponse.UUID.String()))
}
func TestInitValidators(t *testing.T) {
r := router.SetupRouter()
payload := generateInitRequest(9)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func generateInitRequest(races int) rest.Init {
request := rest.Init{
Races: make([]rest.Race, races),
}
for i := range request.Races {
request.Races[i] = rest.Race{Name: fmt.Sprintf("Race_%02d", i)}
}
return request
}
+14 -20
View File
@@ -7,29 +7,19 @@ import (
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/iliadenisov/galaxy/internal/controller" "github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/rest" "github.com/iliadenisov/galaxy/internal/router/handler"
) )
var ( func initConfig() func(*controller.Param) {
StoragePath string
)
func init() {
StoragePath = os.Getenv("STORAGE_PATH")
}
func param() func(*controller.Param) {
return func(p *controller.Param) { return func(p *controller.Param) {
// TODO: initialize base controller settings // TODO: initialize base controller settings
p.StoragePath = StoragePath p.StoragePath = os.Getenv("STORAGE_PATH")
} }
} }
type Executor func(rest.Command) error
type Router struct { type Router struct {
r *gin.Engine r *gin.Engine
executor Executor executor handler.CommandExecutor
} }
func (r Router) Run() error { func (r Router) Run() error {
@@ -37,14 +27,14 @@ func (r Router) Run() error {
} }
func NewRouter() Router { func NewRouter() Router {
return NewRouterExecutor(Execute) return NewRouterExecutor(handler.ExecuteCommand)
} }
func NewRouterExecutor(executor Executor) Router { func NewRouterExecutor(executor handler.CommandExecutor) Router {
return Router{r: setupRouter(executor)} return Router{r: setupRouter(initConfig(), executor)}
} }
func setupRouter(executor Executor) *gin.Engine { func setupRouter(config controller.Config, executor handler.CommandExecutor) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
@@ -53,7 +43,11 @@ func setupRouter(executor Executor) *gin.Engine {
v.RegisterValidation("notblank", notBlankStringValidator) v.RegisterValidation("notblank", notBlankStringValidator)
} }
r.GET("/api/v1/status", StatusHandler) groupV1 := r.Group("/api/v1")
r.PUT("/api/v1/command", LimitMiddleware(1), func(ctx *gin.Context) { CommandHandler(ctx, executor) })
groupV1.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, config) })
groupV1.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, config) })
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, config, executor) })
return r return r
} }
+6 -1
View File
@@ -2,9 +2,14 @@ package router
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/rest" "github.com/iliadenisov/galaxy/internal/model/rest"
) )
func SetupRouter() *gin.Engine { func SetupRouter() *gin.Engine {
return setupRouter(func(c rest.Command) error { return nil }) return SetupRouterConfig(nil)
}
func SetupRouterConfig(config controller.Config) *gin.Engine {
return setupRouter(config, func(controller.Config, rest.Command) error { return nil })
} }
+2 -63
View File
@@ -10,71 +10,10 @@ import (
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/internal/model/rest"
"github.com/iliadenisov/galaxy/internal/router" "github.com/iliadenisov/galaxy/internal/router"
"github.com/stretchr/testify/assert" "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, http.StatusOK, 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) { func TestLimitConnections(t *testing.T) {
r := limitTestingRouter() r := limitTestingRouter()
@@ -94,8 +33,8 @@ func TestLimitConnections(t *testing.T) {
wg.Wait() wg.Wait()
} }
func cmdBody(cmd rest.Command) *strings.Reader { func asBody(body any) *strings.Reader {
commandJson, _ := json.Marshal(cmd) commandJson, _ := json.Marshal(body)
return strings.NewReader(string(commandJson)) return strings.NewReader(string(commandJson))
} }
-1
View File
@@ -17,5 +17,4 @@ func TestGetStatus(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotImplemented, w.Code, w.Body) assert.Equal(t, http.StatusNotImplemented, w.Code, w.Body)
} }