From 23ac82f374dbb32989cfd82f19de3a0db6aad3dc Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 19:11:24 +0000
Subject: [PATCH 01/10] Customize validation handlers with slog
---
pkg/gin-openapi-validator/validationerror.go | 4 +
pkg/gin-openapi-validator/validator.go | 262 +++++++++++++++----
pkg/gin-openapi-validator/validator_test.go | 252 +++++++++++-------
3 files changed, 372 insertions(+), 146 deletions(-)
diff --git a/pkg/gin-openapi-validator/validationerror.go b/pkg/gin-openapi-validator/validationerror.go
index ba7758a..b22f02b 100644
--- a/pkg/gin-openapi-validator/validationerror.go
+++ b/pkg/gin-openapi-validator/validationerror.go
@@ -24,6 +24,7 @@ func Decode(err error) (*openapi3filter.ValidationError, error) {
Status: http.StatusNotFound,
Title: "not found",
}
+
return cErr, nil
}
@@ -158,11 +159,13 @@ func convertSchemaError(e *openapi3filter.RequestError, innerErr *openapi3.Schem
cErr.Source = &openapi3filter.ValidationErrorSource{
Parameter: e.Parameter.Name,
}
+
cErr.Title += " See " + cErr.Source.Parameter
} else if innerErr.JSONPointer() != nil {
cErr.Source = &openapi3filter.ValidationErrorSource{
Pointer: toJSONPointer(innerErr.JSONPointer()),
}
+
cErr.Title += " See " + cErr.Source.Pointer
}
@@ -187,6 +190,7 @@ func convertSchemaError(e *openapi3filter.RequestError, innerErr *openapi3.Schem
(e.Parameter.Style == "" || e.Parameter.Style == "form") &&
strings.Contains(value, ",") {
parts := strings.Split(value, ",")
+
cErr.Detail += "; " + fmt.Sprintf("perhaps you intended '?%s=%s'",
e.Parameter.Name, strings.Join(parts, "&"+e.Parameter.Name+"="))
}
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index 4a25d3d..a9a7283 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -2,49 +2,88 @@ package ginopenapivalidator
import (
"bytes"
+ "log/slog"
"net/http"
+ "sort"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
+ "github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/gin-gonic/gin"
- log "github.com/sirupsen/logrus"
)
// responseBodyWriter captures the response body.
type responseBodyWriter struct {
gin.ResponseWriter
- body *bytes.Buffer
- statusCode int
- headers http.Header
- strict bool
+ body *bytes.Buffer
+ statusCode int
+ headers http.Header
+ wroteHeader bool
}
func (w *responseBodyWriter) Write(b []byte) (int, error) {
- n, err := w.ResponseWriter.Write(b)
- if err == nil {
- w.body.Write(b[:n])
+ if !w.wroteHeader {
+ w.WriteHeaderNow()
}
- return n, err
+ n, err := w.body.Write(b)
+ if err != nil {
+ return n, err
+ }
+
+ return n, nil
+}
+
+func (w *responseBodyWriter) WriteString(s string) (int, error) {
+ if !w.wroteHeader {
+ w.WriteHeaderNow()
+ }
+
+ return w.body.WriteString(s)
}
func (w *responseBodyWriter) WriteHeader(code int) {
w.statusCode = code
- if !w.strict {
- w.ResponseWriter.WriteHeader(code)
- }
+ w.wroteHeader = true
}
func (w *responseBodyWriter) Header() http.Header {
return w.headers
}
+func (w *responseBodyWriter) WriteHeaderNow() {
+ if w.wroteHeader {
+ return
+ }
+
+ w.wroteHeader = true
+ if w.statusCode == 0 {
+ w.statusCode = http.StatusOK
+ }
+}
+
+func (w *responseBodyWriter) Status() int {
+ if w.statusCode == 0 {
+ return http.StatusOK
+ }
+
+ return w.statusCode
+}
+
+func (w *responseBodyWriter) Size() int {
+ return w.body.Len()
+}
+
+func (w *responseBodyWriter) Written() bool {
+ return w.wroteHeader || w.body.Len() > 0
+}
+
+func (w *responseBodyWriter) Flush() {}
+
func (w *responseBodyWriter) flush() {
for k, vv := range w.headers {
- for _, v := range vv {
- w.ResponseWriter.Header().Add(k, v)
- }
+ w.ResponseWriter.Header()[k] = append([]string(nil), vv...)
}
if w.statusCode == 0 {
@@ -52,13 +91,41 @@ func (w *responseBodyWriter) flush() {
}
w.ResponseWriter.WriteHeader(w.statusCode)
- w.ResponseWriter.Write(w.body.Bytes())
+ _, _ = w.ResponseWriter.Write(w.body.Bytes())
+}
+
+func (w *responseBodyWriter) modified() bool {
+ return w.statusCode != 0 || len(w.headers) > 0 || w.body.Len() > 0 || w.wroteHeader
+}
+
+func newResponseBodyWriter(writer gin.ResponseWriter) *responseBodyWriter {
+ return &responseBodyWriter{
+ ResponseWriter: writer,
+ body: &bytes.Buffer{},
+ headers: cloneHeader(writer.Header()),
+ statusCode: http.StatusOK,
+ }
+}
+
+func cloneHeader(header http.Header) http.Header {
+ cloned := make(http.Header, len(header))
+ for k, vv := range header {
+ cloned[k] = append([]string(nil), vv...)
+ }
+
+ return cloned
}
type ValidatorOptions struct {
// If true, the middleware returns HTTP 500 when the response body
// violates the OpenAPI specifications.
StrictResponse bool
+ // Logger receives non-strict response validation failures when provided.
+ Logger *slog.Logger
+ // RequestErrorHandler handles request validation failures when provided.
+ RequestErrorHandler func(*gin.Context, error)
+ // ResponseErrorHandler handles response validation failures when provided.
+ ResponseErrorHandler func(*gin.Context, error)
}
// Validator returns an OpenAPI validation middleware for Gin.
@@ -71,6 +138,7 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc {
openapi3.DefineStringFormatValidator("uuid", openapi3.NewRegexpFormatValidator(openapi3.FormatOfStringForUUIDOfRFC4122))
loader := openapi3.NewLoader()
+
loader.IsExternalRefsAllowed = true
swagger, err := loader.LoadFromData(doc)
@@ -83,70 +151,110 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc {
panic("failed to create router: " + err.Error())
}
+ requestErrorHandler := defaultRequestErrorHandler
+ if options.RequestErrorHandler != nil {
+ requestErrorHandler = options.RequestErrorHandler
+ }
+
+ return validatorHandler(router, options, requestErrorHandler)
+}
+
+func validatorHandler(router routers.Router, options ValidatorOptions, requestErrorHandler func(*gin.Context, error)) gin.HandlerFunc {
return func(c *gin.Context) {
- route, pathParams, err := router.FindRoute(c.Request)
+ requestValidationInput, err := validateIncomingRequest(c, router)
if err != nil {
- abortForValidationError(c, err)
- return
- }
-
- requestValidationInput := &openapi3filter.RequestValidationInput{
- Request: c.Request,
- PathParams: pathParams,
- Route: route,
- }
- if err = openapi3filter.ValidateRequest(c.Request.Context(), requestValidationInput); err != nil {
- abortForValidationError(c, err)
+ requestErrorHandler(c, err)
return
}
- w := &responseBodyWriter{
- ResponseWriter: c.Writer,
- body: &bytes.Buffer{},
- headers: make(http.Header),
- strict: options.StrictResponse,
- statusCode: http.StatusOK,
- }
- for k, vv := range c.Writer.Header() {
- for _, v := range vv {
- w.headers.Add(k, v)
- }
- }
+ originalWriter := c.Writer
+ w := newResponseBodyWriter(originalWriter)
c.Writer = w
c.Next()
- responseValidationInput := &openapi3filter.ResponseValidationInput{
- RequestValidationInput: requestValidationInput,
- Status: w.statusCode,
- Header: w.headers,
- }
- if w.body.Len() > 0 {
- responseValidationInput.SetBodyBytes(w.body.Bytes())
+ if err = validateOutgoingResponse(c, requestValidationInput, w); err != nil {
+ handleResponseValidationError(c, originalWriter, w, options, err)
+ return
}
- err = openapi3filter.ValidateResponse(c.Request.Context(), responseValidationInput)
- if err != nil {
- log.WithError(err).Error("response payload violates OpenAPI contract")
+ c.Writer = originalWriter
- if w.strict {
- c.Writer.Header().Set("Content-Type", "application/json")
- c.Writer.WriteHeader(http.StatusInternalServerError)
- c.Writer.Write([]byte(`{"error":"Internal Server Error","detail":"Response body does not conform to the OpenAPI specification"}`))
- return
- }
+ w.flush()
+ }
+}
+
+func validateIncomingRequest(c *gin.Context, router routers.Router) (*openapi3filter.RequestValidationInput, error) {
+ route, pathParams, err := router.FindRoute(c.Request)
+ if err != nil {
+ return nil, err
+ }
+
+ requestValidationInput := &openapi3filter.RequestValidationInput{
+ Request: c.Request,
+ PathParams: pathParams,
+ Route: route,
+ }
+
+ if err = openapi3filter.ValidateRequest(c.Request.Context(), requestValidationInput); err != nil {
+ return nil, err
+ }
+
+ return requestValidationInput, nil
+}
+
+func validateOutgoingResponse(c *gin.Context, requestValidationInput *openapi3filter.RequestValidationInput, writer *responseBodyWriter) error {
+ responseValidationInput := &openapi3filter.ResponseValidationInput{
+ RequestValidationInput: requestValidationInput,
+ Status: writer.Status(),
+ Header: writer.headers,
+ }
+
+ if writer.body.Len() > 0 {
+ responseValidationInput.SetBodyBytes(writer.body.Bytes())
+ }
+
+ return openapi3filter.ValidateResponse(c.Request.Context(), responseValidationInput)
+}
+
+func handleResponseValidationError(c *gin.Context, originalWriter gin.ResponseWriter, capturedWriter *responseBodyWriter, options ValidatorOptions, err error) {
+ if options.ResponseErrorHandler != nil {
+ handlerWriter := newResponseBodyWriter(originalWriter)
+
+ handlerWriter.headers = make(http.Header)
+ handlerWriter.statusCode = 0
+
+ c.Writer = handlerWriter
+ options.ResponseErrorHandler(c, err)
+
+ if handlerWriter.modified() {
+ handlerWriter.flush()
+ return
}
+ }
- w.flush()
+ if options.StrictResponse {
+ c.Writer = newResponseBodyWriter(originalWriter)
+ defaultResponseErrorHandler(options)(c, err)
+ c.Writer.(*responseBodyWriter).flush()
+
+ return
}
+
+ defaultResponseErrorHandler(options)(c, err)
+
+ c.Writer = originalWriter
+
+ capturedWriter.flush()
}
-func abortForValidationError(c *gin.Context, err error) {
+func defaultRequestErrorHandler(c *gin.Context, err error) {
decodedValidationError, decodeErr := Decode(err)
if decodeErr != nil || decodedValidationError == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
})
+
return
}
@@ -154,3 +262,41 @@ func abortForValidationError(c *gin.Context, err error) {
"error": decodedValidationError.Title,
})
}
+
+func defaultResponseErrorHandler(options ValidatorOptions) func(*gin.Context, error) {
+ return func(c *gin.Context, err error) {
+ if !options.StrictResponse {
+ if options.Logger != nil {
+ attrs := []any{"error", err}
+ if responseWriter, ok := c.Writer.(*responseBodyWriter); ok {
+ attrs = append(attrs,
+ "status", responseWriter.Status(),
+ "headers", headerPairs(responseWriter.headers),
+ )
+ }
+
+ options.Logger.ErrorContext(c.Request.Context(), "response payload violates OpenAPI contract", attrs...)
+ }
+
+ return
+ }
+
+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
+ "error": "Internal Server Error",
+ "detail": "Response body does not conform to the OpenAPI specification",
+ })
+ }
+}
+
+func headerPairs(header http.Header) []string {
+ pairs := make([]string, 0, len(header))
+ for key, values := range header {
+ for _, value := range values {
+ pairs = append(pairs, key+": "+value)
+ }
+ }
+
+ sort.Strings(pairs)
+
+ return pairs
+}
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index d0f3069..e423ab0 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -4,38 +4,27 @@ import (
"bytes"
_ "embed"
"encoding/json"
+ "log/slog"
"net/http"
"net/http/httptest"
- "os"
+ "strings"
"testing"
"github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ginopenapivalidator "github.com/phumberdroz/gin-openapi-validator/pkg/gin-openapi-validator"
)
-var hook *test.Hook
-var r *gin.Engine
-
//go:embed "petstore.yaml"
-var s []byte
-
-func TestMain(m *testing.M) {
- setupRouter()
-
- hook = test.NewGlobal()
+var spec []byte
- code := m.Run()
- os.Exit(code)
-}
+func newRouter(opts ...ginopenapivalidator.ValidatorOptions) *gin.Engine {
+ gin.SetMode(gin.TestMode)
-func setupRouter() {
- r = gin.Default()
- r.Use(ginopenapivalidator.Validator(s))
+ r := gin.New()
+ r.Use(ginopenapivalidator.Validator(spec, opts...))
r.GET("/pets", func(c *gin.Context) {
c.JSON(http.StatusOK, []gin.H{{"name": "string", "tag": "string", "id": 0}})
})
@@ -48,54 +37,43 @@ func setupRouter() {
r.POST("/pets", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"name": "string", "tag": "string", "id": 0})
})
-}
-
-func request(request *http.Request) *httptest.ResponseRecorder {
- w := httptest.NewRecorder()
- r.ServeHTTP(w, request)
- return w
+ return r
}
-func TestGetStatusOk(t *testing.T) {
- req, err := http.NewRequest(http.MethodPost, "/pets", bytes.NewBuffer([]byte(`{"name": "string","tag": "string"}`)))
- require.NoError(t, err)
- req.Header.Set("Content-Type", "application/json")
- resp := request(req)
- assert.Equal(t, http.StatusOK, resp.Code)
-}
+func performRequest(t *testing.T, router *gin.Engine, method, url, body string, setContentType bool) *httptest.ResponseRecorder {
+ t.Helper()
-func TestPostStatusOk(t *testing.T) {
- req, err := http.NewRequest(http.MethodPost, "/pets", bytes.NewBuffer([]byte(`{"name": "string","tag": "string"}`)))
+ req, err := http.NewRequest(method, url, bytes.NewBufferString(body))
require.NoError(t, err)
- req.Header.Set("Content-Type", "application/json")
- resp := request(req)
- assert.Equal(t, http.StatusOK, resp.Code)
-}
-func TestStatusOkButWrongResponse(t *testing.T) {
- defer hook.Reset()
+ if setContentType {
+ req.Header.Set("Content-Type", "application/json")
+ }
- req, err := http.NewRequest(http.MethodGet, "/pets/1", nil)
- require.NoError(t, err)
- req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+
+ return recorder
+}
+
+func TestStatusOk(t *testing.T) {
+ router := newRouter()
- resp := request(req)
+ resp := performRequest(t, router, http.MethodPost, "/pets", `{"name":"string","tag":"string"}`, true)
assert.Equal(t, http.StatusOK, resp.Code)
- assert.Len(t, hook.Entries, 1)
- assert.Equal(t, logrus.ErrorLevel, hook.LastEntry().Level)
- assert.Equal(t, "response payload violates OpenAPI contract", hook.LastEntry().Message)
}
-func TestStatusOkUsersUuid(t *testing.T) {
- req, err := http.NewRequest(http.MethodGet, "/users?userId=bc1a80b7-6e76-4985-be3d-cbf8f8e79a2f", nil)
- assert.NoError(t, err)
+func TestStatusOkUsersUUID(t *testing.T) {
+ router := newRouter()
- resp := request(req)
+ resp := performRequest(t, router, http.MethodGet, "/users?userId=bc1a80b7-6e76-4985-be3d-cbf8f8e79a2f", "", false)
assert.Equal(t, http.StatusOK, resp.Code)
}
func TestBadRequests(t *testing.T) {
+ router := newRouter()
+
tests := []struct {
name string
method string
@@ -106,83 +84,181 @@ func TestBadRequests(t *testing.T) {
responseShouldContain string
}{
{
- name: "NotFound: unknow route",
+ name: "NotFound unknown route",
method: http.MethodGet,
url: "/a/route/that/will/never/exist",
- body: "",
- setContentType: false,
expectedStatusCode: http.StatusNotFound,
responseShouldContain: "no matching operation was found",
- }, {
- name: "NotFound: unknow route",
+ },
+ {
+ name: "NotFound invalid path parameter",
method: http.MethodGet,
url: "/pets/notAnInt",
- body: "",
- setContentType: false,
expectedStatusCode: http.StatusNotFound,
responseShouldContain: `Resource not found with 'id' value: notAnInt`,
- }, {
- name: "ValidationError",
+ },
+ {
+ name: "ValidationError query parameter",
method: http.MethodGet,
url: "/pets?limit=TEST",
- body: "",
- setContentType: false,
expectedStatusCode: http.StatusBadRequest,
responseShouldContain: "Parameter 'limit' in query is invalid: TEST is an invalid integer",
- }, {
- name: "ParseError: Not JSON with ContentType",
+ },
+ {
+ name: "ParseError invalid JSON body",
method: http.MethodPost,
url: "/pets",
body: "not json",
setContentType: true,
expectedStatusCode: http.StatusBadRequest,
responseShouldContain: "Could not parse request body",
- }, {
- name: "ValidationError: Wrong Body age should be int instead of string",
+ },
+ {
+ name: "ValidationError invalid body type",
method: http.MethodPost,
url: "/pets",
- body: `{"name": "string","tag": "string", "age": "I am a string"}`,
+ body: `{"name":"string","tag":"string","age":"I am a string"}`,
setContentType: true,
expectedStatusCode: http.StatusUnprocessableEntity,
responseShouldContain: "Field must be set to integer or not be present See /age",
- }, {
- name: "ValidationError: Wrong Body missing required field",
+ },
+ {
+ name: "ValidationError missing required field",
method: http.MethodPost,
url: "/pets",
- body: `{"test": "string", "tag": "string"}`,
+ body: `{"test":"string","tag":"string"}`,
setContentType: true,
expectedStatusCode: http.StatusUnprocessableEntity,
responseShouldContain: `{"error":"property \"name\" is missing See /name"}`,
- }, {
- name: "ValidationError: missing body",
+ },
+ {
+ name: "ValidationError missing body",
method: http.MethodPost,
url: "/pets",
- body: "",
setContentType: true,
expectedStatusCode: http.StatusBadRequest,
responseShouldContain: `request body has an error: value is required but missing`,
},
}
- for _, tc := range tests {
- testCase := tc
- t.Run(testCase.name, func(t *testing.T) {
- hook.Reset()
-
- req, err := http.NewRequest(testCase.method, testCase.url, bytes.NewBuffer([]byte(testCase.body)))
- assert.NoError(t, err)
-
- if testCase.setContentType {
- req.Header.Set("Content-Type", "application/json")
- }
- resp := request(req)
- assert.Equal(t, testCase.expectedStatusCode, resp.Code)
- assert.Contains(t, resp.Body.String(), testCase.responseShouldContain)
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := performRequest(t, router, tc.method, tc.url, tc.body, tc.setContentType)
+ assert.Equal(t, tc.expectedStatusCode, resp.Code)
+ assert.Contains(t, resp.Body.String(), tc.responseShouldContain)
var js json.RawMessage
-
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &js))
- assert.Len(t, hook.Entries, 0)
})
}
}
+
+func TestResponseValidationLogsWithSlogAndPreservesResponse(t *testing.T) {
+ var logOutput bytes.Buffer
+
+ logger := slog.New(slog.NewTextHandler(&logOutput, nil))
+ router := newRouter(ginopenapivalidator.ValidatorOptions{Logger: logger})
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
+ assert.Contains(t, logOutput.String(), "response payload violates OpenAPI contract")
+ assert.Contains(t, logOutput.String(), "status=200")
+}
+
+func TestStrictResponseReturnsInternalServerError(t *testing.T) {
+ router := newRouter(ginopenapivalidator.ValidatorOptions{StrictResponse: true})
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusInternalServerError, resp.Code)
+ assert.JSONEq(t, `{"detail":"Response body does not conform to the OpenAPI specification","error":"Internal Server Error"}`, resp.Body.String())
+}
+
+func TestCustomRequestErrorHandlerHandlesRouteErrors(t *testing.T) {
+ router := newRouter(ginopenapivalidator.ValidatorOptions{
+ RequestErrorHandler: func(c *gin.Context, err error) {
+ c.AbortWithStatusJSON(http.StatusTeapot, gin.H{"error": err.Error()})
+ },
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/does-not-exist", "", false)
+
+ assert.Equal(t, http.StatusTeapot, resp.Code)
+ assert.Contains(t, resp.Body.String(), "no matching operation was found")
+}
+
+func TestCustomRequestErrorHandlerHandlesValidationErrors(t *testing.T) {
+ var handledErr error
+
+ router := newRouter(ginopenapivalidator.ValidatorOptions{
+ RequestErrorHandler: func(c *gin.Context, err error) {
+ handledErr = err
+
+ c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "custom request handler"})
+ },
+ })
+
+ resp := performRequest(t, router, http.MethodPost, "/pets", "not json", true)
+
+ require.Error(t, handledErr)
+ assert.Equal(t, http.StatusBadGateway, resp.Code)
+ assert.JSONEq(t, `{"error":"custom request handler"}`, resp.Body.String())
+}
+
+func TestCustomResponseErrorHandlerIsInvoked(t *testing.T) {
+ var handledErr error
+
+ router := newRouter(ginopenapivalidator.ValidatorOptions{
+ ResponseErrorHandler: func(c *gin.Context, err error) {
+ handledErr = err
+ },
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ require.Error(t, handledErr)
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
+}
+
+func TestCustomResponseErrorHandlerCanReplaceResponse(t *testing.T) {
+ router := newRouter(ginopenapivalidator.ValidatorOptions{
+ ResponseErrorHandler: func(c *gin.Context, err error) {
+ c.AbortWithStatusJSON(http.StatusTeapot, gin.H{
+ "error": "custom response handler",
+ "detail": err.Error(),
+ })
+ },
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusTeapot, resp.Code)
+ assert.Contains(t, resp.Body.String(), "custom response handler")
+ assert.NotContains(t, resp.Body.String(), `"no":"NO"`)
+}
+
+func TestStrictResponseFallsBackToDefaultWhenCustomResponseHandlerDoesNothing(t *testing.T) {
+ router := newRouter(ginopenapivalidator.ValidatorOptions{
+ StrictResponse: true,
+ ResponseErrorHandler: func(c *gin.Context, err error) {
+ _ = err
+ },
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusInternalServerError, resp.Code)
+ assert.JSONEq(t, `{"detail":"Response body does not conform to the OpenAPI specification","error":"Internal Server Error"}`, resp.Body.String())
+}
+
+func TestNilLoggerDoesNotLogOrPanic(t *testing.T) {
+ router := newRouter(ginopenapivalidator.ValidatorOptions{})
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.True(t, strings.Contains(resp.Body.String(), `"no":"NO"`))
+}
From 02d3a8febeb34a42fc9e1d29697f3e4535cc226c Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 19:21:19 +0000
Subject: [PATCH 02/10] Pin CI to latest Go release
---
.github/workflows/ci.yml | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cfb4946..8602c2b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,16 +9,13 @@ on:
jobs:
test:
runs-on: ubuntu-latest
- strategy:
- matrix:
- go: ["1.22", "1.23", "1.24", "1.25", "tip"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: ${{ matrix.go }}
+ go-version: "1.26.1"
- name: Test
run: go test -v -race ./...
From 152d8dc1af974ca18d245633a963325d83a4b793 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 19:29:39 +0000
Subject: [PATCH 03/10] Update CI lint action
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8602c2b..c4574b0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,6 +21,6 @@ jobs:
run: go test -v -race ./...
- name: Lint
- uses: golangci/golangci-lint-action@v6
+ uses: golangci/golangci-lint-action@v9
with:
- version: latest
+ version: v2.11.3
From f9b061b74b9df3b0c7831627324a676cd538ec6b Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 19:40:30 +0000
Subject: [PATCH 04/10] Add decodable contract error type
---
pkg/gin-openapi-validator/validationerror.go | 172 +++++++++++++++++++
pkg/gin-openapi-validator/validator.go | 4 +-
pkg/gin-openapi-validator/validator_test.go | 23 +++
3 files changed, 197 insertions(+), 2 deletions(-)
diff --git a/pkg/gin-openapi-validator/validationerror.go b/pkg/gin-openapi-validator/validationerror.go
index b22f02b..941366f 100644
--- a/pkg/gin-openapi-validator/validationerror.go
+++ b/pkg/gin-openapi-validator/validationerror.go
@@ -5,6 +5,7 @@ package ginopenapivalidator
// Original license is MIT by the authors of kin-openapi
import (
+ "errors"
"fmt"
"net/http"
"strings"
@@ -16,8 +17,179 @@ import (
const unsupportedContentTypeReason = "header 'Content-Type' has unexpected value: "
+type ValidationPhase string
+
+const (
+ ValidationPhaseRequest ValidationPhase = "request"
+ ValidationPhaseResponse ValidationPhase = "response"
+)
+
+type ValidationKind string
+
+const (
+ ValidationKindUnknown ValidationKind = "unknown"
+ ValidationKindRoute ValidationKind = "route"
+ ValidationKindRequest ValidationKind = "request"
+ ValidationKindResponse ValidationKind = "response"
+ ValidationKindParse ValidationKind = "parse"
+ ValidationKindSchema ValidationKind = "schema"
+)
+
+// ContractError wraps a request or response validation error with both the normalized
+// validation payload and the underlying kin-openapi details.
+type ContractError struct {
+ Phase ValidationPhase
+ Kind ValidationKind
+ Status int
+ Title string
+ Detail string
+ Source *openapi3filter.ValidationErrorSource
+ Reason string
+ Validation *openapi3filter.ValidationError
+ RequestError *openapi3filter.RequestError
+ ResponseError *openapi3filter.ResponseError
+ RouteError *routers.RouteError
+ ParseError *openapi3filter.ParseError
+ SchemaError *openapi3.SchemaError
+ Parameter *openapi3.Parameter
+ RequestBody *openapi3.RequestBody
+ ResponseInput *openapi3filter.ResponseValidationInput
+ Err error
+}
+
+func (e *ContractError) Error() string {
+ if e == nil {
+ return ""
+ }
+
+ if e.Validation != nil {
+ return e.Validation.Error()
+ }
+
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+
+ return string(e.Kind) + " validation error"
+}
+
+func (e *ContractError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+
+ return e.Err
+}
+
+func newContractError(phase ValidationPhase, err error) *ContractError {
+ contractErr := &ContractError{
+ Phase: phase,
+ Kind: ValidationKindUnknown,
+ Err: err,
+ }
+
+ if err == nil {
+ return contractErr
+ }
+
+ enrichContractError(contractErr)
+
+ decoded, decodeErr := decodeValidationError(err)
+ if decodeErr == nil && decoded != nil {
+ applyDecodedValidation(contractErr, decoded)
+ return contractErr
+ }
+
+ if phase == ValidationPhaseResponse {
+ applyDecodedValidation(contractErr, &openapi3filter.ValidationError{
+ Status: http.StatusInternalServerError,
+ Title: "Response body does not conform to the OpenAPI specification",
+ Detail: err.Error(),
+ })
+ }
+
+ return contractErr
+}
+
+func enrichContractError(contractErr *ContractError) {
+ enrichRouteError(contractErr)
+ enrichRequestError(contractErr)
+ enrichResponseError(contractErr)
+ enrichParseError(contractErr)
+ enrichSchemaError(contractErr)
+}
+
+func enrichRouteError(contractErr *ContractError) {
+ var routeErr *routers.RouteError
+ if errors.As(contractErr.Err, &routeErr) {
+ contractErr.Kind = ValidationKindRoute
+ contractErr.RouteError = routeErr
+ contractErr.Reason = routeErr.Reason
+ }
+}
+
+func enrichRequestError(contractErr *ContractError) {
+ var requestErr *openapi3filter.RequestError
+ if errors.As(contractErr.Err, &requestErr) {
+ contractErr.Kind = ValidationKindRequest
+ contractErr.RequestError = requestErr
+ contractErr.Reason = requestErr.Reason
+ contractErr.Parameter = requestErr.Parameter
+ contractErr.RequestBody = requestErr.RequestBody
+ }
+}
+
+func enrichResponseError(contractErr *ContractError) {
+ var responseErr *openapi3filter.ResponseError
+ if errors.As(contractErr.Err, &responseErr) {
+ contractErr.Kind = ValidationKindResponse
+ contractErr.ResponseError = responseErr
+ contractErr.Reason = responseErr.Reason
+ contractErr.ResponseInput = responseErr.Input
+ }
+}
+
+func enrichParseError(contractErr *ContractError) {
+ var parseErr *openapi3filter.ParseError
+ if errors.As(contractErr.Err, &parseErr) {
+ contractErr.Kind = ValidationKindParse
+ contractErr.ParseError = parseErr
+ }
+}
+
+func enrichSchemaError(contractErr *ContractError) {
+ var schemaErr *openapi3.SchemaError
+ if errors.As(contractErr.Err, &schemaErr) {
+ contractErr.Kind = ValidationKindSchema
+ contractErr.SchemaError = schemaErr
+ }
+}
+
+func applyDecodedValidation(contractErr *ContractError, decoded *openapi3filter.ValidationError) {
+ contractErr.Validation = decoded
+ contractErr.Status = decoded.Status
+ contractErr.Title = decoded.Title
+ contractErr.Detail = decoded.Detail
+ contractErr.Source = decoded.Source
+}
+
// Decode takes a validation error and decodes it back to an *openapi3filter.ValidationError.
func Decode(err error) (*openapi3filter.ValidationError, error) {
+ var contractErr *ContractError
+ if errors.As(err, &contractErr) {
+ if contractErr.Validation != nil {
+ return contractErr.Validation, nil
+ }
+
+ if contractErr.Err != nil && contractErr.Err != err {
+ return decodeValidationError(contractErr.Err)
+ }
+ }
+
+ return decodeValidationError(err)
+}
+
+func decodeValidationError(err error) (*openapi3filter.ValidationError, error) {
var cErr *openapi3filter.ValidationError
if err.Error() == "invalid route" {
cErr = &openapi3filter.ValidationError{
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index a9a7283..95c2641 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -163,7 +163,7 @@ func validatorHandler(router routers.Router, options ValidatorOptions, requestEr
return func(c *gin.Context) {
requestValidationInput, err := validateIncomingRequest(c, router)
if err != nil {
- requestErrorHandler(c, err)
+ requestErrorHandler(c, newContractError(ValidationPhaseRequest, err))
return
}
@@ -174,7 +174,7 @@ func validatorHandler(router routers.Router, options ValidatorOptions, requestEr
c.Next()
if err = validateOutgoingResponse(c, requestValidationInput, w); err != nil {
- handleResponseValidationError(c, originalWriter, w, options, err)
+ handleResponseValidationError(c, originalWriter, w, options, newContractError(ValidationPhaseResponse, err))
return
}
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index e423ab0..450f37c 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -177,8 +177,11 @@ func TestStrictResponseReturnsInternalServerError(t *testing.T) {
}
func TestCustomRequestErrorHandlerHandlesRouteErrors(t *testing.T) {
+ var contractErr *ginopenapivalidator.ContractError
+
router := newRouter(ginopenapivalidator.ValidatorOptions{
RequestErrorHandler: func(c *gin.Context, err error) {
+ require.ErrorAs(t, err, &contractErr)
c.AbortWithStatusJSON(http.StatusTeapot, gin.H{"error": err.Error()})
},
})
@@ -187,14 +190,21 @@ func TestCustomRequestErrorHandlerHandlesRouteErrors(t *testing.T) {
assert.Equal(t, http.StatusTeapot, resp.Code)
assert.Contains(t, resp.Body.String(), "no matching operation was found")
+ require.NotNil(t, contractErr)
+ assert.Equal(t, ginopenapivalidator.ValidationPhaseRequest, contractErr.Phase)
+ assert.Equal(t, ginopenapivalidator.ValidationKindRoute, contractErr.Kind)
+ assert.Equal(t, http.StatusNotFound, contractErr.Status)
}
func TestCustomRequestErrorHandlerHandlesValidationErrors(t *testing.T) {
var handledErr error
+ var contractErr *ginopenapivalidator.ContractError
+
router := newRouter(ginopenapivalidator.ValidatorOptions{
RequestErrorHandler: func(c *gin.Context, err error) {
handledErr = err
+ require.ErrorAs(t, err, &contractErr)
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "custom request handler"})
},
@@ -203,6 +213,10 @@ func TestCustomRequestErrorHandlerHandlesValidationErrors(t *testing.T) {
resp := performRequest(t, router, http.MethodPost, "/pets", "not json", true)
require.Error(t, handledErr)
+ require.NotNil(t, contractErr)
+ assert.Equal(t, ginopenapivalidator.ValidationPhaseRequest, contractErr.Phase)
+ assert.Equal(t, ginopenapivalidator.ValidationKindParse, contractErr.Kind)
+ assert.Equal(t, "Could not parse request body", contractErr.Title)
assert.Equal(t, http.StatusBadGateway, resp.Code)
assert.JSONEq(t, `{"error":"custom request handler"}`, resp.Body.String())
}
@@ -210,15 +224,24 @@ func TestCustomRequestErrorHandlerHandlesValidationErrors(t *testing.T) {
func TestCustomResponseErrorHandlerIsInvoked(t *testing.T) {
var handledErr error
+ var contractErr *ginopenapivalidator.ContractError
+
router := newRouter(ginopenapivalidator.ValidatorOptions{
ResponseErrorHandler: func(c *gin.Context, err error) {
handledErr = err
+ require.ErrorAs(t, err, &contractErr)
},
})
resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
require.Error(t, handledErr)
+ require.NotNil(t, contractErr)
+ assert.Equal(t, ginopenapivalidator.ValidationPhaseResponse, contractErr.Phase)
+ assert.Equal(t, ginopenapivalidator.ValidationKindSchema, contractErr.Kind)
+ require.NotNil(t, contractErr.ResponseError)
+ assert.Equal(t, http.StatusInternalServerError, contractErr.Status)
+ assert.NotEmpty(t, contractErr.Detail)
assert.Equal(t, http.StatusOK, resp.Code)
assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
}
From 1bf2b2dd99cabf5966aff5b8104cf68649f49fab Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 20:24:12 +0000
Subject: [PATCH 05/10] Fix response writer flush and handler context
---
pkg/gin-openapi-validator/validator.go | 106 +++++++++++++++++---
pkg/gin-openapi-validator/validator_test.go | 13 +++
2 files changed, 103 insertions(+), 16 deletions(-)
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index 95c2641..e24f552 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -20,6 +20,8 @@ type responseBodyWriter struct {
statusCode int
headers http.Header
wroteHeader bool
+ flushed bool
+ rewriteBody bool
}
func (w *responseBodyWriter) Write(b []byte) (int, error) {
@@ -44,6 +46,11 @@ func (w *responseBodyWriter) WriteString(s string) (int, error) {
}
func (w *responseBodyWriter) WriteHeader(code int) {
+ if w.rewriteBody && !w.flushed {
+ w.body.Reset()
+ w.rewriteBody = false
+ }
+
w.statusCode = code
w.wroteHeader = true
}
@@ -79,25 +86,32 @@ func (w *responseBodyWriter) Written() bool {
return w.wroteHeader || w.body.Len() > 0
}
-func (w *responseBodyWriter) Flush() {}
+func (w *responseBodyWriter) Flush() {
+ w.flush()
+ w.body.Reset()
-func (w *responseBodyWriter) flush() {
- for k, vv := range w.headers {
- w.ResponseWriter.Header()[k] = append([]string(nil), vv...)
+ if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
+ flusher.Flush()
}
+}
- if w.statusCode == 0 {
- w.statusCode = http.StatusOK
+func (w *responseBodyWriter) flush() {
+ if !w.flushed {
+ for k, vv := range w.headers {
+ w.ResponseWriter.Header()[k] = append([]string(nil), vv...)
+ }
+
+ if w.statusCode == 0 {
+ w.statusCode = http.StatusOK
+ }
+
+ w.ResponseWriter.WriteHeader(w.statusCode)
+ w.flushed = true
}
- w.ResponseWriter.WriteHeader(w.statusCode)
_, _ = w.ResponseWriter.Write(w.body.Bytes())
}
-func (w *responseBodyWriter) modified() bool {
- return w.statusCode != 0 || len(w.headers) > 0 || w.body.Len() > 0 || w.wroteHeader
-}
-
func newResponseBodyWriter(writer gin.ResponseWriter) *responseBodyWriter {
return &responseBodyWriter{
ResponseWriter: writer,
@@ -107,6 +121,68 @@ func newResponseBodyWriter(writer gin.ResponseWriter) *responseBodyWriter {
}
}
+func cloneResponseBodyWriter(writer gin.ResponseWriter, source *responseBodyWriter) *responseBodyWriter {
+ cloned := newResponseBodyWriter(writer)
+
+ cloned.statusCode = source.statusCode
+ cloned.wroteHeader = source.wroteHeader
+ cloned.flushed = false
+ cloned.rewriteBody = true
+
+ if source.body.Len() > 0 {
+ _, _ = cloned.body.Write(source.body.Bytes())
+ }
+
+ cloned.headers = cloneHeader(source.headers)
+
+ return cloned
+}
+
+type responseWriterSnapshot struct {
+ statusCode int
+ wroteHeader bool
+ headers http.Header
+ body string
+}
+
+func snapshotResponseBodyWriter(writer *responseBodyWriter) responseWriterSnapshot {
+ return responseWriterSnapshot{
+ statusCode: writer.statusCode,
+ wroteHeader: writer.wroteHeader,
+ headers: cloneHeader(writer.headers),
+ body: writer.body.String(),
+ }
+}
+
+func responseBodyWriterChanged(before responseWriterSnapshot, after *responseBodyWriter) bool {
+ if before.statusCode != after.statusCode || before.wroteHeader != after.wroteHeader || before.body != after.body.String() {
+ return true
+ }
+
+ return !headersEqual(before.headers, after.headers)
+}
+
+func headersEqual(left, right http.Header) bool {
+ if len(left) != len(right) {
+ return false
+ }
+
+ for key, leftValues := range left {
+ rightValues, ok := right[key]
+ if !ok || len(leftValues) != len(rightValues) {
+ return false
+ }
+
+ for i, value := range leftValues {
+ if rightValues[i] != value {
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
func cloneHeader(header http.Header) http.Header {
cloned := make(http.Header, len(header))
for k, vv := range header {
@@ -219,15 +295,13 @@ func validateOutgoingResponse(c *gin.Context, requestValidationInput *openapi3fi
func handleResponseValidationError(c *gin.Context, originalWriter gin.ResponseWriter, capturedWriter *responseBodyWriter, options ValidatorOptions, err error) {
if options.ResponseErrorHandler != nil {
- handlerWriter := newResponseBodyWriter(originalWriter)
-
- handlerWriter.headers = make(http.Header)
- handlerWriter.statusCode = 0
+ handlerWriter := cloneResponseBodyWriter(originalWriter, capturedWriter)
+ before := snapshotResponseBodyWriter(handlerWriter)
c.Writer = handlerWriter
options.ResponseErrorHandler(c, err)
- if handlerWriter.modified() {
+ if responseBodyWriterChanged(before, handlerWriter) {
handlerWriter.flush()
return
}
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index 450f37c..365cbd7 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -226,10 +226,20 @@ func TestCustomResponseErrorHandlerIsInvoked(t *testing.T) {
var contractErr *ginopenapivalidator.ContractError
+ var (
+ observedStatus int
+ observedSize int
+ observedContentType string
+ )
+
router := newRouter(ginopenapivalidator.ValidatorOptions{
ResponseErrorHandler: func(c *gin.Context, err error) {
handledErr = err
require.ErrorAs(t, err, &contractErr)
+
+ observedStatus = c.Writer.Status()
+ observedSize = c.Writer.Size()
+ observedContentType = c.Writer.Header().Get("Content-Type")
},
})
@@ -242,6 +252,9 @@ func TestCustomResponseErrorHandlerIsInvoked(t *testing.T) {
require.NotNil(t, contractErr.ResponseError)
assert.Equal(t, http.StatusInternalServerError, contractErr.Status)
assert.NotEmpty(t, contractErr.Detail)
+ assert.Equal(t, http.StatusOK, observedStatus)
+ assert.Equal(t, len(`{"no":"NO"}`), observedSize)
+ assert.Equal(t, "application/json; charset=utf-8", observedContentType)
assert.Equal(t, http.StatusOK, resp.Code)
assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
}
From 08f75b556341358bf66f9480a7cbdc00c3566811 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 20:27:17 +0000
Subject: [PATCH 06/10] Guard enum query hint against nil parameters
---
pkg/gin-openapi-validator/validationerror.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pkg/gin-openapi-validator/validationerror.go b/pkg/gin-openapi-validator/validationerror.go
index 941366f..d59722b 100644
--- a/pkg/gin-openapi-validator/validationerror.go
+++ b/pkg/gin-openapi-validator/validationerror.go
@@ -358,7 +358,8 @@ func convertSchemaError(e *openapi3filter.RequestError, innerErr *openapi3.Schem
innerErr.Value, toJSONPointer(innerErr.JSONPointer()), strings.Join(enums, ", "))
value := fmt.Sprintf("%v", innerErr.Value)
- if (e.Parameter.Explode == nil || *e.Parameter.Explode) &&
+ if e.Parameter != nil &&
+ (e.Parameter.Explode == nil || *e.Parameter.Explode) &&
(e.Parameter.Style == "" || e.Parameter.Style == "form") &&
strings.Contains(value, ",") {
parts := strings.Split(value, ",")
From c67814a468c3b164f06f12a6ae4f31b255faa419 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 20:53:25 +0000
Subject: [PATCH 07/10] Fix request aborts and flushed response validation
---
pkg/gin-openapi-validator/validator.go | 37 +++++++++++----
pkg/gin-openapi-validator/validator_test.go | 52 +++++++++++++++++++++
2 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index e24f552..a7eb5bc 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -16,12 +16,13 @@ import (
// responseBodyWriter captures the response body.
type responseBodyWriter struct {
gin.ResponseWriter
- body *bytes.Buffer
- statusCode int
- headers http.Header
- wroteHeader bool
- flushed bool
- rewriteBody bool
+ body *bytes.Buffer
+ validationBody *bytes.Buffer
+ statusCode int
+ headers http.Header
+ wroteHeader bool
+ flushed bool
+ rewriteBody bool
}
func (w *responseBodyWriter) Write(b []byte) (int, error) {
@@ -34,6 +35,8 @@ func (w *responseBodyWriter) Write(b []byte) (int, error) {
return n, err
}
+ _, _ = w.validationBody.Write(b)
+
return n, nil
}
@@ -42,12 +45,20 @@ func (w *responseBodyWriter) WriteString(s string) (int, error) {
w.WriteHeaderNow()
}
- return w.body.WriteString(s)
+ n, err := w.body.WriteString(s)
+ if err != nil {
+ return n, err
+ }
+
+ _, _ = w.validationBody.WriteString(s)
+
+ return n, nil
}
func (w *responseBodyWriter) WriteHeader(code int) {
if w.rewriteBody && !w.flushed {
w.body.Reset()
+ w.validationBody.Reset()
w.rewriteBody = false
}
@@ -116,6 +127,7 @@ func newResponseBodyWriter(writer gin.ResponseWriter) *responseBodyWriter {
return &responseBodyWriter{
ResponseWriter: writer,
body: &bytes.Buffer{},
+ validationBody: &bytes.Buffer{},
headers: cloneHeader(writer.Header()),
statusCode: http.StatusOK,
}
@@ -133,6 +145,10 @@ func cloneResponseBodyWriter(writer gin.ResponseWriter, source *responseBodyWrit
_, _ = cloned.body.Write(source.body.Bytes())
}
+ if source.validationBody.Len() > 0 {
+ _, _ = cloned.validationBody.Write(source.validationBody.Bytes())
+ }
+
cloned.headers = cloneHeader(source.headers)
return cloned
@@ -240,6 +256,9 @@ func validatorHandler(router routers.Router, options ValidatorOptions, requestEr
requestValidationInput, err := validateIncomingRequest(c, router)
if err != nil {
requestErrorHandler(c, newContractError(ValidationPhaseRequest, err))
+ if !c.IsAborted() {
+ c.Abort()
+ }
return
}
@@ -286,8 +305,8 @@ func validateOutgoingResponse(c *gin.Context, requestValidationInput *openapi3fi
Header: writer.headers,
}
- if writer.body.Len() > 0 {
- responseValidationInput.SetBodyBytes(writer.body.Bytes())
+ if writer.validationBody.Len() > 0 {
+ responseValidationInput.SetBodyBytes(writer.validationBody.Bytes())
}
return openapi3filter.ValidateResponse(c.Request.Context(), responseValidationInput)
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index 365cbd7..16eaa0e 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -221,6 +221,30 @@ func TestCustomRequestErrorHandlerHandlesValidationErrors(t *testing.T) {
assert.JSONEq(t, `{"error":"custom request handler"}`, resp.Body.String())
}
+func TestCustomRequestErrorHandlerStopsChainWithoutAbort(t *testing.T) {
+ var routeCalled bool
+
+ gin.SetMode(gin.TestMode)
+
+ router := gin.New()
+ router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ RequestErrorHandler: func(c *gin.Context, err error) {
+ _ = err
+ c.JSON(http.StatusBadRequest, gin.H{"error": "custom"})
+ },
+ }))
+ router.POST("/pets", func(c *gin.Context) {
+ routeCalled = true
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ resp := performRequest(t, router, http.MethodPost, "/pets", "not json", true)
+
+ assert.False(t, routeCalled)
+ assert.Equal(t, http.StatusBadRequest, resp.Code)
+ assert.JSONEq(t, `{"error":"custom"}`, resp.Body.String())
+}
+
func TestCustomResponseErrorHandlerIsInvoked(t *testing.T) {
var handledErr error
@@ -298,3 +322,31 @@ func TestNilLoggerDoesNotLogOrPanic(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.Code)
assert.True(t, strings.Contains(resp.Body.String(), `"no":"NO"`))
}
+
+func TestStrictResponseAllowsValidChunkedResponse(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ var logOutput bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&logOutput, nil))
+
+ router := gin.New()
+ router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ StrictResponse: true,
+ Logger: logger,
+ }))
+ router.GET("/pets", func(c *gin.Context) {
+ c.Header("Content-Type", "application/json; charset=utf-8")
+ _, err := c.Writer.Write([]byte(`[{"name":"string"`))
+ require.NoError(t, err)
+ c.Writer.Flush()
+
+ _, err = c.Writer.Write([]byte(`,"tag":"string","id":0}]`))
+ require.NoError(t, err)
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets", "", false)
+
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.JSONEq(t, `[{"name":"string","tag":"string","id":0}]`, resp.Body.String())
+ assert.NotContains(t, logOutput.String(), "response payload violates OpenAPI contract")
+}
From 5eca94feb8c6c7c0aa941e29aa17981e7bdcb7a3 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 20:58:52 +0000
Subject: [PATCH 08/10] Fix validator handler edge cases
---
pkg/gin-openapi-validator/validator.go | 2 ++
pkg/gin-openapi-validator/validator_test.go | 3 +++
2 files changed, 5 insertions(+)
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index a7eb5bc..fdc5018 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -256,9 +256,11 @@ func validatorHandler(router routers.Router, options ValidatorOptions, requestEr
requestValidationInput, err := validateIncomingRequest(c, router)
if err != nil {
requestErrorHandler(c, newContractError(ValidationPhaseRequest, err))
+
if !c.IsAborted() {
c.Abort()
}
+
return
}
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index 16eaa0e..beb535f 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -230,11 +230,13 @@ func TestCustomRequestErrorHandlerStopsChainWithoutAbort(t *testing.T) {
router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
RequestErrorHandler: func(c *gin.Context, err error) {
_ = err
+
c.JSON(http.StatusBadRequest, gin.H{"error": "custom"})
},
}))
router.POST("/pets", func(c *gin.Context) {
routeCalled = true
+
c.JSON(http.StatusOK, gin.H{"ok": true})
})
@@ -327,6 +329,7 @@ func TestStrictResponseAllowsValidChunkedResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
var logOutput bytes.Buffer
+
logger := slog.New(slog.NewTextHandler(&logOutput, nil))
router := gin.New()
From 8ebf49d5b1a1d160a0a94bc75fd9ec91f4b5fb16 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 21:04:59 +0000
Subject: [PATCH 09/10] Reset rewrite state before replacement writes
---
pkg/gin-openapi-validator/validator.go | 21 ++++++++++++++++-----
pkg/gin-openapi-validator/validator_test.go | 2 ++
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index fdc5018..75a167c 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -25,7 +25,20 @@ type responseBodyWriter struct {
rewriteBody bool
}
+func (w *responseBodyWriter) prepareRewrite() {
+ if !w.rewriteBody || w.flushed {
+ return
+ }
+
+ w.body.Reset()
+ w.validationBody.Reset()
+ w.headers = make(http.Header)
+ w.rewriteBody = false
+}
+
func (w *responseBodyWriter) Write(b []byte) (int, error) {
+ w.prepareRewrite()
+
if !w.wroteHeader {
w.WriteHeaderNow()
}
@@ -41,6 +54,8 @@ func (w *responseBodyWriter) Write(b []byte) (int, error) {
}
func (w *responseBodyWriter) WriteString(s string) (int, error) {
+ w.prepareRewrite()
+
if !w.wroteHeader {
w.WriteHeaderNow()
}
@@ -56,11 +71,7 @@ func (w *responseBodyWriter) WriteString(s string) (int, error) {
}
func (w *responseBodyWriter) WriteHeader(code int) {
- if w.rewriteBody && !w.flushed {
- w.body.Reset()
- w.validationBody.Reset()
- w.rewriteBody = false
- }
+ w.prepareRewrite()
w.statusCode = code
w.wroteHeader = true
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index beb535f..407bb57 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -227,6 +227,7 @@ func TestCustomRequestErrorHandlerStopsChainWithoutAbort(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
+
router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
RequestErrorHandler: func(c *gin.Context, err error) {
_ = err
@@ -234,6 +235,7 @@ func TestCustomRequestErrorHandlerStopsChainWithoutAbort(t *testing.T) {
c.JSON(http.StatusBadRequest, gin.H{"error": "custom"})
},
}))
+
router.POST("/pets", func(c *gin.Context) {
routeCalled = true
From bd2977d8f534fbcab14d991472435bbdbe558d56 Mon Sep 17 00:00:00 2001
From: Pierre Humberdroz
Date: Mon, 16 Mar 2026 21:58:16 +0000
Subject: [PATCH 10/10] Handle committed responses and preserve rewrite headers
---
README.md | 32 +++++--
go.mod | 1 -
go.sum | 2 -
pkg/gin-openapi-validator/validator.go | 47 +++++++---
pkg/gin-openapi-validator/validator_test.go | 95 +++++++++++++++++++++
5 files changed, 155 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index fd5055d..50dbdf2 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,9 @@ This package plugs into Gin and validates incoming requests and outgoing respons
## Features
- Validate requests (path params, query params, headers, and body).
-- Validate responses and log validation errors.
+- Validate responses and opt into structured logging for validation errors.
- Optionally fail invalid responses in strict mode.
+- Customize request and response validation failure handling.
- Supports custom string formats (for example UUID RFC 4122).
- Simple middleware API for Gin.
@@ -22,6 +23,7 @@ package main
import (
_ "embed"
+ "log/slog"
"github.com/gin-gonic/gin"
ginopenapivalidator "github.com/phumberdroz/gin-openapi-validator/pkg/gin-openapi-validator"
@@ -33,13 +35,26 @@ var spec []byte
func main() {
r := gin.Default()
- // Basic mode: logs response validation failures only.
+ // Pick one configuration per router or route group.
+
+ // Basic mode.
r.Use(ginopenapivalidator.Validator(spec))
- // Strict mode: invalid responses return 500.
- r.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
- StrictResponse: true,
- }))
+ // Or strict mode:
+ // r.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ // StrictResponse: true,
+ // }))
+
+ // Or custom mode:
+ // r.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ // Logger: slog.Default(),
+ // RequestErrorHandler: func(c *gin.Context, err error) {
+ // c.AbortWithStatusJSON(400, gin.H{"error": err.Error()})
+ // },
+ // ResponseErrorHandler: func(c *gin.Context, err error) {
+ // c.AbortWithStatusJSON(500, gin.H{"error": err.Error()})
+ // },
+ // }))
r.GET("/pets", func(c *gin.Context) {
c.JSON(200, []gin.H{{"name": "string", "tag": "string", "id": 1}})
@@ -49,6 +64,11 @@ func main() {
}
```
+## Response Rewrite Limit
+`ResponseErrorHandler` can replace an invalid response only before any bytes have been flushed to the client.
+
+Once a handler calls `c.Writer.Flush()`, the response is considered committed. At that point the validator can still detect and optionally log the validation failure, but it will pass the committed response through instead of attempting to rewrite it.
+
## Project Structure
- `pkg/gin-openapi-validator/`: library source and tests
- `pkg/gin-openapi-validator/petstore.yaml`: test OpenAPI fixture
diff --git a/go.mod b/go.mod
index 5cd8ddb..1f1026f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.25.6
require (
github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0
- github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
)
diff --git a/go.sum b/go.sum
index acc2dde..5f0fb92 100644
--- a/go.sum
+++ b/go.sum
@@ -82,8 +82,6 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go
index 75a167c..efc7e52 100644
--- a/pkg/gin-openapi-validator/validator.go
+++ b/pkg/gin-openapi-validator/validator.go
@@ -32,7 +32,7 @@ func (w *responseBodyWriter) prepareRewrite() {
w.body.Reset()
w.validationBody.Reset()
- w.headers = make(http.Header)
+ w.headers.Del("Content-Length")
w.rewriteBody = false
}
@@ -108,6 +108,10 @@ func (w *responseBodyWriter) Written() bool {
return w.wroteHeader || w.body.Len() > 0
}
+func (w *responseBodyWriter) committed() bool {
+ return w.flushed
+}
+
func (w *responseBodyWriter) Flush() {
w.flush()
w.body.Reset()
@@ -221,13 +225,15 @@ func cloneHeader(header http.Header) http.Header {
type ValidatorOptions struct {
// If true, the middleware returns HTTP 500 when the response body
- // violates the OpenAPI specifications.
+ // violates the OpenAPI specifications before any bytes have been
+ // flushed to the client.
StrictResponse bool
// Logger receives non-strict response validation failures when provided.
Logger *slog.Logger
// RequestErrorHandler handles request validation failures when provided.
RequestErrorHandler func(*gin.Context, error)
// ResponseErrorHandler handles response validation failures when provided.
+ // It can replace the response only before any bytes have been flushed.
ResponseErrorHandler func(*gin.Context, error)
}
@@ -326,6 +332,15 @@ func validateOutgoingResponse(c *gin.Context, requestValidationInput *openapi3fi
}
func handleResponseValidationError(c *gin.Context, originalWriter gin.ResponseWriter, capturedWriter *responseBodyWriter, options ValidatorOptions, err error) {
+ if capturedWriter.committed() {
+ logResponseValidationError(c, options.Logger, err)
+
+ c.Writer = originalWriter
+ capturedWriter.flush()
+
+ return
+ }
+
if options.ResponseErrorHandler != nil {
handlerWriter := cloneResponseBodyWriter(originalWriter, capturedWriter)
before := snapshotResponseBodyWriter(handlerWriter)
@@ -372,17 +387,7 @@ func defaultRequestErrorHandler(c *gin.Context, err error) {
func defaultResponseErrorHandler(options ValidatorOptions) func(*gin.Context, error) {
return func(c *gin.Context, err error) {
if !options.StrictResponse {
- if options.Logger != nil {
- attrs := []any{"error", err}
- if responseWriter, ok := c.Writer.(*responseBodyWriter); ok {
- attrs = append(attrs,
- "status", responseWriter.Status(),
- "headers", headerPairs(responseWriter.headers),
- )
- }
-
- options.Logger.ErrorContext(c.Request.Context(), "response payload violates OpenAPI contract", attrs...)
- }
+ logResponseValidationError(c, options.Logger, err)
return
}
@@ -394,6 +399,22 @@ func defaultResponseErrorHandler(options ValidatorOptions) func(*gin.Context, er
}
}
+func logResponseValidationError(c *gin.Context, logger *slog.Logger, err error) {
+ if logger == nil {
+ return
+ }
+
+ attrs := []any{"error", err}
+ if responseWriter, ok := c.Writer.(*responseBodyWriter); ok {
+ attrs = append(attrs,
+ "status", responseWriter.Status(),
+ "headers", headerPairs(responseWriter.headers),
+ )
+ }
+
+ logger.ErrorContext(c.Request.Context(), "response payload violates OpenAPI contract", attrs...)
+}
+
func headerPairs(header http.Header) []string {
pairs := make([]string, 0, len(header))
for key, values := range header {
diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go
index 407bb57..cc4e90e 100644
--- a/pkg/gin-openapi-validator/validator_test.go
+++ b/pkg/gin-openapi-validator/validator_test.go
@@ -304,6 +304,33 @@ func TestCustomResponseErrorHandlerCanReplaceResponse(t *testing.T) {
assert.NotContains(t, resp.Body.String(), `"no":"NO"`)
}
+func TestCustomResponseErrorHandlerReplacementPreservesHeaders(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ router := gin.New()
+ router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ ResponseErrorHandler: func(c *gin.Context, err error) {
+ _ = err
+
+ c.AbortWithStatusJSON(http.StatusTeapot, gin.H{"error": "custom response handler"})
+ },
+ }))
+ router.GET("/pets/:id", func(c *gin.Context) {
+ c.Header("Access-Control-Allow-Origin", "*")
+ c.Header("X-Trace-ID", "trace-123")
+ c.SetCookie("session", "abc", 60, "/", "", false, true)
+ c.JSON(http.StatusOK, gin.H{"no": "NO"})
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", true)
+
+ assert.Equal(t, http.StatusTeapot, resp.Code)
+ assert.JSONEq(t, `{"error":"custom response handler"}`, resp.Body.String())
+ assert.Equal(t, "*", resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Equal(t, "trace-123", resp.Header().Get("X-Trace-ID"))
+ assert.Contains(t, strings.Join(resp.Header()["Set-Cookie"], "\n"), "session=abc")
+}
+
func TestStrictResponseFallsBackToDefaultWhenCustomResponseHandlerDoesNothing(t *testing.T) {
router := newRouter(ginopenapivalidator.ValidatorOptions{
StrictResponse: true,
@@ -327,6 +354,74 @@ func TestNilLoggerDoesNotLogOrPanic(t *testing.T) {
assert.True(t, strings.Contains(resp.Body.String(), `"no":"NO"`))
}
+func TestStrictResponseDoesNotRewriteAfterFlush(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ var logOutput bytes.Buffer
+
+ logger := slog.New(slog.NewTextHandler(&logOutput, nil))
+
+ router := gin.New()
+ router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ StrictResponse: true,
+ Logger: logger,
+ }))
+ router.GET("/pets/:id", func(c *gin.Context) {
+ c.Header("Content-Type", "application/json; charset=utf-8")
+ _, err := c.Writer.Write([]byte(`{"no"`))
+ require.NoError(t, err)
+ c.Writer.Flush()
+
+ _, err = c.Writer.Write([]byte(`:"NO"}`))
+ require.NoError(t, err)
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", false)
+
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
+ assert.Contains(t, logOutput.String(), "response payload violates OpenAPI contract")
+}
+
+func TestCustomResponseErrorHandlerCannotReplaceAfterFlush(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ var (
+ handlerCalled bool
+ logOutput bytes.Buffer
+ )
+
+ logger := slog.New(slog.NewTextHandler(&logOutput, nil))
+
+ router := gin.New()
+ router.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{
+ Logger: logger,
+ ResponseErrorHandler: func(c *gin.Context, err error) {
+ _ = err
+ handlerCalled = true
+
+ c.AbortWithStatusJSON(http.StatusTeapot, gin.H{"error": "custom response handler"})
+ },
+ }))
+ router.GET("/pets/:id", func(c *gin.Context) {
+ c.Header("Content-Type", "application/json; charset=utf-8")
+ _, err := c.Writer.Write([]byte(`{"no"`))
+ require.NoError(t, err)
+ c.Writer.Flush()
+
+ _, err = c.Writer.Write([]byte(`:"NO"}`))
+ require.NoError(t, err)
+ })
+
+ resp := performRequest(t, router, http.MethodGet, "/pets/1", "", false)
+
+ assert.False(t, handlerCalled)
+ assert.Equal(t, http.StatusOK, resp.Code)
+ assert.JSONEq(t, `{"no":"NO"}`, resp.Body.String())
+ assert.Contains(t, logOutput.String(), "response payload violates OpenAPI contract")
+ assert.NotContains(t, resp.Body.String(), "custom response handler")
+}
+
func TestStrictResponseAllowsValidChunkedResponse(t *testing.T) {
gin.SetMode(gin.TestMode)