feat: the Go server SDK

This commit is contained in:
edmand46
2026-11-01 13:04:02 +03:00
commit 852275a3ab
5 changed files with 682 additions and 0 deletions
+68
View File
@@ -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.
+330
View File
@@ -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
}
+254
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module rudder.build/server
go 1.27.0
+27
View File
@@ -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"`
}