fixtures #2

Merged
h merged 20 commits from fixtures into master 2026-02-23 20:38:26 +11:00
14 changed files with 427 additions and 46 deletions
Showing only changes of commit 9362448f22 - Show all commits

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/uptrace/bun v1.2.16
github.com/uptrace/bun/dialect/pgdialect v1.2.16
github.com/uptrace/bun/driver/pgdriver v1.2.16
golang.org/x/time v0.14.0
)
require (

2
go.sum
View File

@@ -90,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -10,19 +10,21 @@ import (
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/pkg/oauth"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/joho/godotenv"
"github.com/pkg/errors"
)
type Config struct {
DB *db.Config
HWS *hws.Config
HWSAuth *hwsauth.Config
HLOG *hlog.Config
Discord *discord.Config
OAuth *oauth.Config
RBAC *rbac.Config
Flags *Flags
DB *db.Config
HWS *hws.Config
HWSAuth *hwsauth.Config
HLOG *hlog.Config
Discord *discord.Config
OAuth *oauth.Config
RBAC *rbac.Config
Slapshot *slapshotapi.Config
Flags *Flags
}
// GetConfig loads the application configuration and returns a pointer to the Config object
@@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
discord.NewEZConfIntegration(),
oauth.NewEZConfIntegration(),
rbac.NewEZConfIntegration(),
slapshotapi.NewEZConfIntegration(),
)
if err != nil {
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
@@ -93,15 +96,21 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
return nil, nil, errors.New("RBAC Config not loaded")
}
slapcfg, ok := loader.GetConfig("slapshotapi")
if !ok {
return nil, nil, errors.New("SlapshotAPI Config not loaded")
}
config := &Config{
DB: dbcfg.(*db.Config),
HWS: hwscfg.(*hws.Config),
HWSAuth: hwsauthcfg.(*hwsauth.Config),
HLOG: hlogcfg.(*hlog.Config),
Discord: discordcfg.(*discord.Config),
OAuth: oauthcfg.(*oauth.Config),
RBAC: rbaccfg.(*rbac.Config),
Flags: flags,
DB: dbcfg.(*db.Config),
HWS: hwscfg.(*hws.Config),
HWSAuth: hwsauthcfg.(*hwsauth.Config),
HLOG: hlogcfg.(*hlog.Config),
Discord: discordcfg.(*discord.Config),
OAuth: oauthcfg.(*oauth.Config),
RBAC: rbaccfg.(*rbac.Config),
Slapshot: slapcfg.(*slapshotapi.Config),
Flags: flags,
}
return config, loader, nil

View File

