groupware: implement JSON:API's error response format, with a revamped error handling in jmap and services/groupware

This commit is contained in:
Pascal Bleser
2025-07-28 16:57:17 +02:00
parent 07522ce79a
commit 2191b1d011
11 changed files with 459 additions and 174 deletions

View File

@@ -11,7 +11,7 @@ import (
)
type Client struct {
wellKnown WellKnownClient
wellKnown SessionClient
api ApiClient
io.Closer
}
@@ -20,7 +20,7 @@ func (j *Client) Close() error {
return j.api.Close()
}
func NewClient(wellKnown WellKnownClient, api ApiClient) Client {
func NewClient(wellKnown SessionClient, api ApiClient) Client {
return Client{
wellKnown: wellKnown,
api: api,
@@ -79,41 +79,23 @@ func (s Session) DecorateLogger(l log.Logger) log.Logger {
}
}
var (
errWellKnownResponseHasNoUsername = fmt.Errorf("well-known response has no username")
errWellKnownResponseHasJmapMailPrimaryAccount = fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail)
errWellKnownResponseHasNoApiUrl = fmt.Errorf("well-known response has no API URL")
)
type WellKnownResponseHasInvalidApiUrlError struct {
ApiUrl string
Err error
}
func (e WellKnownResponseHasInvalidApiUrlError) Error() string {
return fmt.Sprintf("well-known response contains an invalid API URL '%s': %v", e.ApiUrl, e.Err.Error())
}
func (e WellKnownResponseHasInvalidApiUrlError) Unwrap() error {
return e.Err
}
// Create a new Session from a WellKnownResponse.
func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
username := wellKnownResponse.Username
func NewSession(sessionResponse SessionResponse) (Session, Error) {
username := sessionResponse.Username
if username == "" {
return Session{}, errWellKnownResponseHasNoUsername
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
}
accountId := wellKnownResponse.PrimaryAccounts[JmapMail]
accountId := sessionResponse.PrimaryAccounts[JmapMail]
if accountId == "" {
return Session{}, errWellKnownResponseHasJmapMailPrimaryAccount
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")}
}
apiStr := wellKnownResponse.ApiUrl
apiStr := sessionResponse.ApiUrl
if apiStr == "" {
return Session{}, errWellKnownResponseHasNoApiUrl
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
}
apiUrl, err := url.Parse(apiStr)
if err != nil {
return Session{}, WellKnownResponseHasInvalidApiUrlError{ApiUrl: apiStr, Err: err}
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
}
return Session{
Username: username,
@@ -123,8 +105,8 @@ func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
}
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, error) {
wk, err := j.wellKnown.GetWellKnown(username, logger)
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) {
wk, err := j.wellKnown.GetSession(username, logger)
if err != nil {
return Session{}, err
}
@@ -141,62 +123,62 @@ func (j *Client) loggerParams(operation string, session *Session, logger *log.Lo
}
// https://jmap.io/spec-mail.html#identityget
func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, error) {
func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) {
logger = j.logger("GetIdentity", session, logger)
cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0"))
if err != nil {
return IdentityGetResponse{}, err
return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, Error) {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response)
return response, err
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
// https://jmap.io/spec-mail.html#vacationresponseget
func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) {
func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) {
logger = j.logger("GetVacationResponse", session, logger)
cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0"))
if err != nil {
return VacationResponseGetResponse{}, err
return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, Error) {
var response VacationResponseGetResponse
err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response)
return response, err
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
// https://jmap.io/spec-mail.html#mailboxget
func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, error) {
func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) {
logger = j.logger("GetMailbox", session, logger)
cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId, Ids: ids}, "0"))
if err != nil {
return MailboxGetResponse{}, err
return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, Error) {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response)
return response, err
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) {
func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) {
return j.GetMailbox(session, ctx, logger, nil)
}
// https://jmap.io/spec-mail.html#mailboxquery
func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, error) {
func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) {
logger = j.logger("QueryMailbox", session, logger)
cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0"))
if err != nil {
return MailboxQueryResponse{}, err
return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, Error) {
var response MailboxQueryResponse
err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response)
return response, err
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
@@ -205,7 +187,7 @@ type Mailboxes struct {
State string `json:"state,omitempty"`
}
func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, error) {
func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) {
logger = j.logger("SearchMailboxes", session, logger)
cmd, err := request(
@@ -216,14 +198,14 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *
}, "1"),
)
if err != nil {
return Mailboxes{}, err
return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, Error) {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response)
if err != nil {
return Mailboxes{}, err
return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil
})
@@ -234,7 +216,7 @@ type Emails struct {
State string `json:"state,omitempty"`
}
func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, error) {
func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
})
@@ -267,14 +249,14 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
invocation(EmailGet, get, "1"),
)
if err != nil {
return Emails{}, err
return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, error) {
return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, Error) {
var response EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
if err != nil {
return Emails{}, err
return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return Emails{Emails: response.List, State: body.SessionState}, nil
})

