1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "errors"
8
9 "connectrpc.com/connect"
10 "github.com/bnema/zerowrap"
11 "google.golang.org/genproto/googleapis/rpc/errdetails"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16// connectError maps an error of a use case to a Connect error with the code
17// the contract documents. Errors it does not know are internal: they are
18// logged here and the client only sees a generic message.
19func connectError(ctx context.Context, err error) error {
20 if err == nil {
21 return nil
22 }
23 var ce *connect.Error
24 if errors.As(err, &ce) {
25 return err
26 }
27 if code, ok := codeOf(err); ok {
28 ce := connect.NewError(code, err)
29 addFieldDetail(ce, err)
30 return ce
31 }
32 log := zerowrap.FromCtx(ctx)
33 log.Error().Err(err).Msg("api request failed")
34 return connect.NewError(connect.CodeInternal, errors.New("internal error"))
35}
36
37// codeOf finds the Connect code of a domain error.
38func codeOf(err error) (connect.Code, bool) {
39 switch {
40 case errors.Is(err, context.Canceled):
41 return connect.CodeCanceled, true
42 case errors.Is(err, context.DeadlineExceeded):
43 return connect.CodeDeadlineExceeded, true
44 case errors.Is(err, domain.ErrNotFound):
45 return connect.CodeNotFound, true
46 case errors.Is(err, domain.ErrAlreadyExists):
47 return connect.CodeAlreadyExists, true
48 case errors.Is(err, domain.ErrUnauthenticated):
49 return connect.CodeUnauthenticated, true
50 case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
51 return connect.CodePermissionDenied, true
52 case errors.Is(err, domain.ErrPreconditionFailed):
53 return connect.CodeFailedPrecondition, true
54 case errors.Is(err, domain.ErrConflict):
55 return connect.CodeAborted, true
56 case errors.Is(err, domain.ErrResourceExhausted):
57 return connect.CodeResourceExhausted, true
58 case errors.Is(err, domain.ErrUnavailable):
59 return connect.CodeUnavailable, true
60 }
61 if domain.IsInvalid(err) {
62 return connect.CodeInvalidArgument, true
63 }
64 return 0, false
65}
66
67// addFieldDetail attaches the fields a request was rejected for as a
68// BadRequest detail, when err carries them.
69func addFieldDetail(ce *connect.Error, err error) {
70 var fe *domain.InvalidFieldsError
71 if !errors.As(err, &fe) {
72 return
73 }
74 br := &errdetails.BadRequest{}
75 for _, f := range fe.Fields {
76 br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{Field: f.Field, Description: f.Reason})
77 }
78 if d, derr := connect.NewErrorDetail(br); derr == nil {
79 ce.AddDetail(d)
80 }
81}