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)
|
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.
|
// AbortWithError calls `AbortWithStatus()` and `Error()` internally.
|
||||||
// This method stops the chain, writes the status code and pushes the specified error to `c.Errors`.
|
// This method stops the chain, writes the status code and pushes the specified error to `c.Errors`.
|
||||||
// See Context.Error() for more details.
|
// 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})
|
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.
|
// XML serializes the given struct as XML into the response body.
|
||||||
// It also sets the Content-Type as "application/xml".
|
// It also sets the Content-Type as "application/xml".
|
||||||
func (c *Context) XML(code int, obj any) {
|
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"))
|
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
|
// Tests that the response executes the templates
|
||||||
// and responds with Content-Type set to text/html
|
// and responds with Content-Type set to text/html
|
||||||
func TestContextRenderHTML(t *testing.T) {
|
func TestContextRenderHTML(t *testing.T) {
|
||||||
@ -1973,6 +2021,21 @@ func TestContextAbortWithStatusJSON(t *testing.T) {
|
|||||||
assert.JSONEq(t, "{\"foo\":\"fooValue\",\"bar\":\"barValue\"}", jsonStringBody)
|
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) {
|
func TestContextAbortWithStatusPureJSON(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := CreateTestContext(w)
|
c, _ := CreateTestContext(w)
|
||||||
|
|||||||
65
docs/doc.md
65
docs/doc.md
@ -52,6 +52,7 @@
|
|||||||
- [JSONP](#jsonp)
|
- [JSONP](#jsonp)
|
||||||
- [AsciiJSON](#asciijson)
|
- [AsciiJSON](#asciijson)
|
||||||
- [PureJSON](#purejson)
|
- [PureJSON](#purejson)
|
||||||
|
- [ProblemJSON](#problemjson)
|
||||||
- [Serving static files](#serving-static-files)
|
- [Serving static files](#serving-static-files)
|
||||||
- [Serving data from file](#serving-data-from-file)
|
- [Serving data from file](#serving-data-from-file)
|
||||||
- [Serving data from reader](#serving-data-from-reader)
|
- [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
|
### Serving static files
|
||||||
|
|
||||||
```go
|
```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
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProblemJSON contains the given interface object, rendered as an
|
||||||
|
// RFC 9457 problem details response.
|
||||||
|
type ProblemJSON struct {
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
jsonContentType = []string{"application/json; charset=utf-8"}
|
jsonContentType = []string{"application/json; charset=utf-8"}
|
||||||
jsonpContentType = []string{"application/javascript; charset=utf-8"}
|
jsonpContentType = []string{"application/javascript; charset=utf-8"}
|
||||||
jsonASCIIContentType = []string{"application/json"}
|
jsonASCIIContentType = []string{"application/json"}
|
||||||
|
problemJSONContentType = []string{"application/problem+json; charset=utf-8"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Render (JSON) writes data with custom ContentType.
|
// 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) {
|
func (r PureJSON) WriteContentType(w http.ResponseWriter) {
|
||||||
writeContentType(w, jsonContentType)
|
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 = (*YAML)(nil)
|
||||||
_ Render = (*Reader)(nil)
|
_ Render = (*Reader)(nil)
|
||||||
_ Render = (*AsciiJSON)(nil)
|
_ Render = (*AsciiJSON)(nil)
|
||||||
|
_ Render = (*ProblemJSON)(nil)
|
||||||
_ Render = (*ProtoBuf)(nil)
|
_ Render = (*ProtoBuf)(nil)
|
||||||
_ Render = (*TOML)(nil)
|
_ Render = (*TOML)(nil)
|
||||||
_ Render = (*PDF)(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"))
|
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
|
type xmlmap map[string]any
|
||||||
|
|
||||||
// Allows type H to be used with xml.Marshal
|
// Allows type H to be used with xml.Marshal
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user