Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
129 changes: 129 additions & 0 deletions rfc9457.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
185 changes: 185 additions & 0 deletions rfc9457_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading