diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b0829..73787be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `WithToken(ctx, token)` context override for per-request authentication, enabling concurrent requests with different user tokens through a single client instance ### Changed diff --git a/docs/advanced.md b/docs/advanced.md index d246fc4..4099b95 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -338,6 +338,54 @@ func CustomHeadersMiddleware(headers map[string]string) Middleware { } ``` +## Per-Request Token Override + +Override the client-level token for individual requests using `WithToken`. This enables concurrent requests with different user tokens through a single client, without creating multiple client instances. + +```go +// Override token for a single request +ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "user-token"}) +followers, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{ + BroadcasterID: "12345", +}) +``` + +### Token Resolution Order + +1. Per-request token from `WithToken(ctx, token)` (highest priority) +2. Client-level `AuthClient` token +3. Client-level `TokenProvider` (e.g., Extension JWT) + +### Concurrent Multi-Token Requests + +Fetch data for multiple channels where each requires its own user token: + +```go +type ChannelToken struct { + BroadcasterID string + Token *helix.Token +} + +channels := []ChannelToken{ + {BroadcasterID: "111", Token: &helix.Token{AccessToken: "token-a"}}, + {BroadcasterID: "222", Token: &helix.Token{AccessToken: "token-b"}}, + {BroadcasterID: "333", Token: &helix.Token{AccessToken: "token-c"}}, +} + +results := make(chan error, len(channels)) +for _, ch := range channels { + go func(ch ChannelToken) { + ctx := helix.WithToken(ctx, ch.Token) + _, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{ + BroadcasterID: ch.BroadcasterID, + }) + results <- err + }(ch) +} +``` + +This works with all context-aware features including caching (`NoCacheContext`), middleware, and batch operations. + ## Low-Level Request Execution For advanced use cases, you can execute raw requests directly. diff --git a/docs/auth.md b/docs/auth.md index 3b98659..329ddaa 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -312,6 +312,29 @@ auth.SetToken(&helix.Token{ token := auth.GetToken() ``` +### WithToken (Per-Request Override) + +Override the client-level token for a single request. This is useful when making +concurrent requests that each require a different user token (e.g., fetching +followers for multiple channels where each requires `moderator:read:followers`). + +```go +// Each request uses a different user's token +ctx := helix.WithToken(context.Background(), &helix.Token{ + AccessToken: "user-specific-token", +}) +followers, err := client.GetChannelFollowers(ctx, &helix.GetChannelFollowersParams{ + BroadcasterID: "12345", +}) +``` + +The token resolution order is: +1. Per-request token from `WithToken` context (if set) +2. Client-level `AuthClient` token +3. Client-level `TokenProvider` (e.g., Extension JWT) + +See [Batch & Caching Examples](examples/batch-caching.md) for a complete concurrent multi-token example. + ## OIDC (OpenID Connect) Support for Twitch's OIDC implementation for identity verification. diff --git a/docs/cookbook.md b/docs/cookbook.md index 9b039e8..8574f6b 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -45,6 +45,7 @@ description: Comprehensive code examples covering all kappopher features. | Feature | Example | |---------|---------| | OAuth Authentication | [Authentication](examples/authentication.md) | +| Per-Request Token Override | [Authentication](examples/authentication.md), [Batch & Caching](examples/batch-caching.md) | | Get Users/Channels | [Basic](examples/basic.md), [API Usage](examples/api-usage.md) | | Send Chat Messages | [Chat Bot](examples/chatbot.md), [IRC Client](examples/irc-client.md) | | Handle Chat Events | [IRC Client](examples/irc-client.md), [EventSub WebSocket](examples/eventsub-websocket.md) | @@ -72,6 +73,7 @@ description: Comprehensive code examples covering all kappopher features. | Use Case | Start Here | |----------|-----------| +| Concurrent multi-user requests | [Authentication](examples/authentication.md), [Batch & Caching](examples/batch-caching.md) | | Build a chat bot | [Chat Bot](examples/chatbot.md) or [IRC Client](examples/irc-client.md) | | Monitor stream events | [EventSub WebSocket](examples/eventsub-websocket.md) | | Create a dashboard | [Batch & Caching](examples/batch-caching.md) | diff --git a/docs/examples/authentication.md b/docs/examples/authentication.md index f24d1bd..c6e12ca 100644 --- a/docs/examples/authentication.md +++ b/docs/examples/authentication.md @@ -453,6 +453,71 @@ func main() { } ``` +## Per-Request Token Override + +When making concurrent requests that each require a different user token, use `WithToken` to override the client-level token on a per-request basis. This avoids creating multiple client instances. + +**When to use**: Fetching scoped data for multiple channels concurrently (e.g., followers, subscriptions) where each channel requires its own user token. + +```go +package main + +import ( + "context" + "fmt" + "log" + "sync" + + "github.com/Its-donkey/kappopher/helix" +) + +func main() { + ctx := context.Background() + + // One client shared across all requests + authClient := helix.NewAuthClient(helix.AuthConfig{ + ClientID: "your-client-id", + ClientSecret: "your-client-secret", + }) + client := helix.NewClient("your-client-id", authClient) + + // Each channel has its own user token (obtained via Authorization Code flow) + type Channel struct { + BroadcasterID string + Token *helix.Token + } + + channels := []Channel{ + {BroadcasterID: "111", Token: &helix.Token{AccessToken: "user-token-a"}}, + {BroadcasterID: "222", Token: &helix.Token{AccessToken: "user-token-b"}}, + {BroadcasterID: "333", Token: &helix.Token{AccessToken: "user-token-c"}}, + } + + // Fetch followers for all channels concurrently + var wg sync.WaitGroup + for _, ch := range channels { + wg.Add(1) + go func(ch Channel) { + defer wg.Done() + + // Override the client token for this request + reqCtx := helix.WithToken(ctx, ch.Token) + + followers, err := client.GetChannelFollowers(reqCtx, &helix.GetChannelFollowersParams{ + BroadcasterID: ch.BroadcasterID, + }) + if err != nil { + log.Printf("Error fetching followers for %s: %v", ch.BroadcasterID, err) + return + } + + fmt.Printf("Channel %s has %d followers\n", ch.BroadcasterID, *followers.Total) + }(ch) + } + wg.Wait() +} +``` + ## Common Scope Combinations The library provides pre-defined scope combinations for common use cases. These help you request the right permissions without having to look up individual scope names. diff --git a/docs/examples/batch-caching.md b/docs/examples/batch-caching.md index 17fd251..8081c6b 100644 --- a/docs/examples/batch-caching.md +++ b/docs/examples/batch-caching.md @@ -546,6 +546,67 @@ func main() { } ``` +## Concurrent Requests with Different Tokens + +When endpoints require user-specific tokens (e.g., `Get Channel Followers` requires `moderator:read:followers`), use `WithToken` to override the client token per-request: + +```go +package main + +import ( + "context" + "fmt" + "log" + "sync" + + "github.com/Its-donkey/kappopher/helix" +) + +func main() { + ctx := context.Background() + + authClient := helix.NewAuthClient(helix.AuthConfig{ + ClientID: "your-client-id", + ClientSecret: "your-client-secret", + }) + client := helix.NewClient("your-client-id", authClient) + + // Each channel's user token (obtained via Authorization Code flow) + type ChannelToken struct { + BroadcasterID string + Token *helix.Token + } + + channels := []ChannelToken{ + {BroadcasterID: "111", Token: &helix.Token{AccessToken: "token-a"}}, + {BroadcasterID: "222", Token: &helix.Token{AccessToken: "token-b"}}, + {BroadcasterID: "333", Token: &helix.Token{AccessToken: "token-c"}}, + } + + var wg sync.WaitGroup + for _, ch := range channels { + wg.Add(1) + go func(ch ChannelToken) { + defer wg.Done() + + // Override token for this request only + reqCtx := helix.WithToken(ctx, ch.Token) + followers, err := client.GetChannelFollowers(reqCtx, &helix.GetChannelFollowersParams{ + BroadcasterID: ch.BroadcasterID, + }) + if err != nil { + log.Printf("Error: %v", err) + return + } + fmt.Printf("Channel %s: %d followers\n", ch.BroadcasterID, *followers.Total) + }(ch) + } + wg.Wait() +} +``` + +The client's rate limiter, cache, and middleware are shared across all requests regardless of which token is used. When caching is enabled, requests with different tokens produce different cache keys automatically. + ## Complete Example: Efficient Multi-Channel Dashboard ```go diff --git a/docs/faq.md b/docs/faq.md index 150258e..d04d065 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -165,6 +165,17 @@ batcher := helix.NewBatcher(client, helix.BatchConfig{ results := batcher.GetUsers(ctx, userIDs) ``` +### Can I use different tokens for different requests? + +Yes. Use `WithToken` to override the client-level token on a per-request basis: + +```go +ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "other-user-token"}) +followers, err := client.GetChannelFollowers(ctx, params) +``` + +This is useful when making concurrent requests that each require a different user token (e.g., fetching followers for multiple channels). See the [Authentication Examples](examples/authentication.md) for a complete example. + --- ## Troubleshooting diff --git a/docs/quickstart.md b/docs/quickstart.md index 3f98cab..8806da9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -143,6 +143,10 @@ err := authClient.RevokeToken(ctx, token.AccessToken) // Auto-refresh (starts background goroutine) cancel := authClient.AutoRefresh(ctx) defer cancel() + +// Per-request token override (for concurrent multi-user requests) +ctx := helix.WithToken(ctx, &helix.Token{AccessToken: "other-user-token"}) +followers, err := client.GetChannelFollowers(ctx, params) ``` ## Creating the Helix Client diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 704ff36..b5cb5db 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -64,6 +64,8 @@ description: Solutions to common issues when using Kappopher. - Use the correct Client ID that matches the token - Generate a new token with your Client ID +**Note:** When using `WithToken` for per-request token overrides, all tokens must belong to the same Client ID as the client. Twitch requires the `Client-Id` header and `Authorization` token to match. + --- ## EventSub Issues diff --git a/helix/client.go b/helix/client.go index b6b9be2..e9b64a3 100644 --- a/helix/client.go +++ b/helix/client.go @@ -26,6 +26,24 @@ type TokenProvider interface { GetToken() *Token } +// tokenContextKey is the context key for per-request token overrides. +type tokenContextKey struct{} + +// WithToken returns a context that overrides the client-level token for a +// single request. This is useful when making concurrent requests that each +// require a different user token. +func WithToken(ctx context.Context, token *Token) context.Context { + return context.WithValue(ctx, tokenContextKey{}, token) +} + +// tokenFromContext retrieves a per-request token override from context. +func tokenFromContext(ctx context.Context) *Token { + if token, ok := ctx.Value(tokenContextKey{}).(*Token); ok { + return token + } + return nil +} + // Client is a Twitch Helix API client. type Client struct { clientID string @@ -382,9 +400,11 @@ func (c *Client) doOnceWithResponse(ctx context.Context, req *Request, result in } } - // Set authorization + // Set authorization (per-request context override takes precedence) var token *Token - if c.authClient != nil { + if ctxToken := tokenFromContext(ctx); ctxToken != nil { + token = ctxToken + } else if c.authClient != nil { token = c.authClient.GetToken() } else if c.tokenProvider != nil { token = c.tokenProvider.GetToken() diff --git a/helix/client_test.go b/helix/client_test.go index 97fd2bc..7c62928 100644 --- a/helix/client_test.go +++ b/helix/client_test.go @@ -1223,3 +1223,121 @@ func TestClient_ResponseBodyReadError(t *testing.T) { } } } + +func TestWithToken_OverridesClientToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer per-request-token" { + t.Errorf("expected per-request token, got %s", got) + } + _ = json.NewEncoder(w).Encode(Response[User]{Data: []User{}}) + })) + defer server.Close() + + authClient := NewAuthClient(AuthConfig{ClientID: "test"}) + authClient.SetToken(&Token{AccessToken: "client-level-token"}) + client := NewClient("test-client-id", authClient, WithBaseURL(server.URL)) + + ctx := WithToken(context.Background(), &Token{AccessToken: "per-request-token"}) + + req := &Request{ + Method: "GET", + Endpoint: "/users", + } + + var result Response[User] + if err := client.Do(ctx, req, &result); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWithToken_FallsBackToClientToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer client-level-token" { + t.Errorf("expected client-level token, got %s", got) + } + _ = json.NewEncoder(w).Encode(Response[User]{Data: []User{}}) + })) + defer server.Close() + + authClient := NewAuthClient(AuthConfig{ClientID: "test"}) + authClient.SetToken(&Token{AccessToken: "client-level-token"}) + client := NewClient("test-client-id", authClient, WithBaseURL(server.URL)) + + req := &Request{ + Method: "GET", + Endpoint: "/users", + } + + var result Response[User] + if err := client.Do(context.Background(), req, &result); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWithToken_NilTokenFallsBack(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer client-level-token" { + t.Errorf("expected client-level token, got %s", got) + } + _ = json.NewEncoder(w).Encode(Response[User]{Data: []User{}}) + })) + defer server.Close() + + authClient := NewAuthClient(AuthConfig{ClientID: "test"}) + authClient.SetToken(&Token{AccessToken: "client-level-token"}) + client := NewClient("test-client-id", authClient, WithBaseURL(server.URL)) + + // WithToken with nil should fall back to client token + ctx := WithToken(context.Background(), nil) + + req := &Request{ + Method: "GET", + Endpoint: "/users", + } + + var result Response[User] + if err := client.Do(ctx, req, &result); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWithToken_ConcurrentDifferentTokens(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Echo the token back in a custom header so we can verify + w.Header().Set("X-Received-Token", r.Header.Get("Authorization")) + _ = json.NewEncoder(w).Encode(Response[User]{Data: []User{}}) + })) + defer server.Close() + + authClient := NewAuthClient(AuthConfig{ClientID: "test"}) + authClient.SetToken(&Token{AccessToken: "client-level-token"}) + client := NewClient("test-client-id", authClient, WithBaseURL(server.URL)) + + tokens := []string{"token-a", "token-b", "token-c"} + errs := make(chan error, len(tokens)) + + for _, tok := range tokens { + go func(token string) { + ctx := WithToken(context.Background(), &Token{AccessToken: token}) + req := &Request{ + Method: "GET", + Endpoint: "/users", + } + var result Response[User] + errs <- client.Do(ctx, req, &result) + }(tok) + } + + for range tokens { + if err := <-errs; err != nil { + t.Errorf("unexpected error: %v", err) + } + } +} + +func TestTokenFromContext_NoToken(t *testing.T) { + token := tokenFromContext(context.Background()) + if token != nil { + t.Error("expected nil token from empty context") + } +}