Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"
Expand All @@ -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}})
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
179 changes: 178 additions & 1 deletion pkg/gin-openapi-validator/validationerror.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package ginopenapivalidator
// Original license is MIT by the authors of kin-openapi

import (
"errors"
"fmt"
"net/http"
"strings"
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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+"="))
}
Expand Down
Loading
Loading