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 }