feat(gin): add ProblemDetails middleware rendering Context errors as RFC 9457

ProblemDetails() renders errors attached via Context.Error as an RFC 9457
problem details JSON response when the handler chain finishes without
writing a response. The response status code is kept when it is an error
status, otherwise it defaults to 500 Internal Server Error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Quintero 2026-07-21 16:19:23 -04:00
parent 7b9d081d7c
commit e08d632300
3 changed files with 111 additions and 2 deletions

View File

@ -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

View File

@ -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()})
}
}

View File

@ -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())
}