From 2bca3c882f6e6b31c50b3b7b500232e6276e1ce4 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sun, 22 May 2022 22:11:33 +0200 Subject: [PATCH] twitter: add GetTweets method --- config/config.go | 3 +- generated/mocks/Getter.go | 48 +++++++++++++++ twitter/api.go | 100 +++++++++++++++++++++++++++----- twitter/api_test.go | 119 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 generated/mocks/Getter.go create mode 100644 twitter/api_test.go diff --git a/config/config.go b/config/config.go index 0f8ef02..f709eb9 100644 --- a/config/config.go +++ b/config/config.go @@ -6,7 +6,7 @@ import ( ) type TwitterConfig struct { - ClientID, ClientSecret, CallbackURL, AuthorizeURL, TokenURL string + BearerToken, ClientID, ClientSecret, CallbackURL, AuthorizeURL, TokenURL string } type Config struct { DatabaseURL string @@ -29,6 +29,7 @@ func NewFromEnv() (Config, error) { SessionKey: sessionKey, ListenAddr: listenAddr, Twitter: TwitterConfig{ + BearerToken: os.Getenv("ELON_TWITTER_BEARER_TOKEN"), ClientID: os.Getenv("ELON_TWITTER_CLIENT_ID"), ClientSecret: os.Getenv("ELON_TWITTER_CLIENT_SECRET"), CallbackURL: os.Getenv("ELON_TWITTER_CALLBACK_URL"), diff --git a/generated/mocks/Getter.go b/generated/mocks/Getter.go new file mode 100644 index 0000000..e0c5e24 --- /dev/null +++ b/generated/mocks/Getter.go @@ -0,0 +1,48 @@ +// Code generated by mockery v2.12.2. DO NOT EDIT. + +package mocks + +import ( + http "net/http" + testing "testing" + + mock "github.com/stretchr/testify/mock" +) + +// Getter is an autogenerated mock type for the Getter type +type Getter struct { + mock.Mock +} + +// Get provides a mock function with given fields: _a0 +func (_m *Getter) Get(_a0 string) (*http.Response, error) { + ret := _m.Called(_a0) + + var r0 *http.Response + if rf, ok := ret.Get(0).(func(string) *http.Response); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Response) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewGetter creates a new instance of Getter. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewGetter(t testing.TB) *Getter { + mock := &Getter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/twitter/api.go b/twitter/api.go index f167807..8ea8eed 100644 --- a/twitter/api.go +++ b/twitter/api.go @@ -1,32 +1,87 @@ package twitter +//go:generate mockery --recursive --name Getter --output ../generated/mocks + import ( "encoding/json" "fmt" "net/http" + "time" ) +// ElonID is the Twitter ID of @elonmusk. +const ElonID = "44196397" + +// User represents a Twitter user. type User struct { ID string Name string Username string } -func NewAPIClient(httpclient *http.Client) *APIClient { +// Tweet represents a tweet. +type Tweet struct { + ID string + Text string +} + +// bearerTokenTransport implements http.RoundTripper. +type bearerTokenTransport struct { + bearerToken string +} + +// RoundTrip sets the Authorization header with the provided bearerToken, and +// forwards the request. It will panic if the request includes a body (which +// requires special handling). +func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + panic("bearerTokenTransport does not handle requests with non-nil body") + } + req2 := cloneRequest(req) + req2.Header.Set("Authorization", "Bearer "+t.bearerToken) + return http.DefaultTransport.RoundTrip(req2) +} + +// https://go.googlesource.com/oauth2/+/f95fa95eaa936d9d87489b15d1d18b97c1ba9c28/transport.go#96 +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} + +type Getter interface { + Get(string) (*http.Response, error) +} + +// NewAPIClient returns a new APIClient. +func NewAPIClient(httpclient Getter) *APIClient { return &APIClient{httpclient} } -type APIClient struct { - *http.Client +// NewAPIClient returns a new APIClient which will authenticate with the +// provided bearer token. +func NewAPIClientWithBearerToken(bearerToken string) *APIClient { + return &APIClient{&http.Client{ + Timeout: time.Second * 5, + Transport: &bearerTokenTransport{bearerToken: bearerToken}, + }} } +// APIClient interacts with the Twitter API V2. +type APIClient struct { + Getter +} + +// GetMe returns the currently authenticated user. func (c *APIClient) GetMe() (*User, error) { type oauthResponse struct { - Data struct { - ID string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - } `json:"data"` + Data *User `json:"data"` } resp, err := c.Get("https://api.twitter.com/2/users/me") @@ -43,9 +98,28 @@ func (c *APIClient) GetMe() (*User, error) { return nil, fmt.Errorf("error decoding resource: %v", err) } - return &User{ - ID: oauthResp.Data.ID, - Name: oauthResp.Data.Name, - Username: oauthResp.Data.Username, - }, nil + return oauthResp.Data, nil +} + +// GetElonTweets returns the latest tweets for a given user. +func (c *APIClient) GetTweets(userID string, sinceID string) ([]*Tweet, error) { + type oauthResponse struct { + Data []*Tweet `json:"data"` + } + + resp, err := c.Get(fmt.Sprintf("https://api.twitter.com/2/users/%s/tweets?since_id=%s", userID, sinceID)) + if err != nil { + return nil, fmt.Errorf("error fetching resource: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error fetching resource: status code %d", resp.StatusCode) + } + + var oauthResp oauthResponse + if err = json.NewDecoder(resp.Body).Decode(&oauthResp); err != nil { + return nil, fmt.Errorf("error decoding resource: %v", err) + } + + return oauthResp.Data, nil } diff --git a/twitter/api_test.go b/twitter/api_test.go new file mode 100644 index 0000000..bb246d0 --- /dev/null +++ b/twitter/api_test.go @@ -0,0 +1,119 @@ +package twitter_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "git.netflux.io/rob/elon-eats-my-tweets/generated/mocks" + "git.netflux.io/rob/elon-eats-my-tweets/twitter" + "github.com/stretchr/testify/assert" +) + +func TestGetMe(t *testing.T) { + testCases := []struct { + name string + responseStatusCode int + responseBody io.Reader + wantUser *twitter.User + wantErr string + }{ + { + name: "successful request", + responseStatusCode: 200, + responseBody: strings.NewReader(`{"Data": {"id": "1", "name": "foo", "username": "foo bar"}}`), + wantUser: &twitter.User{ID: "1", Name: "foo", Username: "foo bar"}, + }, + { + name: "500 response", + responseStatusCode: 500, + responseBody: strings.NewReader("whale"), + wantUser: nil, + wantErr: "error fetching resource: status code 500", + }, + { + name: "decoder error", + responseStatusCode: 200, + responseBody: strings.NewReader(""), + wantUser: nil, + wantErr: "error decoding resource: invalid character '<' looking for beginning of value", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var getter mocks.Getter + getter. + On("Get", "https://api.twitter.com/2/users/me"). + Return(&http.Response{StatusCode: tc.responseStatusCode, Body: io.NopCloser(tc.responseBody)}, nil) + client := twitter.NewAPIClient(&getter) + + user, err := client.GetMe() + assert.Equal(t, tc.wantUser, user) + + if tc.wantErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.wantErr) + } + }) + } +} + +func TestGetTweets(t *testing.T) { + testCases := []struct { + name string + userID, sinceID string + responseStatusCode int + responseBody io.Reader + wantTweets []*twitter.Tweet + wantErr string + }{ + { + name: "successful request", + userID: "1", + sinceID: "1000", + responseStatusCode: 200, + responseBody: strings.NewReader(`{"Data": [{"id": "101", "text": "foo"}, {"id": "102", "text": "bar"}]}`), + wantTweets: []*twitter.Tweet{{ID: "101", Text: "foo"}, {ID: "102", Text: "bar"}}, + }, + { + name: "500 response", + userID: "1", + sinceID: "1000", + responseStatusCode: 500, + responseBody: strings.NewReader("whale"), + wantTweets: nil, + wantErr: "error fetching resource: status code 500", + }, + { + name: "decoder error", + userID: "1", + sinceID: "1000", + responseStatusCode: 200, + responseBody: strings.NewReader(""), + wantTweets: nil, + wantErr: "error decoding resource: invalid character '<' looking for beginning of value", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var getter mocks.Getter + getter. + On("Get", "https://api.twitter.com/2/users/"+tc.userID+"/tweets?since_id="+tc.sinceID). + Return(&http.Response{StatusCode: tc.responseStatusCode, Body: io.NopCloser(tc.responseBody)}, nil) + client := twitter.NewAPIClient(&getter) + + tweets, err := client.GetTweets(tc.userID, tc.sinceID) + assert.Equal(t, tc.wantTweets, tweets) + + if tc.wantErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.wantErr) + } + }) + } +}