diff --git a/docs/doc.md b/docs/doc.md index 53b88ef2..9f425228 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -1862,6 +1862,28 @@ func main() { } ``` +The `gin.ProblemDetails()` middleware renders errors attached with `c.Error()` as problem details responses, so handlers only need to report errors. The response status code is kept when it is an error status, otherwise it defaults to `500`. The last attached error's message is exposed to the client as the problem `detail` member, so do not attach errors whose messages must stay private. + +```go +func main() { + r := gin.Default() + r.Use(gin.ProblemDetails()) + + r.GET("/users/:id", func(c *gin.Context) { + user, err := lookup(c.Param("id")) + if err != nil { + c.Status(http.StatusNotFound) + c.Error(err) //nolint:errcheck + return + } + c.JSON(http.StatusOK, user) + }) + + // listen and serve on 0.0.0.0:8080 + r.Run(":8080") +} +``` + ### Serving static files ```go diff --git a/problem.go b/problem.go index e4aaa57f..2768dc9d 100644 --- a/problem.go +++ b/problem.go @@ -5,6 +5,8 @@ package gin import ( + "net/http" + "github.com/gin-gonic/gin/codec/json" ) @@ -58,3 +60,26 @@ func (p Problem) MarshalJSON() ([]byte, error) { } return json.API.Marshal(obj) } + +// ProblemDetails returns a middleware that renders errors attached to the +// Context (see Context.Error) as an RFC 9457 problem details JSON response. +// It does nothing if no errors were attached or if a response body or header +// was already written. The response status code is kept when it is an error +// status, otherwise it defaults to 500 Internal Server Error. +// WARNING: the last attached error's message is exposed to the client as the +// problem detail member. Do not attach errors whose messages must stay +// private, or write the response yourself instead. +func ProblemDetails() HandlerFunc { + return func(c *Context) { + c.Next() + + if len(c.Errors) == 0 || c.Writer.Written() { + return + } + status := c.Writer.Status() + if status < http.StatusBadRequest { + status = http.StatusInternalServerError + } + c.ProblemJSON(status, Problem{Detail: c.Errors.Last().Error()}) + } +} diff --git a/problem_test.go b/problem_test.go index 02ba13ee..0684255e 100644 --- a/problem_test.go +++ b/problem_test.go @@ -5,6 +5,8 @@ package gin import ( + "errors" + "net/http" "testing" "github.com/gin-gonic/gin/codec/json" @@ -42,8 +44,9 @@ func TestProblemMarshalJSONOmitsEmptyMembers(t *testing.T) { func TestProblemMarshalJSONExtensions(t *testing.T) { p := Problem{ - Status: 403, - Detail: "Your current balance is 30, but that costs 50.", + Status: 403, + Detail: "Your current balance is 30, but that costs 50.", + Instance: "/account/12345/msgs/abc", Extensions: map[string]any{ "balance": 30, "status": "extension members must not override standard members", @@ -56,6 +59,65 @@ func TestProblemMarshalJSONExtensions(t *testing.T) { assert.JSONEq(t, `{ "status": 403, "detail": "Your current balance is 30, but that costs 50.", + "instance": "/account/12345/msgs/abc", "balance": 30 }`, string(jsonBytes)) } + +func TestProblemDetailsMiddleware(t *testing.T) { + router := New() + router.Use(ProblemDetails()) + router.GET("/error", func(c *Context) { + c.Error(errors.New("boom")) //nolint:errcheck + }) + + w := PerformRequest(router, http.MethodGet, "/error") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type")) + assert.JSONEq(t, `{"title":"Internal Server Error","status":500,"detail":"boom"}`, w.Body.String()) +} + +func TestProblemDetailsMiddlewareKeepsErrorStatus(t *testing.T) { + router := New() + router.Use(ProblemDetails()) + router.GET("/conflict", func(c *Context) { + c.Status(http.StatusConflict) + c.Error(errors.New("already exists")) //nolint:errcheck + }) + + w := PerformRequest(router, http.MethodGet, "/conflict") + + assert.Equal(t, http.StatusConflict, w.Code) + assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type")) + assert.JSONEq(t, `{"title":"Conflict","status":409,"detail":"already exists"}`, w.Body.String()) +} + +func TestProblemDetailsMiddlewareNoErrors(t *testing.T) { + router := New() + router.Use(ProblemDetails()) + router.GET("/ok", func(c *Context) { + c.JSON(http.StatusOK, H{"foo": "bar"}) + }) + + w := PerformRequest(router, http.MethodGet, "/ok") + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.JSONEq(t, `{"foo":"bar"}`, w.Body.String()) +} + +func TestProblemDetailsMiddlewareResponseAlreadyWritten(t *testing.T) { + router := New() + router.Use(ProblemDetails()) + router.GET("/written", func(c *Context) { + c.JSON(http.StatusBadGateway, H{"error": "custom body"}) + c.Error(errors.New("boom")) //nolint:errcheck + }) + + w := PerformRequest(router, http.MethodGet, "/written") + + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.JSONEq(t, `{"error":"custom body"}`, w.Body.String()) +}