331 lines
7.1 KiB
Go
331 lines
7.1 KiB
Go
|
|
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
|
||
|
|
}
|