From 427cc77fa3633aca7c4c3377fa26b0e70921a7b5 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Mon, 6 May 2024 00:18:22 +0200 Subject: [golang] bootstrapped a client in yet another language --- golang/cmd/spacetraders/main.go | 78 ++++++++++++++++++++++++++++++++ golang/go.mod | 5 +++ golang/go.sum | 2 + golang/pkg/api/agents.go | 14 ++++++ golang/pkg/api/api.go | 87 ++++++++++++++++++++++++++++++++++++ golang/pkg/api/client.go | 75 +++++++++++++++++++++++++++++++ golang/pkg/api/errors.go | 23 ++++++++++ golang/pkg/api/priority_queue.go | 44 ++++++++++++++++++ golang/pkg/api/register.go | 20 +++++++++ golang/pkg/database/migrations.go | 78 ++++++++++++++++++++++++++++++++ golang/pkg/database/sql/000_init.sql | 7 +++ golang/pkg/database/tokens.go | 14 ++++++ 12 files changed, 447 insertions(+) create mode 100644 golang/cmd/spacetraders/main.go create mode 100644 golang/go.mod create mode 100644 golang/go.sum create mode 100644 golang/pkg/api/agents.go create mode 100644 golang/pkg/api/api.go create mode 100644 golang/pkg/api/client.go create mode 100644 golang/pkg/api/errors.go create mode 100644 golang/pkg/api/priority_queue.go create mode 100644 golang/pkg/api/register.go create mode 100644 golang/pkg/database/migrations.go create mode 100644 golang/pkg/database/sql/000_init.sql create mode 100644 golang/pkg/database/tokens.go diff --git a/golang/cmd/spacetraders/main.go b/golang/cmd/spacetraders/main.go new file mode 100644 index 0000000..937144c --- /dev/null +++ b/golang/cmd/spacetraders/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + + "git.adyxax.org/adyxax/spacetraders/v2/pkg/api" + "git.adyxax.org/adyxax/spacetraders/v2/pkg/database" +) + +func main() { + opts := &slog.HandlerOptions{ + // //AddSource: true, + Level: slog.LevelDebug, + } + //logger := slog.New(slog.NewJSONHandler(os.Stdout, opts)) + logger := slog.New(slog.NewTextHandler(os.Stdout, opts)) + slog.SetDefault(logger) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + db, err := database.DBInit(ctx, "./spacetraders.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DbInit error %+v\n", err) + os.Exit(1) + } + client := api.NewClient(ctx) + defer client.Close() + err = run( //ctx, + db, + client, + //os.Args, + //os.Getenv, + //os.Getwd, + //os.Stdin, + //os.Stdout, + //os.Stderr, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + if err = db.Close(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + } + os.Exit(2) + } +} + +func run( // ctx context.Context, + db *database.DB, + client *api.Client, + //args []string, + //getenv func(string) string, + //getwd func() (string, error), + //stdin io.Reader, + //stdout, stderr io.Writer, +) (err error) { + // ----- Get token or register --------------------------------------------- + token, err := db.GetToken() + if err != nil || token == "" { + var r api.APIMessage[api.RegisterMessage, any] + if r, err = client.Register("COSMIC", "ADYXAX-GO"); err != nil { + // TODO handle server reset + fmt.Printf("%+v, %+v\n", r, err) + return err + } + if err = db.AddToken(r.Data.Token); err != nil { + return err + } + } + client.SetToken(token) + // ----- Update agent ------------------------------------------------------ + agent, err := client.MyAgent() + slog.Info("agent", "agent", agent, "err", err) + return err +} diff --git a/golang/go.mod b/golang/go.mod new file mode 100644 index 0000000..25e76bd --- /dev/null +++ b/golang/go.mod @@ -0,0 +1,5 @@ +module git.adyxax.org/adyxax/spacetraders/v2 + +go 1.22.2 + +require github.com/mattn/go-sqlite3 v1.14.22 diff --git a/golang/go.sum b/golang/go.sum new file mode 100644 index 0000000..e8d092a --- /dev/null +++ b/golang/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/golang/pkg/api/agents.go b/golang/pkg/api/agents.go new file mode 100644 index 0000000..aa8107a --- /dev/null +++ b/golang/pkg/api/agents.go @@ -0,0 +1,14 @@ +package api + +type AgentMessage struct { + AccountID string `json:"accountId"` + Credits int `json:"credits"` + Headquarters string `json:"headquarters"` + ShipCount int `json:"shipCount"` + StartingFaction string `json:"startingFaction"` + Symbol string `json:"symbol"` +} + +func (c *Client) MyAgent() (APIMessage[AgentMessage, any], error) { + return Send[AgentMessage](c, "GET", "/my/agent", nil) +} diff --git a/golang/pkg/api/api.go b/golang/pkg/api/api.go new file mode 100644 index 0000000..662bb87 --- /dev/null +++ b/golang/pkg/api/api.go @@ -0,0 +1,87 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "time" +) + +type Error[T any] struct { + Code int `json:"code"` + Data T `json:"data"` + Message string `json:"message"` +} + +type APIMessage[T any, E any] struct { + Data T `json:"data"` + Error Error[E] `json:"error"` + //meta +} + +type Response struct { + Response []byte + Err error +} + +func Send[T any](c *Client, method, path string, payload any) (message APIMessage[T, any], err error) { + resp := make(chan *Response) + c.channel <- &Request{ + method: method, + path: path, + payload: payload, + priority: 10, + resp: resp, + } + res := <-resp + if res.Err != nil { + return message, res.Err + } + err = json.Unmarshal(res.Response, &message) + return message, err +} + +func (c *Client) sendOne(method, path string, payload any) (body []byte, err error) { + slog.Debug("Request", "method", method, "path", path, "payload", payload) + var req *http.Request + if payload != nil { + body, err = json.Marshal(payload) + if err == nil { + req, err = http.NewRequest(method, c.baseURL+path, bytes.NewBuffer(body)) + } else { + return nil, err + } + } else { + req, err = http.NewRequest(method, c.baseURL+path, nil) + } + if err != nil { + return nil, err + } + req.Header = *c.headers + req = req.WithContext(c.ctx) + + resp, err := c.httpClient.Do(req) + if err != nil { + slog.Error("sendOne Do", "method", method, "path", path, "error", err) + return nil, err + } + defer func() { + if e := resp.Body.Close(); err == nil { + err = e + } + }() + if body, err = io.ReadAll(resp.Body); err != nil { + slog.Error("sendOne ReadAll", "method", method, "path", path, "error", err) + return nil, err + } + slog.Debug("Response", "body", string(body)) + switch resp.StatusCode { + case 429: + e := decode429(body) + time.Sleep(time.Duration(e.Error.Data.RetryAfter * float64(time.Second))) + return c.sendOne(method, path, payload) + } + return body, nil +} diff --git a/golang/pkg/api/client.go b/golang/pkg/api/client.go new file mode 100644 index 0000000..70f3e68 --- /dev/null +++ b/golang/pkg/api/client.go @@ -0,0 +1,75 @@ +package api + +import ( + "container/heap" + "context" + "net/http" + "time" +) + +type Client struct { + baseURL string + channel chan *Request + ctx context.Context + headers *http.Header + httpClient *http.Client + pq *PriorityQueue +} + +func NewClient(ctx context.Context) *Client { + pq := make(PriorityQueue, 0) + heap.Init(&pq) + client := &Client{ + baseURL: "https://api.spacetraders.io/v2", + channel: make(chan *Request), + ctx: ctx, + headers: &http.Header{ + "Content-Type": {"application/json"}, + }, + httpClient: &http.Client{ + Timeout: time.Minute, + }, + pq: &pq, + } + go queueProcessor(client) + return client +} + +func (c *Client) Close() { + close(c.channel) +} + +func (c *Client) SetToken(token string) { + c.headers.Set("Authorization", "Bearer "+token) +} + +func queueProcessor(client *Client) { + var ok bool + for { + // The queue is empty so we do this blocking call + req := <-client.channel + heap.Push(client.pq, req) + // we enqueue all values read from the channel and process the queue's + // contents until empty. We keep reading the channel as long as this + // emptying goes on + for { + select { + case req = <-client.channel: + heap.Push(client.pq, req) + default: + if client.pq.Len() == 0 { + break + } + // we process one + if req, ok = heap.Pop(client.pq).(*Request); !ok { + panic("queueProcessor got something other than a Request on its channel") + } + response, err := client.sendOne(req.method, req.path, req.payload) + req.resp <- &Response{ + Response: response, + Err: err, + } + } + } + } +} diff --git a/golang/pkg/api/errors.go b/golang/pkg/api/errors.go new file mode 100644 index 0000000..d39a205 --- /dev/null +++ b/golang/pkg/api/errors.go @@ -0,0 +1,23 @@ +package api + +import ( + "encoding/json" + "fmt" + "time" +) + +type RateLimitError struct { + LimitType string `json:"type"` + RetryAfter float64 `json:"retryAfter"` + LimitBurst int `json:"limitBurst"` + LimitPerSecond int `json:"limitPerSecond"` + Remaining int `json:"remaining"` + Reset time.Time `json:"reset"` +} + +func decode429(msg []byte) (e APIMessage[any, RateLimitError]) { + if err := json.Unmarshal(msg, &e); err != nil { + panic(fmt.Sprintf("Failed to decode419: %+v", err)) + } + return e +} diff --git a/golang/pkg/api/priority_queue.go b/golang/pkg/api/priority_queue.go new file mode 100644 index 0000000..077c8f7 --- /dev/null +++ b/golang/pkg/api/priority_queue.go @@ -0,0 +1,44 @@ +package api + +type Request struct { + index int + priority int + + method string + path string + payload any + resp chan *Response +} + +type PriorityQueue []*Request + +func (pq PriorityQueue) Len() int { + return len(pq) +} + +func (pq PriorityQueue) Less(i, j int) bool { + return pq[i].priority < pq[j].priority +} + +func (pq PriorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].index = i + pq[j].index = j +} + +func (pq *PriorityQueue) Push(x any) { + n := len(*pq) + item := x.(*Request) + item.index = n + *pq = append(*pq, item) +} + +func (pq *PriorityQueue) Pop() any { + old := *pq + n := len(old) + item := old[n-1] + old[n-1] = nil // avoid memory leak + item.index = -1 // for safety + *pq = old[0 : n-1] + return item +} diff --git a/golang/pkg/api/register.go b/golang/pkg/api/register.go new file mode 100644 index 0000000..4f95e2f --- /dev/null +++ b/golang/pkg/api/register.go @@ -0,0 +1,20 @@ +package api + +type RegisterMessage struct { + //agent + //contract + //faction + //ship + Token string `json:"token"` +} + +func (c *Client) Register(faction, symbol string) (APIMessage[RegisterMessage, any], error) { + type RegisterRequest struct { + Faction string `json:"faction"` + Symbol string `json:"symbol"` + } + return Send[RegisterMessage](c, "POST", "/register", RegisterRequest{ + Faction: faction, + Symbol: symbol, + }) +} diff --git a/golang/pkg/database/migrations.go b/golang/pkg/database/migrations.go new file mode 100644 index 0000000..94207a5 --- /dev/null +++ b/golang/pkg/database/migrations.go @@ -0,0 +1,78 @@ +package database + +import ( + "context" + "database/sql" + "embed" + "io/fs" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + ctx context.Context + db *sql.DB +} + +//go:embed sql/*.sql +var schemaFiles embed.FS + +func DBInit(ctx context.Context, url string) (myDB *DB, err error) { + var db *sql.DB + if db, err = sql.Open("sqlite3", url); err != nil { + return nil, err + } + defer func() { + if err != nil { + _ = db.Close() + } + }() + + if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + return nil, err + } + if _, err = db.ExecContext(ctx, "PRAGMA journal_mode = WAL"); err != nil { + return nil, err + } + + var version int + if err = db.QueryRowContext(ctx, `SELECT version FROM schema_version;`).Scan(&version); err != nil { + if err.Error() == "no such table: schema_version" { + version = 0 + } else { + return nil, err + } + } + + statements := make([]string, 0) + err = fs.WalkDir(schemaFiles, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() || err != nil { + return err + } + var stmts []byte + if stmts, err = schemaFiles.ReadFile(path); err != nil { + return err + } else { + statements = append(statements, string(stmts)) + } + return nil + }) + if err != nil { + return nil, err + } + + for version < len(statements) { + if _, err = db.ExecContext(ctx, statements[version]); err != nil { + return nil, err + } + version++ + } + if _, err = db.ExecContext(ctx, `DELETE FROM schema_version; INSERT INTO schema_version (version) VALUES (?);`, version); err != nil { + return nil, err + } + return &DB{ctx: ctx, db: db}, nil +} + +func (db *DB) Close() error { + return db.db.Close() +} diff --git a/golang/pkg/database/sql/000_init.sql b/golang/pkg/database/sql/000_init.sql new file mode 100644 index 0000000..c06d2d3 --- /dev/null +++ b/golang/pkg/database/sql/000_init.sql @@ -0,0 +1,7 @@ +CREATE TABLE schema_version ( + version INTEGER NOT NULL +); +CREATE TABLE tokens ( + id INTEGER PRIMARY KEY, + data TEXT NOT NULL +); diff --git a/golang/pkg/database/tokens.go b/golang/pkg/database/tokens.go new file mode 100644 index 0000000..16bda2b --- /dev/null +++ b/golang/pkg/database/tokens.go @@ -0,0 +1,14 @@ +package database + +func (db DB) AddToken(token string) error { + _, err := db.db.ExecContext(db.ctx, `INSERT INTO tokens(data) VALUES (?);`, token) + return err +} + +func (db DB) GetToken() (string, error) { + var token string + if err := db.db.QueryRowContext(db.ctx, `SELECT data FROM tokens;`).Scan(&token); err != nil { + return "", err + } + return token, nil +} -- cgit v1.2.3