View File

@@ -8,10 +8,10 @@ import (
)
type ApiClient interface {
Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error)
Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error)
io.Closer
}
type WellKnownClient interface {
GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error)
type SessionClient interface {
GetSession(username string, logger *log.Logger) (SessionResponse, Error)
}

45
pkg/jmap/jmap_error.go Normal file
View File

@@ -0,0 +1,45 @@
package jmap
const (
JmapErrorAuthenticationFailed = iota
JmapErrorInvalidHttpRequest
JmapErrorServerResponse
JmapErrorReadingResponseBody
JmapErrorDecodingResponseBody
JmapErrorEncodingRequestBody
JmapErrorCreatingRequest
JmapErrorSendingRequest
JmapErrorInvalidSessionResponse
JmapErrorInvalidJmapRequestPayload
JmapErrorInvalidJmapResponsePayload
)
type Error interface {
Code() int
error
}
type SimpleError struct {
code int
err error
}
var _ Error = &SimpleError{}
func (e SimpleError) Code() int {
return e.code
}
func (e SimpleError) Unwrap() error {
return e.err
}
func (e SimpleError) Error() string {
return e.err.Error()
}
func simpleError(err error, code int) Error {
if err != nil {
return SimpleError{code: code, err: err}
} else {
return nil
}
}

View File

