feat: add twitch graphql streamer resolution

This commit is contained in:
2026-04-28 23:01:26 +02:00
parent 37ece958f3
commit 2f81173f1e
11 changed files with 928 additions and 31 deletions
+156
View File
@@ -0,0 +1,156 @@
// client.go defines the authenticated Twitch GraphQL transport layer.
// It owns request encoding, session header wiring, and response decoding
// so higher-level Twitch services can issue typed operations without repeating HTTP logic.
package gql
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
const DefaultEndpoint = "https://gql.twitch.tv/gql"
// Session carries the authenticated Twitch headers needed for GraphQL requests.
type Session struct {
AccessToken string
ClientID string
DeviceID string
UserAgent string
}
// Client posts authenticated GraphQL requests to Twitch.
type Client struct {
HTTPClient *http.Client
Endpoint string
Session Session
}
// Error is one entry in a GraphQL errors list.
type Error struct {
Message string `json:"message"`
}
// StatusError reports a non-200 HTTP response together with its response body.
type StatusError struct {
StatusCode int
Body string
}
// Error formats the HTTP status failure in a way that preserves the response body.
func (e *StatusError) Error() string {
body := strings.TrimSpace(e.Body)
if body == "" {
return fmt.Sprintf("graphql status %d", e.StatusCode)
}
return fmt.Sprintf("graphql status %d: %s", e.StatusCode, body)
}
// Do executes one Twitch GraphQL request and decodes its data payload into out.
func (c *Client) Do(ctx context.Context, request Request, out any) error {
if err := c.Validate(); err != nil {
return err
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(request); err != nil {
return fmt.Errorf("encode graphql request %s: %w", request.operationLabel(), err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), &body)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "OAuth "+c.Session.AccessToken)
req.Header.Set("Client-Id", c.Session.ClientID)
req.Header.Set("User-Agent", c.Session.UserAgent)
req.Header.Set("X-Device-Id", c.Session.DeviceID)
resp, err := c.httpClient().Do(req)
if err != nil {
return fmt.Errorf("post graphql %s: %w", request.operationLabel(), err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read graphql %s response: %w", request.operationLabel(), err)
}
if resp.StatusCode != http.StatusOK {
return &StatusError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
var envelope struct {
Data json.RawMessage `json:"data"`
Errors []Error `json:"errors,omitempty"`
}
if err := json.Unmarshal(respBody, &envelope); err != nil {
return fmt.Errorf("parse graphql %s response: %w", request.operationLabel(), err)
}
if len(envelope.Errors) > 0 {
return fmt.Errorf("graphql %s returned errors: %s", request.operationLabel(), formatErrors(envelope.Errors))
}
if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
return fmt.Errorf("graphql %s response missing data", request.operationLabel())
}
if out == nil {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode graphql %s data: %w", request.operationLabel(), err)
}
return nil
}
// formatErrors joins GraphQL error messages into one readable string.
func formatErrors(errs []Error) string {
parts := make([]string, 0, len(errs))
for _, err := range errs {
if err.Message != "" {
parts = append(parts, err.Message)
}
}
if len(parts) == 0 {
return "unknown error"
}
return strings.Join(parts, "; ")
}
// Validate rejects incomplete session configuration before any request is sent.
func (c *Client) Validate() error {
switch {
case c.Session.AccessToken == "":
return errors.New("missing access token")
case c.Session.ClientID == "":
return errors.New("missing client id")
case c.Session.DeviceID == "":
return errors.New("missing device id")
case c.Session.UserAgent == "":
return errors.New("missing user agent")
default:
return nil
}
}
// httpClient returns the configured HTTP client or the default client.
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
// endpoint returns the configured GraphQL endpoint or the default Twitch endpoint.
func (c *Client) endpoint() string {
if c.Endpoint != "" {
return c.Endpoint
}
return DefaultEndpoint
}
+144
View File
@@ -0,0 +1,144 @@
package gql
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDoSendsHeadersAndDecodesData(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "OAuth token" {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
if r.Header.Get("Client-Id") != "client" {
t.Fatalf("Client-Id = %q", r.Header.Get("Client-Id"))
}
if r.Header.Get("X-Device-Id") != "device" {
t.Fatalf("X-Device-Id = %q", r.Header.Get("X-Device-Id"))
}
if r.Header.Get("User-Agent") != "agent" {
t.Fatalf("User-Agent = %q", r.Header.Get("User-Agent"))
}
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
if req.OperationName != "GetIDFromLogin" {
t.Fatalf("operation = %q", req.OperationName)
}
if req.Variables["login"] != "streamer" {
t.Fatalf("login variable = %#v", req.Variables["login"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"ok":true}}`))
}))
defer server.Close()
client := &Client{
HTTPClient: server.Client(),
Endpoint: server.URL,
Session: Session{
AccessToken: "token",
ClientID: "client",
DeviceID: "device",
UserAgent: "agent",
},
}
var out struct {
OK bool `json:"ok"`
}
if err := client.Do(context.Background(), GetIDFromLogin("streamer"), &out); err != nil {
t.Fatal(err)
}
if !out.OK {
t.Fatal("expected decoded data")
}
}
func TestDoSupportsInlineQueries(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
if req.OperationName != "CurrentUser" {
t.Fatalf("operation = %q", req.OperationName)
}
if !strings.Contains(req.Query, "currentUser") {
t.Fatalf("query = %q", req.Query)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"currentUser":{"id":"1","login":"viewer"}}}`))
}))
defer server.Close()
client := &Client{
HTTPClient: server.Client(),
Endpoint: server.URL,
Session: Session{
AccessToken: "token",
ClientID: "client",
DeviceID: "device",
UserAgent: "agent",
},
}
var out struct {
CurrentUser struct {
ID string `json:"id"`
Login string `json:"login"`
} `json:"currentUser"`
}
if err := client.Do(context.Background(), CurrentUser(), &out); err != nil {
t.Fatal(err)
}
if out.CurrentUser.Login != "viewer" {
t.Fatalf("login = %q", out.CurrentUser.Login)
}
}
func TestDoSurfacesGraphQLErrors(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errors":[{"message":"bad auth"}]}`))
}))
defer server.Close()
client := &Client{
HTTPClient: server.Client(),
Endpoint: server.URL,
Session: Session{
AccessToken: "token",
ClientID: "client",
DeviceID: "device",
UserAgent: "agent",
},
}
if err := client.Do(context.Background(), CurrentUser(), &struct{}{}); err == nil {
t.Fatal("expected error")
}
}
func TestPersistedOperationHashes(t *testing.T) {
t.Parallel()
req := GetIDFromLogin("streamer")
if req.OperationName != "GetIDFromLogin" {
t.Fatalf("operation = %q", req.OperationName)
}
if req.Extensions.PersistedQuery == nil {
t.Fatal("expected persisted query metadata")
}
if got := req.Extensions.PersistedQuery.SHA256Hash; got != "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca" {
t.Fatalf("hash = %q", got)
}
}
+69
View File
@@ -0,0 +1,69 @@
// operations.go defines the minimal Twitch GraphQL operations used by the app.
// It builds either persisted-query or inline query requests for viewer identity
// and streamer login resolution without exposing raw payload assembly to callers.
package gql
// persistedQuery stores the persisted-query metadata Twitch expects.
type persistedQuery struct {
Version int `json:"version"`
SHA256Hash string `json:"sha256Hash"`
}
// extensions holds the GraphQL extensions block for one request.
type extensions struct {
PersistedQuery *persistedQuery `json:"persistedQuery,omitempty"`
}
// Request is one Twitch GraphQL operation.
type Request struct {
OperationName string `json:"operationName,omitempty"`
Query string `json:"query,omitempty"`
Variables map[string]any `json:"variables,omitempty"`
Extensions extensions `json:"extensions,omitempty"`
}
// operation builds a persisted-query request with the supplied variables.
func operation(name, hash string, variables map[string]any) Request {
return Request{
OperationName: name,
Variables: variables,
Extensions: extensions{
PersistedQuery: &persistedQuery{
Version: 1,
SHA256Hash: hash,
},
},
}
}
// queryOperation builds an inline-query request with the supplied variables.
func queryOperation(name, query string, variables map[string]any) Request {
return Request{
OperationName: name,
Query: query,
Variables: variables,
}
}
// operationLabel returns a readable operation name for logs and errors.
func (r Request) operationLabel() string {
if r.OperationName != "" {
return r.OperationName
}
if r.Query != "" {
return "anonymous"
}
return "unknown"
}
// CurrentUser fetches the canonical identity for the authenticated viewer.
func CurrentUser() Request {
return queryOperation("CurrentUser", "query CurrentUser { currentUser { id login } }", nil)
}
// GetIDFromLogin resolves one login into a Twitch user record using Twitch's persisted-query hash.
func GetIDFromLogin(login string) Request {
return operation("GetIDFromLogin", "94e82a7b1e3c21e186daa73ee2afc4b8f23bade1fbbff6fe8ac133f50a2f58ca", map[string]any{
"login": login,
})
}