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.ErrOutOfRange):
59 return connect.CodeOutOfRange, true
60 case errors.Is(err, domain.ErrUnavailable):
61 return connect.CodeUnavailable, true
62 }
63 if domain.IsInvalid(err) {
64 return connect.CodeInvalidArgument, true
65 }
66 return 0, false
67}
68
69// addFieldDetail attaches the fields a request was rejected for as a
70// BadRequest detail, when err carries them.
71func addFieldDetail(ce *connect.Error, err error) {
72 var fe *domain.InvalidFieldsError
73 if !errors.As(err, &fe) {
74 return
75 }
76 br := &errdetails.BadRequest{}
77 for _, f := range fe.Fields {
78 br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{Field: f.Field, Description: f.Reason})
79 }
80 if d, derr := connect.NewErrorDetail(br); derr == nil {
81 ce.AddDetail(d)
82 }
83}