feat: add twitch graphql streamer resolution
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user