Migrate to gRPC rather than REST with swagger

Will also be adding in a RESTful endpoint to the server as well so it can consume both types
This commit is contained in:
Marc Di Luzio 2020-06-12 22:51:18 +01:00
parent b815284199
commit 7ababb79f6
23 changed files with 1110 additions and 1101 deletions

View file

@ -1,91 +0,0 @@
// +build integration
package internal
import (
"os"
"testing"
"github.com/google/uuid"
"github.com/mdiluz/rove/pkg/game"
"github.com/mdiluz/rove/pkg/rove"
"github.com/stretchr/testify/assert"
)
const (
defaultAddress = "localhost:8080"
)
var serv = func() rove.Server {
var address = os.Getenv("ROVE_SERVER_ADDRESS")
if len(address) == 0 {
address = defaultAddress
}
return rove.Server(address)
}()
func TestServer_Status(t *testing.T) {
status, err := serv.Status()
assert.NoError(t, err)
assert.True(t, status.Ready)
assert.NotZero(t, len(status.Version))
}
func TestServer_Register(t *testing.T) {
d1 := rove.RegisterData{
Name: uuid.New().String(),
}
_, err := serv.Register(d1)
assert.NoError(t, err)
d2 := rove.RegisterData{
Name: uuid.New().String(),
}
_, err = serv.Register(d2)
assert.NoError(t, err)
_, err = serv.Register(d1)
assert.Error(t, err)
}
func TestServer_Command(t *testing.T) {
d1 := rove.RegisterData{
Name: uuid.New().String(),
}
_, err := serv.Register(d1)
assert.NoError(t, err)
c := rove.CommandData{
Commands: []game.Command{
{
Command: game.CommandMove,
Bearing: "N",
Duration: 1,
},
},
}
_, err = serv.Command(d1.Name, c)
assert.NoError(t, err)
}
func TestServer_Radar(t *testing.T) {
d1 := rove.RegisterData{
Name: uuid.New().String(),
}
_, err := serv.Register(d1)
assert.NoError(t, err)
_, err = serv.Radar(d1.Name)
assert.NoError(t, err)
}
func TestServer_Rover(t *testing.T) {
d1 := rove.RegisterData{
Name: uuid.New().String(),
}
_, err := serv.Register(d1)
assert.NoError(t, err)
_, err = serv.Rover(d1.Name)
assert.NoError(t, err)
}

View file

