feat(users): add user search endpoint
Some checks failed
Security Scan / security (push) Successful in 13s
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 03:20:35 +00:00
parent 452b190e9d
commit 2460a7b09e
2 changed files with 218 additions and 0 deletions

112
users.go
View file

@ -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)

View file

@ -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 {