Repository: sivchari/gotwtr
Pull request #212 (2025-07-16 02:23:44)

feat: implement User Search endpoint (GET /2/users/search)
- Added searchUsersURL endpoint constant
- Added SearchUsers method to Users interface
- Implemented SearchUsersOption with query parameters support
- Added SearchUsersResponse and SearchUsersMeta structures
- Implemented searchUsers function with proper error handling
- Added comprehensive tests for user search functionality

Resolves issue #202


Patch:
diff --git a/client.go b/client.go
index bb99abf..90705df 100644
--- a/client.go
+++ b/client.go
@@ -81,6 +81,8 @@ type Users interface {
 	RetrieveMultipleUsersWithUserNames(ctx context.Context, userNames []string, opt ...*RetrieveUserOption) (*UsersResponse, error)
 	RetrieveSingleUserWithUserName(ctx context.Context, userName string, opt ...*RetrieveUserOption) (*UserResponse, error)
 	Me(ctx context.Context, opt ...*MeOption) (*MeResponse, error)
+	// User search
+	SearchUsers(ctx context.Context, query string, opt ...*SearchUsersOption) (*SearchUsersResponse, error)
 }
 
 type Spaces interface {
@@ -359,6 +361,11 @@ func (c *Client) Me(ctx context.Context, opt ...*MeOption) (*MeResponse, error)
 	return me(ctx, c.client, opt...)
 }
 
