diff --git a/context.go b/context.go index 8014d4bc..760d1d76 100644 --- a/context.go +++ b/context.go @@ -244,16 +244,37 @@ func (c *Context) AbortWithError(code int, err error) *Error { /********* ERROR MANAGEMENT *********/ /************************************/ +// joinedError is an interface for errors that wrap multiple inner errors, +// such as those created by errors.Join. +type joinedError interface { + Unwrap() []error +} + // Error attaches an error to the current context. The error is pushed to a list of errors. // It's a good idea to call Error for each error that occurred during the resolution of a request. // A middleware can be used to collect all the errors and push them to a database together, // print a log, or append it in the HTTP response. // Error will panic if err is nil. +// If err is a joined error (created by errors.Join), it is unwrapped and each +// individual error is added as a separate entry. The last *Error added is returned. func (c *Context) Error(err error) *Error { if err == nil { panic("err is nil") } + // Unwrap joined errors so each one becomes a separate entry. + if joined, ok := err.(joinedError); ok { + errs := joined.Unwrap() + if len(errs) > 0 { + var last *Error + for _, e := range errs { + last = c.Error(e) + } + return last + } + // Fall through for empty joined errors — store as-is. + } + var parsedError *Error ok := errors.As(err, &parsedError) if !ok { diff --git a/context_test.go b/context_test.go index 2a3ce70f..226bb65f 100644 --- a/context_test.go +++ b/context_test.go @@ -1982,6 +1982,63 @@ func TestContextTypedError(t *testing.T) { assert.Equal(t, []string{"externo 0", "interno 0"}, c.Errors.Errors()) } +func TestContextErrorJoined(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + // Single errors around the joined one. + c.Error(errors.New("first")) //nolint: errcheck + + joined := errors.Join(errors.New("service error"), errors.New("store error")) + last := c.Error(joined) //nolint: errcheck + + c.Error(errors.New("last")) //nolint: errcheck + + // Joined error must be unwrapped into separate entries. + assert.Len(t, c.Errors, 4) + assert.Equal(t, "first", c.Errors[0].Error()) + assert.Equal(t, "service error", c.Errors[1].Error()) + assert.Equal(t, "store error", c.Errors[2].Error()) + assert.Equal(t, "last", c.Errors[3].Error()) + + // Return value is the last unwrapped entry. + assert.Equal(t, "store error", last.Error()) + + // All unwrapped entries default to ErrorTypePrivate. + for _, e := range c.Errors { + assert.Equal(t, ErrorTypePrivate, e.Type) + } + + // String output should list each error individually. + expected := "Error #01: first\nError #02: service error\nError #03: store error\nError #04: last\n" + assert.Equal(t, expected, c.Errors.String()) +} + +func TestContextErrorNestedJoined(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + inner := errors.Join(errors.New("a"), errors.New("b")) + outer := errors.Join(inner, errors.New("c")) + c.Error(outer) //nolint: errcheck + + assert.Len(t, c.Errors, 3) + assert.Equal(t, []string{"a", "b", "c"}, c.Errors.Errors()) +} + +func TestContextErrorJoinedWithTypedError(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + typedErr := &Error{Err: errors.New("typed"), Type: ErrorTypePublic, Meta: "meta"} + joined := errors.Join(errors.New("plain"), typedErr) + c.Error(joined) //nolint: errcheck + + assert.Len(t, c.Errors, 2) + assert.Equal(t, "plain", c.Errors[0].Error()) + assert.Equal(t, ErrorTypePrivate, c.Errors[0].Type) + assert.Equal(t, "typed", c.Errors[1].Error()) + assert.Equal(t, ErrorTypePublic, c.Errors[1].Type) + assert.Equal(t, "meta", c.Errors[1].Meta) +} + func TestContextAbortWithError(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w)