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 for _, invalid := range invalidErrors {
60 if errors.Is(err, invalid) {
61 return connect.CodeInvalidArgument, true
62 }
63 }
64 return 0, false
65}
66
67// invalidErrors are the domain errors that mean the request was malformed.
68var invalidErrors = []error{
69 domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility,
70 domain.ErrInvalidScope, domain.ErrInvalidToken,
71 domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath,
72 domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind,
73 domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText,
74}
75
76// addFieldDetail attaches the fields a request was rejected for as a
77// BadRequest detail, when err carries them.
78func addFieldDetail(ce *connect.Error, err error) {
79 var fe *domain.InvalidFieldsError
80 if !errors.As(err, &fe) {
81 return
82 }
83 br := &errdetails.BadRequest{}
84 for _, f := range fe.Fields {
85 br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{Field: f.Field, Description: f.Reason})
86 }
87 if d, derr := connect.NewErrorDetail(br); derr == nil {
88 ce.AddDetail(d)
89 }
90}