groupware: add contact sorting query parameter and fix default sorting (must use updated instead of name)

This commit is contained in:
Pascal Bleser
2026-03-19 12:09:56 +01:00
parent a4dac96fbe
commit 9f0cf5ef4d
6 changed files with 164 additions and 11 deletions

View File

@@ -8,6 +8,18 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
var (
/*
DefaultContactSort = []jmap.ContactCardComparator{
{Property: string(jscontact.ContactCardPropertyName) + "/surname", IsAscending: true},
{Property: string(jscontact.ContactCardPropertyName) + "/given", IsAscending: true},
}
*/
DefaultContactSort = []jmap.ContactCardComparator{
{Property: jscontact.ContactCardPropertyUpdated, IsAscending: true},
}
)
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
@@ -63,18 +75,19 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
if !ok {
return resp
}
accountIds := single(accountId)
l := req.logger.With()
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return errorResponse(single(accountId), err)
return errorResponseWithSessionState(accountIds, err, req.session.State)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return errorResponse(single(accountId), err)
return errorResponseWithSessionState(accountIds, err, req.session.State)
}
if ok {
l = l.Uint(QueryParamOffset, offset)
@@ -82,7 +95,7 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
if err != nil {
return errorResponse(single(accountId), err)
return errorResponseWithSessionState(accountIds, err, req.session.State)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
@@ -91,20 +104,23 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
filter := jmap.ContactCardFilterCondition{
InAddressBook: addressBookId,
}
sortBy := []jmap.ContactCardComparator{{
Property: jscontact.ContactCardPropertyName, IsAscending: true,
}}
var sortBy []jmap.ContactCardComparator
if sort, ok, resp := mapSort(accountIds, &req, DefaultContactSort, jscontact.ContactCardProperties, mapContactCardSort); !ok {
return resp
} else {
sortBy = sort
}
logger := log.From(l)
contactsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryContactCards(single(accountId), req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
contactsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryContactCards(accountIds, req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
return req.errorResponseFromJmap(accountIds, jerr)
}
if contacts, ok := contactsByAccountId[accountId]; ok {
return etagResponse(single(accountId), contacts, sessionState, ContactResponseObjectType, state, lang)
return etagResponse(accountIds, contacts, sessionState, ContactResponseObjectType, state, lang)
} else {
return etagNotFoundResponse(single(accountId), sessionState, ContactResponseObjectType, state, lang)
return etagNotFoundResponse(accountIds, sessionState, ContactResponseObjectType, state, lang)
}
})
}
@@ -207,3 +223,7 @@ func (g *Groupware) DeleteContact(w http.ResponseWriter, r *http.Request) {
return noContentResponseWithEtag(single(accountId), sessionState, ContactResponseObjectType, state)
})
}
func mapContactCardSort(s SortCrit) jmap.ContactCardComparator {
return jmap.ContactCardComparator{Property: s.Attribute, IsAscending: s.Ascending}
}

View File

@@ -201,6 +201,8 @@ const (
ErrorCodeFailedToDeleteContact = "DELCNT"
ErrorCodeNoMailboxWithDraftRole = "NMBXDR"
ErrorCodeNoMailboxWithSentRole = "NMBXSE"
ErrorCodeInvalidSortSpecification = "INVSSP"
ErrorCodeInvalidSortProperty = "INVSPR"
)
var (
@@ -456,6 +458,18 @@ var (
Title: "Failed to find a Mailbox with the sent role",
Detail: "We could not find a Mailbox that has the sent role to store a sent email in.",
}
ErrorInvalidSortSpecification = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidSortSpecification,
Title: "Invalid sort specification",
Detail: "The sort specification in the query parameter does not comply with the expected format.",
}
ErrorInvalidSortProperty = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidSortProperty,
Title: "Invalid sort property",
Detail: "The sort property in the query parameter does not exist or is not acceptable.",
}
)
type ErrorOpt interface {
@@ -638,5 +652,5 @@ func errorResponses(errors ...Error) ErrorResponse {
}
func (r *Request) errorResponseFromJmap(accountIds []string, err jmap.Error) Response {
return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(err)))
return errorResponseWithSessionState(accountIds, r.apiErrorFromJmap(r.observeJmapError(err)), r.session.State)
}

