From 0dcb24d678066e8c39f6403c41251cb4838b75f1 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 25 Mar 2022 21:00:02 -0700 Subject: [PATCH] added the basic client (#132) * added the basic client * added basic unit test * added example * added to the readme * added the comments --- v2/README.md | 1 + v2/_examples/tweets/README.md | 4 + .../tweets/quote/quote-tweets/main.go | 63 ++++++++++ v2/client.go | 70 ++++++++++++ v2/client_tweet_quote_test.go | 108 ++++++++++++++++++ v2/endpoints.go | 1 + v2/tweet_quote.go | 63 ++++++++++ 7 files changed, 310 insertions(+) create mode 100644 v2/_examples/tweets/quote/quote-tweets/main.go create mode 100644 v2/client_tweet_quote_test.go create mode 100644 v2/tweet_quote.go diff --git a/v2/README.md b/v2/README.md index 116ecc5..50d40c1 100644 --- a/v2/README.md +++ b/v2/README.md @@ -83,6 +83,7 @@ The following APIs are supported, with the examples [here](./_examples/tweets) * [Timelines](https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/introduction) * [Hide Replies](https://developer.twitter.com/en/docs/twitter-api/tweets/hide-replies/introduction) * [Search](https://developer.twitter.com/en/docs/twitter-api/tweets/search/introduction) +* [Quote Tweets](https://developer.twitter.com/en/docs/twitter-api/tweets/quote-tweets/introduction) ### Users The following APIs are supported, with the examples [here](./_examples/users) diff --git a/v2/_examples/tweets/README.md b/v2/_examples/tweets/README.md index 17ad458..0bbff15 100644 --- a/v2/_examples/tweets/README.md +++ b/v2/_examples/tweets/README.md @@ -57,3 +57,7 @@ The examples can be run my providing some options, including the authorization t ### [Hide Replies](https://developer.twitter.com/en/docs/twitter-api/tweets/hide-replies/introduction) * [Hides or unhides a reply to a Tweet](./hide-replies/tweet-hide-replies/main.go) + +### [Quote Tweets](https://developer.twitter.com/en/docs/twitter-api/tweets/quote-tweets/introduction) + +* [Returns Quote Tweets for a Tweet specified by the requested Tweet ID](./quote/quote-tweets/main.go) diff --git a/v2/_examples/tweets/quote/quote-tweets/main.go b/v2/_examples/tweets/quote/quote-tweets/main.go new file mode 100644 index 0000000..f3061c0 --- /dev/null +++ b/v2/_examples/tweets/quote/quote-tweets/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + + twitter "github.com/g8rswimmer/go-twitter/v2" +) + +type authorize struct { + Token string +} + +func (a authorize) Add(req *http.Request) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token)) +} + +/** + In order to run, the user will need to provide the bearer token and the list of tweet ids. +**/ +func main() { + token := flag.String("token", "", "twitter API token") + id := flag.String("id", "", "twitter id") + flag.Parse() + + client := &twitter.Client{ + Authorizer: authorize{ + Token: *token, + }, + Client: http.DefaultClient, + Host: "https://api.twitter.com", + } + opts := twitter.QuoteTweetsLookupOpts{ + Expansions: []twitter.Expansion{twitter.ExpansionAuthorID}, + TweetFields: []twitter.TweetField{twitter.TweetFieldCreatedAt, twitter.TweetFieldConversationID, twitter.TweetFieldAttachments, twitter.TweetFieldAuthorID, twitter.TweetFieldPublicMetrics}, + UserFields: []twitter.UserField{twitter.UserFieldUserName}, + } + + fmt.Println("Callout to quote tweet lookup callout") + + tweetResponse, err := client.QuoteTweetsLookup(context.Background(), *id, opts) + if err != nil { + log.Panicf("tweet quote lookup error: %v", err) + } + + dictionaries := tweetResponse.Raw.TweetDictionaries() + + enc, err := json.MarshalIndent(dictionaries, "", " ") + if err != nil { + log.Panic(err) + } + fmt.Println(string(enc)) + + enc, err = json.MarshalIndent(tweetResponse.Meta, "", " ") + if err != nil { + log.Panic(err) + } + fmt.Println(string(enc)) +} diff --git a/v2/client.go b/v2/client.go index 4f1a916..3ed5ad4 100644 --- a/v2/client.go +++ b/v2/client.go @@ -31,6 +31,8 @@ const ( listUserMemberMaxResults = 100 userListFollowedMaxResults = 100 listuserFollowersMaxResults = 100 + quoteTweetMaxResults = 100 + quoteTweetMinResults = 10 ) // Client is used to make twitter v2 API callouts. @@ -3918,3 +3920,71 @@ func (c *Client) ComplianceBatchJobLookup(ctx context.Context, jobType Complianc RateLimit: rl, }, nil } + +// QuoteTweetsLookup returns quote tweets for a tweet specificed by the requested tweet id +func (c *Client) QuoteTweetsLookup(ctx context.Context, tweetID string, opts QuoteTweetsLookupOpts) (*QuoteTweetsLookupResponse, error) { + switch { + case len(tweetID) == 0: + return nil, fmt.Errorf("quote tweets lookup: an id is required: %w", ErrParameter) + case opts.MaxResults == 0: + case opts.MaxResults < quoteTweetMinResults: + return nil, fmt.Errorf("quote tweets lookup: a min results [%d] is required [current: %d]: %w", quoteTweetMinResults, opts.MaxResults, ErrParameter) + case opts.MaxResults > quoteTweetMaxResults: + return nil, fmt.Errorf("quote tweets lookup: a max results [%d] is required [current: %d]: %w", quoteTweetMaxResults, opts.MaxResults, ErrParameter) + default: + } + + ep := quoteTweetLookupEndpoint.urlID(c.Host, tweetID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil) + if err != nil { + return nil, fmt.Errorf("quote tweets lookup request: %w", err) + } + req.Header.Add("Accept", "application/json") + c.Authorizer.Add(req) + opts.addQuery(req) + + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("quote tweets lookup response: %w", err) + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + + rl := rateFromHeader(resp.Header) + + if resp.StatusCode != http.StatusOK { + e := &ErrorResponse{} + if err := decoder.Decode(e); err != nil { + return nil, &HTTPError{ + Status: resp.Status, + StatusCode: resp.StatusCode, + URL: resp.Request.URL.String(), + RateLimit: rl, + } + } + e.StatusCode = resp.StatusCode + e.RateLimit = rl + return nil, e + } + + respBody := struct { + *TweetRaw + Meta *QuoteTweetsLookupMeta `json:"meta"` + }{} + + if err := decoder.Decode(&respBody); err != nil { + return nil, &ResponseDecodeError{ + Name: "quote tweets lookup", + Err: err, + RateLimit: rl, + } + } + + return &QuoteTweetsLookupResponse{ + Raw: respBody.TweetRaw, + Meta: respBody.Meta, + RateLimit: rl, + }, nil +} diff --git a/v2/client_tweet_quote_test.go b/v2/client_tweet_quote_test.go new file mode 100644 index 0000000..11b84ff --- /dev/null +++ b/v2/client_tweet_quote_test.go @@ -0,0 +1,108 @@ +package twitter + +import ( + "context" + "io" + "log" + "net/http" + "reflect" + "strings" + "testing" +) + +func TestClient_QuoteTweetsLookup(t *testing.T) { + type fields struct { + Authorizer Authorizer + Client *http.Client + Host string + } + type args struct { + tweetID string + opts QuoteTweetsLookupOpts + } + tests := []struct { + name string + fields fields + args args + want *QuoteTweetsLookupResponse + wantErr bool + }{ + { + name: "success", + fields: fields{ + Authorizer: &mockAuth{}, + Host: "https://www.test.com", + Client: mockHTTPClient(func(req *http.Request) *http.Response { + if req.Method != http.MethodGet { + log.Panicf("the method is not correct %s %s", req.Method, http.MethodGet) + } + if strings.Contains(req.URL.String(), quoteTweetLookupEndpoint.urlID("", "tweet-1234")) == false { + log.Panicf("the url is not correct %s %s", req.URL.String(), listLookupEndpoint) + } + body := `{ + "data": [ + { + "id": "1503982413004914689", + "text": "RT @suhemparack: Super excited to share our course on Getting started with the #TwitterAPI v2 for academic research\n\nIf you know students w…" + } + ], + "meta": { + "result_count": 1, + "next_token": "axdnchiqasch" + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: func() http.Header { + h := http.Header{} + h.Add(rateLimit, "15") + h.Add(rateRemaining, "12") + h.Add(rateReset, "1644461060") + return h + }(), + } + }), + }, + args: args{ + tweetID: "tweet-1234", + }, + want: &QuoteTweetsLookupResponse{ + Raw: &TweetRaw{ + Tweets: []*TweetObj{ + { + ID: "1503982413004914689", + Text: "RT @suhemparack: Super excited to share our course on Getting started with the #TwitterAPI v2 for academic research\n\nIf you know students w…", + }, + }, + }, + Meta: &QuoteTweetsLookupMeta{ + ResultCount: 1, + NextToken: "axdnchiqasch", + }, + RateLimit: &RateLimit{ + Limit: 15, + Remaining: 12, + Reset: Epoch(1644461060), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + Authorizer: tt.fields.Authorizer, + Client: tt.fields.Client, + Host: tt.fields.Host, + } + got, err := c.QuoteTweetsLookup(context.Background(), tt.args.tweetID, tt.args.opts) + if (err != nil) != tt.wantErr { + t.Errorf("Client.QuoteTweetsLookup() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Client.QuoteTweetsLookup() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/v2/endpoints.go b/v2/endpoints.go index 687f954..215602a 100644 --- a/v2/endpoints.go +++ b/v2/endpoints.go @@ -50,6 +50,7 @@ const ( spaceTweetsLookupEndpoint endpoint = "2/spaces/{id}/tweets" spaceSearchEndpoint endpoint = "2/spaces/search" complianceJobsEndpiont endpoint = "2/compliance/jobs" + quoteTweetLookupEndpoint endpoint = "2/tweets/{id}/quote_tweets" idTag = "{id}" ) diff --git a/v2/tweet_quote.go b/v2/tweet_quote.go new file mode 100644 index 0000000..9dd3ff2 --- /dev/null +++ b/v2/tweet_quote.go @@ -0,0 +1,63 @@ +package twitter + +import ( + "net/http" + "strconv" + "strings" +) + +// QuoteTweetsLookupOpts are the options for the quote tweets +type QuoteTweetsLookupOpts struct { + MaxResults int + PaginationToken string + Expansions []Expansion + MediaFields []MediaField + PlaceFields []PlaceField + PollFields []PollField + TweetFields []TweetField + UserFields []UserField +} + +func (qt QuoteTweetsLookupOpts) addQuery(req *http.Request) { + q := req.URL.Query() + if len(qt.Expansions) > 0 { + q.Add("expansions", strings.Join(expansionStringArray(qt.Expansions), ",")) + } + if len(qt.MediaFields) > 0 { + q.Add("media.fields", strings.Join(mediaFieldStringArray(qt.MediaFields), ",")) + } + if len(qt.PlaceFields) > 0 { + q.Add("place.fields", strings.Join(placeFieldStringArray(qt.PlaceFields), ",")) + } + if len(qt.PollFields) > 0 { + q.Add("poll.fields", strings.Join(pollFieldStringArray(qt.PollFields), ",")) + } + if len(qt.TweetFields) > 0 { + q.Add("tweet.fields", strings.Join(tweetFieldStringArray(qt.TweetFields), ",")) + } + if len(qt.UserFields) > 0 { + q.Add("user.fields", strings.Join(userFieldStringArray(qt.UserFields), ",")) + } + if qt.MaxResults > 0 { + q.Add("max_results", strconv.Itoa(qt.MaxResults)) + } + if len(qt.PaginationToken) > 0 { + q.Add("pagination_token", qt.PaginationToken) + } + if len(q) > 0 { + req.URL.RawQuery = q.Encode() + } +} + +// QuoteTweetsLookupResponse is the response from the quote tweet +type QuoteTweetsLookupResponse struct { + Raw *TweetRaw + Meta *QuoteTweetsLookupMeta + RateLimit *RateLimit +} + +// QuoteTweetsLookupMeta is the meta data from the response +type QuoteTweetsLookupMeta struct { + ResultCount int `json:"result_count"` + NextToken string `json:"next_token"` +}