@@ -22,8 +22,8 @@ type HttpJmapApiClient struct {
}
var (
_ ApiClient = &HttpJmapApiClient{}
_ WellKnownClient = &HttpJmapApiClient{}
_ ApiClient = &HttpJmapApiClient{}
_ SessionClient = &HttpJmapApiClient{}
)
/*
@@ -64,28 +64,13 @@ func (h *HttpJmapApiClient) auth(username string, logger *log.Logger, req *http.
return nil
}
type HttpError struct {
Method string
Url string
Username string
Op string
Err error
}
func (e HttpError) Error() string {
return fmt.Sprintf("HTTP error for method=%v url='%v' username='%v' while %v: %v", e.Method, e.Url, e.Username, e.Op, e.Err.Error())
}
func (e HttpError) Unwrap() error {
return e.Err
}
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) {
wellKnownUrl := h.baseurl.JoinPath(".well-known", "jmap").String()
req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl)
return WellKnownResponse{}, HttpError{Op: "creating request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
h.auth(username, logger, req)
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
@@ -93,11 +78,11 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
return WellKnownResponse{}, HttpError{Op: "performing request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
if res.StatusCode != 200 {
if res.StatusCode < 200 || res.StatusCode > 299 {
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200")
return WellKnownResponse{}, HttpError{Op: "processing response", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: fmt.Errorf("status is %v", res.Status)}
return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)}
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
@@ -111,32 +96,32 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
return WellKnownResponse{}, HttpError{Op: "reading response body", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
return SessionResponse{}, SimpleError{code: JmapErrorReadingResponseBody, err: err}
}
var data WellKnownResponse
var data SessionResponse
err = json.Unmarshal(body, &data)
if err != nil {
logger.Error().Str("url", wellKnownUrl).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
return WellKnownResponse{}, HttpError{Op: "reading decoding response JSON payload", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
return data, nil
}
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) {
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) {
jmapUrl := session.JmapUrl.String()
bodyBytes, marshalErr := json.Marshal(request)
if marshalErr != nil {
logger.Error().Err(marshalErr).Msg("failed to marshall JSON payload")
return nil, marshalErr
bodyBytes, err := json.Marshal(request)
if err != nil {
logger.Error().Err(err).Msg("failed to marshall JSON payload")
return nil, SimpleError{code: JmapErrorEncodingRequestBody, err: err}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
if reqErr != nil {
logger.Error().Err(reqErr).Msgf("failed to create GET request for %v", jmapUrl)
return nil, reqErr
req, err := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
if err != nil {
logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl)
return nil, SimpleError{code: JmapErrorCreatingRequest, err: err}
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", h.userAgent)
@@ -144,12 +129,12 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl)
return nil, HttpError{Op: "performing request", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err}
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
return nil, SimpleError{code: JmapErrorSendingRequest, err: err}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
return nil, HttpError{Op: "processing response", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: fmt.Errorf("status is %v", res.Status)}
return nil, SimpleError{code: JmapErrorServerResponse, err: err}
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
@@ -163,7 +148,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
return nil, HttpError{Op: "reading response body", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err}
return nil, SimpleError{code: JmapErrorServerResponse, err: err}
}
return body, nil

View File

@@ -23,23 +23,23 @@ const (
JmapKeywordMdnSent = "$mdnsent"
)
type WellKnownAccount struct {
type SessionAccount struct {
Name string `json:"name,omitempty"`
IsPersonal bool `json:"isPersonal"`
IsReadOnly bool `json:"isReadOnly"`
AccountCapabilities map[string]any `json:"accountCapabilities,omitempty"`
}
type WellKnownResponse struct {
Capabilities map[string]any `json:"capabilities,omitempty"`
Accounts map[string]WellKnownAccount `json:"accounts,omitempty"`
PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"`
Username string `json:"username,omitempty"`
ApiUrl string `json:"apiUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
UploadUrl string `json:"uploadUrl,omitempty"`
EventSourceUrl string `json:"eventSourceUrl,omitempty"`
State string `json:"state,omitempty"`
type SessionResponse struct {
Capabilities map[string]any `json:"capabilities,omitempty"`
Accounts map[string]SessionAccount `json:"accounts,omitempty"`
PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"`
Username string `json:"username,omitempty"`
ApiUrl string `json:"apiUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
UploadUrl string `json:"uploadUrl,omitempty"`
EventSourceUrl string `json:"eventSourceUrl,omitempty"`
State string `json:"state,omitempty"`
}
type Mailbox struct {

View File

@@ -19,7 +19,7 @@ type TestJmapWellKnownClient struct {
t *testing.T
}
func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient {
func NewTestJmapWellKnownClient(t *testing.T) SessionClient {
return &TestJmapWellKnownClient{t: t}
}
@@ -27,8 +27,8 @@ func (t *TestJmapWellKnownClient) Close() error {
return nil
}
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
return WellKnownResponse{
func (t *TestJmapWellKnownClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) {
return SessionResponse{
Username: generateRandomString(8),
ApiUrl: "test://",
PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
@@ -47,12 +47,12 @@ func (t TestJmapApiClient) Close() error {
return nil
}
func serveTestFile(t *testing.T, name string) ([]byte, error) {
func serveTestFile(t *testing.T, name string) ([]byte, Error) {
cwd, _ := os.Getwd()
p := filepath.Join(cwd, "testdata", name)
bytes, err := os.ReadFile(p)
if err != nil {
return bytes, err
return bytes, SimpleError{code: 0, err: err}
}
// try to parse it first to avoid any deeper issues that are caused by the test tools
var target map[string]any
@@ -60,10 +60,10 @@ func serveTestFile(t *testing.T, name string) ([]byte, error) {
if err != nil {
t.Errorf("failed to parse JSON test data file '%v': %v", p, err)
}
return bytes, err
return bytes, SimpleError{code: 0, err: err}
}
func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) {
func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) {
command := request.MethodCalls[0].Command
switch command {
case MailboxGet:
@@ -72,7 +72,7 @@ func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
return serveTestFile(t.t, "mails1.json")
default:
require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command)
return nil, fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)
return nil, SimpleError{code: 0, err: fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)}
}
}

View File

@@ -16,20 +16,20 @@ func command[T any](api ApiClient,
ctx context.Context,
session *Session,
request Request,
mapper func(body *Response) (T, error)) (T, error) {
mapper func(body *Response) (T, Error)) (T, Error) {
responseBody, err := api.Command(ctx, logger, session, request)
if err != nil {
responseBody, jmapErr := api.Command(ctx, logger, session, request)
if jmapErr != nil {
var zero T
return zero, err
return zero, jmapErr
}
var data Response
err = json.Unmarshal(responseBody, &data)
err := json.Unmarshal(responseBody, &data)
if err != nil {
logger.Error().Err(err).Msg("failed to deserialize body JSON payload")
var zero T
return zero, err
return zero, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
return mapper(&data)

View File

@@ -9,10 +9,10 @@ import (
func TestDeserializeMailboxGetResponse(t *testing.T) {
require := require.New(t)
jsonBytes, err := serveTestFile(t, "mailboxes1.json")
require.NoError(err)
jsonBytes, jmapErr := serveTestFile(t, "mailboxes1.json")
require.NoError(jmapErr)
var data Response
err = json.Unmarshal(jsonBytes, &data)
err := json.Unmarshal(jsonBytes, &data)
require.NoError(err)
require.Empty(data.CreatedIds)
require.Equal("3e25b2a0", data.SessionState)
@@ -62,10 +62,10 @@ func TestDeserializeMailboxGetResponse(t *testing.T) {
func TestDeserializeEmailGetResponse(t *testing.T) {
require := require.New(t)
jsonBytes, err := serveTestFile(t, "mails1.json")
require.NoError(err)
jsonBytes, jmapErr := serveTestFile(t, "mails1.json")
require.NoError(jmapErr)
var data Response
err = json.Unmarshal(jsonBytes, &data)
err := json.Unmarshal(jsonBytes, &data)
require.NoError(err)
require.Empty(data.CreatedIds)
require.Equal("3e25b2a0", data.SessionState)

View File

@@ -14,7 +14,6 @@ import (
func (g Groupware) Route(r chi.Router) {
r.Get("/", g.Index)
r.Get("/ping", g.Ping)
r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed=
r.Get("/mailbox/{id}", g.GetMailboxById)
r.Get("/{mailbox}/messages", g.GetMessages)
@@ -30,11 +29,6 @@ func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("groupware pinged")
w.WriteHeader(http.StatusNoContent)
}
func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := g.logger.SubloggerWithRequestID(ctx)
@@ -55,16 +49,16 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
}
func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
res, err := g.jmap.GetIdentity(session, ctx, logger)
return res, res.State, err
return res, res.State, apiErrorFromJmap(err)
})
}
func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
res, err := g.jmap.GetVacationResponse(session, ctx, logger)
return res, res.State, err
return res, res.State, apiErrorFromJmap(err)
})
}
@@ -75,16 +69,16 @@ func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) {
return
}
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
res, err := g.jmap.GetMailbox(session, ctx, logger, []string{mailboxId})
if err != nil {
return res, "", err
return res, "", apiErrorFromJmap(err)
}
if len(res.List) == 1 {
return res.List[0], res.State, err
return res.List[0], res.State, apiErrorFromJmap(err)
} else {
return nil, res.State, err
return nil, res.State, apiErrorFromJmap(err)
}
})
}
@@ -116,18 +110,18 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
}
if hasCriteria {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
mailboxes, err := g.jmap.SearchMailboxes(session, ctx, logger, filter)
if err != nil {
return nil, "", err
return nil, "", apiErrorFromJmap(err)
}
return mailboxes.Mailboxes, mailboxes.State, nil
})
} else {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
mailboxes, err := g.jmap.GetAllMailboxes(session, ctx, logger)
if err != nil {
return nil, "", err
return nil, "", apiErrorFromJmap(err)
}
return mailboxes.List, mailboxes.State, nil
})
@@ -136,7 +130,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, "mailbox")
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) {
g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) {
page, ok, _ := ParseNumericParam(r, "page", -1)
if ok {
logger = &log.Logger{Logger: logger.With().Int("page", page).Logger()}
@@ -154,7 +148,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
emails, err := g.jmap.GetEmails(session, ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
if err != nil {
return nil, "", err
return nil, "", apiErrorFromJmap(err)
}
return emails, emails.State, nil

View File

@@ -0,0 +1,295 @@
package groupware
import (
"net/http"
"strconv"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
type Link struct {
Href string `json:"href"`
Rel string `json:"rel,omitempty"`
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
type ErrorLinks struct {
About any `json:"about,omitempty"`
Type any `json:"type"` // either a string containing an URL, or a Link object
}
type ErrorSource struct {
Pointer string `json:"pointer,omitempty"` // a JSON Pointer [RFC6901] to the value in the request document that caused the error
Parameter string `json:"parameter,omitempty"` // a string indicating which URI query parameter caused the error
Header string `json:"header,omitempty"` // a string indicating the name of a single request header which caused the error
}
type ApiError struct {
Id string `json:"id"` // a unique identifier for this particular occurrence of the problem
Links *ErrorLinks `json:"links,omitempty"`
NumStatus int `json:"-"`
Status string `json:"status"` // the HTTP status code applicable to this problem, expressed as a string value
Code string `json:"code"` // an application-specific error code, expressed as a string value
Title string `json:"title,omitempty"` // a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem
Detail string `json:"detail,omitempty"` // a human-readable explanation specific to this occurrence of the problem
Source *ErrorSource `json:"source,omitempty"` // an object containing references to the primary source of the error
Meta map[string]any `json:"meta,omitempty"` // a meta object containing non-standard meta-information about the error
}
type ErrorResponse struct {
Errors []ApiError `json:"errors"`
}
var _ render.Renderer = ErrorResponse{}
func (e ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error {
w.Header().Add("Content-Type", ContentTypeJsonApi)
if len(e.Errors) > 0 {
render.Status(r, e.Errors[0].NumStatus)
} else {
render.Status(r, http.StatusInternalServerError)
}
return nil
}
const (
ContentTypeJsonApi = "application/vnd.api+json"
)
type GroupwareError struct {
Status int
Code string
Title string
Detail string
}
func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
if j == nil {
return nil
}
switch j.Code() {
case jmap.JmapErrorAuthenticationFailed:
return &ErrorForbidden
case jmap.JmapErrorInvalidHttpRequest:
return &ErrorInvalidRequest
case jmap.JmapErrorServerResponse:
return &ErrorServerResponse
case jmap.JmapErrorReadingResponseBody:
return &ErrorReadingResponse
case jmap.JmapErrorDecodingResponseBody:
return &ErrorProcessingResponse
case jmap.JmapErrorEncodingRequestBody:
return &ErrorEncodingRequestBody
case jmap.JmapErrorCreatingRequest:
return &ErrorCreatingRequest
case jmap.JmapErrorSendingRequest:
return &ErrorSendingRequest
case jmap.JmapErrorInvalidSessionResponse:
return &ErrorInvalidSessionResponse
case jmap.JmapErrorInvalidJmapRequestPayload:
return &ErrorInvalidRequestPayload
case jmap.JmapErrorInvalidJmapResponsePayload:
return &ErrorInvalidResponsePayload
default:
return &ErrorGeneric
}
}
const (
ErrorCodeGeneric = "ERRGEN"
ErrorCodeMissingAuthentication = "AUTMIS"
ErrorCodeForbiddenGeneric = "AUTFOR"
ErrorCodeInvalidRequest = "INVREQ"
ErrorCodeServerResponse = "SRVRSP"
ErrorCodeServerReadingResponse = "SRVRRE"
ErrorCodeServerDecodingResponseBody = "SRVDRB"
ErrorCodeEncodingRequestBody = "ENCREQ"
ErrorCodeCreatingRequest = "CREREQ"
ErrorCodeSendingRequest = "SNDREQ"
ErrorCodeInvalidSessionResponse = "INVSES"
ErrorCodeInvalidRequestPayload = "INVRQP"
ErrorCodeInvalidResponsePayload = "INVRSP"
)
var (
ErrorGeneric = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeGeneric,
Title: "Unspecific Error",
Detail: "Error without a specific description.",
}
ErrorMissingAuthentication = GroupwareError{
Status: http.StatusUnauthorized,
Code: ErrorCodeMissingAuthentication,
Title: "Missing Authentication",
Detail: "No authentication credentials were provided.",
}
ErrorForbidden = GroupwareError{
Status: http.StatusForbidden,
Code: ErrorCodeForbiddenGeneric,
Title: "Invalid Authentication",
Detail: "Authentication credentials were provided but are either invalid or not authorized to perform the request operation.",
}
ErrorInvalidRequest = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeInvalidRequest,
Title: "Invalid Request",
Detail: "The request that was meant to be sent to the mail server is invalid, which might be caused by configuration issues.",
}
ErrorServerResponse = GroupwareError{
Status: http.StatusServiceUnavailable,
Code: ErrorCodeServerResponse,
Title: "Server responds with an Error",
Detail: "The mail server responded with an error.",
}
ErrorReadingResponse = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerResponse,
Title: "Server Response Body could not be decoded",
Detail: "The mail server response body could not be decoded.",
}
ErrorProcessingResponse = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerResponse,
Title: "Server Response Body could not be decoded",
Detail: "The mail server response body could not be decoded.",
}
ErrorEncodingRequestBody = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeEncodingRequestBody,
Title: "Failed to encode the Request Body",
Detail: "Failed to encode the body of the request to be sent to the mail server.",
}
ErrorCreatingRequest = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeCreatingRequest,
Title: "Failed to create the Request",
Detail: "Failed to create the request to be sent to the mail server.",
}
ErrorSendingRequest = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeSendingRequest,
Title: "Failed to send the Request",
Detail: "Failed to send the request to the mail server.",
}
ErrorInvalidSessionResponse = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeInvalidSessionResponse,
Title: "Invalid JMAP Session Response",
Detail: "The JMAP session response that was provided by the mail server is invalid.",
}
ErrorInvalidRequestPayload = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeInvalidRequestPayload,
Title: "Invalid Request Payload",
Detail: "The request to the mail server is invalid.",
}
ErrorInvalidResponsePayload = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeInvalidResponsePayload,
Title: "Invalid Response Payload",
Detail: "The payload of the response received from the mail server is invalid.",
}
)
type ErrorOpt interface {
apply(error *ApiError)
}
type ErrorLinksOpt struct {
links *ErrorLinks
}
func (o ErrorLinksOpt) apply(error *ApiError) {
error.Links = o.links
}
type SourceLinksOpt struct {
source *ErrorSource
}
func (o SourceLinksOpt) apply(error *ApiError) {
error.Source = o.source
}
type MetaLinksOpt struct {
meta map[string]any
}
func (o MetaLinksOpt) apply(error *ApiError) {
error.Meta = o.meta
}
type CodeOpt struct {
code string
}
func (o CodeOpt) apply(error *ApiError) {
error.Code = o.code
}
type TitleOpt struct {
title string
detail string
}
func (o TitleOpt) apply(error *ApiError) {
error.Title = o.title
error.Detail = o.detail
}
func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorResponse {
err := ApiError{
Id: id,
NumStatus: error.Status,
Status: strconv.Itoa(error.Status),
Code: error.Code,
Title: error.Title,
Detail: error.Detail,
}
for _, o := range options {
o.apply(&err)
}
return ErrorResponse{
Errors: []ApiError{err},
}
}
func apiError(id string, error GroupwareError, options ...ErrorOpt) ApiError {
err := ApiError{
Id: id,
NumStatus: error.Status,
Status: strconv.Itoa(error.Status),
Code: error.Code,
Title: error.Title,
Detail: error.Detail,
}
for _, o := range options {
o.apply(&err)
}
return err
}
func apiErrorFromJmap(error jmap.Error) *ApiError {
if error == nil {
return nil
}
gwe := groupwareErrorFromJmap(error)
if gwe == nil {
return nil
}
api := apiError(uuid.NewString(), *gwe)
return &api
}
func errorResponses(errors ...ApiError) ErrorResponse {
return ErrorResponse{Errors: errors}
}

View File

@@ -124,27 +124,11 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro
return nil, GroupwareInitializationError{Message: "Mail.Master.Password is empty"}
}
defaultEmailLimit := config.Mail.DefaultEmailLimit
if defaultEmailLimit < 0 {
defaultEmailLimit = 0
}
maxBodyValueBytes := config.Mail.MaxBodyValueBytes
if maxBodyValueBytes < 0 {
maxBodyValueBytes = 0
}
responseHeaderTimeout := config.Mail.ResponseHeaderTimeout
if responseHeaderTimeout < 0 {
responseHeaderTimeout = 0
}
sessionCacheTtl := config.Mail.SessionCacheTtl
if sessionCacheTtl < 0 {
sessionCacheTtl = 0
}
sessionFailureCacheTtl := config.Mail.SessionFailureCacheTtl
if sessionFailureCacheTtl < 0 {
sessionFailureCacheTtl = 0
}
defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0)
maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0)
responseHeaderTimeout := max(config.Mail.ResponseHeaderTimeout, 0)
sessionCacheTtl := max(config.Mail.SessionCacheTtl, 0)
sessionFailureCacheTtl := max(config.Mail.SessionFailureCacheTtl, 0)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = responseHeaderTimeout
@@ -223,7 +207,7 @@ func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.L
}
func (g Groupware) respond(w http.ResponseWriter, r *http.Request,
handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error)) {
handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError)) {
ctx := r.Context()
logger := g.logger.SubloggerWithRequestID(ctx)
session, ok, err := g.session(r, ctx, &logger)
@@ -240,10 +224,10 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request,
}
logger = session.DecorateLogger(logger)
response, state, err := handler(r, ctx, &logger, &session)
if err != nil {
logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error())
w.WriteHeader(http.StatusInternalServerError)
response, state, apierr := handler(r, ctx, &logger, &session)
if apierr != nil {
logger.Warn().Interface("error", apierr).Msgf("API error: %v", apierr)
render.Render(w, r, errorResponses(*apierr))
return
}
@@ -251,7 +235,7 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request,
w.Header().Add("ETag", state)
}
if response == nil {
w.WriteHeader(http.StatusNotFound)
render.Status(r, http.StatusNotFound)
} else {
render.Status(r, http.StatusOK)
render.JSON(w, r, response)