diff --git a/v2/_examples/README.md b/v2/_examples/README.md index 0839c23..be11341 100644 --- a/v2/_examples/README.md +++ b/v2/_examples/README.md @@ -10,6 +10,12 @@ This [example](./tweet-lookup) demonstrates the tweets lookup API call. go run *.go -token=YOUR_API_TOKEN -ids=1261326399320715264,1278347468690915330 ``` +## Recent Tweet Count +This [example](./tweet-recent-counts) demonstrates the recent tweet counts API call. +``` +go run &.go -token=YOUR_API_TOKEN -query=YOUR_SEARCH_QUERY +``` + ## User Lookup This [example](./user-lookup) demonstrates the users lookup API call. diff --git a/v2/_examples/tweet-recent-counts/main.go b/v2/_examples/tweet-recent-counts/main.go new file mode 100644 index 0000000..8a0d187 --- /dev/null +++ b/v2/_examples/tweet-recent-counts/main.go @@ -0,0 +1,55 @@ +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)) +} +func main() { + token := flag.String("token", "", "twitter API token") + query := flag.String("query", "", "twitter query") + flag.Parse() + + client := &twitter.Client{ + Authorizer: authorize{ + Token: *token, + }, + Client: http.DefaultClient, + Host: "https://api.twitter.com", + } + opts := twitter.TweetRecentCountsOpts{ + Granularity: twitter.GranularityHour, + } + + fmt.Println("Callout to tweet recent counts callout") + + tweetResponse, err := client.TweetRecentCounts(context.Background(), *query, opts) + if err != nil { + log.Panicf("tweet recent counts error: %v", err) + } + + enc, err := json.MarshalIndent(tweetResponse.TweetCounts, "", " ") + if err != nil { + log.Panic(err) + } + fmt.Println(string(enc)) + + metaBytes, err := json.MarshalIndent(tweetResponse.Meta, "", " ") + if err != nil { + log.Panic(err) + } + fmt.Println(string(metaBytes)) +} diff --git a/v2/client.go b/v2/client.go index c870454..c2456d3 100644 --- a/v2/client.go +++ b/v2/client.go @@ -15,6 +15,7 @@ const ( userMaxIDs = 100 userMaxNames = 100 tweetRecentSearchQueryLength = 512 + tweetRecentCountsQueryLength = 512 ) // Client is used to make twitter v2 API callouts. @@ -288,6 +289,62 @@ func (c *Client) TweetRecentSearch(ctx context.Context, query string, opts Tweet return recentSearch, nil } +// TweetRecentCounts will return a recent tweet counts based of a query +func (c *Client) TweetRecentCounts(ctx context.Context, query string, opts TweetRecentCountsOpts) (*TweetRecentCountsResponse, error) { + switch { + case len(query) == 0: + return nil, fmt.Errorf("tweet recent counts: a query is required: %w", ErrParameter) + case len(query) > tweetRecentCountsQueryLength: + return nil, fmt.Errorf("tweet recent counts: the query over the length (%d): %w", tweetRecentCountsQueryLength, ErrParameter) + default: + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tweetRecentCountsEndpoint.url(c.Host), nil) + if err != nil { + return nil, fmt.Errorf("tweet recent counts request: %w", err) + } + req.Header.Add("Accept", "application/json") + c.Authorizer.Add(req) + opts.addQuery(req) + q := req.URL.Query() + q.Add("query", query) + req.URL.RawQuery = q.Encode() + + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("tweet recent counts response: %w", err) + } + defer resp.Body.Close() + + respBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("tweet recent counts response read: %w", err) + } + if resp.StatusCode != http.StatusOK { + e := &ErrorResponse{} + if err := json.Unmarshal(respBytes, e); err != nil { + return nil, &HTTPError{ + Status: resp.Status, + StatusCode: resp.StatusCode, + URL: resp.Request.URL.String(), + } + } + e.StatusCode = resp.StatusCode + return nil, e + } + + recentCounts := &TweetRecentCountsResponse{ + TweetCounts: []*TweetCount{}, + Meta: &TweetRecentCountsMeta{}, + } + + if err := json.Unmarshal(respBytes, recentCounts); err != nil { + return nil, fmt.Errorf("tweet recent counts response error decode: %w", err) + } + + return recentCounts, nil +} + // UserFollowingLookup will return a user's following users func (c *Client) UserFollowingLookup(ctx context.Context, id string, opts UserFollowingLookupOpts) (*UserFollowingLookupResponse, error) { if len(id) == 0 { diff --git a/v2/client_tweet_recent_counts_test.go b/v2/client_tweet_recent_counts_test.go new file mode 100644 index 0000000..97fe94e --- /dev/null +++ b/v2/client_tweet_recent_counts_test.go @@ -0,0 +1,206 @@ +package twitter + +import ( + "context" + "io/ioutil" + "log" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +func TestClient_TweetRecentCounts(t *testing.T) { + type fields struct { + Authorizer Authorizer + Client *http.Client + Host string + } + type args struct { + query string + opts TweetRecentCountsOpts + } + tests := []struct { + name string + fields fields + args args + want *TweetRecentCountsResponse + 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(), string(tweetRecentCountsEndpoint)) == false { + log.Panicf("the url is not correct %s %s", req.URL.String(), tweetRecentCountsEndpoint) + } + body := `{ + "data": [ + { + "end": "2021-05-27t00:00:00.000z", + "start": "2021-05-26t23:00:00.000z", + "tweet_count": 2 + }, + { + "end": "2021-05-27t01:00:00.000z", + "start": "2021-05-27t00:00:00.000z", + "tweet_count": 2 + } + ], + "meta": { + "total_tweet_count": 4 + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(body)), + } + }), + }, + args: args{ + query: "python", + }, + want: &TweetRecentCountsResponse{ + TweetCounts: []*TweetCount{ + { + End: "2021-05-27t00:00:00.000z", + Start: "2021-05-26t23:00:00.000z", + TweetCount: 2, + }, + { + End: "2021-05-27t01:00:00.000z", + Start: "2021-05-27t00:00:00.000z", + TweetCount: 2, + }, + }, + Meta: &TweetRecentCountsMeta{ + TotalTweetCount: 4, + }, + }, + wantErr: false, + }, + { + name: "success-optional", + 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(), string(tweetRecentCountsEndpoint)) == false { + log.Panicf("the url is not correct %s %s", req.URL.String(), tweetRecentCountsEndpoint) + } + body := `{ + "data": [ + { + "start": "2021-10-08T15:29:42.000Z", + "end": "2021-10-09T00:00:00.000Z", + "tweet_count": 2 + }, + { + "start": "2021-10-09T00:00:00.000Z", + "end": "2021-10-09T15:29:33.000Z", + "tweet_count": 2 + } + ], + "meta": { + "total_tweet_count": 4 + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(body)), + } + }), + }, + args: args{ + query: "python", + opts: TweetRecentCountsOpts{ + StartTime: time.Now().Add(-24 * time.Hour), + Granularity: Granularity("day"), + }, + }, + want: &TweetRecentCountsResponse{ + TweetCounts: []*TweetCount{ + { + End: "2021-10-09T00:00:00.000Z", + Start: "2021-10-08T15:29:42.000Z", + TweetCount: 2, + }, + { + End: "2021-10-09T15:29:33.000Z", + Start: "2021-10-09T00:00:00.000Z", + TweetCount: 2, + }, + }, + Meta: &TweetRecentCountsMeta{ + TotalTweetCount: 4, + }, + }, + wantErr: false, + }, + { + name: "Bad Request", + 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(), string(tweetRecentCountsEndpoint)) == false { + log.Panicf("the url is not correct %s %s", req.URL.String(), tweetRecentCountsEndpoint) + } + body := `{ + "errors": [ + { + "parameters": { + "id": [ + "aassd" + ] + }, + "message": "The id query parameter value [aassd] does not match ^[0-9]{1,19}$" + } + ], + "title": "Invalid Request", + "detail": "One or more parameters to your request was invalid.", + "type": "https://api.twitter.com/2/problems/invalid-request" + }` + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: ioutil.NopCloser(strings.NewReader(body)), + } + }), + }, + args: args{ + query: "nothing", + }, + want: nil, + wantErr: true, + }, + } + 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.TweetRecentCounts(context.Background(), tt.args.query, tt.args.opts) + if (err != nil) != tt.wantErr { + t.Errorf("Client.TweetRecentCounts() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Client.TweetRecentCounts() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/v2/endpoints.go b/v2/endpoints.go index 349ae2a..651e6e8 100644 --- a/v2/endpoints.go +++ b/v2/endpoints.go @@ -12,6 +12,7 @@ const ( userLookupEndpoint endpoint = "2/users" userNameLookupEndpoint endpoint = "2/users/by" tweetRecentSearchEndpoint endpoint = "2/tweets/search/recent" + tweetRecentCountsEndpoint endpoint = "2/tweets/counts/recent" userFollowingEndpoint endpoint = "2/users/{id}/following" userFollowersEndpoint endpoint = "2/users/{id}/followers" userTweetTimelineEndpoint endpoint = "2/users/{id}/tweets" diff --git a/v2/tweet_counts.go b/v2/tweet_counts.go new file mode 100644 index 0000000..7a7395a --- /dev/null +++ b/v2/tweet_counts.go @@ -0,0 +1,19 @@ +package twitter + +// TweetRecentCountsResponse contains all of the information from a tweet recent counts +type TweetRecentCountsResponse struct { + TweetCounts []*TweetCount `json:"data"` + Meta *TweetRecentCountsMeta `json:"meta"` +} + +// TweetRecentCountsMeta contains the meta data from the recent counts information +type TweetRecentCountsMeta struct { + TotalTweetCount int `json:"total_tweet_count"` +} + +// TweetCount is the object on the tweet counts endpoints +type TweetCount struct { + Start string `json:"start"` + End string `json:"end"` + TweetCount int `json:"tweet_count"` +} diff --git a/v2/tweet_counts_options.go b/v2/tweet_counts_options.go new file mode 100644 index 0000000..3a7c26f --- /dev/null +++ b/v2/tweet_counts_options.go @@ -0,0 +1,49 @@ +package twitter + +import ( + "net/http" + "time" +) + +// Granularity is the granularity that you want the timeseries count data to be grouped by +type Granularity string + +const ( + // GranularityMinute will group tweet in minutes + GranularityMinute Granularity = "minute" + // GranularityHour is the default granularity + GranularityHour Granularity = "hour" + // GranularityDay will group tweet on a daily basis + GranularityDay Granularity = "day" +) + +// TweetRecentCountsOpts are the optional paramters that can be passed to the tweet recent counts callout +type TweetRecentCountsOpts struct { + StartTime time.Time + EndTime time.Time + SinceID string + UntilID string + Granularity Granularity +} + +func (t TweetRecentCountsOpts) addQuery(req *http.Request) { + q := req.URL.Query() + if t.StartTime.IsZero() == false { + q.Add("start_time", t.StartTime.Format(time.RFC3339)) + } + if t.EndTime.IsZero() == false { + q.Add("end_time", t.EndTime.Format(time.RFC3339)) + } + if len(t.SinceID) > 0 { + q.Add("since_id", t.SinceID) + } + if len(t.UntilID) > 0 { + q.Add("until_id", t.UntilID) + } + if len(t.Granularity) > 0 { + q.Add("granularity", string(t.Granularity)) + } + if len(q) > 0 { + req.URL.RawQuery = q.Encode() + } +}