groupware: refactoring for pagination and support for multiple query suppliers

* refactor APIs in JMAP and Groupware in order to implement pagination
   across multiple accountIds and multiple suppliers (currently
   implemented using a mock supplier for contacts)

 * requires go 1.26 due to use of self-reflecting generics type
   constraints

 * still missing: query criteria and sorting parameters

 * still missing: multi-accountId support for emails

 * errors are now all just 'error' in the APIs, instead of the
   specialized implementations, and are interpreted dynamically where
   necessary in order to transform them into HTTP responses

 * remove position, anchor, anchorOffset as individual query parameters
   as we now only support a 'next=...' token for subsequent pages
   (except in emails for now), and use jmap.QueryParams instead; those
   tokens have a header character for the format, followed by a JSON
   encoded QueryParams map, all wrapped into base62 to make it clearer
   that it is meant to be an opaque token, and not a parameter clients
   should tinker with or construct themselves

 * introduce QueryParamsSupplier as an interface to provide QueryParams
   for various scenarios (single supplier, multiple supplier, ...) per
   accountId

 * implement multi-supplier template methods slist and squery
This commit is contained in:
Pascal Bleser
2026-06-03 10:42:47 +02:00
parent 6660bd7749
commit b26c6cadce
51 changed files with 2812 additions and 681 deletions

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/opencloud-eu/opencloud
go 1.25.8
go 1.26.0
require (
dario.cat/mergo v1.0.2

View File

@@ -6,6 +6,7 @@ import (
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
type Context struct {
@@ -76,13 +77,52 @@ type Result[T any] struct {
Language Language
}
func RefineResult[A, B any](a Result[A], refiner func(A) B) Result[B] {
return newResult(
refiner(a.Payload),
a.SessionState,
a.State,
a.Language,
)
func RefineResultPayload[A, B any](a Result[A], refiner func(A) (B, bool, error)) (Result[B], error) {
if payloads, ok, err := refiner(a.Payload); err != nil {
return ZeroResult[B](), err
} else if ok {
return newResult(payloads, a.SessionState, a.State, a.Language), nil
} else {
return ZeroResult[B](), nil
}
}
func RefineResult[A, B any](a Result[A], refiner func(A, SessionState, State, Language) (B, SessionState, State, Language)) Result[B] {
b, bss, bs, bl := refiner(a.Payload, a.SessionState, a.State, a.Language)
return newResult(b, bss, bs, bl)
}
func RefineResultSlice[A, B any](a []*Result[A], refiner func([]*A, []*SessionState, []*State, []*Language) (B, SessionState, State, Language, error)) (Result[B], error) {
payloads := structs.Map(a, func(e *Result[A]) *A {
if e != nil {
return &e.Payload
} else {
return nil
}
})
sessionStates := structs.Map(a, func(e *Result[A]) *SessionState {
if e != nil {
return &e.SessionState
} else {
return nil
}
})
states := structs.Map(a, func(e *Result[A]) *State {
if e != nil {
return &e.State
} else {
return nil
}
})
languages := structs.Map(a, func(e *Result[A]) *Language {
if e != nil {
return &e.Language
} else {
return nil
}
})
b, bss, bs, bl, err := refiner(payloads, sessionStates, states, languages)
return newResult(b, bss, bs, bl), err
}
func (r Result[T]) GetSessionState() SessionState {

View File

@@ -2,7 +2,7 @@ package jmap
var NS_ADDRESSBOOKS = ns(JmapContacts)
func (j *Client) GetAddressbooks(accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], Error) {
func (j *Client) GetAddressbooks(accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
return get(j, "GetAddressbooks", MailboxType,
func(accountId string, ids []string) AddressBookGetCommand {
return AddressBookGetCommand{AccountId: accountId, Ids: ids}
@@ -27,7 +27,7 @@ func (c AddressBookChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve Address Book changes since a given state.
// @apidoc addressbook,changes
func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[AddressBookChanges], Error) {
func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[AddressBookChanges], error) {
return changesA(j, "GetAddressbookChanges", MailboxType,
func() AddressBookChangesCommand {
return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -58,7 +58,7 @@ func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxCh
)
}
func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChange, ctx Context) (Result[*AddressBook], Error) {
func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChange, ctx Context) (Result[*AddressBook], error) {
return create(j, "CreateAddressBook", MailboxType,
func(accountId string, create map[string]AddressBookChange) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Create: create}
@@ -77,7 +77,7 @@ func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChan
)
}
func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteAddressBook", MailboxType,
func(accountId string, destroy []string) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Destroy: destroy}
@@ -88,7 +88,7 @@ func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Co
)
}
func (j *Client) UpdateAddressBook(accountId string, id string, changes AddressBookChange, ctx Context) (Result[AddressBook], Error) {
func (j *Client) UpdateAddressBook(accountId string, id string, changes AddressBookChange, ctx Context) (Result[AddressBook], error) {
return update(j, "UpdateAddressBook", MailboxType,
func(update map[string]PatchObject) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Update: update}

View File

@@ -10,7 +10,7 @@ import (
var NS_BLOB = ns(JmapBlob)
func (j *Client) GetBlobMetadata(accountId string, ids []string, ctx Context) (Result[BlobGetResponse], Error) {
func (j *Client) GetBlobMetadata(accountId string, ids []string, ctx Context) (Result[BlobGetResponse], error) {
get := BlobGetCommand{
AccountId: accountId,
Ids: ids,
@@ -41,7 +41,7 @@ type UploadedBlobWithHash struct {
Sha512 string `json:"sha:512,omitempty"`
}
func (j *Client) UploadBlobStream(accountId string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, Error) {
func (j *Client) UploadBlobStream(accountId string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, error) {
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.UploadEndpoint))
ctx = ctx.WithLogger(logger)
// TODO(pbleser-oc) use a library for proper URL template parsing
@@ -49,7 +49,7 @@ func (j *Client) UploadBlobStream(accountId string, contentType string, body io.
return j.blob.UploadBinary(uploadUrl, ctx.Session.UploadEndpoint, contentType, body, ctx)
}
func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, Error) { //NOSONAR
func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, error) { //NOSONAR
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.DownloadEndpoint))
ctx = ctx.WithLogger(logger)
// TODO(pbleser-oc) use a library for proper URL template parsing
@@ -62,7 +62,7 @@ func (j *Client) DownloadBlobStream(accountId string, blobId string, name string
return j.blob.DownloadBinary(downloadUrl, ctx.Session.DownloadEndpoint, ctx)
}
func (j *Client) UploadBlob(accountId string, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], Error) {
func (j *Client) UploadBlob(accountId string, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{

View File

@@ -11,7 +11,7 @@ type AccountBootstrapResult struct {
var NS_MAIL_QUOTA = ns(JmapMail, JmapQuota)
func (j *Client) GetBootstrap(accountIds []string, ctx Context) (Result[map[string]AccountBootstrapResult], Error) { //NOSONAR
func (j *Client) GetBootstrap(accountIds []string, ctx Context) (Result[map[string]AccountBootstrapResult], error) { //NOSONAR
uniqueAccountIds := structs.Uniq(accountIds)
logger := j.logger("GetBootstrap", ctx)

View File

@@ -2,7 +2,7 @@ package jmap
var NS_CALENDARS = ns(JmapCalendars)
func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Context) (Result[CalendarEventParseResponse], Error) {
func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Context) (Result[CalendarEventParseResponse], error) {
logger := j.logger("ParseICalendarBlob", ctx)
parse := CalendarEventParseCommand{AccountId: accountId, BlobIds: blobIds}
@@ -23,7 +23,7 @@ func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Cont
})
}
func (j *Client) GetCalendars(accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], Error) {
func (j *Client) GetCalendars(accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
return get(j, "GetCalendars", CalendarType,
func(accountId string, ids []string) CalendarGetCommand {
return CalendarGetCommand{AccountId: accountId, Ids: ids}
@@ -48,7 +48,7 @@ func (c CalendarChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve Calendar changes since a given state.
// @apidoc calendar,changes
func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[CalendarChanges], Error) {
func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[CalendarChanges], error) {
return changes(j, "GetCalendarChanges", CalendarType,
func() CalendarChangesCommand {
return CalendarChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -79,7 +79,7 @@ func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChang
)
}
func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx Context) (Result[*Calendar], Error) {
func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx Context) (Result[*Calendar], error) {
return create(j, "CreateCalendar", CalendarEventType,
func(accountId string, create map[string]CalendarChange) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Create: create}
@@ -98,7 +98,7 @@ func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx C
)
}
func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteCalendar", CalendarEventType,
func(accountId string, destroy []string) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Destroy: destroy}
@@ -109,7 +109,7 @@ func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Conte
)
}
func (j *Client) UpdateCalendar(accountId string, id string, changes CalendarChange, ctx Context) (Result[Calendar], Error) {
func (j *Client) UpdateCalendar(accountId string, id string, changes CalendarChange, ctx Context) (Result[Calendar], error) {
return update(j, "UpdateCalendar", CalendarEventType,
func(update map[string]PatchObject) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Update: update}

View File

@@ -74,7 +74,7 @@ func (s StateMap) MarshalZerologObject(e *zerolog.Event) {
// Retrieve the changes in any type of objects at once since a given State.
// @api:tags changes
func (j *Client) GetChanges(accountId string, stateMap StateMap, maxChanges uint, ctx Context) (Result[ObjectChanges], Error) { //NOSONAR
func (j *Client) GetChanges(accountId string, stateMap StateMap, maxChanges uint, ctx Context) (Result[ObjectChanges], error) { //NOSONAR
logger := log.From(j.logger("GetChanges", ctx).With().Object("state", stateMap).Uint("maxChanges", maxChanges))
ctx = ctx.WithLogger(logger)

View File

@@ -6,7 +6,7 @@ var NS_CONTACTS = ns(JmapContacts)
var DEFAULT_CONTACT_CARD_VERSION = jscontact.JSContactVersion_1_0
func (j *Client) GetContactCards(accountId string, contactIds []string, ctx Context) (Result[ContactCardGetResponse], Error) {
func (j *Client) GetContactCards(accountId string, contactIds []string, ctx Context) (Result[ContactCardGetResponse], error) {
return get(j, "GetContactCards", ContactCardType,
func(accountId string, ids []string) ContactCardGetCommand {
return ContactCardGetCommand{AccountId: accountId, Ids: contactIds}
@@ -31,7 +31,7 @@ func (c ContactCardChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Contact Cards since a given State.
// @api:tags contact,changes
func (j *Client) GetContactCardChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[ContactCardChanges], Error) {
func (j *Client) GetContactCardChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[ContactCardChanges], error) {
return changes(j, "GetContactCardChanges", ContactCardType,
func() ContactCardChangesCommand {
return ContactCardChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -77,34 +77,46 @@ func (r *ContactCardSearchResults) RemoveResults() { r.Results = nil
func (r *ContactCardSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *ContactCardSearchResults) SetPosition(position *uint) { r.Position = position }
func (j *Client) QueryContactCards(accountIds []string, //NOSONAR
filter ContactCardFilterElement, sortBy []ContactCardComparator,
position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (Result[map[string]*ContactCardSearchResults], Error) {
func (j *Client) QueryContactCards(accountIds map[string]QueryParams, //NOSONAR
limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator, calculateTotal bool,
ctx Context) (Result[map[string]*ContactCardSearchResults], error) {
return queryN(j, "QueryContactCards", ContactCardType,
[]ContactCardComparator{{Property: ContactCardPropertyUpdated, IsAscending: false}},
func(accountId string, filter ContactCardFilterElement, sortBy []ContactCardComparator, position int, anchor string, anchorOffset *int, limit *uint) ContactCardQueryCommand {
return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
func(accountId string, qp QueryParams, limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator) ContactCardQueryCommand {
if qp.Anchor != "" {
return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Anchor: qp.Anchor, AnchorOffset: qp.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
} else {
return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: qp.Position, Limit: limit, CalculateTotal: calculateTotal}
}
},
func(accountId string, cmd Command, path string, rof string) ContactCardGetRefCommand {
return ContactCardGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
},
func(query ContactCardQueryResponse, get ContactCardGetResponse) *ContactCardSearchResults {
func(query ContactCardQueryResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
return &ContactCardSearchResults{
Results: []ContactCard{},
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
Limit: valueIf(query.Limit, limit != nil),
}
},
func(query ContactCardQueryResponse, get ContactCardGetResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
return &ContactCardSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: ptrIf(query.Position, anchor == ""),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
Limit: ptrIf(query.Limit, limit != nil),
Limit: valueIf(query.Limit, limit != nil),
}
},
accountIds,
filter, sortBy, position, anchor, anchorOffset, limit, ctx,
accountIds, limit,
filter, sortBy, ctx,
)
}
// @api:example create
func (j *Client) CreateContactCard(accountId string, contact ContactCardChange, ctx Context) (Result[*ContactCard], Error) {
func (j *Client) CreateContactCard(accountId string, contact ContactCardChange, ctx Context) (Result[*ContactCard], error) {
if contact.Version == nil {
contact.Version = &DEFAULT_CONTACT_CARD_VERSION
}
@@ -126,7 +138,7 @@ func (j *Client) CreateContactCard(accountId string, contact ContactCardChange,
)
}
func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteContactCard", ContactCardType,
func(accountId string, destroy []string) ContactCardSetCommand {
return ContactCardSetCommand{AccountId: accountId, Destroy: destroy}
@@ -138,7 +150,7 @@ func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Co
}
// @api:example update
func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (Result[ContactCard], Error) {
func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (Result[ContactCard], error) {
return update(j, "UpdateContactCard", ContactCardType,
func(update map[string]PatchObject) ContactCardSetCommand {
return ContactCardSetCommand{AccountId: accountId, Update: update}

View File

@@ -16,7 +16,7 @@ var NS_MAIL_SUBMISSION = ns(JmapMail, JmapSubmission)
// Retrieve specific Emails by their id.
func (j *Client) GetEmails(accountId string, ids []string, //NOSONAR
fetchBodies bool, maxBodyValueBytes uint, markAsSeen bool, withThreads bool,
ctx Context) (Result[EmailGetResponse], Error) {
ctx Context) (Result[EmailGetResponse], error) {
logger := j.logger("GetEmails", ctx)
ctx = ctx.WithLogger(logger)
@@ -82,7 +82,7 @@ func (j *Client) GetEmails(accountId string, ids []string, //NOSONAR
})
}
func (j *Client) GetEmailBlobId(accountId string, id string, ctx Context) (Result[string], Error) {
func (j *Client) GetEmailBlobId(accountId string, id string, ctx Context) (Result[string], error) {
logger := j.logger("GetEmailBlobId", ctx)
ctx = ctx.WithLogger(logger)
@@ -120,10 +120,10 @@ func (r *EmailSearchResults) SetPosition(position *uint) { r.Posi
// Retrieve all the Emails in a given Mailbox by its id.
func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOSONAR
position int, anchor string, anchorOffset *int, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
ctx Context) (Result[*EmailSearchResults], Error) {
qp QueryParams, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
ctx Context) (Result[*EmailSearchResults], error) {
logger := j.loggerParams("GetAllEmailsInMailbox", ctx, func(z zerolog.Context) zerolog.Context {
l := z.Bool(logFetchBodies, fetchBodies).Int(logPosition, position)
l := z.Bool(logFetchBodies, fetchBodies)
if limit != nil {
l = l.Uint(logLimit, *limit)
}
@@ -137,9 +137,9 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: collapseThreads,
CalculateTotal: true,
Position: position,
Anchor: anchor,
AnchorOffset: anchorOffset,
Position: qp.Position,
Anchor: qp.Anchor,
AnchorOffset: qp.AnchorOffset,
Limit: limit,
}
@@ -176,7 +176,7 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
}
return command(j, ctx, cmd, func(body *Response) (*EmailSearchResults, State, Error) {
var queryResponse EmailQueryResponse
var queryResponse *EmailQueryResponse
err = retrieveQuery(ctx, body, query, "0", &queryResponse)
if err != nil {
return nil, "", err
@@ -200,8 +200,8 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
return &EmailSearchResults{
Results: getResponse.List,
CanCalculateChanges: ChangeCalculation(queryResponse.CanCalculateChanges),
Position: ptrIf(queryResponse.Position, anchor == ""),
Limit: ptrIf(queryResponse.Limit, limit != nil),
Position: valueIf(queryResponse.Position, qp.Anchor == ""),
Limit: valueIf(queryResponse.Limit, limit != nil),
Total: uintPtr(queryResponse.Total),
}, queryResponse.QueryState, nil
})
@@ -222,7 +222,7 @@ func (c EmailChanges) GetDestroyed() []string { return c.Destroyed }
// @api:tags email,changes
func (j *Client) GetEmailChanges(accountId string,
sinceState State, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint,
ctx Context) (Result[EmailChanges], Error) { //NOSONAR
ctx Context) (Result[EmailChanges], error) { //NOSONAR
logger := j.loggerParams("GetEmailChanges", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, string(sinceState))
})
@@ -304,7 +304,7 @@ type EmailSnippetSearchResults SearchResultsTemplate[SearchSnippetWithMeta]
func (j *Client) QueryEmailSnippets(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint,
ctx Context) (Result[map[string]EmailSnippetSearchResults], Error) {
ctx Context) (Result[map[string]EmailSnippetSearchResults], error) {
logger := j.loggerParams("QueryEmailSnippets", ctx, func(z zerolog.Context) zerolog.Context {
l := z.Int(logPosition, position)
if limit != nil {
@@ -410,14 +410,15 @@ func (j *Client) QueryEmailSnippets(accountIds []string, //NOSONAR
Results: snippets,
CanCalculateChanges: ChangeCalculation(queryResponse.CanCalculateChanges),
Total: uintPtr(queryResponse.Total),
Limit: ptrIf(queryResponse.Limit, limit != nil),
Position: ptrIf(queryResponse.Position, anchor == ""),
Limit: valueIf(queryResponse.Limit, limit != nil),
Position: valueIf(queryResponse.Position, anchor == ""),
}
}
return results, squashState(states), nil
})
}
/*
type EmailQueryResult struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
@@ -425,10 +426,11 @@ type EmailQueryResult struct {
Position uint `json:"position,omitzero"`
QueryState State `json:"queryState"`
}
*/
func (j *Client) QueryEmails(accountIds []string,
filter EmailFilterElement, position int, limit uint, fetchBodies bool, maxBodyValueBytes uint,
ctx Context) (Result[map[string]EmailQueryResult], Error) { //NOSONAR
ctx Context) (Result[map[string]EmailSearchResults], error) { //NOSONAR
logger := j.loggerParams("QueryEmails", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
@@ -468,11 +470,12 @@ func (j *Client) QueryEmails(accountIds []string,
cmd, err := j.request(ctx, NS_MAIL, invocations...)
if err != nil {
return ZeroResult[map[string]EmailQueryResult](), err
return ZeroResult[map[string]EmailSearchResults](), err
}
return command(j, ctx, cmd, func(body *Response) (map[string]EmailQueryResult, State, Error) {
results := make(map[string]EmailQueryResult, len(uniqueAccountIds))
return command(j, ctx, cmd, func(body *Response) (map[string]EmailSearchResults, State, Error) {
results := make(map[string]EmailSearchResults, len(uniqueAccountIds))
queryStates := map[string]State{}
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(ctx, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
@@ -486,15 +489,16 @@ func (j *Client) QueryEmails(accountIds []string,
return nil, "", err
}
results[accountId] = EmailQueryResult{
Emails: emailsResponse.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: queryResponse.Position,
QueryState: queryResponse.QueryState,
results[accountId] = EmailSearchResults{
Results: emailsResponse.List,
Total: &queryResponse.Total, // no need to valufIf() here, we always request with calculateTotals==true
Limit: queryResponse.Limit,
Position: queryResponse.Position,
CanCalculateChanges: queryResponse.CanCalculateChanges,
}
queryStates[accountId] = queryResponse.QueryState
}
return results, squashStateFunc(results, func(r EmailQueryResult) State { return r.QueryState }), nil
return results, squashState(queryStates), nil
})
}
@@ -507,13 +511,13 @@ type EmailQueryWithSnippetsResult struct {
Results []EmailWithSnippets `json:"results"`
Total uint `json:"total"`
Position *uint `json:"position,omitempty"`
Limit uint `json:"limit"`
Limit *uint `json:"limit,omitempty"`
QueryState State `json:"queryState"`
}
func (j *Client) QueryEmailsWithSnippets(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint, collapseThreads bool, calculateTotal bool, fetchBodies bool, maxBodyValueBytes uint,
ctx Context) (Result[map[string]EmailQueryWithSnippetsResult], Error) {
ctx Context) (Result[map[string]EmailQueryWithSnippetsResult], error) {
logger := j.loggerParams("QueryEmailsWithSnippets", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
@@ -610,7 +614,7 @@ func (j *Client) QueryEmailsWithSnippets(accountIds []string, //NOSONAR
Results: results,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: ptrIf(queryResponse.Position, anchor == ""),
Position: valueIf(queryResponse.Position, anchor == ""),
QueryState: queryResponse.QueryState,
}
}
@@ -625,7 +629,7 @@ type UploadedEmail struct {
Sha512 string `json:"sha:512"`
}
func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result[UploadedEmail], Error) {
func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result[UploadedEmail], error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
@@ -698,7 +702,7 @@ func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result
}
func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId string, ctx Context) (Result[*Email], Error) {
func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId string, ctx Context) (Result[*Email], error) {
set := EmailSetCommand{
AccountId: accountId,
Create: map[string]EmailChange{
@@ -753,7 +757,7 @@ func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId stri
// To create drafts, use the CreateEmail function instead.
//
// To delete mails, use the DeleteEmails function instead.
func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject, ctx Context) (Result[map[string]*Email], Error) {
func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject, ctx Context) (Result[map[string]*Email], error) {
set := EmailSetCommand{
AccountId: accountId,
Update: updates,
@@ -779,7 +783,7 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject,
})
}
func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, ctx Context) (Result[Email], Error) {
func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, ctx Context) (Result[Email], error) {
return update(j, "UpdateEmail", EmailType,
func(update map[string]PatchObject) EmailSetCommand {
return EmailSetCommand{AccountId: accountId, Update: update}
@@ -794,7 +798,7 @@ func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, c
)
}
func (j *Client) DeleteEmails(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteEmails(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteEmails", EmailType,
func(accountId string, destroy []string) EmailSetCommand {
return EmailSetCommand{AccountId: accountId, Destroy: destroy}
@@ -835,7 +839,7 @@ type MoveMail struct {
}
func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, move *MoveMail, //NOSONAR
ctx Context) (Result[EmailSubmission], Error) {
ctx Context) (Result[EmailSubmission], error) {
logger := j.logger("SubmitEmail", ctx)
ctx = ctx.WithLogger(logger)
@@ -922,7 +926,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
})
}
func (j *Client) GetEmailSubmissionStatus(accountId string, submissionIds []string, ctx Context) (Result[EmailSubmissionGetResponse], Error) {
func (j *Client) GetEmailSubmissionStatus(accountId string, submissionIds []string, ctx Context) (Result[EmailSubmissionGetResponse], error) {
logger := j.logger("GetEmailSubmissionStatus", ctx)
ctx = ctx.WithLogger(logger)
@@ -990,7 +994,7 @@ func (j *Client) EmailsInThread(accountId string, threadId string,
type EmailsSummary struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
Limit uint `json:"limit"`
Limit *uint `json:"limit,omitempty"`
Position *uint `json:"position,omitempty"`
State State `json:"state"`
}
@@ -1016,7 +1020,7 @@ var EmailSummaryProperties = []string{
func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint, withThreads bool, calculateTotal bool,
ctx Context) (Result[map[string]EmailsSummary], Error) {
ctx Context) (Result[map[string]EmailsSummary], error) {
logger := j.logger("QueryEmailSummaries", ctx)
ctx = ctx.WithLogger(logger)
@@ -1039,7 +1043,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
AnchorOffset: anchorOffset,
Limit: limit,
}
invocations[i*factor+0] = invocation(get, mcid(accountId, "0"))
invocations[i*factor+0] = invocation(&get, mcid(accountId, "0"))
invocations[i*factor+1] = invocation(EmailGetRefCommand{
AccountId: accountId,
@@ -1096,7 +1100,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
Emails: response.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: ptrIf(queryResponse.Position, anchor == ""),
Position: valueIf(queryResponse.Position, anchor == ""),
State: response.State,
}
}
@@ -1109,7 +1113,7 @@ type EmailSubmissionChanges = ChangesTemplate[EmailSubmission]
// Retrieve the changes in Email Submissions since a given State.
// @api:tags email,changes
func (j *Client) GetEmailSubmissionChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[EmailSubmissionChanges], Error) {
ctx Context) (Result[EmailSubmissionChanges], error) {
return changes(j, "GetEmailSubmissionChanges", EmailSubmissionType,
func() EmailSubmissionChangesCommand {
return EmailSubmissionChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}

View File

@@ -17,7 +17,7 @@ func (r *CalendarEventSearchResults) RemoveResults() { r.Results = n
func (r *CalendarEventSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *CalendarEventSearchResults) SetPosition(position *uint) { r.Position = position }
func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Context) (Result[CalendarEventGetResponse], Error) {
func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Context) (Result[CalendarEventGetResponse], error) {
return get(j, "GetCalendarEvents", CalendarEventType,
func(accountId string, ids []string) CalendarEventGetCommand {
return CalendarEventGetCommand{AccountId: accountId, Ids: eventIds}
@@ -29,29 +29,37 @@ func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Cont
)
}
func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (Result[map[string]*CalendarEventSearchResults], Error) {
func (j *Client) QueryCalendarEvents(accountIds map[string]QueryParams, limit *uint, //NOSONAR
filter CalendarEventFilterElement, sortBy []CalendarEventComparator, calculateTotal bool,
ctx Context) (Result[map[string]*CalendarEventSearchResults], error) {
return queryN(j, "QueryCalendarEvents", CalendarEventType,
[]CalendarEventComparator{{Property: CalendarEventPropertyStart, IsAscending: false}},
func(accountId string, filter CalendarEventFilterElement, sortBy []CalendarEventComparator, position int, anchor string, anchorOffset *int, limit *uint) CalendarEventQueryCommand {
return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
func(accountId string, queryParams QueryParams, limit *uint, filter CalendarEventFilterElement, sortBy []CalendarEventComparator) CalendarEventQueryCommand {
return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: queryParams.Position, Anchor: queryParams.Anchor, AnchorOffset: queryParams.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
func(accountId string, cmd Command, path string, rof string) CalendarEventGetRefCommand {
return CalendarEventGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
},
func(query CalendarEventQueryResponse, get CalendarEventGetResponse) *CalendarEventSearchResults {
func(query CalendarEventQueryResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
return &CalendarEventSearchResults{
Results: []CalendarEvent{},
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
Limit: valueIf(query.Limit, limit != nil),
}
},
func(query CalendarEventQueryResponse, get CalendarEventGetResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
return &CalendarEventSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: ptrIf(query.Position, anchor == ""),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
Limit: ptrIf(query.Limit, limit != nil),
Limit: valueIf(query.Limit, limit != nil),
}
},
accountIds,
filter, sortBy, position, anchor, anchorOffset, limit, ctx,
accountIds, limit,
filter, sortBy, ctx,
)
}
@@ -69,7 +77,7 @@ func (c CalendarEventChanges) GetDestroyed() []string { return c.Destroyed
// Retrieve the changes in Calendar Events since a given State.
// @api:tags event,changes
func (j *Client) GetCalendarEventChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[CalendarEventChanges], Error) {
ctx Context) (Result[CalendarEventChanges], error) {
return changes(j, "GetCalendarEventChanges", CalendarEventType,
func() CalendarEventChangesCommand {
return CalendarEventChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -100,7 +108,7 @@ func (j *Client) GetCalendarEventChanges(accountId string, sinceState State, max
)
}
func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange, ctx Context) (Result[*CalendarEvent], Error) {
func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange, ctx Context) (Result[*CalendarEvent], error) {
return create(j, "CreateCalendarEvent", CalendarEventType,
func(accountId string, create map[string]CalendarEventChange) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Create: create}
@@ -119,7 +127,7 @@ func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange
)
}
func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteCalendarEvent", CalendarEventType,
func(accountId string, destroy []string) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Destroy: destroy}
@@ -130,7 +138,7 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx
)
}
func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (Result[CalendarEvent], Error) {
func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (Result[CalendarEvent], error) {
return update(j, "UpdateCalendarEvent", CalendarEventType,
func(update map[string]PatchObject) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Update: update}

View File

@@ -8,7 +8,7 @@ import (
var NS_IDENTITY = ns(JmapMail)
func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Context) (Result[IdentityGetResponse], Error) {
func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Context) (Result[IdentityGetResponse], error) {
return get(j, "GetIdentities", IdentityType,
func(accountId string, ids []string) IdentityGetCommand {
return IdentityGetCommand{AccountId: accountId, Ids: ids}
@@ -20,7 +20,7 @@ func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Conte
)
}
func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, ctx Context) (Result[map[string][]Identity], Error) {
func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, ctx Context) (Result[map[string][]Identity], error) {
return getN(j, "GetIdentitiesForAllAccounts", IdentityType,
func(accountId string, ids []string) IdentityGetCommand {
return IdentityGetCommand{AccountId: accountId}
@@ -39,7 +39,7 @@ type IdentitiesAndMailboxesGetResponse struct {
Mailboxes []Mailbox `json:"mailboxes"`
}
func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, ctx Context) (Result[IdentitiesAndMailboxesGetResponse], Error) {
func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, ctx Context) (Result[IdentitiesAndMailboxesGetResponse], error) {
uniqueAccountIds := structs.Uniq(accountIds)
logger := j.logger("GetIdentitiesAndMailboxes", ctx)
@@ -85,7 +85,7 @@ func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds [
})
}
func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx Context) (Result[*Identity], Error) {
func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx Context) (Result[*Identity], error) {
return create(j, "CreateIdentity", IdentityType,
func(accountId string, create map[string]IdentityChange) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Create: create}
@@ -104,7 +104,7 @@ func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx C
)
}
func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityChange, ctx Context) (Result[Identity], Error) {
func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityChange, ctx Context) (Result[Identity], error) {
return update(j, "UpdateIdentity", IdentityType,
func(update map[string]PatchObject) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Update: update}
@@ -119,7 +119,7 @@ func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityCha
)
}
func (j *Client) DeleteIdentity(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteIdentity(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteIdentity", IdentityType,
func(accountId string, destroy []string) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Destroy: destroyIds}
@@ -144,7 +144,7 @@ func (c IdentityChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Email Identities since a given State.
// @api:tags email,changes
func (j *Client) GetIdentityChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[IdentityChanges], Error) {
ctx Context) (Result[IdentityChanges], error) {
return changes(j, "GetIdentityChanges", IdentityType,
func() IdentityChangesCommand {
return IdentityChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}

View File

@@ -8,7 +8,7 @@ import (
var NS_MAILBOX = ns(JmapMail)
func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result[MailboxGetResponse], Error) {
func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result[MailboxGetResponse], error) {
return get(j, "GetMailbox", MailboxType,
func(accountId string, ids []string) MailboxGetCommand {
return MailboxGetCommand{AccountId: accountId, Ids: ids}
@@ -20,7 +20,7 @@ func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result
)
}
func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[string][]Mailbox], Error) {
func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[string][]Mailbox], error) {
return getAN(j, "GetAllMailboxes", MailboxType,
func(accountId string, ids []string) MailboxGetCommand {
return MailboxGetCommand{AccountId: accountId}
@@ -32,7 +32,7 @@ func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[s
)
}
func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElement, ctx Context) (Result[map[string][]Mailbox], Error) {
func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElement, ctx Context) (Result[map[string][]Mailbox], error) {
logger := j.logger("SearchMailboxes", ctx)
ctx = ctx.WithLogger(logger)
@@ -40,7 +40,7 @@ func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElemen
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
invocations[i*2+0] = invocation(MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
invocations[i*2+0] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
invocations[i*2+1] = invocation(MailboxGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
@@ -72,7 +72,7 @@ func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElemen
})
}
func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ctx Context) (Result[map[string]map[string]string], Error) { //NOSONAR
func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ctx Context) (Result[map[string]map[string]string], error) { //NOSONAR
logger := j.logger("SearchMailboxIdsPerRole", ctx)
ctx = ctx.WithLogger(logger)
@@ -81,7 +81,7 @@ func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ct
invocations := make([]Invocation, len(uniqueAccountIds)*len(roles))
for i, accountId := range uniqueAccountIds {
for j, role := range roles {
invocations[i*len(roles)+j] = invocation(MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
invocations[i*len(roles)+j] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
}
}
cmd, err := j.request(ctx, NS_MAILBOX, invocations...)
@@ -137,8 +137,7 @@ func newMailboxChanges(oldState, newState State, hasMoreChanges bool, created, u
// Retrieve Mailbox changes since a given state.
// @apidoc mailboxes,changes
func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[MailboxChanges], Error) {
func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[MailboxChanges], error) {
return changesA(j, "GetMailboxChanges", MailboxType,
func() MailboxChangesCommand {
return MailboxChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -164,7 +163,7 @@ func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChange
// @api:tags email,changes
func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, //NOSONAR
sinceStateMap map[string]State, maxChanges uint,
ctx Context) (Result[map[string]MailboxChanges], Error) {
ctx Context) (Result[map[string]MailboxChanges], error) {
return changesN(j, "GetMailboxChangesForMultipleAccounts", MailboxType,
accountIds, sinceStateMap,
func(accountId string, state State) MailboxChangesCommand {
@@ -182,26 +181,29 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, //NOS
)
}
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]*[]string], Error) {
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]*[]string], error) {
return queryN(j, "GetMailboxRolesForMultipleAccounts", MailboxType,
[]MailboxComparator{{Property: MailboxPropertySortOrder, IsAscending: true}},
func(accountId string, filter MailboxFilterCondition, sortBy []MailboxComparator, _ int, _ string, _ *int, _ *uint) MailboxQueryCommand {
func(accountId string, _ QueryParams, _ *uint, filter MailboxFilterCondition, sortBy []MailboxComparator) MailboxQueryCommand {
return MailboxQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, SortAsTree: false, FilterAsTree: false, Position: 0, Anchor: "", AnchorOffset: nil, Limit: nil, CalculateTotal: false}
},
func(accountId string, cmd Command, path, rof string) MailboxGetRefCommand {
return MailboxGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
},
func(_ MailboxQueryResponse, get MailboxGetResponse) *[]string {
func(_ MailboxQueryResponse, _ QueryParams, _ *uint) *[]string {
return nil // TODO this should never be called
},
func(_ MailboxQueryResponse, get MailboxGetResponse, _ QueryParams, _ *uint) *[]string {
roles := structs.Map(get.List, func(m Mailbox) string { return m.Role })
slices.Sort(roles)
return &roles
},
accountIds, MailboxFilterCondition{HasAnyRole: truep}, nil, 0, "", nil, nil,
toNullQueryParams(accountIds), nil, MailboxFilterCondition{HasAnyRole: truep}, nil,
ctx,
)
}
func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]string], Error) {
func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]string], error) {
logger := j.logger("GetInboxNameForMultipleAccounts", ctx)
ctx = ctx.WithLogger(logger)
@@ -213,7 +215,7 @@ func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Contex
invocations := make([]Invocation, n*2)
for i, accountId := range uniqueAccountIds {
invocations[i*2+0] = invocation(MailboxQueryCommand{
invocations[i*2+0] = invocation(&MailboxQueryCommand{
AccountId: accountId,
Filter: MailboxFilterCondition{
Role: JmapMailboxRoleInbox,
@@ -252,7 +254,7 @@ func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Contex
}
func (j *Client) UpdateMailbox(accountId string, mailboxId string, change MailboxChange, //NOSONAR
ctx Context) (Result[Mailbox], Error) {
ctx Context) (Result[Mailbox], error) {
return update(j, "UpdateMailbox", MailboxType,
func(update map[string]PatchObject) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Update: update}
@@ -267,7 +269,7 @@ func (j *Client) UpdateMailbox(accountId string, mailboxId string, change Mailbo
)
}
func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Context) (Result[*Mailbox], Error) {
func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Context) (Result[*Mailbox], error) {
return create(j, "CreateMailbox", MailboxType,
func(accountId string, create map[string]MailboxChange) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Create: create}
@@ -286,7 +288,7 @@ func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Cont
)
}
func (j *Client) DeleteMailboxes(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
func (j *Client) DeleteMailboxes(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteMailboxes", MailboxType,
func(accountId string, destroy []string) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Destroy: destroyIds}

View File

@@ -27,7 +27,7 @@ func (j *Client) GetObjects(accountId string, //NOSONAR
quotaIds []string, identityIds []string,
emailSubmissionIds []string,
ctx Context,
) (Result[Objects], Error) {
) (Result[Objects], error) {
l := j.logger("GetObjects", ctx).With()
if len(mailboxIds) > 0 {
l = l.Array("mailboxIds", log.SafeStringArray(mailboxIds))

View File

@@ -2,7 +2,7 @@ package jmap
var NS_PRINCIPALS = ns(JmapPrincipals)
func (j *Client) GetPrincipals(accountId string, ids []string, ctx Context) (Result[PrincipalGetResponse], Error) {
func (j *Client) GetPrincipals(accountId string, ids []string, ctx Context) (Result[PrincipalGetResponse], error) {
return get(j, "GetPrincipals", PrincipalType,
func(accountId string, ids []string) PrincipalGetCommand {
return PrincipalGetCommand{AccountId: accountId, Ids: ids}
@@ -29,27 +29,36 @@ func (r *PrincipalSearchResults) RemoveResults() { r.Results = nil }
func (r *PrincipalSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *PrincipalSearchResults) SetPosition(position *uint) { r.Position = position }
func (j *Client) QueryPrincipals(accountId string, //NOSONAR
filter PrincipalFilterElement, sortBy []PrincipalComparator,
position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (Result[*PrincipalSearchResults], Error) {
return query(j, "QueryPrincipals", PrincipalType,
func (j *Client) QueryPrincipals(accountIds map[string]QueryParams, limit *uint, //NOSONAR
filter PrincipalFilterElement, sortBy []PrincipalComparator, calculateTotal bool,
ctx Context) (Result[map[string]*PrincipalSearchResults], error) {
return queryN(j, "QueryPrincipals", PrincipalType,
[]PrincipalComparator{{Property: PrincipalPropertyName, IsAscending: true}},
func(filter PrincipalFilterElement, sortBy []PrincipalComparator, position int, anchor string, anchorOffset *int, limit *uint) PrincipalQueryCommand {
return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
func(accountId string, p QueryParams, limit *uint, filter PrincipalFilterElement, sortBy []PrincipalComparator) PrincipalQueryCommand {
return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: p.Position, Anchor: p.Anchor, AnchorOffset: p.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
func(cmd Command, path string, rof string) PrincipalGetRefCommand {
func(accountId string, cmd Command, path, rof string) PrincipalGetRefCommand {
return PrincipalGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
},
func(query PrincipalQueryResponse, get PrincipalGetResponse) *PrincipalSearchResults {
func(query PrincipalQueryResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
return &PrincipalSearchResults{
Results: []Principal{},
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: ptrIf(query.Total, calculateTotal),
Limit: valueIf(query.Limit, limit != nil),
}
},
func(query PrincipalQueryResponse, get PrincipalGetResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
return &PrincipalSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
Position: ptrIf(query.Position, anchor == ""),
Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: ptrIf(query.Total, calculateTotal),
Limit: ptrIf(query.Limit, limit != nil),
Limit: valueIf(query.Limit, limit != nil),
}
},
filter, sortBy, position, anchor, anchorOffset, limit, ctx,
accountIds, limit, filter, sortBy,
ctx,
)
}

View File

@@ -2,7 +2,7 @@ package jmap
var NS_QUOTA = ns(JmapQuota)
func (j *Client) GetQuotas(accountIds []string, ctx Context) (Result[map[string]QuotaGetResponse], Error) {
func (j *Client) GetQuotas(accountIds []string, ctx Context) (Result[map[string]QuotaGetResponse], error) {
return getN(j, "GetQuotas", QuotaType,
func(accountId string, ids []string) QuotaGetCommand {
return QuotaGetCommand{AccountId: accountId}
@@ -29,7 +29,7 @@ func (c QuotaChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Quotas since a given State.
// @api:tags quota,changes
func (j *Client) GetQuotaChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[QuotaChanges], Error) {
ctx Context) (Result[QuotaChanges], error) {
return changesA(j, "GetQuotaChanges", QuotaType,
func() QuotaChangesCommand {
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -61,7 +61,7 @@ func (j *Client) GetQuotaChanges(accountId string, sinceState State, maxChanges
}
func (j *Client) GetQuotaUsageChanges(accountId string, sinceState State, maxChanges uint,
ctx Context) (Result[QuotaChanges], Error) {
ctx Context) (Result[QuotaChanges], error) {
return updates(j, "GetQuotaUsageChanges", QuotaType,
func() QuotaChangesCommand {
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}

View File

@@ -11,7 +11,7 @@ const (
vacationResponseId = "singleton"
)
func (j *Client) GetVacationResponse(accountId string, ctx Context) (Result[VacationResponseGetResponse], Error) {
func (j *Client) GetVacationResponse(accountId string, ctx Context) (Result[VacationResponseGetResponse], error) {
return get(j, "GetVacationResponse", VacationResponseType,
func(accountId string, ids []string) VacationResponseGetCommand {
return VacationResponseGetCommand{AccountId: accountId}
@@ -65,7 +65,7 @@ func (c VacationResponseChanges) GetUpdated() []VacationResponse { return c.Upda
func (c VacationResponseChanges) GetDestroyed() []string { return c.Destroyed }
func (j *Client) SetVacationResponse(accountId string, vacation VacationResponseChange,
ctx Context) (Result[VacationResponse], Error) {
ctx Context) (Result[VacationResponse], error) {
logger := j.logger("SetVacationResponse", ctx)
ctx = ctx.WithLogger(logger)

View File

@@ -8,6 +8,7 @@ import (
"slices"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/rs/zerolog"
)
@@ -107,7 +108,9 @@ func (j *Client) maxCallsCheck(calls int, ctx Context) Error {
//
// If an issue occurs, then it is logged prior to returning it.
func (j *Client) request(ctx Context, using []JmapNamespace, methodCalls ...Invocation) (Request, Error) {
err := j.maxCallsCheck(len(methodCalls), ctx)
sanitized := structs.Filter(methodCalls, func(inv Invocation) bool { return inv.Command != "" })
err := j.maxCallsCheck(len(sanitized), ctx)
if err != nil {
return Request{}, err
}
@@ -120,7 +123,7 @@ func (j *Client) request(ctx Context, using []JmapNamespace, methodCalls ...Invo
}
return Request{
Using: using,
MethodCalls: methodCalls,
MethodCalls: sanitized,
CreatedIds: nil,
}, nil
}

View File

@@ -60,6 +60,8 @@ type JmapError struct {
}
var _ Error = &JmapError{}
var _ error = &JmapError{}
var _ error = JmapError{}
func (e JmapError) Code() int {
return e.code

View File

@@ -1157,9 +1157,9 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
acc func(session *Session) string,
obj func(RESP) []OBJ,
id func(OBJ) string,
get func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[RESP], Error),
update func(s *StalwartTest, accountId string, id string, change CHANGE, ctx Context) (Result[OBJ], Error),
destroy func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error),
get func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[RESP], error),
update func(s *StalwartTest, accountId string, id string, change CHANGE, ctx Context) (Result[OBJ], error),
destroy func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error),
fill func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, _ User, principalIds []string) (BOXES, []OBJ, SessionState, State, error),
change func(OBJ) CHANGE,
checkChanged func(t *testing.T, orig OBJ, change CHANGE, changed OBJ),

View File

@@ -45,13 +45,13 @@ func TestAddressBooks(t *testing.T) {
func(session *Session) string { return session.PrimaryAccounts.Contacts },
list,
getid,
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], Error) {
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
return s.client.GetAddressbooks(accountId, ids, ctx)
},
func(s *StalwartTest, accountId string, id string, change AddressBookChange, ctx Context) (Result[AddressBook], Error) { //NOSONAR
func(s *StalwartTest, accountId string, id string, change AddressBookChange, ctx Context) (Result[AddressBook], error) { //NOSONAR
return s.client.UpdateAddressBook(accountId, id, change, ctx)
},
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error) { //NOSONAR
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
return s.client.DeleteAddressBook(accountId, ids, ctx)
},
func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, user User, principalIds []string) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
@@ -104,7 +104,7 @@ func TestContacts(t *testing.T) {
ss := EmptySessionState
os := EmptyState
{
result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
result, err := s.client.QueryContactCards(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
@@ -161,7 +161,7 @@ func TestContacts(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
result, err := s.client.QueryContactCards(map[string]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
@@ -186,7 +186,7 @@ func TestContacts(t *testing.T) {
offset := 0
i := 0
for chunk := range slices.Chunk(results.Results, chunkSize) {
result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, anchor, &offset, uintPtr(chunkSize), true, ctx)
result, err := s.client.QueryContactCards(map[string]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
require.Equal(ss, result.GetSessionState())
require.NoError(err)
require.Len(result.Payload, 1)
@@ -236,7 +236,7 @@ func TestContacts(t *testing.T) {
os = result.GetState()
}
{
result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
result, err := s.client.QueryContactCards(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Contains(result.Payload, accountId)
resp := result.Payload[accountId]

View File

@@ -35,13 +35,13 @@ func TestCalendars(t *testing.T) { //NOSONAR
func(session *Session) string { return session.PrimaryAccounts.Calendars },
func(resp CalendarGetResponse) []Calendar { return resp.List },
func(obj Calendar) string { return obj.Id },
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], Error) {
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
return s.client.GetCalendars(accountId, ids, ctx)
},
func(s *StalwartTest, accountId string, id string, change CalendarChange, ctx Context) (Result[Calendar], Error) { //NOSONAR
func(s *StalwartTest, accountId string, id string, change CalendarChange, ctx Context) (Result[Calendar], error) { //NOSONAR
return s.client.UpdateCalendar(accountId, id, change, ctx)
},
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error) { //NOSONAR
func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
return s.client.DeleteCalendar(accountId, ids, ctx)
},
func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, user User, principalIds []string) (CalendarBoxes, []Calendar, SessionState, State, error) {
@@ -94,7 +94,7 @@ func TestEvents(t *testing.T) {
os := EmptyState
var results *CalendarEventSearchResults
{
result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
result, err := s.client.QueryCalendarEvents(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
@@ -127,7 +127,7 @@ func TestEvents(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
result, err := s.client.QueryCalendarEvents(map[string]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
@@ -152,7 +152,7 @@ func TestEvents(t *testing.T) {
offset := 0
i := 0
for chunk := range slices.Chunk(results.Results, chunkSize) {
result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, anchor, &offset, uintPtr(chunkSize), true, ctx)
result, err := s.client.QueryCalendarEvents(map[string]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
require.Equal(ss, result.GetSessionState())
require.NoError(err)
require.Len(result.Payload, 1)
@@ -207,7 +207,7 @@ func TestEvents(t *testing.T) {
}
{
result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
result, err := s.client.QueryCalendarEvents(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Contains(result.Payload, accountId)
resp := result.Payload[accountId]

View File

@@ -81,7 +81,7 @@ func TestEmails(t *testing.T) {
}
{
result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, true, false, 0, true, ctx)
result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, true, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, result.GetSessionState())
@@ -95,7 +95,7 @@ func TestEmails(t *testing.T) {
}
{
result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, false, false, 0, true, ctx)
result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, false, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, result.GetSessionState())
@@ -331,8 +331,8 @@ func TestSendingEmails(t *testing.T) {
result, err := s.client.QueryEmails([]string{r.accountId}, EmailFilterCondition{InMailbox: inboxId}, 0, 0, true, 0, rctx)
require.NoError(err)
require.Contains(result.Payload, r.accountId)
require.Len(result.Payload[r.accountId].Emails, 1)
received := result.Payload[r.accountId].Emails[0]
require.Len(result.Payload[r.accountId].Results, 1)
received := result.Payload[r.accountId].Results[0]
require.Len(received.From, 1)
require.Equal(from.email, received.From[0].Email)
require.Equal(fromName, received.From[0].Name)

View File

@@ -8,6 +8,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
// https://www.iana.org/assignments/jmap/jmap.xml#jmap-data-types
@@ -1177,6 +1178,10 @@ func invocation(parameters JmapCommand, tag string) Invocation {
}
}
func skipInvocation() Invocation {
return Invocation{Command: ""}
}
type TypeOfRequest string
const RequestType = TypeOfRequest("Request")
@@ -1376,9 +1381,14 @@ type ChangesResponse[T Foo] interface {
GetDestroyed() []string
}
type QueryCommand[T Foo] interface {
type QueryCommand[T Foo, SELF QueryCommand[T, SELF]] interface {
JmapCommand
GetResponse() QueryResponse[T]
GetPosition() int
GetAnchor() string
GetAnchorOffset() *int
GetLimit() *uint
WithLimit(limit *uint) SELF
}
type QueryResponse[T Foo] interface {
@@ -1422,12 +1432,37 @@ type Changes[T Foo] interface {
GetDestroyed() []string
}
type QueryParams struct {
Position int `json:"p,omitzero"`
Anchor string `json:"a,omitempty"`
AnchorOffset *int `json:"o,omitempty"`
}
var NullQueryParams = QueryParams{Position: 0, Anchor: "", AnchorOffset: nil}
func toNullQueryParams(accountIds []string) map[string]QueryParams {
return structs.ToMap(accountIds, func(k string) (string, QueryParams) { return k, NullQueryParams })
}
type FilterElement[T Foo] interface {
GetMarker() T
}
type Comparator[T Foo] interface {
GetMarker() T
}
// using a custom type for the "canCalculateChanges" attribute in order to customize its rendering:
// as it's going to be "true" in 99% if not 100% of cases with Stalwart, we only want to render this
// attribute if its value is "false"
type ChangeCalculation bool
const (
CapableOfChangeCalculation = ChangeCalculation(true)
IncapableOfChangeCalculation = ChangeCalculation(false)
)
func (cc *ChangeCalculation) IsZero() bool {
return *cc == true
}
@@ -1788,22 +1823,27 @@ type MailboxFilterCondition struct {
IsSubscribed *bool `json:"isSubscribed,omitempty"`
}
var _ FilterElement[Mailbox] = &MailboxFilterCondition{}
var _ MailboxFilterElement = &MailboxFilterCondition{}
func (c MailboxFilterCondition) _isAMailboxFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ MailboxFilterElement = &MailboxFilterCondition{}
func (c MailboxFilterCondition) GetMarker() Mailbox { return Mailbox{} }
type MailboxFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []MailboxFilterElement `json:"conditions,omitempty"`
}
var _ FilterElement[Mailbox] = &MailboxFilterCondition{}
var _ MailboxFilterElement = &MailboxFilterOperator{}
func (c MailboxFilterOperator) _isAMailboxFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ MailboxFilterElement = &MailboxFilterOperator{}
func (c MailboxFilterOperator) GetMarker() Mailbox { return Mailbox{} }
type MailboxComparator struct {
Property string `json:"property"`
@@ -1812,6 +1852,10 @@ type MailboxComparator struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
var _ Comparator[Mailbox] = &MailboxComparator{}
func (c MailboxComparator) GetMarker() Mailbox { return Mailbox{} }
type MailboxQueryCommand struct {
AccountId string `json:"accountId"`
Filter MailboxFilterElement `json:"filter,omitempty"`
@@ -1863,11 +1907,29 @@ type MailboxQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
var _ QueryCommand[Mailbox] = &MailboxQueryCommand{}
var _ QueryCommand[Mailbox, MailboxQueryCommand] = &MailboxQueryCommand{}
func (c MailboxQueryCommand) GetCommand() Command { return CommandMailboxQuery }
func (c MailboxQueryCommand) GetObjectType() ObjectType { return MailboxType }
func (c MailboxQueryCommand) GetResponse() QueryResponse[Mailbox] { return MailboxQueryResponse{} }
func (c MailboxQueryCommand) GetResponse() QueryResponse[Mailbox] { return &MailboxQueryResponse{} }
func (c MailboxQueryCommand) GetPosition() int { return c.Position }
func (c MailboxQueryCommand) GetAnchor() string { return c.Anchor }
func (c MailboxQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
func (c MailboxQueryCommand) GetLimit() *uint { return c.Limit }
func (c MailboxQueryCommand) WithLimit(limit *uint) MailboxQueryCommand {
return MailboxQueryCommand{
AccountId: c.AccountId,
Filter: c.Filter,
Sort: c.Sort,
SortAsTree: c.SortAsTree,
FilterAsTree: c.FilterAsTree,
Position: c.Position,
Anchor: c.Anchor,
AnchorOffset: c.AnchorOffset,
Limit: limit,
CalculateTotal: c.CalculateTotal,
}
}
type EmailFilterElement interface {
_isAnEmailFilterElement() // marker method
@@ -1970,6 +2032,11 @@ type EmailFilterCondition struct {
Header []string `json:"header,omitempty"`
}
var _ EmailFilterElement = &EmailFilterCondition{}
var _ FilterElement[Email] = &EmailFilterCondition{}
func (f EmailFilterCondition) GetMarker() Email { return Email{} }
func (f EmailFilterCondition) _isAnEmailFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -2038,13 +2105,16 @@ func (f EmailFilterCondition) IsNotEmpty() bool { //NOSONAR
return false
}
var _ EmailFilterElement = &EmailFilterCondition{}
type EmailFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []EmailFilterElement `json:"conditions,omitempty"`
}
var _ EmailFilterElement = &EmailFilterOperator{}
var _ FilterElement[Email] = &EmailFilterCondition{}
func (f EmailFilterOperator) GetMarker() Email { return Email{} }
func (o EmailFilterOperator) _isAnEmailFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -2053,8 +2123,6 @@ func (o EmailFilterOperator) IsNotEmpty() bool {
return len(o.Conditions) > 0
}
var _ EmailFilterElement = &EmailFilterOperator{}
type EmailComparator struct {
// The name of the property on the objects to compare.
Property string `json:"property,omitempty"`
@@ -2080,6 +2148,10 @@ type EmailComparator struct {
Keyword string `json:"keyword,omitempty"`
}
var _ Comparator[Email] = &EmailComparator{}
func (c EmailComparator) GetMarker() Email { return Email{} }
// If an anchor argument is given, the anchor is looked for in the results after filtering
// and sorting.
//
@@ -2159,11 +2231,28 @@ type EmailQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
var _ QueryCommand[Email] = &EmailQueryCommand{}
var _ QueryCommand[Email, EmailQueryCommand] = &EmailQueryCommand{}
func (c EmailQueryCommand) GetCommand() Command { return CommandEmailQuery }
func (c EmailQueryCommand) GetObjectType() ObjectType { return MailboxType }
func (c EmailQueryCommand) GetResponse() QueryResponse[Email] { return EmailQueryResponse{} }
func (c EmailQueryCommand) GetResponse() QueryResponse[Email] { return &EmailQueryResponse{} }
func (c EmailQueryCommand) GetPosition() int { return c.Position }
func (c EmailQueryCommand) GetAnchor() string { return c.Anchor }
func (c EmailQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
func (c EmailQueryCommand) GetLimit() *uint { return c.Limit }
func (c EmailQueryCommand) WithLimit(limit *uint) EmailQueryCommand {
return EmailQueryCommand{
AccountId: c.AccountId,
Filter: c.Filter,
Sort: c.Sort,
Position: c.Position,
Anchor: c.Anchor,
AnchorOffset: c.AnchorOffset,
Limit: limit,
CalculateTotal: c.CalculateTotal,
CollapseThreads: c.CollapseThreads,
}
}
type EmailGetCommand struct {
// The ids of the Email objects to return.
@@ -3189,7 +3278,7 @@ type EmailQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position uint `json:"position"`
Position *uint `json:"position,omitempty"`
// The list of ids for each Email in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -3207,7 +3296,7 @@ type EmailQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitempty,omitzero"`
Limit *uint `json:"limit,omitempty,omitzero"`
}
var _ QueryResponse[Email] = &EmailQueryResponse{}
@@ -3414,7 +3503,7 @@ type MailboxQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position int `json:"position"`
Position *uint `json:"position,omitempty"`
// The list of ids for each Mailbox in the query results, starting at the index given by the position argument
// of this response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -3430,7 +3519,7 @@ type MailboxQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit int `json:"limit,omitzero"`
Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[Mailbox] = &MailboxQueryResponse{}
@@ -6890,9 +6979,14 @@ type ContactCardComparator struct {
Updated time.Time `json:"updated,omitzero"`
}
var _ Comparator[ContactCard] = &ContactCardComparator{}
func (c ContactCardComparator) GetMarker() ContactCard { return ContactCard{} }
type ContactCardFilterElement interface {
_isAContactCardFilterElement() // marker method
IsNotEmpty() bool
FilterElement[ContactCard]
}
type ContactCardFilterCondition struct {
@@ -6968,6 +7062,12 @@ type ContactCardFilterCondition struct {
Note string `json:"note,omitempty"`
}
var _ ContactCardFilterElement = &ContactCardFilterCondition{}
var _ FilterElement[ContactCard] = &ContactCardFilterCondition{}
func (f ContactCardFilterCondition) GetMarker() ContactCard { return ContactCard{} }
func (f ContactCardFilterCondition) _isAContactCardFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7036,13 +7136,17 @@ func (f ContactCardFilterCondition) IsNotEmpty() bool { //NOSONAR
return false
}
var _ ContactCardFilterElement = &ContactCardFilterCondition{}
type ContactCardFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []ContactCardFilterElement `json:"conditions,omitempty"`
}
var _ ContactCardFilterElement = &ContactCardFilterOperator{}
var _ FilterElement[ContactCard] = &ContactCardFilterOperator{}
func (f ContactCardFilterOperator) GetMarker() ContactCard { return ContactCard{} }
func (o ContactCardFilterOperator) _isAContactCardFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7051,8 +7155,6 @@ func (o ContactCardFilterOperator) IsNotEmpty() bool {
return len(o.Conditions) > 0
}
var _ ContactCardFilterElement = &ContactCardFilterOperator{}
type ContactCardQueryCommand struct {
AccountId string `json:"accountId"`
@@ -7104,12 +7206,28 @@ type ContactCardQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitzero"`
}
var _ QueryCommand[ContactCard] = &ContactCardQueryCommand{}
var _ QueryCommand[ContactCard, ContactCardQueryCommand] = &ContactCardQueryCommand{}
func (c ContactCardQueryCommand) GetCommand() Command { return CommandContactCardQuery }
func (c ContactCardQueryCommand) GetObjectType() ObjectType { return ContactCardType }
func (c ContactCardQueryCommand) GetResponse() QueryResponse[ContactCard] {
return ContactCardQueryResponse{}
return &ContactCardQueryResponse{}
}
func (c ContactCardQueryCommand) GetPosition() int { return c.Position }
func (c ContactCardQueryCommand) GetAnchor() string { return c.Anchor }
func (c ContactCardQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
func (c ContactCardQueryCommand) GetLimit() *uint { return c.Limit }
func (c ContactCardQueryCommand) WithLimit(limit *uint) ContactCardQueryCommand {
return ContactCardQueryCommand{
AccountId: c.AccountId,
Filter: c.Filter,
Sort: c.Sort,
Position: c.Position,
Anchor: c.Anchor,
AnchorOffset: c.AnchorOffset,
Limit: limit,
CalculateTotal: c.CalculateTotal,
}
}
type ContactCardQueryResponse struct {
@@ -7141,7 +7259,7 @@ type ContactCardQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position uint `json:"position"`
Position *uint `json:"position,omitempty"`
// The list of ids for each ContactCard in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -7159,7 +7277,7 @@ type ContactCardQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitzero"`
Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[ContactCard] = &ContactCardQueryResponse{}
@@ -7673,6 +7791,10 @@ type CalendarEventComparator struct {
TimeZone string `json:"timeZone,omitempty" doc:"opt" default:"Etc/UTC"`
}
var _ Comparator[CalendarEvent] = &CalendarEventComparator{}
func (c CalendarEventComparator) GetMarker() CalendarEvent { return CalendarEvent{} }
type CalendarEventFilterElement interface {
_isACalendarEventFilterElement() // marker method
IsNotEmpty() bool
@@ -7725,6 +7847,11 @@ type CalendarEventFilterCondition struct {
Uid string `json:"uid,omitempty" doc:"opt"`
}
var _ CalendarEventFilterElement = &CalendarEventFilterCondition{}
var _ FilterElement[CalendarEvent] = &CalendarEventFilterCondition{}
func (f CalendarEventFilterCondition) GetMarker() CalendarEvent { return CalendarEvent{} }
func (f CalendarEventFilterCondition) _isACalendarEventFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7766,8 +7893,6 @@ func (f CalendarEventFilterCondition) IsNotEmpty() bool {
return false
}
var _ CalendarEventFilterElement = &CalendarEventFilterCondition{}
type CalendarEventFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []CalendarEventFilterElement `json:"conditions,omitempty"`
@@ -7834,12 +7959,28 @@ type CalendarEventQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty" doc:"opt" default:"false"`
}
var _ QueryCommand[CalendarEvent] = &CalendarEventQueryCommand{}
var _ QueryCommand[CalendarEvent, CalendarEventQueryCommand] = &CalendarEventQueryCommand{}
func (c CalendarEventQueryCommand) GetCommand() Command { return CommandCalendarEventQuery }
func (c CalendarEventQueryCommand) GetObjectType() ObjectType { return CalendarEventType }
func (c CalendarEventQueryCommand) GetResponse() QueryResponse[CalendarEvent] {
return CalendarEventQueryResponse{}
return &CalendarEventQueryResponse{}
}
func (c CalendarEventQueryCommand) GetPosition() int { return c.Position }
func (c CalendarEventQueryCommand) GetAnchor() string { return c.Anchor }
func (c CalendarEventQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
func (c CalendarEventQueryCommand) GetLimit() *uint { return c.Limit }
func (c CalendarEventQueryCommand) WithLimit(limit *uint) CalendarEventQueryCommand {
return CalendarEventQueryCommand{
AccountId: c.AccountId,
Filter: c.Filter,
Sort: c.Sort,
Position: c.Position,
Anchor: c.Anchor,
AnchorOffset: c.AnchorOffset,
Limit: limit,
CalculateTotal: c.CalculateTotal,
}
}
type CalendarEventQueryResponse struct {
@@ -7871,7 +8012,7 @@ type CalendarEventQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position uint `json:"position"`
Position *uint `json:"position,omitempty"`
// The list of ids for each ContactCard in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -7889,7 +8030,7 @@ type CalendarEventQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitzero"`
Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[CalendarEvent] = &CalendarEventQueryResponse{}
@@ -8238,23 +8379,29 @@ type PrincipalFilterCondition struct {
TimeZone string `json:"timeZone,omitempty"`
}
var _ PrincipalFilterElement = &PrincipalFilterCondition{}
var _ FilterElement[Principal] = &PrincipalFilterCondition{}
func (f PrincipalFilterCondition) GetMarker() Principal { return Principal{} }
func (c PrincipalFilterCondition) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ PrincipalFilterElement = &PrincipalFilterCondition{}
type PrincipalFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []PrincipalFilterElement `json:"conditions,omitempty"`
}
var _ PrincipalFilterElement = &PrincipalFilterOperator{}
var _ FilterElement[Principal] = &PrincipalFilterOperator{}
func (f PrincipalFilterOperator) GetMarker() Principal { return Principal{} }
func (c PrincipalFilterOperator) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ PrincipalFilterElement = &PrincipalFilterOperator{}
type PrincipalComparator struct {
Property string `json:"property"`
IsAscending bool `json:"isAscending,omitempty"`
@@ -8262,6 +8409,10 @@ type PrincipalComparator struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
var _ Comparator[Principal] = &PrincipalComparator{}
func (c PrincipalComparator) GetMarker() Principal { return Principal{} }
type PrincipalQueryCommand struct {
AccountId string `json:"accountId"`
Filter PrincipalFilterElement `json:"filter,omitempty"`
@@ -8311,12 +8462,28 @@ type PrincipalQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitzero"`
}
var _ QueryCommand[Principal] = PrincipalQueryCommand{}
var _ QueryCommand[Principal, PrincipalQueryCommand] = &PrincipalQueryCommand{}
func (c PrincipalQueryCommand) GetCommand() Command { return CommandPrincipalQuery }
func (c PrincipalQueryCommand) GetObjectType() ObjectType { return PrincipalType }
func (c PrincipalQueryCommand) GetResponse() QueryResponse[Principal] {
return PrincipalQueryResponse{}
return &PrincipalQueryResponse{}
}
func (c PrincipalQueryCommand) GetPosition() int { return c.Position }
func (c PrincipalQueryCommand) GetAnchor() string { return c.Anchor }
func (c PrincipalQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
func (c PrincipalQueryCommand) GetLimit() *uint { return c.Limit }
func (c PrincipalQueryCommand) WithLimit(limit *uint) PrincipalQueryCommand {
return PrincipalQueryCommand{
AccountId: c.AccountId,
Filter: c.Filter,
Sort: c.Sort,
Position: c.Position,
Anchor: c.Anchor,
AnchorOffset: c.AnchorOffset,
Limit: limit,
CalculateTotal: c.CalculateTotal,
}
}
type PrincipalQueryResponse struct {
@@ -8347,7 +8514,7 @@ type PrincipalQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position uint `json:"position"`
Position *uint `json:"position,omitempty"`
// The list of ids for each Principal in the query results, starting at the index given by the position argument
// of this response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -8363,7 +8530,7 @@ type PrincipalQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitzero"`
Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[Principal] = &PrincipalQueryResponse{}

View File

@@ -14,6 +14,19 @@ import (
c "github.com/opencloud-eu/opencloud/pkg/jscontact"
)
func copyTo[B any, A any](a A) B {
if b, err := json.Marshal(a); err != nil {
panic(err)
} else {
var t B
if err := json.Unmarshal(b, &t); err != nil {
panic(err)
} else {
return t
}
}
}
func SerializeExamples(e any) { //NOSONAR
type example struct {
Type string `json:"type"`
@@ -2329,16 +2342,3 @@ func (e Exemplar) ContactCardSearchResults() ContactCardSearchResults {
Total: ptr(uint(4)),
}
}
func copyTo[B any, A any](a A) B {
if b, err := json.Marshal(a); err != nil {
panic(err)
} else {
var t B
if err := json.Unmarshal(b, &t); err != nil {
panic(err)
} else {
return t
}
}
}

View File

@@ -429,13 +429,15 @@ func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP an
})
}
func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
/*
func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP SearchResults[T]]( //NOSONAR
client *Client, name string, objType ObjectType,
defaultSortBy []SORT,
queryCommandFactory func(filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint) QUERY,
queryCommandFactory func(filter FILTER, sortBy []SORT) QUERY,
getCommandFactory func(cmd Command, path string, rof string) GET,
respMapper0 func(query QUERYRESP) *RESP,
respMapper func(query QUERYRESP, get GETRESP) *RESP,
filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint,
filter FILTER, sortBy []SORT,
ctx Context) (Result[*RESP], Error) {
logger := client.logger(name, ctx)
@@ -445,8 +447,14 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
sortBy = defaultSortBy
}
query := queryCommandFactory(filter, sortBy, position, anchor, anchorOffset, limit)
get := getCommandFactory(query.GetCommand(), "/ids/*", "0")
query := queryCommandFactory(filter, sortBy)
var get GET
limit := query.GetLimit()
if limit != nil && *limit == 0 {
query.SetLimit(UintPtrOne)
} else {
get = getCommandFactory(query.GetCommand(), "/ids/*", "0")
}
cmd, err := client.request(ctx, objType.Namespaces, invocation(query, "0"), invocation(get, "1"))
if err != nil {
@@ -459,43 +467,60 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
if err != nil {
return nil, EmptyState, err
}
var getResponse GETRESP
err = retrieveGet(ctx, body, get, "1", &getResponse)
if err != nil {
return nil, EmptyState, err
if limit == nil && *limit == 0 {
result := respMapper0(queryResponse)
if query.GetAnchor() != "" && (*result).GetPosition() != nil && *(*result).GetPosition() == 0 {
(*result).SetPosition(nil)
}
return result, queryResponse.GetQueryState(), nil
} else {
var getResponse GETRESP
err = retrieveGet(ctx, body, get, "1", &getResponse)
if err != nil {
return nil, EmptyState, err
}
return respMapper(queryResponse, getResponse), queryResponse.GetQueryState(), nil
}
return respMapper(queryResponse, getResponse), queryResponse.GetQueryState(), nil
})
}
*/
func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T, QUERY], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
client *Client, name string, objType ObjectType,
defaultSortBy []SORT,
queryCommandFactory func(accountId string, filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, imit *uint) QUERY,
queryCommandFactory func(accountId string, queryParams QueryParams, limit *uint, filter FILTER, sortBy []SORT) QUERY,
getCommandFactory func(accountId string, cmd Command, path string, rof string) GET,
respMapper func(query QUERYRESP, get GETRESP) *RESP,
accountIds []string,
filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint,
respMapper0 func(query QUERYRESP, queryParams QueryParams, limit *uint) *RESP,
respMapper func(query QUERYRESP, get GETRESP, queryParams QueryParams, limit *uint) *RESP,
accountIds map[string]QueryParams,
limit *uint, filter FILTER, sortBy []SORT,
ctx Context) (Result[map[string]*RESP], Error) {
logger := client.logger(name, ctx)
ctx = ctx.WithLogger(logger)
uniqueAccountIds := structs.Uniq(accountIds)
if sortBy == nil {
sortBy = defaultSortBy
}
invocations := make([]Invocation, len(uniqueAccountIds)*2)
invocations := make([]Invocation, len(accountIds)*2)
var g GET
var q QUERY
for i, accountId := range uniqueAccountIds {
query := queryCommandFactory(accountId, filter, sortBy, position, anchor, anchorOffset, limit)
get := getCommandFactory(accountId, query.GetCommand(), "/ids/*", mcid(accountId, "0"))
invocations[i*2+0] = invocation(query, mcid(accountId, "0"))
invocations[i*2+1] = invocation(get, mcid(accountId, "1"))
q = query
g = get
{
i := 0
for accountId, queryParams := range accountIds {
query := queryCommandFactory(accountId, queryParams, limit, filter, sortBy)
q = query
invocations[i*2+0] = invocation(query, mcid(accountId, "0"))
if limit != nil && *limit == 0 {
query = query.WithLimit(UintPtrOne)
invocations[i*2+1] = skipInvocation()
} else {
get := getCommandFactory(accountId, query.GetCommand(), "/ids/*", mcid(accountId, "0"))
invocations[i*2+1] = invocation(get, mcid(accountId, "1"))
g = get
}
i++
}
}
cmd, err := client.request(ctx, objType.Namespaces, invocations...)
@@ -506,22 +531,27 @@ func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T
return command(client, ctx, cmd, func(body *Response) (map[string]*RESP, State, Error) {
resp := map[string]*RESP{}
stateByAccountId := map[string]State{}
for _, accountId := range uniqueAccountIds {
for accountId, queryParams := range accountIds {
var queryResponse QUERYRESP
err = retrieveQuery(ctx, body, q, mcid(accountId, "0"), &queryResponse)
if err != nil {
return nil, "", err
return nil, EmptyState, err
}
var getResponse GETRESP
err = retrieveGet(ctx, body, g, mcid(accountId, "1"), &getResponse)
if err != nil {
return nil, "", err
if limit != nil && *limit == 0 {
resp[accountId] = respMapper0(queryResponse, queryParams, limit)
stateByAccountId[accountId] = queryResponse.GetQueryState()
} else {
var getResponse GETRESP
err = retrieveGet(ctx, body, g, mcid(accountId, "1"), &getResponse)
if err != nil {
return nil, EmptyState, err
}
if len(getResponse.GetNotFound()) > 0 {
// TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
}
resp[accountId] = respMapper(queryResponse, getResponse, queryParams, limit)
stateByAccountId[accountId] = getResponse.GetState()
}
if len(getResponse.GetNotFound()) > 0 {
// TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
}
resp[accountId] = respMapper(queryResponse, getResponse)
stateByAccountId[accountId] = getResponse.GetState()
}
return resp, squashState(stateByAccountId), nil
})

View File

@@ -14,6 +14,9 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
)
var UintPtrOne *uint = uintPtr(1)
var UintPtrZero *uint = uintPtr(0)
type eventListeners[T any] struct {
listeners []T
m sync.Mutex
@@ -141,7 +144,7 @@ func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {
// mapstruct isn't able to properly map RFC3339 date strings into Time
// objects, which is why we require this custom hook,
// see https://github.com/mitchellh/mapstructure/issues/41
wanted := reflect.TypeOf(time.Time{})
wanted := reflect.TypeFor[time.Time]()
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != wanted {
return data, nil
@@ -238,7 +241,7 @@ func retrieveSet[T Foo, C SetCommand[T], R SetResponse[T]](ctx Context, data *Re
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
}
func retrieveQuery[T Foo, C QueryCommand[T], R QueryResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
func retrieveQuery[T Foo, C QueryCommand[T, C], R QueryResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
}

View File

@@ -2,6 +2,7 @@
package structs
import (
"fmt"
"iter"
"maps"
"slices"
@@ -309,3 +310,52 @@ func FilterSeq[T any](it iter.Seq[T], predicate func(T) bool) iter.Seq[T] {
}
}
}
func FilterKeys[K comparable, V any](m map[K]V, predicate func(K, V) bool) []K {
if m == nil {
return []K{}
}
r := []K{}
for k, v := range m {
if predicate(k, v) {
r = append(r, k)
}
}
return r
}
func FilterValues[K comparable, V any](m map[K]V, predicate func(K, V) bool) []V {
if m == nil {
return []V{}
}
r := []V{}
for k, v := range m {
if predicate(k, v) {
r = append(r, v)
}
}
return r
}
func MeshMap[A any, B any, K comparable, V any](keys []A, values []B, mapper func(A, B) (K, V, bool)) (map[K]V, error) {
m := map[K]V{}
if len(keys) != len(values) {
return nil, fmt.Errorf("different length for slices")
}
for i := range keys {
if k, v, b := mapper(keys[i], values[i]); b {
m[k] = v
}
}
return m, nil
}
func First[T any](values []T, predicate func(T) bool) (T, bool) {
for _, value := range values {
if predicate(value) {
return value, true
}
}
var zero T
return zero, false
}

View File

@@ -0,0 +1,247 @@
# Query Pagination
## Single JMAP Backend
Paginating queries, or just plain lists of objects without query filters
(that end up being queries as well, since those are the only JMAP operations that allow
specifying an offset ("position") and limit), are fairly simple to deal with if we are
only considering delegating those to a single JMAP backend, such as Stalwart.
In those cases, we can either make use of
* `position` and `limit`,
* or `anchor` and `anchorOffset`
Considering the following list of elements:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `a` | `b` | `c` | `d` | `e` | `f` | `g` | `h` |
### Using `position` and `limit`
The first option simply uses a numeric offset, e.g.
| Page | `position` | `limit` | results |
|------|------------|---------|---------------|
| 1 | `0` | `3` | `a`, `b`, `c` |
| 2 | `3` | `3` | `d`, `e`, `f` |
| 3 | `6` | `3` | `g`, `h` |
### Using `anchor` and `anchorOffset`
While the second references the unique identifier of the first element to return, with an offset against that element, e.g.:
| Page | `anchor` | `anchorOffset` | `limit` | results |
|------|----------|----------------|---------|---------------|
| 1 | | | `3` | `a`, `b`, `c` |
| 2 | `c` | `1` | `3` | `d`, `e`, `f` |
| 3 | `f` | `1` | `3` | `g`, `h` |
## Non-JMAP Backends
In case of multiple backends, not all will be JMAP, and they won't all support the
same pagination mechanics either.
Paginating over results that come from multiple sources is already a lot more complex
in its own right, since it requires
* performing the query over each backend
* merging the results into a single collection
* ordering them in memory
* capping the results at the requested page size (`limit`)
To illustrate this process, we have a JMAP backend that has the following elements:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `a` | `b` | `c` | `d` | `e` | `f` | `g` | `h` |
and an OpenCloud backend that has these:
| 0 | 1 | 2 | 3 | 4 | 5 |
|:---:|:---:|:---:|:---:|:---:|:---:|
| `U` | `V` | `W` | `X` | `Y` | `Z` |
When we perform a query across those:
| Page | `position` | `limit` | JMAP results | OC results | ordered | capped |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | `0` | `3` | `a`, `b`, `c` | `U`, `V`, `W` | `a`, `b`, `U`, `c`, `V`, `W` | `a`, `b`, `U` |
| 2 | `3` | `3` | `d`, `e`, `f` | `X`, `Y`, `Z` | `d`, `X`, `Y`, `e`, `f`, `Z` | `d`, `X`, `Y` |
:thinking: wait a minute...
If we just naively apply this algorithm, we will skip and lose results (in this example, where have `c`, `V`, `W` gone?).
The thus cannot use the `position` and `limit` "globally" as parameters for queries for each backend.
The approach using `anchor` and `anchorOffset` seems a lot more promising, although it comes with a limitation: it's only ever possible to go to the next page, one by one, and not to jump to e.g. page 5 or page 10 at once.
| Page | `anchor` | `offset` | `limit` | JMAP results | OC results | ordered | capped |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | | | `3` | `a`, `b`, `c` | `U`, `V`, `W` | `a`, `b`, `U`, `c`, `V`, `W` | `a`, `b`, `U` |
| 2 | `b`, `U` | `1`, `1` | `3` | `c`, `d`, `e` | `V`, `W`, `X` | `c`, `V`, `W`, `d`, `e`, `X` | `c`, `V`, `W` |
| 3 | `c`, `W` | `1`, `1` | `3` | `d`, `e`, `f` | `X`, `Y`, `Z` | `d`, `e`, `X`, `Y`, `f`, `Z` | `d`, `e`, `X` |
| 4 | `e`, `X` | `1`, `1` | `3` | `f` | `Y`, `Z` | `Y`, `f`, `Z` | `Y`, `f`, `Z` |
Note that, as is apparent in the example above, we cannot simply have *one* anchor but, instead, require having *one anchor per backend* -- in this case, one for the JMAP backend, and one for the OpenCloud backend, in order to perform the next query for each of them using the correct and respective anchor.
In order to conceal that complexity as well as the fact that there are multiple backends in the first place, the best (and possibly only) approach is to use an opaque object that needs to be passed on for the "next page".
A simple and straightforward approach could be to just encode a JSON object that contains per-backend information using base64, e.g.:
```json
{
"jmap": {
"anchor": "b",
"anchorOffset": 1
},
"oc": {
"anchor": "U",
"anchorOffset": 1
}
}
```
When thrown into a base64 encoder, this results in:
```text
eyJqbWFwIjp7ImFuY2hvciI6ImIiLCJhbmNob3JPZmZzZXQiOjF9LCJvYyI6eyJhbmNob3IiOiJVIiwiYW5jaG9yT2Zmc2V0IjoxfX0K
```
A more optimized and compact object like this:
```json
{
"jmap": "b",
"oc": "U"
}
```
would result in a smaller base64'd opaque object:
```text
eyJqbWFwIjoiYiIsIm9jIjoiVSJ9Cg==
```
Encoding into base64 is not going to make that opaque object any smaller, to the contrary, but it will make it somewhat opaque to avoid conveying the idea that a client should be able to play around with it.
If we want to aim for compactness, a custom encoding would be most efficient, since even JSON is unnecessarily verbose as the only thing we actually want to keep track of is the `anchorOffset` for each backend.
Something like this would indeed be smaller, but not very opaque:
```text
jmap:b,oc:U
```
This approach can also handle with backends that don't all support the same pagination mechanisms: if, say, the OpenCloud backend is unable to use `anchor` and `anchorOffset` but only supports `position` and `limit` instead.
In that case, the opaque "next page" object could look like this:
```text
jmap:b,oc:2
```
And could be made opaque and URL safe using [base62 encoding](https://en.wikipedia.org/wiki/Base62):
```text
R2Wgx8cUhsw2lc
```
But in order to future-proof the format and allow more flexibility/complexity for each backend to store information that is relevant to whatever it needs to perform the pagination, JSON and base62 are the combination of choice:
```json
{"jmap":"b","oc":2}
```
Results in the following opaque object:
```text
1bt1nSpH79BZ7ZI6TK1PH1MrX
```
## API
In order to present a consistent REST API for all paginated endpoints, we should not differentiate between ones that have a single backend and those that have multiple ones, as that might also evolve over time, requiring API changes.
The common denominator is a more restricted API that allows for two operations:
* query the initial page with a given page size (limit): `/groupware/accounts/a/things?first=50`
* query the next page: `/groupware/accounts/a/things?next=...`
* query the next page: `/groupware/accounts/a/things?next=...`
* etc...
It should also be possible to query the first *zero* items in order to only calculate the total amount of items: `/groupware/accounts/a/things?first=0`
The same approach is also applicable to operations that perform queries across multiple accounts, as they pose the same issue as ones that perform queries across multiple backends.
* query the initial page: `/groupware/accounts/all/emails?first=50`
* query the next page: `/groupware/accounts/all/emails?next=...`
It is up to the endpoint to encode the "next" token appropriately, depending on the use-case:
* single account, single backend:
```json
{"p": 50, "l": 50}
```
* multiple accounts, single backend:
```json
{
"a1": {
"a": "naijuh7u", "o": 1, "l": 50
},
"a2": {
"a": "isaicoi0", "o": 1, "l": 50
}
}
```
* single account, multiple backends:
```json
{
"jmap": {
"a1": {
"a": "naijuh7u", "o": 1, "l": 50
}
},
"oc": {
"a1": {
"a": "oc:12291"
}
}
}
```
* multiple accounts, multiple backends:
```json
{
"jmap": {
"a1": {
"a": "naijuh7u", "o": 1, "l": 50
},
"a2": {
"a": "isaicoi0", "o": 1, "l": 50
}
},
"oc": {
"a1": {
"a": "oc:12291"
},
"a2": {
"a": "oc:8182"
}
}
}
```
The alternative would be to have different APIs depending on what each endpoint supports.
* endpoint with multiple backends or multiple accounts:<br>same as above with `?first=...` and `?next=...`
* endpoint with a single backend and a single account:<br>`?position=...&anchor=...&offset=...&limit=...`

View File

@@ -52,6 +52,7 @@ func DefaultConfig() *config.Config {
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Trace-Id", "Cache-Control"},
AllowCredentials: true,
},
OpenCloudPublicURL: "https://localhost:9200/",
},
Service: config.Service{
Name: "groupware",

View File

@@ -12,9 +12,10 @@ type CORS struct {
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
CORS CORS `yaml:"cors"`
Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
CORS CORS `yaml:"cors"`
OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL;GROUPWARE_PUBLIC_URL"`
}

View File

@@ -0,0 +1,35 @@
package groupware
import (
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
type JmapContactCardSupplier struct {
client *jmap.Client
}
var _ ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse] = &JmapContactCardSupplier{}
var _ QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator] = &JmapContactCardSupplier{}
func newJmapContactCardSupplier(client *jmap.Client) *JmapContactCardSupplier {
return &JmapContactCardSupplier{client: client}
}
func (c *JmapContactCardSupplier) GetId() string {
return "jmap"
}
func (c *JmapContactCardSupplier) IsMine(id string) bool {
return id != "" && !strings.Contains(id, ":")
}
func (c *JmapContactCardSupplier) GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
return c.client.GetAddressbooks(accountId, ids, ctx)
}
func (c *JmapContactCardSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*jmap.ContactCardSearchResults], error) { //NOSONAR
if m, err := mapQueryParams(c.GetId(), accountIds, qps); err != nil {
return jmap.ZeroResult[map[string]*jmap.ContactCardSearchResults](), err
} else {
return c.client.QueryContactCards(m, limit, filter, sortBy, calculateTotal, ctx)
}
}

View File

@@ -0,0 +1,150 @@
package groupware
import (
"slices"
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
type MockContactCardSupplier struct {
addressBook jmap.AddressBook
contacts []jmap.ContactCard
}
var MockContactCardSupplierInstance *MockContactCardSupplier = &MockContactCardSupplier{
addressBook: jmap.AddressBook{
Id: "mock:1",
Name: "Automatic Addressbook",
Description: "Users",
IsDefault: false,
IsSubscribed: true,
MyRights: jmap.AddressBookRights{
MayRead: true,
MayWrite: false,
MayAdmin: false,
MayDelete: false,
},
},
contacts: []jmap.ContactCard{
{
Id: "alan",
AddressBookIds: map[string]bool{"mock:1": true},
Type: jscontact.ContactCardType,
Version: jmap.DEFAULT_CONTACT_CARD_VERSION,
Created: mustParseTime("2026-05-26T10:21:00.000Z"),
Kind: jscontact.ContactCardKindIndividual,
ProdId: "OC",
Uid: "dc2858d2-4826-412d-afc9-c4492f8f84bc",
Updated: mustParseTime("2026-05-26T10:21:00.000Z"),
Name: &jscontact.Name{
Type: jscontact.NameType,
Components: []jscontact.NameComponent{
{Kind: jscontact.NameComponentKindGiven, Value: "Alan"},
{Kind: jscontact.NameComponentKindSurname, Value: "Turing"},
},
DefaultSeparator: " ",
IsOrdered: true,
},
Emails: map[string]jscontact.EmailAddress{
"eedujae1": {
Address: "alan@example.com",
},
},
},
},
}
var _ ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse] = &MockContactCardSupplier{}
var _ QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator] = &MockContactCardSupplier{}
func newMockContactCardSupplier() *MockContactCardSupplier {
return MockContactCardSupplierInstance
}
func (c *MockContactCardSupplier) GetId() string {
return "mock"
}
func (c *MockContactCardSupplier) IsMine(id string) bool {
return strings.HasPrefix(id, "mock:")
}
func (c *MockContactCardSupplier) GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
abooks := []jmap.AddressBook{c.addressBook}
if len(ids) > 0 {
abooks = structs.Filter(abooks, func(a jmap.AddressBook) bool { return slices.Contains(ids, a.Id) })
}
return jmap.Result[jmap.AddressBookGetResponse]{
SessionState: jmap.EmptySessionState,
State: "mock",
Language: jmap.NoLanguage,
Payload: jmap.AddressBookGetResponse{
AccountId: accountId,
State: "mock",
List: abooks,
},
}, nil
}
func (c *MockContactCardSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*jmap.ContactCardSearchResults], error) { //NOSONAR
payload := make(map[string]*jmap.ContactCardSearchResults, len(accountIds))
total := len(c.contacts)
for _, accountId := range accountIds {
all := []jmap.ContactCard{}
var qp jmap.QueryParams
if q, ok, err := qps.ForSupplier(c.GetId(), accountId); err != nil {
return jmap.ZeroResult[map[string]*jmap.ContactCardSearchResults](), err
} else if ok {
qp = q
} else {
qp = jmap.NullQueryParams
}
p := uint(qp.Position)
if qp.Position < total {
all = c.contacts[qp.Position:]
}
if qp.Anchor != "" {
a := slices.IndexFunc(all, func(e jmap.ContactCard) bool { return e.Id == qp.Anchor })
if a >= 0 {
if qp.AnchorOffset != nil {
a += int(*qp.AnchorOffset)
} else {
a += 1
}
p += uint(a)
if a < len(all) {
all = all[a:]
} else {
all = []jmap.ContactCard{}
}
} else {
all = []jmap.ContactCard{}
}
}
if limit != nil {
if len(all) > int(*limit) {
all = all[:int(*limit)]
}
}
res := &jmap.ContactCardSearchResults{
Results: all,
CanCalculateChanges: false,
Position: &p,
Limit: limit,
}
if calculateTotal {
t := uint(total)
res.Total = &t
}
payload[accountId] = res
}
return jmap.Result[map[string]*jmap.ContactCardSearchResults]{
Payload: payload,
SessionState: jmap.EmptySessionState,
State: jmap.EmptyState,
Language: jmap.NoLanguage,
}, nil
}

View File

@@ -2,16 +2,18 @@ package groupware
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
getall(AddressBook, w, r, g, g.jmap.GetAddressbooks)
getall(AddressBook, w, r, g, g.addressbooks)
}
// Get an addressbook of an account by its identifier.
func (g *Groupware) GetAddressbookById(w http.ResponseWriter, r *http.Request) {
get(AddressBook, w, r, g, g.jmap.GetAddressbooks)
get(AddressBook, w, r, g, g.addressbooks)
}
// Get the changes to Address Books since a certain State.
@@ -31,3 +33,9 @@ func (g *Groupware) DeleteAddressBook(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) ModifyAddressBook(w http.ResponseWriter, r *http.Request) {
modify(AddressBook, w, r, g, g.jmap.UpdateAddressBook)
}
func (g *Groupware) addressbooks(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
return slist(g.addressBookListSuppliers, accountId, ids, ctx, func(accountId string, state jmap.State, notFound []string, list []jmap.AddressBook) jmap.AddressBookGetResponse {
return jmap.AddressBookGetResponse{AccountId: accountId, State: state, NotFound: notFound, List: list}
})
}

View File

@@ -47,29 +47,34 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
// Download a BLOB by its identifier.
func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
ok, accountId, resp := req.needBloblWithAccount()
if !ok {
return false, resp
}
blobId, err := req.PathParam(UriParamBlobId) // the unique identifier of the blob to download
if err != nil {
return err
return false, req.error(accountId, err)
}
name, err := req.PathParam(UriParamBlobName) // the filename of the blob to download, which is then used in the response and may be arbitrary if unknown
if err != nil {
return err
return false, req.error(accountId, err)
}
typ, _ := req.getStringParam(QueryParamBlobType, "") // optionally, the Content-Type of the blob, which is then used in the response
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
return gwerr
}
logger := log.From(req.logger.With().Str(logAccountId, accountId).Str(UriParamBlobId, blobId))
ctx := req.ctx.WithLogger(logger)
return req.serveBlob(blobId, name, typ, ctx, accountId, w)
if err := req.serveBlob(blobId, name, typ, ctx, accountId, w); err != nil {
return false, req.error(accountId, err)
} else {
return true, req.noop(accountId)
}
})
}
func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Context, accountId string, w http.ResponseWriter) *Error {
func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Context, accountId string, w http.ResponseWriter) *Error { //NOSONAR
if typ == "" {
typ = DefaultBlobDownloadType
}
@@ -83,7 +88,12 @@ func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Con
}(blob.Body)
}
if jerr != nil {
return r.apiErrorFromJmap(jerr)
switch e := jerr.(type) {
case jmap.Error:
return r.apiErrorFromJmap(e)
default:
return apiError(r.errorId(), ErrorGeneric, withDetail(e.Error()))
}
}
if blob == nil {
w.WriteHeader(http.StatusNotFound)

View File

@@ -45,66 +45,9 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
func(addressbookId string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{InAddressBook: addressbookId}
},
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyUpdated, IsAscending: true}},
curryMapQuery(g.jmap.QueryContactCards),
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
curryQueryFunc(g.contacts),
)
/*
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
accountIds := single(accountId)
l := req.logger.With()
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return req.errorN(accountIds, err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.errorN(accountIds, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
if err != nil {
return req.errorN(accountIds, err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
filter := jmap.ContactCardFilterCondition{
InAddressBook: addressBookId,
}
var sortBy []jmap.ContactCardComparator
if sort, ok, resp := mapSort(accountIds, &req, DefaultContactSort, SupportedContactSortingProperties, mapContactCardSort); !ok {
return resp
} else {
sortBy = sort
}
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
contactsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryContactCards(accountIds, filter, sortBy, position, limit, true, ctx)
if jerr != nil {
return req.jmapErrorN(accountIds, jerr, sessionState, lang)
}
if contacts, ok := contactsByAccountId[accountId]; ok {
return req.respondN(accountIds, contacts, sessionState, ContactResponseObjectType, state, lang)
} else {
return req.notFoundN(accountIds, sessionState, ContactResponseObjectType, state)
}
})
*/
}
func (g *Groupware) GetContactById(w http.ResponseWriter, r *http.Request) {
@@ -116,8 +59,8 @@ func (g *Groupware) GetAllContacts(w http.ResponseWriter, r *http.Request) {
func(_ string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{}
},
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyUpdated, IsAscending: true}},
curryMapQuery(g.jmap.QueryContactCards),
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
curryQueryFunc(g.contacts),
)
}
@@ -138,3 +81,20 @@ func (g *Groupware) DeleteContact(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) ModifyContact(w http.ResponseWriter, r *http.Request) {
modify(Contact, w, r, g, g.jmap.UpdateContactCard)
}
func (g *Groupware) contacts(accountIds []string, qps QueryParamsSupplier, limit *uint, //NOSONAR
filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool,
ctx jmap.Context) (jmap.Result[*jmap.ContactCardSearchResults], NextToken, error) {
return squery(g.contactCardQuerySuppliers, accountIds, qps, limit, filter, sortBy, calculateTotal, ctx,
func(a, b jmap.ContactCard) int { return a.Created.Compare(b.Created) },
func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.ContactCard) *jmap.ContactCardSearchResults {
return &jmap.ContactCardSearchResults{
Results: results,
CanCalculateChanges: canCalculateChanges,
Position: position,
Limit: limit,
Total: total,
}
},
)
}

View File

@@ -24,7 +24,7 @@ import (
// Get the changes tp Emails since a certain State.
// @api:tags email,changes
func (g *Groupware) GetEmailChanges(w http.ResponseWriter, r *http.Request) {
changes(Email, w, r, g, func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.EmailChanges], jmap.Error) {
changes(Email, w, r, g, func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.EmailChanges], error) {
return g.jmap.GetEmailChanges(accountId, sinceState, true, g.config.maxBodyValueBytes, maxChanges, ctx)
})
}
@@ -42,26 +42,24 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request
fetchBodies := false
withThreads := true
query(Email, w, r, g, g.defaults.emailLimit,
func(req Request, accountId, containerId string, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[*jmap.EmailSearchResults], *Error) { //NOSONAR
result, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, position, anchor, anchorOffset, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
func(req Request, accountId string, containerId string, qp jmap.QueryParams, limit *uint, ctx jmap.Context) (jmap.Result[*jmap.EmailSearchResults], error) { //NOSONAR
result, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, qp, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
if jerr != nil {
return jmap.ZeroResult[*jmap.EmailSearchResults](), req.apiErrorFromJmap(req.observeJmapError(jerr))
return jmap.ZeroResult[*jmap.EmailSearchResults](), jerr
}
sanitized, err := req.sanitizeEmails(result.Payload.Results)
if err != nil {
return jmap.ZeroResult[*jmap.EmailSearchResults](), err
}
return jmap.RefineResult(result, func(orig *jmap.EmailSearchResults) *jmap.EmailSearchResults {
return jmap.RefineResultPayload(result, func(orig *jmap.EmailSearchResults) (*jmap.EmailSearchResults, bool, error) {
return &jmap.EmailSearchResults{
Results: sanitized,
Total: orig.Total,
Limit: orig.Limit,
Position: orig.Position,
CanCalculateChanges: orig.CanCalculateChanges,
}
}), nil
}, true, nil
})
},
)
}
@@ -69,45 +67,49 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request
func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NOSONAR
accept := r.Header.Get("Accept")
if accept == "message/rfc822" {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return false, req.error(accountId, err)
}
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return err
return false, req.error(accountId, err)
}
ids := strings.Split(id, ",")
if len(ids) != 1 {
return req.parameterError(UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
}
accountId, err := req.GetAccountIdForMail()
if err != nil {
return err
return false, req.parameterErrorResponse(accountId, UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
}
_, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
if err != nil {
return err
return false, req.error(accountId, err)
}
if ok {
return req.parameterError(QueryParamMarkAsSeen, fmt.Sprintf("when the Accept header is set to '%s', the API does not support setting %s", accept, QueryParamMarkAsSeen))
return false, req.parameterErrorResponse(accountId, QueryParamMarkAsSeen, fmt.Sprintf("when the Accept header is set to '%s', the API does not support setting %s", accept, QueryParamMarkAsSeen))
}
logger := log.From(req.logger.With().Str(logAccountId, log.SafeString(accountId)).Str("id", log.SafeString(id)).Str("accept", log.SafeString(accept)))
ctx := req.ctx.WithLogger(logger)
result, jerr := g.jmap.GetEmailBlobId(accountId, id, ctx)
if jerr != nil {
return req.apiErrorFromJmap(req.observeJmapError(jerr))
return false, req.jmapError(accountId, jerr, result)
}
if result.Payload == "" {
return nil
return true, req.noop(accountId)
} else {
name := result.Payload + ".eml"
typ := accept
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
return gwerr
return false, req.error(accountId, gwerr)
}
if err := req.serveBlob(result.Payload, name, typ, ctx, accountId, w); err != nil {
return false, req.error(accountId, err)
} else {
return true, req.noop(accountId)
}
return req.serveBlob(result.Payload, name, typ, ctx, accountId, w)
}
})
} else {
@@ -124,7 +126,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
}
ids := strings.Split(id, ",")
if len(ids) < 1 {
return req.parameterErrorResponse(single(accountId), UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
return req.parameterErrorResponse(accountId, UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
}
markAsSeen, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
@@ -147,7 +149,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
} else {
sanitized, err := req.sanitizeEmail(result.Payload.List[0])
if err != nil {
return req.error(accountId, err)
return req.error(accountId, req.apiError(err))
}
return req.respond(accountId, sanitized, EmailResponseObjectType, result)
}
@@ -163,7 +165,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
} else {
sanitized, err := req.sanitizeEmails(result.Payload.List)
if err != nil {
return req.error(accountId, err)
return req.error(accountId, req.apiError(err))
}
return req.respond(accountId, sanitized, EmailResponseObjectType, result)
}
@@ -199,9 +201,9 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
if attachmentSelector == nil {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return req.error(accountId, err)
ok, accountId, resp := req.needMailWithAccount()
if !ok {
return resp
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
@@ -219,27 +221,27 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
if len(result.Payload.List) < 1 {
return req.notFound(accountId, EmailResponseObjectType, result)
}
email, err := req.sanitizeEmail(result.Payload.List[0])
if err != nil {
return req.error(accountId, err)
if email, err := req.sanitizeEmail(result.Payload.List[0]); err != nil {
return req.error(accountId, req.apiError(err))
} else {
var body []jmap.EmailBodyPart = email.Attachments
return req.respond(accountId, body, EmailResponseObjectType, result)
}
var body []jmap.EmailBodyPart = email.Attachments
return req.respond(accountId, body, EmailResponseObjectType, result)
})
} else {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
mailAccountId, err := req.GetAccountIdForMail()
if err != nil {
return err
g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
ok, mailAccountId, resp := req.needMailWithAccount()
if !ok {
return false, resp
}
blobAccountId, err := req.GetAccountIdForBlob()
if err != nil {
return err
ok, blobAccountId, resp := req.needBloblWithAccount()
if !ok {
return false, resp
}
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return err
return false, req.error(mailAccountId, err)
}
l := req.logger.With().
@@ -251,15 +253,15 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
ctx := req.ctx.WithLogger(logger)
result, jerr := g.jmap.GetEmails(mailAccountId, single(id), false, 0, false, false, ctx)
if jerr != nil {
return req.apiErrorFromJmap(req.observeJmapError(jerr))
return false, req.jmapError(mailAccountId, jerr, result)
}
if len(result.Payload.List) < 1 {
return nil
return true, req.noop(mailAccountId)
}
email, err := req.sanitizeEmail(result.Payload.List[0])
if err != nil {
return err
email, gwerr := req.sanitizeEmail(result.Payload.List[0])
if gwerr != nil {
return false, req.error(mailAccountId, req.apiError(gwerr))
}
var attachment *jmap.EmailBodyPart = nil
for _, part := range email.Attachments {
@@ -269,7 +271,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}
}
if attachment == nil {
return nil
return true, req.noop(mailAccountId)
}
blob, lang, jerr := g.jmap.DownloadBlobStream(blobAccountId, attachment.BlobId, attachment.Name, attachment.Type, ctx)
@@ -282,11 +284,11 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}(blob.Body)
}
if jerr != nil {
return req.apiErrorFromJmap(jerr)
return false, req.jmapError(blobAccountId, jerr, nil)
}
if blob == nil {
w.WriteHeader(http.StatusNotFound)
return nil
return true, req.noop(blobAccountId)
}
if blob.Type != "" {
@@ -306,10 +308,10 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}
_, cerr := io.Copy(w, blob.Body)
if cerr != nil {
return req.observedParameterError(ErrorStreamingResponse)
return false, req.error(blobAccountId, req.observedParameterError(ErrorStreamingResponse))
}
return nil
return true, req.noop(blobAccountId)
})
}
}
@@ -413,13 +415,15 @@ func (g *Groupware) buildEmailFilter(req Request) (bool, jmap.EmailFilterElement
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit) // maximum number of results (size of a page)
v, ok, err := req.parseUIntParam(QueryParamLimit, uint(0)) // maximum number of results (size of a page)
if err != nil {
return false, nil, snippets, 0, "", nil, nil, nil, err
}
if ok {
l = l.Uint(QueryParamLimit, v)
limit = &v
} else {
limit = g.defaults.emailLimit
}
}
@@ -610,7 +614,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) { //NOSONA
}
sanitized, err := req.sanitizeEmail(result.Email)
if err != nil {
return req.error(accountId, err)
return req.error(accountId, req.apiError(err))
}
flattened[i] = EmailWithSnippets{
Email: sanitized,
@@ -619,7 +623,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) { //NOSONA
}
}
rlimit := &results.Limit
rlimit := results.Limit
if limit != nil && *limit == 0 {
rlimit = UintPtrZero
}
@@ -822,7 +826,7 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) {
}
return true, Response{}
},
func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], jmap.Error) {
func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], error) {
return g.jmap.CreateEmail(accountId, body, "", ctx)
},
)
@@ -848,7 +852,7 @@ func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) {
return true, Response{}
},
func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], jmap.Error) {
func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], error) {
ctx = ctx.WithLogger(log.From(ctx.Logger.With().Str("replaceId", replaceId)))
return g.jmap.CreateEmail(accountId, body, replaceId, ctx)
},
@@ -1255,11 +1259,13 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { //N
if results, ok := results.Payload[accountId]; ok {
duration := time.Since(before)
if jerr != nil {
_ = req.observeJmapError(jerr)
if j, ok := jerr.(jmap.Error); ok {
_ = req.observeJmapError(j)
}
l.Error().Err(jerr).Msgf("failed to query %v emails", RelationTypeSameSender)
} else {
req.observe(g.metrics.EmailSameSenderDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
related, err := req.sanitizeEmails(filterEmails(results.Emails, email))
related, err := req.sanitizeEmails(filterEmails(results.Results, email))
if err == nil {
l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameSender, len(related))
if len(related) > 0 {
@@ -1290,14 +1296,14 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { //N
}
})
sanitized, err := req.sanitizeEmail(email)
if err != nil {
return req.error(accountId, err)
if sanitized, err := req.sanitizeEmail(email); err != nil {
return req.error(accountId, req.apiError(err))
} else {
return req.respond(accountId, AboutEmailResponse{
Email: sanitized,
RequestId: reqId,
}, EmailResponseObjectType, result)
}
return req.respond(accountId, AboutEmailResponse{
Email: sanitized,
RequestId: reqId,
}, EmailResponseObjectType, result)
})
}
@@ -1637,7 +1643,7 @@ var sanitizableMediaTypes = []string{
"text/xhtml",
}
func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSONAR
func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *GroupwareError) { //NOSONAR
if !r.g.config.sanitize {
return source, nil
}
@@ -1649,7 +1655,7 @@ func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSO
if err != nil {
msg := fmt.Sprintf("failed to parse the mime type '%s'", p.Type)
r.logger.Error().Str("type", log.SafeString(p.Type)).Msg(msg)
return source, r.apiError(&ErrorFailedToSanitizeEmail, withDetail(msg))
return source, &ErrorFailedToSanitizeEmail
}
if slices.Contains(sanitizableMediaTypes, t) {
if already, done := memory[p.PartId]; !done {
@@ -1685,7 +1691,7 @@ func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSO
return source, nil
}
func (r *Request) sanitizeEmails(source []jmap.Email) ([]jmap.Email, *Error) {
func (r *Request) sanitizeEmails(source []jmap.Email) ([]jmap.Email, *GroupwareError) {
if !r.g.config.sanitize {
return source, nil
}

View File

@@ -16,72 +16,20 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
return jmap.CalendarEventFilterCondition{InCalendar: calendarId}
},
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
curryMapQuery(g.jmap.QueryCalendarEvents),
curryNoNextMapQuery(
g.jmap.QueryCalendarEvents,
func(a, b jmap.CalendarEvent) int { return 0 }, // TODO
func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.CalendarEvent) *jmap.CalendarEventSearchResults {
return &jmap.CalendarEventSearchResults{
Results: results,
CanCalculateChanges: canCalculateChanges,
Position: position,
Limit: limit,
Total: total,
}
},
),
)
/*
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return req.error(accountId, err)
}
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
filter := jmap.CalendarEventFilterCondition{
InCalendar: calendarId,
}
sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: false}}
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), filter, sortBy, position, limit, true, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
if events, ok := eventsByAccountId[accountId]; ok {
return req.respond(accountId, events, sessionState, EventResponseObjectType, state, lang)
} else {
return req.notFound(accountId, sessionState, EventResponseObjectType, state)
}
})
*/
}
func curryMapQuery[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
f func(accountIds []string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]SRES], jmap.Error),
) func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SRES], jmap.Error) {
return func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SRES], jmap.Error) { //NOSONAR
result, err := f(single(accountId), filter, sortBy, position, anchor, anchorOffset, limit, true, ctx)
if err != nil {
return jmap.ZeroResult[SRES](), err
} else {
return jmap.RefineResult(result, func(m map[string]SRES) SRES { return m[accountId] }), err
}
}
}
func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
@@ -89,7 +37,19 @@ func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
false,
func(_ string) jmap.CalendarEventFilterElement { return jmap.CalendarEventFilterCondition{} },
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
curryMapQuery(g.jmap.QueryCalendarEvents),
curryNoNextMapQuery(
g.jmap.QueryCalendarEvents,
func(a, b jmap.CalendarEvent) int { return 0 }, // TODO
func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.CalendarEvent) *jmap.CalendarEventSearchResults {
return &jmap.CalendarEventSearchResults{
Results: results,
CanCalculateChanges: canCalculateChanges,
Position: position,
Limit: limit,
Total: total,
}
},
),
)
}

View File

@@ -13,7 +13,7 @@ import (
//
// Note that there may be multiple Quota objects for different resource types.
func (g *Groupware) GetQuota(w http.ResponseWriter, r *http.Request) {
getFromMap(Quota, w, r, g, func(accountIds, _ []string, ctx jmap.Context) (jmap.Result[map[string]jmap.QuotaGetResponse], jmap.Error) {
getFromMap(Quota, w, r, g, func(accountIds, _ []string, ctx jmap.Context) (jmap.Result[map[string]jmap.QuotaGetResponse], error) {
return g.jmap.GetQuotas(accountIds, ctx)
})
}

View File

@@ -13,7 +13,7 @@ import (
//
// The VacationResponse object represents the state of vacation-response-related settings for an account.
func (g *Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
get(VacationResponse, w, r, g, func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.VacationResponseGetResponse], jmap.Error) {
get(VacationResponse, w, r, g, func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.VacationResponseGetResponse], error) {
return g.jmap.GetVacationResponse(accountId, ctx)
})
}
@@ -23,7 +23,7 @@ func (g *Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
// A vacation response sends an automatic reply when a message is delivered to the mail store, informing the original
// sender that their message may not be read for some time.
func (g *Groupware) SetVacation(w http.ResponseWriter, r *http.Request) {
modify(VacationResponse, w, r, g, func(accountId string, id string, change jmap.VacationResponseChange, ctx jmap.Context) (jmap.Result[jmap.VacationResponse], jmap.Error) {
modify(VacationResponse, w, r, g, func(accountId string, id string, change jmap.VacationResponseChange, ctx jmap.Context) (jmap.Result[jmap.VacationResponse], error) {
return g.jmap.SetVacationResponse(accountId, change, ctx)
})
}

View File

@@ -0,0 +1,97 @@
package groupware
import (
"errors"
"math/big"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var base = big.NewInt(int64(len(alphabet)))
// EncodeBytes converts any byte slice into a Base62 string
func EncodeBytesToBase62(src []byte) string {
if len(src) == 0 {
return ""
}
// 1. Convert the bytes into a single large big.Int
n := new(big.Int).SetBytes(src)
if n.Cmp(big.NewInt(0)) == 0 {
return string(alphabet[0])
}
// 2. Convert the big.Int to Base62
var result []byte
mod := new(big.Int)
for n.Cmp(big.NewInt(0)) > 0 {
n.DivMod(n, base, mod)
result = append(result, alphabet[mod.Int64()])
}
// 3. Handle leading zeros in the original byte array.
// Because math/big drops leading zeros, we must explicitly preserve them.
for _, b := range src {
if b != 0x00 {
break
}
result = append(result, alphabet[0])
}
// Reverse the result slice to get the correct order
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return string(result)
}
// DecodeBytes converts a Base62 string back into its original byte slice
func DecodeBytesFromBase62(src string) ([]byte, error) {
if len(src) == 0 {
return nil, nil
}
n := big.NewInt(0)
idx := big.NewInt(0)
// 1. Reconstruct the large integer from the Base62 characters
for i := 0; i < len(src); i++ {
pos := findAlphabetIndex(src[i])
if pos == -1 {
return nil, errors.New("invalid character in base62 string")
}
idx.SetInt64(int64(pos))
n.Mul(n, base)
n.Add(n, idx)
}
// 2. Extract the raw bytes from the big.Int
decoded := n.Bytes()
// 3. Re-prepend the leading zeros that were stripped during encoding
var leadingZeros int
for i := 0; i < len(src); i++ {
if src[i] != alphabet[0] {
break
}
leadingZeros++
}
if leadingZeros > 0 {
zeroBytes := make([]byte, leadingZeros)
decoded = append(zeroBytes, decoded...)
}
return decoded, nil
}
// Helper to find the index of a character in our alphabet
func findAlphabetIndex(char byte) int {
for i := 0; i < len(alphabet); i++ {
if alphabet[i] == char {
return i
}
}
return -1
}

View File

@@ -2,6 +2,7 @@ package groupware
import (
"context"
"fmt"
"net/http"
"strconv"
@@ -109,6 +110,11 @@ type GroupwareError struct {
Code string
Title string
Detail string
error
}
func (g GroupwareError) Error() string {
return fmt.Sprintf("[%s] %s: %s", g.Code, g.Title, g.Detail)
}
func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
@@ -756,10 +762,36 @@ func (r *Request) errorN(accountIds []string, err *Error) Response {
return errorResponse(accountIds, err, r.session.State, jmap.NoLanguage)
}
func (r *Request) jmapError(accountId string, err jmap.Error, result jmap.ResultMetadata) Response {
return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(err)), result.GetSessionState(), result.GetLanguage())
func (r *Request) jmapError(accountId string, err error, result jmap.ResultMetadata) Response {
switch e := err.(type) {
case jmap.Error:
if result != nil {
return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(e)), result.GetSessionState(), result.GetLanguage())
} else {
return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(e)), r.session.GetSessionState(), jmap.NoLanguage)
}
case GroupwareError:
errorId := r.errorId()
return r.error(accountId, apiError(errorId, e))
default:
errorId := r.errorId()
return r.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
}
func (r *Request) jmapErrorN(accountIds []string, err jmap.Error, result jmap.ResultMetadata) Response {
return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(err)), result.GetSessionState(), result.GetLanguage())
func (r *Request) jmapErrorN(accountIds []string, err error, result jmap.ResultMetadata) Response {
switch e := err.(type) {
case jmap.Error:
if result != nil {
return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(e)), result.GetSessionState(), result.GetLanguage())
} else {
return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(e)), r.session.GetSessionState(), jmap.NoLanguage)
}
case GroupwareError:
errorId := r.errorId()
return r.errorN(accountIds, apiError(errorId, e))
default:
errorId := r.errorId()
return r.errorN(accountIds, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
}

View File

@@ -83,11 +83,12 @@ type Job struct {
type groupwareConfig struct {
maxBodyValueBytes uint
sanitize bool
publicUrl *url.URL
}
type groupwareDefaults struct {
emailLimit uint
contactLimit uint
emailLimit *uint
contactLimit *uint
}
type Groupware struct {
@@ -112,6 +113,9 @@ type Groupware struct {
jobsChannel chan Job
// A threadsafe counter to generate the job IDs.
jobCounter atomic.Uint64
addressBookListSuppliers []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]
contactCardQuerySuppliers []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]
}
// An error during the Groupware initialization.
@@ -180,9 +184,9 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
sessionUrl := baseUrl.JoinPath(".well-known", "jmap")
defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0)
defaultEmailLimit := ptrIfNot(max(config.Mail.DefaultEmailLimit, 0), 0)
maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0)
defaultContactLimit := max(config.Mail.DefaultContactLimit, 0)
defaultContactLimit := ptrIfNot(max(config.Mail.DefaultContactLimit, 0), 0)
responseHeaderTimeout := max(config.Mail.ResponseHeaderTimeout, 0)
sessionCacheMaxCapacity := uint64(max(config.Mail.SessionCache.MaxCapacity, 0))
sessionCacheTtl := max(config.Mail.SessionCache.Ttl, 0)
@@ -383,6 +387,30 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
}
}
addressBookListSuppliers := []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]{}
contactCardQuerySuppliers := []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]{}
{
{
j := newJmapContactCardSupplier(&jmapClient)
addressBookListSuppliers = append(addressBookListSuppliers, j)
contactCardQuerySuppliers = append(contactCardQuerySuppliers, j)
}
{
m := newMockContactCardSupplier()
addressBookListSuppliers = append(addressBookListSuppliers, m)
contactCardQuerySuppliers = append(contactCardQuerySuppliers, m)
}
}
var publicUrl *url.URL
{
if u, err := url.Parse(config.HTTP.OpenCloudPublicURL); err != nil {
return nil, GroupwareInitializationError{Message: "failed to parse the public URL configuration setting", Err: err}
} else {
publicUrl = u
}
}
g := &Groupware{
mux: mux,
metrics: m,
@@ -399,10 +427,13 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
config: groupwareConfig{
maxBodyValueBytes: maxBodyValueBytes,
sanitize: sanitize,
publicUrl: publicUrl,
},
eventChannel: eventChannel,
jobsChannel: jobsChannel,
jobCounter: atomic.Uint64{},
eventChannel: eventChannel,
jobsChannel: jobsChannel,
jobCounter: atomic.Uint64{},
addressBookListSuppliers: addressBookListSuppliers,
contactCardQuerySuppliers: contactCardQuerySuppliers,
}
for w := 1; w <= workerPoolSize; w++ {
@@ -696,6 +727,36 @@ func (g *Groupware) sendResponse(w http.ResponseWriter, r *http.Request, respons
w.Header().Add("Content-Language", string(response.contentLanguage))
}
if response.next != "" && response.next != NoNextToken {
w.Header().Add("next", string(response.next)) // TODO add RESTful links for next?
base := g.config.publicUrl.JoinPath("/groupware/contacts")
limit := r.URL.Query().Get(QueryParamLimit)
// TODO add all the other query parameters for filter and sort
// options for rel: self, first, prev, next
{
u := base
q := u.Query()
q.Set(QueryParamNext, string(response.next))
if limit != "" {
q.Set(QueryParamLimit, limit)
}
u.RawQuery = q.Encode()
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="next">`, u.String()))
}
{
u := base
q := u.Query()
if limit != "" {
q.Set(QueryParamLimit, limit)
}
u.RawQuery = q.Encode()
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="first">`, u.String()))
}
}
notModified := false
{
etag := string(response.etag)
@@ -747,7 +808,7 @@ func (g *Groupware) respond(w http.ResponseWriter, r *http.Request, handler func
g.sendResponse(w, r, response)
}
func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) *Error) {
func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) (bool, Response)) {
cotx := r.Context()
sl := g.logger.SubloggerWithRequestID(cotx)
logger := &sl
@@ -796,15 +857,9 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(
ctx: ctx,
}
apierr := handler(req, w)
if apierr != nil {
g.log(apierr)
w.Header().Add("Content-Type", ContentTypeJsonApi)
render.Status(r, apierr.NumStatus)
w.WriteHeader(apierr.NumStatus)
if err := render.Render(w, r, errorResponses(*apierr)); err != nil {
logger.Error().Err(err).Msgf("failed to render error response")
}
ok, resp := handler(req, w)
if !ok {
g.sendResponse(w, r, resp)
}
}
@@ -833,3 +888,119 @@ func (g *Groupware) MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
func single[S any](s S) []S {
return []S{s}
}
type QueryParamsSupplier interface {
ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error)
ForAccountId(accountId string) (jmap.QueryParams, bool, error)
}
func mapQueryParams(supplierId string, accountIds []string, qps QueryParamsSupplier) (map[string]jmap.QueryParams, error) {
m := map[string]jmap.QueryParams{}
if supplierId == "" {
for _, accountId := range accountIds {
if q, ok, err := qps.ForAccountId(accountId); err != nil {
return nil, err
} else if ok {
m[accountId] = q
}
}
} else {
for _, accountId := range accountIds {
if q, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
return nil, err
} else if ok {
m[accountId] = q
}
}
}
return m, nil
}
type ErrorQueryParamsSupplier struct {
err error
}
func (s ErrorQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
return jmap.NullQueryParams, false, s.err
}
func (s ErrorQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
return jmap.NullQueryParams, false, s.err
}
var _ QueryParamsSupplier = ErrorQueryParamsSupplier{}
type FirstQueryParamsSupplier struct {
}
func (s FirstQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
return jmap.NullQueryParams, true, nil
}
func (s FirstQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
return jmap.NullQueryParams, true, nil
}
var _ QueryParamsSupplier = FirstQueryParamsSupplier{}
type StaticQueryParamsSupplier struct {
qp jmap.QueryParams
}
func (s StaticQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
return s.qp, true, nil
}
func (s StaticQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
return s.qp, true, nil
}
var _ QueryParamsSupplier = StaticQueryParamsSupplier{}
type MultiSupplierQueryParamsSupplier struct {
m map[string]map[string]jmap.QueryParams
}
func (s MultiSupplierQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
if a, ok := s.m[supplierId]; ok {
if b, ok := a[accountId]; ok {
return b, true, nil
}
}
return jmap.NullQueryParams, false, nil
}
func (s MultiSupplierQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
switch len(s.m) {
case 1:
for _, v := range s.m {
if b, ok := v[accountId]; ok {
return b, true, nil
}
}
return jmap.NullQueryParams, false, nil
case 0:
return jmap.NullQueryParams, false, nil
default:
return jmap.NullQueryParams, false, fmt.Errorf("unable to provide for account with multi supplier token")
}
}
var _ QueryParamsSupplier = MultiSupplierQueryParamsSupplier{}
type SingleSupplierQueryParamsSupplier struct {
m map[string]jmap.QueryParams
}
func (s SingleSupplierQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
return jmap.NullQueryParams, false, fmt.Errorf("unable to provide for supplier with single supplier token")
}
func (s SingleSupplierQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
if b, ok := s.m[accountId]; ok {
return b, true, nil
}
return jmap.NullQueryParams, false, nil
}
var _ QueryParamsSupplier = SingleSupplierQueryParamsSupplier{}

View File

@@ -0,0 +1,195 @@
package groupware
import (
"encoding/json"
"fmt"
"slices"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
type NextToken string
const NoNextToken = NextToken("")
func nextSingle(m map[string]jmap.QueryParams) (NextToken, error) {
if b, err := json.Marshal(m); err != nil {
return NoNextToken, err
} else {
return NextToken("S" + EncodeBytesToBase62(b)), nil
}
}
func nextMulti(m map[string]map[string]jmap.QueryParams) (NextToken, error) {
if b, err := json.Marshal(m); err != nil {
return NoNextToken, err
} else {
return NextToken("M" + EncodeBytesToBase62(b)), nil
}
}
func unnext(n NextToken) (QueryParamsSupplier, error) {
s := string(n)
if len(s) < 1 {
err := fmt.Errorf("invalid next token: is empty")
return ErrorQueryParamsSupplier{err: err}, err
}
t := s[0:1]
switch t {
case "S":
payload := s[1:]
if b, err := DecodeBytesFromBase62(payload); err != nil {
return ErrorQueryParamsSupplier{err: err}, err
} else {
var m map[string]jmap.QueryParams
if err := json.Unmarshal(b, &m); err != nil {
return ErrorQueryParamsSupplier{err: err}, err
} else {
return SingleSupplierQueryParamsSupplier{m: m}, nil
}
}
case "M":
payload := s[1:]
if b, err := DecodeBytesFromBase62(payload); err != nil {
return ErrorQueryParamsSupplier{err: err}, err
} else {
var m map[string]map[string]jmap.QueryParams
if err := json.Unmarshal(b, &m); err != nil {
return ErrorQueryParamsSupplier{err: err}, err
} else {
return MultiSupplierQueryParamsSupplier{m: m}, nil
}
}
default:
err := fmt.Errorf("invalid next token: unsupported type header '%s'", t)
return ErrorQueryParamsSupplier{err: err}, err
}
}
func curryNoNextMapQuery[SRES jmap.SearchResults[T], T jmap.Idable, FILTER any, COMP any](
f func(accountIds map[string]jmap.QueryParams, limit *uint, filter FILTER, sortBy []COMP, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]SRES], error),
sorter func(a, b T) int,
searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) SRES,
) func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) {
return func(_ Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) { //NOSONAR
if m, err := mapQueryParams("", accountIds, qps); err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
} else {
result, err := f(m, limit, filter, sortBy, true, ctx)
if err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
} else {
singleAccountId := ""
// TODO what about requests with zero accountIds, can these even happen at all?
if len(accountIds) == 1 {
// optimization: no need to combine the results of several accounts if the query was
// performed with a single accountId
singleAccountId = accountIds[0]
} else {
// the request includes multiple accounts, but that doesn't mean that we have results from
// all accounts: let's first calculate the number of results we have for each account
totalByAccount := structs.MapValues(result.Payload, func(a SRES) int { return len(a.GetResults()) })
// and let's now pick out the accounts that do have results
accountsWithResults := structs.FilterKeys(totalByAccount, func(_ string, total int) bool { return total > 0 })
if len(accountsWithResults) == 1 {
singleAccountId = accountsWithResults[0]
}
// TODO what if we don't have any results at all? (accountsWithResults == 0)
}
if singleAccountId != "" {
r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
r, ok := a[accountIds[0]]
return r, ok, nil
})
return r, NoNextToken, err
} else {
// more than one account with results
return flattenMultipleAccounts(accountIds, qps, limit, result, sorter, searchResultCtor)
}
}
}
}
}
func flattenMultipleAccounts[SRES jmap.SearchResults[T], T jmap.Idable](
accountIds []string,
qps QueryParamsSupplier,
limit *uint,
result jmap.Result[map[string]SRES],
sorter func(a, b T) int,
searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) SRES,
) (jmap.Result[SRES], NextToken, error) {
// 1. we need to flatten all the results, and then sort them in memory
all := []T{}
// 2. we need to sort them in memory in the same way as the filter
slices.SortFunc(all, sorter)
if limit != nil && *limit > 0 && len(all) > int(*limit) {
// 3. we need to cut if off to only keep *limit amount of elements
shrunk := make([]T, *limit)
copy(shrunk, all)
all = shrunk
cc := true
total := uint(0)
lastByAccountId := map[string]string{}
for accountId, searchResult := range result.Payload {
if !searchResult.GetCanCalculateChanges() {
cc = false
}
to := searchResult.GetTotal()
if to != nil {
total += *to
}
for _, item := range searchResult.GetResults() {
lastByAccountId[accountId] = item.GetId()
}
all = append(all, searchResult.GetResults()...)
}
// 4. we need to build the NextToken by taking the ID of the last item
// we kept after shrinking, but separately for each accountId
n := map[string]jmap.QueryParams{}
for accountId, lastId := range lastByAccountId {
n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
}
if err := fillMissingAccounts(qps, "", accountIds, n); err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
}
if t, err := nextSingle(n); err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
} else {
if r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
return searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, all), true, nil
}); err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
} else {
return r, t, nil
}
}
} else {
cc := true
total := uint(0)
for _, searchResult := range result.Payload {
if !searchResult.GetCanCalculateChanges() {
cc = false
}
to := searchResult.GetTotal()
if to != nil {
total += *to
}
}
// no need to compute a NextToken since there was no limit,
// which means that this Result contains all the elements,
// and thus there is no "next" to query for
if r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
return searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, all), true, nil
}); err != nil {
return jmap.ZeroResult[SRES](), NoNextToken, err
} else {
return r, NoNextToken, nil
}
}
}

View File

@@ -0,0 +1,100 @@
package groupware
import (
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
var petObjectType = jmap.ObjectType{Name: jmap.ObjectTypeName("Pet"), Namespaces: []jmap.JmapNamespace{}}
type Pet struct {
id string
name string
}
func (m Pet) GetId() string { return m.id }
func (m Pet) GetObjectType() jmap.ObjectType { return petObjectType }
var _ jmap.Idable = &Pet{}
type PetGetResponse struct {
AccountId string
State jmap.State
List []Pet
}
var _ jmap.GetResponse[Pet] = &PetGetResponse{}
func (r PetGetResponse) GetMarker() Pet { return Pet{} }
func (r PetGetResponse) GetState() jmap.State { return r.State }
func (r PetGetResponse) GetNotFound() []string { return []string{} }
func (r PetGetResponse) GetList() []Pet { return r.List }
type PetSearchResults jmap.SearchResultsTemplate[Pet]
var _ jmap.SearchResults[Pet] = &PetSearchResults{}
func (r *PetSearchResults) GetResults() []Pet { return r.Results }
func (r *PetSearchResults) GetCanCalculateChanges() jmap.ChangeCalculation {
return r.CanCalculateChanges
}
func (r *PetSearchResults) GetPosition() *uint { return r.Position }
func (r *PetSearchResults) GetLimit() *uint { return r.Limit }
func (r *PetSearchResults) GetTotal() *uint { return r.Total }
func (r *PetSearchResults) RemoveResults() { r.Results = nil }
func (r *PetSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *PetSearchResults) SetPosition(position *uint) { r.Position = position }
type PetComparator struct {
Property string
IsAscending bool
}
var _ jmap.Comparator[Pet] = &PetComparator{}
func (c PetComparator) GetMarker() Pet { return Pet{} }
type PetFilterElement interface {
_isAPetFilterElement() // marker method
IsNotEmpty() bool
jmap.FilterElement[Pet]
}
type PetFilterCondition struct {
id string
}
var _ PetFilterElement = &PetFilterCondition{}
var _ jmap.FilterElement[Pet] = &PetFilterCondition{}
func (f PetFilterCondition) GetMarker() Pet { return Pet{} }
func (f PetFilterCondition) _isAPetFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
func (f PetFilterCondition) IsNotEmpty() bool { //NOSONAR
if f.id != "" {
return true
}
return false
}
type PetFilterOperator struct {
Operator jmap.FilterOperatorTerm
Conditions []PetFilterElement
}
var _ PetFilterElement = &PetFilterOperator{}
var _ jmap.FilterElement[Pet] = &PetFilterOperator{}
func (f PetFilterOperator) GetMarker() Pet { return Pet{} }
func (o PetFilterOperator) _isAPetFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
func (o PetFilterOperator) IsNotEmpty() bool {
return len(o.Conditions) > 0
}

View File

@@ -223,7 +223,11 @@ func (r *Request) parameterError(param string, detail string) *Error {
withSource(&ErrorSource{Parameter: param}))
}
func (r *Request) parameterErrorResponse(accountIds []string, param string, detail string) Response {
func (r *Request) parameterErrorResponse(accountId string, param string, detail string) Response {
return r.error(accountId, r.parameterError(param, detail))
}
func (r *Request) parameterErrorResponseN(accountIds []string, param string, detail string) Response {
return r.errorN(accountIds, r.parameterError(param, detail))
}
@@ -239,7 +243,7 @@ func (r *Request) unsupportedQueryParams(accountIds []string, allowed supportedQ
q := r.r.URL.Query()
for n := range q {
if _, ok := allowed[n]; !ok {
return true, r.parameterErrorResponse(accountIds, n, "Unsupported query parameter")
return true, r.parameterErrorResponseN(accountIds, n, "Unsupported query parameter")
}
}
return false, Response{}

View File

@@ -35,6 +35,7 @@ type Response struct {
accountIds []string
sessionState jmap.SessionState
contentLanguage jmap.Language
next NextToken
}
func errorResponse(accountIds []string, err *Error, sessionState jmap.SessionState, contentLanguage jmap.Language) Response {
@@ -45,6 +46,7 @@ func errorResponse(accountIds []string, err *Error, sessionState jmap.SessionSta
etag: "",
sessionState: sessionState,
contentLanguage: contentLanguage,
next: NoNextToken,
}
}
@@ -56,6 +58,7 @@ func response(accountIds []string, body any, sessionState jmap.SessionState, con
etag: jmap.State(sessionState),
sessionState: sessionState,
contentLanguage: contentLanguage,
next: NoNextToken,
}
}
@@ -72,6 +75,7 @@ func etaggedResponse(accountIds []string, body any, sessionState jmap.SessionSta
objectType: objectType,
sessionState: sessionState,
contentLanguage: contentLanguage,
next: NoNextToken,
}
}
@@ -83,6 +87,23 @@ func (r *Request) respondN(accountIds []string, body any, objectType ResponseObj
return etaggedResponse(accountIds, body, result.GetSessionState(), objectType, result.GetState(), result.GetLanguage())
}
func etaggedNextResponse(accountIds []string, body any, sessionState jmap.SessionState, objectType ResponseObjectType, etag jmap.State, contentLanguage jmap.Language, next NextToken) Response {
return Response{
accountIds: accountIds,
body: body,
err: nil,
etag: etag,
objectType: objectType,
sessionState: sessionState,
contentLanguage: contentLanguage,
next: next,
}
}
func (r *Request) respondNext(accountId string, body any, objectType ResponseObjectType, result jmap.ResultMetadata, next NextToken) Response {
return etaggedNextResponse(single(accountId), body, result.GetSessionState(), objectType, result.GetState(), result.GetLanguage(), next)
}
/*
func etagOnlyResponse(body any, etag jmap.State, objectType ResponseObjectType, contentLanguage jmap.Language) Response {
return Response{
@@ -104,6 +125,7 @@ func noContentResponse(accountIds []string, sessionState jmap.SessionState) Resp
err: nil,
etag: jmap.State(sessionState),
sessionState: sessionState,
next: NoNextToken,
}
}
@@ -124,6 +146,7 @@ func noContentResponseWithEtag(accountIds []string, sessionState jmap.SessionSta
etag: etag,
objectType: objectType,
sessionState: sessionState,
next: NoNextToken,
}
}
@@ -164,6 +187,7 @@ func notFoundResponse(accountIds []string, sessionState jmap.SessionState, objec
objectType: objectType,
etag: etag,
sessionState: sessionState,
next: NoNextToken,
}
}
@@ -181,6 +205,7 @@ func etaggedNotFoundResponse(accountIds []string, sessionState jmap.SessionState
objectType: objectType,
sessionState: sessionState,
contentLanguage: contentLanguage,
next: NoNextToken,
}
}
@@ -196,6 +221,7 @@ func notImplementedResponse(accountIds []string, sessionState jmap.SessionState,
err: nil,
objectType: objectType,
sessionState: sessionState,
next: NoNextToken,
}
}

View File

@@ -71,6 +71,7 @@ const (
QueryParamEmailSubmissions = "submissions"
QueryParamId = "id"
QueryParamCalculateTotal = "total"
QueryParamNext = "next"
HeaderParamSince = "if-none-match"
)

View File

@@ -0,0 +1,425 @@
package groupware
import (
"encoding/base64"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
type Supplier[T jmap.Foo] interface {
GetId() string
IsMine(id string) bool
}
type ListSupplier[T jmap.Foo, G jmap.GetResponse[T]] interface {
GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[G], error)
Supplier[T]
}
type QuerySupplier[T jmap.Foo, R jmap.SearchResults[T], F jmap.FilterElement[T], C jmap.Comparator[T]] interface {
Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter F, sortBy []C, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]R], error)
Supplier[T]
}
// queryFunc func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
func curryQueryFunc[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
f func(accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, calculateTotal bool, ctx jmap.Context) (jmap.Result[SRES], NextToken, error),
) func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) {
return func(_ Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) { //NOSONAR
result, next, err := f(accountIds, qps, limit, filter, sortBy, true, ctx)
if err != nil {
return jmap.ZeroResult[SRES](), next, err
} else {
return result, next, err
}
}
}
func agg[T jmap.Idable, R jmap.GetResponse[T]](accountId string, supplierIds []string, responses []*R, //NOSONAR
ctor func(accountId string, state jmap.State, notFound []string, list []T) R) (R, error) {
if len(responses) < 1 {
var zero R
return zero, fmt.Errorf("requires at least one response")
}
lists := structs.Concat(structs.Map(responses, func(e *R) []T {
if e != nil {
return (*e).GetList()
} else {
var zero []T
return zero
}
})...)
states, err := structs.MeshMap(supplierIds, responses, func(id string, e *R) (string, jmap.State, bool) {
if e != nil {
state := (*e).GetState()
if state != jmap.EmptyState {
return id, state, true
} else {
return id, jmap.EmptyState, false
}
} else {
return "", jmap.EmptyState, false
}
})
if err != nil {
var zero R
return zero, err
}
state, err := combineState(states)
if err != nil {
var zero R
return zero, err
}
notFounds := structs.Concat(structs.Map(responses, func(e *R) []string {
if e != nil {
return (*e).GetNotFound()
} else {
return []string{}
}
})...)
return ctor(accountId, state, notFounds, lists), nil
}
func slist[T jmap.Idable, G jmap.GetResponse[T], S ListSupplier[T, G]](suppliers []S, accountId string, ids []string, ctx jmap.Context, //NOSONAR
ctor func(accountId string, state jmap.State, notFound []string, list []T) G) (jmap.Result[G], error) {
switch len(suppliers) {
case 0:
return jmap.ZeroResult[G](), nil
case 1:
return suppliers[0].GetAll(accountId, ids, ctx)
default:
results := make([]*jmap.Result[G], len(suppliers))
supplierIds := make([]string, len(suppliers))
for i, supplier := range suppliers {
supplierIds[i] = supplier.GetId()
localIds := []string{}
if len(ids) > 0 {
localIds = structs.Filter(ids, func(id string) bool { return supplier.IsMine(id) })
if len(localIds) == 0 {
results[i] = nil
continue
}
}
// we are not injecting id prefixes here for all the objects, as each supplier is responsible for doing that if necessary
if result, err := supplier.GetAll(accountId, localIds, ctx); err != nil {
return result, err
} else {
results[i] = &result
}
}
return jmap.RefineResultSlice(results, func(payloads []*G, sessionStates []*jmap.SessionState, states []*jmap.State, langs []*jmap.Language) (G, jmap.SessionState, jmap.State, jmap.Language, error) {
resp, err := agg(accountId, supplierIds, payloads, ctor)
if err != nil {
return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
}
m, err := structs.MeshMap(supplierIds, sessionStates, func(id string, state *jmap.SessionState) (string, jmap.SessionState, bool) {
if state != nil && *state != jmap.EmptySessionState {
return id, *state, true
} else {
return id, jmap.EmptySessionState, false
}
})
if err != nil {
return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
}
sessionState, err := combineState(m)
if err != nil {
return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
}
lang := jmap.NoLanguage
if f, ok := structs.First(langs, func(l *jmap.Language) bool { return l != nil && *l != jmap.NoLanguage }); ok {
lang = *f
}
return resp, sessionState, resp.GetState(), lang, nil
})
}
}
func fillMissingAccounts(qps QueryParamsSupplier, supplierId string, accountIds []string, n map[string]jmap.QueryParams) error {
for _, accountId := range accountIds {
if _, ok := n[accountId]; !ok {
// no result item was kept for this accountId
// => the next page must be either the same as what was requested for this one
// or, if not specified, it should be the first page, which is assumed when there
// is no entry in the "next" map for that accountId, so no need to store anything
// in the "next" map in that case
if qp, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
// unlikely to happen here, since that should already have been requested
// by the query function, but let's handle it gracefully here as well
return err
} else if ok {
// just use the same query parameters for that accountId the next time
n[accountId] = qp
} else {
// we didn't find any query parameters for this supplier and accountId,
// which means that the first page is requested, and thus we don't need
// to store anything in the "next" map either
}
}
}
return nil
}
func squery[T jmap.Idable, R jmap.SearchResults[T], S QuerySupplier[T, R, F, C], F jmap.FilterElement[T], C jmap.Comparator[T]]( //NOSONAR
suppliers []S, accountIds []string, qps QueryParamsSupplier, limit *uint, filter F, sortBy []C,
calculateTotal bool, ctx jmap.Context,
sorter func(T, T) int,
searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) R) (
jmap.Result[R], NextToken, error,
) {
s := len(suppliers)
if s == 0 {
return jmap.ZeroResult[R](), NoNextToken, nil
}
switch s {
case 1:
supplier := suppliers[0]
if result, err := supplier.Query(accountIds, qps, limit, filter, sortBy, calculateTotal, ctx); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else if len(accountIds) == 0 {
return jmap.ZeroResult[R](), NoNextToken, nil
} else if len(accountIds) == 1 {
accountId := accountIds[0]
// use anchor and anchorOffset and limit:
// the anchor for the next query is the ID of the last element in the results for this query
// using an anchor offset of +1
n := map[string]jmap.QueryParams{}
for accountId, payload := range result.Payload {
items := payload.GetResults()
last := items[len(items)-1]
n[accountId] = jmap.QueryParams{Anchor: last.GetId(), AnchorOffset: ptr(1)}
}
if nextToken, err := nextSingle(n); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else {
single, err := jmap.RefineResultPayload(result, func(m map[string]R) (R, bool, error) {
if r, ok := m[accountId]; ok {
return r, true, nil
} else {
return r, false, nil
}
})
return single, nextToken, err
}
} else {
// multiple accountIds => we need to merge/flatten the results, and we must use anchor and offset for the next page
payloads := []T{}
originals := map[string][]T{}
cc := true
total := uint(0)
for accountId, searchResult := range result.Payload {
if !searchResult.GetCanCalculateChanges() {
cc = false
}
to := searchResult.GetTotal()
if to != nil {
total += *to
}
originals[accountId] = searchResult.GetResults()
payloads = append(payloads, searchResult.GetResults()...)
}
// we have to sort everything in memory in order to be able to cut off if the limit is exceeded,
// but only if necessary
var r R
{
l := len(payloads)
if limit != nil && l > int(*limit) {
// the amount of items in payload should not exceed the requested limit
// but in order to determine which items to keep and which to discard, we need to sort
// them in memory first, to have a stable order from which to cut off and drop the
// intermingled search results that are beyond the limit
// e.g. if, with a limit of 10, supplier A gives us 10 results and supplier B gives us 5,
// we end up with 15 elements of which we may only return the first 10, but in order to
// reliably and repeatedly determine which those first 10 are, we need to sort them in
// memory first
slices.SortFunc(payloads, sorter)
var shrunk []T
shrunk = make([]T, int(*limit))
copy(shrunk, payloads)
payloads = shrunk
}
r = searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, payloads) // TODO can we determine the position here, instead of nil?
}
lastIdByAccountId := map[string]string{}
// not amazing, but since the accountId information is not attached to every single
// search result element (e.g. a ContactCard), we have to iterate over the original results that we
// have by accountId to find them again, in order to determine the "last ID"
// for each accountId, since we will have to persist that into the "next page"
// token in order to perform the query for each supplier and accountId
for _, item := range payloads {
for accountId, searchResult := range result.Payload {
if slices.IndexFunc(searchResult.GetResults(), func(t T) bool { return t.GetId() == item.GetId() }) >= 0 {
lastIdByAccountId[accountId] = item.GetId()
}
}
}
n := map[string]jmap.QueryParams{}
for accountId, lastId := range lastIdByAccountId {
// the anchor for the next query is the ID of the last element in the results for this query
// using an anchor offset of +1
n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
}
if err := fillMissingAccounts(qps, supplier.GetId(), accountIds, n); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
}
nextBySupplier := map[string]map[string]jmap.QueryParams{supplier.GetId(): n}
if nextToken, err := nextMulti(nextBySupplier); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else {
return jmap.Result[R]{
Payload: r,
SessionState: result.GetSessionState(),
State: result.GetState(),
Language: result.GetLanguage(),
}, nextToken, nil
}
}
default:
payloads := []T{}
originals := map[string]map[string][]T{}
cc := true
total := uint(0)
sessionState := jmap.EmptySessionState
states := map[string]jmap.State{}
lang := jmap.NoLanguage
for _, supplier := range suppliers {
// we are not injecting id prefixes here for all the objects, as each supplier is responsible for doing that if necessary
if result, err := supplier.Query(accountIds, qps, limit, filter, sortBy, calculateTotal, ctx); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else {
if result.GetSessionState() != jmap.EmptySessionState {
sessionState = result.GetSessionState()
}
states[supplier.GetId()] = result.GetState()
if result.GetLanguage() != jmap.NoLanguage {
lang = result.GetLanguage()
}
// iterate over results by accountId and flatten everything into the 'payloads' array
o := map[string][]T{}
for accountId, searchResult := range result.Payload {
o[accountId] = searchResult.GetResults()
if !searchResult.GetCanCalculateChanges() {
cc = false
}
to := searchResult.GetTotal()
if to != nil {
total += *to
}
payloads = append(payloads, searchResult.GetResults()...)
}
originals[supplier.GetId()] = o
}
}
// we have to sort everything in memory in order to be able to cut off if the limit is exceeded,
// but only if necessary
var r R
{
l := len(payloads)
slices.SortFunc(payloads, sorter)
if limit != nil && l > int(*limit) {
// the amount of items in payload should not exceed the requested limit
// but in order to determine which items to keep and which to discard, we need to sort
// them in memory first, to have a stable order from which to cut off and drop the
// intermingled search results that are beyond the limit
// e.g. if, with a limit of 10, supplier A gives us 10 results and supplier B gives us 5,
// we end up with 15 elements of which we may only return the first 10, but in order to
// reliably and repeatedly determine which those first 10 are, we need to sort them in
// memory first
var shrunk []T
shrunk = make([]T, int(*limit))
copy(shrunk, payloads)
payloads = shrunk
}
r = searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, payloads) // TODO cen we provide the position here instead of nil?
}
lastIdBySupplierByAccountId := map[string]map[string]string{}
// not amazing, but since the accountId and supplier information is not attached to every single
// search result element (e.g. a ContactCard), we have to iterate over the original results that we
// kept by supplier and by accountId to find them again, in order to determine the "last ID"
// for each supplier and for each accountId, since we will have to persist that into the "next page"
// token in order to perform the query for each supplier and accountId
for _, item := range payloads {
for supplierId, o := range originals {
for accountId, items := range o {
if slices.IndexFunc(items, func(t T) bool { return t.GetId() == item.GetId() }) >= 0 {
if _, ok := lastIdBySupplierByAccountId[supplierId]; !ok {
lastIdBySupplierByAccountId[supplierId] = map[string]string{}
}
lastIdBySupplierByAccountId[supplierId][accountId] = item.GetId()
}
}
}
}
nextBySupplier := map[string]map[string]jmap.QueryParams{}
for supplierId, m := range lastIdBySupplierByAccountId {
n := map[string]jmap.QueryParams{}
for accountId, lastId := range m {
// the anchor for the next query is the ID of the last element in the results for this query
// using an anchor offset of +1
n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
}
if err := fillMissingAccounts(qps, supplierId, accountIds, n); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
}
nextBySupplier[supplierId] = n
}
if nextToken, err := nextMulti(nextBySupplier); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else {
if state, err := combineState(states); err != nil {
return jmap.ZeroResult[R](), NoNextToken, err
} else {
return jmap.Result[R]{
Payload: r,
SessionState: sessionState,
State: state,
Language: lang,
}, nextToken, nil
}
}
}
}
const combinedStateEncodingPrefix = "="
func combineState[S jmap.State | jmap.SessionState](m map[string]S) (S, error) {
if b, err := json.Marshal(m); err != nil {
return "", err
} else {
s := combinedStateEncodingPrefix + base64.RawURLEncoding.EncodeToString(b)
return S(s), nil
}
}
func splitState[S jmap.State | jmap.SessionState](state S) (map[string]S, error) {
s := string(state)
if strings.HasPrefix(s, combinedStateEncodingPrefix) {
if b, err := base64.RawURLEncoding.DecodeString(s[len(combinedStateEncodingPrefix):]); err != nil {
return nil, err
} else {
m := map[string]S{}
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
} else {
return m, nil
}
}
} else {
return map[string]S{"jmap": state}, nil
}
}

View File

@@ -0,0 +1,223 @@
package groupware
import (
"slices"
"strings"
"testing"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/stretchr/testify/require"
)
type PetSupplier struct {
id string
petsByAccountId map[string][]Pet
}
// var _ ListSupplier[Pet, PetGetResponse] = &PetSupplier{}
var _ QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator] = &PetSupplier{}
func (s *PetSupplier) GetId() string {
return s.id
}
func (s *PetSupplier) IsMine(id string) bool {
return strings.HasPrefix(id, s.id+":")
}
func (s *PetSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter PetFilterElement, sortBy []PetComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*PetSearchResults], error) {
return inmemquery(s.id, s.petsByAccountId, accountIds, qps, limit, calculateTotal,
func(results []Pet, canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint) *PetSearchResults {
return &PetSearchResults{Results: results, CanCalculateChanges: canCalculateChanges, Position: position, Limit: limit, Total: total}
},
)
}
func inmemquery[T jmap.Idable, R jmap.SearchResults[T]](
supplierId string,
store map[string][]T,
accountIds []string,
qps QueryParamsSupplier, limit *uint,
calculateTotal bool,
searchResultCtor func(results []T, canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint) R,
) (jmap.Result[map[string]R], error) {
payload := make(map[string]R, len(accountIds))
for _, accountId := range accountIds {
qp := jmap.NullQueryParams
if q, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
return jmap.ZeroResult[map[string]R](), err
} else if ok {
qp = q
}
items := store[accountId]
if items == nil {
items = []T{}
}
total := len(items)
all := []T{}
p := uint(qp.Position)
if qp.Position < total {
all = items[qp.Position:]
}
if qp.Anchor != "" {
a := slices.IndexFunc(all, func(e T) bool { return e.GetId() == qp.Anchor })
if a >= 0 {
if qp.AnchorOffset != nil {
a += int(*qp.AnchorOffset)
} else {
a += 1
}
p += uint(a)
if a < len(all) {
all = all[a:]
} else {
all = []T{}
}
} else {
all = []T{}
}
}
if limit != nil {
if len(all) > int(*limit) {
all = all[:int(*limit)]
}
}
var t *uint = nil
if calculateTotal {
ut := uint(total)
t = &ut
}
res := searchResultCtor(all, false, &p, limit, t)
payload[accountId] = res
}
return jmap.Result[map[string]R]{
Payload: payload,
SessionState: jmap.EmptySessionState,
State: jmap.EmptyState,
Language: jmap.NoLanguage,
}, nil
}
func pets(
suppliers []QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator],
accountIds []string, qps QueryParamsSupplier, limit *uint,
filter PetFilterElement, sortBy []PetComparator,
calculateTotal bool,
ctx jmap.Context) (jmap.Result[*PetSearchResults], NextToken, error) {
return squery(suppliers, accountIds, qps, limit, filter, sortBy, calculateTotal, ctx,
func(a, b Pet) int { return strings.Compare(a.name, b.name) },
func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []Pet) *PetSearchResults {
return &PetSearchResults{
Results: results,
CanCalculateChanges: canCalculateChanges,
Position: position,
Limit: limit,
Total: total,
}
},
)
}
func TestSquery(t *testing.T) {
require := require.New(t)
suppliers := []QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator]{
&PetSupplier{
id: "X",
petsByAccountId: map[string][]Pet{
"a": {
{id: "X:1", name: "ace"},
{id: "X:2", name: "bella"},
},
"b": {
{id: "X:3", name: "mitzi"},
},
},
},
&PetSupplier{
id: "Y",
petsByAccountId: map[string][]Pet{
"a": {
{id: "Y:1", name: "cupcake"},
{id: "Y:2", name: "elvis"},
},
"c": {
{id: "Y:3", name: "fluffy"},
{id: "Y:4", name: "rambo"},
},
},
},
}
f := func(accountIds []string, position int, anchor string, anchorOffset *int, limit *uint) (jmap.Result[*PetSearchResults], NextToken, error) {
return pets(suppliers, accountIds,
StaticQueryParamsSupplier{qp: jmap.QueryParams{Position: position, Anchor: anchor, AnchorOffset: anchorOffset}}, limit,
PetFilterCondition{}, []PetComparator{{Property: "id", IsAscending: true}},
true, jmap.Context{},
)
}
n := func(accountIds []string, nextToken NextToken, limit *uint) (jmap.Result[*PetSearchResults], NextToken, error) {
if qps, err := unnext(nextToken); err != nil {
return jmap.ZeroResult[*PetSearchResults](), NoNextToken, err
} else {
return pets(suppliers, accountIds, qps, limit, PetFilterCondition{}, []PetComparator{{Property: "id", IsAscending: true}}, true, jmap.Context{})
}
}
{
res, n, err := f([]string{"a", "b", "c"}, 0, "", nil, nil)
require.NoError(err)
require.Len(res.Payload.Results, 7)
require.Equal(uint(len(res.Payload.Results)), *res.Payload.Total)
{
require.Equal("ace", res.Payload.Results[0].name)
require.Equal("bella", res.Payload.Results[1].name)
require.Equal("cupcake", res.Payload.Results[2].name)
require.Equal("elvis", res.Payload.Results[3].name)
require.Equal("fluffy", res.Payload.Results[4].name)
require.Equal("mitzi", res.Payload.Results[5].name)
require.Equal("rambo", res.Payload.Results[6].name)
}
require.Equal(jmap.IncapableOfChangeCalculation, res.Payload.CanCalculateChanges)
require.Nil(res.Payload.Limit)
require.Equal(uint(0), *res.Payload.Position)
{
m, err := unnext(n)
require.NoError(err)
require.NotEmpty(m)
}
}
var nextToken NextToken
{
res, n, err := f([]string{"a", "b", "c"}, 0, "", nil, uintPtr(4))
nextToken = n
require.NoError(err)
require.Len(res.Payload.Results, 4)
require.Equal(uint(7), *res.Payload.Total)
{
require.Equal("ace", res.Payload.Results[0].name)
require.Equal("bella", res.Payload.Results[1].name)
require.Equal("cupcake", res.Payload.Results[2].name)
require.Equal("elvis", res.Payload.Results[3].name)
}
require.Equal(jmap.IncapableOfChangeCalculation, res.Payload.CanCalculateChanges)
require.Equal(uint(4), *res.Payload.Limit)
require.Equal(uint(0), *res.Payload.Position)
{
m, err := unnext(n)
require.NoError(err)
require.NotEmpty(m)
}
}
{
res, _, err := n([]string{"a", "b", "c"}, nextToken, uintPtr(4))
require.NoError(err)
require.Equal(uint(7), *res.Payload.Total)
require.Len(res.Payload.Results, 3)
{
require.Equal("fluffy", res.Payload.Results[4].name)
require.Equal("mitzi", res.Payload.Results[5].name)
require.Equal("rambo", res.Payload.Results[6].name)
}
}
}

View File

@@ -10,12 +10,12 @@ import (
// Create a new {{.Name}} using the JSON payload in the body of the `{{.Verb}}` operation.
// @api:response 200:T returns the {{.Name}} that was just created
// @api:body CHANGE the {{.Name}} to create
func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
bodyFunc func(r Request, accountId string, body *CHANGE, ctx jmap.Context) (bool, Response),
createFunc func(accountId string, change CHANGE, ctx jmap.Context) (jmap.Result[*T], jmap.Error),
createFunc func(accountId string, change CHANGE, ctx jmap.Context) (jmap.Result[*T], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -43,11 +43,20 @@ func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
}
}
result, jerr := createFunc(accountId, create, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
if result, err := createFunc(accountId, create, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.respond(accountId, result.Payload, o.responseType, result)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -57,7 +66,7 @@ func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.G
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], jmap.Error),
getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -72,15 +81,27 @@ func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.G
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := getFunc(accountId, []string{}, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
if result, err := getFunc(accountId, []string{}, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.respond(accountId, result.Payload, o.responseType, result)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}
var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamAnchor, QueryParamAnchorOffset, QueryParamLimit)
// var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamAnchor, QueryParamAnchorOffset, QueryParamLimit)
var firstQueryParams = toSupportedQueryParams(QueryParamLimit)
var nextQueryParams = toSupportedQueryParams(QueryParamNext, QueryParamLimit)
var noQueryParams = toSupportedQueryParams()
// Retrieve all the {{.Name}} with support for paging using the {{.QueryParam.QueryParamPosition.Name}} and {{.QueryParam.QueryParamLimit.Name}} query parameters.
// @api:response 200:SEARCHRESULTS returns the {{.Names}} within the requested range, as well as the total amount of {{.Names}}
@@ -91,7 +112,7 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
withContainerId bool,
filterFunc func(containerId string) FILTER,
sortBy []COMP,
queryFunc func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], jmap.Error),
queryFunc func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -100,31 +121,6 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
anchor, ok := req.getStringParam(QueryParamAnchor, "")
if ok {
l = l.Str(QueryParamAnchor, log.SafeString(anchor))
}
var anchorOffset *int = nil
{
v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamAnchorOffset, v)
anchorOffset = &v
}
}
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, uint(0))
@@ -137,6 +133,51 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
}
}
var qps QueryParamsSupplier
var supportedQueryParams = noQueryParams
{
if s, ok := req.getStringParam(QueryParamNext, ""); ok {
supportedQueryParams = nextQueryParams
if n, err := unnext(NextToken(s)); err != nil {
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
} else {
qps = n
}
} else {
supportedQueryParams = firstQueryParams
qps = FirstQueryParamsSupplier{}
/*
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
anchor, ok := req.getStringParam(QueryParamAnchor, "")
if ok {
l = l.Str(QueryParamAnchor, log.SafeString(anchor))
}
var anchorOffset *int = nil
{
v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamAnchorOffset, v)
anchorOffset = &v
}
}
*/
}
}
containerId := ""
if withContainerId && o.containerUriParamName != "" {
var err *Error
@@ -147,33 +188,28 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
l = l.Str(o.containerUriParamName, log.SafeString(containerId))
}
if notok, resp := req.unsupportedQueryParams(single(accountId), paginationQueryParams); notok {
if notok, resp := req.unsupportedQueryParams(single(accountId), supportedQueryParams); notok {
return resp
}
filter := filterFunc(containerId)
jmaplimit := limit
if limit != nil && *limit == 0 {
jmaplimit = UintPtrOne
}
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := queryFunc(req, accountId, filter, sortBy, position, anchor, anchorOffset, jmaplimit, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
if result, nextNextToken, err := queryFunc(req, single(accountId), qps, limit, filter, sortBy, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.respondNext(accountId, result.Payload, o.responseType, result, nextNextToken)
}
if limit != nil && *limit == 0 {
result.Payload.RemoveResults()
result.Payload.SetLimit(UintPtrZero)
}
if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
result.Payload.SetPosition(nil)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -183,8 +219,8 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
defaultLimit uint,
queryFunc func(req Request, accountId string, containerId string, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], *Error),
defaultLimit *uint,
queryFunc func(req Request, accountId string, containerId string, qp jmap.QueryParams, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -203,78 +239,134 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
l = l.Str(o.containerUriParamName, log.SafeString(containerId))
}
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
anchor, ok := req.getStringParam(QueryParamAnchor, "")
if ok {
l = l.Str(QueryParamAnchor, log.SafeString(anchor))
}
var anchorOffset *int = nil
{
v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamAnchorOffset, v)
anchorOffset = &v
}
}
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, defaultLimit)
v, ok, err := req.parseUIntParam(QueryParamLimit, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, v)
limit = &v
} else if defaultLimit > 0 {
limit = &defaultLimit
} else {
limit = defaultLimit
}
}
var qp jmap.QueryParams
var supportedQueryParams = noQueryParams
{
if s, ok := req.getStringParam(QueryParamNext, ""); ok {
supportedQueryParams = nextQueryParams
if n, err := unnext(NextToken(s)); err != nil {
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
} else {
if q, ok, err := n.ForAccountId(accountId); err != nil {
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
} else if ok {
qp = q
} else {
qp = jmap.NullQueryParams
}
}
} else {
supportedQueryParams = firstQueryParams
qp = jmap.NullQueryParams
}
}
/*
position, ok, err := req.parseIntParam(QueryParamPosition, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamPosition, position)
}
anchor, ok := req.getStringParam(QueryParamAnchor, "")
if ok {
l = l.Str(QueryParamAnchor, log.SafeString(anchor))
}
var anchorOffset *int = nil
{
v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Int(QueryParamAnchorOffset, v)
anchorOffset = &v
}
}
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, defaultLimit)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, v)
limit = &v
} else if defaultLimit > 0 {
limit = &defaultLimit
}
}
*/
if notok, resp := req.unsupportedQueryParams(single(accountId), supportedQueryParams); notok {
return resp
}
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
jmaplimit := limit
if limit != nil && *limit == 0 {
jmaplimit = UintPtrOne
}
/*
jmaplimit := limit
if limit != nil && *limit == 0 {
jmaplimit = UintPtrOne
}
*/
result, err := queryFunc(req, accountId, containerId, position, anchor, anchorOffset, jmaplimit, ctx)
if err != nil {
return req.error(accountId, err)
}
if result, err := queryFunc(req, accountId, containerId, qp, limit, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
/*
if limit != nil && *limit == 0 {
result.Payload.RemoveResults()
result.Payload.SetLimit(UintPtrZero)
}
if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
result.Payload.SetPosition(nil)
}
*/
if limit != nil && *limit == 0 {
result.Payload.RemoveResults()
result.Payload.SetLimit(UintPtrZero)
return req.respond(accountId, result.Payload, o.responseType, result)
}
if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
result.Payload.SetPosition(nil)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}
// Retrieve a specific {{.Name}} referenced by its unique identifier as specified in the path parameter `{{.UriParamName}}` in the path `{{.Path}}`
// @api:response 200:T returns the {{.Name}} that matches the requested identifier, if it exists
// @api:response 404 when there is no {{.Name}} for the requested identifier
func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]](
func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], jmap.Error),
getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -298,20 +390,28 @@ func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetR
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := getFunc(accountId, ids, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
}
n := len(result.Payload.GetList())
switch n {
case 0:
return req.notFound(accountId, ContactResponseObjectType, result)
case 1:
return req.respond(accountId, result.Payload.GetList()[0], ContactResponseObjectType, result)
default:
logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, ids)
return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
if result, err := getFunc(accountId, ids, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
n := len(result.Payload.GetList())
switch n {
case 0:
return req.notFound(accountId, ContactResponseObjectType, result)
case 1:
return req.respond(accountId, result.Payload.GetList()[0], ContactResponseObjectType, result)
default:
logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, ids)
return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
}
}
})
}
@@ -319,11 +419,11 @@ func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetR
// Retrieve a specific {{.Name}} referenced by its unique identifier as specified in the path parameter `{{.UriParamName}}` in the path `{{.Path}}`
// @api:response 200:T returns the {{.Name}} that matches the requested identifier, if it exists
// @api:response 404 when there is no {{.Name}} for the requested identifier
func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]](
func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
getFunc func(accountIds []string, ids []string, ctx jmap.Context) (jmap.Result[map[string]RESP], jmap.Error),
getFunc func(accountIds []string, ids []string, ctx jmap.Context) (jmap.Result[map[string]RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -343,24 +443,32 @@ func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jm
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := getFunc(single(accountId), single(id), ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
}
if objs, ok := result.Payload[accountId]; ok {
n := len(objs.GetList())
switch n {
case 0:
return req.notFound(accountId, ContactResponseObjectType, result)
case 1:
return req.respond(accountId, objs.GetList()[0], ContactResponseObjectType, result)
if result, err := getFunc(single(accountId), single(id), ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, id)
return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.notFound(accountId, ContactResponseObjectType, result)
if objs, ok := result.Payload[accountId]; ok {
n := len(objs.GetList())
switch n {
case 0:
return req.notFound(accountId, ContactResponseObjectType, result)
case 1:
return req.respond(accountId, objs.GetList()[0], ContactResponseObjectType, result)
default:
logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, id)
return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
}
} else {
return req.notFound(accountId, ContactResponseObjectType, result)
}
}
})
}
@@ -370,11 +478,11 @@ var changesSupportedQueryParams = toSupportedQueryParams(QueryParamMaxChanges)
// Retrieve the changes that occured for {{.Name}}, optionally since an opaque state specified using the header `{{.HeaderParam.HeaderParamSince}}`,
// optionally bounded by the query parameter `{{.QueryParam.QueryParamMaxChanges}}`.
// @api:response 200:CHANGES returns the changes to {{.Names}}: created, updated, and identifiers of destroyed {{.Names}}
func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
changesFunc func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[CHANGES], jmap.Error),
changesFunc func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[CHANGES], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -402,12 +510,20 @@ func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := changesFunc(accountId, sinceState, maxChanges, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
if result, err := changesFunc(accountId, sinceState, maxChanges, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.respond(accountId, result.Payload, o.responseType, result)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -419,7 +535,7 @@ func delete[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], jmap.Error),
deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -439,27 +555,35 @@ func delete[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := deleteFunc(accountId, single(id), ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
}
for _, e := range result.Payload {
desc := e.Description
if desc != "" {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
withDetail(e.Description),
))
} else {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
))
if result, err := deleteFunc(accountId, single(id), ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
for _, e := range result.Payload {
desc := e.Description
if desc != "" {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
withDetail(e.Description),
))
} else {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
))
}
}
return req.noContent(accountId, o.responseType, result)
}
return req.noContent(accountId, o.responseType, result)
})
}
@@ -473,7 +597,7 @@ func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSO
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], jmap.Error),
deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -524,27 +648,35 @@ func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSO
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := deleteFunc(accountId, ids, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
}
for _, e := range result.Payload {
desc := e.Description
if desc != "" {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
withDetail(e.Description),
))
} else {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
))
if result, err := deleteFunc(accountId, ids, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
for _, e := range result.Payload {
desc := e.Description
if desc != "" {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
withDetail(e.Description),
))
} else {
return req.error(accountId, apiError(
req.errorId(),
o.failedToDeleteError,
))
}
}
return req.noContent(accountId, o.responseType, result)
}
return req.noContent(accountId, o.responseType, result)
})
}
@@ -554,7 +686,7 @@ func modify[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
updateFunc func(accountId string, id string, change CHANGE, ctx jmap.Context) (jmap.Result[T], jmap.Error),
updateFunc func(accountId string, id string, change CHANGE, ctx jmap.Context) (jmap.Result[T], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -580,10 +712,19 @@ func modify[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
result, jerr := updateFunc(accountId, id, change, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, result)
if result, err := updateFunc(accountId, id, change, ctx); err != nil {
switch e := err.(type) {
case jmap.Error:
return req.jmapError(accountId, e, result)
case GroupwareError:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, e))
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
return req.respond(accountId, result.Payload, o.responseType, result)
}
return req.respond(accountId, result.Payload, o.responseType, result)
})
}

View File

@@ -33,3 +33,11 @@ func ptrIf[T any | uint | int | bool](t T, predicate bool) *T {
return nil
}
}
func ptrIfNot[T comparable](t T, notThis T) *T {
if t != notThis {
return &t
} else {
return nil
}
}