diff --git a/twitch/token.go b/twitch/token.go index 69bc3cc..3c42c04 100644 --- a/twitch/token.go +++ b/twitch/token.go @@ -20,7 +20,7 @@ func Validate(ctx context.Context, client *http.Client, tok *oauth2.Token) (*Val if err != nil { return nil, fmt.Errorf("couldn't make validate request: %w", err) } - req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + tok.SetAuthHeader(req) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("couldn't validate access token: %w", err) diff --git a/twitch/twitch.go b/twitch/twitch.go new file mode 100644 index 0000000..01e012f --- /dev/null +++ b/twitch/twitch.go @@ -0,0 +1,60 @@ +package twitch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "golang.org/x/oauth2" +) + +// Client holds the context for requests to the Twitch API. +type Client struct { + HTTP *http.Client + Token *oauth2.Token +} + +// reqjson performs an HTTP request and decodes the response as JSON. +// The response body is truncated to 2 MB. +func reqjson[Resp any](ctx context.Context, client Client, method, url string, body io.Reader, u *Resp) error { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return fmt.Errorf("couldn't make request: %w", err) + } + client.Token.SetAuthHeader(req) + resp, err := client.HTTP.Do(req) + if err != nil { + return fmt.Errorf("couldn't %s: %w", method, err) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return fmt.Errorf("couldn't read response: %w", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed: %s (%s)", b, resp.Status) + } + r := struct { + Data *Resp `json:"data"` + }{u} + if err := json.Unmarshal(b, &r); err != nil { + return fmt.Errorf("couldn't decode JSON response: %w", err) + } + return nil +} + +// apiurl creates an api.twitch.tv URL for the given endpoint and with the +// given URL parameters. +func apiurl(ep string, values url.Values) string { + u, err := url.JoinPath("https://api.twitch.tv/", ep) + if err != nil { + panic("twitch: bad url join with " + ep) + } + if len(values) == 0 { + return u + } + return u + "?" + values.Encode() +} diff --git a/twitch/twitch_test.go b/twitch/twitch_test.go new file mode 100644 index 0000000..ae9f076 --- /dev/null +++ b/twitch/twitch_test.go @@ -0,0 +1,56 @@ +package twitch + +import ( + "context" + _ "embed" + "errors" + "io" + "net/http" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +type reqspy struct { + // got is the first request the round tripper received. + got *http.Request + // respond is the response the round tripper returns. + respond *http.Response +} + +func (r *reqspy) RoundTrip(req *http.Request) (*http.Response, error) { + if r.got != nil { + return nil, errors.New("already have a request") + } + r.got = req + return r.respond, nil +} + +func TestReqJSON(t *testing.T) { + spy := &reqspy{ + respond: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"data":1}`)), + }, + } + cl := Client{ + HTTP: &http.Client{ + Transport: spy, + }, + Token: &oauth2.Token{ + AccessToken: "bocchi", + }, + } + var u int + err := reqjson(context.Background(), cl, "GET", "https://bocchi.rocks/bocchi", nil, &u) + if err != nil { + t.Errorf("failed to request: %v", err) + } + if u != 1 { + t.Errorf("didn't get the result: want 1, got %d", u) + } + if spy.got.URL.String() != "https://bocchi.rocks/bocchi" { + t.Errorf("request went to the wrong place: want https://bocchi.rocks/bocchi, got %v", spy.got.URL) + } +} diff --git a/twitch/user.go b/twitch/user.go new file mode 100644 index 0000000..08667b9 --- /dev/null +++ b/twitch/user.go @@ -0,0 +1,44 @@ +package twitch + +import ( + "context" + "fmt" + "net/url" +) + +// User is the response type from https://dev.twitch.tv/docs/api/reference/#get-users. +type User struct { + ID string `json:"id"` + Login string `json:"login"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + BroadcasterType string `json:"broadcaster_type"` + Description string `json:"description"` + ProfileImageURL string `json:"profile_image_url"` + OfflineImageURL string `json:"offline_image_url"` + ViewCount string `json:"view_count"` + Email string `json:"email"` + CreatedAt string `json:"created_at"` +} + +func UsersByLogin(ctx context.Context, client Client, names ...string) ([]User, error) { + v := url.Values{"login": names} + u := make([]User, 0, 100) + url := apiurl("/helix/users", v) + err := reqjson(ctx, client, "GET", url, nil, &u) + if err != nil { + return nil, fmt.Errorf("couldn't get users info: %w", err) + } + return u, nil +} + +func UsersByID(ctx context.Context, client Client, ids ...string) ([]User, error) { + v := url.Values{"id": ids} + u := make([]User, 0, 100) + url := apiurl("/helix/users", v) + err := reqjson(ctx, client, "GET", url, nil, &u) + if err != nil { + return nil, fmt.Errorf("couldn't get users info: %w", err) + } + return u, nil +}