diff --git a/errors/README.md b/errors/README.md new file mode 100644 index 0000000..0e2e9a6 --- /dev/null +++ b/errors/README.md @@ -0,0 +1,34 @@ +# Errors + +The standardizing of errors to be used in Dapr based on the gRPC Richer Error Model and [accepted dapr/proposal](https://github.com/dapr/proposals/blob/main/0009-BCIRS-error-handling-codes.md). + +## Usage + +Define the error +```go +import kitErrors "github.com/dapr/kit/errors" + +// Define error in dapr pkg/api/_errors.go +func PubSubNotFound(name string, pubsubType string, metadata map[string]string) error { + message := fmt.Sprintf("pubsub %s is not found", name) + + return kitErrors.NewBuilder( + grpcCodes.NotFound, + http.StatusBadRequest, + message, + kitErrors.CodePrefixPubSub+kitErrors.CodeNotFound, + ). + WithErrorInfo(kitErrors.CodePrefixPubSub+kitErrors.CodeNotFound, metadata). + WithResourceInfo(pubsubType, name, "", message). + Build() +} +``` + +Use the error +```go +import apiErrors "github.com/dapr/dapr/pkg/api/errors" + +// Use error in dapr and pass in relevant information +err = apiErrors.PubSubNotFound(pubsubName, pubsubType, metadata) + +``` diff --git a/errors/codes.go b/errors/codes.go new file mode 100644 index 0000000..171c78a --- /dev/null +++ b/errors/codes.go @@ -0,0 +1,37 @@ +/* +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errors + +const ( + // Generic + CodeNotFound = "NOT_FOUND" + CodeNotConfigured = "NOT_CONFIGURED" + CodeNotSupported = "NOT_SUPPORTED" + CodeIllegalKey = "ILLEGAL_KEY" + + // Components + CodePrefixStateStore = "DAPR_STATE_" + CodePrefixPubSub = "DAPR_PUBSUB_" + CodePrefixBindings = "DAPR_BINDING_" + CodePrefixSecretStore = "DAPR_SECRET_" + CodePrefixConfigurationStore = "DAPR_CONFIGURATION_" + CodePrefixLock = "DAPR_LOCK_" + CodePrefixNameResolution = "DAPR_NAME_RESOLUTION_" + CodePrefixMiddleware = "DAPR_MIDDLEWARE_" + + // State + CodePostfixGetStateFailed = "GET_STATE_FAILED" + CodePostfixTooManyTransactions = "TOO_MANY_TRANSACTIONS" + CodePostfixQueryFailed = "QUERY_FAILED" +) diff --git a/errors/errors.go b/errors/errors.go index 28637d9..ae66f08 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -15,209 +15,421 @@ package errors import ( "encoding/json" + "errors" "fmt" "net/http" "google.golang.org/genproto/googleapis/rpc/errdetails" - "google.golang.org/grpc/codes" + grpcCodes "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/runtime/protoiface" - "github.com/dapr/kit/grpccodes" + "github.com/dapr/kit/logger" ) const ( - resourceInfoDefaultOwner = "dapr-components" - errorInfoDefaultDomain = "dapr.io" - errorInfoResonUnknown = "UNKNOWN_REASON" -) + Domain = "dapr.io" -var UnknownErrorReason = WithErrorReason(errorInfoResonUnknown, codes.Unknown) + errStringFormat = "api error: code = %s desc = %s" -// ResourceInfo is meant to be used by Dapr components -// to indicate the Type and Name. -type ResourceInfo struct { - Type string - Name string - Owner string -} + typeGoogleAPI = "type.googleapis.com/" +) -// Option allows passing additional information -// to the Error struct. -// See With* functions for further details. -type Option func(*Error) +var log = logger.NewLogger("dapr.kit") -// Error encapsulates error information -// with additional details like: -// - http code -// - grpcStatus code -// - error reason -// - metadata information -// - optional resourceInfo (componenttype/name) +// Error implements the Error interface and the interface that complies with "google.golang.org/grpc/status".FromError(). +// It can be used to send errors to HTTP and gRPC servers, indicating the correct status code for each. type Error struct { - err error - description string - reason string - httpCode int - grpcStatusCode codes.Code - metadata map[string]string - resourceInfo *ResourceInfo + // Added error details. To see available details see: + // https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto + details []proto.Message + + // Status code for gRPC responses. + grpcCode grpcCodes.Code + + // Status code for HTTP responses. + httpCode int + + // Message is the human-readable error message. + message string + + // Tag is a string identifying the error, used with HTTP responses only. + tag string } -// New create a new Error using the supplied metadata and Options -func New(err error, metadata map[string]string, options ...Option) *Error { - if err == nil { - return nil - } +// ErrorBuilder is used to build the error +type ErrorBuilder struct { + err Error +} - // Use default values - de := &Error{ - err: err, - reason: errorInfoResonUnknown, - httpCode: grpccodes.HTTPStatusFromCode(codes.Unknown), - grpcStatusCode: codes.Unknown, - metadata: metadata, - } +// errorJSON is used to build the error for the HTTP Methods json output +type errorJSON struct { + ErrorCode string `json:"errorCode"` + Message string `json:"message"` + Details []any `json:"details,omitempty"` +} - // Now apply any requested options - // to override - for _, option := range options { - option(de) - } +/************************************** +Error +**************************************/ - return de +// HTTPStatusCode gets the error http code +func (e *Error) HTTPStatusCode() int { + return e.httpCode +} + +// GrpcStatusCode gets the error grpc code +func (e *Error) GrpcStatusCode() grpcCodes.Code { + return e.grpcCode } // Error implements the error interface. -func (e *Error) Error() string { - if e != nil && e.err != nil { - return e.err.Error() - } - return "" +func (e Error) Error() string { + return e.String() +} + +// String returns the string representation. +func (e Error) String() string { + return fmt.Sprintf(errStringFormat, e.grpcCode.String(), e.message) } -// Unwrap implements the error unwrapping interface. -func (e *Error) Unwrap() error { - if e == nil { - return nil +// Is implements the interface that checks if the error matches the given one. +func (e *Error) Is(targetI error) bool { + // Ignore the message in the comparison because the target could have been formatted + var target *Error + if !errors.As(targetI, &target) { + return false } - return e.err + return e.tag == target.tag && + e.grpcCode == target.grpcCode && + e.httpCode == target.httpCode } -// Description returns the description of the error. -func (e *Error) Description() string { - if e == nil { - return "" +// Allow details to be mutable and added to the error in runtime +func (e *Error) AddDetails(details ...proto.Message) *Error { + e.details = append(e.details, details...) + + return e +} + +// FromError takes in an error and returns back the kitError if it's that type under the hood +func FromError(err error) (*Error, bool) { + if err == nil { + return nil, false } - if e.description != "" { - return e.description + + var kitErr Error + if errors.As(err, &kitErr) { + return &kitErr, true } - return e.err.Error() + + return nil, false } -// WithErrorReason used to pass reason and -// grpcStatus code to the Error struct. -func WithErrorReason(reason string, grpcStatusCode codes.Code) Option { - return func(err *Error) { - err.reason = reason - err.grpcStatusCode = grpcStatusCode - err.httpCode = grpccodes.HTTPStatusFromCode(grpcStatusCode) +/*** GRPC Methods ***/ + +// GRPCStatus returns the gRPC status.Status object. +func (e Error) GRPCStatus() *status.Status { + stat := status.New(e.grpcCode, e.message) + + // convert details from proto.Msg -> protoiface.MsgV1 + var convertedDetails []protoiface.MessageV1 + for _, detail := range e.details { + if v1, ok := detail.(protoiface.MessageV1); ok { + convertedDetails = append(convertedDetails, v1) + } else { + log.Debugf("Failed to convert error details: %s", detail) + } } -} -// WithResourceInfo used to pass ResourceInfo to the Error struct. -func WithResourceInfo(resourceInfo *ResourceInfo) Option { - return func(e *Error) { - e.resourceInfo = resourceInfo + if len(e.details) > 0 { + var err error + stat, err = stat.WithDetails(convertedDetails...) + if err != nil { + log.Debugf("Failed to add error details: %s to status: %s", err, stat) + } } + + return stat } -// WithDescription used to pass a description -// to the Error struct. -func WithDescription(description string) Option { - return func(e *Error) { - e.description = description +/*** HTTP Methods ***/ + +// JSONErrorValue implements the errorResponseValue interface. +func (e Error) JSONErrorValue() []byte { + grpcStatus := e.GRPCStatus().Proto() + + // Make httpCode human readable + + // If there is no http legacy code, use the http status text + // This will get overwritten later if there is an ErrorInfo code + httpStatus := e.tag + if httpStatus == "" { + httpStatus = http.StatusText(e.httpCode) } -} -// WithMetadata used to pass a Metadata[string]string -// to the Error struct. -func WithMetadata(md map[string]string) Option { - return func(e *Error) { - e.metadata = md + errJSON := errorJSON{ + ErrorCode: httpStatus, + Message: grpcStatus.GetMessage(), } + + // Handle err details + details := e.details + if len(details) > 0 { + errJSON.Details = make([]any, len(details)) + for i, detail := range details { + detailMap, errorCode := convertErrorDetails(detail, e) + errJSON.Details[i] = detailMap + + // If there is an errorCode, update the overall ErrorCode + if errorCode != "" { + errJSON.ErrorCode = errorCode + } + } + } + + errBytes, err := json.Marshal(errJSON) + if err != nil { + errJSON, _ := json.Marshal(fmt.Sprintf("failed to encode proto to JSON: %v", err)) + return errJSON + } + return errBytes } -func newErrorInfo(reason string, md map[string]string) *errdetails.ErrorInfo { - return &errdetails.ErrorInfo{ - Domain: errorInfoDefaultDomain, - Reason: reason, - Metadata: md, +func convertErrorDetails(detail any, e Error) (map[string]interface{}, string) { + // cast to interface to be able to do type switch + // over all possible error_details defined + // https://github.com/googleapis/go-genproto/blob/main/googleapis/rpc/errdetails/error_details.pb.go + switch typedDetail := detail.(type) { + case *errdetails.ErrorInfo: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "reason": typedDetail.GetReason(), + "domain": typedDetail.GetDomain(), + "metadata": typedDetail.GetMetadata(), + } + var errorCode string + // If there is an ErrorInfo Reason, but no legacy Tag code, use the ErrorInfo Reason as the error code + if e.tag == "" && typedDetail.GetReason() != "" { + errorCode = typedDetail.GetReason() + } + return detailMap, errorCode + case *errdetails.RetryInfo: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "retry_delay": typedDetail.GetRetryDelay(), + } + return detailMap, "" + case *errdetails.DebugInfo: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "stack_entries": typedDetail.GetStackEntries(), + "detail": typedDetail.GetDetail(), + } + return detailMap, "" + case *errdetails.QuotaFailure: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "violations": typedDetail.GetViolations(), + } + return detailMap, "" + case *errdetails.PreconditionFailure: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "violations": typedDetail.GetViolations(), + } + return detailMap, "" + case *errdetails.BadRequest: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "field_violations": typedDetail.GetFieldViolations(), + } + return detailMap, "" + case *errdetails.RequestInfo: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "request_id": typedDetail.GetRequestId(), + "serving_data": typedDetail.GetServingData(), + } + return detailMap, "" + case *errdetails.ResourceInfo: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "resource_type": typedDetail.GetResourceType(), + "resource_name": typedDetail.GetResourceName(), + "owner": typedDetail.GetOwner(), + "description": typedDetail.GetDescription(), + } + return detailMap, "" + case *errdetails.Help: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "links": typedDetail.GetLinks(), + } + return detailMap, "" + case *errdetails.LocalizedMessage: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "locale": typedDetail.GetLocale(), + "message": typedDetail.GetMessage(), + } + return detailMap, "" + case *errdetails.QuotaFailure_Violation: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "subject": typedDetail.GetSubject(), + "description": typedDetail.GetDescription(), + } + return detailMap, "" + case *errdetails.PreconditionFailure_Violation: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "subject": typedDetail.GetSubject(), + "description": typedDetail.GetDescription(), + "type": typedDetail.GetType(), + } + return detailMap, "" + case *errdetails.BadRequest_FieldViolation: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "field": typedDetail.GetField(), + "description": typedDetail.GetDescription(), + } + return detailMap, "" + case *errdetails.Help_Link: + desc := typedDetail.ProtoReflect().Descriptor() + detailMap := map[string]interface{}{ + "@type": typeGoogleAPI + desc.FullName(), + "description": typedDetail.GetDescription(), + "url": typedDetail.GetUrl(), + } + return detailMap, "" + default: + log.Debugf("Failed to convert error details due to incorrect type. \nSee types here: https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto. \nDetail: %s", detail) + // Handle unknown detail types + unknownDetail := map[string]interface{}{ + "unknownDetailType": fmt.Sprintf("%T", typedDetail), + "unknownDetails": fmt.Sprintf("%#v", typedDetail), + } + return unknownDetail, "" } } -func newResourceInfo(rid *ResourceInfo, err error) *errdetails.ResourceInfo { - owner := resourceInfoDefaultOwner - if rid.Owner != "" { - owner = rid.Owner +/************************************** +ErrorBuilder +**************************************/ + +// NewBuilder create a new ErrorBuilder using the supplied required error fields +func NewBuilder(grpcCode grpcCodes.Code, httpCode int, message string, tag string) *ErrorBuilder { + return &ErrorBuilder{ + err: Error{ + details: make([]proto.Message, 0), + grpcCode: grpcCode, + httpCode: httpCode, + message: message, + tag: tag, + }, } - return &errdetails.ResourceInfo{ - ResourceType: rid.Type, - ResourceName: rid.Name, +} + +// WithResourceInfo is used to pass ResourceInfo error details to the Error struct. +func (b *ErrorBuilder) WithResourceInfo(resourceType string, resourceName string, owner string, description string) *ErrorBuilder { + resourceInfo := &errdetails.ResourceInfo{ + ResourceType: resourceType, + ResourceName: resourceName, Owner: owner, - Description: err.Error(), + Description: description, } -} -// *** GRPC Methods *** + b.err.details = append(b.err.details, resourceInfo) -// GRPCStatus returns the gRPC status.Status object. -func (e *Error) GRPCStatus() *status.Status { - var stErr error - ste := status.New(e.grpcStatusCode, e.description) - if e.resourceInfo != nil { - ste, stErr = ste.WithDetails(newErrorInfo(e.reason, e.metadata), newResourceInfo(e.resourceInfo, e.err)) - } else { - ste, stErr = ste.WithDetails(newErrorInfo(e.reason, e.metadata)) - } - if stErr != nil { - return status.New(codes.Internal, fmt.Sprintf("failed to create gRPC status message: %v", stErr)) + return b +} + +// WithHelpLink is used to pass HelpLink error details to the Error struct. +func (b *ErrorBuilder) WithHelpLink(url string, description string) *ErrorBuilder { + link := errdetails.Help_Link{ + Description: description, + Url: url, } + var links []*errdetails.Help_Link + links = append(links, &link) + + help := &errdetails.Help{Links: links} + b.err.details = append(b.err.details, help) - return ste + return b } -// *** HTTP Methods *** +// WithHelp is used to pass Help error details to the Error struct. +func (b *ErrorBuilder) WithHelp(links []*errdetails.Help_Link) *ErrorBuilder { + b.err.details = append(b.err.details, &errdetails.Help{Links: links}) -// ToHTTP transforms the supplied error into -// a GRPC Status and then Marshals it to JSON. -// It assumes if the supplied error is of type Error. -// Otherwise, returns the original error. -func (e *Error) ToHTTP() (int, []byte) { - resp, err := protojson.Marshal(e.GRPCStatus().Proto()) - if err != nil { - errJSON, _ := json.Marshal(fmt.Sprintf("failed to encode proto to JSON: %v", err)) - return http.StatusInternalServerError, errJSON + return b +} + +// WithErrorInfo adds error information to the Error struct. +func (b *ErrorBuilder) WithErrorInfo(reason string, metadata map[string]string) *ErrorBuilder { + errorInfo := &errdetails.ErrorInfo{ + Domain: Domain, + Reason: reason, + Metadata: metadata, } + b.err.details = append(b.err.details, errorInfo) - return e.httpCode, resp + return b } -// HTTPCode returns the value of the HTTPCode property. -func (e *Error) HTTPCode() int { - if e == nil { - return http.StatusOK +// WithFieldViolation is used to pass FieldViolation error details to the Error struct. +func (b *ErrorBuilder) WithFieldViolation(fieldName string, msg string) *ErrorBuilder { + br := &errdetails.BadRequest{ + FieldViolations: []*errdetails.BadRequest_FieldViolation{{ + Field: fieldName, + Description: msg, + }}, } - return e.httpCode + b.err.details = append(b.err.details, br) + + return b } -// JSONErrorValue implements the errorResponseValue interface (used by `github.com/dapr/dapr/pkg/http`). -func (e *Error) JSONErrorValue() []byte { - b, err := protojson.Marshal(e.GRPCStatus().Proto()) - if err != nil { - errJSON, _ := json.Marshal(fmt.Sprintf("failed to encode proto to JSON: %v", err)) - return errJSON - } +// WithDetails is used to pass any error details to the Error struct. +func (b *ErrorBuilder) WithDetails(details ...proto.Message) *ErrorBuilder { + b.err.details = append(b.err.details, details...) + return b } + +// Build builds our error +func (b *ErrorBuilder) Build() error { + // Check for ErrorInfo, since it's required per the proposal + containsErrorInfo := false + for _, detail := range b.err.details { + if _, ok := detail.(*errdetails.ErrorInfo); ok { + containsErrorInfo = true + break + } + } + + if !containsErrorInfo { + log.Errorf("Must include ErrorInfo in error details. Error: %s", b.err.Error()) + panic("Must include ErrorInfo in error details.") + } + + return b.err +} diff --git a/errors/errors_test.go b/errors/errors_test.go index e15042f..1d64309 100644 --- a/errors/errors_test.go +++ b/errors/errors_test.go @@ -14,198 +14,963 @@ limitations under the License. package errors import ( + "encoding/json" "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" "net/http" + "path/filepath" + "reflect" + "runtime" "testing" + "golang.org/x/tools/go/packages" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/context" "google.golang.org/genproto/googleapis/rpc/errdetails" - "google.golang.org/grpc/codes" + grpcCodes "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" ) -func TestNewErrorReason(t *testing.T) { +func TestError_HTTPStatusCode(t *testing.T) { + httpStatusCode := http.StatusTeapot + kitErr := NewBuilder( + grpcCodes.ResourceExhausted, + httpStatusCode, + "Test Msg", + "SOME_ERROR", + ). + WithErrorInfo("fake", map[string]string{"fake": "test"}). + Build() + + err, ok := kitErr.(Error) + require.True(t, ok, httpStatusCode, err.HTTPStatusCode()) +} + +func TestError_GrpcStatusCode(t *testing.T) { + grpcStatusCode := grpcCodes.ResourceExhausted + kitErr := NewBuilder( + grpcStatusCode, + http.StatusTeapot, + "Test Msg", + "SOME_ERROR", + ). + WithErrorInfo("fake", map[string]string{"fake": "test"}). + Build() + + err, ok := kitErr.(Error) + require.True(t, ok, grpcStatusCode, err.GrpcStatusCode()) +} + +func TestError_AddDetails(t *testing.T) { + reason := "example_reason" + metadata := map[string]string{"key": "value"} + + details1 := &errdetails.ErrorInfo{ + Domain: Domain, + Reason: reason, + Metadata: metadata, + } + + details2 := &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + } + + expected := Error{ + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + details: []proto.Message{ + details1, + details2, + }, + } + + kitErr := &Error{ + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + } + + kitErr.AddDetails(details1, details2) + assert.Equal(t, expected, *kitErr) +} + +// Ensure Err format does not break users expecting this format +func TestError_Error(t *testing.T) { + type fields struct { + message string + grpcCode grpcCodes.Code + } tests := []struct { - name string - inErr error - inMetadata map[string]string - inOptions []Option - expectedDaprError bool - expectedReason string + name string + builder *ErrorBuilder + fields fields + want string }{ { - name: "Error_New_OK_No_Reason", - inErr: &Error{}, - inMetadata: map[string]string{}, - expectedDaprError: true, - expectedReason: "UNKNOWN_REASON", - }, - { - name: "DaprError_New_OK_StateETagMismatchReason", - inErr: &Error{}, - inMetadata: map[string]string{}, - expectedDaprError: true, - inOptions: []Option{ - WithErrorReason("StateETagMismatchReason", codes.NotFound), + name: "Has_GrpcCode_And_Message", + builder: NewBuilder( + grpcCodes.ResourceExhausted, + http.StatusTeapot, + "Msg", + "SOME_ERROR", + ).WithErrorInfo("fake", map[string]string{"fake": "test"}), + fields: fields{ + message: "Msg", + grpcCode: grpcCodes.ResourceExhausted, }, - expectedReason: "StateETagMismatchReason", + want: fmt.Sprintf(errStringFormat, grpcCodes.ResourceExhausted, "Msg"), }, { - name: "DaprError_New_OK_StateETagInvalidReason", - inErr: &Error{}, - inMetadata: map[string]string{}, - expectedDaprError: true, - inOptions: []Option{ - WithErrorReason("StateETagInvalidReason", codes.Aborted), + name: "Has_Only_Message", + builder: NewBuilder( + grpcCodes.OK, + http.StatusTeapot, + "Msg", + "SOME_ERROR", + ).WithErrorInfo("fake", map[string]string{"fake": "test"}), + fields: fields{ + message: "Msg", }, - expectedReason: "StateETagInvalidReason", + want: fmt.Sprintf(errStringFormat, grpcCodes.OK, "Msg"), }, { - name: "DaprError_New_Nil_Error", - inErr: nil, - inMetadata: map[string]string{}, - expectedDaprError: false, + name: "Has_Only_GrpcCode", + builder: NewBuilder( + grpcCodes.Canceled, + http.StatusTeapot, + "Msg", + "SOME_ERROR", + ).WithErrorInfo("fake", map[string]string{"fake": "test"}), + fields: fields{ + grpcCode: grpcCodes.Canceled, + }, + want: fmt.Sprintf(errStringFormat, grpcCodes.Canceled, "Msg"), }, - { - name: "DaprError_New_Nil_Metadata", - inErr: nil, - expectedDaprError: false, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kitErr := tt.builder.Build() + if got := kitErr.Error(); got != tt.want { + t.Errorf("got = %v, want %v", got, tt.want) + } + + err, ok := kitErr.(Error) + require.True(t, ok, err.Is(kitErr)) + }) + } +} + +func TestErrorBuilder_WithErrorInfo(t *testing.T) { + reason := "fake" + metadata := map[string]string{"fake": "test"} + details := &errdetails.ErrorInfo{ + Domain: Domain, + Reason: reason, + Metadata: metadata, + } + + expected := Error{ + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + details: []proto.Message{ + details, }, + } + + builder := NewBuilder( + grpcCodes.ResourceExhausted, + http.StatusTeapot, + "fake_message", + "DAPR_FAKE_TAG", + ). + WithErrorInfo(reason, metadata) + + assert.Equal(t, expected, builder.Build()) +} + +// helperSlicesEqual compares slices element by element +func helperSlicesEqual(a, b []proto.Message) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !reflect.DeepEqual(a[i], b[i]) { + return false + } + } + return true +} + +func TestErrorBuilder_WithDetails(t *testing.T) { + type fields struct { + details []proto.Message + grpcCode grpcCodes.Code + httpCode int + message string + tag string + } + + type args struct { + a []proto.Message + } + + tests := []struct { + name string + fields fields + args args + want Error + }{ { - name: "DaprError_New_Metadata_No_ErrorCodes_Key", - inErr: nil, - inMetadata: map[string]string{}, - expectedDaprError: false, + name: "Has_Multiple_Details", + fields: fields{ + details: []proto.Message{}, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + args: args{a: []proto.Message{ + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "example_reason", + Metadata: map[string]string{"key": "value"}, + }, + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + }}, + want: Error{ + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + details: []proto.Message{ + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "example_reason", + Metadata: map[string]string{"key": "value"}, + }, + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + }, + }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - de := New(test.inErr, test.inMetadata, test.inOptions...) - if test.expectedDaprError { - assert.NotNil(t, de, "expected DaprError but got none") - assert.Equal(t, test.expectedReason, de.reason, "want %s, but got = %v", test.expectedReason, de.reason) - } else { - assert.Nil(t, de, "unexpected DaprError but got %v", de) - } + kitErr := NewBuilder( + test.fields.grpcCode, + test.fields.httpCode, + test.fields.message, + test.fields.tag, + ).WithDetails(test.args.a...) + + assert.Equal(t, test.want, kitErr.Build()) }) } } -func TestNewError(t *testing.T) { - md := map[string]string{} - tests := []struct { - name string - de *Error - expectedDetailsCount int - expectedResourceInfo bool - expectedError error - expectedDescription string - }{ +func TestWithErrorHelp(t *testing.T) { + // Initialize the Error struct with some default values + err := NewBuilder(grpcCodes.InvalidArgument, http.StatusBadRequest, "Internal error", "INTERNAL_ERROR") + + // Define test data for the help links + links := []*errdetails.Help_Link{ { - name: "WithResourceInfo_OK", - de: New(fmt.Errorf("some error"), md, - WithResourceInfo(&ResourceInfo{Type: "testResourceType", Name: "testResourceName"})), - expectedDetailsCount: 2, - expectedResourceInfo: true, - expectedError: fmt.Errorf("some error"), - expectedDescription: "some error", + Description: "Link 1 Description", + Url: "http://example.com/1", }, { - name: "ResourceInfo_Empty", - de: New(fmt.Errorf("some error"), md, WithDescription("some"), WithErrorReason("StateETagInvalidReason", codes.Aborted)), - expectedDetailsCount: 1, - expectedResourceInfo: false, - expectedError: fmt.Errorf("some error"), - expectedDescription: "some", + Description: "Link 2 Description", + Url: "http://example.com/2", }, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - st := test.de.GRPCStatus() - assert.NotNil(t, st, "want nil, got = %v", st) - assert.NotNil(t, st.Details()) - assert.Len(t, st.Details(), test.expectedDetailsCount, "want 2, got = %d", len(st.Details())) - gotResourceInfo := false - for _, detail := range st.Details() { - switch detail.(type) { - case *errdetails.ResourceInfo: - gotResourceInfo = true - } - } - assert.Equal(t, test.expectedResourceInfo, gotResourceInfo, "expected ResourceInfo, but got none") - require.EqualError(t, test.expectedError, test.de.Error()) - assert.Equal(t, test.expectedDescription, test.de.Description()) - }) - } + + // Call WithHelp + err = err.WithHelp(links) + // Use require to make assertions + require.Len(t, err.err.details, 1, "Details should contain exactly one item") + + // Type assert to *errdetails.Help + helpDetail, ok := err.err.details[0].(*errdetails.Help) + require.True(t, ok, "Details[0] should be of type *errdetails.Help") + require.Equal(t, links, helpDetail.GetLinks(), "Links should match the provided links") +} + +func TestWithErrorFieldViolation(t *testing.T) { + // Initialize the Error struct with some default values + err := NewBuilder(grpcCodes.InvalidArgument, http.StatusBadRequest, "Internal error", "INTERNAL_ERROR") + + // Define test data for the field violation + fieldName := "testField" + msg := "test message" + + // Call WithFieldViolation + updatedErr := err.WithFieldViolation(fieldName, msg) + + // Check if the Details slice contains the expected BadRequest + require.Len(t, updatedErr.err.details, 1) + + // Type assert to *errdetails.BadRequest + br, ok := updatedErr.err.details[0].(*errdetails.BadRequest) + require.True(t, ok, "Expected BadRequest type, got %T", updatedErr.err.details[0]) + + // Check if the BadRequest contains the expected FieldViolation + require.Len(t, br.GetFieldViolations(), 1, "Expected 1 field violation, got %d", len(br.GetFieldViolations())) + require.Equal(t, fieldName, br.GetFieldViolations()[0].GetField(), "Expected field name %s, got %s", fieldName, br.GetFieldViolations()[0].GetField()) + require.Equal(t, msg, br.GetFieldViolations()[0].GetDescription(), "Expected description %s, got %s", msg, br.GetFieldViolations()[0].GetDescription()) } -func TestToHTTP(t *testing.T) { - md := map[string]string{} +func TestError_JSONErrorValue(t *testing.T) { + type fields struct { + details []proto.Message + grpcCode grpcCodes.Code + httpCode int + message string + tag string + } + tests := []struct { - name string - de *Error - expectedCode int - expectedReason string - expectedResourceType string - expectedResourceName string + name string + fields fields + want []byte }{ { - name: "WithResourceInfo_OK", - de: New(fmt.Errorf("some error"), md, - WithResourceInfo(&ResourceInfo{Type: "testResourceType", Name: "testResourceName"})), - expectedCode: http.StatusInternalServerError, - expectedReason: "UNKNOWN_REASON", - expectedResourceType: "testResourceType", - expectedResourceName: "testResourceName", + name: "No_Details", + fields: fields{ + details: []proto.Message{}, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message"}`), }, { - name: "WithResourceInfo_OK", - de: New(fmt.Errorf("some error"), md, - WithErrorReason("RedisFailure", codes.Internal), - WithResourceInfo(&ResourceInfo{Type: "testResourceType", Name: "testResourceName"})), - expectedCode: http.StatusInternalServerError, - expectedReason: "RedisFailure", - expectedResourceType: "testResourceType", - expectedResourceName: "testResourceName", + name: "With_Multiple_Details", + fields: fields{ + details: []proto.Message{ + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "test_reason", + Metadata: map[string]string{"key": "value"}, + }, + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"dapr.io","reason":"test_reason","metadata":{"key":"value"}},{"@type":"type.googleapis.com/google.rpc.PreconditionFailure.Violation","type":"TOS","subject":"google.com/cloud","description":"test_description"}]}`), + }, + { + name: "ErrorInfo", + fields: fields{ + details: []proto.Message{ + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "test_reason", + Metadata: map[string]string{"key": "value"}, + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"dapr.io","reason":"test_reason","metadata":{"key":"value"}}]}`), + }, + { + name: "RetryInfo", + fields: fields{ + details: []proto.Message{ + &errdetails.RetryInfo{ + RetryDelay: &durationpb.Duration{ + Seconds: 2, + Nanos: 0, + }, + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retry_delay":"2s"}]}`), + }, + { + name: "DebugInfo", + fields: fields{ + details: []proto.Message{ + &errdetails.DebugInfo{ + StackEntries: []string{ + "stack_entry_1", + "stack_entry_2", + }, + Detail: "debug_details", + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.DebugInfo","stack_entries":["stack_entry_1","stack_entry_2"],"detail":"debug_details"}]}`), + }, + { + name: "QuotaFailure", + fields: fields{ + details: []proto.Message{ + &errdetails.QuotaFailure{ + Violations: []*errdetails.QuotaFailure_Violation{ + { + Subject: "quota_subject_1", + Description: "quota_description_1", + }, + }, + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"subject":"quota_subject_1","description":"quota_description_1"}]}]}`), + }, + { + name: "PreconditionFailure", + fields: fields{ + details: []proto.Message{ + &errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{ + { + Type: "precondition_type_1", + Subject: "precondition_subject_1", + Description: "precondition_description_1", + }, + }, + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.PreconditionFailure","violations":[{"type":"precondition_type_1","subject":"precondition_subject_1","description":"precondition_description_1"}]}]}`), + }, + { + name: "BadRequest", + fields: fields{ + details: []proto.Message{ + &errdetails.BadRequest{ + FieldViolations: []*errdetails.BadRequest_FieldViolation{ + { + Field: "field_1", + Description: "field_description_1", + }, + }, + }, + }, + grpcCode: grpcCodes.InvalidArgument, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest","field_violations":[{"field":"field_1","description":"field_description_1"}]}]}`), + }, + { + name: "RequestInfo", + fields: fields{ + details: []proto.Message{ + &errdetails.RequestInfo{ + RequestId: "request_id_1", + ServingData: "serving_data_1", + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.RequestInfo","request_id":"request_id_1","serving_data":"serving_data_1"}]}`), + }, + { + name: "ResourceInfo", + fields: fields{ + details: []proto.Message{ + &errdetails.ResourceInfo{ + ResourceType: "resource_type_1", + ResourceName: "resource_name_1", + Owner: "owner_1", + Description: "description_1", + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.ResourceInfo","resource_type":"resource_type_1","resource_name":"resource_name_1","owner":"owner_1","description":"description_1"}]}`), + }, + { + name: "Help", + fields: fields{ + details: []proto.Message{ + &errdetails.Help{ + Links: []*errdetails.Help_Link{ + { + Description: "description_1", + Url: "dapr_url_1", + }, + }, + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.Help","links":[{"description":"description_1","url":"dapr_url_1"}]}]}`), + }, + { + name: "LocalizedMessage", + fields: fields{ + details: []proto.Message{ + &errdetails.LocalizedMessage{ + Locale: "en-US", + Message: "fake_localized_message", + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.LocalizedMessage","locale":"en-US","message":"fake_localized_message"}]}`), + }, + { + name: "QuotaFailure_Violation", + fields: fields{ + details: []proto.Message{ + &errdetails.QuotaFailure_Violation{ + Subject: "test_subject", + Description: "test_description", + }, + }, + grpcCode: grpcCodes.FailedPrecondition, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure.Violation","subject":"test_subject","description":"test_description"}]}`), + }, + { + name: "PreconditionFailure_Violation", + fields: fields{ + details: []proto.Message{ + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.PreconditionFailure.Violation","type":"TOS","subject":"google.com/cloud","description":"test_description"}]}`), + }, + { + name: "BadRequest_FieldViolation", + fields: fields{ + details: []proto.Message{ + &errdetails.BadRequest_FieldViolation{ + Field: "test_field", + Description: "test_description", + }, + }, + grpcCode: grpcCodes.InvalidArgument, + httpCode: http.StatusBadRequest, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest.FieldViolation","field":"test_field","description":"test_description"}]}`), + }, + { + name: "Help_Link", + fields: fields{ + details: []proto.Message{ + &errdetails.Help_Link{ + Description: "test_description", + Url: "https://docs.dapr.io/", + }, + }, + grpcCode: grpcCodes.InvalidArgument, + httpCode: http.StatusBadRequest, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"errorCode":"DAPR_FAKE_TAG","message":"fake_message","details":[{"@type":"type.googleapis.com/google.rpc.Help.Link","description":"test_description","url":"https://docs.dapr.io/"}]}`), + }, + { + name: "Unknown_Detail_Type", + fields: fields{ + details: []proto.Message{ + &context.AuditContext{ + TargetResource: "target_1", + }, + }, + grpcCode: grpcCodes.Internal, + httpCode: http.StatusInternalServerError, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: []byte(`{"details":[{"unknownDetailType":"*context.AuditContext","unknownDetails":"\u0026context.AuditContext{state:impl.MessageState{NoUnkeyedLiterals:pragma.NoUnkeyedLiterals{}, DoNotCompare:pragma.DoNotCompare{}, DoNotCopy:pragma.DoNotCopy{}, atomicMessageInfo:(*impl.MessageInfo)(0x14000156b00)}, sizeCache:10, unknownFields:[]uint8(nil), AuditLog:[]uint8(nil), ScrubbedRequest:(*structpb.Struct)(nil), ScrubbedResponse:(*structpb.Struct)(nil), ScrubbedResponseItemCount:0, TargetResource:\"target_1\"}"}],"errorCode":"DAPR_FAKE_TAG","message":"fake_message"}`), }, } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { - i, b := test.de.ToHTTP() - bodyStr := string(b) - assert.Equal(t, test.expectedCode, i, "want %d, got = %d", test.expectedCode, i) - assert.Contains(t, bodyStr, test.expectedReason) - assert.Contains(t, bodyStr, test.expectedResourceName) - assert.Contains(t, bodyStr, test.expectedResourceType) + kitErr := NewBuilder(test.fields.grpcCode, test.fields.httpCode, test.fields.message, test.fields.tag). + WithDetails(test.fields.details...) + + got := kitErr.err.JSONErrorValue() + + // Use map[string]interface{} to handle order diff in the slices + var gotMap, wantMap map[string]interface{} + _ = json.Unmarshal(got, &gotMap) + _ = json.Unmarshal(test.want, &wantMap) + + // Compare only the errorCode field + gotErrorCode, gotErrorCodeOK := gotMap["errorCode"].(string) + wantErrorCode, wantErrorCodeOK := wantMap["errorCode"].(string) + + if gotErrorCodeOK && wantErrorCodeOK && gotErrorCode != wantErrorCode { + t.Errorf("errorCode: \ngot = %s, \nwant %s", got, test.want) + } + + // Compare only the message field + gotMsg, gotMsgOK := gotMap["message"].(string) + wantMsg, wantMsgOK := wantMap["message"].(string) + + if gotMsgOK && wantMsgOK && gotMsg != wantMsg { + t.Errorf("message: \ngot = %s, \nwant %s", got, test.want) + } + + // Compare only the tag field + gotTag, gotTagOK := gotMap["tag"].(string) + wantTag, wantTagOK := wantMap["tag"].(string) + + if gotTagOK && wantTagOK && gotTag != wantTag { + t.Errorf("tag: \ngot = %s, \nwant %s", got, test.want) + } + + if !helperSlicesEqual(kitErr.err.details, test.fields.details) { + t.Errorf("Error.JSONErrorValue(): \ngot %s, \nwant %s", got, test.want) + } }) } } -func TestGRPCStatus(t *testing.T) { - md := map[string]string{} +func TestError_GRPCStatus(t *testing.T) { + type fields struct { + details []proto.Message + grpcCode grpcCodes.Code + httpCode int + message string + tag string + } + tests := []struct { - name string - de *Error - expectedCode int - expectedBytes int - expectedJSON string + name string + fields fields + want *status.Status }{ { - name: "WithResourceInfo_OK", - de: New(fmt.Errorf("some error"), md, - WithResourceInfo(&ResourceInfo{Type: "testResourceType", Name: "testResourceName"}), WithMetadata(md)), - expectedCode: 500, - expectedBytes: 308, - expectedJSON: `{"code":2, "details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo", "reason":"UNKNOWN_REASON", "domain":"dapr.io"}, {"@type":"type.googleapis.com/google.rpc.ResourceInfo", "resourceType":"testResourceType", "resourceName":"testResourceName", "owner":"components-contrib", "description":"some error"}]}`, + name: "No_Details", + fields: fields{ + details: []proto.Message{}, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: status.New(grpcCodes.ResourceExhausted, "fake_message"), + }, + { + name: "With_Details", + fields: fields{ + details: []proto.Message{ + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "FAKE_REASON", + Metadata: map[string]string{"key": "value"}, + }, + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + }, + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + }, + want: func() *status.Status { + s, _ := status.New(grpcCodes.ResourceExhausted, "fake_message"). + WithDetails( + &errdetails.ErrorInfo{ + Domain: Domain, + Reason: "FAKE_REASON", + Metadata: map[string]string{"key": "value"}, + }, + &errdetails.PreconditionFailure_Violation{ + Type: "TOS", + Subject: "google.com/cloud", + Description: "test_description", + }, + ) + return s + }(), }, } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { - test.de.GRPCStatus() - // assert.NotNil(t, st, i, "want %d, got = %d", test.expectedCode, i) - // assert.Equal(t, test.expectedBytes, len(b), "want %d bytes, got = %d", test.expectedBytes, len(b)) - // assert.Equal(t, test.expectedJSON, string(b), "want JSON %s , got = %s", test.expectedJSON, string(b)) + kitErr := NewBuilder( + test.fields.grpcCode, + test.fields.httpCode, + test.fields.message, + test.fields.tag, + ).WithDetails(test.fields.details...) + + got := kitErr.err.GRPCStatus() + + if !reflect.DeepEqual(got.Proto(), test.want.Proto()) { + t.Errorf("Error.GRPCStatus(): \ngot = %v, \nwant %v", got.Proto(), test.want.Proto()) + } + }) + } +} + +func TestErrorBuilder_Build(t *testing.T) { + t.Run("With_ErrorInfo", func(t *testing.T) { + built := NewBuilder( + grpcCodes.ResourceExhausted, + http.StatusTeapot, + "Test Msg", + "SOME_ERROR", + ).WithErrorInfo("fake", map[string]string{"fake": "test"}).Build() + + builtErr, ok := built.(Error) + require.True(t, ok) + + containsErrorInfo := false + + for _, detail := range builtErr.details { + _, isErrInfo := detail.(*errdetails.ErrorInfo) + if isErrInfo { + containsErrorInfo = true + break + } + } + + assert.True(t, containsErrorInfo) + }) + + t.Run("Without_ErrorInfo", func(t *testing.T) { + builder := NewBuilder( + grpcCodes.ResourceExhausted, + http.StatusTeapot, + "Test Msg", + "SOME_ERROR", + ) + + assert.PanicsWithValue(t, "Must include ErrorInfo in error details.", func() { + _ = builder.Build() }) + }) +} + +// This test ensures that all the error details google provides are covered in our switch case +// in errors.go. If google adds an error detail, this test should fail, and we should add +// that specific error detail to the switch case +func TestEnsureAllErrDetailsCovered(t *testing.T) { + packagePath := "google.golang.org/genproto/googleapis/rpc/errdetails" + + // Load the package + cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedTypesInfo} + pkgs, err := packages.Load(cfg, packagePath) + if err != nil { + t.Error(err) + } + + if packages.PrintErrors(pkgs) > 0 { + t.Errorf("ensure package is correct: %v", packages.ListError) + } + + //nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + // path where convertErrorDetails function lives + filePath := filepath.Join(filepath.Dir(filename), "errors.go") + mySwitchTypes, err := extractTypesFromSwitch(filePath, "convertErrorDetails") + if err != nil { + t.Errorf("err extracting type from switch: %v", err) + } + + coveredTypes := make(map[string]bool) + + // Iterate through the types in googles error detail package + for _, name := range pkgs[0].Types.Scope().Names() { + obj := pkgs[0].Types.Scope().Lookup(name) + if typ, ok := obj.Type().(*types.Named); ok { + typeFullName := typ.Obj().Name() + + // Check if the type is covered in errors.go switch cases + if containsType(mySwitchTypes, typ.Obj().Name()) { + coveredTypes[typeFullName] = true + } else { + coveredTypes[typeFullName] = false + } + } + } + + // Check if there are any uncovered types + for typeName, covered := range coveredTypes { + // Skip "FileDescriptor" && "Once" since those aren't types we care about + if !covered && typeName != "FileDescriptor" && typeName != "Once" { + t.Errorf("Type %s is not handled in switch cases, please update the switch case in errors.go", + typeName) + } + } +} + +// extractTypesFromSwitch extracts type names from the switch statement in the specified function, +// so we don't have to hard code the error details we support when comparing them +// to the ones google supports. +func extractTypesFromSwitch(filePath, funcName string) ([]string, error) { + fileSet := token.NewFileSet() + var err error + + parsedFile, err := parser.ParseFile(fileSet, filePath, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("error parsing file: %v", err) + } + + // Find the function + var foundFunc *ast.FuncDecl + for _, decl := range parsedFile.Decls { + if funcDecl, ok := decl.(*ast.FuncDecl); ok && funcDecl.Name.Name == funcName { + foundFunc = funcDecl + break + } + } + + if foundFunc == nil { + return nil, fmt.Errorf("function %s not found in file", funcName) + } + + var errTypes []string + + // Traverse the AST to find the switch statement inside the function + ast.Inspect(foundFunc.Body, func(n ast.Node) bool { + // Check if it's a block statement + if blockStmt, ok := n.(*ast.BlockStmt); ok { + // Iterate over the statements in the block + for _, stmt := range blockStmt.List { + // Check if it's a switch statement + if switchStmt, ok := stmt.(*ast.TypeSwitchStmt); ok { + // Iterate over the cases in the switch statement + for _, caseClause := range switchStmt.Body.List { + // Check if it's a case clause + if cc, ok := caseClause.(*ast.CaseClause); ok { + // Extract the type name from the case clause + for _, expr := range cc.List { + // Check if it's a type assertion + if typeAssert, ok := expr.(*ast.StarExpr); ok { + // Check if it's a selector expression + if selectorExpr, ok := typeAssert.X.(*ast.SelectorExpr); ok { + // Extract the type name from the selector expression + errTypes = append(errTypes, selectorExpr.Sel.Name) + } + } + } + } + } + } + } + } + return true + }) + + return errTypes, nil +} + +// containsType checks if the slice of types contains a specific type +func containsType(types []string, target string) bool { + for _, t := range types { + if t == target { + return true + } + } + return false +} + +func TestFromError(t *testing.T) { + result, ok := FromError(nil) + if result != nil || ok { + t.Errorf("Expected result to be nil and ok to be false, got result: %v, ok: %t", result, ok) + } + + kitErr := Error{ + grpcCode: grpcCodes.ResourceExhausted, + httpCode: http.StatusTeapot, + message: "fake_message", + tag: "DAPR_FAKE_TAG", + details: []proto.Message{}, + } + + result, ok = FromError(kitErr) + if !ok || !reflect.DeepEqual(result, &kitErr) { + t.Errorf("Expected result to be %#v and ok to be true, got result: %#v, ok: %t", &kitErr, result, ok) + } + + var nonKitError error + result, ok = FromError(nonKitError) + if result != nil || ok { + t.Errorf("Expected result to be nil and ok to be false, got result: %#v, ok: %t", result, ok) + } + + wrapped := fmt.Errorf("wrapped: %w", kitErr) + result, ok = FromError(wrapped) + if !ok || !reflect.DeepEqual(result, &kitErr) { + t.Errorf("Expected result to be %#v and ok to be true, got result: %#v, ok: %t", &kitErr, result, ok) } } diff --git a/go.mod b/go.mod index 5c98fbb..14df794 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde golang.org/x/crypto v0.14.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d + golang.org/x/tools v0.14.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d google.golang.org/grpc v1.57.0 google.golang.org/protobuf v1.31.0 @@ -33,6 +34,7 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/mod v0.13.0 // indirect golang.org/x/sys v0.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6c7fcc6..27db2a2 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -82,13 +84,14 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -120,6 +123,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=