From 70cac41579b30b7dddc2ce65e9eb3d40e60537e1 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 26 Jun 2019 15:40:46 +0200 Subject: [PATCH] Enhance user filtering New response structure Use query-builder Refactor user service & repo --- api/system/spec.json | 36 +++++ api/system/spec/user.json | 36 +++++ docs/system/README.md | 6 + messaging/rest/request/util.go | 2 +- system/commands/users.go | 6 +- system/internal/repository/application.go | 1 + system/internal/repository/mocks/user.go | 9 +- system/internal/repository/organisation.go | 1 + system/internal/repository/repository.go | 71 ++++++++ system/internal/repository/role.go | 1 + system/internal/repository/user.go | 180 ++++++++++++--------- system/internal/service/user.go | 13 +- system/rest/request/user.go | 36 ++++- system/rest/request/util.go | 9 ++ system/rest/user.go | 50 ++++-- system/types/user.go | 15 +- 16 files changed, 364 insertions(+), 108 deletions(-) diff --git a/api/system/spec.json b/api/system/spec.json index 655c680d4..764e70a81 100644 --- a/api/system/spec.json +++ b/api/system/spec.json @@ -701,6 +701,42 @@ "name": "email", "required": false, "title": "Search email to match against users" + }, + { + "name": "kind", + "type": "types.UserKind", + "required": false, + "title": "Kind (normal, bot)" + }, + { + "type": "bool", + "name": "incDeleted", + "required": false, + "title": "Include deleted users (requires 'access' permission)" + }, + { + "type": "bool", + "name": "incSuspended", + "required": false, + "title": "Include suspended users (requires 'access' permission)" + }, + { + "name": "sort", + "type": "string", + "required": false, + "title": "Sort by (createdAt, updatedAt, deletedAt, suspendedAt, email, username, userID)" + }, + { + "name": "page", + "type": "uint", + "required": false, + "title": "Page number (0 based)" + }, + { + "name": "perPage", + "type": "uint", + "required": false, + "title": "Returned items per page" } ] } diff --git a/api/system/spec/user.json b/api/system/spec/user.json index 98b0c6258..c9dee7109 100644 --- a/api/system/spec/user.json +++ b/api/system/spec/user.json @@ -40,6 +40,42 @@ "required": false, "title": "Search email to match against users", "type": "string" + }, + { + "name": "kind", + "required": false, + "title": "Kind (normal, bot)", + "type": "types.UserKind" + }, + { + "name": "incDeleted", + "required": false, + "title": "Include deleted users (requires 'access' permission)", + "type": "bool" + }, + { + "name": "incSuspended", + "required": false, + "title": "Include suspended users (requires 'access' permission)", + "type": "bool" + }, + { + "name": "sort", + "required": false, + "title": "Sort by (createdAt, updatedAt, deletedAt, suspendedAt, email, username, userID)", + "type": "string" + }, + { + "name": "page", + "required": false, + "title": "Page number (0 based)", + "type": "uint" + }, + { + "name": "perPage", + "required": false, + "title": "Returned items per page", + "type": "uint" } ] } diff --git a/docs/system/README.md b/docs/system/README.md index e4f57a836..9f8795cd3 100644 --- a/docs/system/README.md +++ b/docs/system/README.md @@ -750,6 +750,12 @@ An organisation may have many roles. Roles may have many channels available. Acc | query | string | GET | Search query to match against users | N/A | NO | | username | string | GET | Search username to match against users | N/A | NO | | email | string | GET | Search email to match against users | N/A | NO | +| kind | types.UserKind | GET | Kind (normal, bot) | N/A | NO | +| incDeleted | bool | GET | Include deleted users (requires 'access' permission) | N/A | NO | +| incSuspended | bool | GET | Include suspended users (requires 'access' permission) | N/A | NO | +| sort | string | GET | Sort by (createdAt, updatedAt, deletedAt, suspendedAt, email, username, userID) | N/A | NO | +| page | uint | GET | Page number (0 based) | N/A | NO | +| perPage | uint | GET | Returned items per page | N/A | NO | ## Create user diff --git a/messaging/rest/request/util.go b/messaging/rest/request/util.go index 83b2228a0..b5dd1757b 100644 --- a/messaging/rest/request/util.go +++ b/messaging/rest/request/util.go @@ -46,7 +46,7 @@ func parseUInt64(s string) uint64 { return i } -// parseUInt64 parses a string to uint64 +// parseUInt parses a string to uint64 func parseUint(s string) uint { if s == "" { return 0 diff --git a/system/commands/users.go b/system/commands/users.go index ae1c3f445..abdbedd12 100644 --- a/system/commands/users.go +++ b/system/commands/users.go @@ -32,11 +32,11 @@ func Users(ctx context.Context) *cobra.Command { ) userRepo := repository.User(ctx, db) - uf := &types.UserFilter{ - OrderBy: "updated_at", + uf := types.UserFilter{ + Sort: "updatedAt", } - users, err := userRepo.Find(uf) + users, _, err := userRepo.Find(uf) if err != nil { cli.HandleError(err) } diff --git a/system/internal/repository/application.go b/system/internal/repository/application.go index 95e5f1091..e94196e24 100644 --- a/system/internal/repository/application.go +++ b/system/internal/repository/application.go @@ -37,6 +37,7 @@ const ( ErrApplicationNotFound = repositoryError("ApplicationNotFound") ) +// @todo migrate to same pattern as we have for users func Application(ctx context.Context, db *factory.DB) ApplicationRepository { return (&application{}).With(ctx, db) } diff --git a/system/internal/repository/mocks/user.go b/system/internal/repository/mocks/user.go index 27b69376f..994c434d1 100644 --- a/system/internal/repository/mocks/user.go +++ b/system/internal/repository/mocks/user.go @@ -106,11 +106,12 @@ func (mr *MockUserRepositoryMockRecorder) FindByIDs(id ...interface{}) *gomock.C } // Find mocks base method -func (m *MockUserRepository) Find(filter *types.UserFilter) ([]*types.User, error) { +func (m *MockUserRepository) Find(filter types.UserFilter) (types.UserSet, types.UserFilter, error) { ret := m.ctrl.Call(m, "Find", filter) - ret0, _ := ret[0].([]*types.User) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret0, _ := ret[0].(types.UserSet) + ret1, _ := ret[1].(types.UserFilter) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // Find indicates an expected call of Find diff --git a/system/internal/repository/organisation.go b/system/internal/repository/organisation.go index de2bd6e98..93bc256d8 100644 --- a/system/internal/repository/organisation.go +++ b/system/internal/repository/organisation.go @@ -36,6 +36,7 @@ const ( ErrOrganisationNotFound = repositoryError("OrganisationNotFound") ) +// @todo migrate to same pattern as we have for users func Organisation(ctx context.Context, db *factory.DB) OrganisationRepository { return (&organisation{}).With(ctx, db) } diff --git a/system/internal/repository/repository.go b/system/internal/repository/repository.go index 64993a25f..420be2d12 100644 --- a/system/internal/repository/repository.go +++ b/system/internal/repository/repository.go @@ -4,6 +4,7 @@ import ( "context" "github.com/titpetric/factory" + squirrel "gopkg.in/Masterminds/squirrel.v1" "github.com/cortezaproject/corteza-server/internal/auth" ) @@ -45,3 +46,73 @@ func (r *repository) db() *factory.DB { } return DB(r.ctx) } + +func (r repository) fetchOne(one interface{}, q squirrel.SelectBuilder) (err error) { + var ( + sql string + args []interface{} + ) + + if sql, args, err = q.ToSql(); err != nil { + return + } + + if err = r.db().Get(one, sql, args...); err != nil { + return + } + + return +} + +// Fetches single row from table +func (r repository) fetchSet(set interface{}, q squirrel.SelectBuilder) (err error) { + var ( + sql string + args []interface{} + ) + + if sql, args, err = q.ToSql(); err != nil { + return + } + + if err = r.db().Select(set, sql, args...); err != nil { + return + } + + return +} + +// Fetches paged rows +func (r repository) fetchPaged(set interface{}, q squirrel.SelectBuilder, page, perPage uint) error { + if perPage > 0 { + q = q.Limit(uint64(perPage)) + } + + if page > 0 { + q = q.Offset(uint64(page * perPage)) + } + + if sqlSelect, argsSelect, err := q.ToSql(); err != nil { + return err + } else { + return r.db().Select(set, sqlSelect, argsSelect...) + } +} + +// Counts all rows that match conditions from given query builder +func (r repository) count(q squirrel.SelectBuilder) (uint, error) { + var ( + count uint + cq = q.Column("COUNT(*)") + ) + + if sqlSelect, argsSelect, err := cq.ToSql(); err != nil { + return 0, err + } else { + if err := r.db().Get(&count, sqlSelect, argsSelect...); err != nil { + return 0, err + } + } + + return count, nil +} diff --git a/system/internal/repository/role.go b/system/internal/repository/role.go index 8b7374249..562080711 100644 --- a/system/internal/repository/role.go +++ b/system/internal/repository/role.go @@ -47,6 +47,7 @@ const ( ErrRoleNotFound = repositoryError("RoleNotFound") ) +// @todo migrate to same pattern as we have for uselang/en.jsonrs func Role(ctx context.Context, db *factory.DB) RoleRepository { return (&role{}).With(ctx, db) } diff --git a/system/internal/repository/user.go b/system/internal/repository/user.go index cd8ba688b..b4dbec25d 100644 --- a/system/internal/repository/user.go +++ b/system/internal/repository/user.go @@ -2,12 +2,11 @@ package repository import ( "context" - "fmt" "io" "time" - "github.com/jmoiron/sqlx" "github.com/titpetric/factory" + "gopkg.in/Masterminds/squirrel.v1" "github.com/cortezaproject/corteza-server/system/types" ) @@ -20,7 +19,7 @@ type ( FindByUsername(username string) (*types.User, error) FindByID(id uint64) (*types.User, error) FindByIDs(id ...uint64) (types.UserSet, error) - Find(filter *types.UserFilter) ([]*types.User, error) + Find(filter types.UserFilter) (set types.UserSet, f types.UserFilter, err error) Total() uint Create(mod *types.User) (*types.User, error) @@ -35,19 +34,10 @@ type ( user struct { *repository - - // sql table reference - users string } ) const ( - sqlUserColumns = "id, email, username, name, handle, " + - "meta, rel_organisation, email_confirmed, " + - "created_at, updated_at, suspended_at, deleted_at" - sqlUserScope = "suspended_at IS NULL AND deleted_at IS NULL" - sqlUserSelect = "SELECT " + sqlUserColumns + " FROM %s WHERE " + sqlUserScope - ErrUserNotFound = repositoryError("UserNotFound") ) @@ -55,109 +45,149 @@ func User(ctx context.Context, db *factory.DB) UserRepository { return (&user{}).With(ctx, db) } +func (r user) table() string { + return "sys_user" +} + +func (r user) columns() []string { + return []string{ + "u.id", + "u.email", + "u.username", + "u.name", + "u.handle", + "u.meta", + "u.kind", + "u.rel_organisation", + "u.email_confirmed", + "u.created_at", + "u.updated_at", + "u.suspended_at", + "u.deleted_at", + } +} + +func (r user) query() squirrel.SelectBuilder { + return r.queryNoFilter().Where("u.deleted_at IS NULL AND u.suspended_at IS NULL") +} + +func (r user) queryNoFilter() squirrel.SelectBuilder { + return squirrel. + Select(). + From(r.table() + " AS u"). + Columns(r.columns()...) +} + func (r *user) With(ctx context.Context, db *factory.DB) UserRepository { return &user{ repository: r.repository.With(ctx, db), - users: "sys_user", } } -func (r *user) FindByUsername(username string) (*types.User, error) { - sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND username = ?" - mod := &types.User{} +func (r user) findBy(field string, value interface{}) (*types.User, error) { + var ( + query = r.query().Where("u."+field+" = ?", value) + u = &types.User{} + ) - return mod, isFound(r.db().Get(mod, sql, username), mod.ID > 0, ErrUserNotFound) + return u, isFound(r.fetchOne(u, query), u.ID > 0, ErrUserNotFound) } -func (r *user) FindByEmail(email string) (*types.User, error) { - sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND email = ?" - mod := &types.User{} - - return mod, isFound(r.db().Get(mod, sql, email), mod.ID > 0, ErrUserNotFound) +func (r user) FindByUsername(username string) (*types.User, error) { + return r.findBy("username", username) } -func (r *user) FindByID(id uint64) (*types.User, error) { - sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND id = ?" - mod := &types.User{} - if err := isFound(r.db().Get(mod, sql, id), mod.ID > 0, ErrUserNotFound); err != nil { - return nil, err - } - - return mod, nil +func (r user) FindByEmail(email string) (*types.User, error) { + return r.findBy("email", email) } -func (r *user) FindByIDs(IDs ...uint64) (uu types.UserSet, err error) { +func (r user) FindByID(id uint64) (*types.User, error) { + return r.findBy("id", id) +} + +func (r user) FindByIDs(IDs ...uint64) (types.UserSet, error) { if len(IDs) == 0 { - return + return nil, nil } - sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND id IN (?)" - - if sql, args, err := sqlx.In(sql, IDs); err != nil { - return nil, err - } else { - return uu, r.db().Select(&uu, sql, args...) - } + var ( + query = r.query().Where("u.id IN (?)", IDs) + uu = types.UserSet{} + ) + return uu, r.fetchSet(&uu, query) } -func (r *user) Find(filter *types.UserFilter) ([]*types.User, error) { - if filter == nil { - filter = &types.UserFilter{} +func (r user) Find(filter types.UserFilter) (set types.UserSet, f types.UserFilter, err error) { + f = filter + q := r.queryNoFilter() + + if !f.IncDeleted { + q = q.Where("u.deleted_at IS NULL") } - rval := make([]*types.User, 0) - params := make([]interface{}, 0) - sql := fmt.Sprintf(sqlUserSelect, r.users) - - if filter.Query != "" { - sql += " AND (username LIKE ?" - params = append(params, filter.Query+"%") - sql += " OR email LIKE ?" - params = append(params, filter.Query+"%") - sql += " OR name LIKE ?)" - params = append(params, filter.Query+"%") + if !f.IncSuspended { + q = q.Where("u.suspended_at IS NULL") } - if filter.Email != "" { - sql += " AND (email = ?)" - params = append(params, filter.Email) + if f.Query != "" { + qs := f.Query + "%" + q = q.Where("u.username LIKE ? OR u.email LIKE ? OR u.name LIKE ?", qs, qs, qs) } - if filter.Username != "" { - sql += " AND (username = ?)" - params = append(params, filter.Username) + if f.Email != "" { + q = q.Where("u.email = ?", f.Email) } - switch filter.OrderBy { - case "updated_at", "createdAt": - sql += " ORDER BY updated_at DESC" + if f.Username != "" { + q = q.Where("u.username = ?", f.Username) + } + + if f.Kind != "" { + q = q.Where("u.kind = ?", f.Kind) + } + + if f.Email != "" { + q = q.Where("u.email = ?", f.Email) + } + + // @todo add support for more sophisticated sorting through ql + // refactor github.com/cortezaproject/corteza-server/compose/internal/repository/ql + // for common use (out of compose pkg) + switch f.Sort { + case "createdAt": + q = q.OrderBy("created_at") + case "updatedAt": + q = q.OrderBy("updated_at") + case "deletedAt": + q = q.OrderBy("deleted_at") + case "suspendedAt": + q = q.OrderBy("suspended_at") + case "email", "username": + q = q.OrderBy(f.Sort) + case "userID": + q = q.OrderBy("id") default: - sql += " ORDER BY username ASC" + q = q.OrderBy("id") } - if err := r.db().Select(&rval, sql, params...); err != nil { - return nil, err - } - - return rval, nil + return set, f, r.fetchPaged(&set, q, f.Page, f.PerPage) } func (r user) Total() (count uint) { - query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", r.users, sqlUserScope) - _ = r.db().Get(&count, query) + count, _ = r.count(r.query()) return } func (r *user) Create(mod *types.User) (*types.User, error) { mod.ID = factory.Sonyflake.NextID() mod.CreatedAt = time.Now() - return mod, r.db().Insert(r.users, mod) + return mod, r.db().Insert(r.table(), mod) } func (r *user) Update(mod *types.User) (*types.User, error) { mod.UpdatedAt = timeNowPtr() - return mod, r.db().Replace(r.users, mod) + return mod, r.db().Replace(r.table(), mod) } func (r *user) BindAvatar(user *types.User, avatar io.Reader) (*types.User, error) { @@ -170,13 +200,13 @@ func (r *user) BindAvatar(user *types.User, avatar io.Reader) (*types.User, erro } func (r *user) SuspendByID(id uint64) error { - return r.updateColumnByID(r.users, "suspend_at", time.Now(), id) + return r.updateColumnByID(r.table(), "suspend_at", time.Now(), id) } func (r *user) UnsuspendByID(id uint64) error { - return r.updateColumnByID(r.users, "suspend_at", nil, id) + return r.updateColumnByID(r.table(), "suspend_at", nil, id) } func (r *user) DeleteByID(id uint64) error { - return r.updateColumnByID(r.users, "deleted_at", time.Now(), id) + return r.updateColumnByID(r.table(), "deleted_at", time.Now(), id) } diff --git a/system/internal/service/user.go b/system/internal/service/user.go index 794629032..82444ef03 100644 --- a/system/internal/service/user.go +++ b/system/internal/service/user.go @@ -28,6 +28,7 @@ type ( } userAccessController interface { + CanAccess(context.Context) bool CanCreateUser(context.Context) bool CanUpdateUser(context.Context, *types.User) bool CanDeleteUser(context.Context, *types.User) bool @@ -42,7 +43,7 @@ type ( FindByEmail(email string) (*types.User, error) FindByID(id uint64) (*types.User, error) FindByIDs(id ...uint64) (types.UserSet, error) - Find(filter *types.UserFilter) (types.UserSet, error) + Find(types.UserFilter) (types.UserSet, types.UserFilter, error) Create(input *types.User) (*types.User, error) Update(mod *types.User) (*types.User, error) @@ -103,8 +104,14 @@ func (svc user) FindByUsername(username string) (*types.User, error) { return svc.user.FindByUsername(username) } -func (svc user) Find(filter *types.UserFilter) (types.UserSet, error) { - return svc.user.Find(filter) +func (svc user) Find(f types.UserFilter) (types.UserSet, types.UserFilter, error) { + if f.IncDeleted || f.IncSuspended { + if !svc.ac.CanAccess(svc.ctx) { + return nil, f, ErrNoPermissions.withStack() + } + } + + return svc.user.Find(f) } func (svc user) Create(input *types.User) (out *types.User, err error) { diff --git a/system/rest/request/user.go b/system/rest/request/user.go index dc4efbc65..62a5af887 100644 --- a/system/rest/request/user.go +++ b/system/rest/request/user.go @@ -34,9 +34,15 @@ var _ = multipart.FileHeader{} // User list request parameters type UserList struct { - Query string - Username string - Email string + Query string + Username string + Email string + Kind types.UserKind + IncDeleted bool + IncSuspended bool + Sort string + Page uint + PerPage uint } func NewUserList() *UserList { @@ -49,6 +55,12 @@ func (r UserList) Auditable() map[string]interface{} { out["query"] = r.Query out["username"] = r.Username out["email"] = r.Email + out["kind"] = r.Kind + out["incDeleted"] = r.IncDeleted + out["incSuspended"] = r.IncSuspended + out["sort"] = r.Sort + out["page"] = r.Page + out["perPage"] = r.PerPage return out } @@ -89,6 +101,24 @@ func (r *UserList) Fill(req *http.Request) (err error) { if val, ok := get["email"]; ok { r.Email = val } + if val, ok := get["kind"]; ok { + r.Kind = types.UserKind(val) + } + if val, ok := get["incDeleted"]; ok { + r.IncDeleted = parseBool(val) + } + if val, ok := get["incSuspended"]; ok { + r.IncSuspended = parseBool(val) + } + if val, ok := get["sort"]; ok { + r.Sort = val + } + if val, ok := get["page"]; ok { + r.Page = parseUint(val) + } + if val, ok := get["perPage"]; ok { + r.PerPage = parseUint(val) + } return err } diff --git a/system/rest/request/util.go b/system/rest/request/util.go index d4c308d49..a139bec5d 100644 --- a/system/rest/request/util.go +++ b/system/rest/request/util.go @@ -28,6 +28,15 @@ func parseInt(s string) int { return i } +// parseUInt parses a string to uint64 +func parseUint(s string) uint { + if s == "" { + return 0 + } + i, _ := strconv.ParseUint(s, 10, 32) + return uint(i) +} + // parseInt64 parses a string to int64 func parseInt64(s string) int64 { if s == "" { diff --git a/system/rest/user.go b/system/rest/user.go index c7227d601..a974cade7 100644 --- a/system/rest/user.go +++ b/system/rest/user.go @@ -17,6 +17,11 @@ type ( User struct { user service.UserService } + + userSetPayload struct { + Filter types.UserFilter `json:"filter"` + Set types.UserSet `json:"set"` + } ) func (User) New() *User { @@ -25,50 +30,65 @@ func (User) New() *User { return ctrl } -// Searches the users table in the database to find users by matching (by-prefix) their.Username -func (ctrl *User) List(ctx context.Context, r *request.UserList) (interface{}, error) { - return ctrl.user.With(ctx).Find(&types.UserFilter{ - Query: r.Query, - Email: r.Email, - Username: r.Username, - }) +func (ctrl User) List(ctx context.Context, r *request.UserList) (interface{}, error) { + f := types.UserFilter{ + Query: r.Query, + Email: r.Email, + Username: r.Username, + Kind: r.Kind, + IncSuspended: r.IncSuspended, + IncDeleted: r.IncDeleted, + Page: r.Page, + PerPage: r.PerPage, + } + + set, filter, err := ctrl.user.With(ctx).Find(f) + return ctrl.makeFilterPayload(ctx, set, filter, err) } -func (ctrl *User) Create(ctx context.Context, r *request.UserCreate) (interface{}, error) { +func (ctrl User) Create(ctx context.Context, r *request.UserCreate) (interface{}, error) { user := &types.User{ Email: r.Email, Name: r.Name, Handle: r.Handle, - Kind: types.UserKind(r.Kind), + Kind: r.Kind, } return ctrl.user.With(ctx).Create(user) } -func (ctrl *User) Update(ctx context.Context, r *request.UserUpdate) (interface{}, error) { +func (ctrl User) Update(ctx context.Context, r *request.UserUpdate) (interface{}, error) { user := &types.User{ ID: r.UserID, Email: r.Email, Name: r.Name, Handle: r.Handle, - Kind: types.UserKind(r.Kind), + Kind: r.Kind, } return ctrl.user.With(ctx).Update(user) } -func (ctrl *User) Read(ctx context.Context, r *request.UserRead) (interface{}, error) { +func (ctrl User) Read(ctx context.Context, r *request.UserRead) (interface{}, error) { return ctrl.user.With(ctx).FindByID(r.UserID) } -func (ctrl *User) Delete(ctx context.Context, r *request.UserDelete) (interface{}, error) { +func (ctrl User) Delete(ctx context.Context, r *request.UserDelete) (interface{}, error) { return resputil.OK(), ctrl.user.With(ctx).Delete(r.UserID) } -func (ctrl *User) Suspend(ctx context.Context, r *request.UserSuspend) (interface{}, error) { +func (ctrl User) Suspend(ctx context.Context, r *request.UserSuspend) (interface{}, error) { return resputil.OK(), ctrl.user.With(ctx).Suspend(r.UserID) } -func (ctrl *User) Unsuspend(ctx context.Context, r *request.UserUnsuspend) (interface{}, error) { +func (ctrl User) Unsuspend(ctx context.Context, r *request.UserUnsuspend) (interface{}, error) { return resputil.OK(), ctrl.user.With(ctx).Unsuspend(r.UserID) } + +func (ctrl User) makeFilterPayload(ctx context.Context, uu types.UserSet, f types.UserFilter, err error) (*userSetPayload, error) { + if err != nil { + return nil, err + } + + return &userSetPayload{Filter: f, Set: uu}, nil +} diff --git a/system/types/user.go b/system/types/user.go index 5ac14e50f..732c29d86 100644 --- a/system/types/user.go +++ b/system/types/user.go @@ -43,10 +43,17 @@ type ( } UserFilter struct { - Query string - Email string - Username string - OrderBy string + Query string `json:"query"` + Email string `json:"email"` + Username string `json:"username"` + Kind UserKind `json:"kind"` + IncDeleted bool `json:"incDeleted"` + IncSuspended bool `json:"incSuspended"` + + Page uint `json:"page"` + PerPage uint `json:"perPage"` + Sort string `json:"sort"` + Count uint `json:"count"` } UserKind string