diff --git a/src/server/config.go b/src/server/config.go new file mode 100644 index 0000000..589e680 --- /dev/null +++ b/src/server/config.go @@ -0,0 +1,52 @@ +package main + +import ( + "local/args" + "local/storage" + "strings" +) + +type Config struct { + Game struct { + db storage.DB + } + Server struct { + Port int + File struct { + Root string + Prefix string + } + API struct { + Prefix string + } + } +} + +func NewConfig() Config { + as := args.NewArgSet() + as.Append(args.INT, "p", "port to serve", 4201) + as.Append(args.STRING, "dbaddr", "db path", "/tmp/blind.poker") + as.Append(args.STRING, "dbtype", "db type", "map") + as.Append(args.STRING, "root", "file server root", "../client") + as.Append(args.STRING, "root-http-prefix", "file server http prefix", "poker") + as.Append(args.STRING, "api-http-prefix", "api server http prefix", "api") + if err := as.Parse(); err != nil { + panic(err) + } + db, err := storage.New( + storage.TypeFromString(as.GetString("dbtype")), + as.GetString("dbaddr"), + ) + if err != nil { + panic(err) + } + + config := Config{} + config.Game.db = db + config.Server.Port = as.GetInt("p") + config.Server.File.Root = as.GetString("root") + config.Server.File.Prefix = "/" + strings.TrimPrefix(as.GetString("root-http-prefix"), "/") + config.Server.API.Prefix = "/" + strings.TrimPrefix(as.GetString("api-http-prefix"), "/") + + return config +} diff --git a/src/server/config_test.go b/src/server/config_test.go new file mode 100644 index 0000000..9805c03 --- /dev/null +++ b/src/server/config_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "io/ioutil" + "local/storage" + "os" + "path" + "testing" +) + +func mockConfig(t *testing.T) Config { + d := t.TempDir() + + config := Config{} + config.Game.db = storage.NewMap() + config.Server.Port = 9999 + config.Server.File.Root = d + config.Server.File.Prefix = "/file" + config.Server.API.Prefix = "/api" + + err := ioutil.WriteFile(path.Join(d, "index.html"), []byte("Hello, world"), os.ModePerm) + if err != nil { + t.Fatal(err) + } + + return config +} diff --git a/src/server/errors.go b/src/server/errors.go new file mode 100644 index 0000000..0426817 --- /dev/null +++ b/src/server/errors.go @@ -0,0 +1,7 @@ +package main + +import "errors" + +var ( + errGameExists = errors.New("cannot create game: already exists") +) diff --git a/src/server/game.go b/src/server/game.go new file mode 100644 index 0000000..0c6331b --- /dev/null +++ b/src/server/game.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +type Game struct { + Pot Currency + Players [16]Player +} + +type Player struct { + ID string + Name string + Card string + Balance Currency +} + +type Currency int + +func (game Game) GetPlayers() []Player { + players := []Player{} + for _, player := range game.Players { + if !player.Empty() { + players = append(players, player) + } + } + return players +} + +func (p Player) Empty() bool { + return p == (Player{}) +} + +func (c Currency) MarshalJSON() ([]byte, error) { + return json.Marshal(fmt.Sprintf(`$%v.%02d`, c/100, c%100)) +} + +func (c *Currency) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + re := regexp.MustCompile(`^\$([0-9]+(\.[0-9][0-9])?|\.[0-9]{2})$`) + if !re.MatchString(s) { + return fmt.Errorf("illegal currency format: %q", s) + } + s = strings.TrimPrefix(s, "$") + dollars := strings.Split(s, ".")[0] + cents := strings.TrimPrefix(s, dollars+".") + if !strings.HasPrefix(s, dollars+".") { + cents = "0" + } + if dollars == "" { + dollars = "0" + } + if cents == "00" { + cents = "0" + } + dI, err := strconv.Atoi(dollars) + if err != nil { + return err + } + cI, err := strconv.Atoi(cents) + if err != nil { + return err + } + c2 := Currency(dI*100 + cI) + *c = c2 + return nil +} diff --git a/src/server/game_test.go b/src/server/game_test.go new file mode 100644 index 0000000..bf12463 --- /dev/null +++ b/src/server/game_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestCurrencyMarshal(t *testing.T) { + cases := map[string]struct { + input Currency + want string + }{ + "zero": { + want: "$0.00", + }, + "one cent": { + input: 1, + want: "$0.01", + }, + "one dollar and one cent": { + input: 101, + want: "$1.01", + }, + "one dollar and no cent": { + input: 100, + want: "$1.00", + }, + } + + for name, d := range cases { + c := d + t.Run(name, func(t *testing.T) { + b, err := json.Marshal(c.input) + if err != nil { + t.Fatal(err) + } + b = b[1 : len(b)-1] + if s := string(b); s != c.want { + t.Fatalf("given %v, want %q, got %q", c.input, c.want, s) + } + }) + } +} + +func TestCurrencyUnmarshal(t *testing.T) { + cases := map[string]struct { + input string + want Currency + }{ + "no dollar and no cent": { + input: "$0.00", + want: 0, + }, + "omitted no dollar and no cent": { + input: "$.00", + want: 0, + }, + "no dollar and omitted no cent": { + input: "$0", + want: 0, + }, + "no dollar and one cent": { + input: "$0.01", + want: 1, + }, + "omitted no dollar and one cent": { + input: "$.01", + want: 1, + }, + "one dollar and one cent": { + input: "$1.01", + want: 101, + }, + "one dollar and no cent": { + input: "$1.00", + want: 100, + }, + "one dollar and omitted no cent": { + input: "$1", + want: 100, + }, + } + + for name, d := range cases { + c := d + t.Run(name, func(t *testing.T) { + var got Currency + err := json.Unmarshal([]byte(`"`+c.input+`"`), &got) + if err != nil { + t.Fatal(err) + } + if got != c.want { + t.Fatalf("given %s, want %v, got %v", c.input, c.want, got) + } + }) + } +} + +func TestPlayerEmpty(t *testing.T) { + var p Player + if !p.Empty() { + t.Fatal(p) + } + p.ID = "id" + if p.Empty() { + t.Fatal(p) + } +} + +func TestGameGetPlayers(t *testing.T) { + var g Game + if players := g.GetPlayers(); len(players) != 0 { + t.Fatal(players) + } + g.Players[5].ID = "id" + if players := g.GetPlayers(); len(players) != 1 { + t.Fatal(players) + } else if players[0].ID != "id" { + t.Fatal(players[0]) + } +} diff --git a/src/server/gamemaster.go b/src/server/gamemaster.go new file mode 100644 index 0000000..f2e53fe --- /dev/null +++ b/src/server/gamemaster.go @@ -0,0 +1,37 @@ +package main + +type GameMaster struct { + config Config + storage *Storage + locks *RWLockMap +} + +func NewGameMaster(config Config, storage *Storage) *GameMaster { + return &GameMaster{ + config: config, + storage: storage, + locks: NewRWLockMap(), + } +} + +func (game *GameMaster) ListGames() ([]string, error) { + return game.storage.ListGames() +} + +func (game *GameMaster) GetGame(id string) (Game, error) { + game.locks.RLock(id) + defer game.locks.RUnlock(id) + + return game.storage.GetGame(id) +} + +func (game *GameMaster) CreateGame(id string) error { + game.locks.Lock(id) + defer game.locks.Unlock(id) + + if _, err := game.storage.GetGame(id); err == nil { + return errGameExists + } + + return game.storage.CreateGame(id) +} diff --git a/src/server/gamemaster_test.go b/src/server/gamemaster_test.go new file mode 100644 index 0000000..e3239ed --- /dev/null +++ b/src/server/gamemaster_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +func mockGameMaster(t *testing.T) *GameMaster { + config := mockConfig(t) + storage := NewStorage(config) + return NewGameMaster(config, storage) +} + +func TestGameMasterGetCreateGet(t *testing.T) { + gm := mockGameMaster(t) + + id := "game" + + if games, err := gm.ListGames(); err != nil { + t.Fatal(err) + } else if len(games) != 0 { + t.Fatal(games) + } + if _, err := gm.GetGame(id); err == nil { + t.Fatal(err) + } + if err := gm.CreateGame(id); err != nil { + t.Fatal(err) + } + if _, err := gm.GetGame(id); err != nil { + t.Fatal(err) + } + if games, err := gm.ListGames(); err != nil { + t.Fatal(err) + } else if len(games) != 1 { + t.Fatal(games) + } +} diff --git a/src/server/lockmap.go b/src/server/lockmap.go new file mode 100644 index 0000000..c6d3a28 --- /dev/null +++ b/src/server/lockmap.go @@ -0,0 +1,38 @@ +package main + +import "sync" + +type RWLockMap struct { + m *sync.Map +} + +func NewRWLockMap() *RWLockMap { + return &RWLockMap{ + m: &sync.Map{}, + } +} + +func (rwlm *RWLockMap) Lock(k string) { + rwlm.getLock(k).Lock() +} + +func (rwlm *RWLockMap) RLock(k string) { + rwlm.getLock(k).RLock() +} + +func (rwlm *RWLockMap) RUnlock(k string) { + rwlm.getLock(k).RUnlock() +} + +func (rwlm *RWLockMap) Unlock(k string) { + rwlm.getLock(k).Unlock() +} + +func (rwlm *RWLockMap) getLock(k string) *sync.RWMutex { + v, ok := rwlm.m.Load(k) + if !ok { + v = &sync.RWMutex{} + rwlm.m.Store(k, v) + } + return v.(*sync.RWMutex) +} diff --git a/src/server/lockmap_test.go b/src/server/lockmap_test.go new file mode 100644 index 0000000..6b5ccec --- /dev/null +++ b/src/server/lockmap_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestLockMap(t *testing.T) { + rwlm := NewRWLockMap() + k1 := "k1" + k2 := "k2" + + rwlm.Lock(k1) + rwlm.RLock(k2) + rwlm.Unlock(k1) + rwlm.RUnlock(k2) +} diff --git a/src/server/main.go b/src/server/main.go new file mode 100644 index 0000000..727564d --- /dev/null +++ b/src/server/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + config := NewConfig() + storage := NewStorage(config) + gm := NewGameMaster(config, storage) + server := NewServer(config, gm) + if err := server.Routes(); err != nil { + panic(err) + } + log.Println(config) + if err := http.ListenAndServe(fmt.Sprintf(":%d", config.Server.Port), server); err != nil { + panic(err) + } +} diff --git a/src/server/server.go b/src/server/server.go new file mode 100644 index 0000000..11ac7b3 --- /dev/null +++ b/src/server/server.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "fmt" + "local/router" + "log" + "net/http" + "strings" +) + +type Server struct { + config Config + gm *GameMaster + *router.Router +} + +func NewServer(config Config, gm *GameMaster) *Server { + return &Server{ + config: config, + gm: gm, + Router: router.New(), + } +} + +func (server *Server) Routes() error { + cases := map[string]map[string]http.HandlerFunc{ + fmt.Sprintf("%s/%s%s", server.config.Server.File.Prefix, router.Wildcard, router.Wildcard): map[string]http.HandlerFunc{ + http.MethodGet: server.File, + }, + fmt.Sprintf("%s/games/%s", server.config.Server.API.Prefix, router.Wildcard): map[string]http.HandlerFunc{ + http.MethodGet: server.GameGet, + http.MethodPost: server.GameInsert, + }, + } + + for path, spec := range cases { + log.Println("listening for:", path) + handler := server.ByMethod(spec) + if err := server.Add(path, handler); err != nil { + return err + } + } + + return nil +} + +func (server *Server) ByMethod(m map[string]http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foo, ok := m[r.Method] + if ok { + foo(w, r) + } else { + http.NotFound(w, r) + } + }) +} + +func (server *Server) File(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + s := http.FileServer(http.Dir(server.config.Server.File.Root)) + r.URL.Path = strings.TrimPrefix(r.URL.Path, server.config.Server.File.Prefix) + s.ServeHTTP(w, r) +} + +func (server *Server) GameGet(w http.ResponseWriter, r *http.Request) { + var gameID string + err := router.Params(r, &gameID) + if err != nil { + badRequest(w, err.Error()) + return + } + resp, err := server.gm.GetGame(gameID) + if err != nil { + internalError(w, err.Error()) + return + } + json.NewEncoder(w).Encode(resp) +} + +func (server *Server) GameInsert(w http.ResponseWriter, r *http.Request) { + var gameID string + err := router.Params(r, &gameID) + if err != nil { + badRequest(w, err.Error()) + return + } + err = server.gm.CreateGame(gameID) + if err != nil { + internalError(w, err.Error()) + return + } + json.NewEncoder(w).Encode(Game{}) +} + +func notImpl(w http.ResponseWriter) { + errHandler(w, http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented)) +} + +func internalError(w http.ResponseWriter, message string) { + errHandler(w, http.StatusInternalServerError, message) +} + +func badRequest(w http.ResponseWriter, message string) { + errHandler(w, http.StatusBadRequest, message) +} + +func errHandler(w http.ResponseWriter, status int, message string) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": http.StatusText(status), + "statusCode": status, + "error": message, + }) +} diff --git a/src/server/server_test.go b/src/server/server_test.go new file mode 100644 index 0000000..b29ea91 --- /dev/null +++ b/src/server/server_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path" + "strings" + "testing" +) + +func TestServerRouter(t *testing.T) { + gm := mockGameMaster(t) + server := NewServer(gm.config, gm) + if err := server.Routes(); err != nil { + t.Fatal(err) + } + + cases := map[string]struct { + method string + path string + }{ + "file server root": { + method: http.MethodGet, + path: path.Join(server.config.Server.File.Prefix), + }, + "api: games: get": { + method: http.MethodGet, + path: path.Join(server.config.Server.API.Prefix, "games", "my-game-id"), + }, + "api: games: post": { + method: http.MethodPost, + path: path.Join(server.config.Server.API.Prefix, "games", "my-game-id"), + }, + } + + for name, d := range cases { + c := d + t.Run(name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(c.method, c.path, strings.NewReader("{}")) + server.ServeHTTP(w, r) + if w.Code == http.StatusNotFound { + t.Fatalf("not found: (%s) %s: (%v) %s", c.method, c.path, w.Code, w.Body.Bytes()) + } + }) + } +} + +func TestServerByMethod(t *testing.T) { + server := NewServer(Config{}, nil) + + gotGet := false + get := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotGet = true }) + gotPost := false + post := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPost = true }) + + handler := server.ByMethod(map[string]http.HandlerFunc{ + http.MethodGet: get, + http.MethodPost: post, + }) + + handler(nil, &http.Request{Method: http.MethodGet}) + if !gotGet || gotPost { + t.Fatal(gotGet, gotPost) + } + gotGet = false + gotPost = false + + handler(nil, &http.Request{Method: http.MethodPost}) + if gotGet || !gotPost { + t.Fatal(gotGet, gotPost) + } +} diff --git a/src/server/storage.go b/src/server/storage.go new file mode 100644 index 0000000..c675959 --- /dev/null +++ b/src/server/storage.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "local/storage" +) + +const ( + nsGames = "games" +) + +type Storage struct { + config Config + db storage.DB +} + +func NewStorage(config Config) *Storage { + return &Storage{ + config: config, + db: config.Game.db, + } +} + +func (storage Storage) GetGame(id string) (Game, error) { + var game Game + + b, err := storage.db.Get(id, nsGames) + if err != nil { + return game, err + } + + err = json.Unmarshal(b, &game) + return game, err +} + +func (storage Storage) CreateGame(id string) error { + b, err := json.Marshal(Game{}) + if err != nil { + return err + } + + return storage.db.Set(id, b, nsGames) +} + +func (storage Storage) ListGames() ([]string, error) { + return storage.db.List([]string{nsGames}) +}