Merge e08d63230090da616538ccd1fb49aba9f7d47233 into dcaa4296d111981ffb31ac3eba90bb63e1eb5ab9

This commit is contained in:
Miguel Quintero 2026-08-19 23:09:58 -07:00 committed by GitHub
commit b895c74968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 422 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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