+// SearchUsers searches for users matching the specified query.
+func (c *Client) SearchUsers(ctx context.Context, query string, opt ...*SearchUsersOption) (*SearchUsersResponse, error) {
+	return searchUsers(ctx, c.client, query, opt...)
+}
+
 // Followers returns a list of users who are followers of the specified userID.
 func (c *Client) Followers(ctx context.Context, userID string, opt ...*FollowOption) (*FollowersResponse, error) {
 	return followers(ctx, c.client, userID, opt...)
diff --git a/endpoint.go b/endpoint.go
index 605085f..73264c2 100644
--- a/endpoint.go
+++ b/endpoint.go
@@ -54,6 +54,8 @@ const (
 	retrieveMultipleUsersWithUserNamesURL = "https://api.twitter.com/2/users/by?usernames="
 	retrieveSingleUserWithUserNameURL     = "https://api.twitter.com/2/users/by/username/%v"
 	meURL                                 = "https://api.twitter.com/2/users/me"
+	// User search
+	searchUsersURL = "https://api.twitter.com/2/users/search"
 	// Follows
 	undoFollowingURL = "https://api.twitter.com/2/users/%v/following/%v"
 	followersURL     = "https://api.twitter.com/2/users/%v/followers"
diff --git a/user.go b/user.go
index 1638392..ddc9dc9 100644
--- a/user.go
+++ b/user.go
@@ -270,3 +270,19 @@ type MeWithheld struct {
 type MeIncludes struct {
 	Tweets []*Tweet
 }
+
+type SearchUsersResponse struct {
+	Users    []*User             `json:"data"`
+	Includes *UserIncludes       `json:"includes,omitempty"`
+	Errors   []*APIResponseError `json:"errors,omitempty"`
+	Meta     *SearchUsersMeta    `json:"meta,omitempty"`
+	Title    string              `json:"title,omitempty"`
+	Detail   string              `json:"detail,omitempty"`
+	Type     string              `json:"type,omitempty"`
+}
+
+type SearchUsersMeta struct {
+	ResultCount   int    `json:"result_count,omitempty"`
+	NextToken     string `json:"next_token,omitempty"`
+	PreviousToken string `json:"previous_token,omitempty"`
+}
diff --git a/user_lookup.go b/user_lookup.go
index 72956f8..72e1518 100644
--- a/user_lookup.go
+++ b/user_lookup.go
@@ -198,3 +198,50 @@ func retrieveSingleUserWithUserName(ctx context.Context, c *client, userName str
 
 	return &ur, nil
 }
+
+func searchUsers(ctx context.Context, c *client, query string, opt ...*SearchUsersOption) (*SearchUsersResponse, error) {
+	if len(query) == 0 {
+		return nil, errors.New("search users: query parameter is required")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchUsersURL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("search users new request with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+
+	q := req.URL.Query()
+	q.Add("query", query)
+	req.URL.RawQuery = q.Encode()
+
+	var sopt SearchUsersOption
+	switch len(opt) {
+	case 0:
+		// do nothing
+	case 1:
+		sopt = *opt[0]
+	default:
+		return nil, errors.New("search users: only one option is allowed")
+	}
+	sopt.addQuery(req)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("search users response: %w", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	var sur SearchUsersResponse
+	if err := json.NewDecoder(resp.Body).Decode(&sur); err != nil {
+		return nil, fmt.Errorf("search users decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		return &sur, &HTTPError{
+			APIName: "search users",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+
+	return &sur, nil
+}
diff --git a/user_option.go b/user_option.go
index b07aa76..14c127a 100644
--- a/user_option.go
+++ b/user_option.go
@@ -147,3 +147,33 @@ func (m *MeOption) addQuery(req *http.Request) {
 		req.URL.RawQuery = q.Encode()
 	}
 }
+
+type SearchUsersOption struct {
+	Expansions      []Expansion
+	MaxResults      int
+	PaginationToken string
+	TweetFields     []TweetField
+	UserFields      []UserField
+}
+
+func (s *SearchUsersOption) addQuery(req *http.Request) {
+	q := req.URL.Query()
+	if len(s.Expansions) > 0 {
+		q.Add("expansions", strings.Join(expansionsToString(s.Expansions), ","))
+	}
+	if s.MaxResults > 0 {
+		q.Add("max_results", strconv.Itoa(s.MaxResults))
+	}
+	if len(s.PaginationToken) > 0 {
+		q.Add("pagination_token", s.PaginationToken)
+	}
+	if len(s.TweetFields) > 0 {
+		q.Add("tweet.fields", strings.Join(tweetFieldsToString(s.TweetFields), ","))
+	}
+	if len(s.UserFields) > 0 {
+		q.Add("user.fields", strings.Join(userFieldsToString(s.UserFields), ","))
+	}
+	if len(q) > 0 {
+		req.URL.RawQuery = q.Encode()
+	}
+}


Test patch:
diff --git a/user_lookup_test.go b/user_lookup_test.go
index 4c3a83d..dff3ec0 100644
--- a/user_lookup_test.go
+++ b/user_lookup_test.go
@@ -969,3 +969,83 @@ func Test_retrieveMultipleUsersWithUserNames(t *testing.T) {
 		})
 	}
 }
+
+func Test_searchUsers(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx    context.Context
+		client *http.Client
+		query  string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.SearchUsersResponse
+		wantErr bool
+	}{
+		{
+			name: "200 ok default payload",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(request *http.Request) *http.Response {
+					data := `{
+						"data": [
+							{
+								"id": "2244994945",
+								"name": "Twitter Dev",
+								"username": "TwitterDev"
+							}
+						],
+						"meta": {
+							"result_count": 1
+						}
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(data)),
+					}
+				}),
+				query: "TwitterDev",
+			},
+			want: &gotwtr.SearchUsersResponse{
+				Users: []*gotwtr.User{
+					{
+						ID:       "2244994945",
+						Name:     "Twitter Dev",
+						UserName: "TwitterDev",
+					},
+				},
+				Meta: &gotwtr.SearchUsersMeta{
+					ResultCount: 1,
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "empty query",
+			args: args{
+				ctx:    context.Background(),
+				client: mockHTTPClient(func(request *http.Request) *http.Response { return nil }),
+				query:  "",
+			},
+			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.SearchUsers(tt.args.ctx, tt.args.query)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.SearchUsers() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.SearchUsers() diff = %v", diff)
+				return
+			}
+		})
+	}
+}