View File

@@ -28,6 +28,8 @@ import (
const (
// TODO remove this once Stalwart has actual support for Tasks and we don't need to mock it any more
IgnoreSessionCapabilityChecksForTasks = true
MaxSortParams = 16
)
// using a wrapper class for requests, to group multiple parameters, really to avoid crowding the
@@ -519,3 +521,55 @@ func (r *Request) needContactWithAccount() (bool, string, Response) {
}
return true, accountId, Response{}
}
type SortCrit struct {
Attribute string
Ascending bool
}
func (r *Request) parseSort(s string, props []string) ([]SortCrit, *Error) {
parts := strings.SplitN(s, ",", MaxSortParams)
result := []SortCrit{}
for _, part := range parts {
name := strings.TrimSpace(part)
if name == "" {
continue
}
asc := true
i := strings.LastIndex(name, ":")
if i == 0 {
// invalid spec, e.g. ':asc'
return nil, r.apiError(&ErrorInvalidSortProperty)
} else if i > 0 {
order := name[i+1:]
name = name[0:i]
switch order {
case "", "asc":
asc = true
case "desc":
asc = false
default:
return nil, r.apiError(&ErrorInvalidSortSpecification)
}
}
if len(props) > 0 && !slices.Contains(props, name) {
return nil, r.apiError(&ErrorInvalidSortProperty)
} else {
result = append(result, SortCrit{Attribute: name, Ascending: asc})
}
}
return result, nil
}
func mapSort[T any](accountIds []string, req *Request, defaultSort []T, props []string, mapper func(SortCrit) T) ([]T, bool, Response) {
if sortSpec, ok := req.getStringParam(QueryParamSort, ""); ok && strings.TrimSpace(sortSpec) != "" {
if sort, err := req.parseSort(sortSpec, props); err != nil {
return nil, false, errorResponseWithSessionState(accountIds, err, req.session.State)
} else {
return structs.Map(sort, mapper), true, Response{}
}
} else {
return defaultSort, true, Response{}
}
}

View File

@@ -0,0 +1,64 @@
package groupware
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseSort(t *testing.T) {
req := Request{
r: &http.Request{},
ctx: context.Background(),
}
require := require.New(t)
{
res, err := req.parseSort("name", []string{"name", "time"})
require.Nil(err)
require.Len(res, 1)
require.Equal("name", res[0].Attribute)
require.True(res[0].Ascending)
}
{
res, err := req.parseSort("name:asc", []string{"name"})
require.Nil(err)
require.Len(res, 1)
require.Equal("name", res[0].Attribute)
require.True(res[0].Ascending)
}
{
res, err := req.parseSort("name:desc", []string{"name"})
require.Nil(err)
require.Len(res, 1)
require.Equal("name", res[0].Attribute)
require.False(res[0].Ascending)
}
{
res, err := req.parseSort("name:", []string{"name"})
require.Nil(err)
require.Len(res, 1)
require.Equal("name", res[0].Attribute)
require.True(res[0].Ascending)
}
{
_, err := req.parseSort("name:xyz", []string{"name"})
require.NotNil(err)
require.Equal(ErrorCodeInvalidSortSpecification, err.Code)
}
{
_, err := req.parseSort("age", []string{"name"})
require.NotNil(err)
require.Equal(ErrorCodeInvalidSortProperty, err.Code)
}
{
res, err := req.parseSort("name:asc,updated:desc", []string{"name", "updated"})
require.Nil(err)
require.Len(res, 2)
require.Equal("name", res[0].Attribute)
require.True(res[0].Ascending)
require.Equal("updated", res[1].Attribute)
require.False(res[1].Ascending)
}
}

View File

@@ -58,6 +58,7 @@ const (
QueryParamSeen = "seen"
QueryParamUndesirable = "undesirable"
QueryParamMarkAsSeen = "markAsSeen"
QueryParamSort = "sort"
HeaderParamSince = "if-none-match"
)