diff --git a/context.go b/context.go index 1dc730e3..8346e5e2 100644 --- a/context.go +++ b/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) { diff --git a/context_test.go b/context_test.go index e8d305e4..89f326cd 100644 --- a/context_test.go +++ b/context_test.go @@ -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) diff --git a/docs/doc.md b/docs/doc.md index d1c33b87..9f425228 100644 --- a/docs/doc.md +++ b/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 diff --git a/problem.go b/problem.go new file mode 100644 index 00000000..2768dc9d --- /dev/null +++ b/problem.go @@ -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()}) + } +} diff --git a/problem_test.go b/problem_test.go new file mode 100644 index 00000000..0684255e --- /dev/null +++ b/problem_test.go @@ -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()) +} diff --git a/render/json.go b/render/json.go index 2f98676c..a357e5f2 100644 --- a/render/json.go +++ b/render/json.go @@ -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) +} diff --git a/render/render.go b/render/render.go index 28bc0f5d..4da7e4b6 100644 --- a/render/render.go +++ b/render/render.go @@ -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) diff --git a/render/render_test.go b/render/render_test.go index f63878b9..a9b01789 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -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