mirror of
https://github.com/gin-gonic/gin.git
synced 2026-09-04 14:49:27 +08:00
Merge e08d63230090da616538ccd1fb49aba9f7d47233 into dcaa4296d111981ffb31ac3eba90bb63e1eb5ab9
This commit is contained in:
commit
b895c74968
36
context.go
36
context.go
@ -242,6 +242,15 @@ func (c *Context) AbortWithStatusJSON(code int, jsonObj any) {
|
||||
c.JSON(code, jsonObj)
|
||||
}
|
||||
|
||||
// AbortWithProblemJSON calls `Abort()` and then `ProblemJSON` internally.
|
||||
// This method stops the chain, writes the status code and returns an RFC 9457
|
||||
// problem details JSON body.
|
||||
// It also sets the Content-Type as "application/problem+json".
|
||||
func (c *Context) AbortWithProblemJSON(code int, obj any) {
|
||||
c.Abort()
|
||||
c.ProblemJSON(code, obj)
|
||||
}
|
||||
|
||||
// AbortWithError calls `AbortWithStatus()` and `Error()` internally.
|
||||
// This method stops the chain, writes the status code and pushes the specified error to `c.Errors`.
|
||||
// See Context.Error() for more details.
|
||||
@ -1268,6 +1277,33 @@ func (c *Context) PureJSON(code int, obj any) {
|
||||
c.Render(code, render.PureJSON{Data: obj})
|
||||
}
|
||||
|
||||
// ProblemJSON serializes the given struct as an RFC 9457 problem details
|
||||
// JSON object into the response body.
|
||||
// It also sets the Content-Type as "application/problem+json".
|
||||
// If obj is a Problem or *Problem, a zero Status is defaulted to code and
|
||||
// an empty Title is defaulted to http.StatusText(code).
|
||||
func (c *Context) ProblemJSON(code int, obj any) {
|
||||
switch p := obj.(type) {
|
||||
case Problem:
|
||||
obj = problemWithDefaults(p, code)
|
||||
case *Problem:
|
||||
if p != nil {
|
||||
obj = problemWithDefaults(*p, code)
|
||||
}
|
||||
}
|
||||
c.Render(code, render.ProblemJSON{Data: obj})
|
||||
}
|
||||
|
||||
func problemWithDefaults(p Problem, code int) Problem {
|
||||
if p.Status == 0 {
|
||||
p.Status = code
|
||||
}
|
||||
if p.Title == "" {
|
||||
p.Title = http.StatusText(p.Status)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// XML serializes the given struct as XML into the response body.
|
||||
// It also sets the Content-Type as "application/xml".
|
||||
func (c *Context) XML(code int, obj any) {
|
||||
|
||||
@ -1329,6 +1329,54 @@ func TestContextRenderPureJSON(t *testing.T) {
|
||||
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
// Tests that the response is serialized as an RFC 9457 problem details object
|
||||
// and Content-Type is set to application/problem+json
|
||||
// and empty Status and Title members are defaulted from the response code
|
||||
func TestContextRenderProblemJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := CreateTestContext(w)
|
||||
|
||||
c.ProblemJSON(http.StatusForbidden, Problem{
|
||||
Type: "https://example.com/probs/out-of-credit",
|
||||
Detail: "Your current balance is 30, but that costs 50.",
|
||||
Extensions: map[string]any{"balance": 30},
|
||||
})
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.JSONEq(t, `{
|
||||
"type": "https://example.com/probs/out-of-credit",
|
||||
"title": "Forbidden",
|
||||
"status": 403,
|
||||
"detail": "Your current balance is 30, but that costs 50.",
|
||||
"balance": 30
|
||||
}`, w.Body.String())
|
||||
assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestContextRenderProblemJSONPointerDefaults(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := CreateTestContext(w)
|
||||
|
||||
p := &Problem{Detail: "no such user"}
|
||||
c.ProblemJSON(http.StatusNotFound, p)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.JSONEq(t, `{"title":"Not Found","status":404,"detail":"no such user"}`, w.Body.String())
|
||||
// the caller's Problem must not be mutated
|
||||
assert.Equal(t, &Problem{Detail: "no such user"}, p)
|
||||
}
|
||||
|
||||
func TestContextRenderProblemJSONCustomObject(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := CreateTestContext(w)
|
||||
|
||||
c.ProblemJSON(http.StatusBadRequest, H{"title": "Bad Request", "status": 400})
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.JSONEq(t, `{"title":"Bad Request","status":400}`, w.Body.String())
|
||||
assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
// Tests that the response executes the templates
|
||||
// and responds with Content-Type set to text/html
|
||||
func TestContextRenderHTML(t *testing.T) {
|
||||
@ -1973,6 +2021,21 @@ func TestContextAbortWithStatusJSON(t *testing.T) {
|
||||
assert.JSONEq(t, "{\"foo\":\"fooValue\",\"bar\":\"barValue\"}", jsonStringBody)
|
||||
}
|
||||
|
||||
func TestContextAbortWithProblemJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := CreateTestContext(w)
|
||||
c.index = 4
|
||||
|
||||
c.AbortWithProblemJSON(http.StatusNotFound, Problem{Detail: "no such user"})
|
||||
|
||||
assert.Equal(t, abortIndex, c.index)
|
||||
assert.Equal(t, http.StatusNotFound, c.Writer.Status())
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.True(t, c.IsAborted())
|
||||
assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
assert.JSONEq(t, `{"title":"Not Found","status":404,"detail":"no such user"}`, w.Body.String())
|
||||
}
|
||||
|
||||
func TestContextAbortWithStatusPureJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := CreateTestContext(w)
|
||||
|
||||
65
docs/doc.md
65
docs/doc.md
@ -52,6 +52,7 @@
|
||||
- [JSONP](#jsonp)
|
||||
- [AsciiJSON](#asciijson)
|
||||
- [PureJSON](#purejson)
|
||||
- [ProblemJSON](#problemjson)
|
||||
- [Serving static files](#serving-static-files)
|
||||
- [Serving data from file](#serving-data-from-file)
|
||||
- [Serving data from reader](#serving-data-from-reader)
|
||||
@ -1819,6 +1820,70 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
#### ProblemJSON
|
||||
|
||||
ProblemJSON serializes error responses as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details objects and sets the Content-Type to `application/problem+json`.
|
||||
|
||||
When the object is a `gin.Problem`, a zero `Status` defaults to the response code and an empty `Title` defaults to the standard status text. Extension members are serialized as top-level members of the problem details object.
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/orders/:id", func(c *gin.Context) {
|
||||
c.ProblemJSON(http.StatusForbidden, gin.Problem{
|
||||
Type: "https://example.com/probs/out-of-credit",
|
||||
Detail: "Your current balance is 30, but that costs 50.",
|
||||
Instance: "/orders/" + c.Param("id"),
|
||||
Extensions: map[string]any{"balance": 30},
|
||||
})
|
||||
// Response body:
|
||||
// {
|
||||
// "type": "https://example.com/probs/out-of-credit",
|
||||
// "title": "Forbidden",
|
||||
// "status": 403,
|
||||
// "detail": "Your current balance is 30, but that costs 50.",
|
||||
// "instance": "/orders/1234",
|
||||
// "balance": 30
|
||||
// }
|
||||
})
|
||||
|
||||
// In middleware, AbortWithProblemJSON stops the chain and writes the problem details response.
|
||||
r.Use(func(c *gin.Context) {
|
||||
if c.GetHeader("Authorization") == "" {
|
||||
c.AbortWithProblemJSON(http.StatusUnauthorized, gin.Problem{Detail: "missing credentials"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// listen and serve on 0.0.0.0:8080
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
85
problem.go
Normal file
85
problem.go
Normal file
@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Gin Core Team. All rights reserved.
|
||||
// Use of this source code is governed by a MIT style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin/codec/json"
|
||||
)
|
||||
|
||||
// Problem represents an RFC 9457 problem details object.
|
||||
// See https://www.rfc-editor.org/rfc/rfc9457 for the meaning of each member.
|
||||
type Problem struct {
|
||||
// Type is a URI reference that identifies the problem type.
|
||||
// When empty it is omitted from the output, which consumers
|
||||
// must interpret as "about:blank" (RFC 9457 section 3.1.1).
|
||||
Type string `json:"type,omitempty"`
|
||||
// Title is a short, human-readable summary of the problem type.
|
||||
Title string `json:"title,omitempty"`
|
||||
// Status is the HTTP status code generated by the origin server.
|
||||
Status int `json:"status,omitempty"`
|
||||
// Detail is a human-readable explanation specific to this occurrence.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Instance is a URI reference that identifies the specific occurrence.
|
||||
Instance string `json:"instance,omitempty"`
|
||||
// Extensions holds problem type specific extension members
|
||||
// (RFC 9457 section 3.2). They are serialized as top-level members
|
||||
// of the problem details object. Extension keys that collide with
|
||||
// a standard member name are ignored.
|
||||
Extensions map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface, serializing
|
||||
// Extensions as top-level members of the problem details object.
|
||||
func (p Problem) MarshalJSON() ([]byte, error) {
|
||||
type problem Problem // avoid infinite recursion
|
||||
if len(p.Extensions) == 0 {
|
||||
return json.API.Marshal(problem(p))
|
||||
}
|
||||
obj := make(map[string]any, len(p.Extensions)+5)
|
||||
for k, v := range p.Extensions {
|
||||
obj[k] = v
|
||||
}
|
||||
if p.Type != "" {
|
||||
obj["type"] = p.Type
|
||||
}
|
||||
if p.Title != "" {
|
||||
obj["title"] = p.Title
|
||||
}
|
||||
if p.Status != 0 {
|
||||
obj["status"] = p.Status
|
||||
}
|
||||
if p.Detail != "" {
|
||||
obj["detail"] = p.Detail
|
||||
}
|
||||
if p.Instance != "" {
|
||||
obj["instance"] = p.Instance
|
||||
}
|
||||
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()})
|
||||
}
|
||||
}
|
||||
123
problem_test.go
Normal file
123
problem_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Gin Core Team. All rights reserved.
|
||||
// Use of this source code is governed by a MIT style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin/codec/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProblemMarshalJSON(t *testing.T) {
|
||||
p := Problem{
|
||||
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",
|
||||
}
|
||||
|
||||
jsonBytes, err := json.API.Marshal(p)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{
|
||||
"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"
|
||||
}`, string(jsonBytes))
|
||||
}
|
||||
|
||||
func TestProblemMarshalJSONOmitsEmptyMembers(t *testing.T) {
|
||||
jsonBytes, err := json.API.Marshal(Problem{Status: 404})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"status":404}`, string(jsonBytes))
|
||||
}
|
||||
|
||||
func TestProblemMarshalJSONExtensions(t *testing.T) {
|
||||
p := Problem{
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.API.Marshal(p)
|
||||
|
||||
require.NoError(t, err)
|
||||
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())
|
||||
}
|
||||
@ -47,10 +47,17 @@ type PureJSON struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
// ProblemJSON contains the given interface object, rendered as an
|
||||
// RFC 9457 problem details response.
|
||||
type ProblemJSON struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
var (
|
||||
jsonContentType = []string{"application/json; charset=utf-8"}
|
||||
jsonpContentType = []string{"application/javascript; charset=utf-8"}
|
||||
jsonASCIIContentType = []string{"application/json"}
|
||||
jsonContentType = []string{"application/json; charset=utf-8"}
|
||||
jsonpContentType = []string{"application/javascript; charset=utf-8"}
|
||||
jsonASCIIContentType = []string{"application/json"}
|
||||
problemJSONContentType = []string{"application/problem+json; charset=utf-8"}
|
||||
)
|
||||
|
||||
// Render (JSON) writes data with custom ContentType.
|
||||
@ -192,3 +199,19 @@ func (r PureJSON) Render(w http.ResponseWriter) error {
|
||||
func (r PureJSON) WriteContentType(w http.ResponseWriter) {
|
||||
writeContentType(w, jsonContentType)
|
||||
}
|
||||
|
||||
// Render (ProblemJSON) marshals the given interface object and writes it with custom ContentType.
|
||||
func (r ProblemJSON) Render(w http.ResponseWriter) error {
|
||||
r.WriteContentType(w)
|
||||
jsonBytes, err := json.API.Marshal(r.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(jsonBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteContentType (ProblemJSON) writes problem details JSON ContentType.
|
||||
func (r ProblemJSON) WriteContentType(w http.ResponseWriter) {
|
||||
writeContentType(w, problemJSONContentType)
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ var (
|
||||
_ Render = (*YAML)(nil)
|
||||
_ Render = (*Reader)(nil)
|
||||
_ Render = (*AsciiJSON)(nil)
|
||||
_ Render = (*ProblemJSON)(nil)
|
||||
_ Render = (*ProtoBuf)(nil)
|
||||
_ Render = (*TOML)(nil)
|
||||
_ Render = (*PDF)(nil)
|
||||
|
||||
@ -281,6 +281,29 @@ func TestRenderPureJSON(t *testing.T) {
|
||||
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestRenderProblemJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
data := map[string]any{
|
||||
"type": "https://example.com/probs/out-of-credit",
|
||||
"title": "You do not have enough credit.",
|
||||
"status": 403,
|
||||
}
|
||||
|
||||
err := (ProblemJSON{data}).Render(w)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, "{\"type\":\"https://example.com/probs/out-of-credit\",\"title\":\"You do not have enough credit.\",\"status\":403}", w.Body.String())
|
||||
assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestRenderProblemJSONFail(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
data := make(chan int)
|
||||
|
||||
// json: unsupported type: chan int
|
||||
require.Error(t, (ProblemJSON{data}).Render(w))
|
||||
}
|
||||
|
||||
type xmlmap map[string]any
|
||||
|
||||
// Allows type H to be used with xml.Marshal
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user