init
This commit is contained in:
+289
@@ -0,0 +1,289 @@
|
||||
// Package api exposes the player-facing HTTP interface.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"wh/comms"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
// New returns the HTTP handler.
|
||||
//
|
||||
// POST /register {"name": "..."} -> player, api_key, ship_id
|
||||
// GET /me -> name, credits, day
|
||||
// GET /market -> ore prices
|
||||
// GET /info -> day and the belt's limits
|
||||
// POST /assemble assembly text -> program (base64), size
|
||||
// GET /ships -> your ships
|
||||
// PUT /ships/{id}/program raw bytecode (ship must be in inventory)
|
||||
// POST /ships/{id}/launch (enters the belt on the next run)
|
||||
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
|
||||
//
|
||||
// Authenticated routes take "Authorization: Bearer <api_key>".
|
||||
func New(st *store.Store, cfg config.Config) *http.ServeMux {
|
||||
s := &Server{st: st, cfg: cfg}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /register", s.register)
|
||||
mux.HandleFunc("GET /market", s.market)
|
||||
mux.HandleFunc("GET /info", s.info)
|
||||
mux.HandleFunc("POST /assemble", s.assemble)
|
||||
mux.Handle("GET /me", s.auth(s.me))
|
||||
mux.Handle("GET /ships", s.auth(s.ships))
|
||||
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
||||
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
||||
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
||||
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok {
|
||||
httpError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
p, err := s.st.PlayerByKey(r.Context(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpError(w, http.StatusUnauthorized, "invalid api key")
|
||||
} else {
|
||||
fail(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
h(w, r, p)
|
||||
})
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// fail maps store errors to HTTP statuses.
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
httpError(w, http.StatusNotFound, "not found")
|
||||
case errors.Is(err, store.ErrState):
|
||||
httpError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, store.ErrTaken):
|
||||
httpError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
httpError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 {
|
||||
httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`)
|
||||
return
|
||||
}
|
||||
p, key, ship, err := s.st.Register(r.Context(), req.Name)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
|
||||
}
|
||||
|
||||
func (s *Server) currentDay(ctx context.Context) int64 {
|
||||
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())})
|
||||
}
|
||||
|
||||
func (s *Server) market(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
v = "null"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, v)
|
||||
}
|
||||
|
||||
// info reports the day and the limits that apply to programs and messages.
|
||||
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
||||
ores := make([]string, world.NumOre)
|
||||
for o := range ores {
|
||||
ores[o] = world.Ore(o).String()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"day": s.currentDay(r.Context()),
|
||||
"ticks_per_day": s.cfg.TicksPerDay,
|
||||
"tick_seconds": s.cfg.TickSeconds(),
|
||||
"cycles_per_tick": s.cfg.CyclesPerTick,
|
||||
"program_bytes": s.cfg.ProgramBytes,
|
||||
"ram_bytes": s.cfg.RAMBytes,
|
||||
"comm_bytes": s.cfg.CommBytes,
|
||||
"ores": ores,
|
||||
})
|
||||
}
|
||||
|
||||
// assemble compiles assembly source with the same assembler as cmd/asm, so
|
||||
// that clients need no assembler of their own. Nothing is stored.
|
||||
func (s *Server) assemble(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := readBody(w, r, 64<<10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
prog, err := vm.Assemble(string(b))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"size": len(prog),
|
||||
"instructions": len(prog) / 4,
|
||||
"limit": s.cfg.ProgramBytes,
|
||||
"fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes,
|
||||
"program": base64.StdEncoding.EncodeToString(prog),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
ships, err := s.st.PlayerShips(r.Context(), p.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
type out struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ProgramSize int `json:"program_bytes"`
|
||||
DownlinkDay int64 `json:"downlink_day"`
|
||||
}
|
||||
res := make([]out, 0, len(ships))
|
||||
for _, sh := range ships {
|
||||
res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, "bad ship id")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// readBody reads at most limit bytes and rejects larger bodies.
|
||||
func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1))
|
||||
if err != nil || len(b) > limit {
|
||||
httpError(w, http.StatusRequestEntityTooLarge, "body too large")
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, ok := readBody(w, r, s.cfg.ProgramBytes)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, ok := readBody(w, r, s.cfg.CommBytes)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, day, err := s.st.Downlink(r.Context(), p.ID, id)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if day < 0 {
|
||||
httpError(w, http.StatusNotFound, "no downlink yet")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10))
|
||||
w.Write(data)
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/sim"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
)
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := config.Default()
|
||||
cfg.AsteroidCount = 20
|
||||
|
||||
// Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL
|
||||
// (its public schema is wiped first).
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
if pg := os.Getenv("WH_TEST_PG"); pg != "" {
|
||||
db, err := sql.Open("pgx", pg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
dsn = pg
|
||||
}
|
||||
st, err := store.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(api.New(st, cfg))
|
||||
defer srv.Close()
|
||||
|
||||
var key string
|
||||
do := func(method, path string, body []byte, want int) []byte {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, srv.URL+path, bytes.NewReader(body))
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
var reg struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ShipID int64 `json:"ship_id"`
|
||||
}
|
||||
json.Unmarshal(do("POST", "/register", []byte(`{"name":"ann"}`), 201), ®)
|
||||
do("POST", "/register", []byte(`{"name":"ann"}`), 409)
|
||||
do("GET", "/me", nil, 401)
|
||||
key = reg.APIKey
|
||||
ship := "/ships/" + itoa(reg.ShipID)
|
||||
|
||||
// Launch needs a program first; program limits are enforced.
|
||||
do("POST", ship+"/launch", nil, 409)
|
||||
do("PUT", ship+"/program", []byte{1, 2, 3}, 400)
|
||||
do("PUT", ship+"/program", make([]byte, cfg.ProgramBytes+4), 413)
|
||||
|
||||
// Echo the uplink back on the downlink, then idle.
|
||||
prog, err := vm.Assemble(`
|
||||
.equ TX 1024
|
||||
in r1, 0x70
|
||||
ldi r2, 0
|
||||
beq r1, r2, idle
|
||||
ldw r3, [r2]
|
||||
stw r3, [r2+TX]
|
||||
out 0x70, r1
|
||||
idle:
|
||||
yield
|
||||
jmp idle
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do("PUT", ship+"/program", prog, 204)
|
||||
do("PUT", ship+"/uplink", []byte("hi"), 409) // not launched yet
|
||||
do("POST", ship+"/launch", nil, 202)
|
||||
do("PUT", ship+"/program", prog, 409) // frozen once launching
|
||||
do("PUT", ship+"/uplink", []byte("PING"), 204)
|
||||
do("PUT", ship+"/uplink", make([]byte, cfg.CommBytes+1), 413)
|
||||
do("GET", ship+"/downlink", nil, 404)
|
||||
|
||||
res, err := runner.RunNextDay(ctx, st, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Day != 0 {
|
||||
t.Fatalf("first run should be day 0, got %d", res.Day)
|
||||
}
|
||||
if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
|
||||
t.Fatalf("downlink = %q", b[:8])
|
||||
}
|
||||
var ships []struct{ Status string }
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
|
||||
if len(ships) != 1 || ships[0].Status != store.StatusActive {
|
||||
t.Fatalf("ships = %+v", ships)
|
||||
}
|
||||
do("GET", "/market", nil, 200)
|
||||
|
||||
// A second day from the persisted snapshot must match an uninterrupted
|
||||
// in-memory run: persistence may not change the simulation.
|
||||
res2, err := runner.RunNextDay(ctx, st, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mem := sim.NewState(cfg)
|
||||
in := sim.DayInput{
|
||||
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}},
|
||||
Uplinks: []sim.Uplink{{ShipID: reg.ShipID, Data: []byte("PING")}},
|
||||
}
|
||||
var want sim.DayResult
|
||||
for d := 0; d < 2; d++ {
|
||||
if want, err = mem.RunDay(cfg, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in = sim.DayInput{}
|
||||
}
|
||||
if res2.Hash != want.Hash {
|
||||
t.Fatal("state after DB round-trip differs from in-memory run")
|
||||
}
|
||||
|
||||
// Changing the config on an existing world is refused.
|
||||
cfg.Seed = 99
|
||||
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
|
||||
t.Fatal("expected config mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/store"
|
||||
"wh/web"
|
||||
)
|
||||
|
||||
// newSite starts the API with the web front end mounted, as cmd/server does.
|
||||
func newSite(t *testing.T) (*httptest.Server, config.Config) {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, cfg)
|
||||
mux.Handle("/", web.Handler())
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
func TestInfoAndAssemble(t *testing.T) {
|
||||
srv, cfg := newSite(t)
|
||||
|
||||
_, body := get(t, srv.URL+"/info")
|
||||
var info struct {
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
CommBytes int `json:"comm_bytes"`
|
||||
Ores []string `json:"ores"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ProgramBytes != cfg.ProgramBytes || info.CommBytes != cfg.CommBytes || len(info.Ores) != 4 {
|
||||
t.Fatalf("info = %+v", info)
|
||||
}
|
||||
|
||||
post := func(src string) (int, map[string]any) {
|
||||
resp, err := http.Post(srv.URL+"/assemble", "text/plain", strings.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
code, m := post("ldi r1, 5\nhalt")
|
||||
if code != 200 || m["size"].(float64) != 8 || m["fits"] != true {
|
||||
t.Fatalf("assemble ok case: %d %v", code, m)
|
||||
}
|
||||
if raw, _ := base64.StdEncoding.DecodeString(m["program"].(string)); len(raw) != 8 {
|
||||
t.Fatalf("program bytes = %d", len(raw))
|
||||
}
|
||||
code, m = post("ldi r1, 5\nbogus r1")
|
||||
if code != 400 || !strings.Contains(m["error"].(string), "line 2") {
|
||||
t.Fatalf("assemble error case: %d %v", code, m)
|
||||
}
|
||||
// Too big for the belt: assembles, but reports that it does not fit.
|
||||
code, m = post(strings.Repeat("nop\n", cfg.ProgramBytes/4+1))
|
||||
if code != 200 || m["fits"] != false {
|
||||
t.Fatalf("oversize case: %d %v", code, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAssets(t *testing.T) {
|
||||
srv, _ := newSite(t)
|
||||
for path, want := range map[string]string{
|
||||
"/": "<title>",
|
||||
"/manual.txt": "PROGRAMMER'S REFERENCE MANUAL",
|
||||
"/examples/echo.s": "ECHO",
|
||||
"/examples/index.json": `"pursue"`,
|
||||
} {
|
||||
resp, body := get(t, srv.URL+path)
|
||||
if resp.StatusCode != 200 || !strings.Contains(body, want) {
|
||||
t.Errorf("%s: status %d, missing %q", path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
resp, _ := get(t, srv.URL+"/")
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||
t.Error("missing CSP header")
|
||||
}
|
||||
// The API still wins over the catch-all.
|
||||
if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("/market served as %q", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user