diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfb4946..c4574b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,21 +9,18 @@ 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 ./... - name: Lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: - version: latest + version: v2.11.3 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/validationerror.go b/pkg/gin-openapi-validator/validationerror.go index ba7758a..d59722b 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,14 +17,186 @@ 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{ Status: http.StatusNotFound, Title: "not found", } + return cErr, nil } @@ -158,11 +331,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 } @@ -183,10 +358,12 @@ 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, ",") + 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..efc7e52 100644 --- a/pkg/gin-openapi-validator/validator.go +++ b/pkg/gin-openapi-validator/validator.go @@ -2,63 +2,239 @@ 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 + validationBody *bytes.Buffer + statusCode int + headers http.Header + wroteHeader bool + flushed bool + rewriteBody bool +} + +func (w *responseBodyWriter) prepareRewrite() { + if !w.rewriteBody || w.flushed { + return + } + + w.body.Reset() + w.validationBody.Reset() + w.headers.Del("Content-Length") + w.rewriteBody = false } func (w *responseBodyWriter) Write(b []byte) (int, error) { - n, err := w.ResponseWriter.Write(b) - if err == nil { - w.body.Write(b[:n]) + w.prepareRewrite() + + if !w.wroteHeader { + w.WriteHeaderNow() + } + + n, err := w.body.Write(b) + if err != nil { + return n, err + } + + _, _ = w.validationBody.Write(b) + + return n, nil +} + +func (w *responseBodyWriter) WriteString(s string) (int, error) { + w.prepareRewrite() + + if !w.wroteHeader { + w.WriteHeaderNow() } - return n, err + 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) { + w.prepareRewrite() + 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) flush() { - for k, vv := range w.headers { - for _, v := range vv { - w.ResponseWriter.Header().Add(k, v) - } +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) committed() bool { + return w.flushed +} + +func (w *responseBodyWriter) Flush() { + w.flush() + w.body.Reset() + + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +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.Write(w.body.Bytes()) +} + +func newResponseBodyWriter(writer gin.ResponseWriter) *responseBodyWriter { + return &responseBodyWriter{ + ResponseWriter: writer, + body: &bytes.Buffer{}, + validationBody: &bytes.Buffer{}, + headers: cloneHeader(writer.Header()), + statusCode: http.StatusOK, + } +} + +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()) + } + + if source.validationBody.Len() > 0 { + _, _ = cloned.validationBody.Write(source.validationBody.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 + } + } + } - w.ResponseWriter.WriteHeader(w.statusCode) - w.ResponseWriter.Write(w.body.Bytes()) + return true +} + +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. + // 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) } // Validator returns an OpenAPI validation middleware for Gin. @@ -71,6 +247,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 +260,122 @@ 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 - } + requestErrorHandler(c, newContractError(ValidationPhaseRequest, err)) + + if !c.IsAborted() { + c.Abort() + } - requestValidationInput := &openapi3filter.RequestValidationInput{ - Request: c.Request, - PathParams: pathParams, - Route: route, - } - if err = openapi3filter.ValidateRequest(c.Request.Context(), requestValidationInput); err != nil { - abortForValidationError(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, newContractError(ValidationPhaseResponse, 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.validationBody.Len() > 0 { + responseValidationInput.SetBodyBytes(writer.validationBody.Bytes()) + } + + return openapi3filter.ValidateResponse(c.Request.Context(), responseValidationInput) +} + +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) + + c.Writer = handlerWriter + options.ResponseErrorHandler(c, err) + + if responseBodyWriterChanged(before, handlerWriter) { + 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 +383,47 @@ 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 { + logResponseValidationError(c, options.Logger, err) + + return + } + + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "Internal Server Error", + "detail": "Response body does not conform to the OpenAPI specification", + }) + } +} + +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 { + 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..cc4e90e 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() +var spec []byte - hook = test.NewGlobal() +func newRouter(opts ...ginopenapivalidator.ValidatorOptions) *gin.Engine { + gin.SetMode(gin.TestMode) - code := m.Run() - os.Exit(code) -} - -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) - resp := request(req) + return recorder +} + +func TestStatusOk(t *testing.T) { + router := newRouter() + + 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,369 @@ 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) { + 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()}) + }, + }) + + 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") + 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"}) + }, + }) + + 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()) +} + +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 + + 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") + }, + }) + + 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, 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()) +} + +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 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, + 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"`)) +} + +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) + + 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") +}