@ -2,69 +2,26 @@ package internal
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/golang/protobuf/ptypes/empty"
"github.com/google/uuid"
"github.com/mdiluz/rove/pkg/accounts"
"github.com/mdiluz/rove/pkg/game"
"github.com/mdiluz/rove/pkg/rove"
"github.com/mdiluz/rove/pkg/version"
"google.golang.org/grpc"
)
// Handler describes a function that handles any incoming request and can respond
type Handler func(*Server, map[string]string, io.ReadCloser) (interface{}, error)
// Route defines the information for a single path->function route
type Route struct {
path string
method string
handler Handler
}
// Routes is an array of all the Routes
var Routes = []Route{
{
path: "/status",
method: http.MethodGet,
handler: HandleStatus,
},
{
path: "/register",
method: http.MethodPost,
handler: HandleRegister,
},
{
path: "/{account}/command",
method: http.MethodPost,
handler: HandleCommand,
},
{
path: "/{account}/radar",
method: http.MethodGet,
handler: HandleRadar,
},
{
path: "/{account}/rover",
method: http.MethodGet,
handler: HandleRover,
},
}
// HandleStatus handles the /status request
func HandleStatus(s *Server, vars map[string]string, b io.ReadCloser) (interface{}, error) {
// Simply return the current server status
response := rove.StatusResponse{
func (s *Server) Status(context.Context, *empty.Empty) (*rove.StatusResponse, error) {
response := &rove.StatusResponse{
Ready: true,
Version: version.Version,
Tick: s.tick,
Tick: int32(s.tick),
}
// TODO: Verify the accountant is up and ready too
// If there's a schedule, respond with it
if len(s.schedule.Entries()) > 0 {
response.NextTick = s.schedule.Entries()[0].Next.Format("15:04:05")
@ -73,31 +30,15 @@ func HandleStatus(s *Server, vars map[string]string, b io.ReadCloser) (interface
return response, nil
}
// HandleRegister handles /register endpoint
func HandleRegister(s *Server, vars map[string]string, b io.ReadCloser) (interface{}, error) {
var response = rove.RegisterResponse{}
// Decode the registration info, verify it and register the account
var data rove.RegisterData
err := json.NewDecoder(b).Decode(&data)
if err != nil {
log.Printf("Failed to decode json: %s\n", err)
return BadRequestError{Error: err.Error()}, nil
} else if len(data.Name) == 0 {
return BadRequestError{Error: "cannot register empty name"}, nil
func (s *Server) Register(ctx context.Context, req *rove.RegisterRequest) (*empty.Empty, error) {
if len(req.Name) == 0 {
return nil, fmt.Errorf("empty account name")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reg := accounts.RegisterInfo{Name: data.Name}
if acc, err := s.accountant.Register(ctx, &reg, grpc.WaitForReady(true)); err != nil {
return nil, fmt.Errorf("gRPC failed to contact accountant: %s", err)
} else if !acc.Success {
return BadRequestError{Error: acc.Error}, nil
if _, err := s.accountant.Register(ctx, &accounts.RegisterInfo{Name: req.Name}, grpc.WaitForReady(true)); err != nil {
return nil, err
} else if _, _, err := s.SpawnRoverForAccount(data.Name); err != nil {
} else if _, _, err := s.SpawnRoverForAccount(req.Name); err != nil {
return nil, fmt.Errorf("failed to spawn rover for account: %s", err)
} else if err := s.SaveWorld(); err != nil {
@ -105,66 +46,48 @@ func HandleRegister(s *Server, vars map[string]string, b io.ReadCloser) (interfa
}
log.Printf("register response:%+v\n", response)
return response, nil
return &empty.Empty{}, nil
}
// HandleSpawn will spawn the player entity for the associated account
func HandleCommand(s *Server, vars map[string]string, b io.ReadCloser) (interface{}, error) {
var response = rove.CommandResponse{}
func (s *Server) Rover(ctx context.Context, req *rove.RoverRequest) (*rove.RoverResponse, error) {
response := &rove.RoverResponse{}
if len(req.Account) == 0 {
return nil, fmt.Errorf("empty account name")
id := vars["account"]
// Decode the commands, verify them and the account, and execute the commands
var data rove.CommandData
if err := json.NewDecoder(b).Decode(&data); err != nil {
log.Printf("Failed to decode json: %s\n", err)
return BadRequestError{Error: err.Error()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
key := accounts.DataKey{Account: id, Key: "rover"}
if len(id) == 0 {
return BadRequestError{Error: "no account ID provided"}, nil
} else if resp, err := s.accountant.GetValue(ctx, &key); err != nil {
} else if resp, err := s.accountant.GetValue(ctx, &accounts.DataKey{Account: req.Account, Key: "rover"}); err != nil {
return nil, fmt.Errorf("gRPC failed to contact accountant: %s", err)
} else if !resp.Success {
return BadRequestError{Error: resp.Error}, nil
} else if id, err := uuid.Parse(resp.Value); err != nil {
return nil, fmt.Errorf("account had invalid rover ID: %s", resp.Value)
} else if err := s.world.Enqueue(id, data.Commands...); err != nil {
return BadRequestError{Error: err.Error()}, nil
} else if attrib, err := s.world.RoverAttributes(id); err != nil {
return nil, fmt.Errorf("error getting rover attributes: %s", err)
} else {
response = &rove.RoverResponse{
Name: attrib.Name,
Position: &rove.Vector{
X: int32(attrib.Pos.X),
Y: int32(attrib.Pos.Y),
},
}
}
log.Printf("command response \taccount:%s\tresponse:%+v\n", id, response)
return response, nil
}
// HandleRadar handles the radar request
func HandleRadar(s *Server, vars map[string]string, b io.ReadCloser) (interface{}, error) {
var response = rove.RadarResponse{}
func (s *Server) Radar(ctx context.Context, req *rove.RadarRequest) (*rove.RadarResponse, error) {
if len(req.Account) == 0 {
return nil, fmt.Errorf("empty account name")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
id := vars["account"]
key := accounts.DataKey{Account: id, Key: "rover"}
if len(id) == 0 {
return BadRequestError{Error: "no account ID provided"}, nil
response := &rove.RadarResponse{}
} else if resp, err := s.accountant.GetValue(ctx, &key); err != nil {
resp, err := s.accountant.GetValue(ctx, &accounts.DataKey{Account: req.Account, Key: "rover"})
if err != nil {
return nil, fmt.Errorf("gRPC failed to contact accountant: %s", err)
}
} else if !resp.Success {
return BadRequestError{Error: resp.Error}, nil
} else if id, err := uuid.Parse(resp.Value); err != nil {
if id, err := uuid.Parse(resp.Value); err != nil {
return nil, fmt.Errorf("account had invalid rover ID: %s", resp.Value)
} else if attrib, err := s.world.RoverAttributes(id); err != nil {
@ -175,40 +98,38 @@ func HandleRadar(s *Server, vars map[string]string, b io.ReadCloser) (interface{
} else {
response.Tiles = radar
response.Range = attrib.Range
response.Range = int32(attrib.Range)
}
log.Printf("radar response \taccount:%s\tresponse:%+v\n", id, response)
return response, nil
}
// HandleRover handles the rover request
func HandleRover(s *Server, vars map[string]string, b io.ReadCloser) (interface{}, error) {
var response = rove.RoverResponse{}
func (s *Server) Commands(ctx context.Context, req *rove.CommandsRequest) (*empty.Empty, error) {
if len(req.Account) == 0 {
return nil, fmt.Errorf("empty account")
}
resp, err := s.accountant.GetValue(ctx, &accounts.DataKey{Account: req.Account, Key: "rover"})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
id := vars["account"]
key := accounts.DataKey{Account: id, Key: "rover"}
if len(id) == 0 {
return BadRequestError{Error: "no account ID provided"}, nil
if err != nil {
return nil, err
}
} else if resp, err := s.accountant.GetValue(ctx, &key); err != nil {
return nil, fmt.Errorf("gRPC failed to contact accountant: %s", err)
} else if !resp.Success {
return BadRequestError{Error: resp.Error}, nil
} else if id, err := uuid.Parse(resp.Value); err != nil {
id, err := uuid.Parse(resp.Value)
if err != nil {
return nil, fmt.Errorf("account had invalid rover ID: %s", resp.Value)
} else if attrib, err := s.world.RoverAttributes(id); err != nil {
return nil, fmt.Errorf("error getting rover attributes: %s", err)
} else {
response.Attributes = attrib
}
log.Printf("rover response \taccount:%s\tresponse:%+v\n", id, response)
return response, nil
var cmds []game.Command
for _, c := range req.Commands {
cmds = append(cmds, game.Command{
Bearing: c.Bearing,
Command: c.Command,
Duration: int(c.Duration)})
}
if err := s.world.Enqueue(id, cmds...); err != nil {
return nil, err
}
return &empty.Empty{}, nil
}

View file

@ -1,202 +0,0 @@
// +build integration
package internal
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path"
"testing"
"github.com/google/uuid"
"github.com/mdiluz/rove/pkg/accounts"
"github.com/mdiluz/rove/pkg/atlas"
"github.com/mdiluz/rove/pkg/game"
"github.com/mdiluz/rove/pkg/rove"
"github.com/mdiluz/rove/pkg/vector"
"github.com/stretchr/testify/assert"
)
func TestHandleStatus(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/status", nil)
response := httptest.NewRecorder()
s := NewServer()
s.Initialise(true)
s.router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var status rove.StatusResponse
err := json.NewDecoder(response.Body).Decode(&status)
assert.NoError(t, err)
if status.Ready != true {
t.Errorf("got false for /status")
}
if len(status.Version) == 0 {
t.Errorf("got empty version info")
}
}
func TestHandleRegister(t *testing.T) {
data := rove.RegisterData{Name: uuid.New().String()}
b, err := json.Marshal(data)
if err != nil {
t.Error(err)
}
request, _ := http.NewRequest(http.MethodPost, "/register", bytes.NewReader(b))
response := httptest.NewRecorder()
s := NewServer()
s.Initialise(true)
s.router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var status rove.RegisterResponse
err = json.NewDecoder(response.Body).Decode(&status)
assert.NoError(t, err)
}
func TestHandleCommand(t *testing.T) {
name := uuid.New().String()
s := NewServer()
s.Initialise(false) // Leave the world empty with no obstacles
reg := accounts.RegisterInfo{Name: name}
acc, err := s.accountant.Register(context.Background(), &reg)
assert.NoError(t, err)
assert.NotNil(t, acc)
assert.True(t, acc.Success, acc.Error)
assert.NoError(t, err, "Error registering account")
// Spawn the rover rover for the account
_, inst, err := s.SpawnRoverForAccount(name)
assert.NoError(t, s.world.WarpRover(inst, vector.Vector{}))
attribs, err := s.world.RoverAttributes(inst)
assert.NoError(t, err, "Couldn't get rover position")
data := rove.CommandData{
Commands: []game.Command{
{
Command: game.CommandMove,
Bearing: "N",
Duration: 1,
},
},
}
b, err := json.Marshal(data)
assert.NoError(t, err, "Error marshalling data")
request, _ := http.NewRequest(http.MethodPost, path.Join("/", name, "/command"), bytes.NewReader(b))
response := httptest.NewRecorder()
s.router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var status rove.CommandResponse
err = json.NewDecoder(response.Body).Decode(&status)
assert.NoError(t, err)
attrib, err := s.world.RoverAttributes(inst)
assert.NoError(t, err, "Couldn't get rover attribs")
// Tick the command queues to progress the move command
s.world.EnqueueAllIncoming()
s.world.ExecuteCommandQueues()
attribs2, err := s.world.RoverAttributes(inst)
assert.NoError(t, err, "Couldn't get rover position")
attribs.Pos.Add(vector.Vector{X: 0.0, Y: attrib.Speed * 1}) // Should have moved north by the speed and duration
assert.Equal(t, attribs.Pos, attribs2.Pos, "Rover should have moved by bearing")
}
func TestHandleRadar(t *testing.T) {
name := uuid.New().String()
s := NewServer()
s.Initialise(false) // Spawn a clean world
reg := accounts.RegisterInfo{Name: name}
acc, err := s.accountant.Register(context.Background(), &reg)
assert.NoError(t, err)
assert.True(t, acc.Success, acc.Error)
assert.NoError(t, err, "Error registering account")
// Spawn the rover rover for the account
attrib, id, err := s.SpawnRoverForAccount(name)
assert.NoError(t, err)
// Warp this rover to 0,0
assert.NoError(t, s.world.WarpRover(id, vector.Vector{}))
// Explicity set a few nearby tiles
wallPos1 := vector.Vector{X: 0, Y: -1}
wallPos2 := vector.Vector{X: 1, Y: 1}
rockPos := vector.Vector{X: 1, Y: 3}
emptyPos := vector.Vector{X: -2, Y: -3}
assert.NoError(t, s.world.Atlas.SetTile(wallPos1, atlas.TileWall))
assert.NoError(t, s.world.Atlas.SetTile(wallPos2, atlas.TileWall))
assert.NoError(t, s.world.Atlas.SetTile(rockPos, atlas.TileRock))
assert.NoError(t, s.world.Atlas.SetTile(emptyPos, atlas.TileEmpty))
request, _ := http.NewRequest(http.MethodGet, path.Join("/", name, "/radar"), nil)
response := httptest.NewRecorder()
s.router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var status rove.RadarResponse
err = json.NewDecoder(response.Body).Decode(&status)
assert.NoError(t, err)
scope := attrib.Range*2 + 1
radarOrigin := vector.Vector{X: -attrib.Range, Y: -attrib.Range}
// Make sure the rover tile is correct
assert.Equal(t, atlas.TileRover, status.Tiles[len(status.Tiles)/2])
// Check our other tiles
wallPos1.Add(radarOrigin.Negated())
wallPos2.Add(radarOrigin.Negated())
rockPos.Add(radarOrigin.Negated())
emptyPos.Add(radarOrigin.Negated())
assert.Equal(t, atlas.TileWall, status.Tiles[wallPos1.X+wallPos1.Y*scope])
assert.Equal(t, atlas.TileWall, status.Tiles[wallPos2.X+wallPos2.Y*scope])
assert.Equal(t, atlas.TileRock, status.Tiles[rockPos.X+rockPos.Y*scope])
assert.Equal(t, atlas.TileEmpty, status.Tiles[emptyPos.X+emptyPos.Y*scope])
}
func TestHandleRover(t *testing.T) {
name := uuid.New().String()
s := NewServer()
s.Initialise(true)
reg := accounts.RegisterInfo{Name: name}
acc, err := s.accountant.Register(context.Background(), &reg)
assert.NoError(t, err)
assert.True(t, acc.Success, acc.Error)
// Spawn one rover for the account
attribs, _, err := s.SpawnRoverForAccount(name)
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodGet, path.Join("/", name, "/rover"), nil)
response := httptest.NewRecorder()
s.router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var status rove.RoverResponse
err = json.NewDecoder(response.Body).Decode(&status)
assert.NoError(t, err)
if attribs != status.Attributes {
t.Errorf("Missmatched attributes: %+v, !=%+v", attribs, status.Attributes)
}
}

View file

@ -2,20 +2,17 @@ package internal
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/mdiluz/rove/pkg/accounts"
"github.com/mdiluz/rove/pkg/game"
"github.com/mdiluz/rove/pkg/persistence"
"github.com/mdiluz/rove/pkg/rove"
"github.com/robfig/cron"
"google.golang.org/grpc"
)
@ -40,10 +37,9 @@ type Server struct {
accountant accounts.AccountantClient
clientConn *grpc.ClientConn
// HTTP server
listener net.Listener
server *http.Server
router *mux.Router
// gRPC server
netListener net.Listener
grpcServ *grpc.Server
// Config settings
address string
@ -85,13 +81,10 @@ func OptionTick(minutes int) ServerOption {
// NewServer sets up a new server
func NewServer(opts ...ServerOption) *Server {
router := mux.NewRouter().StrictSlash(true)
// Set up the default server
s := &Server{
address: "",
persistence: EphemeralData,
router: router,
schedule: cron.New(),
}
@ -100,9 +93,6 @@ func NewServer(opts ...ServerOption) *Server {
o(s)
}
// Set up the server object
s.server = &http.Server{Addr: s.address, Handler: s.router}
// Start small, we can grow the world later
s.world = game.NewWorld(4, 8)
@ -133,18 +123,14 @@ func (s *Server) Initialise(fillWorld bool) (err error) {
return err
}
// Set up the handlers
for _, route := range Routes {
s.router.HandleFunc(route.path, s.wrapHandler(route.method, route.handler))
// Set up the RPC server and register
s.netListener, err = net.Listen("tcp", s.address)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s.grpcServ = grpc.NewServer()
rove.RegisterRoverServerServer(s.grpcServ, s)
// Start the listen
log.Printf("Listening on %s\n", s.server.Addr)
if s.listener, err = net.Listen("tcp", s.server.Addr); err != nil {
return err
}
s.address = s.listener.Addr().String()
return nil
}
@ -178,9 +164,10 @@ func (s *Server) Run() {
log.Printf("First server tick scheduled for %s\n", s.schedule.Entries()[0].Next.Format("15:04:05"))
}
// Serve the http requests
if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
// Serve the RPC server
log.Printf("Serving rove on %s\n", s.address)
if err := s.grpcServ.Serve(s.netListener); err != nil && err != grpc.ErrServerStopped {
log.Fatalf("failed to serve gRPC: %s", err)
}
}
@ -189,12 +176,8 @@ func (s *Server) Stop() error {
// Stop the cron
s.schedule.Stop()
// Try and shut down the http server
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
return err
}
// Stop the gRPC
s.grpcServ.Stop()
// Close the accountant connection
if err := s.clientConn.Close(); err != nil {
@ -206,7 +189,7 @@ func (s *Server) Stop() error {
// Close waits until the server is finished and closes up shop
func (s *Server) Close() error {
// Wait until the server has shut down
// Wait until the world has shut down
s.sync.Wait()
// Save and return
@ -253,40 +236,6 @@ type BadRequestError struct {
Error string `json:"error"`
}
// wrapHandler wraps a request handler in http checks
func (s *Server) wrapHandler(method string, handler Handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Log the request
log.Printf("%s\t%s\n", r.Method, r.RequestURI)
vars := mux.Vars(r)
// Verify the method, call the handler, and encode the return
if r.Method != method {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
val, err := handler(s, vars, r.Body)
if err != nil {
log.Printf("Failed to handle http request: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
} else if _, ok := val.(BadRequestError); ok {
w.WriteHeader(http.StatusBadRequest)
}
if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Failed to encode reply to json: %s", err)
w.WriteHeader(http.StatusInternalServerError)
} else {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
}
}
}
// SpawnRoverForAccount spawns the rover rover for an account
func (s *Server) SpawnRoverForAccount(account string) (game.RoverAttributes, uuid.UUID, error) {
if inst, err := s.world.SpawnRover(); err != nil {
@ -297,9 +246,9 @@ func (s *Server) SpawnRoverForAccount(account string) (game.RoverAttributes, uui
} else {
keyval := accounts.DataKeyValue{Account: account, Key: "rover", Value: inst.String()}
resp, err := s.accountant.AssignValue(context.Background(), &keyval)
if err != nil || !resp.Success {
log.Printf("Failed to assign rover to account, %s, %s", err, resp.Error)
_, err := s.accountant.AssignValue(context.Background(), &keyval)
if err != nil {
log.Printf("Failed to assign rover to account, %s", err)
// Try and clear up the rover
if err := s.world.DestroyRover(inst); err != nil {