@@ -1,3 +1,4 @@
// Package discord provides utilities for interacting with the discord API
package discord
import (

View File

@@ -19,19 +19,19 @@ type RateLimitState struct {
// Do executes an HTTP request with automatic rate limit handling
// It will wait if rate limits are about to be exceeded and retry once if a 429 is received
func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
func (api *APIClient) Do(req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errors.New("request cannot be nil")
}
// Step 1: Check if we need to wait before making request
bucket := c.getBucketFromRequest(req)
if err := c.waitIfNeeded(bucket); err != nil {
bucket := api.getBucketFromRequest(req)
if err := api.waitIfNeeded(bucket); err != nil {
return nil, err
}
// Step 2: Execute request
resp, err := c.client.Do(req)
resp, err := api.client.Do(req)
if err != nil {
// Check if it's a network timeout
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
@@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Step 3: Update rate limit state from response headers
c.updateRateLimit(resp.Header)
api.updateRateLimit(resp.Header)
// Step 4: Handle 429 (rate limited)
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() // Close original response
retryAfter := c.parseRetryAfter(resp.Header)
retryAfter := api.parseRetryAfter(resp.Header)
// No Retry-After header, can't retry safely
if retryAfter == 0 {
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// Retry-After exceeds 30 second cap
if retryAfter > 30*time.Second {
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Wait and retry
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
time.Sleep(retryAfter)
// Retry the request
resp, err = c.client.Do(req)
resp, err = api.client.Do(req)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return nil, errors.Wrap(err, "retry request timed out")
@@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Update rate limit again after retry
c.updateRateLimit(resp.Header)
api.updateRateLimit(resp.Header)
// If STILL rate limited after retry, return error
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
c.logger.Error().
api.logger.Error().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// getBucketFromRequest extracts or generates bucket ID from request
// For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers
func (c *APIClient) getBucketFromRequest(req *http.Request) string {
func (api *APIClient) getBucketFromRequest(req *http.Request) string {
return req.Method + ":" + req.URL.Path
}
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
func (c *APIClient) waitIfNeeded(bucket string) error {
c.mu.RLock()
state, exists := c.buckets[bucket]
c.mu.RUnlock()
func (api *APIClient) waitIfNeeded(bucket string) error {
api.mu.RLock()
state, exists := api.buckets[bucket]
api.mu.RUnlock()
if !exists {
return nil // No state yet, proceed
@@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
waitDuration += 100 * time.Millisecond
if waitDuration > 0 {
c.logger.Debug().
api.logger.Debug().
Str("bucket", bucket).
Dur("wait_duration", waitDuration).
Msg("Proactively waiting for rate limit reset")
@@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
}
// updateRateLimit parses response headers and updates bucket state
func (c *APIClient) updateRateLimit(headers http.Header) {
func (api *APIClient) updateRateLimit(headers http.Header) {
bucket := headers.Get("X-RateLimit-Bucket")
if bucket == "" {
return // No bucket info, can't track
}
// Parse headers
limit := c.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := c.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After"))
limit := api.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := api.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After"))
state := &RateLimitState{
Bucket: bucket,
@@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
}
c.mu.Lock()
c.buckets[bucket] = state
c.mu.Unlock()
api.mu.Lock()
api.buckets[bucket] = state
api.mu.Unlock()
// Log rate limit state for debugging
c.logger.Debug().
api.logger.Debug().
Str("bucket", bucket).
Int("remaining", remaining).
Int("limit", limit).
@@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
}
// parseRetryAfter extracts retry delay from Retry-After header
func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration {
retryAfter := headers.Get("Retry-After")
if retryAfter == "" {
return 0
}
// Discord returns seconds as float
seconds := c.parseFloat(retryAfter)
seconds := api.parseFloat(retryAfter)
if seconds <= 0 {
return 0
}
@@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
}
// parseInt parses an integer from a header value, returns 0 on error
func (c *APIClient) parseInt(s string) int {
func (api *APIClient) parseInt(s string) int {
if s == "" {
return 0
}
@@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int {
}
// parseFloat parses a float from a header value, returns 0 on error
func (c *APIClient) parseFloat(s string) float64 {
func (api *APIClient) parseFloat(s string) float64 {
if s == "" {
return 0
}

View File

@@ -0,0 +1,18 @@
package discord
import (
"github.com/pkg/errors"
)
func (s *OAuthSession) GetSteamID() (string, error) {
connections, err := s.UserConnections()
if err != nil {
return "", errors.Wrap(err, "s.UserConnections")
}
for _, conn := range connections {
if conn.Type == "steam" {
return conn.ID, nil
}
}
return "", errors.New("steam connection not found")
}

37
pkg/slapshotapi/client.go Normal file
View File

@@ -0,0 +1,37 @@
package slapshotapi
import (
"net/http"
"sync"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
type SlapAPI struct {
client *http.Client
ratelimiter *rate.Limiter
mu sync.Mutex
maxTokens int
key string
env string
}
func NewSlapAPIClient(cfg *Config) (*SlapAPI, error) {
if cfg == nil {
return nil, errors.New("config cannot be nil")
}
if cfg.Environment != "api" && cfg.Environment != "staging" {
return nil, errors.New("invalid env specified, must be 'api' or 'staging'")
}
rl := rate.NewLimiter(rate.Inf, 10)
client := &SlapAPI{
client: http.DefaultClient,
ratelimiter: rl,
mu: sync.Mutex{},
maxTokens: 10,
key: cfg.Key,
env: cfg.Environment,
}
return client, nil
}

23
pkg/slapshotapi/config.go Normal file
View File

@@ -0,0 +1,23 @@
// Package slapshotapi provides utilities for interacting with the slapshot public API
package slapshotapi
import (
"git.haelnorr.com/h/golib/env"
"github.com/pkg/errors"
)
type Config struct {
Environment string // ENV SLAPSHOT_ENVIRONMENT: API environment to connect to (default: staging)
Key string // ENV SLAPSHOT_API_KEY: API Key for authorisation with the API (required)
}
func ConfigFromEnv() (any, error) {
cfg := &Config{
Environment: env.String("SLAPSHOT_ENVIRONMENT", "staging"),
Key: env.String("SLAPSHOT_API_KEY", ""),
}
if cfg.Key == "" {
return nil, errors.New("Envar not set: SLAPSHOT_API_KEY")
}
return cfg, nil
}

35
pkg/slapshotapi/enums.go Normal file
View File

@@ -0,0 +1,35 @@
package slapshotapi
const (
RegionEUWest = "eu-west"
RegionNAEast = "na-east"
RegionNACentral = "na-central"
RegionNAWest = "na-west"
RegionOCEEast = "oce-east"
ArenaSlapstadium = "Slapstadium"
ArenaSlapville = "Slapville"
ArenaSlapstadiumMini = "Slapstadium_mini"
ArenaTableHockey = "Table_Hockey"
ArenaColosseum = "Colosseum"
ArenaSlapvilleJumbo = "Slapville_Jumbo"
ArenaSlapstation = "Slapstation"
ArenaSlapstadiumXL = "Slapstadium_XL"
ArenaIsland = "Island"
ArenaObstacles = "Obstacles"
ArenaObstaclesXL = "Obstacles_XL"
EndReasonEndOfReg = "EndOfRegulation"
EndReasonOvertime = "Overtime"
EndReasonHomeTeamLeft = "HomeTeamLeft"
EndReasonAwayTeamLeft = "AwayTeamLeft"
EndReasonMercy = "MercyRule"
EndReasonTie = "Tie"
EndReasonForfeit = "Forfeit"
EndReasonCancelled = "Cancelled"
EndReasonUnknown = "Unknown"
GameModeHockey = "hockey"
GameModeDodgePuck = "dodgepuck"
GameModeTag = "tag"
)

41
pkg/slapshotapi/ezconf.go Normal file
View File

@@ -0,0 +1,41 @@
package slapshotapi
import (
"runtime"
"strings"
)
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration {
return EZConfIntegration{name: "SlapshotAPI", configFunc: ConfigFromEnv}
}

View File

@@ -0,0 +1,59 @@
package slapshotapi
import (
"context"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
"golang.org/x/time/rate"
)
func (c *SlapAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) {
for {
err := c.ratelimiter.Wait(ctx)
if err != nil {
return nil, errors.Wrap(err, "c.ratelimiter.Wait")
}
resp, err := c.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "c.client.Do")
}
if resp.StatusCode == http.StatusTooManyRequests {
resetAfter := 30 * time.Second
err := resp.Body.Close()
if err != nil {
return nil, errors.Wrap(err, "resp.Body.Close")
}
if resetAfter > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(resetAfter):
continue
}
}
}
c.updateLimiterFromHeaders(resp.Header)
return resp, nil
}
}
func (c *SlapAPI) updateLimiterFromHeaders(h http.Header) {
c.mu.Lock()
defer c.mu.Unlock()
limit, err1 := strconv.Atoi(h.Get("RateLimit-Limit"))
window, err2 := strconv.Atoi(h.Get("RateLimit-Window"))
if err1 != nil || err2 != nil || limit <= 0 || window <= 0 {
return
}
if limit != c.maxTokens || time.Duration(window) != time.Duration(float64(window)/float64(limit))*time.Second {
c.maxTokens = limit
c.ratelimiter.SetBurst(limit)
c.ratelimiter.SetLimit(rate.Every(time.Duration(window) / time.Duration(limit)))
}
}

View File

@@ -0,0 +1,62 @@
package slapshotapi
import (
"context"
"encoding/json"
"fmt"
"github.com/pkg/errors"
)
type endpointMatchmaking struct {
regions []string
}
func getEndpointMatchmaking(regions []string) *endpointMatchmaking {
return &endpointMatchmaking{
regions: regions,
}
}
func (ep *endpointMatchmaking) path() string {
path := "/api/public/matchmaking%s"
filters := ""
if len(ep.regions) > 0 {
filters = "?regions="
for i, region := range ep.regions {
filters = filters + region
if i+1 != len(ep.regions) {
filters = filters + ","
}
}
}
return fmt.Sprintf(path, filters)
}
func (ep *endpointMatchmaking) method() string {
return "GET"
}
type matchmakingresp struct {
Playlists PubsQueue `json:"playlists"`
}
type PubsQueue struct {
InQueue uint16 `json:"in_queue"`
InMatch uint16 `json:"in_match"`
}
// GetQueueStatus gets the number of players in public matchmaking
func (c *SlapAPI) GetQueueStatus(
ctx context.Context,
regions []string,
) (*PubsQueue, error) {
endpoint := getEndpointMatchmaking(regions)
data, err := c.request(ctx, endpoint)
if err != nil {
return nil, errors.Wrap(err, "slapapiReq")
}
resp := matchmakingresp{}
json.Unmarshal(data, &resp)
return &resp.Playlists, nil
}

View File

@@ -0,0 +1,44 @@
package slapshotapi
import (
"context"
"fmt"
"io"
"net/http"
"github.com/pkg/errors"
)
type endpoint interface {
path() string
method() string
}
func (c *SlapAPI) request(
ctx context.Context,
ep endpoint,
) ([]byte, error) {
baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path())
req, err := http.NewRequest(ep.method(), baseurl, nil)
if err != nil {
return nil, errors.Wrap(err, "http.NewRequest")
}
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key))
res, err := c.do(ctx, req)
if err != nil {
return nil, errors.Wrap(err, "http.DefaultClient.Do")
}
if res.StatusCode != 200 {
return nil, errors.New(fmt.Sprintf("Error making request: %v", res.StatusCode))
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, errors.Wrap(err, "io.ReadAll")
}
err = res.Body.Close()
if err != nil {
return nil, errors.Wrap(err, "resp.Body.Close")
}
return body, nil
}

49
pkg/slapshotapi/slapid.go Normal file
View File

@@ -0,0 +1,49 @@
package slapshotapi
import (
"context"
"encoding/json"
"fmt"
"github.com/pkg/errors"
)
type endpointSteamID struct {
steamID string
}
func getEndpointSteamID(steamID string) *endpointSteamID {
return &endpointSteamID{
steamID: steamID,
}
}
func (ep *endpointSteamID) path() string {
return fmt.Sprintf("/api/public/players/steam/%s", ep.steamID)
}
func (ep *endpointSteamID) method() string {
return "GET"
}
type idresp struct {
ID uint32 `json:"id"`
}
// GetSlapID returns the slapshot ID of the steam user
func (c *SlapAPI) GetSlapID(
ctx context.Context,
steamid string,
) (uint32, error) {
endpoint := getEndpointSteamID(steamid)
data, err := c.request(ctx, endpoint)
if err != nil {
return 0, errors.Wrap(err, "slapapiReq")
}
resp := idresp{}
err = json.Unmarshal(data, &resp)
if err != nil {
return 0, errors.Wrap(err, "json.Unmarshal")
}
return resp.ID, nil
}