Skip to content

Commit 4a43641

Browse files
authored
feat: implement idiomatic error handling with As function (#14)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent ef53cf0 commit 4a43641

File tree

3 files changed

+195
-0
lines changed

3 files changed

+195
-0
lines changed

error.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,35 @@ func addHelpLink(e *TrogonError, description, url string) {
894894
})
895895
}
896896

897+
type trogonError interface {
898+
Is(error) bool
899+
}
900+
901+
// As checks if the error matches the target and returns the TrogonError if it does.
902+
// This combines error matching and error extraction in a single, more idiomatic operation.
903+
// The target can be either a TrogonError or an ErrorTemplate.
904+
// Returns the TrogonError and true if the error matches, nil and false otherwise.
905+
//
906+
// Example usage:
907+
//
908+
// if trogonErr, ok := trogonerror.As(err, users.ErrUserNotFound); ok {
909+
// return trogonErr.WithChanges(
910+
// trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "user_id", req.UserID),
911+
// )
912+
// }
913+
func As(err error, target trogonError) (*TrogonError, bool) {
914+
var trogonErr *TrogonError
915+
if !errors.As(err, &trogonErr) {
916+
return nil, false
917+
}
918+
919+
if !target.Is(trogonErr) {
920+
return nil, false
921+
}
922+
923+
return trogonErr, true
924+
}
925+
897926
func addMetadataValue(e *TrogonError, visibility Visibility, key, value string) {
898927
if len(e.metadata) == 0 {
899928
e.metadata = make(Metadata)

error_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,138 @@ func (c CustomError) Error() string {
785785
return c.msg
786786
}
787787

788+
func TestAs(t *testing.T) {
789+
t.Run("As returns TrogonError when error matches", func(t *testing.T) {
790+
template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY",
791+
trogonerror.TemplateWithCode(trogonerror.CodeFailedPrecondition))
792+
793+
originalErr := template.NewError(
794+
trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890"))
795+
796+
trogonErr, ok := trogonerror.As(originalErr, template)
797+
assert.True(t, ok)
798+
assert.NotNil(t, trogonErr)
799+
assert.Equal(t, "shopify.inventory", trogonErr.Domain())
800+
assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason())
801+
assert.Equal(t, "gid://shopify/Product/1234567890", trogonErr.Metadata()["productId"].Value())
802+
803+
})
804+
805+
t.Run("As returns false when error doesn't match", func(t *testing.T) {
806+
template1 := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY")
807+
template2 := trogonerror.NewErrorTemplate("shopify.users", "NOT_FOUND")
808+
809+
err1 := template1.NewError()
810+
811+
trogonErr, ok := trogonerror.As(err1, template2)
812+
assert.False(t, ok)
813+
assert.Nil(t, trogonErr)
814+
})
815+
816+
t.Run("As returns false for non-TrogonError", func(t *testing.T) {
817+
template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY")
818+
regularErr := errors.New("regular error")
819+
820+
trogonErr, ok := trogonerror.As(regularErr, template)
821+
assert.False(t, ok)
822+
assert.Nil(t, trogonErr)
823+
})
824+
825+
t.Run("As works with WithChanges pattern", func(t *testing.T) {
826+
template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY",
827+
trogonerror.TemplateWithCode(trogonerror.CodeResourceExhausted))
828+
829+
originalErr := template.NewError(
830+
trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890"))
831+
832+
trogonErr, ok := trogonerror.As(originalErr, template)
833+
assert.True(t, ok)
834+
assert.NotNil(t, trogonErr)
835+
836+
modifiedErr := trogonErr.WithChanges(
837+
trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "main_order_id", "order_123"),
838+
trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "listing_count", "5"),
839+
)
840+
841+
assert.Equal(t, "order_123", modifiedErr.Metadata()["main_order_id"].Value())
842+
assert.Equal(t, "5", modifiedErr.Metadata()["listing_count"].Value())
843+
assert.Equal(t, "gid://shopify/Product/1234567890", modifiedErr.Metadata()["productId"].Value()) // Original preserved
844+
})
845+
846+
t.Run("As works with TrogonError directly", func(t *testing.T) {
847+
originalErr := trogonerror.NewError("shopify.inventory", "INSUFFICIENT_INVENTORY",
848+
trogonerror.WithCode(trogonerror.CodeFailedPrecondition),
849+
trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890"))
850+
851+
// Test with TrogonError as target (not template)
852+
trogonErr, ok := trogonerror.As(originalErr, originalErr)
853+
assert.True(t, ok)
854+
assert.NotNil(t, trogonErr)
855+
assert.Equal(t, "shopify.inventory", trogonErr.Domain())
856+
assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason())
857+
})
858+
859+
t.Run("As works with wrapped TrogonError", func(t *testing.T) {
860+
template := trogonerror.NewErrorTemplate("shopify.inventory", "INSUFFICIENT_INVENTORY")
861+
862+
originalErr := template.NewError(
863+
trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "productId", "gid://shopify/Product/1234567890"))
864+
865+
// Wrap the TrogonError with fmt.Errorf (this was the problematic scenario)
866+
wrappedErr := fmt.Errorf("context: %w", originalErr)
867+
868+
// Test the As function with the wrapped error
869+
trogonErr, ok := trogonerror.As(wrappedErr, template)
870+
assert.True(t, ok, "As should return true for wrapped TrogonError")
871+
assert.NotNil(t, trogonErr, "As should return non-nil TrogonError")
872+
873+
// Verify the extracted error has the correct properties
874+
assert.Equal(t, "shopify.inventory", trogonErr.Domain())
875+
assert.Equal(t, "INSUFFICIENT_INVENTORY", trogonErr.Reason())
876+
assert.Equal(t, "gid://shopify/Product/1234567890", trogonErr.Metadata()["productId"].Value())
877+
})
878+
879+
}
880+
881+
func TestInternalMethods(t *testing.T) {
882+
t.Run("TrogonError.is method delegates to Is", func(t *testing.T) {
883+
err1 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED")
884+
err2 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED")
885+
err3 := trogonerror.NewError("shopify.session", "SESSION_INVALID")
886+
887+
// Test the internal is method by using it in the As function
888+
// This indirectly tests the is method since As calls target.is(err)
889+
trogonErr, ok := trogonerror.As(err2, err1)
890+
assert.True(t, ok)
891+
assert.NotNil(t, trogonErr)
892+
893+
trogonErr2, ok2 := trogonerror.As(err3, err1)
894+
assert.False(t, ok2)
895+
assert.Nil(t, trogonErr2)
896+
})
897+
898+
t.Run("ErrorTemplate.is method delegates to Is", func(t *testing.T) {
899+
template := trogonerror.NewErrorTemplate("shopify.session", "SESSION_EXPIRED")
900+
err1 := template.NewError()
901+
err2 := trogonerror.NewError("shopify.session", "SESSION_EXPIRED")
902+
err3 := trogonerror.NewError("shopify.session", "SESSION_INVALID")
903+
904+
// Test the internal is method by using it in the As function
905+
// This indirectly tests the is method since As calls target.is(err)
906+
trogonErr, ok := trogonerror.As(err1, template)
907+
assert.True(t, ok)
908+
assert.NotNil(t, trogonErr)
909+
910+
trogonErr2, ok2 := trogonerror.As(err2, template)
911+
assert.True(t, ok2)
912+
assert.NotNil(t, trogonErr2)
913+
914+
trogonErr3, ok3 := trogonerror.As(err3, template)
915+
assert.False(t, ok3)
916+
assert.Nil(t, trogonErr3)
917+
})
918+
}
919+
788920
func TestErrorTemplate(t *testing.T) {
789921
t.Run("NewErrorTemplate creates template with defaults", func(t *testing.T) {
790922
template := trogonerror.NewErrorTemplate("shopify.session", "SESSION_EXPIRED")

example_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,37 @@ func ExampleWithMetadataValuef_formattedValues() {
376376
// Amount: $299.99
377377
// Request ID: req_1234567890_5432109876
378378
}
379+
380+
func ExampleAs_idiomaticErrorHandling() {
381+
// Define error templates (typically done at package level)
382+
var ErrInsufficientStock = trogonerror.NewErrorTemplate("inventory", "INSUFFICIENT_STOCK",
383+
trogonerror.TemplateWithCode(trogonerror.CodeResourceExhausted))
384+
385+
// Simulate an error from inventory service
386+
inventoryErr := ErrInsufficientStock.NewError(
387+
trogonerror.WithMetadataValue(trogonerror.VisibilityPublic, "product_id", "prod_12345"))
388+
389+
// Old verbose pattern:
390+
// if inventory.ErrInsufficientStock.Is(err) {
391+
// var trogonErr *trogonerror.TrogonError
392+
// if errors.As(err, &trogonErr) {
393+
// return nil, trogonErr.WithChanges(...)
394+
// }
395+
// }
396+
397+
// New idiomatic pattern using As:
398+
if trogonErr, ok := trogonerror.As(inventoryErr, ErrInsufficientStock); ok {
399+
modifiedErr := trogonErr.WithChanges(
400+
trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "order_id", "order_789"),
401+
trogonerror.WithChangeMetadataValue(trogonerror.VisibilityPublic, "requested_quantity", "10"),
402+
)
403+
fmt.Printf("Modified error domain: %s\n", modifiedErr.Domain())
404+
fmt.Printf("Order ID: %s\n", modifiedErr.Metadata()["order_id"].Value())
405+
fmt.Printf("Requested quantity: %s\n", modifiedErr.Metadata()["requested_quantity"].Value())
406+
}
407+
408+
// Output:
409+
// Modified error domain: inventory
410+
// Order ID: order_789
411+
// Requested quantity: 10
412+
}

0 commit comments

Comments
 (0)