From 852275a3abfdeabc5cb8d16fd12b61b43325967c Mon Sep 17 00:00:00 2001 From: edmand46 Date: Sun, 1 Nov 2026 13:04:02 +0300 Subject: [PATCH] feat: the Go server SDK --- README.md | 68 ++++++++++ client.go | 330 +++++++++++++++++++++++++++++++++++++++++++++++++ client_test.go | 254 +++++++++++++++++++++++++++++++++++++ go.mod | 3 + types.go | 27 ++++ 5 files changed, 682 insertions(+) create mode 100644 README.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 go.mod create mode 100644 types.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8ea5bd --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# rudder.build/server + +The game server of Rudder for Go. It calls the functions of the modules of a project for a player +the game server names, and it updates the profile of a player, its ban and the environment of +the key. It uses the standard library only. + +## Install + +```sh +go get rudder.build/server@v1.0.0 +``` + +The module path is resolved through the `go-import` meta tag of `rudder.build/server`; set +`GOPRIVATE=rudder.build` (or `GONOSUMDB`/`GONOSUMCHECK`) so the Go command fetches it from the +Rudder Gitea instead of the public proxy. + +## Generate the client + +In the Go module of the game server, where `rudder project use` chose the project: + +```sh +rudder server generate --lang go --output ./rudderclient --package rudderclient \ + [--game-address https://api.rudder.build] [--environment staging|prod] +``` + +The client follows the newest release of the environment, staging by default. It writes a +package with a `Client` and one subpackage per module (`stores`, `battlepass`), a method per +function, the functions marked `server` included, and the types of their arguments and results. +Every method names the player first. Generate it again when a release changes the functions or +their types. + +## Use + +```go +import ( + "context" + + "game/rudderclient" + "rudder.build/server" +) + +client := rudderclient.New(server.Options{ServerKey: serverKey}) + +environment, err := client.Server.Environment(ctx) +purchase, err := client.Stores.Buy(ctx, playerID, rudderclient.StoresBuyArguments{Offer: "pumpkin-pack"}, nil) + +err = client.Server.SetPlayerProfile(ctx, playerID, server.Profile{Nickname: "Ada", Avatar: "https://example.com/ada.png"}) +err = client.Server.BanPlayer(ctx, playerID, server.Ban{Until: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), Reason: "cheating"}) +err = client.Server.UnbanPlayer(ctx, playerID) +``` + +The generated root `Client` holds `Server *server.Client` and a client per module; `New` takes +`server.Options` (`ServerKey`, `Address`, `Attempts`, `HTTPClient`). + +- **Environment.** `client.Server.Environment(ctx)` answers `staging` or `prod`, the environment + of the server key. The header of the generated files names the environment they were generated + from, which can be another one. +- **Calls.** A query reads and a mutation changes data, for the player id the method names + first. A mutation sends an `Idempotency-Key`, generated for each call unless + `server.MutationOptions{IdempotencyKey: ...}` is passed. A banned player is refused to the game + client only. +- **Profile and ban.** `SetPlayerProfile` replaces the nickname and the avatar, `BanPlayer` bans + until the moment given, `UnbanPlayer` lifts it. +- **Errors.** A refused call answers `*server.Error` with `Code`, `Message` and `Status`; use + `errors.As` to read it. +- **Retries.** A request that got no answer or the answer `try_again` is sent again after the + `Retry-After` it gave or a short backoff, at most `Attempts` times (5), with the same + idempotency key. 429 is not retried. Pass `context.Context` to cancel. diff --git a/client.go b/client.go new file mode 100644 index 0000000..8cd805d --- /dev/null +++ b/client.go @@ -0,0 +1,330 @@ +package server + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + mathrand "math/rand" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultAttempts = 5 + firstBackoff = 500 * time.Millisecond + longestBackoff = 8 * time.Second +) + +type Options struct { + ServerKey string + Address string + Attempts int + HTTPClient *http.Client +} + +type Client struct { + options Options +} + +func New(options Options) *Client { + if options.Attempts == 0 { + options.Attempts = defaultAttempts + } + + if options.HTTPClient == nil { + options.HTTPClient = http.DefaultClient + } + + options.Address = strings.TrimRight(options.Address, "/") + + return &Client{ + options: options, + } +} + +type MutationOptions struct { + IdempotencyKey string +} + +type Profile struct { + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +type Ban struct { + Until time.Time `json:"until"` + Reason string `json:"reason"` +} + +type Environment string + +const ( + Staging Environment = "staging" + Prod Environment = "prod" +) + +type Error struct { + Code string + Message string + Status int +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +func Query[T any](ctx context.Context, client *Client, module string, function string, playerID string, input any) (T, error) { + var value T + + answer, status, err := client.call(ctx, module, function, playerID, input, "") + if err != nil { + return value, err + } + + if err := json.Unmarshal(answer, &value); err != nil { + return value, &Error{ + Code: "invalid_response", + Message: "the result is not one of the module", + Status: status, + } + } + + return value, nil +} + +func Mutation[T any](ctx context.Context, client *Client, module string, function string, playerID string, input any, options *MutationOptions) (T, error) { + var value T + + key := "" + if options != nil { + key = options.IdempotencyKey + } + + if key == "" { + generated, err := newKey() + if err != nil { + return value, err + } + + key = generated + } + + answer, status, err := client.call(ctx, module, function, playerID, input, key) + if err != nil { + return value, err + } + + if err := json.Unmarshal(answer, &value); err != nil { + return value, &Error{ + Code: "invalid_response", + Message: "the result is not one of the module", + Status: status, + } + } + + return value, nil +} + +func (c *Client) SetPlayerProfile(ctx context.Context, playerID string, profile Profile) error { + _, _, err := c.do(ctx, http.MethodPut, playerPath(playerID)+"/profile", profile, "") + + return err +} + +func (c *Client) BanPlayer(ctx context.Context, playerID string, ban Ban) error { + _, _, err := c.do(ctx, http.MethodPut, playerPath(playerID)+"/ban", ban, "") + + return err +} + +func (c *Client) UnbanPlayer(ctx context.Context, playerID string) error { + _, _, err := c.do(ctx, http.MethodDelete, playerPath(playerID)+"/ban", nil, "") + + return err +} + +func (c *Client) Environment(ctx context.Context) (Environment, error) { + body, status, err := c.do(ctx, http.MethodGet, "/server/v1/environment", nil, "") + if err != nil { + return "", err + } + + var answer struct { + Environment Environment `json:"environment"` + } + + if err := json.Unmarshal(body, &answer); err != nil { + return "", &Error{ + Code: "invalid_response", + Message: "the answer is not one of Rudder", + Status: status, + } + } + + return answer.Environment, nil +} + +func (c *Client) call(ctx context.Context, module string, function string, playerID string, input any, idempotencyKey string) (json.RawMessage, int, error) { + args := input + if args == nil { + args = struct{}{} + } + + path := playerPath(playerID) + "/modules/" + url.PathEscape(module) + "/" + url.PathEscape(function) + + body, status, err := c.do(ctx, http.MethodPost, path, map[string]any{"args": args}, idempotencyKey) + if err != nil { + return nil, status, err + } + + var answer struct { + Result json.RawMessage `json:"result"` + } + + if err := json.Unmarshal(body, &answer); err != nil { + return nil, status, &Error{ + Code: "invalid_response", + Message: "the answer is not one of Rudder", + Status: status, + } + } + + return answer.Result, status, nil +} + +func (c *Client) do(ctx context.Context, method string, path string, body any, idempotencyKey string) ([]byte, int, error) { + var payload []byte + + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("encode body: %w", err) + } + + payload = encoded + } + + for attempt := 1; ; attempt++ { + request, err := http.NewRequestWithContext(ctx, method, c.options.Address+path, bytes.NewReader(payload)) + if err != nil { + return nil, 0, fmt.Errorf("new request: %w", err) + } + + request.Header.Set("X-Rudder-Server-Key", c.options.ServerKey) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + if idempotencyKey != "" { + request.Header.Set("Idempotency-Key", idempotencyKey) + } + + response, err := c.options.HTTPClient.Do(request) + if err != nil { + if ctx.Err() != nil || attempt >= c.options.Attempts { + return nil, 0, err + } + + if err := wait(ctx, backoff(attempt)); err != nil { + return nil, 0, err + } + + continue + } + + answer, readErr := io.ReadAll(response.Body) + response.Body.Close() + + if readErr != nil { + return nil, 0, fmt.Errorf("read answer: %w", readErr) + } + + if response.StatusCode == http.StatusServiceUnavailable && attempt < c.options.Attempts { + if err := wait(ctx, retryAfter(response, attempt)); err != nil { + return nil, 0, err + } + + continue + } + + if response.StatusCode >= 200 && response.StatusCode < 300 { + return answer, response.StatusCode, nil + } + + return nil, response.StatusCode, failure(response.StatusCode, answer) + } +} + +func playerPath(playerID string) string { + return "/server/v1/players/" + url.PathEscape(playerID) +} + +func failure(status int, body []byte) error { + var answer struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &answer); err != nil || answer.Error.Code == "" { + return &Error{ + Code: "invalid_response", + Message: fmt.Sprintf("the answer of status %d is not one of Rudder", status), + Status: status, + } + } + + return &Error{ + Code: answer.Error.Code, + Message: answer.Error.Message, + Status: status, + } +} + +func retryAfter(response *http.Response, attempt int) time.Duration { + seconds, err := strconv.Atoi(response.Header.Get("Retry-After")) + if err == nil { + return time.Duration(seconds) * time.Second + } + + return backoff(attempt) +} + +func backoff(attempt int) time.Duration { + delay := firstBackoff << min(attempt-1, 10) + if delay > longestBackoff { + delay = longestBackoff + } + + return time.Duration(float64(delay) * (0.5 + mathrand.Float64()/2)) +} + +func wait(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func newKey() (string, error) { + buffer := make([]byte, 16) + + if _, err := cryptorand.Read(buffer); err != nil { + return "", fmt.Errorf("generate idempotency key: %w", err) + } + + return hex.EncodeToString(buffer), nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..715b7a1 --- /dev/null +++ b/client_test.go @@ -0,0 +1,254 @@ +package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +type request struct { + method string + path string + headers http.Header + body string +} + +func testClient(t *testing.T, handler http.Handler) *Client { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + return New(Options{ + ServerKey: "server_key", + Address: server.URL + "/", + }) +} + +func readBody(t *testing.T, r *http.Request) string { + t.Helper() + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + return string(body) +} + +func TestQuerySendsThePlayerAndDecodesTheResult(t *testing.T) { + var sent request + + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} + _, _ = w.Write([]byte(`{"result": {"asset": "gems", "amount": 5}}`)) + })) + + amount, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) + if err != nil { + t.Fatal(err) + } + + if amount.Asset != "gems" || amount.Amount != 5 { + t.Fatalf("amount %+v, want 5 gems", amount) + } + + if sent.method != http.MethodPost || sent.path != "/server/v1/players/player-1/modules/wallet/balance" { + t.Fatalf("request %s %s, want POST the module of the player", sent.method, sent.path) + } + + if sent.headers.Get("X-Rudder-Server-Key") != "server_key" { + t.Fatalf("server key header %q", sent.headers.Get("X-Rudder-Server-Key")) + } + + if sent.headers.Get("Idempotency-Key") != "" { + t.Fatalf("a query sent the idempotency key %q", sent.headers.Get("Idempotency-Key")) + } + + if sent.body != `{"args":{}}` { + t.Fatalf("query body %s", sent.body) + } +} + +func TestMutationGeneratesAndReusesTheCallerKey(t *testing.T) { + var sent request + + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} + _, _ = w.Write([]byte(`{"result": {"offer": "welcome"}}`)) + })) + + input := struct { + Offer string `json:"offer"` + }{Offer: "welcome"} + + result, err := Mutation[struct { + Offer string `json:"offer"` + }](context.Background(), client, "stores", "buy", "player-1", input, nil) + if err != nil { + t.Fatal(err) + } + + if result.Offer != "welcome" { + t.Fatalf("result %+v, want the welcome offer", result) + } + + if key := sent.headers.Get("Idempotency-Key"); len(key) != 32 { + t.Fatalf("generated key %q, want 32 hex characters", key) + } + + if sent.body != `{"args":{"offer":"welcome"}}` { + t.Fatalf("mutation body %s", sent.body) + } + + client = testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent = request{headers: r.Header, body: readBody(t, r)} + _, _ = w.Write([]byte(`{"result": null}`)) + })) + + if _, err := Mutation[struct{}](context.Background(), client, "stores", "reset", "player-1", nil, &MutationOptions{IdempotencyKey: "mine"}); err != nil { + t.Fatal(err) + } + + if sent.headers.Get("Idempotency-Key") != "mine" { + t.Fatalf("key %q, want the caller's", sent.headers.Get("Idempotency-Key")) + } +} + +func TestEnvironmentReadsTheKeyEnvironment(t *testing.T) { + var sent request + + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} + _, _ = w.Write([]byte(`{"environment": "staging"}`)) + })) + + environment, err := client.Environment(context.Background()) + if err != nil { + t.Fatal(err) + } + + if environment != Staging { + t.Fatalf("environment %q, want staging", environment) + } + + if sent.method != http.MethodGet || sent.path != "/server/v1/environment" { + t.Fatalf("request %s %s", sent.method, sent.path) + } + + if sent.body != "" { + t.Fatalf("environment body %q, want none", sent.body) + } +} + +func TestProfileBanAndUnban(t *testing.T) { + var sent []request + + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent = append(sent, request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)}) + w.WriteHeader(http.StatusNoContent) + })) + + until := time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC) + + if err := client.SetPlayerProfile(context.Background(), "player-1", Profile{Nickname: "Ada", Avatar: "https://example.com/ada.png"}); err != nil { + t.Fatal(err) + } + + if err := client.BanPlayer(context.Background(), "player-1", Ban{Until: until, Reason: "cheating"}); err != nil { + t.Fatal(err) + } + + if err := client.UnbanPlayer(context.Background(), "player-1"); err != nil { + t.Fatal(err) + } + + if len(sent) != 3 { + t.Fatalf("%d requests, want three", len(sent)) + } + + if sent[0].method != http.MethodPut || sent[0].path != "/server/v1/players/player-1/profile" || + sent[0].body != `{"nickname":"Ada","avatar":"https://example.com/ada.png"}` { + t.Fatalf("profile request %+v", sent[0]) + } + + if sent[1].method != http.MethodPut || sent[1].path != "/server/v1/players/player-1/ban" || + sent[1].body != `{"until":"2026-12-01T00:00:00Z","reason":"cheating"}` { + t.Fatalf("ban request %+v", sent[1]) + } + + if sent[2].method != http.MethodDelete || sent[2].path != "/server/v1/players/player-1/ban" || sent[2].body != "" { + t.Fatalf("unban request %+v", sent[2]) + } +} + +func TestTryAgainKeepsTheSameKey(t *testing.T) { + var attempts int32 + var keys []string + + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + keys = append(keys, r.Header.Get("Idempotency-Key")) + + if len(keys) == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error": {"code": "try_again", "message": "try again"}}`)) + + return + } + + _, _ = w.Write([]byte(`{"result": null}`)) + })) + + if _, err := Mutation[struct{}](context.Background(), client, "stores", "reset", "player-1", nil, nil); err != nil { + t.Fatal(err) + } + + if attempts != 2 { + t.Fatalf("%d attempts, want two", attempts) + } + + if keys[0] != keys[1] { + t.Fatalf("keys %v, want the same", keys) + } +} + +func TestRefusalIsAnErrorWithTheCodeAndStatus(t *testing.T) { + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": {"code": "player_not_found", "message": "no such player"}}`)) + })) + + _, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) + if err == nil { + t.Fatal("a refusal: no error") + } + + var failure *Error + if !errors.As(err, &failure) { + t.Fatalf("error %T, want *server.Error", err) + } + + if failure.Code != "player_not_found" || failure.Status != http.StatusNotFound { + t.Fatalf("error %+v", failure) + } +} + +func TestAnswerThatIsNotJSON(t *testing.T) { + client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + + _, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) + + var failure *Error + if !errors.As(err, &failure) || failure.Code != "invalid_response" { + t.Fatalf("error %v, want invalid_response", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d861a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module rudder.build/server + +go 1.27.0 diff --git a/types.go b/types.go new file mode 100644 index 0000000..bfe4bf8 --- /dev/null +++ b/types.go @@ -0,0 +1,27 @@ +package server + +import "time" + +type Amount struct { + Asset string `json:"asset"` + Amount int64 `json:"amount"` +} + +type Window struct { + Start *time.Time `json:"start,omitempty"` + End *time.Time `json:"end,omitempty"` +} + +type RegenRule struct { + Every int64 `json:"every"` + Max int64 `json:"max"` +} + +type RegenState struct { + Value int64 `json:"value"` + Max int64 `json:"max"` + Every int64 `json:"every"` + NextAt *time.Time `json:"nextAt,omitempty"` + FullAt *time.Time `json:"fullAt,omitempty"` + At time.Time `json:"at"` +}