gin/problem_test.go
Miguel Quintero e08d632300 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 <noreply@anthropic.com>
2026-07-21 16:43:58 -04:00

124 lines
3.6 KiB
Go

// 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())
}