diff --git a/users.go b/users.go index 7baf8af..b01b528 100644 --- a/users.go +++ b/users.go @@ -4,6 +4,8 @@ import ( "context" "iter" "net/http" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -18,6 +20,25 @@ type UserService struct { Resource[types.User, struct{}, struct{}] } +// UserSearchOptions controls filtering for user searches. +type UserSearchOptions struct { + UID int64 +} + +func (o UserSearchOptions) queryParams() map[string]string { + if o.UID == 0 { + return nil + } + return map[string]string{ + "uid": strconv.FormatInt(o.UID, 10), + } +} + +type userSearchResults struct { + Data []*types.User `json:"data,omitempty"` + OK bool `json:"ok,omitempty"` +} + func newUserService(c *Client) *UserService { return &UserService{ Resource: *NewResource[types.User, struct{}, struct{}]( @@ -62,6 +83,97 @@ func (s *UserService) GetQuota(ctx context.Context) (*types.QuotaInfo, error) { return &out, nil } +// SearchUsersPage returns a single page of users matching the search filters. +func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpts ListOptions, filters ...UserSearchOptions) (*PagedResult[types.User], error) { + if pageOpts.Page < 1 { + pageOpts.Page = 1 + } + if pageOpts.Limit < 1 { + pageOpts.Limit = 50 + } + + u, err := url.Parse("/api/v1/users/search") + if err != nil { + return nil, err + } + + q := u.Query() + q.Set("q", query) + for _, filter := range filters { + for key, value := range filter.queryParams() { + q.Set(key, value) + } + } + q.Set("page", strconv.Itoa(pageOpts.Page)) + q.Set("limit", strconv.Itoa(pageOpts.Limit)) + u.RawQuery = q.Encode() + + var out userSearchResults + resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + items := make([]types.User, 0, len(out.Data)) + for _, user := range out.Data { + if user != nil { + items = append(items, *user) + } + } + + return &PagedResult[types.User]{ + Items: items, + TotalCount: totalCount, + Page: pageOpts.Page, + HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageOpts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageOpts.Limit), + }, nil +} + +// SearchUsers returns all users matching the search filters. +func (s *UserService) SearchUsers(ctx context.Context, query string, filters ...UserSearchOptions) ([]types.User, error) { + var all []types.User + page := 1 + + for { + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +// IterSearchUsers returns an iterator over users matching the search filters. +func (s *UserService) IterSearchUsers(ctx context.Context, query string, filters ...UserSearchOptions) iter.Seq2[types.User, error] { + return func(yield func(types.User, error) bool) { + page := 1 + for { + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + if err != nil { + yield(*new(types.User), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + // ListQuotaArtifacts returns all artifacts affecting the authenticated user's quota. func (s *UserService) ListQuotaArtifacts(ctx context.Context) ([]types.QuotaUsedArtifact, error) { return ListAll[types.QuotaUsedArtifact](ctx, s.client, "/api/v1/user/quota/artifacts", nil) diff --git a/users_test.go b/users_test.go index 00d3c43..654d4c3 100644 --- a/users_test.go +++ b/users_test.go @@ -5,6 +5,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "strconv" "testing" "dappco.re/go/core/forge/types" @@ -163,6 +164,111 @@ func TestUserService_GetQuota_Good(t *testing.T) { } } +func TestUserService_SearchUsersPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "al" { + t.Errorf("wrong q: %s", got) + } + if got := r.URL.Query().Get("uid"); got != "7" { + t.Errorf("wrong uid: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "alex"}, + }, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Users.SearchUsersPage(context.Background(), "al", ListOptions{}, UserSearchOptions{UID: 7}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 2 || result.Page != 1 || result.HasMore { + t.Fatalf("got %#v", result) + } + if len(result.Items) != 2 || result.Items[0].UserName != "alice" || result.Items[1].UserName != "alex" { + t.Fatalf("got %#v", result.Items) + } +} + +func TestUserService_SearchUsers_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "al" { + t.Errorf("wrong q: %s", got) + } + if got := r.URL.Query().Get("limit"); got != strconv.Itoa(50) { + t.Errorf("wrong limit: %s", got) + } + + switch r.URL.Query().Get("page") { + case "1": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "alex"}, + }, + "ok": true, + }) + case "2": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 3, UserName: "ally"}, + }, + "ok": true, + }) + default: + t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for user, err := range f.Users.IterSearchUsers(context.Background(), "al") { + if err != nil { + t.Fatal(err) + } + got = append(got, user.UserName) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } + if len(got) != 3 || got[0] != "alice" || got[1] != "alex" || got[2] != "ally" { + t.Fatalf("got %#v", got) + } +} + func TestUserService_ListQuotaArtifacts_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet {