groupware: fully implement suppliers for addressbooks and contacts

* implement supplier templating to allow addressbooks and contacts to
   come from various sources ("suppliers", thus) as opposed to solely
   from JMAP

 * add creating, deleting, updating, getting changes

 * add some search filter parameters, currently incomplete
This commit is contained in:
Pascal Bleser
2026-06-30 18:42:31 +02:00
parent e1bd8e285f
commit c56d726b9b
24 changed files with 710 additions and 256 deletions

View File

@@ -47,12 +47,16 @@ type VacationResponseChange struct {
HtmlBody string `json:"htmlBody,omitempty"`
}
var _ Change = VacationResponseChange{}
var _ Change[VacationResponse] = VacationResponseChange{}
func (m VacationResponseChange) AsPatch() (PatchObject, error) {
return toPatchObject(m)
}
func (m VacationResponseChange) GetMarker() VacationResponse {
return VacationResponse{}
}
type VacationResponseChanges ChangesTemplate[VacationResponse]
var _ Changes[VacationResponse] = VacationResponseChanges{}

View File

@@ -1744,7 +1744,7 @@ func deepEqual[T any](t *testing.T, expected, actual T) {
require.Empty(t, diff)
}
func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](t *testing.T, //NOSONAR
func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change[OBJ]](t *testing.T, //NOSONAR
acc func(session *Session) AccountId,
obj func(RESP) []OBJ,
id func(OBJ) string,

View File

@@ -29,6 +29,12 @@ func (o ObjectType) String() string {
// For example, `"2014-10-30T06:12:00Z"`.
type UTCDate string
// Time Format string for parsing a `UTCDate`.
//
// According to Section 1.4 of RFC 8620 (JMAP Core), the specification strictly dictates that for a UTCDate type,
// the time-offset component MUST be "Z". Other timezone numerical specs (like +02:00) are explicitly forbidden.
const UTCDateFormat = "2026-06-29T15:59:59Z"
// Where `LocalDate` is given as a type, it means a string in the same format as `Date`
// (see [RFC8620, Section 1.4](https://www.rfc-editor.org/rfc/rfc8620.html#section-1.4)),
// but with the time-offset omitted from the end.
@@ -1370,8 +1376,9 @@ type SetResponse[T Foo] interface {
GetNewState() State
}
type Change interface {
type Change[T Foo] interface {
AsPatch() (PatchObject, error)
GetMarker() T
}
type ChangesCommand[T Foo] interface {
@@ -1755,12 +1762,16 @@ type MailboxChange struct {
IsSubscribed *bool `json:"isSubscribed,omitempty"`
}
var _ Change = MailboxChange{}
var _ Change[Mailbox] = MailboxChange{}
func (m MailboxChange) AsPatch() (PatchObject, error) {
return toPatchObject(m)
}
func (m MailboxChange) GetMarker() Mailbox {
return Mailbox{}
}
type MailboxGetCommand struct {
AccountId AccountId `json:"accountId"`
Ids []string `json:"ids,omitempty"`
@@ -3634,12 +3645,16 @@ type EmailChange struct {
Attachments []EmailBodyPart `json:"attachments,omitempty"`
}
var _ Change = EmailChange{}
var _ Change[Email] = EmailChange{}
func (e EmailChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
func (m EmailChange) GetMarker() Email {
return Email{}
}
type EmailSetCommand struct {
// The id of the account to use.
AccountId AccountId `json:"accountId"`
@@ -4091,12 +4106,16 @@ type IdentityChange struct {
HtmlSignature *string `json:"htmlSignature,omitempty"`
}
var _ Change = IdentityChange{}
var _ Change[Identity] = IdentityChange{}
func (i IdentityChange) AsPatch() (PatchObject, error) {
return toPatchObject(i)
}
func (m IdentityChange) GetMarker() Identity {
return Identity{}
}
type IdentityGetResponse struct {
AccountId AccountId `json:"accountId"`
State State `json:"state"`
@@ -4340,12 +4359,16 @@ func (f Blob) GetId() string { return f.Id }
type BlobChange struct {
}
var _ Change = BlobChange{}
var _ Change[Blob] = BlobChange{}
func (m BlobChange) AsPatch() (PatchObject, error) {
return nil, fmt.Errorf("BlobChange is unsupported")
}
func (m BlobChange) GetMarker() Blob {
return Blob{}
}
type BlobChanges ChangesTemplate[Blob]
var _ Changes[Blob] = BlobChanges{}
@@ -5148,12 +5171,16 @@ type ContactCardChange struct {
PersonalInfo map[string]jscontact.PersonalInfo `json:"personalInfo,omitempty"`
}
var _ Change = ContactCardChange{}
var _ Change[ContactCard] = ContactCardChange{}
func (e ContactCardChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
func (m ContactCardChange) GetMarker() ContactCard {
return ContactCard{}
}
type CalendarRights struct {
// The user may read the free-busy information for this calendar.
MayReadFreeBusy bool `json:"mayReadFreeBusy"`
@@ -5498,12 +5525,16 @@ type CalendarChange struct {
MyRights *CalendarRights `json:"myRights,omitempty"`
}
var _ Change = CalendarChange{}
var _ Change[Calendar] = CalendarChange{}
func (c CalendarChange) AsPatch() (PatchObject, error) {
return toPatchObject(c)
}
func (m CalendarChange) GetMarker() Calendar {
return Calendar{}
}
// A CalendarEvent object contains information about an event, or recurring series of events,
// that takes place at a particular time.
//
@@ -5654,12 +5685,16 @@ type CalendarEventChange struct {
jscalendar.EventChange
}
var _ Change = CalendarEventChange{}
var _ Change[CalendarEvent] = CalendarEventChange{}
func (e CalendarEventChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
func (m CalendarEventChange) GetMarker() CalendarEvent {
return CalendarEvent{}
}
var _ Idable = &CalendarEvent{}
func (f CalendarEvent) GetObjectType() ObjectType { return CalendarEventType }
@@ -6536,12 +6571,16 @@ func (f Quota) GetId() string { return f.Id }
type QuotaChange struct {
}
var _ Change = QuotaChange{}
var _ Change[Quota] = QuotaChange{}
func (m QuotaChange) AsPatch() (PatchObject, error) {
return nil, fmt.Errorf("QuotaChange is unsupported")
}
func (m QuotaChange) GetMarker() Quota {
return Quota{}
}
// See [RFC8098] for the exact meaning of these different fields.
//
// These fields are defined as case insensitive in [RFC8098] but are case sensitive in this RFC
@@ -6815,12 +6854,16 @@ type AddressBookChange struct {
ShareWith map[PrincipalId]AddressBookRights `json:"shareWith,omitempty"`
}
var _ Change = AddressBookChange{}
var _ Change[AddressBook] = AddressBookChange{}
func (a AddressBookChange) AsPatch() (PatchObject, error) {
return toPatchObject(a)
}
func (m AddressBookChange) GetMarker() AddressBook {
return AddressBook{}
}
type AddressBookSetCommand struct {
AccountId AccountId `json:"accountId"`
IfInState State `json:"ifInState,omitempty"`
@@ -7007,7 +7050,7 @@ type ContactCardFilterCondition struct {
HasMember string `json:"hasMember,omitempty"`
// A card must have a type property that equals this string exactly to match.
Kind string `json:"kind,omitempty"`
Kind jscontact.ContactCardKind `json:"kind,omitempty"`
// The “created” date-time of the ContactCard must be before this date-time to match the condition.
CreatedBefore UTCDate `json:"createdBefore,omitzero"`
@@ -7047,11 +7090,11 @@ type ContactCardFilterCondition struct {
// matches the value.
Organization string `json:"organization,omitempty"`
// A card matches this condition if the “address” or “label” of any EmailAddress in the “emails” property of the
// A card matches this condition if the “address” or “label” of any EmailAddress in the “emails” property of the
// card matches the value.
Email string `json:"email,omitempty"`
// A card matches this condition if the “number” or “label” of any Phone in the “phones” property of the card
// A card matches this condition if the “number” or “label” of any Phone in the “phones” property of the card
// matches the value.
Phone string `json:"phone,omitempty"`

View File

@@ -380,7 +380,7 @@ func updates[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESR
})
}
func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP any, SETRESP SetResponse[T], GETRESP GetResponse[T]]( //NOSONAR
func update[T Foo, CHANGES Change[T], SET SetCommand[T], GET GetCommand[T], RESP any, SETRESP SetResponse[T], GETRESP GetResponse[T]]( //NOSONAR
client *Client, name string, objType ObjectType,
setCommandFactory func(map[string]PatchObject) SET,
getCommandFactory func(string) GET,

View File

@@ -406,3 +406,18 @@ func AllMatch[T any](s []T, predicate func(e T) bool) bool {
}
return true
}
func Distribute[T comparable, V any](s []V, distributor func(e V) T) map[T][]V {
result := map[T][]V{}
for _, e := range s {
k := distributor(e)
if l, ok := result[k]; ok {
l = append(l, e)
result[k] = l
} else {
l := []V{e}
result[k] = l
}
}
return result
}

View File

@@ -309,3 +309,36 @@ func TestFlatten(t *testing.T) {
assert.Equal(t, []int{}, result)
}
}
func TestAllMatch(t *testing.T) {
assert.True(t, AllMatch([]int{1, 2, 3}, func(i int) bool { return i > 0 }))
assert.False(t, AllMatch([]int{1, 2, 0, 3}, func(i int) bool { return i > 0 }))
assert.False(t, AllMatch([]int{1, 2, 3, 0}, func(i int) bool { return i > 0 }))
assert.False(t, AllMatch([]int{0}, func(i int) bool { return i > 0 }))
assert.True(t, AllMatch([]int{}, func(i int) bool { return i > 0 }))
}
func TestDistribute(t *testing.T) {
{
result := Distribute([]string{"Z", "a", "b", "X", "c", "Y"}, func(e string) int {
if strings.ToUpper(e) == e {
return 1
} else {
return 0
}
})
assert.Len(t, result, 2)
assert.Contains(t, result, 1)
assert.Equal(t, result[1], []string{"Z", "X", "Y"})
assert.Contains(t, result, 0)
assert.Equal(t, result[0], []string{"a", "b", "c"})
}
{
result := Distribute([]string{}, func(e string) int { return 1 })
assert.Empty(t, result)
}
{
result := Distribute(nil, func(e string) int { return 1 })
assert.Empty(t, result)
}
}

View File

@@ -8,40 +8,52 @@ import (
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
getall(AddressBook, w, r, g, g.addressbooks)
getall(AddressBook, w, r, g, g.listAddressBooks)
}
// 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.addressbooks)
get(AddressBook, w, r, g, g.listAddressBooks)
}
// Get the changes to Address Books since a certain State.
// @api:tags addressbook,changes
func (g *Groupware) GetAddressBookChanges(w http.ResponseWriter, r *http.Request) {
changes(AddressBook, w, r, g, g.addressbooksChanges)
changes(AddressBook, w, r, g, g.addressBooksChanges)
}
func (g *Groupware) CreateAddressBook(w http.ResponseWriter, r *http.Request) {
create(AddressBook, w, r, g, nil, g.jmap.CreateAddressBook)
create(AddressBook, w, r, g, nil, g.createAddressBook)
}
func (g *Groupware) DeleteAddressBook(w http.ResponseWriter, r *http.Request) {
delete(AddressBook, w, r, g, g.jmap.DeleteAddressBook)
deleteById(AddressBook, w, r, g, g.deleteAddressBooks)
}
func (g *Groupware) ModifyAddressBook(w http.ResponseWriter, r *http.Request) {
modify(AddressBook, w, r, g, g.jmap.UpdateAddressBook)
modify(AddressBook, w, r, g, g.updateAddressBook)
}
func (g *Groupware) addressbooks(accountId jmap.AccountId, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
return slist(g.addressBookListSuppliers, accountId, ids, ctx, func(accountId jmap.AccountId, state jmap.State, notFound []string, list []jmap.AddressBook) jmap.AddressBookGetResponse {
func (g *Groupware) createAddressBook(accountId jmap.AccountId, addressbook jmap.AddressBookChange, ctx jmap.Context) (jmap.Result[*jmap.AddressBook], error) {
return screate(g.suppliers.addressBooks.create, accountId, addressbook, ctx)
}
func (g *Groupware) updateAddressBook(accountId jmap.AccountId, id string, addressbook jmap.AddressBookChange, ctx jmap.Context) (jmap.Result[jmap.AddressBook], error) {
return supdate(g.suppliers.addressBooks.update, accountId, id, addressbook, ctx)
}
func (g *Groupware) deleteAddressBooks(accountId jmap.AccountId, destroyIds []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error) {
return sdelete[jmap.AddressBook](g.suppliers.addressBooks.delete, accountId, destroyIds, ctx)
}
func (g *Groupware) listAddressBooks(accountId jmap.AccountId, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
return slist(g.suppliers.addressBooks.list, accountId, ids, ctx, func(accountId jmap.AccountId, state jmap.State, notFound []string, list []jmap.AddressBook) jmap.AddressBookGetResponse {
return jmap.AddressBookGetResponse{AccountId: accountId, State: state, NotFound: notFound, List: list}
})
}
func (g *Groupware) addressbooksChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.AddressBookChanges], error) {
return schanges(g.addressBookChangesSuppliers, accountId, sinceState, maxChanges, ctx,
func (g *Groupware) addressBooksChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.AddressBookChanges], error) {
return schanges(g.suppliers.addressBooks.changes, accountId, sinceState, maxChanges, ctx,
func(accountId jmap.AccountId, oldState, newState jmap.State, created, updated []jmap.AddressBook, destroyed []string, hasMoreChanges bool) jmap.AddressBookChanges {
return jmap.AddressBookChanges{HasMoreChanges: hasMoreChanges, OldState: oldState, NewState: newState, Created: created, Updated: updated, Destroyed: destroyed}
},

View File

@@ -25,7 +25,7 @@ func (g *Groupware) CreateCalendar(w http.ResponseWriter, r *http.Request) {
}
func (g *Groupware) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
delete(Calendar, w, r, g, g.jmap.DeleteCalendar)
deleteById(Calendar, w, r, g, g.jmap.DeleteCalendar)
}
func (g *Groupware) ModifyCalendar(w http.ResponseWriter, r *http.Request) {

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/log"
)
var (
@@ -42,66 +44,125 @@ var (
// Get all the contacts in an addressbook of an account by its identifier.
func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Request) { //NOSONAR
getallpaged(Contact, w, r, g, true,
func(addressbookId string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{InAddressBook: addressbookId}
},
g.buildContactsFilter, supportedContactsFilterQueryParams,
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
curryQueryFunc(g.queryContacts),
curryQueryFunc(g.queryContactCards),
)
}
func (g *Groupware) GetContactById(w http.ResponseWriter, r *http.Request) {
get(Contact, w, r, g, g.contacts)
get(Contact, w, r, g, g.listContactCards)
}
func (g *Groupware) GetAllContacts(w http.ResponseWriter, r *http.Request) {
getallpaged(Contact, w, r, g, false,
func(_ string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{}
},
g.buildContactsFilter, supportedContactsFilterQueryParams,
[]jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
curryQueryFunc(g.queryContacts),
curryQueryFunc(g.queryContactCards),
)
}
var supportedContactsFilterQueryParams = toSupportedQueryParams(
QueryParamContactFilterUid,
QueryParamContactFilterUid,
QueryParamContactFilterCreatedAfter,
QueryParamContactFilterCreatedBefore,
QueryParamContactFilterEmail,
QueryParamContactFilterMember,
QueryParamContactFilterKind,
QueryParamContactFilterName,
// TODO add more as they are added below
)
func (g *Groupware) buildContactsFilter(addressbookId string, req Request, _ *log.Logger) (jmap.ContactCardFilterElement, *Error) {
filter := jmap.ContactCardFilterCondition{}
if addressbookId != "" {
filter.InAddressBook = addressbookId
}
if v, ok := req.getStringParam(QueryParamContactFilterUid, ""); ok {
filter.Uid = v
}
if v, ok := req.getStringParam(QueryParamContactFilterAddress, ""); ok {
filter.Address = v
}
if v, ok, err := req.parseUTCDateParam(QueryParamContactFilterCreatedAfter); err != nil {
return filter, err
} else if ok {
filter.CreatedAfter = v
}
if v, ok, err := req.parseUTCDateParam(QueryParamContactFilterCreatedBefore); err != nil {
return filter, err
} else if ok {
filter.CreatedBefore = v
}
if v, ok := req.getStringParam(QueryParamContactFilterEmail, ""); ok {
filter.Email = v
}
if v, ok := req.getStringParam(QueryParamContactFilterMember, ""); ok {
filter.HasMember = v
}
if v, ok, err := req.getValidStringParam(QueryParamContactFilterKind, "", striter(jscontact.ContactCardKinds)); err != nil {
return filter, err
} else if ok {
filter.Kind = jscontact.ContactCardKind(v)
}
if v, ok := req.getStringParam(QueryParamContactFilterName, ""); ok {
filter.Name = v
}
// TODO more filter conditions for ContactCard
return filter, nil
}
// Get changes to Contacts since a given State
// @api:tags contact,changes
func (g *Groupware) GetContactsChanges(w http.ResponseWriter, r *http.Request) {
changes(Contact, w, r, g, g.contactsChanges)
changes(Contact, w, r, g, g.contactCardsChanges)
}
func (g *Groupware) CreateContact(w http.ResponseWriter, r *http.Request) {
create(Contact, w, r, g, nil, g.jmap.CreateContactCard)
create(Contact, w, r, g, nil, g.createContactCard)
}
func (g *Groupware) DeleteContact(w http.ResponseWriter, r *http.Request) {
delete(Contact, w, r, g, g.jmap.DeleteContactCard)
deleteById(Contact, w, r, g, g.deleteContactCards)
}
func (g *Groupware) ModifyContact(w http.ResponseWriter, r *http.Request) {
modify(Contact, w, r, g, g.jmap.UpdateContactCard)
modify(Contact, w, r, g, g.updateContactCard)
}
func (g *Groupware) contacts(accountId jmap.AccountId, ids []string, ctx jmap.Context) (jmap.Result[jmap.ContactCardGetResponse], error) {
return slist(g.contactCardListSuppliers, accountId, ids, ctx,
func (g *Groupware) createContactCard(accountId jmap.AccountId, contact jmap.ContactCardChange, ctx jmap.Context) (jmap.Result[*jmap.ContactCard], error) {
return screate(g.suppliers.contactCards.create, accountId, contact, ctx)
}
func (g *Groupware) updateContactCard(accountId jmap.AccountId, id string, contactCard jmap.ContactCardChange, ctx jmap.Context) (jmap.Result[jmap.ContactCard], error) {
return supdate(g.suppliers.contactCards.update, accountId, id, contactCard, ctx)
}
func (g *Groupware) deleteContactCards(accountId jmap.AccountId, destroyIds []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error) {
return sdelete[jmap.ContactCard](g.suppliers.contactCards.delete, accountId, destroyIds, ctx)
}
func (g *Groupware) listContactCards(accountId jmap.AccountId, ids []string, ctx jmap.Context) (jmap.Result[jmap.ContactCardGetResponse], error) {
return slist(g.suppliers.contactCards.list, accountId, ids, ctx,
func(accountId jmap.AccountId, state jmap.State, notFound []string, list []jmap.ContactCard) jmap.ContactCardGetResponse {
return jmap.ContactCardGetResponse{AccountId: accountId, State: state, NotFound: notFound, List: list}
},
)
}
func (g *Groupware) contactsChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.ContactCardChanges], error) {
return schanges(g.contactCardChangesSuppliers, accountId, sinceState, maxChanges, ctx,
func (g *Groupware) contactCardsChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.ContactCardChanges], error) {
return schanges(g.suppliers.contactCards.changes, accountId, sinceState, maxChanges, ctx,
func(accountId jmap.AccountId, oldState, newState jmap.State, created, updated []jmap.ContactCard, destroyed []string, hasMoreChanges bool) jmap.ContactCardChanges {
return jmap.ContactCardChanges{HasMoreChanges: hasMoreChanges, OldState: oldState, NewState: newState, Created: created, Updated: updated, Destroyed: destroyed}
},
)
}
func (g *Groupware) queryContacts(accountIds []jmap.AccountId, qps QueryParamsSupplier, limit *uint, //NOSONAR
func (g *Groupware) queryContactCards(accountIds []jmap.AccountId, 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,
return squery(g.suppliers.contactCards.query, accountIds, qps, limit, filter, sortBy, calculateTotal, ctx,
func(supplier QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator], filter jmap.ContactCardFilterElement) bool {
switch c := filter.(type) {
case jmap.ContactCardFilterCondition:

View File

@@ -1065,7 +1065,7 @@ func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request)
// Delete an email by its unique identifier.
func (g *Groupware) DeleteEmail(w http.ResponseWriter, r *http.Request) {
delete(Email, w, r, g, g.jmap.DeleteEmails)
deleteById(Email, w, r, g, g.jmap.DeleteEmails)
}
// Delete a set of emails by their unique identifiers.

View File

@@ -12,9 +12,10 @@ import (
func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { //NOSONAR
getallpaged(Event, w, r, g,
true,
func(calendarId string) jmap.CalendarEventFilterElement {
return jmap.CalendarEventFilterCondition{InCalendar: calendarId}
func(calendarId string, req Request, logger *log.Logger) (jmap.CalendarEventFilterElement, *Error) {
return g.buildEventsFilter(calendarId, req, logger)
},
supportedEventsFilterQueryParams,
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
curryNoNextMapQuery(
g.jmap.QueryCalendarEvents,
@@ -35,7 +36,10 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
getallpaged(Event, w, r, g,
false,
func(_ string) jmap.CalendarEventFilterElement { return jmap.CalendarEventFilterCondition{} },
func(calendarId string, req Request, logger *log.Logger) (jmap.CalendarEventFilterElement, *Error) {
return g.buildEventsFilter(calendarId, req, logger)
},
supportedEventsFilterQueryParams,
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
curryNoNextMapQuery(
g.jmap.QueryCalendarEvents,
@@ -53,6 +57,23 @@ func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
)
}
var supportedEventsFilterQueryParams = toSupportedQueryParams(
QueryParamEventsFilterUid,
// TODO add more as they are added below
)
func (g *Groupware) buildEventsFilter(calendarId string, req Request, _ *log.Logger) (jmap.CalendarEventFilterCondition, *Error) {
filter := jmap.CalendarEventFilterCondition{}
if calendarId != "" {
filter.InCalendar = calendarId
}
if v, ok := req.getStringParam(QueryParamEventsFilterUid, ""); ok {
filter.Uid = v
}
// TODO more filter conditions for Event
return filter, nil
}
func (g *Groupware) GetEventById(w http.ResponseWriter, r *http.Request) {
get(Event, w, r, g, g.jmap.GetCalendarEvents)
}
@@ -68,7 +89,7 @@ func (g *Groupware) CreateEvent(w http.ResponseWriter, r *http.Request) {
}
func (g *Groupware) DeleteEvent(w http.ResponseWriter, r *http.Request) {
delete(Event, w, r, g, g.jmap.DeleteCalendarEvent)
deleteById(Event, w, r, g, g.jmap.DeleteCalendarEvent)
}
func (g *Groupware) ModifyEvent(w http.ResponseWriter, r *http.Request) {

View File

@@ -23,7 +23,7 @@ func (g *Groupware) ModifyIdentity(w http.ResponseWriter, r *http.Request) {
// Delete an identity.
func (g *Groupware) DeleteIdentity(w http.ResponseWriter, r *http.Request) {
delete(Identity, w, r, g, g.jmap.DeleteIdentity)
deleteById(Identity, w, r, g, g.jmap.DeleteIdentity)
}
// Get changes to Identities since a given State

View File

@@ -239,7 +239,7 @@ func (g *Groupware) CreateMailbox(w http.ResponseWriter, r *http.Request) {
}
func (g *Groupware) DeleteMailbox(w http.ResponseWriter, r *http.Request) {
delete(Mailbox, w, r, g, g.jmap.DeleteMailboxes)
deleteById(Mailbox, w, r, g, g.jmap.DeleteMailboxes)
}
var mailboxRoleSortOrderScore = map[string]int{

View File

@@ -805,3 +805,16 @@ func (r *Request) jmapErrorN(accountIds []jmap.AccountId, err error, result jmap
return r.errorN(accountIds, apiError(errorId, ErrorGeneric, withDetail(e.Error())), result.GetDurations())
}
}
func (r *Request) errorResponse(accountId jmap.AccountId, err error, result jmap.ResultMetadata) Response {
switch e := err.(type) {
case jmap.Error:
return r.jmapError(accountId, e, result)
case GroupwareError:
errorId := r.errorId()
return r.error(accountId, apiError(errorId, e), result.GetDurations())
default:
errorId := r.errorId()
return r.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())), result.GetDurations())
}
}

View File

@@ -116,12 +116,8 @@ type Groupware struct {
jobsChannel chan Job
// A threadsafe counter to generate the job IDs.
jobCounter atomic.Uint64
addressBookListSuppliers []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]
addressBookChangesSuppliers []ChangesSupplier[jmap.AddressBook, jmap.AddressBookChanges]
contactCardQuerySuppliers []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]
contactCardListSuppliers []ListSupplier[jmap.ContactCard, jmap.ContactCardGetResponse]
contactCardChangesSuppliers []ChangesSupplier[jmap.ContactCard, jmap.ContactCardChanges]
// Implementations of suppliers to use to provide Groupware objects.
suppliers Suppliers
}
// An error during the Groupware initialization.
@@ -394,36 +390,20 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
}
}
addressBookListSuppliers := []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]{}
addressBookChangesSuppliers := []ChangesSupplier[jmap.AddressBook, jmap.AddressBookChanges]{}
contactCardQuerySuppliers := []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]{}
contactCardListSuppliers := []ListSupplier[jmap.ContactCard, jmap.ContactCardGetResponse]{}
contactCardChangesSuppliers := []ChangesSupplier[jmap.ContactCard, jmap.ContactCardChanges]{}
var suppliers Suppliers
{
{
j := newJmapAddressBookSupplier(&jmapClient)
addressBookListSuppliers = append(addressBookListSuppliers, j)
addressBookChangesSuppliers = append(addressBookChangesSuppliers, j)
}
{
j := newJmapContactCardSupplier(&jmapClient)
contactCardQuerySuppliers = append(contactCardQuerySuppliers, j)
contactCardListSuppliers = append(contactCardListSuppliers, j)
contactCardChangesSuppliers = append(contactCardChangesSuppliers, j)
impls := []Supplier[jmap.Foo]{
newJmapAddressBookSupplier(&jmapClient),
newJmapContactCardSupplier(&jmapClient),
}
if config.EnableMockData {
{
m := newMockAddressBookSupplier()
addressBookListSuppliers = append(addressBookListSuppliers, m)
addressBookChangesSuppliers = append(addressBookChangesSuppliers, m)
}
{
m := newMockContactCardSupplier()
contactCardQuerySuppliers = append(contactCardQuerySuppliers, m)
contactCardListSuppliers = append(contactCardListSuppliers, m)
contactCardChangesSuppliers = append(contactCardChangesSuppliers, m)
}
impls = append(impls,
newMockAddressBookSupplier(),
newMockContactCardSupplier(),
)
}
suppliers, err = newSuppliers(impls...)
}
var publicUrl *url.URL
@@ -454,14 +434,10 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
publicUrl: publicUrl,
sendDurationsResponse: sendDurationsResponse,
},
eventChannel: eventChannel,
jobsChannel: jobsChannel,
jobCounter: atomic.Uint64{},
addressBookListSuppliers: addressBookListSuppliers,
addressBookChangesSuppliers: addressBookChangesSuppliers,
contactCardQuerySuppliers: contactCardQuerySuppliers,
contactCardListSuppliers: contactCardListSuppliers,
contactCardChangesSuppliers: contactCardChangesSuppliers,
eventChannel: eventChannel,
jobsChannel: jobsChannel,
jobCounter: atomic.Uint64{},
suppliers: suppliers,
}
for w := 1; w <= workerPoolSize; w++ {

View File

@@ -4,7 +4,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
type ObjectType[T jmap.Foo, CH jmap.Change, CHS jmap.Changes[T]] struct {
type ObjectType[T jmap.Foo, CH jmap.Change[T], CHS jmap.Changes[T]] struct {
name string
plural string
responseType ResponseObjectType

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net/http"
"slices"
"strconv"
@@ -239,12 +240,15 @@ func toSupportedQueryParams(params ...string) supportedQueryParams {
var noSupportedQueryParams supportedQueryParams = toSupportedQueryParams()
func (r *Request) unsupportedQueryParams(accountIds []jmap.AccountId, allowed supportedQueryParams) (bool, Response) {
func (r *Request) unsupportedQueryParams(accountIds []jmap.AccountId, allowed ...supportedQueryParams) (bool, Response) {
q := r.r.URL.Query()
for n := range q {
if _, ok := allowed[n]; !ok {
return true, r.parameterErrorResponseN(accountIds, n, "Unsupported query parameter")
for _, a := range allowed {
if _, ok := a[n]; ok {
return false, Response{}
}
}
return true, r.parameterErrorResponseN(accountIds, n, "Unsupported query parameter")
}
return false, Response{}
}
@@ -261,6 +265,27 @@ func (r *Request) getStringParam(param string, defaultValue string) (string, boo
return str, true
}
func (r *Request) getValidStringParam(param string, defaultValue string, possibleValues iter.Seq[string]) (string, bool, *Error) {
q := r.r.URL.Query()
if !q.Has(param) {
return defaultValue, false, nil
}
str := q.Get(param)
if str == "" {
return defaultValue, true, nil
}
for p := range possibleValues {
if p == str {
return str, true, nil
}
}
msg := fmt.Sprintf("Invalid value for query parameter '%v'", param)
return "", true, r.observedParameterError(ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: param}),
)
}
func (r *Request) getMandatoryStringParam(param string) (string, *Error) {
str := ""
q := r.r.URL.Query()
@@ -347,6 +372,28 @@ func (r *Request) parseDateParam(param string) (time.Time, bool, *Error) {
return t, true, nil
}
func (r *Request) parseUTCDateParam(param string) (jmap.UTCDate, bool, *Error) {
q := r.r.URL.Query()
if !q.Has(param) {
return "", false, nil
}
str := q.Get(param)
if str == "" {
return "", false, nil
}
if _, err := time.Parse(jmap.UTCDateFormat, str); err != nil {
msg := fmt.Sprintf("Invalid UTCDate value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error())
return "", true, r.observedParameterError(ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: param}),
)
} else {
return jmap.UTCDate(str), true, nil
}
}
func (r *Request) parseBoolParam(param string, defaultValue bool) (bool, bool, *Error) {
q := r.r.URL.Query()
if !q.Has(param) {

View File

@@ -11,68 +11,77 @@ var (
)
const (
UriParamAccountId = "accountid" // Identifier of the account
UriParamMailboxId = "mailboxid" // Identifier of the mailbox
UriParamEmailId = "emailid" // Identifier of the email
UriParamIdentityId = "identityid" // Identifier of the identity
UriParamBlobId = "blobid" // Identifier of theblob
UriParamStreamId = "stream" // Identifier of the stream
UriParamAddressBookId = "addressbookid" // Identifier of the address book
UriParamCalendarId = "calendarid" // Identifier of the calendar
UriParamTaskListId = "tasklistid" // Identifier of the tasklist
UriParamContactId = "contactid" // Identifier of the contact
UriParamEventId = "eventid" // Idenfitier of the event
UriParamBlobName = "blobname"
UriParamRole = "role"
QueryParamMailboxSearchName = "name"
QueryParamMailboxSearchRole = "role"
QueryParamMailboxSearchSubscribed = "subscribed"
QueryParamBlobType = "type"
QueryParamSince = "since"
QueryParamMaxChanges = "maxchanges"
QueryParamMailboxId = "mailbox"
QueryParamIdentityId = "identity"
QueryParamMoveFromMailboxId = "move-from"
QueryParamMoveToMailboxId = "move-to"
QueryParamNotInMailboxId = "notmailbox"
QueryParamSearchText = "text"
QueryParamSearchFrom = "from"
QueryParamSearchTo = "to"
QueryParamSearchCc = "cc"
QueryParamSearchBcc = "bcc"
QueryParamSearchSubject = "subject"
QueryParamSearchBody = "body"
QueryParamSearchBefore = "before"
QueryParamSearchAfter = "after"
QueryParamSearchMinSize = "minsize"
QueryParamSearchMaxSize = "maxsize"
QueryParamSearchKeyword = "keyword"
QueryParamSearchMessageId = "messageId"
QueryParamPosition = "position"
QueryParamAnchor = "anchor"
QueryParamAnchorOffset = "offset"
QueryParamLimit = "limit"
QueryParamDays = "days"
QueryParamPartId = "partId"
QueryParamAttachmentName = "name"
QueryParamAttachmentBlobId = "blobId"
QueryParamSeen = "seen"
QueryParamUndesirable = "undesirable"
QueryParamMarkAsSeen = "markAsSeen"
QueryParamSort = "sort"
QueryParamMailboxes = "mailboxes"
QueryParamEmails = "emails"
QueryParamAddressbooks = "addressbooks"
QueryParamContacts = "contacts"
QueryParamCalendars = "calendars"
QueryParamEvents = "events"
QueryParamQuotas = "quotas"
QueryParamIdentities = "identities"
QueryParamEmailSubmissions = "submissions"
QueryParamId = "id"
QueryParamCalculateTotal = "total"
QueryParamNext = "next"
HeaderParamSince = "if-none-match"
UriParamAccountId = "accountid" // Identifier of the account
UriParamMailboxId = "mailboxid" // Identifier of the mailbox
UriParamEmailId = "emailid" // Identifier of the email
UriParamIdentityId = "identityid" // Identifier of the identity
UriParamBlobId = "blobid" // Identifier of theblob
UriParamStreamId = "stream" // Identifier of the stream
UriParamAddressBookId = "addressbookid" // Identifier of the address book
UriParamCalendarId = "calendarid" // Identifier of the calendar
UriParamTaskListId = "tasklistid" // Identifier of the tasklist
UriParamContactId = "contactid" // Identifier of the contact
UriParamEventId = "eventid" // Idenfitier of the event
UriParamBlobName = "blobname"
UriParamRole = "role"
QueryParamMailboxSearchName = "name"
QueryParamMailboxSearchRole = "role"
QueryParamMailboxSearchSubscribed = "subscribed"
QueryParamBlobType = "type"
QueryParamSince = "since"
QueryParamMaxChanges = "maxchanges"
QueryParamMailboxId = "mailbox"
QueryParamIdentityId = "identity"
QueryParamMoveFromMailboxId = "move-from"
QueryParamMoveToMailboxId = "move-to"
QueryParamNotInMailboxId = "notmailbox"
QueryParamSearchText = "text"
QueryParamSearchFrom = "from"
QueryParamSearchTo = "to"
QueryParamSearchCc = "cc"
QueryParamSearchBcc = "bcc"
QueryParamSearchSubject = "subject"
QueryParamSearchBody = "body"
QueryParamSearchBefore = "before"
QueryParamSearchAfter = "after"
QueryParamSearchMinSize = "minsize"
QueryParamSearchMaxSize = "maxsize"
QueryParamSearchKeyword = "keyword"
QueryParamSearchMessageId = "messageId"
QueryParamPosition = "position"
QueryParamAnchor = "anchor"
QueryParamAnchorOffset = "offset"
QueryParamLimit = "limit"
QueryParamDays = "days"
QueryParamPartId = "partId"
QueryParamAttachmentName = "name"
QueryParamAttachmentBlobId = "blobId"
QueryParamSeen = "seen"
QueryParamUndesirable = "undesirable"
QueryParamMarkAsSeen = "markAsSeen"
QueryParamSort = "sort"
QueryParamMailboxes = "mailboxes"
QueryParamEmails = "emails"
QueryParamAddressbooks = "addressbooks"
QueryParamContacts = "contacts"
QueryParamCalendars = "calendars"
QueryParamEvents = "events"
QueryParamQuotas = "quotas"
QueryParamIdentities = "identities"
QueryParamEmailSubmissions = "submissions"
QueryParamId = "id"
QueryParamCalculateTotal = "total"
QueryParamNext = "next"
QueryParamContactFilterUid = "uid"
QueryParamContactFilterAddress = "address"
QueryParamContactFilterCreatedAfter = "after"
QueryParamContactFilterCreatedBefore = "before"
QueryParamContactFilterEmail = "email"
QueryParamContactFilterMember = "member"
QueryParamContactFilterKind = "kind"
QueryParamContactFilterName = "name"
QueryParamEventsFilterUid = "uid"
HeaderParamSince = "if-none-match"
)
func (g *Groupware) Route(r chi.Router) {

View File

@@ -11,7 +11,8 @@ type JmapAddressBookSupplier struct {
}
var _ ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse] = &JmapAddressBookSupplier{}
var _ ChangesSupplier[jmap.ContactCard, jmap.ContactCardChanges] = &JmapContactCardSupplier{}
var _ ChangesSupplier[jmap.AddressBook, jmap.AddressBookChanges] = &JmapAddressBookSupplier{}
var _ CreateSupplier[jmap.AddressBook, jmap.AddressBookChange] = &JmapAddressBookSupplier{}
func newJmapAddressBookSupplier(client *jmap.Client) *JmapAddressBookSupplier {
return &JmapAddressBookSupplier{client: client}
@@ -34,3 +35,11 @@ func (c *JmapAddressBookSupplier) GetAll(accountId jmap.AccountId, ids []string,
func (c *JmapAddressBookSupplier) GetChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.AddressBookChanges], error) {
return c.client.GetAddressbookChanges(accountId, sinceState, maxChanges, ctx)
}
func (c *JmapAddressBookSupplier) CanCreate(accountId jmap.AccountId, create jmap.AddressBookChange, ctx jmap.Context) bool {
return true
}
func (c *JmapAddressBookSupplier) Create(accountId jmap.AccountId, create jmap.AddressBookChange, ctx jmap.Context) (jmap.Result[*jmap.AddressBook], error) {
return c.client.CreateAddressBook(accountId, create, ctx)
}

View File

@@ -13,6 +13,7 @@ type JmapContactCardSupplier struct {
var _ QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator] = &JmapContactCardSupplier{}
var _ ListSupplier[jmap.ContactCard, jmap.ContactCardGetResponse] = &JmapContactCardSupplier{}
var _ ChangesSupplier[jmap.ContactCard, jmap.ContactCardChanges] = &JmapContactCardSupplier{}
var _ CreateSupplier[jmap.ContactCard, jmap.ContactCardChange] = &JmapContactCardSupplier{}
func newJmapContactCardSupplier(client *jmap.Client) *JmapContactCardSupplier {
return &JmapContactCardSupplier{client: client}
@@ -43,3 +44,11 @@ func (c *JmapContactCardSupplier) GetAll(accountId jmap.AccountId, ids []string,
func (c *JmapContactCardSupplier) GetChanges(accountId jmap.AccountId, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.ContactCardChanges], error) {
return c.client.GetContactCardChanges(accountId, sinceState, maxChanges, ctx)
}
func (c *JmapContactCardSupplier) CanCreate(accountId jmap.AccountId, create jmap.ContactCardChange, ctx jmap.Context) bool {
return true
}
func (c *JmapContactCardSupplier) Create(accountId jmap.AccountId, create jmap.ContactCardChange, ctx jmap.Context) (jmap.Result[*jmap.ContactCard], error) {
return c.client.CreateContactCard(accountId, create, ctx)
}

View File

@@ -78,7 +78,35 @@ func (c *MockContactCardSupplier) GetAll(accountId jmap.AccountId, ids []string,
}
func (c *MockContactCardSupplier) Query(accountIds []jmap.AccountId, qps QueryParamsSupplier, limit *uint, filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[jmap.AccountId]*jmap.ContactCardSearchResults], error) { //NOSONAR
payload := make(map[jmap.AccountId]*jmap.ContactCardSearchResults, len(accountIds))
total := len(c.contacts)
hits := []jmap.ContactCard{}
switch cond := filter.(type) {
case jmap.ContactCardFilterCondition:
for _, h := range c.contacts {
if cond.Name != "" {
for _, comp := range h.Name.Components {
if strings.Contains(comp.Value, cond.Name) {
hits = append(hits, h)
continue
}
}
continue
}
if cond.Email != "" {
for _, email := range h.Emails {
if strings.Contains(email.Address, cond.Email) {
hits = append(hits, h)
continue
}
}
continue
}
// TODO add more filter conditions
hits = append(hits, h)
}
}
total := len(hits)
for _, accountId := range accountIds {
all := []jmap.ContactCard{}
var qp jmap.QueryParams
@@ -92,7 +120,7 @@ func (c *MockContactCardSupplier) Query(accountIds []jmap.AccountId, qps QueryPa
p := uint(qp.Position)
if qp.Position < total {
all = c.contacts[qp.Position:]
all = hits[qp.Position:]
}
if qp.Anchor != "" {
a := slices.IndexFunc(all, func(e jmap.ContactCard) bool { return e.Id == qp.Anchor })

View File

@@ -38,7 +38,134 @@ type ChangesSupplier[T jmap.Foo, C jmap.Changes[T]] interface {
Supplier[T]
}
// queryFunc func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
type CreateSupplier[T jmap.Foo, C jmap.Change[T]] interface {
CanCreate(accountId jmap.AccountId, create C, ctx jmap.Context) bool
Create(accountId jmap.AccountId, create C, ctx jmap.Context) (jmap.Result[*T], error)
Supplier[T]
}
type UpdateSupplier[T jmap.Foo, C jmap.Change[T]] interface {
Update(accountId jmap.AccountId, id string, change C, ctx jmap.Context) (jmap.Result[T], error)
Supplier[T]
}
type DeleteSupplier[T jmap.Foo] interface {
Delete(accountId jmap.AccountId, destroyIds []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error)
Supplier[T]
}
type containerSuppliers[
T jmap.Foo,
G jmap.GetResponse[T],
C jmap.Change[T],
D jmap.Changes[T],
] struct {
create []CreateSupplier[T, C]
update []UpdateSupplier[T, C]
delete []DeleteSupplier[T]
list []ListSupplier[T, G]
changes []ChangesSupplier[T, D]
}
type containedSuppliers[
T jmap.Foo,
G jmap.GetResponse[T],
C jmap.Change[T],
D jmap.Changes[T],
S jmap.SearchResults[T],
F jmap.FilterElement[T],
O jmap.Comparator[T],
] struct {
create []CreateSupplier[T, C]
update []UpdateSupplier[T, C]
delete []DeleteSupplier[T]
query []QuerySupplier[T, S, F, O]
list []ListSupplier[T, G]
changes []ChangesSupplier[T, D]
}
type Suppliers struct {
addressBooks containerSuppliers[jmap.AddressBook, jmap.AddressBookGetResponse, jmap.AddressBookChange, jmap.AddressBookChanges]
contactCards containedSuppliers[jmap.ContactCard, jmap.ContactCardGetResponse, jmap.ContactCardChange, jmap.ContactCardChanges, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]
}
func newSuppliers(suppliers ...Supplier[jmap.Foo]) (Suppliers, error) {
result := Suppliers{
addressBooks: containerSuppliers[jmap.AddressBook, jmap.AddressBookGetResponse, jmap.AddressBookChange, jmap.AddressBookChanges]{},
contactCards: containedSuppliers[jmap.ContactCard, jmap.ContactCardGetResponse, jmap.ContactCardChange, jmap.ContactCardChanges, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]{},
}
unsupported := []Supplier[jmap.Foo]{}
for _, s := range suppliers {
hits := 0
switch s := s.(type) {
case CreateSupplier[jmap.AddressBook, jmap.AddressBookChange]:
result.addressBooks.create = append(result.addressBooks.create, s)
hits++
}
switch s := s.(type) {
case UpdateSupplier[jmap.AddressBook, jmap.AddressBookChange]:
result.addressBooks.update = append(result.addressBooks.update, s)
hits++
}
switch s := s.(type) {
case DeleteSupplier[jmap.AddressBook]:
result.addressBooks.delete = append(result.addressBooks.delete, s)
hits++
}
switch s := s.(type) {
case ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]:
result.addressBooks.list = append(result.addressBooks.list, s)
hits++
}
switch s := s.(type) {
case ChangesSupplier[jmap.AddressBook, jmap.AddressBookChanges]:
result.addressBooks.changes = append(result.addressBooks.changes, s)
hits++
}
switch s := s.(type) {
case CreateSupplier[jmap.ContactCard, jmap.ContactCardChange]:
result.contactCards.create = append(result.contactCards.create, s)
hits++
}
switch s := s.(type) {
case UpdateSupplier[jmap.ContactCard, jmap.ContactCardChange]:
result.contactCards.update = append(result.contactCards.update, s)
hits++
}
switch s := s.(type) {
case DeleteSupplier[jmap.ContactCard]:
result.contactCards.delete = append(result.contactCards.delete, s)
hits++
}
switch s := s.(type) {
case QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]:
result.contactCards.query = append(result.contactCards.query, s)
hits++
}
switch s := s.(type) {
case ListSupplier[jmap.ContactCard, jmap.ContactCardGetResponse]:
result.contactCards.list = append(result.contactCards.list, s)
hits++
}
switch s := s.(type) {
case ChangesSupplier[jmap.ContactCard, jmap.ContactCardChanges]:
result.contactCards.changes = append(result.contactCards.changes, s)
hits++
}
if hits < 1 {
unsupported = append(unsupported, s)
}
}
if len(unsupported) > 0 {
return result, fmt.Errorf("%d unsupported suppliers: %v", len(unsupported), unsupported)
} else {
return result, nil
}
}
func curryQueryFunc[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
f func(accountIds []jmap.AccountId, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, calculateTotal bool, ctx jmap.Context) (jmap.Result[SRES], NextToken, error),
) func(req Request, accountIds []jmap.AccountId, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) {
@@ -56,7 +183,7 @@ func agg[T jmap.Idable, R jmap.GetResponse[T]](accountId jmap.AccountId, supplie
ctor func(accountId jmap.AccountId, 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")
return zero, fmt.Errorf("requires at least one response") //NOSONAR
}
lists := structs.Concat(structs.Map(responses, func(e *R) []T {
if e != nil {
@@ -173,10 +300,25 @@ func aggChanges[T jmap.Idable, R jmap.Changes[T]](accountId jmap.AccountId, supp
} else {
return false
}
}), func(b bool) bool { return b })
}), identity)
return ctor(accountId, oldState, newState, created, updated, destroyed, hasMoreChanges), nil
}
func aggDeletes(responses []*map[string]jmap.SetError) (map[string]jmap.SetError, error) { //NOSONAR
if len(responses) < 1 {
return nil, fmt.Errorf("requires at least one response")
}
payload := map[string]jmap.SetError{}
for _, r := range responses {
if r != nil {
for k, v := range *r {
payload[k] = v
}
}
}
return payload, nil
}
func slist[T jmap.Idable, G jmap.GetResponse[T], S ListSupplier[T, G]](suppliers []S, accountId jmap.AccountId, ids []string, ctx jmap.Context, //NOSONAR
ctor func(accountId jmap.AccountId, state jmap.State, notFound []string, list []T) G) (jmap.Result[G], error) {
switch len(suppliers) {
@@ -205,40 +347,9 @@ func slist[T jmap.Idable, G jmap.GetResponse[T], S ListSupplier[T, G]](suppliers
}
}
return smeld[T](supplierIds, results, func(payloads []*G) (G, error) {
return smeld(supplierIds, results, func(payloads []*G) (G, error) {
return agg(accountId, supplierIds, payloads, ctor)
}, func(resp G) jmap.State {
return resp.GetState()
})
/*
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 SupplierId, state *jmap.SessionState) (SupplierId, 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
})
*/
}, G.GetState)
}
}
@@ -271,45 +382,18 @@ func schanges[T jmap.Idable, C jmap.Changes[T], S ChangesSupplier[T, C]](supplie
}
}
// TODO need to reduce the maxChanges? this is quite complex to implement, if even possible, not doing that for now
// TODO need to reduce the maxChanges? this is quite complex to implement, if even possible at all, not doing that for now
// which means that a request with multiple suppliers with maxChanges=10 can yield a lot more changes than that, but that is
// a limitation we have to live with, at least for now, but probably forever, since we don't have the state for each change
// and if we cap the changes from a supplier, we do not know which state to use to resume from there
return smeld[T](supplierIds, results, func(payloads []*C) (C, error) {
return smeld(supplierIds, results, func(payloads []*C) (C, error) {
return aggChanges(accountId, supplierIds, payloads, ctor)
}, func(resp C) jmap.State {
return resp.GetNewState()
})
/*
return jmap.RefineResultSlice(results, func(payloads []*C, sessionStates []*jmap.SessionState, states []*jmap.State, langs []*jmap.Language) (C, jmap.SessionState, jmap.State, jmap.Language, error) {
resp, err := aggChanges(accountId, supplierIds, payloads, ctor)
if err != nil {
return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
}
m, err := structs.MeshMap(supplierIds, sessionStates, func(id SupplierId, state *jmap.SessionState) (SupplierId, 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.GetNewState(), lang, nil
})
*/
}, C.GetNewState)
}
}
func smeld[T jmap.Idable, C any](
func smeld[C any](
supplierIds []SupplierId,
results []*jmap.Result[C],
aggregator func(payloads []*C) (C, error),
@@ -342,6 +426,87 @@ func smeld[T jmap.Idable, C any](
})
}
func screate[T jmap.Idable, C jmap.Change[T], S CreateSupplier[T, C]](suppliers []S, accountId jmap.AccountId, create C, ctx jmap.Context) (jmap.Result[*T], error) {
candidates := structs.Filter(suppliers, func(s S) bool { return s.CanCreate(accountId, create, ctx) })
switch len(candidates) {
case 0:
return jmap.ZeroResultV[*T](), fmt.Errorf("TODO found no suppliers to create, must return 5XX Not Implemented") // TODO groupware error for this
case 1:
return candidates[0].Create(accountId, create, ctx)
default:
// found more than one Supplier that would be capable of creating this object
// there are a few ways to deal with this, but in order to be deal with this in a
// predictable way, we will return an error
return jmap.ZeroResultV[*T](), fmt.Errorf("TODO found multiple suppliers to create, must return 5XX Conflict") // TODO groupware error for this
}
}
func supdate[T jmap.Idable, C jmap.Change[T], S UpdateSupplier[T, C]](suppliers []S, accountId jmap.AccountId, id string, change C, ctx jmap.Context) (jmap.Result[T], error) {
candidates := structs.Filter(suppliers, func(s S) bool { return s.IsMine(id) })
switch len(candidates) {
case 0:
return jmap.ZeroResultV[T](), fmt.Errorf("TODO found no suppliers to update, must return 5XX Not Implemented") // TODO groupware error for this
case 1:
return candidates[0].Update(accountId, id, change, ctx)
default:
// found more than one Supplier that would be capable of creating this object
// there are a few ways to deal with this, but in order to be deal with this in a
// predictable way, we will return an error
return jmap.ZeroResultV[T](), fmt.Errorf("TODO found multiple suppliers to update, must return 5XX Conflict") // TODO groupware error for this
}
}
func sdelete[T jmap.Idable, S DeleteSupplier[T]](suppliers []S, accountId jmap.AccountId, destroyIds []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error) {
idsBySupplier := structs.Distribute(destroyIds, func(id string) SupplierId {
for _, s := range suppliers {
if s.IsMine(id) {
return s.GetId()
}
}
return EmptySupplierId
})
result := map[string]jmap.SetError{}
if ids, ok := idsBySupplier[EmptySupplierId]; ok {
for _, id := range ids {
result[id] = jmap.SetError{
Type: jmap.SetErrorTypeNotFound,
Description: "no supplier supports that id",
}
}
}
delete(idsBySupplier, EmptySupplierId)
switch len(idsBySupplier) {
case 0:
return jmap.NewResult(result, ctx.Session.GetSessionState(), jmap.EmptyState, jmap.NoLanguage, nil), nil
default:
suppliersById := structs.Index(suppliers, S.GetId)
results := []*jmap.Result[map[string]jmap.SetError]{}
stateBySupplierId := map[SupplierId]jmap.State{}
for supplierId, ids := range idsBySupplier {
if supplier, ok := suppliersById[supplierId]; ok {
if r, err := supplier.Delete(accountId, ids, ctx); err != nil {
return jmap.ZeroResult[map[string]jmap.SetError](nil), err
} else {
results = append(results, &r)
stateBySupplierId[supplierId] = r.GetState()
}
}
}
if len(results) == 1 {
return *results[0], nil
} else {
supplierIds := structs.Keys(suppliersById)
state, err := combineState(stateBySupplierId)
if err != nil {
return jmap.ZeroResultV[map[string]jmap.SetError](), err
}
return smeld(supplierIds, results, aggDeletes, func(m map[string]jmap.SetError) jmap.State { return state })
}
}
}
func fillMissingAccounts(qps QueryParamsSupplier, supplierId SupplierId, accountIds []jmap.AccountId, n map[jmap.AccountId]jmap.QueryParams) error {
for _, accountId := range accountIds {
if _, ok := n[accountId]; !ok {

View File

@@ -10,7 +10,7 @@ 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]]( //NOSONAR
func create[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -62,7 +62,7 @@ func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
// Retrieve all the {{.Name}}.
// @api:response 200:[]T returns all the {{.Names}}
func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
func getall[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -105,12 +105,13 @@ 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}}
func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER any, COMP any, SEARCHRESULTS jmap.SearchResults[T]]( //NOSONAR
func getallpaged[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T], FILTER any, COMP any, SEARCHRESULTS jmap.SearchResults[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
withContainerId bool,
filterFunc func(containerId string) FILTER,
filterFunc func(containerId string, req Request, logger *log.Logger) (FILTER, *Error),
supportedFilterParams supportedQueryParams,
sortBy []COMP,
queryFunc func(req Request, accountIds []jmap.AccountId, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
) {
@@ -160,25 +161,19 @@ 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), supportedQueryParams); notok {
if notok, resp := req.unsupportedQueryParams(single(accountId), supportedQueryParams, supportedFilterParams); notok {
return resp
}
filter := filterFunc(containerId)
logger := log.From(l)
filter, err := filterFunc(containerId, req, logger)
if err != nil {
return req.errorV(accountId, err)
}
ctx := req.ctx.WithLogger(logger)
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), result.GetDurations())
default:
errorId := req.errorId()
return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())), result.GetDurations())
}
return req.errorResponse(accountId, err, result)
} else {
return req.respondNext(accountId, result.Payload, o.responseType, result, nextNextToken)
}
@@ -187,7 +182,7 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
// Query 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}} that match the filter, within the requested range, as well as the total amount of matches
func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULTS jmap.SearchResults[T]]( //NOSONAR
func query[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T], SEARCHRESULTS jmap.SearchResults[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -334,7 +329,7 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
// 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]]( //NOSONAR
func get[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -391,7 +386,7 @@ 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]]( //NOSONAR
func getFromMap[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -450,7 +445,7 @@ 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]]( //NOSONAR
func changes[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -503,7 +498,7 @@ func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
// @api:success 204
// @api:response 204 when the referenced {{.Name}} has been deleted successfully
// @api:response 404 when there is no {{.Name}} for the requested identifier
func delete[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
func deleteById[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -565,7 +560,7 @@ var deleteManySupportedQueryParams = toSupportedQueryParams(QueryParamId)
// or using the query parameter `{{.QueryParam.QueryParamId}}`.
// @api:response 204 when the referenced {{.Names}} have all been deleted successfully
// @api:body ?[]string an array of identifiers of {{.Names}} to delete
func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
func deleteMany[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
@@ -654,7 +649,7 @@ func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSO
// Modify the specified {{.Name}} referenced its unique identifier, changes to attributes being specified as a JSON map in the request body.
// @api:response 200:T the modified {{.Name}}
func modify[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
func modify[T jmap.Foo, CHANGE jmap.Change[T], CHANGES jmap.Changes[T]](
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,

View File

@@ -41,3 +41,17 @@ func ptrIfNot[T comparable](t T, notThis T) *T {
return nil
}
}
func identity[T any | uint | int | bool](value T) T {
return value
}
func striter[T ~string](s []T) iter.Seq[string] {
return func(yield func(string) bool) {
for _, v := range s {
if !yield(string(v)) {
return
}
}
}
}