added slapapi

This commit is contained in:
2026-02-17 08:12:07 +11:00
parent 85fcf104b9
commit e50f855206
14 changed files with 427 additions and 46 deletions

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
}