1
0
Fork 0

[golang] bootstrapped a client in yet another language

This commit is contained in:
Julien Dessaux 2024-05-06 00:18:22 +02:00
parent 5aac233c08
commit 427cc77fa3
Signed by: adyxax
GPG key ID: F92E51B86E07177E
12 changed files with 447 additions and 0 deletions

View file

@ -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
}

5
golang/go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.adyxax.org/adyxax/spacetraders/v2
go 1.22.2
require github.com/mattn/go-sqlite3 v1.14.22

2
golang/go.sum Normal file
View file

@ -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=

14
golang/pkg/api/agents.go Normal file
View file

@ -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)
}

87
golang/pkg/api/api.go Normal file
View file

@ -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
}

75
golang/pkg/api/client.go Normal file
View file

@ -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,
}
}
}
}
}

23
golang/pkg/api/errors.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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,
})
}

View file

@ -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()
}

View file

@ -0,0 +1,7 @@
CREATE TABLE schema_version (
version INTEGER NOT NULL
);
CREATE TABLE tokens (
id INTEGER PRIMARY KEY,
data TEXT NOT NULL
);

View file

@ -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
}