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