From 7b9d081d7ca588efceea11499a1bed21caaf3f9f Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Tue, 21 Jul 2026 16:10:11 -0400 Subject: [PATCH 1/2] feat(render): add RFC 9457 problem details JSON response support Add first-class support for RFC 9457 "Problem Details for HTTP APIs" error responses in JSON format: - gin.Problem type with the standard members (type, title, status, detail, instance) and extension members serialized at the top level - render.ProblemJSON renderer setting Content-Type to "application/problem+json; charset=utf-8" - Context.ProblemJSON and Context.AbortWithProblemJSON helpers; when given a gin.Problem, a zero Status defaults to the response code and an empty Title defaults to http.StatusText Co-Authored-By: Claude Fable 5 --- context.go | 36 +++++++++++++++++++++++++ context_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++ docs/doc.md | 43 +++++++++++++++++++++++++++++ problem.go | 60 +++++++++++++++++++++++++++++++++++++++++ problem_test.go | 61 +++++++++++++++++++++++++++++++++++++++++ render/json.go | 29 +++++++++++++++++--- render/render.go | 1 + render/render_test.go | 23 ++++++++++++++++ 8 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 problem.go create mode 100644 problem_test.go 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..53b88ef2 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,48 @@ 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") +} +``` + ### Serving static files ```go diff --git a/problem.go b/problem.go new file mode 100644 index 00000000..e4aaa57f --- /dev/null +++ b/problem.go @@ -0,0 +1,60 @@ +// 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 ( + "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) +} diff --git a/problem_test.go b/problem_test.go new file mode 100644 index 00000000..02ba13ee --- /dev/null +++ b/problem_test.go @@ -0,0 +1,61 @@ +// 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 ( + "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.", + 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.", + "balance": 30 + }`, string(jsonBytes)) +} 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 From e08d63230090da616538ccd1fb49aba9f7d47233 Mon Sep 17 00:00:00 2001 From: Miguel Quintero Date: Tue, 21 Jul 2026 16:19:23 -0400 Subject: [PATCH 2/2] 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 --- docs/doc.md | 22 +++++++++++++++++ problem.go | 25 +++++++++++++++++++ problem_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/docs/doc.md b/docs/doc.md index 53b88ef2..9f425228 100644 --- a/docs/doc.md +++ b/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 diff --git a/problem.go b/problem.go index e4aaa57f..2768dc9d 100644 --- a/problem.go +++ b/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()}) + } +} diff --git a/problem_test.go b/problem_test.go index 02ba13ee..0684255e 100644 --- a/problem_test.go +++ b/problem_test.go @@ -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()) +}