diff --git a/echo.go b/echo.go index 98e7558cb..c8520498e 100644 --- a/echo.go +++ b/echo.go @@ -160,12 +160,15 @@ const ( MIMEApplicationForm = "application/x-www-form-urlencoded" MIMEApplicationProtobuf = "application/protobuf" MIMEApplicationMsgpack = "application/msgpack" - MIMETextHTML = "text/html" - MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8 - MIMETextPlain = "text/plain" - MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8 - MIMEMultipartForm = "multipart/form-data" - MIMEOctetStream = "application/octet-stream" + // MIMEApplicationProblemJSON is the content type for RFC 9457 Problem Details responses. + // https://www.rfc-editor.org/rfc/rfc9457 + MIMEApplicationProblemJSON = "application/problem+json" + MIMETextHTML = "text/html" + MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8 + MIMETextPlain = "text/plain" + MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8 + MIMEMultipartForm = "multipart/form-data" + MIMEOctetStream = "application/octet-stream" ) const ( diff --git a/rfc9457.go b/rfc9457.go new file mode 100644 index 000000000..90db0ac06 --- /dev/null +++ b/rfc9457.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "errors" + "fmt" + "net/http" +) + +// ProblemError represents a "problem detail" as defined in RFC 9457 (Problem Details for +// HTTP APIs). https://www.rfc-editor.org/rfc/rfc9457 +type ProblemError struct { + // Type is a URI reference that identifies the problem type. Defaults to "about:blank" + // when empty, which means the problem is the HTTP status code itself. + Type string `json:"type"` + // Title is a short, human-readable summary of the problem type. Defaults to the status + // text of Status when empty. + Title string `json:"title"` + // Status is the HTTP status code for this occurrence of the problem. + Status int `json:"status"` + // Detail is a human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail,omitempty"` + // Instance is a URI reference that identifies the specific occurrence of the problem. + Instance string `json:"instance,omitempty"` +} + +// Error makes ProblemError compatible with the `error` interface. +func (pe *ProblemError) Error() string { + msg := pe.Title + if pe.Detail != "" { + msg = fmt.Sprintf("%v: %v", pe.Title, pe.Detail) + } + return fmt.Sprintf("code=%d, message=%v", pe.Status, msg) +} + +// StatusCode returns status code for HTTP response, implementing HTTPStatusCoder interface. +func (pe *ProblemError) StatusCode() int { + return pe.Status +} + +// ProblemErrorer is the interface that custom error types can implement so they can be +// converted into a *ProblemError by ProblemDetailsHTTPErrorHandler. +type ProblemErrorer interface { + ProblemError() *ProblemError +} + +// ProblemDetailsHTTPErrorHandler creates a new HTTP error handler that converts every error +// into a RFC 9457 (Problem Details for HTTP APIs) response and sends it with +// `application/problem+json` content type. `exposeError` parameter decides if the returned +// problem detail will contain the underlying error message for errors that are not *HTTPError +// or do not implement ProblemErrorer. +// +// Precedence used to build the response for a given error: +// 1. If err (or any error wrapped by it) is a *ProblemError, it is used as-is. +// 2. Else if err (or any error wrapped by it) implements ProblemErrorer, ProblemError() is used. +// 3. Else a *ProblemError is built from err's HTTPStatusCoder status code (defaulting to 500). +// For *HTTPError, Detail is set to its Message, further extended with the wrapped error's +// message when exposeError is true. For any other error, Detail is only populated (with +// err.Error()) when exposeError is true. +// +// Any zero-value Type, Title or Status field is defaulted to "about:blank", the status text +// of Status, and 500 respectively. +// +// Note: ProblemDetailsHTTPErrorHandler does not log errors. Use middleware for it if errors +// need to be logged (separately). +func ProblemDetailsHTTPErrorHandler(exposeError bool) HTTPErrorHandler { + return func(c *Context, err error) { + if r, _ := UnwrapResponse(c.response); r != nil && r.Committed { + return + } + + var pe *ProblemError + var pder ProblemErrorer + switch { + case errors.As(err, &pe): + case errors.As(err, &pder): + if pe = pder.ProblemError(); pe == nil { + pe = &ProblemError{} + } + default: + pe = &ProblemError{} + + var sc HTTPStatusCoder + if errors.As(err, &sc) { + pe.Status = sc.StatusCode() + } + + var he *HTTPError + if errors.As(err, &he) { + pe.Detail = he.Message + if exposeError { + if wrapped := he.Unwrap(); wrapped != nil { + if pe.Detail == "" { + pe.Detail = wrapped.Error() + } else { + pe.Detail = fmt.Sprintf("%s: %s", pe.Detail, wrapped.Error()) + } + } + } + } else if exposeError { + pe.Detail = err.Error() + } + } + + if pe.Status == 0 { + pe.Status = http.StatusInternalServerError + } + if pe.Type == "" { + pe.Type = "about:blank" + } + if pe.Title == "" { + pe.Title = http.StatusText(pe.Status) + } + + c.Response().Header().Set(HeaderContentType, MIMEApplicationProblemJSON) + + var cErr error + if c.Request().Method == http.MethodHead { // Issue #608 + cErr = c.NoContent(pe.Status) + } else { + cErr = c.JSON(pe.Status, pe) + } + if cErr != nil { + c.Logger().Error("echo RFC 9457 error handler failed to send error to client", "error", cErr) // truly rare case. ala client already disconnected + } + } +} diff --git a/rfc9457_test.go b/rfc9457_test.go new file mode 100644 index 000000000..5902e11bf --- /dev/null +++ b/rfc9457_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +type customProblemErrorer struct { + pe *ProblemError +} + +func (ce *customProblemErrorer) Error() string { + return "custom problem errorer" +} + +func (ce *customProblemErrorer) ProblemError() *ProblemError { + return ce.pe +} + +func TestProblemDetailsHTTPErrorHandler(t *testing.T) { + var testCases = []struct { + whenError error + name string + whenMethod string + expectBody string + expectStatus int + givenExposeError bool + }{ + { + name: "ok, expose error = true, HTTPError, no wrapped err", + givenExposeError: true, + whenError: &HTTPError{Code: http.StatusTeapot, Message: "my_error"}, + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n", + }, + { + name: "ok, expose error = true, HTTPError + wrapped error", + givenExposeError: true, + whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(errors.New("internal_error")), + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error: internal_error"}` + "\n", + }, + { + name: "ok, expose error = true, HTTPError + wrapped HTTPError", + givenExposeError: true, + whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(&HTTPError{Code: http.StatusTeapot, Message: "early_error"}), + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error: code=418, message=early_error"}` + "\n", + }, + { + name: "ok, expose error = false, HTTPError + wrapped error", + whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(errors.New("internal_error")), + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n", + }, + { + name: "ok, expose error = false, HTTPError", + whenError: &HTTPError{Code: http.StatusTeapot, Message: "my_error"}, + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n", + }, + { + name: "ok, expose error = true, HTTPError, no message, wrapped error", + givenExposeError: true, + whenError: HTTPError{Code: http.StatusTeapot, Message: ""}.Wrap(errors.New("internal_error")), + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"internal_error"}` + "\n", + }, + { + name: "ok, expose error = false, HTTPError, no message", + whenError: &HTTPError{Code: http.StatusTeapot, Message: ""}, + expectStatus: http.StatusTeapot, + expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418}` + "\n", + }, + { + name: "ok, expose error = true, plain error", + givenExposeError: true, + whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")), + expectStatus: http.StatusInternalServerError, + expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"my errors wraps: internal_error"}` + "\n", + }, + { + name: "ok, expose error = false, plain error", + whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")), + expectStatus: http.StatusInternalServerError, + expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n", + }, + { + name: "ok, http.HEAD, expose error = true, plain error", + givenExposeError: true, + whenMethod: http.MethodHead, + whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")), + expectStatus: http.StatusInternalServerError, + expectBody: ``, + }, + { + name: "ok, error is *ProblemError, used as-is", + whenMethod: http.MethodGet, + whenError: &ProblemError{Type: "https://example.com/probs/out-of-credit", Title: "You do not have enough credit.", Status: http.StatusForbidden, Detail: "Your current balance is 30, but that costs 50.", Instance: "/account/12345/msgs/abc"}, + expectStatus: http.StatusForbidden, + expectBody: `{"type":"https://example.com/probs/out-of-credit","title":"You do not have enough credit.","status":403,"detail":"Your current balance is 30, but that costs 50.","instance":"/account/12345/msgs/abc"}` + "\n", + }, + { + name: "ok, error is *ProblemError with zero fields, defaults are filled", + whenMethod: http.MethodGet, + whenError: &ProblemError{}, + expectStatus: http.StatusInternalServerError, + expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n", + }, + { + name: "ok, custom error implements ProblemErrorer", + whenMethod: http.MethodGet, + whenError: &customProblemErrorer{pe: &ProblemError{Status: http.StatusConflict, Detail: "already exists"}}, + expectStatus: http.StatusConflict, + expectBody: `{"type":"about:blank","title":"Conflict","status":409,"detail":"already exists"}` + "\n", + }, + { + name: "ok, custom error implements ProblemErrorer, returns nil", + whenMethod: http.MethodGet, + whenError: &customProblemErrorer{pe: nil}, + expectStatus: http.StatusInternalServerError, + expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := New() + e.Logger = slog.New(slog.DiscardHandler) + e.Any("/path", func(c *Context) error { + return tc.whenError + }) + + e.HTTPErrorHandler = ProblemDetailsHTTPErrorHandler(tc.givenExposeError) + + method := http.MethodGet + if tc.whenMethod != "" { + method = tc.whenMethod + } + req := httptest.NewRequest(method, "/path", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, tc.expectStatus, rec.Code) + assert.Equal(t, tc.expectBody, rec.Body.String()) + assert.Equal(t, MIMEApplicationProblemJSON, rec.Header().Get(HeaderContentType)) + }) + } +} + +func TestProblemDetailsHTTPErrorHandler_CommittedResponse(t *testing.T) { + e := New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp := httptest.NewRecorder() + c := e.NewContext(req, resp) + + c.orgResponse.Committed = true + errHandler := ProblemDetailsHTTPErrorHandler(false) + + errHandler(c, errors.New("my_error")) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "", resp.Header().Get(HeaderContentType)) +} + +func TestProblemError_Error(t *testing.T) { + pe := &ProblemError{Status: http.StatusTeapot, Title: "I'm a teapot"} + assert.Equal(t, "code=418, message=I'm a teapot", pe.Error()) + + pe.Detail = "brewing" + assert.Equal(t, "code=418, message=I'm a teapot: brewing", pe.Error()) +} + +func TestProblemError_StatusCode(t *testing.T) { + pe := &ProblemError{Status: http.StatusTeapot} + assert.Equal(t, http.StatusTeapot, pe.StatusCode()) +}