Repository: sivchari/gotwtr (go)

Issue:
Implement Quote Tweets endpoint (GET /2/tweets/:id/quote_tweets)

Pull request:
feat: implement Quote Tweets endpoint (GET /2/tweets/:id/quote_tweets)
- Added quoteTweetsURL endpoint constant
- Added QuoteTweets method to Tweets interface
- Implemented QuoteTweetsOption with query parameters support
- Added QuoteTweetsResponse and QuoteTweetsMeta structures
- Implemented quoteTweets function with proper error handling
- Added comprehensive tests for quote tweets functionality

close #197

Patch:
diff --git a/client.go b/client.go
index 90705df..d5d845e 100644
--- a/client.go
+++ b/client.go
@@ -38,7 +38,8 @@ type Tweets interface {
 	// Manage Tweets
 	DeleteTweet(ctx context.Context, tweetID string) (*DeleteTweetResponse, error)
 	PostTweet(ctx context.Context, body *PostTweetOption) (*PostTweetResponse, error)
-	// TODO: /2/tweets/:id/quote_tweets
+	// Quote tweets
+	QuoteTweets(ctx context.Context, tweetID string, opt ...*QuoteTweetsOption) (*QuoteTweetsResponse, error)
 	// Retweets
 	UndoRetweet(ctx context.Context, userID string, sourceTweetID string) (*UndoRetweetResponse, error)
 	RetweetsLookup(ctx context.Context, tweetID string, opt ...*RetweetsLookupOption) (*RetweetsResponse, error)
@@ -214,6 +215,11 @@ func (c *Client) RetrieveSingleTweet(ctx context.Context, tweetID string, opt ..
 	return retrieveSingleTweet(ctx, c.client, tweetID, opt...)
 }
 
+// QuoteTweets returns a list of Tweets that quote the specified Tweet ID.
+func (c *Client) QuoteTweets(ctx context.Context, tweetID string, opt ...*QuoteTweetsOption) (*QuoteTweetsResponse, error) {
+	return quoteTweets(ctx, c.client, tweetID, opt...)
+}
+
 // UserMentionTimeline returns Tweets mentioning a single user specified by the requested userID.
 // By default, the most recent ten Tweets are returned per request. Using pagination, up to the most recent 800 Tweets can be retrieved.
 func (c *Client) UserMentionTimeline(ctx context.Context, userID string, opt ...*UserMentionTimelineOption) (*UserMentionTimelineResponse, error) {
diff --git a/endpoint.go b/endpoint.go
index 73264c2..255c02e 100644
--- a/endpoint.go
+++ b/endpoint.go
@@ -33,7 +33,8 @@ const (
 	undoRetweetURL    = "https://api.twitter.com/2/users/%v/retweets/%v"
 	retweetsLookupURL = "https://api.twitter.com/2/tweets/%v/retweeted_by"
 	postRetweetURL    = "https://api.twitter.com/2/users/%v/retweets"
-	// TODO: /2/tweets/:id/quote_tweets
+	// Quote tweets
+	quoteTweetsURL = "https://api.twitter.com/2/tweets/%v/quote_tweets"
 	// Likes
 	usersLikingTweetURL     = "https://api.twitter.com/2/tweets/%v/liking_users"
 	tweetsUserLikedURL      = "https://api.twitter.com/2/users/%v/liked_tweets"
diff --git a/tweet.go b/tweet.go
index 9967908..93ad386 100644
--- a/tweet.go
+++ b/tweet.go
@@ -708,3 +708,19 @@ type TweetsUserLikedEditControls struct {
 	IsEditEligible bool   `json:"is_edit_eligible"`
 	EditableUntil  string `json:"editable_until"`
 }
+
+type QuoteTweetsResponse struct {
+	Tweets   []*Tweet            `json:"data"`
+	Includes *TweetIncludes      `json:"includes,omitempty"`
+	Meta     *QuoteTweetsMeta    `json:"meta,omitempty"`
+	Errors   []*APIResponseError `json:"errors,omitempty"`
+	Title    string              `json:"title,omitempty"`
+	Detail   string              `json:"detail,omitempty"`
+	Type     string              `json:"type,omitempty"`
+}
+
+type QuoteTweetsMeta struct {
+	ResultCount   int    `json:"result_count,omitempty"`
+	NextToken     string `json:"next_token,omitempty"`
+	PreviousToken string `json:"previous_token,omitempty"`
+}
diff --git a/tweet_lookup.go b/tweet_lookup.go
index 5e27979..55f5919 100644
--- a/tweet_lookup.go
+++ b/tweet_lookup.go
@@ -108,3 +108,47 @@ func retrieveSingleTweet(ctx context.Context, c *client, tweetID string, opt ...
 
 	return &tweet, nil
 }
+
+func quoteTweets(ctx context.Context, c *client, tweetID string, opt ...*QuoteTweetsOption) (*QuoteTweetsResponse, error) {
+	if len(tweetID) == 0 {
+		return nil, errors.New("quote tweets: tweet id parameter is required")
+	}
+
+	ep := fmt.Sprintf(quoteTweetsURL, tweetID)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
+	if err != nil {
+		return nil, fmt.Errorf("quote tweets new request with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+
+	var qopt QuoteTweetsOption
+	switch len(opt) {
+	case 0:
+		// do nothing
+	case 1:
+		qopt = *opt[0]
+	default:
+		return nil, errors.New("quote tweets: only one option is allowed")
+	}
+	qopt.addQuery(req)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("quote tweets response: %w", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	var qtr QuoteTweetsResponse
+	if err := json.NewDecoder(resp.Body).Decode(&qtr); err != nil {
+		return nil, fmt.Errorf("quote tweets decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		return &qtr, &HTTPError{
+			APIName: "quote tweets",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+
+	return &qtr, nil
+}
diff --git a/tweet_option.go b/tweet_option.go
index a223b5c..bdd99ce 100644
--- a/tweet_option.go
+++ b/tweet_option.go
@@ -563,3 +563,45 @@ func (l LookupUserBookmarksOption) addQuery(req *http.Request) {
 		req.URL.RawQuery = q.Encode()
 	}
 }
+
+type QuoteTweetsOption struct {
+	Expansions      []Expansion
+	MaxResults      int
+	MediaFields     []MediaField
+	PaginationToken string
+	PlaceFields     []PlaceField
+	PollFields      []PollField
+	TweetFields     []TweetField
+	UserFields      []UserField
+}
+
+func (q QuoteTweetsOption) addQuery(req *http.Request) {
+	query := req.URL.Query()
+	if len(q.Expansions) > 0 {
+		query.Add("expansions", strings.Join(expansionsToString(q.Expansions), ","))
+	}
+	if q.MaxResults > 0 {
+		query.Add("max_results", strconv.Itoa(q.MaxResults))
+	}
+	if len(q.MediaFields) > 0 {
+		query.Add("media.fields", strings.Join(mediaFieldsToString(q.MediaFields), ","))
+	}
+	if q.PaginationToken != "" {
+		query.Add("pagination_token", q.PaginationToken)
+	}
+	if len(q.PlaceFields) > 0 {
+		query.Add("place.fields", strings.Join(placeFieldsToString(q.PlaceFields), ","))
+	}
+	if len(q.PollFields) > 0 {
+		query.Add("poll.fields", strings.Join(pollFieldsToString(q.PollFields), ","))
+	}
+	if len(q.TweetFields) > 0 {
+		query.Add("tweet.fields", strings.Join(tweetFieldsToString(q.TweetFields), ","))
+	}
+	if len(q.UserFields) > 0 {
+		query.Add("user.fields", strings.Join(userFieldsToString(q.UserFields), ","))
+	}
+	if len(query) > 0 {
+		req.URL.RawQuery = query.Encode()
+	}
+}


Test patch:
diff --git a/tweet_lookup_test.go b/tweet_lookup_test.go
index 80d5589..6962ded 100644
--- a/tweet_lookup_test.go
+++ b/tweet_lookup_test.go
@@ -991,3 +991,81 @@ func Test_lookUpByID(t *testing.T) {
 		})
 	}
 }
+
+func Test_quoteTweets(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx     context.Context
+		client  *http.Client
+		tweetID string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.QuoteTweetsResponse
+		wantErr bool
+	}{
+		{
+			name: "200 ok default payload",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(request *http.Request) *http.Response {
+					data := `{
+						"data": [
+							{
+								"id": "1460323737035677698",
+								"text": "Quoting this amazing tweet!"
+							}
+						],
+						"meta": {
+							"result_count": 1
+						}
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(data)),
+					}
+				}),
+				tweetID: "1460323737035677698",
+			},
+			want: &gotwtr.QuoteTweetsResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						ID:   "1460323737035677698",
+						Text: "Quoting this amazing tweet!",
+					},
+				},
+				Meta: &gotwtr.QuoteTweetsMeta{
+					ResultCount: 1,
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "empty tweet id",
+			args: args{
+				ctx:     context.Background(),
+				client:  mockHTTPClient(func(request *http.Request) *http.Response { return nil }),
+				tweetID: "",
+			},
+			want:    nil,
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.QuoteTweets(tt.args.ctx, tt.args.tweetID)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.QuoteTweets() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.QuoteTweets() diff = %v", diff)
+				return
+			}
+		})
+	}
+}