feat: the Go server SDK
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user