commit 718eb09a78142e6e359f3ac9fbf08b31d6445e5c Author: rudder Date: Wed Aug 12 14:04:30 2026 +0300 Initial commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/liveops-go-sdk.iml b/.idea/liveops-go-sdk.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/liveops-go-sdk.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..90dee70 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + {} + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..4b1c544 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..00b75d7 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# liveops-go-sdk + +Серверный Go SDK для LiveOps-платформы Rudder. Покрывает server-admin +поверхность `/game/v1` (auth по `X-API-Key`, ключ — `GAME_ADMIN_API_KEY` +на gateway). + +## Install + +Модуль раздаётся напрямую с self-hosted Gitea, минуя proxy.golang.org, +поэтому Go должен пропускать его мимо checksum database: + +```bash +go env -w GOPRIVATE=hub.rudder.build/* +go get hub.rudder.build/rudder/rudder-go-sdk@latest +``` + +Требуется Go 1.25+. Токен не нужен — репозиторий публичный. + +## Usage + +```go +client, err := rudder.New(rudder.Config{ + APIKey: os.Getenv("GAME_ADMIN_API_KEY"), + ProjectID: "your-project-id", +}) +if err != nil { + // *rudder.APIError с кодом sdk/invalid-options +} + +players, err := client.Players.List(ctx, rudder.ListPlayersParameters{Limit: 100}) +entries, err := client.Leaderboards.Results(ctx, "weekly") +wallet, err := client.Wallet.Adjust(ctx, playerID, rudder.AdjustWalletParameters{ + CurrencyCode: "coins", + Amount: 100, + Reason: "compensation", +}) +metrics, err := client.Metrics.Get(ctx) +page, err := client.Ugc.List(ctx, rudder.ListUgcParameters{Status: "pending"}) +``` + +## Ошибки + +```go +var apiErr *rudder.APIError +if errors.As(err, &apiErr) { + apiErr.Status // HTTP status + apiErr.Code // машинный код, константы в models.ErrorCode* + apiErr.RequestID // корреляция с логами gateway +} +``` + +## Генерация + +`models/` генерируется apigen'ом из `liveops-gateway/openapi.yaml` +(`make generate-openapi` в liveops-gateway). Руками не править. diff --git a/client.go b/client.go new file mode 100644 index 0000000..0cbc080 --- /dev/null +++ b/client.go @@ -0,0 +1,58 @@ +package rudder + +import ( + "net/http" + "time" +) + +const DefaultBaseURL = "https://api.rudder.build" + +type Config struct { + APIKey string + ProjectID string + BaseURL string + Timeout time.Duration + HTTPClient *http.Client +} + +type Client struct { + Players *PlayersService + Metrics *MetricsService + Leaderboards *LeaderboardsService + Wallet *WalletService + Ugc *UgcService +} + +func New(config Config) (*Client, error) { + if config.APIKey == "" { + return nil, &APIError{Code: ErrorCodeInvalidOptions, Message: "APIKey is required"} + } + if config.ProjectID == "" { + return nil, &APIError{Code: ErrorCodeInvalidOptions, Message: "ProjectID is required"} + } + + if config.BaseURL == "" { + config.BaseURL = DefaultBaseURL + } + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: config.Timeout} + } + + transport := &transport{ + baseURL: config.BaseURL, + apiKey: config.APIKey, + projectID: config.ProjectID, + httpClient: config.HTTPClient, + } + + return &Client{ + Players: &PlayersService{transport: transport}, + Metrics: &MetricsService{transport: transport}, + Leaderboards: &LeaderboardsService{transport: transport}, + Wallet: &WalletService{transport: transport}, + Ugc: &UgcService{transport: transport}, + }, nil +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..991c3ea --- /dev/null +++ b/errors.go @@ -0,0 +1,19 @@ +package rudder + +import "fmt" + +const ErrorCodeInvalidOptions = "sdk/invalid-options" + +type APIError struct { + Status int + Code string + Message string + RequestID string +} + +func (e *APIError) Error() string { + if e.RequestID != "" { + return fmt.Sprintf("rudder: status %d, code %q: %s (request %s)", e.Status, e.Code, e.Message, e.RequestID) + } + return fmt.Sprintf("rudder: status %d, code %q: %s", e.Status, e.Code, e.Message) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..332891e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module hub.rudder.build/rudder/rudder-go-sdk + +go 1.25.2 diff --git a/leaderboards.go b/leaderboards.go new file mode 100644 index 0000000..406b07e --- /dev/null +++ b/leaderboards.go @@ -0,0 +1,21 @@ +package rudder + +import ( + "context" + "net/http" + "net/url" + + "hub.rudder.build/rudder/rudder-go-sdk/models" +) + +type LeaderboardsService struct { + transport *transport +} + +func (s *LeaderboardsService) Results(ctx context.Context, slug string) ([]models.RankEntry, error) { + var response models.GetLeaderboardResultsResponse + if err := s.transport.do(ctx, http.MethodGet, "/leaderboards/"+url.PathEscape(slug)+"/results", nil, nil, &response); err != nil { + return nil, err + } + return response.Entries, nil +} diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..35574cb --- /dev/null +++ b/metrics.go @@ -0,0 +1,20 @@ +package rudder + +import ( + "context" + "net/http" + + "hub.rudder.build/rudder/rudder-go-sdk/models" +) + +type MetricsService struct { + transport *transport +} + +func (s *MetricsService) Get(ctx context.Context) (*models.GetProjectMetricsResponse, error) { + var response models.GetProjectMetricsResponse + if err := s.transport.do(ctx, http.MethodGet, "/metrics", nil, nil, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/models/adjustplayerwalletrequest.go b/models/adjustplayerwalletrequest.go new file mode 100644 index 0000000..58cf3a9 --- /dev/null +++ b/models/adjustplayerwalletrequest.go @@ -0,0 +1,10 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type AdjustPlayerWalletRequest struct { + Amount int64 `json:"amount,omitempty"` + CurrencyCode string `json:"currencyCode,omitempty"` + PlayerID string `json:"playerId,omitempty"` + Reason string `json:"reason,omitempty"` +} diff --git a/models/adminplayer.go b/models/adminplayer.go new file mode 100644 index 0000000..ab8ea22 --- /dev/null +++ b/models/adminplayer.go @@ -0,0 +1,19 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +import "encoding/json" + +type AdminPlayer struct { + CreatedAt string `json:"createdAt,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + ID string `json:"id,omitempty"` + Language string `json:"language,omitempty"` + LastSeen string `json:"lastSeen,omitempty"` + Nickname string `json:"nickname,omitempty"` + PayerFlag bool `json:"payerFlag,omitempty"` + ProjectID string `json:"projectId,omitempty"` + Region string `json:"region,omitempty"` + StableID string `json:"stableId,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} diff --git a/models/adminplayerdetails.go b/models/adminplayerdetails.go new file mode 100644 index 0000000..2243d52 --- /dev/null +++ b/models/adminplayerdetails.go @@ -0,0 +1,11 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type AdminPlayerDetails struct { + Inventory []InventoryItem `json:"inventory,omitempty"` + Player AdminPlayer `json:"player,omitempty"` + Storages []PlayerStorage `json:"storages,omitempty"` + WalletTransactions []WalletAudit `json:"walletTransactions,omitempty"` + Wallets []WalletDetails `json:"wallets,omitempty"` +} diff --git a/models/error_codes.go b/models/error_codes.go new file mode 100644 index 0000000..e80d61b --- /dev/null +++ b/models/error_codes.go @@ -0,0 +1,15 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +const ( + ErrorCodeEarlyCompletion = "early_completion" + ErrorCodeForbidden = "forbidden" + ErrorCodeLevelNotReached = "level_not_reached" + ErrorCodeNodeNotActive = "node_not_active" + ErrorCodeObjectivesIncomplete = "objectives_incomplete" + ErrorCodeRunExpired = "run_expired" + ErrorCodeRunNotActive = "run_not_active" + ErrorCodeScenarioNotActive = "scenario_not_active" + ErrorCodeUnknownRun = "unknown_run" +) diff --git a/models/getleaderboardresultsresponse.go b/models/getleaderboardresultsresponse.go new file mode 100644 index 0000000..59dc31f --- /dev/null +++ b/models/getleaderboardresultsresponse.go @@ -0,0 +1,7 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type GetLeaderboardResultsResponse struct { + Entries []RankEntry `json:"entries,omitempty"` +} diff --git a/models/getprojectmetricsresponse.go b/models/getprojectmetricsresponse.go new file mode 100644 index 0000000..1427b99 --- /dev/null +++ b/models/getprojectmetricsresponse.go @@ -0,0 +1,11 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type GetProjectMetricsResponse struct { + Dau int `json:"dau,omitempty"` + DauChange float64 `json:"dauChange,omitempty"` + Mau int `json:"mau,omitempty"` + MauChange float64 `json:"mauChange,omitempty"` + Retention []RetentionPoint `json:"retention,omitempty"` +} diff --git a/models/inventoryitem.go b/models/inventoryitem.go new file mode 100644 index 0000000..4378839 --- /dev/null +++ b/models/inventoryitem.go @@ -0,0 +1,10 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type InventoryItem struct { + Amount int64 `json:"amount,omitempty"` + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} diff --git a/models/listplayersresponse.go b/models/listplayersresponse.go new file mode 100644 index 0000000..9973784 --- /dev/null +++ b/models/listplayersresponse.go @@ -0,0 +1,10 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type ListPlayersResponse struct { + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + Players []AdminPlayer `json:"players,omitempty"` + Total int `json:"total,omitempty"` +} diff --git a/models/listugcresponse.go b/models/listugcresponse.go new file mode 100644 index 0000000..c6ed672 --- /dev/null +++ b/models/listugcresponse.go @@ -0,0 +1,8 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type ListUgcResponse struct { + Items []UgcSubmission `json:"items,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` +} diff --git a/models/playerstorage.go b/models/playerstorage.go new file mode 100644 index 0000000..643b454 --- /dev/null +++ b/models/playerstorage.go @@ -0,0 +1,12 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type PlayerStorage struct { + Data string `json:"data,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + ID string `json:"id,omitempty"` + PlayerID string `json:"playerId,omitempty"` + Type string `json:"type,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} diff --git a/models/rankentry.go b/models/rankentry.go new file mode 100644 index 0000000..ab77f8a --- /dev/null +++ b/models/rankentry.go @@ -0,0 +1,10 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type RankEntry struct { + PlayerID string `json:"playerId,omitempty"` + PlayerName string `json:"playerName,omitempty"` + Rank int64 `json:"rank,omitempty"` + Score float64 `json:"score,omitempty"` +} diff --git a/models/retentionpoint.go b/models/retentionpoint.go new file mode 100644 index 0000000..7e2b082 --- /dev/null +++ b/models/retentionpoint.go @@ -0,0 +1,8 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type RetentionPoint struct { + Day int `json:"day,omitempty"` + Value float64 `json:"value,omitempty"` +} diff --git a/models/reviewugcrequest.go b/models/reviewugcrequest.go new file mode 100644 index 0000000..043fe0e --- /dev/null +++ b/models/reviewugcrequest.go @@ -0,0 +1,9 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type ReviewUgcRequest struct { + ID string `json:"id,omitempty"` + ReviewedBy string `json:"reviewedBy,omitempty"` + Status string `json:"status,omitempty"` +} diff --git a/models/submittedby.go b/models/submittedby.go new file mode 100644 index 0000000..635437d --- /dev/null +++ b/models/submittedby.go @@ -0,0 +1,8 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type SubmittedBy struct { + UserID string `json:"userId,omitempty"` + Username string `json:"username,omitempty"` +} diff --git a/models/ugcsubmission.go b/models/ugcsubmission.go new file mode 100644 index 0000000..e7d185f --- /dev/null +++ b/models/ugcsubmission.go @@ -0,0 +1,19 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +import "encoding/json" + +type UgcSubmission struct { + Description string `json:"description,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + FileURL string `json:"fileUrl,omitempty"` + ID string `json:"id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Name string `json:"name,omitempty"` + ReviewedAt string `json:"reviewedAt,omitempty"` + ReviewedBy string `json:"reviewedBy,omitempty"` + Status string `json:"status,omitempty"` + SubmittedAt string `json:"submittedAt,omitempty"` + SubmittedBy SubmittedBy `json:"submittedBy,omitempty"` +} diff --git a/models/updateugcmetadatarequest.go b/models/updateugcmetadatarequest.go new file mode 100644 index 0000000..4b38889 --- /dev/null +++ b/models/updateugcmetadatarequest.go @@ -0,0 +1,10 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +import "encoding/json" + +type UpdateUgcMetadataRequest struct { + ID string `json:"id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} diff --git a/models/walletaudit.go b/models/walletaudit.go new file mode 100644 index 0000000..2e5bcb5 --- /dev/null +++ b/models/walletaudit.go @@ -0,0 +1,19 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +import "encoding/json" + +type WalletAudit struct { + Amount int64 `json:"amount,omitempty"` + BalanceAfter int64 `json:"balanceAfter,omitempty"` + BalanceBefore int64 `json:"balanceBefore,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + CurrencyCode string `json:"currencyCode,omitempty"` + ID string `json:"id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + PlayerID string `json:"playerId,omitempty"` + Reason string `json:"reason,omitempty"` + Source string `json:"source,omitempty"` + WalletID string `json:"walletId,omitempty"` +} diff --git a/models/walletdetails.go b/models/walletdetails.go new file mode 100644 index 0000000..6a6c6c9 --- /dev/null +++ b/models/walletdetails.go @@ -0,0 +1,11 @@ +// Code generated by apigen. DO NOT EDIT. + +package models + +type WalletDetails struct { + Balance int64 `json:"balance,omitempty"` + CurrencyCode string `json:"currencyCode,omitempty"` + ID string `json:"id,omitempty"` + PlayerID string `json:"playerId,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} diff --git a/players.go b/players.go new file mode 100644 index 0000000..1d6c73e --- /dev/null +++ b/players.go @@ -0,0 +1,43 @@ +package rudder + +import ( + "context" + "net/http" + "net/url" + "strconv" + + "hub.rudder.build/rudder/rudder-go-sdk/models" +) + +type PlayersService struct { + transport *transport +} + +type ListPlayersParameters struct { + Limit int + Offset int +} + +func (s *PlayersService) List(ctx context.Context, parameters ListPlayersParameters) (*models.ListPlayersResponse, error) { + query := url.Values{} + if parameters.Limit > 0 { + query.Set("limit", strconv.Itoa(parameters.Limit)) + } + if parameters.Offset > 0 { + query.Set("offset", strconv.Itoa(parameters.Offset)) + } + + var response models.ListPlayersResponse + if err := s.transport.do(ctx, http.MethodGet, "/players/list", query, nil, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (s *PlayersService) Get(ctx context.Context, playerID string) (*models.AdminPlayerDetails, error) { + var response models.AdminPlayerDetails + if err := s.transport.do(ctx, http.MethodGet, "/players/"+url.PathEscape(playerID), nil, nil, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..944d3d8 --- /dev/null +++ b/transport.go @@ -0,0 +1,81 @@ +package rudder + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type transport struct { + baseURL string + apiKey string + projectID string + httpClient *http.Client +} + +func (t *transport) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + endpoint := strings.TrimSuffix(t.baseURL, "/") + "/game/v1/projects/" + t.projectID + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("rudder: encode request body: %w", err) + } + reader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return fmt.Errorf("rudder: build request: %w", err) + } + req.Header.Set("X-API-Key", t.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := t.httpClient.Do(req) + if err != nil { + return fmt.Errorf("rudder: %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("rudder: read response body: %w", err) + } + + if resp.StatusCode >= 400 { + apiErr := &APIError{Status: resp.StatusCode} + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + RequestID string `json:"requestId"` + } + if json.Unmarshal(data, &payload) == nil { + apiErr.Code = payload.Code + apiErr.Message = payload.Error + apiErr.RequestID = payload.RequestID + } + if apiErr.Message == "" { + apiErr.Message = http.StatusText(resp.StatusCode) + } + return apiErr + } + + if out == nil || len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("rudder: decode response: %w", err) + } + return nil +} diff --git a/ugc.go b/ugc.go new file mode 100644 index 0000000..274f435 --- /dev/null +++ b/ugc.go @@ -0,0 +1,72 @@ +package rudder + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + + "hub.rudder.build/rudder/rudder-go-sdk/models" +) + +type UgcService struct { + transport *transport +} + +type ListUgcParameters struct { + Status string + Limit int + Cursor string +} + +func (s *UgcService) List(ctx context.Context, parameters ListUgcParameters) (*models.ListUgcResponse, error) { + query := url.Values{} + if parameters.Status != "" { + query.Set("status", parameters.Status) + } + if parameters.Limit > 0 { + query.Set("limit", strconv.Itoa(parameters.Limit)) + } + if parameters.Cursor != "" { + query.Set("cursor", parameters.Cursor) + } + + var response models.ListUgcResponse + if err := s.transport.do(ctx, http.MethodGet, "/ugc", query, nil, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (s *UgcService) UpdateMetadata(ctx context.Context, id string, metadata json.RawMessage) (*models.UgcSubmission, error) { + body := models.UpdateUgcMetadataRequest{ + ID: id, + Metadata: metadata, + } + + var response models.UgcSubmission + if err := s.transport.do(ctx, http.MethodPatch, "/ugc/"+url.PathEscape(id)+"/metadata", nil, body, &response); err != nil { + return nil, err + } + return &response, nil +} + +type ReviewUgcParameters struct { + Status string + ReviewedBy string +} + +func (s *UgcService) Review(ctx context.Context, id string, parameters ReviewUgcParameters) (*models.UgcSubmission, error) { + body := models.ReviewUgcRequest{ + ID: id, + Status: parameters.Status, + ReviewedBy: parameters.ReviewedBy, + } + + var response models.UgcSubmission + if err := s.transport.do(ctx, http.MethodPost, "/ugc/"+url.PathEscape(id)+"/review", nil, body, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/wallet.go b/wallet.go new file mode 100644 index 0000000..be911e2 --- /dev/null +++ b/wallet.go @@ -0,0 +1,34 @@ +package rudder + +import ( + "context" + "net/http" + "net/url" + + "hub.rudder.build/rudder/rudder-go-sdk/models" +) + +type WalletService struct { + transport *transport +} + +type AdjustWalletParameters struct { + CurrencyCode string + Amount int64 + Reason string +} + +func (s *WalletService) Adjust(ctx context.Context, playerID string, parameters AdjustWalletParameters) (*models.WalletDetails, error) { + body := models.AdjustPlayerWalletRequest{ + PlayerID: playerID, + CurrencyCode: parameters.CurrencyCode, + Amount: parameters.Amount, + Reason: parameters.Reason, + } + + var response models.WalletDetails + if err := s.transport.do(ctx, http.MethodPost, "/players/"+url.PathEscape(playerID)+"/wallet/adjust", nil, body, &response); err != nil { + return nil, err + } + return &response, nil +}