mirror of
https://github.com/gin-gonic/gin.git
synced 2026-09-04 14:49:27 +08:00
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:
parent
7b9d081d7c
commit
e08d632300
22
docs/doc.md
22
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
|
||||
|
||||
25
problem.go
25
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()})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user