diff --git a/error.go b/error.go index f6f9860..b76f30e 100644 --- a/error.go +++ b/error.go @@ -894,6 +894,35 @@ func addHelpLink(e *TrogonError, description, url string) { }) } +type trogonError interface { + Is(error) bool +} + +// As checks if the error matches the target and returns the TrogonError if it does. +// This combines error matching and error extraction in a single, more idiomatic operation. +// The target can be either a TrogonError or an ErrorTemplate. +// Returns the TrogonError and true if the error matches, nil and false otherwise. +// +// Example usage: +// +// if trogonErr, ok := trogonerror.As(err, users.ErrUserNotFound); ok { +// return trogonErr.WithChanges( +// trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "user_id", req.UserID), +// ) +// } +func As(err error, target trogonError) (*TrogonError, bool) { + var trogonErr *TrogonError + if !errors.As(err, &trogonErr) { + return nil, false + } + + if !target.Is(trogonErr) { + return nil, false + } + + return trogonErr, true +} + func addMetadataValue(e *TrogonError, visibility Visibility, key, value string) { if len(e.metadata) == 0 { e.metadata = make(Metadata) diff --git a/error_test.go b/error_test.go index 569e220..66ac842 100644 --- a/error_test.go +++ b/error_test.go @@ -785,6 +785,138 @@ func (c CustomError) Error() string { return c.msg } +func TestAs(t *testing.T) { + t.Run("As returns TrogonError when error matches", func(t *testing.T) { + template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY", + trogonerror.TemplateWithCode(trogonerror.CodeFailedPrecondition)) + + originalErr := template.NewError( + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890")) + + trogonErr, ok := trogonerror.As(originalErr, template) + assert.True(t, ok) + assert.NotNil(t, trogonErr) + assert.Equal(t, "shopify.inventory", trogonErr.Domain()) + assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason()) + assert.Equal(t, "gid://shopify/Product/1234567890", trogonErr.Metadata()["productId"].Value()) + + }) + + t.Run("As returns false when error doesn't match", func(t *testing.T) { + template1 := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY") + template2 := trogonerror.NewErrorTemplate("shopify.users", "NOT_FOUND") + + err1 := template1.NewError() + + trogonErr, ok := trogonerror.As(err1, template2) + assert.False(t, ok) + assert.Nil(t, trogonErr) + }) + + t.Run("As returns false for non-TrogonError", func(t *testing.T) { + template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY") + regularErr := errors.New("regular error") + + trogonErr, ok := trogonerror.As(regularErr, template) + assert.False(t, ok) + assert.Nil(t, trogonErr) + }) + + t.Run("As works with WithChanges pattern", func(t *testing.T) { + template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY", + trogonerror.TemplateWithCode(trogonerror.CodeResourceExhausted)) + + originalErr := template.NewError( + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890")) + + trogonErr, ok := trogonerror.As(originalErr, template) + assert.True(t, ok) + assert.NotNil(t, trogonErr) + + modifiedErr := trogonErr.WithChanges( + trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "main_order_id", "order_123"), + trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "listing_count", "5"), + ) + + assert.Equal(t, "order_123", modifiedErr.Metadata()["main_order_id"].Value()) + assert.Equal(t, "5", modifiedErr.Metadata()["listing_count"].Value()) + assert.Equal(t, "gid://shopify/Product/1234567890", modifiedErr.Metadata()["productId"].Value()) // Original preserved + }) + + t.Run("As works with TrogonError directly", func(t *testing.T) { + originalErr := trogonerror.NewError("shopify.inventory", "INSUFFICIENT_INVENTORY", + trogonerror.WithCode(trogonerror.CodeFailedPrecondition), + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890")) + + // Test with TrogonError as target (not template) + trogonErr, ok := trogonerror.As(originalErr, originalErr) + assert.True(t, ok) + assert.NotNil(t, trogonErr) + assert.Equal(t, "shopify.inventory", trogonErr.Domain()) + assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason()) + }) + + t.Run("As works with wrapped TrogonError", func(t *testing.T) { + template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY") + + originalErr := template.NewError( + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890")) + + // Wrap the TrogonError with fmt.Errorf (this was the problematic scenario) + wrappedErr := fmt.Errorf("context: %w", originalErr) + + // Test the As function with the wrapped error + trogonErr, ok := trogonerror.As(wrappedErr, template) + assert.True(t, ok, "As should return true for wrapped TrogonError") + assert.NotNil(t, trogonErr, "As should return non-nil TrogonError") + + // Verify the extracted error has the correct properties + assert.Equal(t, "shopify.inventory", trogonErr.Domain()) + assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason()) + assert.Equal(t, "gid://shopify/Product/1234567890", trogonErr.Metadata()["productId"].Value()) + }) + +} + +func TestInternalMethods(t *testing.T) { + t.Run("TrogonError.is method delegates to Is", func(t *testing.T) { + err1 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED") + err2 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED") + err3 := trogonerror.NewError("shopify.session", "SESSION_INVALID") + + // Test the internal is method by using it in the As function + // This indirectly tests the is method since As calls target.is(err) + trogonErr, ok := trogonerror.As(err2, err1) + assert.True(t, ok) + assert.NotNil(t, trogonErr) + + trogonErr2, ok2 := trogonerror.As(err3, err1) + assert.False(t, ok2) + assert.Nil(t, trogonErr2) + }) + + t.Run("ErrorTemplate.is method delegates to Is", func(t *testing.T) { + template := trogonerror.NewErrorTemplate("shopify.session", "SESSION_EXPIRED") + err1 := template.NewError() + err2 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED") + err3 := trogonerror.NewError("shopify.session", "SESSION_INVALID") + + // Test the internal is method by using it in the As function + // This indirectly tests the is method since As calls target.is(err) + trogonErr, ok := trogonerror.As(err1, template) + assert.True(t, ok) + assert.NotNil(t, trogonErr) + + trogonErr2, ok2 := trogonerror.As(err2, template) + assert.True(t, ok2) + assert.NotNil(t, trogonErr2) + + trogonErr3, ok3 := trogonerror.As(err3, template) + assert.False(t, ok3) + assert.Nil(t, trogonErr3) + }) +} + func TestErrorTemplate(t *testing.T) { t.Run("NewErrorTemplate creates template with defaults", func(t *testing.T) { template := trogonerror.NewErrorTemplate("shopify.session", "SESSION_EXPIRED") diff --git a/example_test.go b/example_test.go index e698e3a..84be37f 100644 --- a/example_test.go +++ b/example_test.go @@ -376,3 +376,37 @@ func ExampleWithMetadataValuef_formattedValues() { // Amount: $299.99 // Request ID: req_1234567890_5432109876 } + +func ExampleAs_idiomaticErrorHandling() { + // Define error templates (typically done at package level) + var ErrInsufficientStock = trogonerror.NewErrorTemplate("inventory", "INSUFFICIENT_STOCK", + trogonerror.TemplateWithCode(trogonerror.CodeResourceExhausted)) + + // Simulate an error from inventory service + inventoryErr := ErrInsufficientStock.NewError( + trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "product_id", "prod_12345")) + + // Old verbose pattern: + // if inventory.ErrInsufficientStock.Is(err) { + // var trogonErr *trogonerror.TrogonError + // if errors.As(err, &trogonErr) { + // return nil, trogonErr.WithChanges(...) + // } + // } + + // New idiomatic pattern using As: + if trogonErr, ok := trogonerror.As(inventoryErr, ErrInsufficientStock); ok { + modifiedErr := trogonErr.WithChanges( + trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "order_id", "order_789"), + trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "requested_quantity", "10"), + ) + fmt.Printf("Modified error domain: %s\n", modifiedErr.Domain()) + fmt.Printf("Order ID: %s\n", modifiedErr.Metadata()["order_id"].Value()) + fmt.Printf("Requested quantity: %s\n", modifiedErr.Metadata()["requested_quantity"].Value()) + } + + // Output: + // Modified error domain: inventory + // Order ID: order_789 + // Requested quantity: 10 +}