diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d285f99e..b0b28eccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -> Place unreleased changes here. +- Add sorting to `GET /users` endpoint [#104](https://github.com/stellar/stellar-disbursement-platform-backend/pull/104) ## [1.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0-rc2...1.0.0) diff --git a/internal/data/query_params.go b/internal/data/query_params.go index c24cd4811..bb91a9403 100644 --- a/internal/data/query_params.go +++ b/internal/data/query_params.go @@ -20,6 +20,8 @@ type SortField string const ( SortFieldName SortField = "name" + SortFieldEmail SortField = "email" + SortFieldIsActive SortField = "is_active" SortFieldCreatedAt SortField = "created_at" SortFieldUpdatedAt SortField = "updated_at" ) diff --git a/internal/serve/httphandler/user_handler.go b/internal/serve/httphandler/user_handler.go index c940f1eee..36bc14b79 100644 --- a/internal/serve/httphandler/user_handler.go +++ b/internal/serve/httphandler/user_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "sort" "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" @@ -32,6 +33,18 @@ type UserActivationRequest struct { IsActive *bool `json:"is_active"` } +type UserSorterByEmail []auth.User + +func (a UserSorterByEmail) Len() int { return len(a) } +func (a UserSorterByEmail) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a UserSorterByEmail) Less(i, j int) bool { return a[i].Email < a[j].Email } + +type UserSorterByIsActive []auth.User + +func (a UserSorterByIsActive) Len() int { return len(a) } +func (a UserSorterByIsActive) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a UserSorterByIsActive) Less(i, j int) bool { return a[i].IsActive } + func (uar UserActivationRequest) validate() *httperror.HTTPError { validator := validators.NewValidator() @@ -275,6 +288,13 @@ func (h UserHandler) UpdateUserRoles(rw http.ResponseWriter, req *http.Request) } func (h UserHandler) GetAllUsers(rw http.ResponseWriter, req *http.Request) { + validator := validators.NewUserQueryValidator() + queryParams := validator.ParseParametersFromRequest(req) + if validator.HasErrors() { + httperror.BadRequest("request invalid", nil, validator.Errors).Render(rw) + return + } + ctx := req.Context() token, ok := ctx.Value(middleware.TokenContextKey).(string) @@ -290,10 +310,25 @@ func (h UserHandler) GetAllUsers(rw http.ResponseWriter, req *http.Request) { httperror.Unauthorized("", err, nil).Render(rw) return } - httperror.InternalError(ctx, "Cannot get all users", err, nil).Render(rw) return } + // Order users + switch queryParams.SortBy { + case data.SortFieldEmail: + if queryParams.SortOrder == data.SortOrderDESC { + sort.Sort(sort.Reverse(UserSorterByEmail(users))) + } else { + sort.Sort(UserSorterByEmail(users)) + } + case data.SortFieldIsActive: + if queryParams.SortOrder == data.SortOrderDESC { + sort.Sort(sort.Reverse(UserSorterByIsActive(users))) + } else { + sort.Sort(UserSorterByIsActive(users)) + } + } + httpjson.RenderStatus(rw, http.StatusOK, users, httpjson.JSON) } diff --git a/internal/serve/httphandler/user_handler_test.go b/internal/serve/httphandler/user_handler_test.go index ede35bd76..34201069d 100644 --- a/internal/serve/httphandler/user_handler_test.go +++ b/internal/serve/httphandler/user_handler_test.go @@ -1232,12 +1232,111 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { assert.Contains(t, buf.String(), "Cannot get all users") }) - t.Run("returns all users successfully", func(t *testing.T) { + const orderByEmailAscURL = "/users?sort=email&direction=ASC" + const orderByEmailDescURL = "/users?sort=email&direction=DESC" + const orderByIsActiveAscURL = "/users?sort=is_active&direction=ASC" + const orderByIsActiveDescURL = "/users?sort=is_active&direction=DESC" + + t.Run("returns all users ordered by email ASC", func(t *testing.T) { token := "mytoken" ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByEmailAscURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userC@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + { + ID: "user3-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + }, + { + "id": "user3-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userC@email.com", + "is_active": true, + "roles": [ + "owner" + ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by email DESC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByEmailDescURL, nil) require.NoError(t, err) jwtManagerMock. @@ -1252,7 +1351,7 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { ID: "user1-ID", FirstName: "First", LastName: "Last", - Email: "user1@email.com", + Email: "userA@email.com", IsOwner: false, IsActive: false, Roles: []string{data.BusinessUserRole.String()}, @@ -1261,7 +1360,16 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { ID: "user2-ID", FirstName: "First", LastName: "Last", - Email: "user2@email.com", + Email: "userC@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + { + ID: "user3-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", IsOwner: true, IsActive: true, Roles: []string{data.OwnerUserRole.String()}, @@ -1280,25 +1388,185 @@ func Test_UserHandler_GetAllUsers(t *testing.T) { wantsBody := ` [ + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userC@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, + { + "id": "user3-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] + }, { "id": "user1-ID", "first_name": "First", "last_name": "Last", - "email": "user1@email.com", + "email": "userA@email.com", "is_active": false, "roles": [ "business" ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by is_active ASC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByIsActiveAscURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ { "id": "user2-ID", "first_name": "First", "last_name": "Last", - "email": "user2@email.com", + "email": "userB@email.com", "is_active": true, "roles": [ "owner" ] + }, + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + } + ] + ` + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns all users ordered by is_active DESC", func(t *testing.T) { + token := "mytoken" + + ctx := context.WithValue(context.Background(), middleware.TokenContextKey, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, orderByIsActiveDescURL, nil) + require.NoError(t, err) + + jwtManagerMock. + On("ValidateToken", req.Context(), token). + Return(true, nil). + Once() + + authenticatorMock. + On("GetAllUsers", req.Context()). + Return([]auth.User{ + { + ID: "user1-ID", + FirstName: "First", + LastName: "Last", + Email: "userA@email.com", + IsOwner: false, + IsActive: false, + Roles: []string{data.BusinessUserRole.String()}, + }, + { + ID: "user2-ID", + FirstName: "First", + LastName: "Last", + Email: "userB@email.com", + IsOwner: true, + IsActive: true, + Roles: []string{data.OwnerUserRole.String()}, + }, + }, nil). + Once() + + w := httptest.NewRecorder() + + http.HandlerFunc(handler.GetAllUsers).ServeHTTP(w, req) + + resp := w.Result() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + wantsBody := ` + [ + { + "id": "user1-ID", + "first_name": "First", + "last_name": "Last", + "email": "userA@email.com", + "is_active": false, + "roles": [ + "business" + ] + }, + { + "id": "user2-ID", + "first_name": "First", + "last_name": "Last", + "email": "userB@email.com", + "is_active": true, + "roles": [ + "owner" + ] } ] ` diff --git a/internal/serve/validators/user_query_validator.go b/internal/serve/validators/user_query_validator.go new file mode 100644 index 000000000..5425596a9 --- /dev/null +++ b/internal/serve/validators/user_query_validator.go @@ -0,0 +1,27 @@ +package validators + +import ( + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +type UserQueryValidator struct { + QueryValidator +} + +var ( + DefaultUserSortField = data.SortFieldEmail + DefaultUserSortOrder = data.SortOrderASC + AllowedUserSorts = []data.SortField{data.SortFieldEmail, data.SortFieldIsActive} +) + +// NewUserQueryValidator creates a new UserQueryValidator with the provided configuration. +func NewUserQueryValidator() *UserQueryValidator { + return &UserQueryValidator{ + QueryValidator: QueryValidator{ + Validator: NewValidator(), + DefaultSortField: DefaultUserSortField, + DefaultSortOrder: DefaultUserSortOrder, + AllowedSortFields: AllowedUserSorts, + }, + } +}