diff --git a/README.md b/README.md
index f2c20050..cf758339 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,9 @@
[](https://discord.gg/x7VACVuEGP)
[](https://pkg.go.dev/github.com/pb33f/libopenapi-validator)
-A validation module for [libopenapi](https://github.com/pb33f/libopenapi).
+A validation module for [libopenapi](https://github.com/pb33f/libopenapi).
+
+Document validation is explicitly covered for OpenAPI 3.0, 3.1, and 3.2.
`libopenapi-validator` will validate the following elements against an OpenAPI 3+ specification
@@ -18,7 +20,24 @@ A validation module for [libopenapi](https://github.com/pb33f/libopenapi).
- *libopenapi.Document* - Validates the OpenAPI document against the OpenAPI specification
- *base.Schema* - Validates a schema against a JSON or YAML blob / unmarshalled object
-👉👉 [Check out the full documentation](https://pb33f.io/libopenapi/validation/) 👈👈
+👉👉 [Check out the full documentation](https://pb33f.io/libopenapi/validation/) 👈👈
+
+## Runtime validation features
+
+The validator now includes a standalone OpenAPI router, authentication callbacks with route context and replayable bodies, immutable custom body codecs, opt-in request defaults, `Parameter.content` decoding, validation policy switches, and explicit OpenAPI 3.2 document validation.
+
+Existing behavior remains the default. Strict server matching, non-JSON codecs, request mutation, and new rejection policies are opt-in:
+
+```go
+v := validator.NewValidatorFromV3Model(&model.Model,
+ config.WithStrictServerMatching(),
+ config.WithStandardBodyDecoders(),
+ config.WithRejectUnsupportedBodyContent(),
+ config.WithRequestDefaults(),
+)
+```
+
+Use `router.NewRouter` for routing without validation, `WithBodyDecoder` and `WithBodyEncoder` for proprietary formats, and `WithAuthenticationFunc` for application-owned security checks. See the [complete validation guide](https://pb33f.io/libopenapi/validation/) for defaults, options, and examples.
---
diff --git a/config/config.go b/config/config.go
index c38657e8..806774f3 100644
--- a/config/config.go
+++ b/config/config.go
@@ -8,11 +8,15 @@ import (
"log/slog"
"net/http"
- v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
"github.com/santhosh-tekuri/jsonschema/v6"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
"github.com/pb33f/libopenapi-validator/cache"
+ "github.com/pb33f/libopenapi-validator/content"
"github.com/pb33f/libopenapi-validator/radix"
+ "github.com/pb33f/libopenapi-validator/router"
)
// RegexCache can be set to enable compiled regex caching.
@@ -24,38 +28,77 @@ type RegexCache interface {
Store(key, value any) // Set a compiled regex to the cache
}
-// AuthenticationFunc validates a security scheme for an HTTP request.
-// Return nil when the scheme is satisfied; return an error to fail the current security requirement.
+// AuthenticationFunc validates one security scheme for an HTTP request.
+// Return nil when the scheme is satisfied; return an error to fail the current
+// security requirement. Each invocation receives a fresh replayable body reader.
type AuthenticationFunc func(context.Context, *AuthenticationInput) error
// AuthenticationInput contains the request and OpenAPI security scheme details passed to an AuthenticationFunc.
type AuthenticationInput struct {
- Request *http.Request
- SecuritySchemeName string
- SecurityScheme *v3.SecurityScheme
- Scopes []string
+ Request *http.Request // Request is the request being authenticated.
+ SecuritySchemeName string // SecuritySchemeName is the component key from the requirement.
+ SecurityScheme *v3.SecurityScheme // SecurityScheme is the resolved OpenAPI scheme.
+ Scopes []string // Scopes contains the scopes required by this requirement.
+ Path string // Path is the matched OpenAPI path template.
+ PathItem *v3.PathItem // PathItem is the matched OpenAPI path item.
+ Operation *v3.Operation // Operation is the matched OpenAPI operation.
+ PathParams map[string]string // PathParams contains decoded operation path parameters.
+ Server *v3.Server // Server is the effective matched server, if explicit.
+ ServerParams map[string]string // ServerParams contains decoded server variables.
+}
+
+// ContentParameterDecoder decodes an OpenAPI Parameter.content value and may select a schema.
+type ContentParameterDecoder func(context.Context, *ContentParameterInput) (value any, schema *base.Schema, err error)
+
+// ContentParameterInput contains raw parameter values and matched route context.
+type ContentParameterInput struct {
+ Parameter *v3.Parameter // Parameter is the OpenAPI parameter being decoded.
+ RawValues []string // RawValues contains all query values or the single non-query value.
+ MediaType string // MediaType is the declared Parameter.content media type.
+ DefaultSchema *base.Schema // DefaultSchema is the schema declared by the selected media type.
+ Request *http.Request // Request is the request being validated.
+ PathParams map[string]string // PathParams contains decoded operation path parameters.
+ ServerVariables map[string]string // ServerVariables contains decoded server variables.
}
// ValidationOptions A container for validation configuration.
//
// Generally fluent With... style functions are used to establish the desired behavior.
type ValidationOptions struct {
- RegexEngine jsonschema.RegexpEngine
- RegexCache RegexCache // Enable compiled regex caching
- FormatAssertions bool
- ContentAssertions bool
- SecurityValidation bool
- AuthenticationFunc AuthenticationFunc
+ RegexEngine jsonschema.RegexpEngine
+ RegexCache RegexCache // Enable compiled regex caching
+ FormatAssertions bool
+ ContentAssertions bool
+ SecurityValidation bool
+ AuthenticationFunc AuthenticationFunc
+ // ContentParameterDecoder replaces built-in Parameter.content decoding when non-nil.
+ ContentParameterDecoder ContentParameterDecoder
+ // ValidateContentParameters enables built-in JSON decoding for path, header, and cookie Parameter.content values.
+ // Existing query Parameter.content behavior remains enabled independently.
+ ValidateContentParameters bool
OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation
AllowScalarCoercion bool // Enable string->boolean/number coercion
Formats map[string]func(v any) error
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
SchemaResourceCache cache.SchemaResourceCache // Optional cache for rendered document-level schema resources
PathTree radix.PathLookup // O(k) path lookup via radix tree (built automatically)
+ Router router.Router // Shared immutable request router, when constructed by the high-level validator.
pathTreeDisabled bool // Internal: true if radix tree auto-build was disabled via DisablePathTree
Logger *slog.Logger // Logger for debug/error output (nil = silent)
AllowXMLBodyValidation bool // Allows to convert XML to JSON for validating a request/response body.
AllowURLEncodedBodyValidation bool // Allows to convert URL Encoded to JSON for validating a request/response body.
+ BodyRegistry *content.Registry // BodyRegistry is the frozen per-validator body codec registry.
+ RejectUnsupportedBodyContent bool // RejectUnsupportedBodyContent rejects declared media types without a decoder.
+ RejectUndeclaredRequestBody bool // RejectUndeclaredRequestBody rejects bodies on operations without requestBody.
+ ValidateRequestQuery bool // ValidateRequestQuery controls high-level query validation.
+ ValidateRequestBody bool // ValidateRequestBody controls high-level request-body validation.
+ ValidateResponseBody bool // ValidateResponseBody controls high-level response-body validation.
+ ValidateResponseStatus bool // ValidateResponseStatus rejects undocumented response status codes.
+ RequestDefaults bool // RequestDefaults stages and atomically applies request defaults.
+ StrictServerMatching bool // StrictServerMatching matches scheme, host, port, base path, and server variables.
+ bodyDecoders []content.Registration
+ bodyEncoders []content.EncoderRegistration
+ borrowedState bool
// strict mode options - detect undeclared properties even when additionalProperties: true
StrictMode bool // Enable strict property validation
@@ -73,12 +116,24 @@ type Option func(*ValidationOptions)
func NewValidationOptions(opts ...Option) *ValidationOptions {
// create the set of default values
o := &ValidationOptions{
- FormatAssertions: false,
- ContentAssertions: false,
- SecurityValidation: true,
- OpenAPIMode: true, // Enable OpenAPI vocabulary by default
- SchemaCache: cache.NewDefaultCache(), // Enable compiled schema caching by default
- SchemaResourceCache: cache.NewDefaultSchemaResourceCache(), // Enable rendered resource caching by default
+ FormatAssertions: false,
+ ContentAssertions: false,
+ SecurityValidation: true,
+ OpenAPIMode: true, // Enable OpenAPI vocabulary by default
+ ValidateRequestQuery: true,
+ ValidateRequestBody: true,
+ ValidateResponseBody: true,
+ ValidateResponseStatus: true,
+ SchemaCache: cache.NewDefaultCache(), // Enable compiled schema caching by default
+ SchemaResourceCache: cache.NewDefaultSchemaResourceCache(), // Enable rendered resource caching by default
+ bodyDecoders: []content.Registration{
+ {MediaType: "application/json", Decoder: content.JSONDecoder()},
+ {MediaType: "application/*+json", Decoder: content.JSONDecoder()},
+ },
+ bodyEncoders: []content.EncoderRegistration{
+ {MediaType: "application/json", Encoder: content.JSONEncoder()},
+ {MediaType: "application/*+json", Encoder: content.JSONEncoder()},
+ },
}
for _, opt := range opts {
@@ -86,6 +141,11 @@ func NewValidationOptions(opts ...Option) *ValidationOptions {
opt(o)
}
}
+ if o.BodyRegistry == nil {
+ o.BodyRegistry = content.NewRegistry(o.bodyDecoders, o.bodyEncoders)
+ }
+ o.bodyDecoders = nil
+ o.bodyEncoders = nil
return o
}
@@ -95,20 +155,29 @@ func (o *ValidationOptions) Release() {
if o == nil {
return
}
- releaseIfSupported(o.SchemaCache)
- releaseIfSupported(o.SchemaResourceCache)
- releaseIfSupported(o.PathTree)
+ if !o.borrowedState {
+ releaseIfSupported(o.SchemaCache)
+ releaseIfSupported(o.SchemaResourceCache)
+ releaseIfSupported(o.PathTree)
+ }
o.RegexEngine = nil
o.RegexCache = nil
o.AuthenticationFunc = nil
+ o.ContentParameterDecoder = nil
+ o.ValidateContentParameters = false
o.Formats = nil
o.SchemaCache = nil
o.SchemaResourceCache = nil
o.PathTree = nil
+ o.Router = nil
o.Logger = nil
o.StrictIgnorePaths = nil
o.StrictIgnoredHeaders = nil
+ o.BodyRegistry = nil
+ o.bodyDecoders = nil
+ o.bodyEncoders = nil
+ o.borrowedState = false
}
type releaser interface {
@@ -125,22 +194,35 @@ func releaseIfSupported(value any) {
func WithExistingOpts(options *ValidationOptions) Option {
return func(o *ValidationOptions) {
if options != nil {
+ o.borrowedState = true
o.RegexEngine = options.RegexEngine
o.RegexCache = options.RegexCache
o.FormatAssertions = options.FormatAssertions
o.ContentAssertions = options.ContentAssertions
o.SecurityValidation = options.SecurityValidation
o.AuthenticationFunc = options.AuthenticationFunc
+ o.ContentParameterDecoder = options.ContentParameterDecoder
+ o.ValidateContentParameters = options.ValidateContentParameters
o.OpenAPIMode = options.OpenAPIMode
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.SchemaResourceCache = options.SchemaResourceCache
o.PathTree = options.PathTree
+ o.Router = options.Router
o.pathTreeDisabled = options.pathTreeDisabled
o.Logger = options.Logger
o.AllowXMLBodyValidation = options.AllowXMLBodyValidation
o.AllowURLEncodedBodyValidation = options.AllowURLEncodedBodyValidation
+ o.BodyRegistry = options.BodyRegistry
+ o.RejectUnsupportedBodyContent = options.RejectUnsupportedBodyContent
+ o.RejectUndeclaredRequestBody = options.RejectUndeclaredRequestBody
+ o.ValidateRequestQuery = options.ValidateRequestQuery
+ o.ValidateRequestBody = options.ValidateRequestBody
+ o.ValidateResponseBody = options.ValidateResponseBody
+ o.ValidateResponseStatus = options.ValidateResponseStatus
+ o.RequestDefaults = options.RequestDefaults
+ o.StrictServerMatching = options.StrictServerMatching
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
@@ -203,6 +285,20 @@ func WithAuthenticationFunc(fn AuthenticationFunc) Option {
}
}
+// WithContentParameterDecoder enables Parameter.content validation with a custom per-validator decoder.
+func WithContentParameterDecoder(decoder ContentParameterDecoder) Option {
+ return func(o *ValidationOptions) {
+ o.ContentParameterDecoder = decoder
+ o.ValidateContentParameters = decoder != nil
+ }
+}
+
+// WithContentParameterValidation enables built-in JSON Parameter.content validation for path, header, and cookie parameters.
+// Existing query Parameter.content behavior remains enabled independently for compatibility.
+func WithContentParameterValidation() Option {
+ return func(o *ValidationOptions) { o.ValidateContentParameters = true }
+}
+
// WithCustomFormat adds custom formats and their validators that checks for custom 'format' assertions
// When you add different validators with the same name, they will be overridden,
// and only the last registration will take effect.
@@ -242,6 +338,10 @@ func WithScalarCoercion() Option {
func WithXmlBodyValidation() Option {
return func(o *ValidationOptions) {
o.AllowXMLBodyValidation = true
+ o.bodyDecoders = append(o.bodyDecoders,
+ content.Registration{MediaType: "application/xml", Decoder: content.XMLCompatibilityDecoder()},
+ content.Registration{MediaType: "text/xml", Decoder: content.XMLCompatibilityDecoder()},
+ )
}
}
@@ -250,9 +350,89 @@ func WithXmlBodyValidation() Option {
func WithURLEncodedBodyValidation() Option {
return func(o *ValidationOptions) {
o.AllowURLEncodedBodyValidation = true
+ o.bodyDecoders = append(o.bodyDecoders, content.Registration{
+ MediaType: "application/x-www-form-urlencoded", Decoder: content.FormCompatibilityDecoder(),
+ })
}
}
+// WithBodyDecoder registers a per-validator body decoder. Later exact registrations win.
+func WithBodyDecoder(mediaType string, decoder content.Decoder) Option {
+ return func(o *ValidationOptions) {
+ o.BodyRegistry = nil
+ o.bodyDecoders = append(o.bodyDecoders, content.Registration{MediaType: mediaType, Decoder: decoder})
+ }
+}
+
+// WithBodyEncoder registers a per-validator body encoder. Later exact registrations win.
+func WithBodyEncoder(mediaType string, encoder content.Encoder) Option {
+ return func(o *ValidationOptions) {
+ o.BodyRegistry = nil
+ o.bodyEncoders = append(o.bodyEncoders, content.EncoderRegistration{MediaType: mediaType, Encoder: encoder})
+ }
+}
+
+// WithStandardBodyDecoders enables YAML, generic XML, URL-encoded forms,
+// multipart forms, plain text, CSV, and binary codecs. ZIP remains separately
+// opt-in through WithZipBodyDecoder.
+func WithStandardBodyDecoders() Option {
+ return func(o *ValidationOptions) {
+ o.BodyRegistry = nil
+ o.bodyDecoders = append(o.bodyDecoders, content.StandardDecoderRegistrations()...)
+ }
+}
+
+// WithZipBodyDecoder enables ZIP validation bounded by limits.
+func WithZipBodyDecoder(limits content.ZipLimits) Option {
+ return func(o *ValidationOptions) {
+ o.BodyRegistry = nil
+ o.bodyDecoders = append(o.bodyDecoders, content.Registration{
+ MediaType: "application/zip", Decoder: content.ZIPDecoder(limits),
+ })
+ }
+}
+
+// WithRejectUnsupportedBodyContent rejects declared body media types without a decoder.
+func WithRejectUnsupportedBodyContent() Option {
+ return func(o *ValidationOptions) { o.RejectUnsupportedBodyContent = true }
+}
+
+// WithRejectUndeclaredRequestBody rejects non-empty bodies on operations without requestBody.
+func WithRejectUndeclaredRequestBody() Option {
+ return func(o *ValidationOptions) { o.RejectUndeclaredRequestBody = true }
+}
+
+// WithoutRequestQueryParameterValidation excludes query validation from high-level request validation.
+func WithoutRequestQueryParameterValidation() Option {
+ return func(o *ValidationOptions) { o.ValidateRequestQuery = false }
+}
+
+// WithoutRequestBodyValidation excludes all request-body policy, defaults, decoding, and validation.
+func WithoutRequestBodyValidation() Option {
+ return func(o *ValidationOptions) { o.ValidateRequestBody = false }
+}
+
+// WithoutResponseBodyValidation excludes response content and schema checks while retaining status and headers.
+func WithoutResponseBodyValidation() Option {
+ return func(o *ValidationOptions) { o.ValidateResponseBody = false }
+}
+
+// WithoutResponseStatusValidation allows undocumented response status codes.
+func WithoutResponseStatusValidation() Option {
+ return func(o *ValidationOptions) { o.ValidateResponseStatus = false }
+}
+
+// WithRequestDefaults stages query, header, cookie, and request-body defaults
+// and commits them atomically after successful decoding, encoding, and validation.
+func WithRequestDefaults() Option {
+ return func(o *ValidationOptions) { o.RequestDefaults = true }
+}
+
+// WithStrictServerMatching enables standalone-router server semantics in high-level validation.
+func WithStrictServerMatching() Option {
+ return func(o *ValidationOptions) { o.StrictServerMatching = true }
+}
+
// WithSchemaCache sets a custom cache implementation or disables caching if nil.
// Pass nil to disable schema caching and skip cache warming during validator initialization.
// The default cache is a thread-safe sync.Map wrapper.
diff --git a/config/config_test.go b/config/config_test.go
index 9d0096b5..b9cfc728 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -9,11 +9,16 @@ import (
"sync"
"testing"
- validatorcache "github.com/pb33f/libopenapi-validator/cache"
- "github.com/pb33f/libopenapi-validator/radix"
- v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
"github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
"github.com/santhosh-tekuri/jsonschema/v6"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ validatorcache "github.com/pb33f/libopenapi-validator/cache"
+ "github.com/pb33f/libopenapi-validator/content"
+ "github.com/pb33f/libopenapi-validator/radix"
)
func TestNewValidationOptions_Defaults(t *testing.T) {
@@ -27,6 +32,7 @@ func TestNewValidationOptions_Defaults(t *testing.T) {
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
assert.False(t, opts.AllowURLEncodedBodyValidation) // Default is false
+ assert.False(t, opts.ValidateContentParameters)
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.SchemaCache)
@@ -368,6 +374,124 @@ func TestWithExistingOpts_AuthenticationFuncCopied(t *testing.T) {
assert.True(t, called)
}
+func TestBodyCodecAndParityOptions(t *testing.T) {
+ decoder := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return "custom", nil })
+ encoder := content.EncoderFunc(func(*content.EncodeInput) ([]byte, error) { return []byte("custom"), nil })
+ zipLimits := content.ZipLimits{CompressedSize: 1024, ExpandedSize: 2048, Entries: 4, ExpansionRatio: 10}
+ opts := NewValidationOptions(
+ WithBodyDecoder("application/custom", decoder),
+ WithBodyEncoder("application/custom", encoder),
+ WithStandardBodyDecoders(),
+ WithZipBodyDecoder(zipLimits),
+ WithRejectUnsupportedBodyContent(),
+ WithRejectUndeclaredRequestBody(),
+ WithoutRequestQueryParameterValidation(),
+ WithoutRequestBodyValidation(),
+ WithoutResponseBodyValidation(),
+ WithoutResponseStatusValidation(),
+ WithRequestDefaults(),
+ WithStrictServerMatching(),
+ )
+ assert.True(t, opts.RejectUnsupportedBodyContent)
+ assert.True(t, opts.RejectUndeclaredRequestBody)
+ assert.False(t, opts.ValidateRequestQuery)
+ assert.False(t, opts.ValidateRequestBody)
+ assert.False(t, opts.ValidateResponseBody)
+ assert.False(t, opts.ValidateResponseStatus)
+ assert.True(t, opts.RequestDefaults)
+ assert.True(t, opts.StrictServerMatching)
+ resolved, _, _ := opts.BodyRegistry.Decoder("application/custom")
+ require.NotNil(t, resolved)
+ value, err := resolved.Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "custom", value)
+ resolvedEncoder, _, _ := opts.BodyRegistry.Encoder("application/custom")
+ require.NotNil(t, resolvedEncoder)
+ encoded, err := resolvedEncoder.Encode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "custom", string(encoded))
+ for _, mediaType := range []string{"application/yaml", "application/xml", "multipart/form-data", "text/plain", "application/zip"} {
+ resolved, _, _ = opts.BodyRegistry.Decoder(mediaType)
+ assert.NotNil(t, resolved, mediaType)
+ }
+}
+
+func TestCompatibilityCodecAliasesAndContentParameterDecoder(t *testing.T) {
+ parameterDecoder := func(context.Context, *ContentParameterInput) (any, *base.Schema, error) {
+ return "value", nil, nil
+ }
+ opts := NewValidationOptions(WithXmlBodyValidation(), WithURLEncodedBodyValidation(), WithContentParameterDecoder(parameterDecoder))
+ assert.True(t, opts.AllowXMLBodyValidation)
+ assert.True(t, opts.AllowURLEncodedBodyValidation)
+ assert.NotNil(t, opts.ContentParameterDecoder)
+ assert.True(t, opts.ValidateContentParameters)
+ for _, mediaType := range []string{"application/xml", "text/xml", "application/x-www-form-urlencoded"} {
+ decoder, _, _ := opts.BodyRegistry.Decoder(mediaType)
+ assert.NotNil(t, decoder, mediaType)
+ }
+}
+
+func TestContentParameterValidationOptionsAndDecoderPrecedence(t *testing.T) {
+ opts := NewValidationOptions(WithContentParameterValidation())
+ assert.True(t, opts.ValidateContentParameters)
+ assert.Nil(t, opts.ContentParameterDecoder)
+ opts = NewValidationOptions(WithContentParameterDecoder(nil))
+ assert.False(t, opts.ValidateContentParameters)
+
+ opts = NewValidationOptions(WithXmlBodyValidation(), WithStandardBodyDecoders())
+ _, genericWins := opts.BodyRegistry.ExactDecoder("application/xml").(content.CompatibilityDecoder)
+ assert.False(t, genericWins)
+ opts = NewValidationOptions(WithStandardBodyDecoders(), WithXmlBodyValidation())
+ marker, compatibilityWins := opts.BodyRegistry.ExactDecoder("application/xml").(content.CompatibilityDecoder)
+ require.True(t, compatibilityWins)
+ assert.Equal(t, "xml", marker.CompatibilityKind())
+}
+
+func TestWithExistingOptsCopiesParityStateAndRegistry(t *testing.T) {
+ decoder := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return true, nil })
+ parameterDecoder := func(context.Context, *ContentParameterInput) (any, *base.Schema, error) { return nil, nil, nil }
+ original := NewValidationOptions(
+ WithBodyDecoder("application/custom", decoder), WithRejectUnsupportedBodyContent(),
+ WithRejectUndeclaredRequestBody(), WithoutRequestQueryParameterValidation(), WithoutRequestBodyValidation(),
+ WithoutResponseBodyValidation(), WithoutResponseStatusValidation(), WithRequestDefaults(),
+ WithContentParameterDecoder(parameterDecoder),
+ WithStrictServerMatching(),
+ )
+ copied := NewValidationOptions(WithExistingOpts(original))
+ assert.Same(t, original.BodyRegistry, copied.BodyRegistry)
+ assert.Equal(t, original.RejectUnsupportedBodyContent, copied.RejectUnsupportedBodyContent)
+ assert.Equal(t, original.RejectUndeclaredRequestBody, copied.RejectUndeclaredRequestBody)
+ assert.Equal(t, original.ValidateRequestQuery, copied.ValidateRequestQuery)
+ assert.Equal(t, original.ValidateRequestBody, copied.ValidateRequestBody)
+ assert.Equal(t, original.ValidateResponseBody, copied.ValidateResponseBody)
+ assert.Equal(t, original.ValidateResponseStatus, copied.ValidateResponseStatus)
+ assert.Equal(t, original.RequestDefaults, copied.RequestDefaults)
+ assert.Equal(t, original.StrictServerMatching, copied.StrictServerMatching)
+ assert.NotNil(t, copied.ContentParameterDecoder)
+ assert.True(t, copied.ValidateContentParameters)
+ original.Release()
+ assert.Nil(t, original.BodyRegistry)
+ assert.Nil(t, original.ContentParameterDecoder)
+ assert.False(t, original.ValidateContentParameters)
+}
+
+func TestWithExistingOptsBorrowsSharedReleaseState(t *testing.T) {
+ schemaCache := validatorcache.NewDefaultCache()
+ schemaCache.Store(1, &validatorcache.SchemaCacheEntry{RenderedInline: []byte("schema")})
+ pathTree := radix.NewPathTree()
+ pathTree.Insert("/pets", &v3.PathItem{})
+ original := NewValidationOptions(WithSchemaCache(schemaCache), WithPathTree(pathTree))
+ borrowed := NewValidationOptions(WithExistingOpts(original))
+ borrowed.Release()
+ _, found := schemaCache.Load(1)
+ assert.True(t, found)
+ assert.Equal(t, 1, pathTree.Size())
+ original.Release()
+ _, found = schemaCache.Load(1)
+ assert.False(t, found)
+ assert.Equal(t, 0, pathTree.Size())
+}
+
// Tests for new OpenAPI and scalar coercion configuration options
func TestWithOpenAPIMode(t *testing.T) {
diff --git a/content/content.go b/content/content.go
new file mode 100644
index 00000000..e657f48e
--- /dev/null
+++ b/content/content.go
@@ -0,0 +1,714 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+// Package content provides immutable per-validator HTTP body codec registries.
+//
+// Decoders convert request and response bodies into the JSON-compatible values
+// consumed by schema validation. Encoders are used only when opt-in request
+// default application rewrites a body. Registry lookup precedence is exact
+// media type, structured suffix, type wildcard, then global wildcard.
+package content
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/csv"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "go.yaml.in/yaml/v4"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+)
+
+// Direction identifies whether a codec is processing request or response data.
+type Direction uint8
+
+const (
+ // Request identifies request-body processing.
+ Request Direction = iota
+ // Response identifies response-body processing.
+ Response
+)
+
+// DecodeInput contains the complete body-decoding context supplied by a validator.
+type DecodeInput struct {
+ Context context.Context // Context is the request context.
+ Body io.Reader // Body is a fresh reader over the immutable body snapshot.
+ Header http.Header // Header contains the request or response headers.
+ // MediaType is the normalized media type without parameters.
+ MediaType string
+ // Parameters contains parsed media-type parameters such as charset or boundary.
+ Parameters map[string]string
+ Schema *base.Schema // Schema is the OpenAPI media-type schema.
+ // Encoding contains OpenAPI per-property encoding metadata when available.
+ Encoding interface{ GetOrZero(string) *v3.Encoding }
+ Direction Direction // Direction identifies request or response processing.
+}
+
+// EncodeInput contains the complete body-encoding context.
+type EncodeInput struct {
+ Context context.Context // Context is the request context.
+ Value any // Value is the canonical decoded value to encode.
+ Header http.Header // Header contains the staged request headers.
+ MediaType string // MediaType is the normalized media type.
+ Schema *base.Schema // Schema is the OpenAPI media-type schema.
+ // Encoding contains OpenAPI per-property encoding metadata when available.
+ Encoding interface{ GetOrZero(string) *v3.Encoding }
+ Direction Direction // Direction identifies request or response processing.
+}
+
+// FailureContext retains HTTP and OpenAPI objects associated with a decoding or rewrite failure.
+type FailureContext struct {
+ Request *http.Request // Request is the request being validated.
+ Response *http.Response // Response is populated for response failures.
+ Operation *v3.Operation // Operation is the matched OpenAPI operation.
+ MediaType *v3.MediaType // MediaType is the selected OpenAPI media-type definition.
+ Schema *base.Schema // Schema is the selected body schema.
+}
+
+// Decoder converts an HTTP entity into a JSON-compatible validation value.
+type Decoder interface {
+ // Decode returns a JSON-compatible validation value.
+ Decode(*DecodeInput) (any, error)
+}
+
+// DecoderFunc adapts a function to Decoder.
+type DecoderFunc func(*DecodeInput) (any, error)
+
+// Decode calls f with input.
+func (f DecoderFunc) Decode(input *DecodeInput) (any, error) { return f(input) }
+
+// Encoder converts a validation value back into an HTTP entity.
+type Encoder interface {
+ // Encode serializes a canonical validation value into an HTTP body.
+ Encode(*EncodeInput) ([]byte, error)
+}
+
+// EncoderFunc adapts a function to Encoder.
+type EncoderFunc func(*EncodeInput) ([]byte, error)
+
+// Encode calls f with input.
+func (f EncoderFunc) Encode(input *EncodeInput) ([]byte, error) { return f(input) }
+
+// Registration associates a normalized media-range with a decoder.
+type Registration struct {
+ MediaType string // MediaType is an exact type or wildcard media range.
+ Decoder Decoder // Decoder handles values selected by MediaType.
+}
+
+// EncoderRegistration associates a normalized media-range with an encoder.
+type EncoderRegistration struct {
+ MediaType string // MediaType is an exact type or wildcard media range.
+ Encoder Encoder // Encoder handles values selected by MediaType.
+}
+
+// Registry is an immutable, concurrency-safe, lock-free codec lookup table.
+// Construct registries with NewRegistry and derive replacements with WithDecoder.
+type Registry struct {
+ decoders map[string]Decoder
+ encoders map[string]Encoder
+}
+
+// CompatibilityDecoder marks a decoder placeholder that is replaced by a schema-aware validator adapter.
+type CompatibilityDecoder interface {
+ Decoder
+ // CompatibilityKind identifies the legacy adapter that must replace this marker.
+ CompatibilityKind() string
+}
+
+type compatibilityDecoder string
+
+func (d compatibilityDecoder) Decode(*DecodeInput) (any, error) {
+ return nil, fmt.Errorf("%s compatibility decoder was not initialized", d)
+}
+
+func (d compatibilityDecoder) CompatibilityKind() string { return string(d) }
+
+// XMLCompatibilityDecoder returns the marker used by the legacy XML validation option.
+func XMLCompatibilityDecoder() Decoder { return compatibilityDecoder("xml") }
+
+// FormCompatibilityDecoder returns the marker used by the legacy form validation option.
+func FormCompatibilityDecoder() Decoder { return compatibilityDecoder("form") }
+
+// Precompute returns a new immutable registry with resolved exact dispatch entries for declared media types.
+func (r *Registry) Precompute(mediaTypes []string) *Registry {
+ if r == nil {
+ return nil
+ }
+ resolved := &Registry{decoders: make(map[string]Decoder, len(r.decoders)+len(mediaTypes)), encoders: make(map[string]Encoder, len(r.encoders)+len(mediaTypes))}
+ for mediaType, decoder := range r.decoders {
+ resolved.decoders[mediaType] = decoder
+ }
+ for mediaType, encoder := range r.encoders {
+ resolved.encoders[mediaType] = encoder
+ }
+ for _, declared := range mediaTypes {
+ normalized, _ := NormalizeMediaType(declared)
+ if normalized == "" {
+ continue
+ }
+ if _, exists := resolved.decoders[normalized]; !exists {
+ if decoder, _, _ := r.Decoder(normalized); decoder != nil {
+ resolved.decoders[normalized] = decoder
+ }
+ }
+ if _, exists := resolved.encoders[normalized]; !exists {
+ if encoder, _, _ := r.Encoder(normalized); encoder != nil {
+ resolved.encoders[normalized] = encoder
+ }
+ }
+ }
+ return resolved
+}
+
+// ExactDecoder returns only an exact normalized registration without wildcard resolution.
+func (r *Registry) ExactDecoder(mediaType string) Decoder {
+ if r == nil {
+ return nil
+ }
+ normalized, _ := NormalizeMediaType(mediaType)
+ return r.decoders[normalized]
+}
+
+// WithDecoder returns a new immutable registry with one exact decoder replaced.
+func (r *Registry) WithDecoder(mediaType string, decoder Decoder) *Registry {
+ if r == nil {
+ return NewRegistry([]Registration{{MediaType: mediaType, Decoder: decoder}}, nil)
+ }
+ resolved := r.Precompute(nil)
+ key := normalizeRange(mediaType)
+ if key != "" && decoder != nil {
+ resolved.decoders[key] = decoder
+ }
+ return resolved
+}
+
+// NewRegistry freezes codec registrations. Later duplicate exact registrations win.
+func NewRegistry(decoders []Registration, encoders []EncoderRegistration) *Registry {
+ r := &Registry{decoders: make(map[string]Decoder, len(decoders)), encoders: make(map[string]Encoder, len(encoders))}
+ for _, registration := range decoders {
+ if key := normalizeRange(registration.MediaType); key != "" && registration.Decoder != nil {
+ r.decoders[key] = registration.Decoder
+ }
+ }
+ for _, registration := range encoders {
+ if key := normalizeRange(registration.MediaType); key != "" && registration.Encoder != nil {
+ r.encoders[key] = registration.Encoder
+ }
+ }
+ return r
+}
+
+// Decoder resolves exact, structured-suffix, type-wildcard, then global-wildcard matches.
+func (r *Registry) Decoder(mediaType string) (Decoder, string, map[string]string) {
+ if r == nil {
+ return nil, "", nil
+ }
+ if decoder := r.decoders[mediaType]; decoder != nil {
+ return decoder, mediaType, nil
+ }
+ normalized, params := NormalizeMediaType(mediaType)
+ if normalized == "" {
+ return nil, "", params
+ }
+ if decoder := r.decoders[normalized]; decoder != nil {
+ return decoder, normalized, params
+ }
+ parts := strings.SplitN(normalized, "/", 2)
+ if len(parts) != 2 {
+ return nil, normalized, params
+ }
+ if plus := strings.LastIndexByte(parts[1], '+'); plus >= 0 {
+ if decoder := r.decoders[parts[0]+"/*"+parts[1][plus:]]; decoder != nil {
+ return decoder, normalized, params
+ }
+ }
+ if decoder := r.decoders[parts[0]+"/*"]; decoder != nil {
+ return decoder, normalized, params
+ }
+ return r.decoders["*/*"], normalized, params
+}
+
+// Encoder resolves an encoder with the same precedence as Decoder.
+func (r *Registry) Encoder(mediaType string) (Encoder, string, map[string]string) {
+ if r == nil {
+ return nil, "", nil
+ }
+ normalized, params := NormalizeMediaType(mediaType)
+ if encoder := r.encoders[normalized]; encoder != nil {
+ return encoder, normalized, params
+ }
+ parts := strings.SplitN(normalized, "/", 2)
+ if len(parts) == 2 {
+ if plus := strings.LastIndexByte(parts[1], '+'); plus >= 0 {
+ if encoder := r.encoders[parts[0]+"/*"+parts[1][plus:]]; encoder != nil {
+ return encoder, normalized, params
+ }
+ }
+ if encoder := r.encoders[parts[0]+"/*"]; encoder != nil {
+ return encoder, normalized, params
+ }
+ }
+ return r.encoders["*/*"], normalized, params
+}
+
+// NormalizeMediaType parses a media type and lowercases its lookup key.
+func NormalizeMediaType(value string) (string, map[string]string) {
+ mediaType, params, err := mime.ParseMediaType(value)
+ if err != nil && mediaType == "" {
+ return "", params
+ }
+ return strings.ToLower(mediaType), params
+}
+
+func normalizeRange(value string) string {
+ value = strings.TrimSpace(strings.ToLower(value))
+ if strings.Contains(value, ";") {
+ value, _ = NormalizeMediaType(value)
+ }
+ if strings.Count(value, "/") != 1 {
+ return ""
+ }
+ return value
+}
+
+// DecodingError retains codec context while exposing the decoder failure.
+type DecodingError struct {
+ MediaType string // MediaType is the normalized type selected for decoding.
+ Direction Direction // Direction identifies request or response decoding.
+ Err error // Err is the underlying decoder or canonicalization error.
+}
+
+// Error describes the media type, direction, and underlying decoder failure.
+func (e *DecodingError) Error() string {
+ if e == nil || e.Err == nil {
+ return "body decoding failed"
+ }
+ return fmt.Sprintf("decode %s body as %s: %v", directionName(e.Direction), e.MediaType, e.Err)
+}
+
+// Unwrap exposes the underlying decoder or canonicalization failure.
+func (e *DecodingError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Err
+}
+
+func directionName(direction Direction) string {
+ if direction == Response {
+ return "response"
+ }
+ return "request"
+}
+
+// Canonicalize recursively converts decoded values to the JSON validation data model.
+// It rejects maps with non-string keys and values outside the documented primitive,
+// []any, map[string]any, []byte, json.Number, and time.Time set.
+func Canonicalize(value any) (any, error) {
+ switch typed := value.(type) {
+ case map[string]any:
+ result := make(map[string]any, len(typed))
+ for key, child := range typed {
+ canonical, err := Canonicalize(child)
+ if err != nil {
+ return nil, err
+ }
+ result[key] = canonical
+ }
+ return result, nil
+ case map[any]any:
+ result := make(map[string]any, len(typed))
+ for key, child := range typed {
+ stringKey, ok := key.(string)
+ if !ok {
+ return nil, fmt.Errorf("mapping key %v is not a string", key)
+ }
+ canonical, err := Canonicalize(child)
+ if err != nil {
+ return nil, err
+ }
+ result[stringKey] = canonical
+ }
+ return result, nil
+ case []any:
+ result := make([]any, len(typed))
+ for i, child := range typed {
+ canonical, err := Canonicalize(child)
+ if err != nil {
+ return nil, err
+ }
+ result[i] = canonical
+ }
+ return result, nil
+ case int:
+ return float64(typed), nil
+ case int8:
+ return float64(typed), nil
+ case int16:
+ return float64(typed), nil
+ case int32:
+ return float64(typed), nil
+ case int64:
+ return float64(typed), nil
+ case uint:
+ return float64(typed), nil
+ case uint8:
+ return float64(typed), nil
+ case uint16:
+ return float64(typed), nil
+ case uint32:
+ return float64(typed), nil
+ case uint64:
+ return float64(typed), nil
+ case float32:
+ return float64(typed), nil
+ case json.Number:
+ return typed.Float64()
+ case time.Time:
+ return typed.Format(time.RFC3339Nano), nil
+ case nil, string, bool, float64, []byte:
+ return value, nil
+ default:
+ return nil, fmt.Errorf("decoded value of type %T is not JSON-compatible", value)
+ }
+}
+
+// JSONDecoder returns the compatibility JSON decoder.
+func JSONDecoder() Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ var value any
+ if input == nil || input.Body == nil {
+ return nil, nil
+ }
+ decoder := json.NewDecoder(input.Body)
+ if err := decoder.Decode(&value); err != nil {
+ if errors.Is(err, io.EOF) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
+ if err == nil {
+ return nil, errors.New("JSON body contains multiple values")
+ }
+ return nil, err
+ }
+ return value, nil
+ })
+}
+
+// JSONEncoder returns the standard JSON encoder.
+func JSONEncoder() Encoder {
+ return EncoderFunc(func(input *EncodeInput) ([]byte, error) {
+ if input == nil {
+ return nil, nil
+ }
+ return json.Marshal(input.Value)
+ })
+}
+
+// YAMLDecoder returns a YAML decoder with JSON-compatible canonicalization.
+func YAMLDecoder() Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ var value any
+ if input == nil || input.Body == nil {
+ return nil, nil
+ }
+ if err := yaml.NewDecoder(input.Body).Decode(&value); err != nil {
+ return nil, err
+ }
+ return Canonicalize(value)
+ })
+}
+
+// TextDecoder returns a decoder that preserves the body as a string.
+func TextDecoder() Decoder { return stringDecoder(false) }
+
+// CSVDecoder validates CSV syntax and preserves the original body as a string.
+func CSVDecoder() Decoder { return stringDecoder(true) }
+
+// BinaryDecoder returns a decoder for string/binary schemas.
+func BinaryDecoder() Decoder { return stringDecoder(false) }
+
+func stringDecoder(validateCSV bool) Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ if input == nil || input.Body == nil {
+ return "", nil
+ }
+ body, err := io.ReadAll(input.Body)
+ if err != nil {
+ return nil, err
+ }
+ if validateCSV {
+ if _, err = csv.NewReader(bytes.NewReader(body)).ReadAll(); err != nil {
+ return nil, err
+ }
+ }
+ return string(body), nil
+ })
+}
+
+// FormDecoder returns a URL-encoded form decoder.
+func FormDecoder() Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ if input == nil || input.Body == nil {
+ return map[string]any{}, nil
+ }
+ body, err := io.ReadAll(input.Body)
+ if err != nil {
+ return nil, err
+ }
+ values, err := url.ParseQuery(string(body))
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string]any, len(values))
+ for key, value := range values {
+ if len(value) == 1 {
+ result[key] = coerceString(value[0], propertySchema(input.Schema, key))
+ } else {
+ items := make([]any, len(value))
+ property := propertySchema(input.Schema, key)
+ for i, item := range value {
+ items[i] = coerceString(item, arrayItemSchema(property))
+ }
+ result[key] = items
+ }
+ }
+ return result, nil
+ })
+}
+
+// MultipartDecoder returns a form-data decoder using the boundary parameter.
+func MultipartDecoder() Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ if input == nil || input.Body == nil {
+ return map[string]any{}, nil
+ }
+ boundary := ""
+ if input.Parameters != nil {
+ boundary = input.Parameters["boundary"]
+ }
+ if boundary == "" {
+ return nil, errors.New("multipart boundary is missing")
+ }
+ reader := multipart.NewReader(input.Body, boundary)
+ values := make(map[string]any)
+ for {
+ part, err := reader.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ body, err := io.ReadAll(part)
+ _ = part.Close()
+ if err != nil {
+ return nil, err
+ }
+ name := part.FormName()
+ property := propertySchema(input.Schema, name)
+ decodedBody := coerceString(string(body), arrayItemSchema(property))
+ if previous, exists := values[name]; exists {
+ switch prior := previous.(type) {
+ case []any:
+ values[name] = append(prior, coerceString(string(body), arrayItemSchema(property)))
+ default:
+ values[name] = []any{prior, coerceString(string(body), arrayItemSchema(property))}
+ }
+ } else {
+ if isArraySchema(property) {
+ values[name] = []any{decodedBody}
+ } else {
+ values[name] = decodedBody
+ }
+ }
+ }
+ return values, nil
+ })
+}
+
+type xmlNode struct {
+ XMLName xml.Name
+ Text string `xml:",chardata"`
+ Nodes []xmlNode `xml:",any"`
+}
+
+// XMLDecoder returns a generic XML object decoder.
+func XMLDecoder() Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ if input == nil || input.Body == nil {
+ return nil, nil
+ }
+ var root xmlNode
+ if err := xml.NewDecoder(input.Body).Decode(&root); err != nil {
+ return nil, err
+ }
+ return xmlValue(root, input.Schema), nil
+ })
+}
+
+func xmlValue(node xmlNode, schema *base.Schema) any {
+ if len(node.Nodes) == 0 {
+ return coerceString(strings.TrimSpace(node.Text), schema)
+ }
+ result := make(map[string]any)
+ for _, child := range node.Nodes {
+ property := propertySchema(schema, child.XMLName.Local)
+ value := xmlValue(child, arrayItemSchema(property))
+ if previous, exists := result[child.XMLName.Local]; exists {
+ switch prior := previous.(type) {
+ case []any:
+ result[child.XMLName.Local] = append(prior, value)
+ default:
+ result[child.XMLName.Local] = []any{prior, value}
+ }
+ } else {
+ if isArraySchema(property) {
+ result[child.XMLName.Local] = []any{value}
+ } else {
+ result[child.XMLName.Local] = value
+ }
+ }
+ }
+ return result
+}
+
+func propertySchema(schema *base.Schema, name string) *base.Schema {
+ if schema == nil || schema.Properties == nil {
+ return nil
+ }
+ proxy := schema.Properties.GetOrZero(name)
+ if proxy == nil {
+ return nil
+ }
+ return proxy.Schema()
+}
+
+func arrayItemSchema(schema *base.Schema) *base.Schema {
+ if schema == nil || schema.Items == nil || !schema.Items.IsA() || schema.Items.A == nil {
+ return schema
+ }
+ return schema.Items.A.Schema()
+}
+
+func isArraySchema(schema *base.Schema) bool {
+ if schema == nil {
+ return false
+ }
+ for _, kind := range schema.Type {
+ if kind == "array" {
+ return true
+ }
+ }
+ return false
+}
+
+func coerceString(value string, schema *base.Schema) any {
+ if schema == nil || len(schema.Type) == 0 {
+ return value
+ }
+ return ParseScalar(value, schema.Type[0])
+}
+
+// ZipLimits bounds archive processing.
+type ZipLimits struct {
+ CompressedSize int64 // CompressedSize is the maximum archive size in bytes.
+ ExpandedSize int64 // ExpandedSize is the maximum total uncompressed size in bytes.
+ Entries int // Entries is the maximum number of archive entries.
+ ExpansionRatio float64 // ExpansionRatio is the maximum expanded-to-compressed size ratio.
+}
+
+// Validate requires finite positive archive limits.
+func (l ZipLimits) Validate() error {
+ if l.CompressedSize <= 0 || l.ExpandedSize <= 0 || l.Entries <= 0 || l.ExpansionRatio <= 0 {
+ return errors.New("all ZIP limits must be positive")
+ }
+ return nil
+}
+
+// ZIPDecoder returns a decoder that validates archive metadata against limits and
+// returns the original archive bytes as a string for schema validation. It does not
+// extract archive entries. All limits must be positive.
+func ZIPDecoder(limits ZipLimits) Decoder {
+ return DecoderFunc(func(input *DecodeInput) (any, error) {
+ if err := limits.Validate(); err != nil {
+ return nil, err
+ }
+ body, err := io.ReadAll(io.LimitReader(input.Body, limits.CompressedSize+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(body)) > limits.CompressedSize {
+ return nil, errors.New("ZIP compressed-size limit exceeded")
+ }
+ archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
+ if err != nil {
+ return nil, err
+ }
+ if len(archive.File) > limits.Entries {
+ return nil, errors.New("ZIP entry-count limit exceeded")
+ }
+ var expanded uint64
+ for _, file := range archive.File {
+ expanded += file.UncompressedSize64
+ if expanded > uint64(limits.ExpandedSize) {
+ return nil, errors.New("ZIP expanded-size limit exceeded")
+ }
+ }
+ compressed := len(body)
+ if float64(expanded)/float64(compressed) > limits.ExpansionRatio {
+ return nil, errors.New("ZIP expansion-ratio limit exceeded")
+ }
+ return string(body), nil
+ })
+}
+
+// StandardDecoderRegistrations returns non-archive built-in codecs.
+func StandardDecoderRegistrations() []Registration {
+ return []Registration{
+ {"application/json", JSONDecoder()},
+ {"application/*+json", JSONDecoder()},
+ {"application/yaml", YAMLDecoder()},
+ {"application/x-yaml", YAMLDecoder()},
+ {"text/yaml", YAMLDecoder()},
+ {"application/xml", XMLDecoder()},
+ {"text/xml", XMLDecoder()},
+ {"application/x-www-form-urlencoded", FormDecoder()},
+ {"multipart/form-data", MultipartDecoder()},
+ {"text/csv", CSVDecoder()},
+ {"text/plain", TextDecoder()},
+ {"text/*", TextDecoder()},
+ {"application/octet-stream", BinaryDecoder()},
+ }
+}
+
+// ParseScalar converts a string to a JSON-compatible primitive when requested by custom codecs.
+func ParseScalar(value, kind string) any {
+ switch kind {
+ case "integer", "number":
+ if number, err := strconv.ParseFloat(value, 64); err == nil {
+ return number
+ }
+ case "boolean":
+ if boolean, err := strconv.ParseBool(value); err == nil {
+ return boolean
+ }
+ }
+ return value
+}
diff --git a/content/content_test.go b/content/content_test.go
new file mode 100644
index 00000000..6f6ef5dd
--- /dev/null
+++ b/content/content_test.go
@@ -0,0 +1,483 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package content
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "mime/multipart"
+ "testing"
+ "time"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+)
+
+func TestRegistryLookupPrecedenceAndIsolation(t *testing.T) {
+ global := DecoderFunc(func(*DecodeInput) (any, error) { return "global", nil })
+ typeWildcard := DecoderFunc(func(*DecodeInput) (any, error) { return "type", nil })
+ suffix := DecoderFunc(func(*DecodeInput) (any, error) { return "suffix", nil })
+ exactOld := DecoderFunc(func(*DecodeInput) (any, error) { return "old", nil })
+ exactNew := DecoderFunc(func(*DecodeInput) (any, error) { return "new", nil })
+ encoder := EncoderFunc(func(*EncodeInput) ([]byte, error) { return []byte("encoded"), nil })
+ r := NewRegistry([]Registration{
+ {"*/*", global},
+ {"application/*", typeWildcard},
+ {"application/*+json", suffix},
+ {"application/problem+json", exactOld},
+ {"application/problem+json", exactNew},
+ {"bad", exactNew},
+ {"text/plain", nil},
+ }, []EncoderRegistration{{"application/*+json", encoder}, {"bad", encoder}, {"text/plain", nil}})
+
+ decoder, mediaType, params := r.Decoder("application/problem+json")
+ require.NotNil(t, decoder)
+ value, err := decoder.Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "new", value)
+ assert.Equal(t, "application/problem+json", mediaType)
+ assert.Nil(t, params)
+
+ decoder, mediaType, params = r.Decoder("application/vnd.test+json; charset=utf-8")
+ value, err = decoder.Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "suffix", value)
+ assert.Equal(t, "application/vnd.test+json", mediaType)
+ assert.Equal(t, "utf-8", params["charset"])
+ decoder, _, _ = r.Decoder("Application/Problem+JSON; charset=utf-8")
+ value, _ = decoder.Decode(nil)
+ assert.Equal(t, "new", value)
+
+ decoder, _, _ = r.Decoder("application/yaml")
+ value, _ = decoder.Decode(nil)
+ assert.Equal(t, "type", value)
+ decoder, _, _ = r.Decoder("image/png")
+ value, _ = decoder.Decode(nil)
+ assert.Equal(t, "global", value)
+
+ resolvedEncoder, _, _ := r.Encoder("application/vnd.test+json")
+ encoded, err := resolvedEncoder.Encode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "encoded", string(encoded))
+ exactEncoder := EncoderFunc(func(*EncodeInput) ([]byte, error) { return []byte("exact"), nil })
+ typeEncoder := EncoderFunc(func(*EncodeInput) ([]byte, error) { return []byte("type"), nil })
+ globalEncoder := EncoderFunc(func(*EncodeInput) ([]byte, error) { return []byte("global"), nil })
+ encoders := NewRegistry(nil, []EncoderRegistration{{"application/json", exactEncoder}, {"application/*", typeEncoder}, {"*/*", globalEncoder}})
+ resolvedEncoder, _, _ = encoders.Encoder("application/json")
+ encoded, _ = resolvedEncoder.Encode(nil)
+ assert.Equal(t, "exact", string(encoded))
+ resolvedEncoder, _, _ = encoders.Encoder("application/yaml")
+ encoded, _ = resolvedEncoder.Encode(nil)
+ assert.Equal(t, "type", string(encoded))
+ resolvedEncoder, _, _ = encoders.Encoder("image/png")
+ encoded, _ = resolvedEncoder.Encode(nil)
+ assert.Equal(t, "global", string(encoded))
+ assert.Nil(t, NewRegistry(nil, nil).decoders["bad"])
+ assert.NotSame(t, r, NewRegistry(nil, nil))
+}
+
+func TestCompatibilityDecodersAndExactReplacement(t *testing.T) {
+ xmlMarker := XMLCompatibilityDecoder()
+ formMarker := FormCompatibilityDecoder()
+
+ xmlCompatibility, ok := xmlMarker.(CompatibilityDecoder)
+ require.True(t, ok)
+ assert.Equal(t, "xml", xmlCompatibility.CompatibilityKind())
+ _, err := xmlCompatibility.Decode(nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "xml compatibility decoder")
+
+ formCompatibility, ok := formMarker.(CompatibilityDecoder)
+ require.True(t, ok)
+ assert.Equal(t, "form", formCompatibility.CompatibilityKind())
+
+ registry := NewRegistry([]Registration{{MediaType: "application/xml", Decoder: xmlMarker}}, nil)
+ exactCompatibility, ok := registry.ExactDecoder("Application/XML; charset=utf-8").(CompatibilityDecoder)
+ require.True(t, ok)
+ assert.Equal(t, "xml", exactCompatibility.CompatibilityKind())
+ assert.Nil(t, registry.ExactDecoder("text/xml"))
+ assert.Nil(t, (*Registry)(nil).ExactDecoder("application/xml"))
+
+ replacement := DecoderFunc(func(*DecodeInput) (any, error) { return "replacement", nil })
+ replaced := registry.WithDecoder("application/xml", replacement)
+ value, err := replaced.ExactDecoder("application/xml").Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "replacement", value)
+ _, originalIsMarker := registry.ExactDecoder("application/xml").(CompatibilityDecoder)
+ assert.True(t, originalIsMarker, "the original registry must remain immutable")
+
+ created := (*Registry)(nil).WithDecoder("text/xml", replacement)
+ value, err = created.ExactDecoder("text/xml").Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "replacement", value)
+ unchanged := registry.WithDecoder("bad media type", replacement)
+ _, unchangedIsMarker := unchanged.ExactDecoder("application/xml").(CompatibilityDecoder)
+ assert.True(t, unchangedIsMarker)
+ unchanged = registry.WithDecoder("application/xml", nil)
+ _, unchangedIsMarker = unchanged.ExactDecoder("application/xml").(CompatibilityDecoder)
+ assert.True(t, unchangedIsMarker)
+}
+
+func TestRegistryNilMalformedAndWildcards(t *testing.T) {
+ var r *Registry
+ decoder, mediaType, params := r.Decoder("application/json")
+ assert.Nil(t, decoder)
+ assert.Empty(t, mediaType)
+ assert.Nil(t, params)
+ encoder, mediaType, params := r.Encoder("application/json")
+ assert.Nil(t, encoder)
+ assert.Empty(t, mediaType)
+ assert.Nil(t, params)
+
+ r = NewRegistry([]Registration{{"*/*", JSONDecoder()}}, nil)
+ decoder, mediaType, _ = r.Decoder("not a media type")
+ assert.Nil(t, decoder)
+ assert.Empty(t, mediaType)
+ decoder, mediaType, _ = r.Decoder("json")
+ assert.Nil(t, decoder)
+ assert.Equal(t, "json", mediaType)
+ encoder, mediaType, _ = r.Encoder("not a media type")
+ assert.Nil(t, encoder)
+ assert.Empty(t, mediaType)
+ encoder, mediaType, _ = NewRegistry(nil, []EncoderRegistration{{"*/*", JSONEncoder()}}).Encoder("json")
+ assert.NotNil(t, encoder)
+ assert.Equal(t, "json", mediaType)
+
+ assert.Equal(t, "application/json", normalizeRange(" Application/JSON; charset=utf-8 "))
+ assert.Empty(t, normalizeRange("invalid"))
+}
+
+func TestRegistryPrecompute(t *testing.T) {
+ var nilRegistry *Registry
+ assert.Nil(t, nilRegistry.Precompute([]string{"application/json"}))
+ decoder := DecoderFunc(func(*DecodeInput) (any, error) { return "decoded", nil })
+ encoder := EncoderFunc(func(*EncodeInput) ([]byte, error) { return []byte("encoded"), nil })
+ original := NewRegistry(
+ []Registration{{"application/*+json", decoder}, {"text/plain", TextDecoder()}},
+ []EncoderRegistration{{"application/*+json", encoder}},
+ )
+ resolved := original.Precompute([]string{"application/vnd.test+json", "text/plain", "invalid", "not a media;=", "image/png"})
+ assert.NotSame(t, original, resolved)
+ assert.NotNil(t, resolved.decoders["application/vnd.test+json"])
+ assert.NotNil(t, resolved.encoders["application/vnd.test+json"])
+ assert.NotNil(t, resolved.decoders["text/plain"])
+ assert.Nil(t, resolved.decoders["image/png"])
+ assert.Nil(t, original.decoders["application/vnd.test+json"])
+}
+
+func TestCanonicalize(t *testing.T) {
+ input := map[any]any{
+ "numbers": []any{int(1), int8(2), int16(3), int32(4), int64(5), uint(6), uint8(7), uint16(8), uint32(9), uint64(10), float32(11.5)},
+ "nested": map[string]any{"ok": true},
+ }
+ value, err := Canonicalize(input)
+ require.NoError(t, err)
+ numbers := value.(map[string]any)["numbers"].([]any)
+ for _, number := range numbers {
+ assert.IsType(t, float64(0), number)
+ }
+ _, err = Canonicalize(map[any]any{1: "bad"})
+ assert.ErrorContains(t, err, "not a string")
+ for _, invalid := range []any{
+ map[string]any{"nested": map[any]any{1: "bad"}},
+ map[any]any{"nested": map[any]any{1: "bad"}},
+ []any{map[any]any{1: "bad"}},
+ } {
+ _, err = Canonicalize(invalid)
+ assert.Error(t, err)
+ }
+ assert.Equal(t, "unchanged", mustCanonical(t, "unchanged"))
+ number, err := Canonicalize(json.Number("12.5"))
+ require.NoError(t, err)
+ assert.Equal(t, 12.5, number)
+ instant := time.Date(2026, 7, 10, 1, 2, 3, 4, time.UTC)
+ canonicalInstant, err := Canonicalize(instant)
+ require.NoError(t, err)
+ assert.Equal(t, instant.Format(time.RFC3339Nano), canonicalInstant)
+ bytesValue, err := Canonicalize([]byte("raw"))
+ require.NoError(t, err)
+ assert.Equal(t, []byte("raw"), bytesValue)
+ _, err = Canonicalize(struct{ Value string }{"bad"})
+ assert.ErrorContains(t, err, "not JSON-compatible")
+}
+
+func mustCanonical(t *testing.T, value any) any {
+ t.Helper()
+ canonical, err := Canonicalize(value)
+ require.NoError(t, err)
+ return canonical
+}
+
+func TestJSONYAMLAndStringDecoders(t *testing.T) {
+ value, err := JSONDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString(`{"n":1.5}`)})
+ require.NoError(t, err)
+ assert.Equal(t, 1.5, value.(map[string]any)["n"])
+ value, err = JSONDecoder().Decode(&DecodeInput{Body: bytes.NewReader(nil)})
+ require.NoError(t, err)
+ assert.Nil(t, value)
+ _, err = JSONDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString(`{`)})
+ assert.Error(t, err)
+ _, err = JSONDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString(`{"first":true}{"second":true}`)})
+ assert.ErrorContains(t, err, "multiple values")
+ _, err = JSONDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString(`{"first":true} trailing`)})
+ assert.Error(t, err)
+ value, err = JSONDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("{\"first\":true} \n\t")})
+ require.NoError(t, err)
+ assert.Equal(t, true, value.(map[string]any)["first"])
+ value, err = JSONDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Nil(t, value)
+
+ value, err = YAMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("n: 2\nitems: [a, b]\n")})
+ require.NoError(t, err)
+ assert.Equal(t, float64(2), value.(map[string]any)["n"])
+ _, err = YAMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("? [a, b]\n: value\n")})
+ assert.Error(t, err)
+ _, err = YAMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("[unterminated")})
+ assert.Error(t, err)
+ value, err = YAMLDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Nil(t, value)
+
+ value, err = TextDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("hello")})
+ require.NoError(t, err)
+ assert.Equal(t, "hello", value)
+ value, err = BinaryDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "", value)
+ value, err = CSVDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("a,b\n1,2\n")})
+ require.NoError(t, err)
+ assert.Equal(t, "a,b\n1,2\n", value)
+ _, err = CSVDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("a,b\n1\n")})
+ assert.Error(t, err)
+}
+
+func TestFormMultipartAndXMLDecoders(t *testing.T) {
+ value, err := FormDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("one=1&many=a&many=b")})
+ require.NoError(t, err)
+ assert.Equal(t, "1", value.(map[string]any)["one"])
+ assert.Equal(t, []any{"a", "b"}, value.(map[string]any)["many"])
+ value, err = FormDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Empty(t, value)
+ _, err = FormDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("bad=%zz")})
+ assert.Error(t, err)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormField("name")
+ require.NoError(t, err)
+ _, _ = part.Write([]byte("one"))
+ part, _ = writer.CreateFormField("name")
+ _, _ = part.Write([]byte("two"))
+ part, _ = writer.CreateFormField("name")
+ _, _ = part.Write([]byte("three"))
+ require.NoError(t, writer.Close())
+ value, err = MultipartDecoder().Decode(&DecodeInput{Body: &body, Parameters: map[string]string{"boundary": writer.Boundary()}})
+ require.NoError(t, err)
+ assert.Equal(t, []any{"one", "two", "three"}, value.(map[string]any)["name"])
+ _, err = MultipartDecoder().Decode(&DecodeInput{Body: bytes.NewReader(nil)})
+ assert.ErrorContains(t, err, "boundary")
+ value, err = MultipartDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Empty(t, value)
+
+ value, err = XMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("onetwothreetrue")})
+ require.NoError(t, err)
+ object := value.(map[string]any)
+ assert.Equal(t, []any{"one", "two", "three"}, object["name"])
+ assert.Equal(t, "true", object["nested"].(map[string]any)["ok"])
+ _, err = XMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("")})
+ assert.Error(t, err)
+ value, err = XMLDecoder().Decode(nil)
+ require.NoError(t, err)
+ assert.Nil(t, value)
+}
+
+func TestSchemaAwareFormMultipartAndXMLDecoding(t *testing.T) {
+ document, err := libopenapi.NewDocument([]byte(`openapi: 3.1.0
+info: {title: codecs, version: 1.0.0}
+components:
+ schemas:
+ Input:
+ type: object
+ properties:
+ count: {type: integer}
+ enabled: {type: boolean}
+ values: {type: array, items: {type: number}}`))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ schema := model.Model.Components.Schemas.GetOrZero("Input").Schema()
+
+ value, err := FormDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("count=2&enabled=true&values=1.5&values=2.5"), Schema: schema})
+ require.NoError(t, err)
+ form := value.(map[string]any)
+ assert.Equal(t, float64(2), form["count"])
+ assert.Equal(t, true, form["enabled"])
+ assert.Equal(t, []any{1.5, 2.5}, form["values"])
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ for _, pair := range [][2]string{{"count", "2"}, {"values", "1.5"}, {"values", "2.5"}} {
+ part, partErr := writer.CreateFormField(pair[0])
+ require.NoError(t, partErr)
+ _, partErr = part.Write([]byte(pair[1]))
+ require.NoError(t, partErr)
+ }
+ require.NoError(t, writer.Close())
+ value, err = MultipartDecoder().Decode(&DecodeInput{Body: &body, Parameters: map[string]string{"boundary": writer.Boundary()}, Schema: schema})
+ require.NoError(t, err)
+ form = value.(map[string]any)
+ assert.Equal(t, float64(2), form["count"])
+ assert.Equal(t, []any{1.5, 2.5}, form["values"])
+
+ value, err = XMLDecoder().Decode(&DecodeInput{Body: bytes.NewBufferString("2true1.52.5"), Schema: schema})
+ require.NoError(t, err)
+ xmlObject := value.(map[string]any)
+ assert.Equal(t, float64(2), xmlObject["count"])
+ assert.Equal(t, true, xmlObject["enabled"])
+ assert.Equal(t, []any{1.5, 2.5}, xmlObject["values"])
+ assert.Nil(t, propertySchema(schema, "missing"))
+}
+
+func TestZipDecoderLimits(t *testing.T) {
+ archive := zipBytes(t, map[string]string{"one.txt": "hello"})
+ limits := ZipLimits{CompressedSize: int64(len(archive)) + 1, ExpandedSize: 100, Entries: 2, ExpansionRatio: 10}
+ value, err := ZIPDecoder(limits).Decode(&DecodeInput{Body: bytes.NewReader(archive)})
+ require.NoError(t, err)
+ assert.Equal(t, string(archive), value)
+
+ _, err = ZIPDecoder(ZipLimits{}).Decode(&DecodeInput{Body: bytes.NewReader(archive)})
+ assert.ErrorContains(t, err, "positive")
+ _, err = ZIPDecoder(ZipLimits{1, 100, 2, 10}).Decode(&DecodeInput{Body: bytes.NewReader(archive)})
+ assert.ErrorContains(t, err, "compressed-size")
+ _, err = ZIPDecoder(ZipLimits{1000, 100, 1, 10}).Decode(&DecodeInput{Body: bytes.NewReader(zipBytes(t, map[string]string{"a": "a", "b": "b"}))})
+ assert.ErrorContains(t, err, "entry-count")
+ _, err = ZIPDecoder(ZipLimits{1000, 1, 2, 10}).Decode(&DecodeInput{Body: bytes.NewReader(archive)})
+ assert.ErrorContains(t, err, "expanded-size")
+ _, err = ZIPDecoder(ZipLimits{1000, 100, 2, 0.001}).Decode(&DecodeInput{Body: bytes.NewReader(archive)})
+ assert.ErrorContains(t, err, "expansion-ratio")
+ _, err = ZIPDecoder(limits).Decode(&DecodeInput{Body: bytes.NewBufferString("not zip")})
+ assert.Error(t, err)
+}
+
+func zipBytes(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ for name, body := range files {
+ file, err := writer.Create(name)
+ require.NoError(t, err)
+ _, err = file.Write([]byte(body))
+ require.NoError(t, err)
+ }
+ require.NoError(t, writer.Close())
+ return buffer.Bytes()
+}
+
+func TestEncodersErrorsAndHelpers(t *testing.T) {
+ encoded, err := JSONEncoder().Encode(&EncodeInput{Context: context.Background(), Value: map[string]any{"ok": true}})
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"ok":true}`, string(encoded))
+ encoded, err = JSONEncoder().Encode(nil)
+ require.NoError(t, err)
+ assert.Nil(t, encoded)
+ _, err = JSONEncoder().Encode(&EncodeInput{Value: make(chan int)})
+ assert.Error(t, err)
+
+ sentinel := errors.New("boom")
+ decodingErr := &DecodingError{MediaType: "text/plain", Direction: Response, Err: sentinel}
+ assert.ErrorIs(t, decodingErr, sentinel)
+ assert.Contains(t, decodingErr.Error(), "response")
+ assert.Equal(t, "body decoding failed", (*DecodingError)(nil).Error())
+ assert.Nil(t, (*DecodingError)(nil).Unwrap())
+ assert.Equal(t, "request", directionName(Request))
+
+ assert.Equal(t, float64(42), ParseScalar("42", "integer"))
+ assert.Equal(t, float64(1.5), ParseScalar("1.5", "number"))
+ assert.Equal(t, true, ParseScalar("true", "boolean"))
+ assert.Equal(t, "nope", ParseScalar("nope", "integer"))
+ assert.Equal(t, "value", ParseScalar("value", "string"))
+ assert.NotEmpty(t, StandardDecoderRegistrations())
+}
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") }
+
+func TestDecoderReadFailures(t *testing.T) {
+ inputs := []Decoder{TextDecoder(), FormDecoder()}
+ for _, decoder := range inputs {
+ _, err := decoder.Decode(&DecodeInput{Body: failingReader{}})
+ assert.ErrorContains(t, err, "read failed")
+ }
+ _, err := MultipartDecoder().Decode(&DecodeInput{Body: failingReader{}, Parameters: map[string]string{"boundary": "boundary"}})
+ assert.Error(t, err)
+ multipartBody := []byte("--boundary\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue")
+ _, err = MultipartDecoder().Decode(&DecodeInput{Body: &dataThenError{data: multipartBody}, Parameters: map[string]string{"boundary": "boundary"}})
+ assert.ErrorContains(t, err, "read failed")
+ _, err = ZIPDecoder(ZipLimits{100, 100, 1, 10}).Decode(&DecodeInput{Body: failingReader{}})
+ assert.ErrorContains(t, err, "read failed")
+}
+
+type dataThenError struct {
+ data []byte
+}
+
+func (r *dataThenError) Read(target []byte) (int, error) {
+ if len(r.data) == 0 {
+ return 0, errors.New("read failed")
+ }
+ n := copy(target, r.data)
+ r.data = r.data[n:]
+ return n, nil
+}
+
+var _ io.Reader = failingReader{}
+
+func BenchmarkRegistryExactLookup(b *testing.B) {
+ registry := NewRegistry(StandardDecoderRegistrations(), nil)
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _, _ = registry.Decoder("application/json")
+ }
+}
+
+func BenchmarkRegistryPrecomputedVendorJSONLookup(b *testing.B) {
+ registry := NewRegistry(StandardDecoderRegistrations(), nil).Precompute([]string{"application/vnd.example+json"})
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _, _ = registry.Decoder("application/vnd.example+json")
+ }
+}
+
+func BenchmarkCustomDecoderLookup(b *testing.B) {
+ custom := DecoderFunc(func(*DecodeInput) (any, error) { return nil, nil })
+ registry := NewRegistry([]Registration{{"application/custom", custom}}, nil)
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _, _ = registry.Decoder("application/custom")
+ }
+}
+
+func BenchmarkMultipartDecode(b *testing.B) {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, _ := writer.CreateFormField("name")
+ _, _ = part.Write([]byte("value"))
+ _ = writer.Close()
+ data := body.Bytes()
+ boundary := writer.Boundary()
+ decoder := MultipartDecoder()
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = decoder.Decode(&DecodeInput{Body: bytes.NewReader(data), Parameters: map[string]string{"boundary": boundary}})
+ }
+}
diff --git a/go.mod b/go.mod
index 2dbd340b..95b88056 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/go-openapi/jsonpointer v0.23.2
github.com/goccy/go-yaml v1.19.2
github.com/pb33f/jsonpath v0.8.2
- github.com/pb33f/libopenapi v0.38.4
+ github.com/pb33f/libopenapi v0.38.6
github.com/pb33f/testify v0.1.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
go.yaml.in/yaml/v4 v4.0.0-rc.6
diff --git a/go.sum b/go.sum
index 8bc5c502..2b88717c 100644
--- a/go.sum
+++ b/go.sum
@@ -21,8 +21,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
-github.com/pb33f/libopenapi v0.38.4 h1:qtOr4/Ct3ZIy9YNY4xoTYegYhMozaotSsZ7lqr1FHso=
-github.com/pb33f/libopenapi v0.38.4/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY=
+github.com/pb33f/libopenapi v0.38.6 h1:rnLshOSCSLx3JmN/MnftyYcdN1FfaeRkbaBE/ZHdIXg=
+github.com/pb33f/libopenapi v0.38.6/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8=
diff --git a/internal/bodycodec/compatibility.go b/internal/bodycodec/compatibility.go
new file mode 100644
index 00000000..4f0bc31d
--- /dev/null
+++ b/internal/bodycodec/compatibility.go
@@ -0,0 +1,103 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+// Package bodycodec adapts legacy schema-aware body transforms to content decoders.
+package bodycodec
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
+ validatorerrors "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/schema_validation"
+)
+
+// ValidationErrors carries the original structured transform errors through the decoder contract.
+type ValidationErrors struct {
+ Errors []*validatorerrors.ValidationError // Errors contains the original structured transform failures.
+}
+
+// Error returns the first structured transform error.
+func (e *ValidationErrors) Error() string {
+ if e == nil || len(e.Errors) == 0 {
+ return "body transform failed"
+ }
+ return e.Errors[0].Error()
+}
+
+// Apply replaces compatibility markers with schema-aware immutable decoder adapters.
+func Apply(options *config.ValidationOptions) {
+ if options == nil || options.BodyRegistry == nil {
+ return
+ }
+ for _, registration := range []struct {
+ mediaType string
+ kind string
+ decoder content.Decoder
+ }{
+ {"application/xml", "xml", xmlDecoder{}},
+ {"text/xml", "xml", xmlDecoder{}},
+ {"application/x-www-form-urlencoded", "form", formDecoder{}},
+ } {
+ marker, ok := options.BodyRegistry.ExactDecoder(registration.mediaType).(content.CompatibilityDecoder)
+ if ok && marker.CompatibilityKind() == registration.kind {
+ options.BodyRegistry = options.BodyRegistry.WithDecoder(registration.mediaType, registration.decoder)
+ }
+ }
+}
+
+type xmlDecoder struct{}
+
+func (xmlDecoder) Decode(input *content.DecodeInput) (any, error) {
+ body, err := readBody(input)
+ if err != nil {
+ return nil, err
+ }
+ value, validationErrors := schema_validation.TransformXMLToSchemaJSON(string(body), input.Schema)
+ if len(validationErrors) > 0 {
+ return nil, &ValidationErrors{Errors: validationErrors}
+ }
+ if _, err = json.Marshal(value); err != nil {
+ return nil, &ValidationErrors{Errors: []*validatorerrors.ValidationError{
+ validatorerrors.InvalidXMLParsing(err.Error(), string(body)),
+ }}
+ }
+ return value, nil
+}
+
+type formDecoder struct{}
+
+func (formDecoder) Decode(input *content.DecodeInput) (any, error) {
+ body, err := readBody(input)
+ if err != nil {
+ return nil, err
+ }
+ encoding, _ := input.Encoding.(*orderedmap.Map[string, *v3.Encoding])
+ value, validationErrors := schema_validation.TransformURLEncodedToSchemaJSON(string(body), input.Schema, encoding)
+ if len(validationErrors) > 0 {
+ return nil, &ValidationErrors{Errors: validationErrors}
+ }
+ if _, err = json.Marshal(value); err != nil {
+ return nil, &ValidationErrors{Errors: []*validatorerrors.ValidationError{
+ validatorerrors.InvalidURLEncodedParsing(err.Error(), string(body)),
+ }}
+ }
+ return value, nil
+}
+
+func readBody(input *content.DecodeInput) ([]byte, error) {
+ if input == nil || input.Body == nil {
+ return nil, nil
+ }
+ body, err := io.ReadAll(input.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ return body, nil
+}
diff --git a/internal/bodycodec/compatibility_test.go b/internal/bodycodec/compatibility_test.go
new file mode 100644
index 00000000..94dfd6a6
--- /dev/null
+++ b/internal/bodycodec/compatibility_test.go
@@ -0,0 +1,136 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package bodycodec
+
+import (
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/libopenapi/orderedmap"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
+ validatorerrors "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+)
+
+func TestApplyReplacesOnlyCompatibilityMarkers(t *testing.T) {
+ Apply(nil)
+ Apply(&config.ValidationOptions{})
+
+ options := config.NewValidationOptions(config.WithXmlBodyValidation(), config.WithURLEncodedBodyValidation())
+ Apply(options)
+ for _, mediaType := range []string{"application/xml", "text/xml", "application/x-www-form-urlencoded"} {
+ decoder := options.BodyRegistry.ExactDecoder(mediaType)
+ require.NotNil(t, decoder)
+ _, isMarker := decoder.(content.CompatibilityDecoder)
+ assert.False(t, isMarker, mediaType)
+ }
+
+ custom := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return "custom", nil })
+ customOptions := config.NewValidationOptions(
+ config.WithXmlBodyValidation(),
+ config.WithBodyDecoder("application/xml", custom),
+ config.WithBodyDecoder("text/xml", content.FormCompatibilityDecoder()),
+ )
+ Apply(customOptions)
+ value, err := customOptions.BodyRegistry.ExactDecoder("application/xml").Decode(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "custom", value)
+ marker, ok := customOptions.BodyRegistry.ExactDecoder("text/xml").(content.CompatibilityDecoder)
+ require.True(t, ok)
+ assert.Equal(t, "form", marker.CompatibilityKind())
+
+ Apply(options)
+ _, isMarker := options.BodyRegistry.ExactDecoder("application/xml").(content.CompatibilityDecoder)
+ assert.False(t, isMarker, "applying twice must be idempotent")
+}
+
+func TestCompatibilityDecodersPreserveSchemaAwareValuesAndErrors(t *testing.T) {
+ options := config.NewValidationOptions(config.WithXmlBodyValidation(), config.WithURLEncodedBodyValidation())
+ Apply(options)
+
+ xmlDecoder := options.BodyRegistry.ExactDecoder("application/xml")
+ value, err := xmlDecoder.Decode(&content.DecodeInput{Body: strings.NewReader("Dave"), Schema: &base.Schema{}})
+ require.NoError(t, err)
+ assert.NotNil(t, value)
+
+ formDecoder := options.BodyRegistry.ExactDecoder("application/x-www-form-urlencoded")
+ value, err = formDecoder.Decode(&content.DecodeInput{Body: strings.NewReader("name=Dave")})
+ require.NoError(t, err)
+ assert.Equal(t, "Dave", value.(map[string]any)["name"])
+
+ _, err = xmlDecoder.Decode(&content.DecodeInput{Body: strings.NewReader("<")})
+ var validationErrors *ValidationErrors
+ require.ErrorAs(t, err, &validationErrors)
+ require.Len(t, validationErrors.Errors, 1)
+ assert.Contains(t, validationErrors.Errors[0].Reason, "malformed xml")
+
+ _, err = formDecoder.Decode(&content.DecodeInput{Body: strings.NewReader("bad=%zz")})
+ require.ErrorAs(t, err, &validationErrors)
+ require.Len(t, validationErrors.Errors, 1)
+ assert.Equal(t, helpers.URLEncodedValidation, validationErrors.Errors[0].ValidationType)
+}
+
+func TestCompatibilityDecodersReportNonJSONNumbers(t *testing.T) {
+ properties := orderedmap.New[string, *base.SchemaProxy]()
+ properties.Set("bad_number", base.CreateSchemaProxy(&base.Schema{Type: []string{helpers.Number}}))
+ schema := &base.Schema{Type: []string{helpers.Object}, Properties: properties}
+ options := config.NewValidationOptions(config.WithXmlBodyValidation(), config.WithURLEncodedBodyValidation())
+ Apply(options)
+
+ _, err := options.BodyRegistry.ExactDecoder("application/xml").Decode(&content.DecodeInput{
+ Body: strings.NewReader("NaN"), Schema: schema,
+ })
+ var validationErrors *ValidationErrors
+ require.ErrorAs(t, err, &validationErrors)
+ require.Len(t, validationErrors.Errors, 1)
+ assert.Equal(t, "xml example is malformed", validationErrors.Errors[0].Message)
+
+ _, err = options.BodyRegistry.ExactDecoder("application/x-www-form-urlencoded").Decode(&content.DecodeInput{
+ Body: strings.NewReader("bad_number=NaN"), Schema: schema,
+ })
+ require.ErrorAs(t, err, &validationErrors)
+ require.Len(t, validationErrors.Errors, 1)
+ assert.Equal(t, "Unable to parse form-urlencoded body", validationErrors.Errors[0].Message)
+}
+
+func TestValidationErrorsAndBodyReadFailures(t *testing.T) {
+ var nilErrors *ValidationErrors
+ assert.Equal(t, "body transform failed", nilErrors.Error())
+ assert.Equal(t, "body transform failed", (&ValidationErrors{}).Error())
+ structured := validatorerrors.InvalidXMLParsing("bad", "body")
+ assert.Equal(t, structured.Error(), (&ValidationErrors{Errors: []*validatorerrors.ValidationError{structured}}).Error())
+
+ body, err := readBody(nil)
+ require.NoError(t, err)
+ assert.Nil(t, body)
+ body, err = readBody(&content.DecodeInput{})
+ require.NoError(t, err)
+ assert.Nil(t, body)
+
+ _, err = readBody(&content.DecodeInput{Body: failingReader{}})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "read body")
+ assert.True(t, errors.Is(err, errRead))
+
+ for _, decoder := range []content.Decoder{xmlDecoder{}, formDecoder{}} {
+ _, err = decoder.Decode(&content.DecodeInput{Body: failingReader{}})
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, errRead))
+ }
+}
+
+var errRead = errors.New("read failure")
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, errRead }
+
+var _ io.Reader = failingReader{}
diff --git a/internal/requeststate/body.go b/internal/requeststate/body.go
new file mode 100644
index 00000000..6d34ed1e
--- /dev/null
+++ b/internal/requeststate/body.go
@@ -0,0 +1,143 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+// Package requeststate owns replayable request-body snapshots shared by validation stages.
+package requeststate
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net/http"
+ "reflect"
+)
+
+type replayableBody interface {
+ io.ReaderAt
+ Size() int64
+}
+
+type snapshotBody struct {
+ *bytes.Reader
+ bytes []byte
+}
+
+func (b *snapshotBody) Close() error { return nil }
+
+// Snapshot reads a request body once and installs replay support while leaving it readable.
+func Snapshot(request *http.Request) ([]byte, error) {
+ if request == nil {
+ return nil, nil
+ }
+ if request.Body == nil {
+ if request.GetBody == nil {
+ return nil, nil
+ }
+ replay, err := request.GetBody()
+ if err != nil {
+ return nil, err
+ }
+ if replay == nil {
+ return nil, nil
+ }
+ body, readErr := io.ReadAll(replay)
+ _ = replay.Close()
+ if readErr != nil {
+ return nil, readErr
+ }
+ Install(request, body)
+ return body, nil
+ }
+ if request.Body == http.NoBody {
+ return nil, nil
+ }
+ if snapshot, ok := request.Body.(*snapshotBody); ok {
+ return append([]byte(nil), snapshot.bytes...), nil
+ }
+ var prior []byte
+ if replayable, ok := underlyingReader(request.Body).(replayableBody); ok && replayable.Size() > 0 {
+ var err error
+ prior, err = io.ReadAll(io.NewSectionReader(replayable, 0, replayable.Size()))
+ if err != nil {
+ prior = nil
+ }
+ }
+ body, err := io.ReadAll(request.Body)
+ _ = request.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if len(body) == 0 && len(prior) > 0 && request.GetBody != nil {
+ replay, replayErr := request.GetBody()
+ if replayErr == nil && replay != nil {
+ replayed, readErr := io.ReadAll(replay)
+ _ = replay.Close()
+ if readErr == nil && bytes.Equal(replayed, prior) {
+ body = replayed
+ }
+ }
+ }
+ Install(request, body)
+ return body, nil
+}
+
+func underlyingReader(body io.ReadCloser) io.Reader {
+ value := reflect.ValueOf(body)
+ if value.Kind() == reflect.Pointer {
+ if value.IsNil() {
+ return nil
+ }
+ value = value.Elem()
+ }
+ if value.Kind() == reflect.Struct {
+ field := value.FieldByName("Reader")
+ if field.IsValid() && field.CanInterface() {
+ if reader, ok := field.Interface().(io.Reader); ok {
+ return reader
+ }
+ }
+ }
+ return body
+}
+
+// Install replaces a request body with an immutable replayable snapshot.
+func Install(request *http.Request, body []byte) {
+ if request == nil {
+ return
+ }
+ snapshot := append([]byte(nil), body...)
+ request.Body = &snapshotBody{Reader: bytes.NewReader(snapshot), bytes: snapshot}
+ request.ContentLength = int64(len(snapshot))
+ request.GetBody = func() (io.ReadCloser, error) {
+ return &snapshotBody{Reader: bytes.NewReader(snapshot), bytes: snapshot}, nil
+ }
+}
+
+// WithFreshBody gives a callback an independent reader and restores a fresh reader afterward.
+func WithFreshBody(request *http.Request, callback func() error) error {
+ if request == nil || request.Body == nil || request.Body == http.NoBody {
+ return callback()
+ }
+ if request.GetBody == nil {
+ if _, err := Snapshot(request); err != nil {
+ return err
+ }
+ }
+ getBody := request.GetBody
+ body, err := getBody()
+ if err != nil {
+ return err
+ }
+ request.Body = body
+ callbackErr := callback()
+ if request.Body != nil {
+ _ = request.Body.Close()
+ }
+ restored, restoreErr := getBody()
+ request.GetBody = getBody
+ if restoreErr != nil {
+ return errors.Join(callbackErr, restoreErr)
+ }
+ request.Body = restored
+ return callbackErr
+}
diff --git a/internal/requeststate/body_test.go b/internal/requeststate/body_test.go
new file mode 100644
index 00000000..8b4a22e9
--- /dev/null
+++ b/internal/requeststate/body_test.go
@@ -0,0 +1,203 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package requeststate
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+)
+
+func TestSnapshotAndInstall(t *testing.T) {
+ request, err := http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("body"))
+ require.NoError(t, err)
+ body, err := Snapshot(request)
+ require.NoError(t, err)
+ assert.Equal(t, "body", string(body))
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Equal(t, "body", string(body))
+ assert.Equal(t, int64(4), request.ContentLength)
+ replayed, err := request.GetBody()
+ require.NoError(t, err)
+ bytesRead, _ := io.ReadAll(replayed)
+ assert.Equal(t, "body", string(bytesRead))
+ require.NoError(t, replayed.Close())
+
+ Install(request, []byte("new"))
+ bytesRead, _ = io.ReadAll(request.Body)
+ assert.Equal(t, "new", string(bytesRead))
+ Install(nil, nil)
+}
+
+func TestSnapshotConsumedAndStaleBodies(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com", bytes.NewReader([]byte("original")))
+ _, _ = io.ReadAll(request.Body)
+ body, err := Snapshot(request)
+ require.NoError(t, err)
+ assert.Equal(t, "original", string(body))
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("stale"))
+ request.Body = io.NopCloser(bytes.NewBufferString("fresh"))
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Equal(t, "fresh", string(body))
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("stale"))
+ request.Body = io.NopCloser(bytes.NewBufferString("other"))
+ _, _ = io.ReadAll(request.Body)
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Empty(t, body)
+}
+
+func TestSnapshotNilEmptyAndFailures(t *testing.T) {
+ body, err := Snapshot(nil)
+ require.NoError(t, err)
+ assert.Nil(t, body)
+ request := &http.Request{Body: http.NoBody, GetBody: func() (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewBufferString("stale")), nil
+ }}
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Empty(t, body)
+ replayed, _ := request.GetBody()
+ replayedBytes, _ := io.ReadAll(replayed)
+ assert.Equal(t, "stale", string(replayedBytes), "http.NoBody must not mutate an unrelated GetBody function")
+
+ request = &http.Request{GetBody: func() (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewBufferString("from get body")), nil
+ }}
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Equal(t, "from get body", string(body))
+ installed, readErr := io.ReadAll(request.Body)
+ require.NoError(t, readErr)
+ assert.Equal(t, "from get body", string(installed))
+
+ request = &http.Request{}
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Nil(t, body)
+ assert.Nil(t, request.Body)
+
+ request = &http.Request{GetBody: func() (io.ReadCloser, error) { return nil, errors.New("get body failed") }}
+ _, err = Snapshot(request)
+ assert.ErrorContains(t, err, "get body failed")
+ request = &http.Request{GetBody: func() (io.ReadCloser, error) { return nil, nil }}
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Nil(t, body)
+ request = &http.Request{GetBody: func() (io.ReadCloser, error) {
+ return &errorBody{readErr: errors.New("get body read failed")}, nil
+ }}
+ _, err = Snapshot(request)
+ assert.ErrorContains(t, err, "get body read failed")
+
+ request = &http.Request{Body: &errorBody{readErr: errors.New("read failed")}}
+ _, err = Snapshot(request)
+ assert.ErrorContains(t, err, "read failed")
+ request = &http.Request{Body: &failingReplayable{}}
+ body, err = Snapshot(request)
+ require.NoError(t, err)
+ assert.Empty(t, body)
+
+ var nilBody *errorBody
+ assert.Nil(t, underlyingReader(nilBody))
+ plain := &errorBody{}
+ assert.Same(t, plain, underlyingReader(plain))
+}
+
+func TestWithFreshBody(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com", bytes.NewBufferString("payload"))
+ var reads []string
+ for range 2 {
+ err := WithFreshBody(request, func() error {
+ body, readErr := io.ReadAll(request.Body)
+ reads = append(reads, string(body))
+ return readErr
+ })
+ require.NoError(t, err)
+ }
+ assert.Equal(t, []string{"payload", "payload"}, reads)
+ restored, _ := io.ReadAll(request.Body)
+ assert.Equal(t, "payload", string(restored))
+
+ sentinel := errors.New("callback failed")
+ err := WithFreshBody(request, func() error { return sentinel })
+ assert.ErrorIs(t, err, sentinel)
+ err = WithFreshBody(request, func() error {
+ request.Body = nil
+ return nil
+ })
+ require.NoError(t, err)
+ err = WithFreshBody(request, func() error {
+ request.GetBody = nil
+ return nil
+ })
+ require.NoError(t, err)
+ require.NotNil(t, request.GetBody)
+ replayed, replayErr := request.GetBody()
+ require.NoError(t, replayErr)
+ replayedBody, readErr := io.ReadAll(replayed)
+ require.NoError(t, readErr)
+ assert.Equal(t, "payload", string(replayedBody))
+ assert.NoError(t, WithFreshBody(nil, func() error { return nil }))
+ assert.NoError(t, WithFreshBody(&http.Request{Body: http.NoBody}, func() error { return nil }))
+}
+
+func TestWithFreshBodyFailures(t *testing.T) {
+ request := &http.Request{Body: &errorBody{readErr: errors.New("snapshot failed")}}
+ err := WithFreshBody(request, func() error { return nil })
+ assert.ErrorContains(t, err, "snapshot failed")
+
+ request = &http.Request{Body: io.NopCloser(bytes.NewReader(nil)), GetBody: func() (io.ReadCloser, error) {
+ return nil, errors.New("fresh failed")
+ }}
+ err = WithFreshBody(request, func() error { return nil })
+ assert.ErrorContains(t, err, "fresh failed")
+
+ newRestoreFailureRequest := func() *http.Request {
+ calls := 0
+ return &http.Request{Body: io.NopCloser(bytes.NewReader(nil)), GetBody: func() (io.ReadCloser, error) {
+ calls++
+ if calls == 1 {
+ return io.NopCloser(bytes.NewBufferString("body")), nil
+ }
+ return nil, errors.New("restore failed")
+ }}
+ }
+ request = newRestoreFailureRequest()
+ err = WithFreshBody(request, func() error { return nil })
+ assert.ErrorContains(t, err, "restore failed")
+
+ request = newRestoreFailureRequest()
+ callbackFailure := errors.New("callback also failed")
+ err = WithFreshBody(request, func() error { return callbackFailure })
+ assert.ErrorContains(t, err, "restore failed")
+ assert.ErrorIs(t, err, callbackFailure)
+}
+
+type errorBody struct {
+ readErr error
+}
+
+func (e *errorBody) Read([]byte) (int, error) {
+ if e.readErr != nil {
+ return 0, e.readErr
+ }
+ return 0, io.EOF
+}
+
+func (*errorBody) Close() error { return nil }
+
+type failingReplayable struct{ errorBody }
+
+func (*failingReplayable) ReadAt([]byte, int64) (int, error) { return 0, errors.New("read-at failed") }
+func (*failingReplayable) Size() int64 { return 4 }
diff --git a/internal/requeststate/route.go b/internal/requeststate/route.go
new file mode 100644
index 00000000..e46f25fd
--- /dev/null
+++ b/internal/requeststate/route.go
@@ -0,0 +1,39 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package requeststate
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/pb33f/libopenapi-validator/router"
+)
+
+type routeContextKey struct{}
+
+// Route returns the route scoped to the current high-level validation call.
+func Route(request *http.Request) *router.Route {
+ if request == nil {
+ return nil
+ }
+ route, _ := request.Context().Value(routeContextKey{}).(*router.Route)
+ return route
+}
+
+// AttachRoute scopes a resolved route to a request and returns an idempotent restoration function.
+func AttachRoute(request *http.Request, route *router.Route) func() {
+ if request == nil || route == nil {
+ return func() {}
+ }
+ original := request.Context()
+ *request = *request.WithContext(context.WithValue(original, routeContextKey{}, route))
+ restored := false
+ return func() {
+ if restored {
+ return
+ }
+ restored = true
+ *request = *request.WithContext(original)
+ }
+}
diff --git a/internal/requeststate/route_test.go b/internal/requeststate/route_test.go
new file mode 100644
index 00000000..1aebf1d6
--- /dev/null
+++ b/internal/requeststate/route_test.go
@@ -0,0 +1,38 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package requeststate
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/pb33f/testify/assert"
+
+ "github.com/pb33f/libopenapi-validator/router"
+)
+
+func TestAttachRouteScopesAndRestoresRequestContext(t *testing.T) {
+ type contextKey struct{}
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items", nil)
+ original := context.WithValue(request.Context(), contextKey{}, "original")
+ *request = *request.WithContext(original)
+ first := &router.Route{Path: "/items"}
+ second := &router.Route{Path: "/other"}
+
+ restoreFirst := AttachRoute(request, first)
+ assert.Same(t, first, Route(request))
+ restoreSecond := AttachRoute(request, second)
+ assert.Same(t, second, Route(request))
+ restoreSecond()
+ assert.Same(t, first, Route(request))
+ restoreFirst()
+ restoreFirst()
+ assert.Nil(t, Route(request))
+ assert.Equal(t, "original", request.Context().Value(contextKey{}))
+
+ AttachRoute(nil, first)()
+ AttachRoute(request, nil)()
+ assert.Nil(t, Route(nil))
+}
diff --git a/kin_parity_test.go b/kin_parity_test.go
new file mode 100644
index 00000000..c4b2a100
--- /dev/null
+++ b/kin_parity_test.go
@@ -0,0 +1,776 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package validator
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+ "go.yaml.in/yaml/v4"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
+ validationerrors "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
+ "github.com/pb33f/libopenapi-validator/router"
+)
+
+const defaultsSpec = `openapi: 3.1.0
+info: {title: defaults, version: 1.0.0}
+paths:
+ /items:
+ post:
+ parameters:
+ - {name: limit, in: query, schema: {type: integer, default: 10}}
+ - name: tags
+ in: query
+ explode: true
+ schema: {type: array, items: {type: string}, default: [a, b]}
+ - {name: X-Mode, in: header, schema: {type: string, default: safe}}
+ - {name: session, in: cookie, schema: {type: string, default: abc}}
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [requiredName]
+ properties:
+ requiredName: {type: string}
+ mode: {type: [string, "null"], default: fast}
+ nested:
+ type: object
+ properties:
+ count: {type: integer, default: 2}
+ list:
+ type: array
+ items:
+ type: object
+ properties:
+ enabled: {type: boolean, default: true}
+ branch:
+ oneOf:
+ - type: object
+ properties:
+ ignored: {type: string, default: no}
+ allOf:
+ - type: object
+ properties:
+ inherited: {type: string, default: yes}
+ responses:
+ "200":
+ description: ok
+ headers:
+ X-Rate: {required: true, schema: {type: integer}}
+ content:
+ application/json:
+ schema: {type: object, required: [ok], properties: {ok: {type: boolean}}}
+ /undeclared:
+ post:
+ responses: {"204": {description: ok}}
+`
+
+func parityValidator(t *testing.T, specification string, opts ...config.Option) Validator {
+ t.Helper()
+ document, err := libopenapi.NewDocument([]byte(specification))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ return NewValidatorFromV3Model(&model.Model, opts...)
+}
+
+func TestRequestDefaultsAtomicSuccessSyncAndAsync(t *testing.T) {
+ for _, synchronous := range []bool{true, false} {
+ t.Run(map[bool]string{true: "sync", false: "async"}[synchronous], func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults())
+ t.Cleanup(v.Release)
+ request, err := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item","nested":{},"list":[{}],"branch":{}}`))
+ require.NoError(t, err)
+ request.Header.Set("Content-Type", "application/json")
+ var valid bool
+ var validationErrors []*ValidationErrorAlias
+ if synchronous {
+ valid, validationErrors = validateSync(v, request)
+ } else {
+ valid, validationErrors = validateAsync(v, request)
+ }
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+ assert.Equal(t, "10", request.URL.Query().Get("limit"))
+ assert.Equal(t, []string{"a", "b"}, request.URL.Query()["tags"])
+ assert.Equal(t, "safe", request.Header.Get("X-Mode"))
+ cookie, cookieErr := request.Cookie("session")
+ require.NoError(t, cookieErr)
+ assert.Equal(t, "abc", cookie.Value)
+ body, readErr := io.ReadAll(request.Body)
+ require.NoError(t, readErr)
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(body, &decoded))
+ assert.Equal(t, "fast", decoded["mode"])
+ assert.Equal(t, float64(2), decoded["nested"].(map[string]any)["count"])
+ assert.Equal(t, true, decoded["list"].([]any)[0].(map[string]any)["enabled"])
+ assert.Equal(t, "yes", decoded["inherited"])
+ assert.NotContains(t, decoded["branch"].(map[string]any), "ignored")
+ replayed, replayErr := request.GetBody()
+ require.NoError(t, replayErr)
+ replayedBody, _ := io.ReadAll(replayed)
+ assert.Equal(t, body, replayedBody)
+ assert.Equal(t, int64(len(body)), request.ContentLength)
+ })
+ }
+}
+
+type ValidationErrorAlias = validationerrors.ValidationError
+
+func validateSync(v Validator, request *http.Request) (bool, []*ValidationErrorAlias) {
+ return v.ValidateHttpRequestSync(request)
+}
+
+func validateAsync(v Validator, request *http.Request) (bool, []*ValidationErrorAlias) {
+ return v.ValidateHttpRequest(request)
+}
+
+func TestRequestDefaultsPreserveExplicitNullAndDisabledMode(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item","mode":null}`))
+ request.Header.Set("Content-Type", "application/json")
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults())
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ body, _ := io.ReadAll(request.Body)
+ assert.Contains(t, string(body), `"mode":null`)
+ v.Release()
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item"}`))
+ request.Header.Set("Content-Type", "application/json")
+ v = parityValidator(t, defaultsSpec)
+ valid, validationErrors = v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ assert.Empty(t, request.URL.RawQuery)
+ assert.Empty(t, request.Header.Get("X-Mode"))
+ body, _ = io.ReadAll(request.Body)
+ assert.NotContains(t, string(body), "mode")
+ v.Release()
+}
+
+func TestRequestDefaultsPreserveExplicitEmptyHeader(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item"}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header["X-Mode"] = []string{""}
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults())
+ t.Cleanup(v.Release)
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ assert.Equal(t, []string{""}, request.Header.Values("X-Mode"))
+}
+
+func TestRequestDefaultsRejectUnsupportedObjectParameterSerialization(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: object default, version: 1.0.0}
+paths:
+ /items:
+ get:
+ parameters:
+ - name: filter
+ in: query
+ schema: {type: object, default: {active: true}}
+ responses: {"204": {description: ok}}`
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items", nil)
+ v := parityValidator(t, spec, config.WithRequestDefaults()).(*validator)
+ t.Cleanup(v.Release)
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "unsupported object serialization")
+ assert.Empty(t, request.URL.RawQuery)
+
+ explicit, _ := http.NewRequest(http.MethodGet, "http://example.com/items?filter=active%2Ctrue", nil)
+ pathItem := v.v3Model.Paths.PathItems.GetOrZero("/items")
+ staged, stageErrors := v.stageRequestDefaults(explicit, pathItem)
+ require.Empty(t, stageErrors)
+ require.NotNil(t, staged)
+ assert.Equal(t, "filter=active%2Ctrue", staged.URL.RawQuery)
+
+ assert.False(t, containsObjectDefault("value"))
+ assert.False(t, containsObjectDefault([]any{"value", 1.0}))
+ assert.True(t, containsObjectDefault([]any{map[string]any{"active": true}}))
+ assert.True(t, headerExists(http.Header{"x-direct": {""}}, "X-Direct"))
+ assert.False(t, headerExists(http.Header{}, "missing"))
+ assert.False(t, parameterNeedsDefault(nil, nil, true))
+ assert.False(t, parameterNeedsDefault(request, &v3.Parameter{In: helpers.Path}, true))
+}
+
+func TestHighLevelValidationResolvesRouteOnce(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: route sharing, version: 1.0.0}
+components:
+ securitySchemes:
+ first: {type: apiKey, in: header, name: X-First}
+ second: {type: apiKey, in: header, name: X-Second}
+paths:
+ /items/{id}:
+ get:
+ security: [{first: [], second: []}]
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: {type: string}
+ - name: filter
+ in: query
+ content: {application/json: {schema: {type: boolean}}}
+ responses: {"204": {description: ok}}`
+ decoder := func(_ context.Context, input *config.ContentParameterInput) (any, *base.Schema, error) {
+ assert.Equal(t, "abc", input.PathParams["id"])
+ return true, input.DefaultSchema, nil
+ }
+ v := parityValidator(t, spec,
+ config.WithAuthenticationFunc(func(context.Context, *config.AuthenticationInput) error { return nil }),
+ config.WithContentParameterDecoder(decoder),
+ ).(*validator)
+ t.Cleanup(v.Release)
+ counter := &countingRouter{Router: v.options.Router}
+ v.options.Router = counter
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/abc?filter=true", nil)
+ valid, validationErrors := v.ValidateHttpRequest(request)
+ require.True(t, valid, validationErrors)
+ assert.Equal(t, int64(1), counter.calls.Load())
+ assert.Nil(t, requeststate.Route(request), "route context must be scoped to validation")
+}
+
+func TestWithPathItemResolvesAndScopesRoute(t *testing.T) {
+ v := parityValidator(t, defaultsSpec).(*validator)
+ t.Cleanup(v.Release)
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item"}`))
+ request.Header.Set("Content-Type", "application/json")
+ pathItem := v.v3Model.Paths.PathItems.GetOrZero("/items")
+ valid, validationErrors := v.ValidateHttpRequestSyncWithPathItem(request, pathItem, "/items")
+ require.True(t, valid, validationErrors)
+ assert.Nil(t, requeststate.Route(request))
+}
+
+type countingRouter struct {
+ router.Router
+ calls atomic.Int64
+}
+
+func (r *countingRouter) FindRoute(request *http.Request) (*router.Route, error) {
+ r.calls.Add(1)
+ return r.Router.FindRoute(request)
+}
+
+func TestRequestDefaultsRollbackOnValidationFailure(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults())
+ t.Cleanup(v.Release)
+ original := `{"nested":{}}`
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items?keep=yes", strings.NewReader(original))
+ request.Header.Set("Content-Type", "application/json")
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ assert.NotEmpty(t, validationErrors)
+ assert.Equal(t, "keep=yes", request.URL.RawQuery)
+ assert.Empty(t, request.Header.Get("X-Mode"))
+ assert.Empty(t, request.Header.Get("Cookie"))
+ body, _ := io.ReadAll(request.Body)
+ assert.JSONEq(t, original, string(body))
+}
+
+func TestRequestDefaultsCustomCodecAndMissingEncoder(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: custom, version: 1.0.0}
+paths:
+ /custom:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/custom:
+ schema:
+ type: object
+ properties: {added: {type: string, default: yes}}
+ responses: {"204": {description: ok}}`
+ decoder := content.DecoderFunc(func(input *content.DecodeInput) (any, error) {
+ var value any
+ err := json.NewDecoder(input.Body).Decode(&value)
+ return value, err
+ })
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/custom", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/custom")
+ v := parityValidator(t, spec, config.WithRequestDefaults(), config.WithBodyDecoder("application/custom", decoder))
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "encoder")
+ body, _ := io.ReadAll(request.Body)
+ assert.Equal(t, `{}`, string(body))
+ v.Release()
+
+ noDefaultSpec := strings.Replace(spec, ", default: yes", "", 1)
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/custom", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/custom")
+ v = parityValidator(t, noDefaultSpec, config.WithRequestDefaults(), config.WithBodyDecoder("application/custom", decoder))
+ valid, validationErrors = v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ v.Release()
+
+ encoder := content.EncoderFunc(func(input *content.EncodeInput) ([]byte, error) { return json.Marshal(input.Value) })
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/custom", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/custom")
+ v = parityValidator(t, spec, config.WithRequestDefaults(), config.WithBodyDecoder("application/custom", decoder), config.WithBodyEncoder("application/custom", encoder))
+ valid, validationErrors = v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ body, _ = io.ReadAll(request.Body)
+ assert.JSONEq(t, `{"added":"yes"}`, string(body))
+ v.Release()
+}
+
+func TestRequestDefaultsForJSONContentParameter(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: content default, version: 1.0.0}
+paths:
+ /content:
+ get:
+ parameters:
+ - name: filter
+ in: query
+ content:
+ application/json:
+ schema:
+ type: object
+ default: {active: true}
+ properties: {active: {type: boolean}}
+ responses: {"204": {description: ok}}`
+ v := parityValidator(t, spec, config.WithRequestDefaults())
+ t.Cleanup(v.Release)
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/content", nil)
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ assert.JSONEq(t, `{"active":true}`, request.URL.Query().Get("filter"))
+}
+
+func TestAuthenticationRouteContextAndReplayableBody(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: auth, version: 1.0.0}
+servers: [{url: /api}]
+components:
+ securitySchemes:
+ first: {type: apiKey, in: header, name: X-Key}
+ second: {type: oauth2, flows: {clientCredentials: {tokenUrl: /token, scopes: {read: Read}}}}
+paths:
+ /secure/{id}:
+ post:
+ security: [{first: [], second: [read]}]
+ parameters: [{name: id, in: path, required: true, schema: {type: string}}]
+ requestBody:
+ required: true
+ content: {application/json: {schema: {type: object, required: [ok], properties: {ok: {type: boolean}}}}}
+ responses: {"204": {description: ok}}`
+ type contextKey string
+ ctx := context.WithValue(context.Background(), contextKey("key"), "value")
+ var schemes []string
+ var bodies []string
+ auth := func(callbackContext context.Context, input *config.AuthenticationInput) error {
+ schemes = append(schemes, input.SecuritySchemeName)
+ body, err := io.ReadAll(input.Request.Body)
+ bodies = append(bodies, string(body))
+ require.NoError(t, err)
+ assert.Equal(t, "value", callbackContext.Value(contextKey("key")))
+ assert.Equal(t, "/secure/{id}", input.Path)
+ assert.Equal(t, "a/b", input.PathParams["id"])
+ assert.NotNil(t, input.PathItem)
+ assert.NotNil(t, input.Operation)
+ assert.NotNil(t, input.Server)
+ assert.Equal(t, "/api", input.Server.URL)
+ return nil
+ }
+ v := parityValidator(t, spec, config.WithAuthenticationFunc(auth))
+ t.Cleanup(v.Release)
+ request, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://example.com/api/secure/a%2Fb", strings.NewReader(`{"ok":true}`))
+ request.Header.Set("Content-Type", "application/json")
+ valid, validationErrors := v.ValidateHttpRequest(request)
+ require.True(t, valid, validationErrors)
+ assert.Equal(t, []string{"first", "second"}, schemes)
+ assert.Equal(t, []string{`{"ok":true}`, `{"ok":true}`}, bodies)
+ body, _ := io.ReadAll(request.Body)
+ assert.JSONEq(t, `{"ok":true}`, string(body))
+}
+
+func TestAuthenticationReceivesCancellation(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: auth cancel, version: 1.0.0}
+components:
+ securitySchemes: {key: {type: apiKey, in: header, name: X-Key}}
+paths:
+ /secure:
+ get:
+ security: [{key: []}]
+ responses: {"204": {description: ok}}`
+ called := false
+ v := parityValidator(t, spec, config.WithAuthenticationFunc(func(ctx context.Context, _ *config.AuthenticationInput) error {
+ called = true
+ assert.ErrorIs(t, ctx.Err(), context.Canceled)
+ return ctx.Err()
+ }))
+ t.Cleanup(v.Release)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com/secure", nil)
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ assert.True(t, called)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, context.Canceled.Error())
+}
+
+func TestUndeclaredBodyAndHighLevelPolicies(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/undeclared", strings.NewReader("payload"))
+ v := parityValidator(t, defaultsSpec)
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ v.Release()
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/undeclared", strings.NewReader("payload"))
+ v = parityValidator(t, defaultsSpec, config.WithRejectUndeclaredRequestBody())
+ valid, validationErrors = v.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ body, _ := io.ReadAll(request.Body)
+ assert.Equal(t, "payload", string(body))
+ v.Release()
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/undeclared", strings.NewReader("payload"))
+ v = parityValidator(t, defaultsSpec, config.WithRejectUndeclaredRequestBody(), config.WithoutRequestBodyValidation())
+ valid, validationErrors = v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ v.Release()
+}
+
+func TestQueryResponseBodyAndStatusExclusions(t *testing.T) {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items?limit=bad", strings.NewReader(`{"requiredName":"item"}`))
+ request.Header.Set("Content-Type", "application/json")
+ v := parityValidator(t, defaultsSpec, config.WithoutRequestQueryParameterValidation())
+ valid, validationErrors := v.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ v.Release()
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"item"}`))
+ request.Header.Set("Content-Type", "application/json")
+ response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}, "X-Rate": {"2"}}, Body: io.NopCloser(strings.NewReader(`{"ok":"wrong"}`))}
+ v = parityValidator(t, defaultsSpec, config.WithoutResponseBodyValidation())
+ valid, validationErrors = v.ValidateHttpResponse(request, response)
+ require.True(t, valid, validationErrors)
+ body, _ := io.ReadAll(response.Body)
+ assert.JSONEq(t, `{"ok":"wrong"}`, string(body))
+ v.Release()
+
+ response = &http.Response{StatusCode: 299, Header: make(http.Header), Body: http.NoBody}
+ v = parityValidator(t, defaultsSpec, config.WithoutResponseStatusValidation())
+ valid, validationErrors = v.ValidateHttpResponse(request, response)
+ require.True(t, valid, validationErrors)
+ v.Release()
+}
+
+func TestRequestDefaultPreparationErrors(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults())
+ concrete := v.(*validator)
+ valid, validationErrors := concrete.validateWithRequestDefaults(nil, nil, "", concrete.validateHttpRequestSyncWithPathItem)
+ assert.False(t, valid)
+ assert.NotEmpty(t, validationErrors)
+ v.Release()
+
+ assert.Equal(t, "x", serializeDefault("x", ","))
+ assert.Nil(t, requestMediaType(nil, "application/json"))
+ changed, err := applySchemaDefaults(nil, nil)
+ require.NoError(t, err)
+ assert.False(t, changed)
+}
+
+func TestRequestDefaultBranchCoverage(t *testing.T) {
+ t.Run("unreadable body", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ defer v.Release()
+ request := &http.Request{Method: http.MethodPost, URL: mustURL(t, "http://example.com/items"), Header: make(http.Header), Body: &parityErrorBody{}}
+ _, errs := v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "staged")
+ })
+
+ t.Run("operation and empty body exits", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ defer v.Release()
+ request := &http.Request{Method: http.MethodGet, URL: mustURL(t, "http://example.com/none")}
+ staged, errs := v.stageRequestDefaults(request, &v3.PathItem{})
+ require.Empty(t, errs)
+ assert.NotNil(t, staged.Header)
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", http.NoBody)
+ staged, errs = v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.Empty(t, errs)
+ assert.NotNil(t, staged)
+ })
+
+ t.Run("media and codec failures", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ item := v.v3Model.Paths.PathItems.GetOrZero("/items")
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"x"}`))
+ request.Header.Set("Content-Type", "text/plain")
+ staged, errs := v.stageRequestDefaults(request, item)
+ require.Empty(t, errs)
+ assert.NotNil(t, staged)
+ v.Release()
+
+ yamlSpec := strings.Replace(defaultsSpec, "application/json:", "application/yaml:", 1)
+ v = parityValidator(t, yamlSpec, config.WithRequestDefaults()).(*validator)
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader("requiredName: x"))
+ request.Header.Set("Content-Type", "application/yaml")
+ _, errs = v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "decoder")
+ v.Release()
+
+ decodeFailure := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return nil, assert.AnError })
+ v = parityValidator(t, defaultsSpec, config.WithRequestDefaults(), config.WithBodyDecoder("application/json", decodeFailure)).(*validator)
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ _, errs = v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "decoded")
+ v.Release()
+
+ canonicalFailure := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return map[any]any{1: "bad"}, nil })
+ v = parityValidator(t, defaultsSpec, config.WithRequestDefaults(), config.WithBodyDecoder("application/json", canonicalFailure)).(*validator)
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ _, errs = v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "canonicalized")
+ v.Release()
+ })
+
+ t.Run("apply and encode failures", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ item := v.v3Model.Paths.PathItems.GetOrZero("/items")
+ schema := item.Post.RequestBody.Content.GetOrZero("application/json").Schema.Schema()
+ mode := schema.Properties.GetOrZero("mode").Schema()
+ mode.Default = &yaml.Node{Kind: 99}
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"x"}`))
+ request.Header.Set("Content-Type", "application/json")
+ _, errs := v.stageRequestDefaults(request, item)
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "applied")
+ v.Release()
+
+ encodeFailure := content.EncoderFunc(func(*content.EncodeInput) ([]byte, error) { return nil, assert.AnError })
+ v = parityValidator(t, defaultsSpec, config.WithRequestDefaults(), config.WithBodyEncoder("application/json", encodeFailure)).(*validator)
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(`{"requiredName":"x"}`))
+ request.Header.Set("Content-Type", "application/json")
+ _, errs = v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "encoded")
+ v.Release()
+ })
+
+ t.Run("unchanged body needs no rewrite", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ defer v.Release()
+ body := `{"requiredName":"x","mode":"set","nested":{"count":1},"list":[{"enabled":false}],"inherited":"set"}`
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ staged, errs := v.stageRequestDefaults(request, v.v3Model.Paths.PathItems.GetOrZero("/items"))
+ require.Empty(t, errs)
+ stagedBody, _ := io.ReadAll(staged.Body)
+ assert.JSONEq(t, body, string(stagedBody))
+ })
+}
+
+func TestRequestDefaultPureHelpers(t *testing.T) {
+ query := make(url.Values)
+ parameter := &v3.Parameter{Name: "items"}
+ writeQueryDefault(query, parameter, []any{"a", "b"})
+ assert.Equal(t, "a,b", query.Get("items"))
+ assert.Equal(t, "a|b", serializeDefault([]any{"a", "b"}, "|"))
+ encoded := serializeContentDefault(map[string]any{"ok": true})
+ assert.JSONEq(t, `{"ok":true}`, encoded)
+ assert.Nil(t, requestMediaType(&v3.Operation{}, "application/json"))
+ assert.Nil(t, requestMediaType(&v3.Operation{RequestBody: &v3.RequestBody{}}, "application/json"))
+
+ wildcardSpec := `openapi: 3.1.0
+info: {title: wildcard, version: 1.0.0}
+paths:
+ /wild:
+ parameters:
+ - name: filter
+ in: query
+ content: {application/vnd.filter+json: {schema: {type: object}}}
+ post:
+ requestBody:
+ content:
+ application/*: {schema: {type: string}}
+ responses: {"204": {description: ok}}`
+ v := parityValidator(t, wildcardSpec).(*validator)
+ operation := v.v3Model.Paths.PathItems.GetOrZero("/wild").Post
+ assert.NotNil(t, requestMediaType(operation, "application/json"))
+ assert.Nil(t, requestMediaType(operation, "invalid"))
+ assert.Nil(t, requestMediaType(operation, "text/plain"))
+ v.Release()
+
+ badNode := &yaml.Node{Kind: 99}
+ _, err := defaultValue(badNode)
+ assert.Error(t, err)
+ var nonStringDefault yaml.Node
+ require.NoError(t, yaml.Unmarshal([]byte("1: value"), &nonStringDefault))
+ _, err = defaultValue(nonStringDefault.Content[0])
+ assert.Error(t, err)
+}
+
+func TestRequestDefaultParameterAndTraversalEdges(t *testing.T) {
+ t.Run("parameter skips and malformed default", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec, config.WithRequestDefaults()).(*validator)
+ defer v.Release()
+ item := v.v3Model.Paths.PathItems.GetOrZero("/items")
+ item.Post.Parameters = append(item.Post.Parameters, nil, &v3.Parameter{})
+ limit := item.Post.Parameters[0].Schema.Schema()
+ original := limit.Default
+ limit.Default = nil
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/items", http.NoBody)
+ _, errs := v.stageRequestDefaults(request, item)
+ require.Empty(t, errs)
+ limit.Default = &yaml.Node{Kind: 99}
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items", http.NoBody)
+ _, errs = v.stageRequestDefaults(request, item)
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "parameter default")
+ limit.Default = original
+ })
+
+ t.Run("schema traversal error and skip branches", func(t *testing.T) {
+ v := parityValidator(t, defaultsSpec).(*validator)
+ defer v.Release()
+ schema := v.v3Model.Paths.PathItems.GetOrZero("/items").Post.RequestBody.Content.GetOrZero("application/json").Schema.Schema()
+ schema.AllOf = append(schema.AllOf, nil)
+ schema.Properties.Set("nil-proxy", nil)
+ schema.Properties.Set("empty-proxy", &base.SchemaProxy{})
+ mode := schema.Properties.GetOrZero("mode").Schema()
+ readOnly := true
+ mode.ReadOnly = &readOnly
+ changed, err := applySchemaDefaults(map[string]any{}, schema)
+ require.NoError(t, err)
+ assert.True(t, changed)
+ assert.NotNil(t, mode.ReadOnly)
+
+ inherited := schema.AllOf[0].Schema().Properties.GetOrZero("inherited").Schema()
+ inherited.Default = &yaml.Node{Kind: 99}
+ _, err = applySchemaDefaults(map[string]any{}, schema)
+ assert.Error(t, err)
+
+ inherited.Default = nil
+ nested := schema.Properties.GetOrZero("nested").Schema().Properties.GetOrZero("count").Schema()
+ nested.Default = &yaml.Node{Kind: 99}
+ _, err = applySchemaDefaults(map[string]any{"nested": map[string]any{}}, schema)
+ assert.Error(t, err)
+
+ nested.Default = nil
+ itemSchema := schema.Properties.GetOrZero("list").Schema().Items.A.Schema()
+ itemSchema.Properties.GetOrZero("enabled").Schema().Default = &yaml.Node{Kind: 99}
+ _, err = applySchemaDefaults([]any{map[string]any{}}, schema.Properties.GetOrZero("list").Schema())
+ assert.Error(t, err)
+ })
+}
+
+func TestRequestDefaultAllOfConflictIsDeterministic(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: conflicts, version: 1.0.0}
+components:
+ schemas:
+ Conflict:
+ type: object
+ allOf:
+ - type: object
+ properties: {value: {type: string, default: first}}
+ - type: object
+ properties: {value: {type: string, default: second}}`
+ document, err := libopenapi.NewDocument([]byte(spec))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ schema := model.Model.Components.Schemas.GetOrZero("Conflict").Schema()
+ value := map[string]any{}
+ changed, err := applySchemaDefaults(value, schema)
+ require.NoError(t, err)
+ assert.True(t, changed)
+ assert.Equal(t, "first", value["value"])
+}
+
+func TestValidatorConstructorRegexCacheAndAuthReadError(t *testing.T) {
+ cache := &sync.Map{}
+ v := parityValidator(t, defaultsSpec, config.WithRegexCache(cache))
+ v.Release()
+
+ authSpec := `openapi: 3.1.0
+info: {title: auth, version: 1.0.0}
+components:
+ securitySchemes: {key: {type: apiKey, in: header, name: X-Key}}
+paths:
+ /secure:
+ post:
+ security: [{key: []}]
+ requestBody: {content: {application/json: {schema: {type: object}}}}
+ responses: {"204": {description: ok}}`
+ v = parityValidator(t, authSpec, config.WithAuthenticationFunc(func(context.Context, *config.AuthenticationInput) error { return nil }))
+ request := &http.Request{Method: http.MethodPost, URL: mustURL(t, "http://example.com/secure"), Header: http.Header{"Content-Type": {"application/json"}}, Body: &parityErrorBody{}}
+ valid, errs := v.ValidateHttpRequest(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, errs)
+ assert.Contains(t, errs[0].Message, "authentication")
+ v.Release()
+}
+
+func TestHighLevelStrictServerMatchingOptIn(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: strict, version: 1.0.0}
+servers: [{url: https://api.example.com/v1}]
+paths:
+ /things:
+ get: {responses: {"204": {description: ok}}}`
+ compatible := parityValidator(t, spec)
+ request, _ := http.NewRequest(http.MethodGet, "http://wrong.example.com/v1/things", nil)
+ valid, validationErrors := compatible.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ compatible.Release()
+
+ strict := parityValidator(t, spec, config.WithStrictServerMatching())
+ request, _ = http.NewRequest(http.MethodGet, "http://wrong.example.com/v1/things", nil)
+ valid, validationErrors = strict.ValidateHttpRequestSync(request)
+ assert.False(t, valid)
+ assert.NotEmpty(t, validationErrors)
+ request, _ = http.NewRequest(http.MethodGet, "https://api.example.com/v1/things", nil)
+ valid, validationErrors = strict.ValidateHttpRequestSync(request)
+ require.True(t, valid, validationErrors)
+ strict.Release()
+}
+
+func mustURL(t *testing.T, value string) *url.URL {
+ t.Helper()
+ parsed, err := url.Parse(value)
+ require.NoError(t, err)
+ return parsed
+}
+
+type parityErrorBody struct{}
+
+func (*parityErrorBody) Read([]byte) (int, error) { return 0, assert.AnError }
+func (*parityErrorBody) Close() error { return nil }
diff --git a/parameters/content_parameter.go b/parameters/content_parameter.go
new file mode 100644
index 00000000..6133fc5e
--- /dev/null
+++ b/parameters/content_parameter.go
@@ -0,0 +1,167 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package parameters
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/libopenapi/orderedmap"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
+)
+
+func (v *paramValidator) validateContentParameters(request *http.Request, pathItem *v3.PathItem, pathValue, location string) []*errors.ValidationError {
+ if v.options == nil || (location == helpers.Query && v.options.ContentParameterDecoder == nil) ||
+ (location != helpers.Query && !v.options.ValidateContentParameters) {
+ return nil
+ }
+ params := helpers.ExtractParamsForOperation(request, pathItem)
+ var validationErrors []*errors.ValidationError
+ var pathParams, serverParams map[string]string
+ routeResolved := false
+ for _, parameter := range params {
+ if parameter == nil || parameter.In != location || parameter.Schema != nil || parameter.Content == nil || orderedmap.Len(parameter.Content) == 0 {
+ continue
+ }
+ if v.options.ContentParameterDecoder != nil && !routeResolved {
+ pathParams, serverParams = v.routeParameters(request)
+ routeResolved = true
+ }
+ raw := rawParameterValues(request, parameter, pathValue, pathParams)
+ if len(raw) == 0 {
+ if parameter.Required != nil && *parameter.Required {
+ validationErrors = append(validationErrors, contentParameterError(parameter, location, "required parameter is missing"))
+ }
+ continue
+ }
+ entry := orderedmap.First(parameter.Content)
+ mediaType := entry.Key()
+ media := entry.Value()
+ var schema *base.Schema
+ if media != nil && media.Schema != nil {
+ schema = media.Schema.Schema()
+ }
+ input := &config.ContentParameterInput{
+ Parameter: parameter, RawValues: raw, MediaType: mediaType, DefaultSchema: schema,
+ Request: request, PathParams: pathParams, ServerVariables: serverParams,
+ }
+ var value any
+ var decodeErr error
+ if v.options.ContentParameterDecoder != nil {
+ value, schema, decodeErr = v.options.ContentParameterDecoder(request.Context(), input)
+ } else if strings.EqualFold(mediaType, helpers.JSONContentType) || strings.HasSuffix(strings.ToLower(mediaType), "+json") {
+ decodeErr = json.Unmarshal([]byte(raw[0]), &value)
+ } else {
+ decodeErr = fmt.Errorf("no content-parameter decoder is registered for %s", mediaType)
+ }
+ if decodeErr != nil {
+ validationErrors = append(validationErrors, contentParameterError(parameter, location, decodeErr.Error()))
+ continue
+ }
+ if schema == nil || schema.GoLow() == nil {
+ validationErrors = append(validationErrors, contentParameterError(parameter, location, "decoded parameter schema has no low-level render identity"))
+ continue
+ }
+ validationErrors = append(validationErrors, ValidateSingleParameterSchema(
+ schema, value, parameterLocationLabel(location)+" parameter", "The "+location+" parameter", parameter.Name,
+ helpers.ParameterValidation, parameterSubtype(location), v.options, pathValue, strings.ToLower(request.Method),
+ )...)
+ }
+ errors.PopulateValidationErrors(validationErrors, request, pathValue)
+ return validationErrors
+}
+
+func parameterLocationLabel(location string) string {
+ if location == "" {
+ return ""
+ }
+ return strings.ToUpper(location[:1]) + location[1:]
+}
+
+func (v *paramValidator) routeParameters(request *http.Request) (map[string]string, map[string]string) {
+ if route := requeststate.Route(request); route != nil {
+ return route.PathParams, route.ServerParams
+ }
+ if v.options != nil && v.options.Router != nil {
+ if route, err := v.options.Router.FindRoute(request); err == nil && route != nil {
+ return route.PathParams, route.ServerParams
+ }
+ }
+ return nil, nil
+}
+
+func rawParameterValues(request *http.Request, parameter *v3.Parameter, pathValue string, routeParams map[string]string) []string {
+ switch parameter.In {
+ case helpers.Query:
+ return request.URL.Query()[parameter.Name]
+ case helpers.Header:
+ return request.Header.Values(parameter.Name)
+ case helpers.Cookie:
+ if cookie, err := request.Cookie(parameter.Name); err == nil {
+ return []string{cookie.Value}
+ }
+ case helpers.Path:
+ if value, ok := routeParams[parameter.Name]; ok {
+ return []string{value}
+ }
+ return pathParameterValues(request.URL.EscapedPath(), pathValue, parameter.Name)
+ }
+ return nil
+}
+
+func pathParameterValues(requestPath, template, target string) []string {
+ expression, err := helpers.GetRegexForPath(template)
+ if err != nil {
+ return nil
+ }
+ matches := expression.FindStringSubmatch(requestPath)
+ indices, err := helpers.BraceIndices(template)
+ if err != nil || len(matches) != len(indices)/2+1 {
+ return nil
+ }
+ for i := 0; i < len(indices); i += 2 {
+ name := template[indices[i]+1 : indices[i+1]-1]
+ name = strings.TrimSuffix(strings.TrimLeft(strings.SplitN(name, ":", 2)[0], ".;"), "*")
+ if name == target {
+ value, err := url.PathUnescape(matches[i/2+1])
+ if err != nil {
+ value = matches[i/2+1]
+ }
+ return []string{value}
+ }
+ }
+ return nil
+}
+
+func contentParameterError(parameter *v3.Parameter, location, reason string) *errors.ValidationError {
+ return &errors.ValidationError{
+ ValidationType: helpers.ParameterValidation, ValidationSubType: parameterSubtype(location),
+ ParameterName: parameter.Name, Message: fmt.Sprintf("%s parameter '%s' could not be decoded", location, parameter.Name),
+ Reason: reason, HowToFix: errors.HowToFixDecodingError, Context: parameter,
+ }
+}
+
+func parameterSubtype(location string) string {
+ switch location {
+ case helpers.Query:
+ return helpers.ParameterValidationQuery
+ case helpers.Header:
+ return helpers.ParameterValidationHeader
+ case helpers.Cookie:
+ return helpers.ParameterValidationCookie
+ case helpers.Path:
+ return helpers.ParameterValidationPath
+ }
+ return helpers.ParameterValidation
+}
diff --git a/parameters/content_parameter_test.go b/parameters/content_parameter_test.go
new file mode 100644
index 00000000..7d57899e
--- /dev/null
+++ b/parameters/content_parameter_test.go
@@ -0,0 +1,359 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package parameters
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "sync/atomic"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ validatorcache "github.com/pb33f/libopenapi-validator/cache"
+ "github.com/pb33f/libopenapi-validator/config"
+ validatorErrors "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
+ "github.com/pb33f/libopenapi-validator/router"
+)
+
+const contentParameterSpec = `openapi: 3.1.0
+info: {title: params, version: 1.0.0}
+paths:
+ /items/{id}:
+ get:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ content: {application/json: {schema: {type: integer, minimum: 1}}}
+ - name: filter
+ in: query
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [active]
+ properties: {active: {type: boolean}}
+ - name: X-Data
+ in: header
+ required: true
+ content: {application/json: {schema: {type: string, minLength: 2}}}
+ - name: preference
+ in: cookie
+ required: true
+ content: {application/json: {schema: {type: boolean}}}
+ responses: {"204": {description: ok}}
+`
+
+func contentParameterModel(t *testing.T, specification string) *v3.Document {
+ t.Helper()
+ document, err := libopenapi.NewDocument([]byte(specification))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ return &model.Model
+}
+
+func TestContentParametersAllLocations(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ routeFinder := router.NewRouter(model, router.WithPathOnlyMatching())
+ opts := config.NewValidationOptions(config.WithContentParameterValidation())
+ opts.Router = routeFinder
+ validator := NewParameterValidator(model, config.WithExistingOpts(opts))
+ t.Cleanup(func() {
+ validator.Release()
+ routeFinder.Release()
+ })
+ request, err := http.NewRequest(http.MethodGet, "http://example.com/items/12?filter="+url.QueryEscape(`{"active":true}`), nil)
+ require.NoError(t, err)
+ request.Header.Set("X-Data", `"ok"`)
+ request.AddCookie(&http.Cookie{Name: "preference", Value: "true"})
+
+ for _, validate := range []func(*http.Request) (bool, []*ValidationErrorAlias){
+ validator.ValidatePathParams, validator.ValidateQueryParams, validator.ValidateHeaderParams, validator.ValidateCookieParams,
+ } {
+ valid, validationErrors := validate(request)
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+ }
+}
+
+type ValidationErrorAlias = validatorErrors.ValidationError
+
+func TestContentParameterMalformedMissingAndSchemaViolation(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ validator := NewParameterValidator(model, config.WithContentParameterValidation())
+ t.Cleanup(validator.Release)
+
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/0?filter=not-json", nil)
+ request.Header.Set("X-Data", `"x"`)
+ request.AddCookie(&http.Cookie{Name: "preference", Value: "not-json"})
+ for _, validate := range []func(*http.Request) (bool, []*ValidationErrorAlias){
+ validator.ValidatePathParams, validator.ValidateQueryParams, validator.ValidateHeaderParams, validator.ValidateCookieParams,
+ } {
+ valid, validationErrors := validate(request)
+ assert.False(t, valid)
+ assert.NotEmpty(t, validationErrors)
+ }
+
+ missing, _ := http.NewRequest(http.MethodGet, "http://example.com/items/2", nil)
+ valid, validationErrors := validator.ValidateQueryParams(missing)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "required")
+ valid, validationErrors = validator.ValidateHeaderParams(missing)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "required")
+}
+
+func TestCustomContentParameterDecoder(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ var inputs []*config.ContentParameterInput
+ decoder := func(ctx context.Context, input *config.ContentParameterInput) (any, *base.Schema, error) {
+ require.NotNil(t, ctx)
+ inputs = append(inputs, input)
+ if input.Parameter.Name == "filter" {
+ return map[string]any{"active": true}, input.DefaultSchema, nil
+ }
+ return nil, input.DefaultSchema, errors.New("custom failure")
+ }
+ validator := NewParameterValidator(model, config.WithContentParameterDecoder(decoder))
+ t.Cleanup(validator.Release)
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/2?filter=one&filter=two", nil)
+ valid, validationErrors := validator.ValidateQueryParams(request)
+ require.True(t, valid, validationErrors)
+ require.Len(t, inputs, 1)
+ assert.Equal(t, []string{"one", "two"}, inputs[0].RawValues)
+ assert.Equal(t, "application/json", inputs[0].MediaType)
+ assert.Same(t, request, inputs[0].Request)
+
+ request.Header.Set("X-Data", `"ok"`)
+ valid, validationErrors = validator.ValidateHeaderParams(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "custom failure")
+}
+
+func TestCustomContentParameterSchemaIdentityAndUnsupportedMedia(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ decoder := func(context.Context, *config.ContentParameterInput) (any, *base.Schema, error) {
+ return map[string]any{"active": true}, &base.Schema{Type: []string{"object"}}, nil
+ }
+ validator := NewParameterValidator(model, config.WithContentParameterDecoder(decoder))
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/2?filter=value", nil)
+ valid, validationErrors := validator.ValidateQueryParams(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "low-level")
+ validator.Release()
+
+ unsupportedSpec := `openapi: 3.1.0
+info: {title: unsupported, version: 1.0.0}
+paths:
+ /items:
+ get:
+ parameters:
+ - name: data
+ in: header
+ content: {text/plain: {schema: {type: string}}}
+ responses: {"204": {description: ok}}`
+ model = contentParameterModel(t, unsupportedSpec)
+ validator = NewParameterValidator(model)
+ request, _ = http.NewRequest(http.MethodGet, "http://example.com/items", nil)
+ valid, validationErrors = validator.ValidateHeaderParams(request)
+ require.True(t, valid, validationErrors)
+ request.Header.Set("data", "value")
+ valid, validationErrors = validator.ValidateHeaderParams(request)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+
+ validator = NewParameterValidator(model, config.WithContentParameterValidation())
+ valid, validationErrors = validator.ValidateHeaderParams(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "no content-parameter decoder")
+ validator.Release()
+}
+
+func TestLegacyBuiltInQueryContentPreservesRepeatedValues(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ validator := NewParameterValidator(model, config.WithContentParameterValidation())
+ t.Cleanup(validator.Release)
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/2?filter=%7B%22active%22%3Atrue%7D&filter=%7B%22active%22%3Afalse%7D", nil)
+ valid, validationErrors := validator.ValidateQueryParams(request)
+ require.True(t, valid, validationErrors)
+ assert.Empty(t, validationErrors)
+}
+
+func TestContentParameterHelpers(t *testing.T) {
+ assert.Equal(t, []string{"a/b"}, pathParameterValues("/items/a%2Fb", "/items/{id}", "id"))
+ assert.Nil(t, pathParameterValues("/items/x", "/items/{", "id"))
+ assert.Nil(t, pathParameterValues("/other/x", "/items/{id}", "id"))
+ assert.Nil(t, pathParameterValues("/items/x", "/items/{id}", "other"))
+ assert.Equal(t, []string{"%zz"}, pathParameterValues("/items/%zz", "/items/{id}", "id"))
+ assert.Empty(t, parameterLocationLabel(""))
+ assert.Equal(t, "Query", parameterLocationLabel("query"))
+ assert.Equal(t, helpers.ParameterValidation, parameterSubtype("unknown"))
+
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/1?a=one&a=two", nil)
+ request.Header.Add("X", "one")
+ request.Header.Add("X", "two")
+ request.AddCookie(&http.Cookie{Name: "cookie", Value: "value"})
+ assert.Equal(t, []string{"one", "two"}, rawParameterValues(request, &v3.Parameter{Name: "a", In: helpers.Query}, "", nil))
+ assert.Equal(t, []string{"one", "two"}, rawParameterValues(request, &v3.Parameter{Name: "X", In: helpers.Header}, "", nil))
+ assert.Equal(t, []string{"value"}, rawParameterValues(request, &v3.Parameter{Name: "cookie", In: helpers.Cookie}, "", nil))
+ assert.Nil(t, rawParameterValues(request, &v3.Parameter{Name: "missing", In: helpers.Cookie}, "", nil))
+ assert.Equal(t, []string{"route"}, rawParameterValues(request, &v3.Parameter{Name: "id", In: helpers.Path}, "/items/{id}", map[string]string{"id": "route"}))
+ assert.Nil(t, rawParameterValues(request, &v3.Parameter{Name: "x", In: "unknown"}, "", nil))
+
+ errorValue := contentParameterError(&v3.Parameter{Name: "x"}, helpers.Header, "bad")
+ assert.Equal(t, "x", errorValue.ParameterName)
+}
+
+func TestCustomContentParameterReceivesServerVariables(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: route context, version: 1.0.0}
+servers:
+ - url: /api/{version}
+ variables: {version: {default: v1}}
+paths:
+ /items:
+ get:
+ parameters:
+ - name: data
+ in: query
+ content: {application/json: {schema: {type: string}}}
+ responses: {"204": {description: ok}}`
+ model := contentParameterModel(t, spec)
+ routeFinder := router.NewRouter(model)
+ called := false
+ decoder := func(_ context.Context, input *config.ContentParameterInput) (any, *base.Schema, error) {
+ called = true
+ assert.Equal(t, "v2", input.ServerVariables["version"])
+ return "ok", input.DefaultSchema, nil
+ }
+ counter := &countingParameterRouter{Router: routeFinder}
+ opts := config.NewValidationOptions(config.WithContentParameterDecoder(decoder))
+ opts.Router = counter
+ validator := NewParameterValidator(model, config.WithExistingOpts(opts))
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/api/v2/items?data=value", nil)
+ resolved, resolveErr := routeFinder.FindRoute(request)
+ require.NoError(t, resolveErr)
+ valid, validationErrors := validator.ValidateQueryParamsWithPathItem(request, resolved.PathItem, resolved.Path)
+ require.True(t, valid, validationErrors)
+ assert.Equal(t, int64(1), counter.calls.Load())
+ counter.calls.Store(0)
+ restoreRoute := requeststate.AttachRoute(request, resolved)
+ defer restoreRoute()
+ valid, validationErrors = validator.ValidateQueryParamsWithPathItem(request, resolved.PathItem, resolved.Path)
+ require.True(t, valid, validationErrors)
+ assert.True(t, called)
+ assert.Zero(t, counter.calls.Load())
+ validator.Release()
+ routeFinder.Release()
+}
+
+type countingSchemaCache struct {
+ inner validatorcache.SchemaCache
+ loads int
+ stores int
+}
+
+func (c *countingSchemaCache) Load(key uint64) (*validatorcache.SchemaCacheEntry, bool) {
+ c.loads++
+ return c.inner.Load(key)
+}
+
+func (c *countingSchemaCache) Store(key uint64, value *validatorcache.SchemaCacheEntry) {
+ c.stores++
+ c.inner.Store(key, value)
+}
+
+func (c *countingSchemaCache) Range(fn func(uint64, *validatorcache.SchemaCacheEntry) bool) {
+ c.inner.Range(fn)
+}
+
+func TestCustomContentParameterSchemaCacheReuse(t *testing.T) {
+ model := contentParameterModel(t, contentParameterSpec)
+ cache := &countingSchemaCache{inner: validatorcache.NewDefaultCache()}
+ decoder := func(_ context.Context, input *config.ContentParameterInput) (any, *base.Schema, error) {
+ return map[string]any{"active": true}, input.DefaultSchema, nil
+ }
+ validator := NewParameterValidator(model, config.WithSchemaCache(cache), config.WithContentParameterDecoder(decoder))
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/2?filter=value", nil)
+ for range 2 {
+ valid, validationErrors := validator.ValidateQueryParams(request)
+ require.True(t, valid, validationErrors)
+ }
+ assert.Equal(t, 1, cache.stores)
+ assert.GreaterOrEqual(t, cache.loads, 2)
+ validator.Release()
+}
+
+func TestAuthenticationInputUsesSharedRouterContext(t *testing.T) {
+ spec := `openapi: 3.1.0
+info: {title: auth route, version: 1.0.0}
+servers:
+ - url: /api/{version}
+ variables: {version: {default: v1}}
+components:
+ securitySchemes: {key: {type: apiKey, in: header, name: X-Key}}
+paths:
+ /items/{id}:
+ get:
+ security: [{key: []}]
+ responses: {"204": {description: ok}}`
+ model := contentParameterModel(t, spec)
+ routeFinder := router.NewRouter(model)
+ called := false
+ authentication := func(_ context.Context, input *config.AuthenticationInput) error {
+ called = true
+ assert.Equal(t, "/items/{id}", input.Path)
+ assert.Equal(t, "item", input.PathParams["id"])
+ assert.Equal(t, "v2", input.ServerParams["version"])
+ assert.NotNil(t, input.Server)
+ assert.NotNil(t, input.Operation)
+ return nil
+ }
+ counter := &countingParameterRouter{Router: routeFinder}
+ opts := config.NewValidationOptions(config.WithAuthenticationFunc(authentication))
+ opts.Router = counter
+ validator := NewParameterValidator(model, config.WithExistingOpts(opts))
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/api/v2/items/item", nil)
+ resolved, resolveErr := routeFinder.FindRoute(request)
+ require.NoError(t, resolveErr)
+ valid, validationErrors := validator.ValidateSecurityWithPathItem(request, resolved.PathItem, resolved.Path)
+ require.True(t, valid, validationErrors)
+ assert.Equal(t, int64(1), counter.calls.Load())
+ counter.calls.Store(0)
+ restoreRoute := requeststate.AttachRoute(request, resolved)
+ defer restoreRoute()
+ valid, validationErrors = validator.ValidateSecurityWithPathItem(request, resolved.PathItem, resolved.Path)
+ require.True(t, valid, validationErrors)
+ assert.True(t, called)
+ assert.Zero(t, counter.calls.Load())
+ validator.Release()
+ routeFinder.Release()
+}
+
+type countingParameterRouter struct {
+ router.Router
+ calls atomic.Int64
+}
+
+func (r *countingParameterRouter) FindRoute(request *http.Request) (*router.Route, error) {
+ r.calls.Add(1)
+ return r.Router.FindRoute(request)
+}
diff --git a/parameters/cookie_parameters.go b/parameters/cookie_parameters.go
index c92883a5..add208c2 100644
--- a/parameters/cookie_parameters.go
+++ b/parameters/cookie_parameters.go
@@ -1,4 +1,4 @@
-// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package parameters
@@ -43,7 +43,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request,
}
// extract params for the operation
params := helpers.ExtractParamsForOperation(request, pathItem)
- var validationErrors []*errors.ValidationError
+ validationErrors := v.validateContentParameters(request, pathItem, pathValue, helpers.Cookie)
operation := strings.ToLower(request.Method)
// build a map of cookies from the request for efficient lookup
@@ -53,7 +53,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request,
}
for _, p := range params {
- if p.In == helpers.Cookie {
+ if p.In == helpers.Cookie && p.Schema != nil {
// look up the cookie by name (cookies are case-sensitive)
cookie, found := cookieMap[p.Name]
if !found {
diff --git a/parameters/header_parameters.go b/parameters/header_parameters.go
index 624d07a2..9dea41da 100644
--- a/parameters/header_parameters.go
+++ b/parameters/header_parameters.go
@@ -1,4 +1,4 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package parameters
@@ -45,13 +45,16 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request,
// extract params for the operation
params := helpers.ExtractParamsForOperation(request, pathItem)
- var validationErrors []*errors.ValidationError
+ validationErrors := v.validateContentParameters(request, pathItem, pathValue, helpers.Header)
seenHeaders := make(map[string]bool)
operation := strings.ToLower(request.Method)
for _, p := range params {
if p.In == helpers.Header {
seenHeaders[strings.ToLower(p.Name)] = true
+ if p.Schema == nil {
+ continue
+ }
if param := request.Header.Get(p.Name); param != "" {
var sch *base.Schema
diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go
index c5fd7ac6..e295badb 100644
--- a/parameters/path_parameters.go
+++ b/parameters/path_parameters.go
@@ -1,4 +1,4 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package parameters
@@ -51,9 +51,9 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p
// extract params for the operation
params := helpers.ExtractParamsForOperation(request, pathItem)
- var validationErrors []*errors.ValidationError
+ validationErrors := v.validateContentParameters(request, pathItem, pathValue, helpers.Path)
for _, p := range params {
- if p.In == helpers.Path {
+ if p.In == helpers.Path && p.Schema != nil {
// var paramTemplate string
for x := range pathSegments {
if pathSegments[x] == "" { // skip empty segments
diff --git a/parameters/query_parameters.go b/parameters/query_parameters.go
index 5b24d6dc..1c8086e4 100644
--- a/parameters/query_parameters.go
+++ b/parameters/query_parameters.go
@@ -1,4 +1,4 @@
-// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package parameters
@@ -51,7 +51,7 @@ func (v *paramValidator) ValidateQueryParamsWithPathItem(request *http.Request,
// extract params for the operation
params := helpers.ExtractParamsForOperation(request, pathItem)
queryParams := make(map[string][]*helpers.QueryParam)
- var validationErrors []*errors.ValidationError
+ validationErrors := v.validateContentParameters(request, pathItem, pathValue, helpers.Query)
// build a set of spec parameter names for exact matching
specParamNames := make(map[string]bool)
@@ -91,7 +91,7 @@ func (v *paramValidator) ValidateQueryParamsWithPathItem(request *http.Request,
// look through the params for the query key
doneLooking:
for p := range params {
- if params[p].In == helpers.Query {
+ if params[p].In == helpers.Query && (params[p].Schema != nil || (params[p].Content != nil && v.options.ContentParameterDecoder == nil)) {
contentWrapped := false
var contentType string
diff --git a/parameters/validate_security.go b/parameters/validate_security.go
index 6aab8fe3..3d36f07a 100644
--- a/parameters/validate_security.go
+++ b/parameters/validate_security.go
@@ -9,12 +9,14 @@ import (
"strings"
"github.com/pb33f/libopenapi/datamodel/high/base"
- v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi/orderedmap"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
"github.com/pb33f/libopenapi-validator/paths"
)
@@ -85,7 +87,7 @@ func (v *paramValidator) ValidateSecurityWithPathItem(request *http.Request, pat
}
secScheme := v.document.Components.SecuritySchemes.GetOrZero(secName)
- schemeValid, schemeErrors := v.validateSecurityScheme(secName, secScheme, pair.Value(), sec, request, pathValue)
+ schemeValid, schemeErrors := v.validateSecurityScheme(secName, secScheme, pair.Value(), sec, request, pathValue, pathItem)
if !schemeValid {
requirementSatisfied = false
requirementErrors = append(requirementErrors, schemeErrors...)
@@ -110,9 +112,10 @@ func (v *paramValidator) validateSecurityScheme(
sec *base.SecurityRequirement,
request *http.Request,
pathValue string,
+ pathItem *v3.PathItem,
) (bool, []*errors.ValidationError) {
if v.options.AuthenticationFunc != nil {
- return v.validateAuthenticationFunc(secName, secScheme, scopes, sec, request, pathValue)
+ return v.validateAuthenticationFunc(secName, secScheme, scopes, sec, request, pathValue, pathItem)
}
switch strings.ToLower(secScheme.Type) {
@@ -132,12 +135,36 @@ func (v *paramValidator) validateAuthenticationFunc(
sec *base.SecurityRequirement,
request *http.Request,
pathValue string,
+ pathItem *v3.PathItem,
) (bool, []*errors.ValidationError) {
- authErr := v.options.AuthenticationFunc(request.Context(), &config.AuthenticationInput{
+ input := &config.AuthenticationInput{
Request: request,
SecuritySchemeName: secName,
SecurityScheme: secScheme,
Scopes: scopes,
+ Path: pathValue,
+ PathItem: pathItem,
+ }
+ input.Operation = helpers.ExtractOperation(request, input.PathItem)
+ if route := requeststate.Route(request); route != nil {
+ input.Path = route.Path
+ input.PathItem = route.PathItem
+ input.Operation = route.Operation
+ input.PathParams = route.PathParams
+ input.Server = route.Server
+ input.ServerParams = route.ServerParams
+ } else if v.options.Router != nil {
+ if route, err := v.options.Router.FindRoute(request); err == nil && route != nil {
+ input.Path = route.Path
+ input.PathItem = route.PathItem
+ input.Operation = route.Operation
+ input.PathParams = route.PathParams
+ input.Server = route.Server
+ input.ServerParams = route.ServerParams
+ }
+ }
+ authErr := requeststate.WithFreshBody(request, func() error {
+ return v.options.AuthenticationFunc(request.Context(), input)
})
if authErr == nil {
return true, nil
diff --git a/paths/kin_parity_test.go b/paths/kin_parity_test.go
new file mode 100644
index 00000000..6bfab13f
--- /dev/null
+++ b/paths/kin_parity_test.go
@@ -0,0 +1,71 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package paths
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/router"
+)
+
+func TestFindPathRouterCompatibilityAdapter(t *testing.T) {
+ document, err := libopenapi.NewDocument([]byte(`openapi: 3.1.0
+info: {title: adapter, version: 1.0.0}
+paths:
+ /items/{id}:
+ get: {responses: {"204": {description: ok}}}`))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ routeFinder := router.NewRouter(&model.Model, router.WithPathOnlyMatching())
+ t.Cleanup(routeFinder.Release)
+ options := config.NewValidationOptions()
+ options.Router = routeFinder
+
+ request, _ := http.NewRequest(http.MethodGet, "http://example.com/items/1", nil)
+ legacyRoute, legacyErrors := ResolveRoute(request, &model.Model, nil)
+ require.NotNil(t, legacyRoute)
+ assert.Empty(t, legacyErrors)
+ assert.Equal(t, "/items/{id}", legacyRoute.Path)
+ assert.NotNil(t, legacyRoute.Operation)
+
+ pathItem, validationErrors, path := FindPath(request, &model.Model, options)
+ assert.NotNil(t, pathItem)
+ assert.Empty(t, validationErrors)
+ assert.Equal(t, "/items/{id}", path)
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/items/1", nil)
+ pathItem, validationErrors, path = FindPath(request, &model.Model, options)
+ assert.NotNil(t, pathItem)
+ require.NotEmpty(t, validationErrors)
+ assert.True(t, validationErrors[0].IsOperationMissingError())
+ assert.Equal(t, "/items/{id}", path)
+
+ request, _ = http.NewRequest(http.MethodGet, "http://example.com/missing", nil)
+ pathItem, validationErrors, path = FindPath(request, &model.Model, options)
+ assert.Nil(t, pathItem)
+ require.NotEmpty(t, validationErrors)
+ assert.True(t, validationErrors[0].IsPathMissingError())
+ assert.Empty(t, path)
+ legacyRoute, legacyErrors = ResolveRoute(request, &model.Model, nil)
+ assert.Nil(t, legacyRoute)
+ require.NotEmpty(t, legacyErrors)
+
+ nilOptions := config.NewValidationOptions()
+ nilOptions.Router = nilResultRouter{}
+ nilRoute, nilErrors := ResolveRoute(request, &model.Model, nilOptions)
+ assert.Nil(t, nilRoute)
+ require.NotEmpty(t, nilErrors)
+}
+
+type nilResultRouter struct{}
+
+func (nilResultRouter) FindRoute(*http.Request) (*router.Route, error) { return nil, nil }
+func (nilResultRouter) Release() {}
diff --git a/paths/paths.go b/paths/paths.go
index f99f971a..e15ae59f 100644
--- a/paths/paths.go
+++ b/paths/paths.go
@@ -1,9 +1,10 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package paths
import (
+ stderrors "errors"
"fmt"
"net/http"
"net/url"
@@ -11,13 +12,13 @@ import (
"regexp"
"strings"
- "github.com/pb33f/libopenapi/orderedmap"
-
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/router"
)
// FindPath will find the path in the document that matches the request path. If a successful match was found, then
@@ -33,6 +34,13 @@ import (
// Path matching follows the OpenAPI specification: literal (concrete) paths take precedence over
// parameterized paths, regardless of definition order in the specification.
func FindPath(request *http.Request, document *v3.Document, options *config.ValidationOptions) (*v3.PathItem, []*errors.ValidationError, string) {
+ if options != nil && options.Router != nil {
+ route, validationErrors := ResolveRoute(request, document, options)
+ if route == nil {
+ return nil, validationErrors, ""
+ }
+ return route.PathItem, validationErrors, route.Path
+ }
stripped := StripRequestPath(request, document)
// Fast path: try radix tree first (O(k) where k = path depth)
@@ -117,6 +125,41 @@ func FindPath(request *http.Request, document *v3.Document, options *config.Vali
return bestOverall.pathItem, missingOperationError(request, bestOverall.path), bestOverall.path
}
+// ResolveRoute returns the complete route used by high-level validation.
+// It preserves existing ValidationError shapes while exposing the router's server,
+// operation, path-parameter, and server-variable context to internal callers.
+func ResolveRoute(request *http.Request, document *v3.Document, options *config.ValidationOptions) (*router.Route, []*errors.ValidationError) {
+ if options == nil || options.Router == nil {
+ pathItem, validationErrors, path := FindPath(request, document, options)
+ if pathItem == nil {
+ return nil, validationErrors
+ }
+ return &router.Route{
+ Document: document, Path: path, PathItem: pathItem, Method: request.Method,
+ Operation: helpers.ExtractOperation(request, pathItem),
+ }, validationErrors
+ }
+ route, err := options.Router.FindRoute(request)
+ if err == nil && route != nil {
+ return route, nil
+ }
+ if stderrors.Is(err, router.ErrMethodNotAllowed) && route != nil {
+ return route, missingOperationError(request, route.Path)
+ }
+ validationErrors := []*errors.ValidationError{{
+ ValidationType: helpers.PathValidation,
+ ValidationSubType: helpers.ValidationMissing,
+ Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path),
+ Reason: fmt.Sprintf("The %s request contains a path of '%s' however that path, or the %s method for that path does not exist in the specification",
+ request.Method, request.URL.Path, request.Method),
+ SpecLine: -1,
+ SpecCol: -1,
+ HowToFix: errors.HowToFixPath,
+ }}
+ errors.PopulateValidationErrors(validationErrors, request, "")
+ return nil, validationErrors
+}
+
// normalizePathForMatching removes the fragment from a path template unless
// the request path itself contains a fragment.
func normalizePathForMatching(path, requestPath string) string {
diff --git a/request_defaults.go b/request_defaults.go
new file mode 100644
index 00000000..6e9979e5
--- /dev/null
+++ b/request_defaults.go
@@ -0,0 +1,331 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package validator
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "go.yaml.in/yaml/v4"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/content"
+ "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
+)
+
+type requestValidation func(*http.Request, *v3.PathItem, string) (bool, []*errors.ValidationError)
+
+func (v *validator) validateWithRequestDefaults(request *http.Request, pathItem *v3.PathItem, pathValue string, validate requestValidation) (bool, []*errors.ValidationError) {
+ staged, preparationErrors := v.stageRequestDefaults(request, pathItem)
+ if len(preparationErrors) > 0 {
+ return false, preparationErrors
+ }
+ valid, validationErrors := validate(staged, pathItem, pathValue)
+ if !valid {
+ return false, validationErrors
+ }
+ request.URL = staged.URL
+ request.Header = staged.Header
+ request.Body = staged.Body
+ request.GetBody = staged.GetBody
+ request.ContentLength = staged.ContentLength
+ return true, nil
+}
+
+func (v *validator) stageRequestDefaults(request *http.Request, pathItem *v3.PathItem) (*http.Request, []*errors.ValidationError) {
+ if request == nil {
+ return request, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: "request is nil", Reason: "request defaults require a request",
+ }}
+ }
+ staged := new(http.Request)
+ *staged = *request
+ if request.URL != nil {
+ clonedURL := new(url.URL)
+ *clonedURL = *request.URL
+ staged.URL = clonedURL
+ }
+ staged.Header = request.Header.Clone()
+ if staged.Header == nil {
+ staged.Header = make(http.Header)
+ }
+ body, err := requeststate.Snapshot(request)
+ if err != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body could not be staged", err)}
+ }
+ requeststate.Install(staged, body)
+
+ operation := helpers.ExtractOperation(staged, pathItem)
+ if operation == nil {
+ return staged, nil
+ }
+ for _, parameter := range helpers.ExtractParamsForOperation(staged, pathItem) {
+ if parameter == nil {
+ continue
+ }
+ var schema *base.Schema
+ contentWrapped := false
+ if parameter.Schema != nil {
+ schema = parameter.Schema.Schema()
+ } else if parameter.Content != nil && parameter.Content.First() != nil {
+ mediaType := parameter.Content.First().Value()
+ if mediaType != nil && mediaType.Schema != nil {
+ schema = mediaType.Schema.Schema()
+ contentWrapped = true
+ }
+ }
+ if schema == nil || schema.Default == nil {
+ continue
+ }
+ if !parameterNeedsDefault(staged, parameter, v.options.ValidateRequestQuery) {
+ continue
+ }
+ value, decodeErr := defaultValue(schema.Default)
+ if decodeErr != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("parameter default could not be decoded", decodeErr)}
+ }
+ if !contentWrapped && containsObjectDefault(value) {
+ return nil, []*errors.ValidationError{requestDefaultError(
+ "parameter default uses unsupported object serialization",
+ fmt.Errorf("parameter %q must use Parameter.content or a scalar/array default", parameter.Name),
+ )}
+ }
+ serialized := serializeDefault(value, ",")
+ if contentWrapped {
+ serialized = serializeContentDefault(value)
+ }
+ switch parameter.In {
+ case helpers.Query:
+ query := staged.URL.Query()
+ if contentWrapped {
+ query.Set(parameter.Name, serialized)
+ } else {
+ writeQueryDefault(query, parameter, value)
+ }
+ staged.URL.RawQuery = query.Encode()
+ case helpers.Header:
+ staged.Header.Set(parameter.Name, serialized)
+ case helpers.Cookie:
+ staged.AddCookie(&http.Cookie{Name: parameter.Name, Value: serialized})
+ }
+ }
+
+ if !v.options.ValidateRequestBody || operation.RequestBody == nil || len(body) == 0 {
+ return staged, nil
+ }
+ contentType, _ := content.NormalizeMediaType(staged.Header.Get(helpers.ContentTypeHeader))
+ mediaType := requestMediaType(operation, contentType)
+ if mediaType == nil || mediaType.Schema == nil {
+ return staged, nil
+ }
+ decoder, normalized, parameters := v.options.BodyRegistry.Decoder(contentType)
+ if decoder == nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body defaults require a decoder", fmt.Errorf("no decoder for %s", contentType))}
+ }
+ schema := mediaType.Schema.Schema()
+ decoded, decodeErr := decoder.Decode(&content.DecodeInput{
+ Context: staged.Context(), Body: bytes.NewReader(body), Header: staged.Header,
+ MediaType: normalized, Parameters: parameters, Schema: schema, Encoding: mediaType.Encoding, Direction: content.Request,
+ })
+ if decodeErr != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body could not be decoded for defaults", decodeErr)}
+ }
+ decoded, decodeErr = content.Canonicalize(decoded)
+ if decodeErr != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body could not be canonicalized for defaults", decodeErr)}
+ }
+ changed, applyErr := applySchemaDefaults(decoded, schema)
+ if applyErr != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body defaults could not be applied", applyErr)}
+ }
+ if !changed {
+ return staged, nil
+ }
+ encoder, _, _ := v.options.BodyRegistry.Encoder(contentType)
+ if encoder == nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body defaults require an encoder", fmt.Errorf("no encoder for %s", contentType))}
+ }
+ rewritten, encodeErr := encoder.Encode(&content.EncodeInput{
+ Context: staged.Context(), Value: decoded, Header: staged.Header, MediaType: contentType,
+ Schema: schema, Encoding: mediaType.Encoding, Direction: content.Request,
+ })
+ if encodeErr != nil {
+ return nil, []*errors.ValidationError{requestDefaultError("request body defaults could not be encoded", encodeErr)}
+ }
+ requeststate.Install(staged, rewritten)
+ return staged, nil
+}
+
+func parameterNeedsDefault(request *http.Request, parameter *v3.Parameter, validateQuery bool) bool {
+ if request == nil || parameter == nil {
+ return false
+ }
+ switch parameter.In {
+ case helpers.Query:
+ return validateQuery && request.URL != nil && !request.URL.Query().Has(parameter.Name)
+ case helpers.Header:
+ return !headerExists(request.Header, parameter.Name)
+ case helpers.Cookie:
+ _, err := request.Cookie(parameter.Name)
+ return err != nil
+ }
+ return false
+}
+
+func headerExists(header http.Header, name string) bool {
+ for key := range header {
+ if strings.EqualFold(key, name) {
+ return true
+ }
+ }
+ return false
+}
+
+func containsObjectDefault(value any) bool {
+ switch typed := value.(type) {
+ case map[string]any:
+ return true
+ case []any:
+ for _, item := range typed {
+ if containsObjectDefault(item) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func serializeContentDefault(value any) string {
+ encoded, _ := json.Marshal(value) // defaultValue already proves JSON encodability.
+ return string(encoded)
+}
+
+func requestDefaultError(message string, err error) *errors.ValidationError {
+ return &errors.ValidationError{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: message, Reason: err.Error(), HowToFix: "fix the default, decoder, encoder, or request value before enabling request defaults",
+ }
+}
+
+func defaultValue(node *yaml.Node) (any, error) {
+ var value any
+ if err := node.Decode(&value); err != nil {
+ return nil, err
+ }
+ canonical, err := content.Canonicalize(value)
+ if err != nil {
+ return nil, err
+ }
+ return canonical, nil
+}
+
+func writeQueryDefault(query url.Values, parameter *v3.Parameter, value any) {
+ if values, ok := value.([]any); ok {
+ if parameter.IsExploded() {
+ for _, item := range values {
+ query.Add(parameter.Name, fmt.Sprint(item))
+ }
+ return
+ }
+ query.Set(parameter.Name, serializeDefault(value, ","))
+ return
+ }
+ query.Set(parameter.Name, fmt.Sprint(value))
+}
+
+func serializeDefault(value any, separator string) string {
+ if values, ok := value.([]any); ok {
+ parts := make([]string, len(values))
+ for i, item := range values {
+ parts[i] = fmt.Sprint(item)
+ }
+ return strings.Join(parts, separator)
+ }
+ return fmt.Sprint(value)
+}
+
+func requestMediaType(operation *v3.Operation, mediaType string) *v3.MediaType {
+ if operation == nil || operation.RequestBody == nil || operation.RequestBody.Content == nil {
+ return nil
+ }
+ if exact := operation.RequestBody.Content.GetOrZero(mediaType); exact != nil {
+ return exact
+ }
+ parts := strings.SplitN(mediaType, "/", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+ for pair := operation.RequestBody.Content.First(); pair != nil; pair = pair.Next() {
+ declared := strings.SplitN(strings.ToLower(pair.Key()), "/", 2)
+ if len(declared) == 2 && (declared[0] == "*" || declared[0] == parts[0]) && (declared[1] == "*" || declared[1] == parts[1]) {
+ return pair.Value()
+ }
+ }
+ return nil
+}
+
+func applySchemaDefaults(value any, schema *base.Schema) (bool, error) {
+ if schema == nil {
+ return false, nil
+ }
+ changed := false
+ for _, allOf := range schema.AllOf {
+ if allOf == nil {
+ continue
+ }
+ applied, err := applySchemaDefaults(value, allOf.Schema())
+ if err != nil {
+ return false, err
+ }
+ changed = changed || applied
+ }
+ if object, ok := value.(map[string]any); ok && schema.Properties != nil {
+ for pair := schema.Properties.First(); pair != nil; pair = pair.Next() {
+ if pair.Value() == nil {
+ continue
+ }
+ property := pair.Value().Schema()
+ if property == nil || (property.ReadOnly != nil && *property.ReadOnly) {
+ continue
+ }
+ current, exists := object[pair.Key()]
+ if !exists && property.Default != nil {
+ defaulted, err := defaultValue(property.Default)
+ if err != nil {
+ return false, err
+ }
+ object[pair.Key()] = defaulted
+ changed = true
+ continue
+ }
+ if exists && current != nil {
+ applied, err := applySchemaDefaults(current, property)
+ if err != nil {
+ return false, err
+ }
+ changed = changed || applied
+ }
+ }
+ }
+ if array, ok := value.([]any); ok && schema.Items != nil && schema.Items.IsA() {
+ itemSchema := schema.Items.A.Schema()
+ for _, item := range array {
+ applied, err := applySchemaDefaults(item, itemSchema)
+ if err != nil {
+ return false, err
+ }
+ changed = changed || applied
+ }
+ }
+ return changed, nil
+}
diff --git a/requests/kin_parity_test.go b/requests/kin_parity_test.go
new file mode 100644
index 00000000..e003d73d
--- /dev/null
+++ b/requests/kin_parity_test.go
@@ -0,0 +1,280 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package requests
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
+)
+
+func bodyModel(t testing.TB, mediaType, schema string, declared bool) *v3.Document {
+ t.Helper()
+ requestBody := ""
+ if declared {
+ requestBody = "requestBody:\n required: true\n content:\n " + mediaType + ":\n schema:\n" + indent(schema, 14)
+ }
+ spec := "openapi: 3.1.0\ninfo: {title: body, version: 1.0.0}\npaths:\n /body:\n post:\n " + requestBody + "\n responses: {\"204\": {description: ok}}\n"
+ document, err := libopenapi.NewDocument([]byte(spec))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ return &model.Model
+}
+
+func indent(value string, spaces int) string {
+ prefix := strings.Repeat(" ", spaces)
+ return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix)
+}
+
+func TestUndeclaredRequestBodyPolicy(t *testing.T) {
+ model := bodyModel(t, "", "", false)
+ validator := NewRequestBodyValidator(model, config.WithRejectUndeclaredRequestBody())
+ t.Cleanup(validator.Release)
+
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader("payload"))
+ valid, validationErrors := validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "not declared")
+ body, _ := io.ReadAll(request.Body)
+ assert.Equal(t, "payload", string(body))
+
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/body", http.NoBody)
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+
+ request = &http.Request{Method: http.MethodPost, URL: mustRequestURL(t, "http://example.com/body"), Header: make(http.Header), Body: errorReadCloser{}}
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "inspected")
+}
+
+func TestStandardCustomAndUnsupportedRequestDecoders(t *testing.T) {
+ schema := "type: object\nrequired: [count]\nproperties:\n count: {type: integer}"
+ model := bodyModel(t, "application/yaml", schema, true)
+ validator := NewRequestBodyValidator(model)
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader("count: 2"))
+ request.Header.Set("Content-Type", "application/yaml")
+ valid, validationErrors := validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+
+ unread := &countingReadCloser{}
+ validator = NewRequestBodyValidator(model)
+ request = &http.Request{
+ Method: http.MethodPost, URL: mustRequestURL(t, "http://example.com/body"),
+ Header: http.Header{"Content-Type": {"application/yaml"}}, Body: unread,
+ }
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+ assert.Zero(t, unread.reads)
+ assert.Same(t, unread, request.Body)
+ validator.Release()
+
+ validator = NewRequestBodyValidator(model, config.WithRejectUnsupportedBodyContent())
+ unread = &countingReadCloser{}
+ request = &http.Request{
+ Method: http.MethodPost, URL: mustRequestURL(t, "http://example.com/body"),
+ Header: http.Header{"Content-Type": {"application/yaml"}}, Body: unread,
+ }
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "no registered decoder")
+ assert.Zero(t, unread.reads)
+ assert.Same(t, unread, request.Body)
+ validator.Release()
+
+ validator = NewRequestBodyValidator(model, config.WithStandardBodyDecoders())
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader("count: 2"))
+ request.Header.Set("Content-Type", "application/yaml")
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+ body, _ := io.ReadAll(request.Body)
+ assert.Equal(t, "count: 2", string(body))
+ validator.Release()
+
+ canonicalFailure := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return map[any]any{1: "bad"}, nil })
+ validator = NewRequestBodyValidator(model, config.WithBodyDecoder("application/yaml", canonicalFailure))
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader("count: 2"))
+ request.Header.Set("Content-Type", "application/yaml")
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "could not be decoded")
+ failureContext, ok := validationErrors[0].Context.(*content.FailureContext)
+ require.True(t, ok)
+ assert.Same(t, request, failureContext.Request)
+ assert.NotNil(t, failureContext.Operation)
+ assert.NotNil(t, failureContext.Schema)
+ validator.Release()
+}
+
+func TestRequestDecoderReadAndTypedErrors(t *testing.T) {
+ schema := "type: string"
+ model := bodyModel(t, "application/custom", schema, true)
+ validator := NewRequestBodyValidator(model, config.WithBodyDecoder("application/custom", content.TextDecoder()))
+ request := &http.Request{Method: http.MethodPost, URL: mustRequestURL(t, "http://example.com/body"), Header: http.Header{"Content-Type": {"application/custom"}}, Body: errorReadCloser{}}
+ valid, validationErrors := validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "could not be read")
+ validator.Release()
+
+ failing := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return nil, errors.New("decode failed") })
+ for _, mediaType := range []string{"application/xml", "application/x-www-form-urlencoded"} {
+ model = bodyModel(t, mediaType, schema, true)
+ validator = NewRequestBodyValidator(model, config.WithBodyDecoder(mediaType, failing))
+ request, _ = http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader("bad"))
+ request.Header.Set("Content-Type", mediaType)
+ valid, validationErrors = validator.ValidateRequestBody(request)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ if strings.Contains(mediaType, "xml") {
+ assert.Contains(t, validationErrors[0].Message, "xml")
+ } else {
+ assert.Contains(t, validationErrors[0].Message, "form-urlencoded")
+ }
+ validator.Release()
+ }
+}
+
+func TestVendorJSONDecoderCompatibility(t *testing.T) {
+ model := bodyModel(t, "application/vnd.test+json", "type: object\nrequired: [ok]\nproperties: {ok: {type: boolean}}", true)
+ validator := NewRequestBodyValidator(model)
+ t.Cleanup(validator.Release)
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", bytes.NewBufferString(`{"ok":true}`))
+ request.Header.Set("Content-Type", "application/vnd.test+json")
+ valid, validationErrors := validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+}
+
+func TestEveryStandardRequestBodyCodec(t *testing.T) {
+ var multipartBody bytes.Buffer
+ multipartWriter := multipart.NewWriter(&multipartBody)
+ part, err := multipartWriter.CreateFormField("name")
+ require.NoError(t, err)
+ _, err = part.Write([]byte("value"))
+ require.NoError(t, err)
+ require.NoError(t, multipartWriter.Close())
+
+ var zipBody bytes.Buffer
+ zipWriter := zip.NewWriter(&zipBody)
+ file, err := zipWriter.Create("value.txt")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("value"))
+ require.NoError(t, err)
+ require.NoError(t, zipWriter.Close())
+
+ zipLimits := content.ZipLimits{CompressedSize: int64(zipBody.Len()) + 1, ExpandedSize: 1024, Entries: 2, ExpansionRatio: 10}
+ tests := []struct {
+ name string
+ mediaType string
+ contentType string
+ schema string
+ body []byte
+ opts []config.Option
+ }{
+ {"yaml", "application/yaml", "application/yaml", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("name: value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"xml", "application/xml", "application/xml", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"form", "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("name=value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"multipart", "multipart/form-data", multipartWriter.FormDataContentType(), "type: object\nrequired: [name]\nproperties: {name: {type: string}}", multipartBody.Bytes(), []config.Option{config.WithStandardBodyDecoders()}},
+ {"text", "text/plain", "text/plain", "type: string\nminLength: 1", []byte("value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"csv", "text/csv", "text/csv", "type: string\nminLength: 1", []byte("name,value\none,two\n"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"binary", "application/octet-stream", "application/octet-stream", "type: string\nformat: binary", []byte{0, 1, 2}, []config.Option{config.WithStandardBodyDecoders()}},
+ {"zip", "application/zip", "application/zip", "type: string", zipBody.Bytes(), []config.Option{config.WithZipBodyDecoder(zipLimits)}},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ model := bodyModel(t, test.mediaType, test.schema, true)
+ validator := NewRequestBodyValidator(model, test.opts...)
+ defer validator.Release()
+ request, err := http.NewRequest(http.MethodPost, "http://example.com/body", bytes.NewReader(test.body))
+ require.NoError(t, err)
+ request.Header.Set("Content-Type", test.contentType)
+ valid, validationErrors := validator.ValidateRequestBody(request)
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+ restored, readErr := io.ReadAll(request.Body)
+ require.NoError(t, readErr)
+ assert.Equal(t, test.body, restored)
+ })
+ }
+}
+
+func TestLegacyValidateRequestSchemaMalformedJSON(t *testing.T) {
+ model := bodyModel(t, "application/json", "type: object", true)
+ schema := model.Paths.PathItems.GetOrZero("/body").Post.RequestBody.Content.GetOrZero("application/json").Schema.Schema()
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", strings.NewReader(`{`))
+ valid, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{Request: request, Schema: schema, Version: 3.1})
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "cannot be decoded")
+}
+
+type errorReadCloser struct{}
+
+func (errorReadCloser) Read([]byte) (int, error) { return 0, errors.New("read failed") }
+func (errorReadCloser) Close() error { return nil }
+
+type countingReadCloser struct {
+ reads int
+}
+
+func (r *countingReadCloser) Read([]byte) (int, error) {
+ r.reads++
+ return 0, errors.New("body must not be read")
+}
+
+func (*countingReadCloser) Close() error { return nil }
+
+func mustRequestURL(t *testing.T, value string) *url.URL {
+ t.Helper()
+ parsed, err := url.Parse(value)
+ require.NoError(t, err)
+ return parsed
+}
+
+func BenchmarkJSONRequestBodyValidation(b *testing.B) {
+ model := bodyModel(b, "application/json", "type: object\nrequired: [ok]\nproperties: {ok: {type: boolean}}", true)
+ validator := NewRequestBodyValidator(model)
+ b.Cleanup(validator.Release)
+ body := []byte(`{"ok":true}`)
+ b.ReportAllocs()
+ for b.Loop() {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ _, _ = validator.ValidateRequestBody(request)
+ }
+}
+
+func BenchmarkVendorJSONRequestBodyValidation(b *testing.B) {
+ model := bodyModel(b, "application/vnd.example+json", "type: object\nrequired: [ok]\nproperties: {ok: {type: boolean}}", true)
+ validator := NewRequestBodyValidator(model)
+ b.Cleanup(validator.Release)
+ body := []byte(`{"ok":true}`)
+ b.ReportAllocs()
+ for b.Loop() {
+ request, _ := http.NewRequest(http.MethodPost, "http://example.com/body", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/vnd.example+json")
+ _, _ = validator.ValidateRequestBody(request)
+ }
+}
diff --git a/requests/request_body.go b/requests/request_body.go
index c7517d4f..6aed18a8 100644
--- a/requests/request_body.go
+++ b/requests/request_body.go
@@ -10,6 +10,7 @@ import (
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/internal/bodycodec"
)
// RequestBodyValidator is an interface that defines the methods for validating request bodies for Operations.
@@ -34,6 +35,7 @@ type RequestBodyValidator interface {
// NewRequestBodyValidator will create a new RequestBodyValidator from an OpenAPI 3+ document
func NewRequestBodyValidator(document *v3.Document, opts ...config.Option) RequestBodyValidator {
options := config.NewValidationOptions(opts...)
+ bodycodec.Apply(options)
return &requestBodyValidator{options: options, document: document}
}
diff --git a/requests/validate_body.go b/requests/validate_body.go
index 1586e5c8..22010aba 100644
--- a/requests/validate_body.go
+++ b/requests/validate_body.go
@@ -1,10 +1,11 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package requests
import (
- "encoding/json"
+ "bytes"
+ stderrors "errors"
"fmt"
"net/http"
"strings"
@@ -12,8 +13,11 @@ import (
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/bodycodec"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
"github.com/pb33f/libopenapi-validator/paths"
"github.com/pb33f/libopenapi-validator/schema_validation"
)
@@ -45,6 +49,22 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
return false, []*errors.ValidationError{errors.OperationNotFound(pathItem, request, request.Method, pathValue)}
}
if operation.RequestBody == nil {
+ if v.options.RejectUndeclaredRequestBody {
+ body, err := requeststate.Snapshot(request)
+ if err != nil {
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: "request body could not be inspected", Reason: err.Error(), Context: operation,
+ }}
+ }
+ if len(body) > 0 {
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%s request body for '%s' is not declared", request.Method, request.URL.Path),
+ Reason: "the matched operation does not declare a requestBody", Context: operation,
+ }}
+ }
+ }
return true, nil
}
@@ -77,49 +97,61 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
schema := mediaType.Schema.Schema()
isJson := strings.Contains(strings.ToLower(contentType), helpers.JSONType)
-
- // we currently only support JSON, XML and URLEncoded validation for request bodies
- if !isJson {
- isXml := schema_validation.IsXMLContentType(contentType)
- isUrlEncoded := schema_validation.IsURLEncodedContentType(contentType)
-
- xmlValid := isXml && v.options.AllowXMLBodyValidation
- urlEncodedValid := isUrlEncoded && v.options.AllowURLEncodedBodyValidation
-
- if !xmlValid && !urlEncodedValid {
+ isXml := schema_validation.IsXMLContentType(contentType)
+ isUrlEncoded := schema_validation.IsURLEncodedContentType(contentType)
+ decoder, normalized, parameters := v.options.BodyRegistry.Decoder(contentType)
+ if decoder == nil && isJson {
+ decoder = content.JSONDecoder()
+ }
+ if decoder == nil {
+ if !v.options.RejectUnsupportedBodyContent {
return true, nil
}
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%s request body for '%s' has no registered decoder", request.Method, request.URL.Path),
+ Reason: fmt.Sprintf("no body decoder is registered for %s", contentType),
+ Context: &content.FailureContext{Request: request, Operation: operation, MediaType: mediaType, Schema: schema},
+ }}
+ }
+ requestBody, readErr := requeststate.Snapshot(request)
+ if readErr != nil {
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: "request body could not be read", Reason: readErr.Error(), Context: schema,
+ }}
+ }
+ var decodedValue any
+ var decodeErr error
+ decoded := false
- if request != nil && (request.Body != nil || request.GetBody != nil) {
- requestBody := readAndResetRequestBody(request)
- stringedBody := string(requestBody)
- var jsonBody any
- var prevalidationErrors []*errors.ValidationError
-
- switch {
- case xmlValid:
- jsonBody, prevalidationErrors = schema_validation.TransformXMLToSchemaJSON(stringedBody, schema)
- case urlEncodedValid:
- jsonBody, prevalidationErrors = schema_validation.TransformURLEncodedToSchemaJSON(stringedBody, schema, mediaType.Encoding)
- }
-
- if len(prevalidationErrors) > 0 {
- return false, prevalidationErrors
- }
-
- transformedBytes, err := json.Marshal(jsonBody)
- if err != nil {
- switch {
- case isXml:
- return false, []*errors.ValidationError{errors.InvalidXMLParsing(err.Error(), stringedBody)}
- case isUrlEncoded:
- return false, []*errors.ValidationError{errors.InvalidURLEncodedParsing(err.Error(), stringedBody)}
- }
- }
-
- setRequestBody(request, transformedBytes)
+ decodedValue, decodeErr = decoder.Decode(&content.DecodeInput{
+ Context: request.Context(), Body: bytes.NewReader(requestBody), Header: request.Header,
+ MediaType: normalized, Parameters: parameters, Schema: schema, Encoding: mediaType.Encoding, Direction: content.Request,
+ })
+ if decodeErr == nil {
+ decodedValue, decodeErr = content.Canonicalize(decodedValue)
+ }
+ if decodeErr != nil {
+ var transformErrors *bodycodec.ValidationErrors
+ if stderrors.As(decodeErr, &transformErrors) {
+ return false, transformErrors.Errors
+ }
+ structured := &content.DecodingError{MediaType: normalized, Direction: content.Request, Err: decodeErr}
+ if isXml {
+ return false, []*errors.ValidationError{errors.InvalidXMLParsing(structured.Error(), string(requestBody))}
}
+ if isUrlEncoded {
+ return false, []*errors.ValidationError{errors.InvalidURLEncodedParsing(structured.Error(), string(requestBody))}
+ }
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%s request body for '%s' could not be decoded", request.Method, request.URL.Path),
+ Reason: "The request body cannot be decoded: " + structured.Error(),
+ Context: &content.FailureContext{Request: request, Operation: operation, MediaType: mediaType, Schema: schema},
+ }}
}
+ decoded = true
validationSucceeded, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{
Request: request,
@@ -127,6 +159,9 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
Version: helpers.VersionToFloat(v.document.Version),
Options: []config.Option{config.WithExistingOpts(v.options)},
BodyRequired: required,
+ DecodedValue: decodedValue,
+ RawBody: requestBody,
+ ValueDecoded: decoded,
})
errors.PopulateValidationErrors(validationErrors, request, pathValue)
diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go
index 21e59467..36674434 100644
--- a/requests/validate_body_test.go
+++ b/requests/validate_body_test.go
@@ -1,4 +1,4 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package requests
@@ -818,16 +818,19 @@ paths:
})
tests := []struct {
- name string
- body io.ReadCloser
+ name string
+ body io.ReadCloser
+ expectedReplay []byte
}{
{
- name: "http no body",
- body: http.NoBody,
+ name: "http no body",
+ body: http.NoBody,
+ expectedReplay: staleBodyBytes,
},
{
- name: "empty reader",
- body: io.NopCloser(bytes.NewReader(nil)),
+ name: "empty reader",
+ body: io.NopCloser(bytes.NewReader(nil)),
+ expectedReplay: []byte{},
},
}
@@ -848,7 +851,7 @@ paths:
replayedBytes, err := io.ReadAll(replayedBody)
require.NoError(t, err)
require.NoError(t, replayedBody.Close())
- require.Empty(t, replayedBytes)
+ require.Equal(t, tc.expectedReplay, replayedBytes)
})
}
}
@@ -858,63 +861,6 @@ func TestRequestBodyHelpers_NilRequest(t *testing.T) {
require.Nil(t, readAndResetRequestBody(nil))
}
-type requestBodyReaderTestBody struct{}
-
-func (r *requestBodyReaderTestBody) Read(_ []byte) (int, error) {
- return 0, io.EOF
-}
-
-func (r *requestBodyReaderTestBody) Close() error {
- return nil
-}
-
-type failingReplayableBody struct{}
-
-func (r *failingReplayableBody) Read(_ []byte) (int, error) {
- return 0, io.EOF
-}
-
-func (r *failingReplayableBody) Close() error {
- return nil
-}
-
-func (r *failingReplayableBody) ReadAt(_ []byte, _ int64) (int, error) {
- return 0, io.ErrUnexpectedEOF
-}
-
-func (r *failingReplayableBody) Size() int64 {
- return 1
-}
-
-func TestRequestBodyReader_DefensiveBranches(t *testing.T) {
- require.Nil(t, requestBodyReader(nil))
- require.Nil(t, requestBodyReader(http.NoBody))
-
- var nilBody *requestBodyReaderTestBody
- require.Nil(t, requestBodyReader(nilBody))
-
- body := &requestBodyReaderTestBody{}
- require.Same(t, body, requestBodyReader(body))
-}
-
-func TestRequestBodySnapshot_DefensiveBranches(t *testing.T) {
- snapshot, ok := requestBodySnapshot(nil)
- require.False(t, ok)
- require.Nil(t, snapshot)
-
- snapshot, ok = requestBodySnapshot(&http.Request{Body: &requestBodyReaderTestBody{}})
- require.False(t, ok)
- require.Nil(t, snapshot)
-
- snapshot, ok = requestBodySnapshot(&http.Request{Body: io.NopCloser(bytes.NewReader(nil))})
- require.False(t, ok)
- require.Nil(t, snapshot)
-
- snapshot, ok = requestBodySnapshot(&http.Request{Body: &failingReplayableBody{}})
- require.False(t, ok)
- require.Nil(t, snapshot)
-}
-
func TestValidateBody_ValidBasicSchema_WithFullContentTypeHeader(t *testing.T) {
spec := `openapi: 3.1.0
paths:
diff --git a/requests/validate_request.go b/requests/validate_request.go
index b04ac87d..e760f852 100644
--- a/requests/validate_request.go
+++ b/requests/validate_request.go
@@ -4,11 +4,9 @@
package requests
import (
- "bytes"
"encoding/json"
"errors"
"fmt"
- "io"
"net/http"
"reflect"
"regexp"
@@ -23,6 +21,7 @@ import (
"github.com/pb33f/libopenapi-validator/config"
liberrors "github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
"github.com/pb33f/libopenapi-validator/schema_validation"
"github.com/pb33f/libopenapi-validator/strict"
)
@@ -36,97 +35,18 @@ type ValidateRequestSchemaInput struct {
Version float32 // Required: OpenAPI version (3.0 or 3.1)
Options []config.Option // Optional: Functional options (defaults applied if empty/nil)
BodyRequired bool // Optional: Whether the request body is required (default false)
-}
-
-type replayableBody interface {
- io.ReaderAt
- Size() int64
+ DecodedValue any // Optional: A value produced by a registered body decoder
+ RawBody []byte // Optional: Original bytes used for diagnostics with DecodedValue
+ ValueDecoded bool // Distinguishes an explicitly decoded nil from the legacy JSON path
}
func setRequestBody(request *http.Request, body []byte) {
- if request == nil {
- return
- }
- bodyCopy := append([]byte(nil), body...)
- request.Body = io.NopCloser(bytes.NewReader(bodyCopy))
- request.ContentLength = int64(len(bodyCopy))
- request.GetBody = func() (io.ReadCloser, error) {
- return io.NopCloser(bytes.NewReader(bodyCopy)), nil
- }
-}
-
-func requestBodySnapshot(request *http.Request) ([]byte, bool) {
- if request == nil || request.Body == nil || request.Body == http.NoBody {
- return nil, false
- }
- reader := requestBodyReader(request.Body)
- body, ok := reader.(replayableBody)
- if !ok {
- return nil, false
- }
- size := body.Size()
- if size <= 0 {
- return nil, false
- }
- snapshot, err := io.ReadAll(io.NewSectionReader(body, 0, size))
- if err != nil {
- return nil, false
- }
- return snapshot, true
-}
-
-func requestBodyReader(body io.ReadCloser) io.Reader {
- if body == nil || body == http.NoBody {
- return nil
- }
-
- value := reflect.ValueOf(body)
- if value.Kind() == reflect.Pointer {
- if value.IsNil() {
- return nil
- }
- value = value.Elem()
- }
- if value.Kind() == reflect.Struct {
- field := value.FieldByName("Reader")
- if field.IsValid() && field.CanInterface() {
- if reader, ok := field.Interface().(io.Reader); ok {
- return reader
- }
- }
- }
- return body
+ requeststate.Install(request, body)
}
func readAndResetRequestBody(request *http.Request) []byte {
- if request == nil {
- return nil
- }
-
- var requestBody []byte
- bodyRead := false
- bodySnapshot, hasBodySnapshot := requestBodySnapshot(request)
- if request.Body != nil {
- requestBody, _ = io.ReadAll(request.Body)
- _ = request.Body.Close()
- bodyRead = true
- }
-
- if len(requestBody) == 0 && hasBodySnapshot && request.GetBody != nil {
- if body, err := request.GetBody(); err == nil && body != nil {
- replayedBody, _ := io.ReadAll(body)
- _ = body.Close()
- if bytes.Equal(replayedBody, bodySnapshot) {
- requestBody = replayedBody
- bodyRead = true
- }
- }
- }
-
- if bodyRead {
- setRequestBody(request, requestBody)
- }
- return requestBody
+ body, _ := requeststate.Snapshot(request)
+ return body
}
// ValidateRequestSchema will validate a http.Request pointer against a schema.
@@ -215,11 +135,13 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror
request := input.Request
schema := input.Schema
- requestBody := readAndResetRequestBody(request)
-
- var decodedObj interface{}
+ requestBody := input.RawBody
+ decodedObj := input.DecodedValue
+ if !input.ValueDecoded {
+ requestBody = readAndResetRequestBody(request)
+ }
- if len(requestBody) > 0 {
+ if !input.ValueDecoded && len(requestBody) > 0 {
err := json.Unmarshal(requestBody, &decodedObj)
if err != nil {
// cannot decode the request body, so it's not valid
diff --git a/responses/kin_parity_test.go b/responses/kin_parity_test.go
new file mode 100644
index 00000000..31c488a6
--- /dev/null
+++ b/responses/kin_parity_test.go
@@ -0,0 +1,282 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package responses
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
+)
+
+func responseModel(t *testing.T, mediaType, schema string) *v3.Document {
+ t.Helper()
+ spec := "openapi: 3.1.0\ninfo: {title: response, version: 1.0.0}\npaths:\n /body:\n get:\n responses:\n \"200\":\n description: ok\n headers:\n X-Rate: {required: true, schema: {type: integer}}\n content:\n " + mediaType + ":\n schema:\n" + responseIndent(schema, 16) + "\n"
+ document, err := libopenapi.NewDocument([]byte(spec))
+ require.NoError(t, err)
+ model, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ return &model.Model
+}
+
+func responseIndent(value string, spaces int) string {
+ prefix := strings.Repeat(" ", spaces)
+ return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix)
+}
+
+func responseRequest(t *testing.T) *http.Request {
+ t.Helper()
+ request, err := http.NewRequest(http.MethodGet, "http://example.com/body", nil)
+ require.NoError(t, err)
+ return request
+}
+
+func TestStandardAndUnsupportedResponseDecoders(t *testing.T) {
+ model := responseModel(t, "application/yaml", "type: object\nrequired: [count]\nproperties: {count: {type: integer}}")
+ request := responseRequest(t)
+ newResponse := func() *http.Response {
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/yaml"}, "X-Rate": {"2"}}, Body: io.NopCloser(strings.NewReader("count: 2"))}
+ }
+
+ validator := NewResponseBodyValidator(model)
+ valid, validationErrors := validator.ValidateResponseBody(request, newResponse())
+ require.True(t, valid, validationErrors)
+ validator.Release()
+ var response *http.Response
+
+ for _, body := range []io.ReadCloser{http.NoBody, nil} {
+ validator = NewResponseBodyValidator(model)
+ response = &http.Response{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"application/yaml"}, "X-Rate": {"2"}}, Body: body,
+ }
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+ }
+
+ validator = NewResponseBodyValidator(model, config.WithRejectUnsupportedBodyContent())
+ response = &http.Response{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"application/yaml"}, "X-Rate": {"2"}}, Body: http.NoBody,
+ }
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "no registered decoder")
+ validator.Release()
+
+ unread := &countingResponseBody{}
+ validator = NewResponseBodyValidator(model)
+ response = &http.Response{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"application/yaml"}, "X-Rate": {"2"}}, Body: unread,
+ }
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ require.True(t, valid, validationErrors)
+ assert.Zero(t, unread.reads)
+ assert.Same(t, unread, response.Body)
+ validator.Release()
+
+ validator = NewResponseBodyValidator(model, config.WithRejectUnsupportedBodyContent())
+ unread = &countingResponseBody{}
+ response = &http.Response{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"application/yaml"}, "X-Rate": {"2"}}, Body: unread,
+ }
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "no registered decoder")
+ assert.Zero(t, unread.reads)
+ assert.Same(t, unread, response.Body)
+ validator.Release()
+
+ validator = NewResponseBodyValidator(model, config.WithStandardBodyDecoders())
+ response = newResponse()
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ require.True(t, valid, validationErrors)
+ body, _ := io.ReadAll(response.Body)
+ assert.Equal(t, "count: 2", string(body))
+ validator.Release()
+
+ canonicalFailure := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return map[any]any{1: "bad"}, nil })
+ validator = NewResponseBodyValidator(model, config.WithBodyDecoder("application/yaml", canonicalFailure))
+ valid, validationErrors = validator.ValidateResponseBody(request, newResponse())
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "could not be decoded")
+ failureContext, ok := validationErrors[0].Context.(*content.FailureContext)
+ require.True(t, ok)
+ assert.Same(t, request, failureContext.Request)
+ assert.NotNil(t, failureContext.Response)
+ assert.NotNil(t, failureContext.Operation)
+ validator.Release()
+}
+
+type countingResponseBody struct {
+ reads int
+}
+
+func (r *countingResponseBody) Read([]byte) (int, error) {
+ r.reads++
+ return 0, errors.New("body must not be read")
+}
+
+func (*countingResponseBody) Close() error { return nil }
+
+func TestResponseDecoderTypedErrors(t *testing.T) {
+ failing := content.DecoderFunc(func(*content.DecodeInput) (any, error) { return nil, errors.New("decode failed") })
+ for _, mediaType := range []string{"application/xml", "application/x-www-form-urlencoded"} {
+ model := responseModel(t, mediaType, "type: string")
+ validator := NewResponseBodyValidator(model, config.WithBodyDecoder(mediaType, failing))
+ response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {mediaType}, "X-Rate": {"2"}}, Body: io.NopCloser(strings.NewReader("bad"))}
+ valid, validationErrors := validator.ValidateResponseBody(responseRequest(t), response)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "could not be decoded")
+ validator.Release()
+ }
+}
+
+func TestResponseBodyAndStatusPolicies(t *testing.T) {
+ model := responseModel(t, "application/json", "type: object\nrequired: [ok]\nproperties: {ok: {type: boolean}}")
+ request := responseRequest(t)
+ validator := NewResponseBodyValidator(model, config.WithoutResponseBodyValidation())
+ response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewBufferString(`{"ok":"bad"}`))}
+ valid, validationErrors := validator.ValidateResponseBody(request, response)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Message, "Missing required header")
+ response = &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}, "X-Rate": {"2"}}, Body: io.NopCloser(bytes.NewBufferString(`{"ok":"bad"}`))}
+ valid, validationErrors = validator.ValidateResponseBody(request, response)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+
+ strict := NewResponseBodyValidator(model)
+ response = &http.Response{StatusCode: 299, Header: make(http.Header), Body: http.NoBody}
+ valid, validationErrors = strict.ValidateResponseBody(request, response)
+ assert.False(t, valid)
+ assert.NotEmpty(t, validationErrors)
+ strict.Release()
+
+ lenient := NewResponseBodyValidator(model, config.WithoutResponseStatusValidation())
+ valid, validationErrors = lenient.ValidateResponseBody(request, response)
+ require.True(t, valid, validationErrors)
+ lenient.Release()
+}
+
+func TestNilResponseBodyUsesLegacyMissingBodyError(t *testing.T) {
+ model := responseModel(t, "application/json", "type: object")
+ validator := NewResponseBodyValidator(model)
+ t.Cleanup(validator.Release)
+ response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}, "X-Rate": {"2"}}}
+ valid, validationErrors := validator.ValidateResponseBody(responseRequest(t), response)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Contains(t, validationErrors[0].Reason, "missing")
+}
+
+func TestResponseDefaultExclusionAndLegacyJSONMediaName(t *testing.T) {
+ defaultSpec := `openapi: 3.1.0
+info: {title: default, version: 1.0.0}
+paths:
+ /body:
+ get:
+ responses:
+ default:
+ description: fallback
+ content: {application/json: {schema: {type: object}}}`
+ document, err := libopenapi.NewDocument([]byte(defaultSpec))
+ require.NoError(t, err)
+ built, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ validator := NewResponseBodyValidator(&built.Model, config.WithoutResponseBodyValidation())
+ response := &http.Response{StatusCode: 299, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`bad`))}
+ valid, validationErrors := validator.ValidateResponseBody(responseRequest(t), response)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+
+ model := responseModel(t, "foo/json", "type: object\nrequired: [ok]\nproperties: {ok: {type: boolean}}")
+ validator = NewResponseBodyValidator(model)
+ response = &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"foo/json"}, "X-Rate": {"2"}}, Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}
+ valid, validationErrors = validator.ValidateResponseBody(responseRequest(t), response)
+ require.True(t, valid, validationErrors)
+ validator.Release()
+}
+
+func TestResponseOperationWithoutResponsesIsSkipped(t *testing.T) {
+ document, err := libopenapi.NewDocument([]byte(`openapi: 3.1.0
+info: {title: no responses, version: 1.0.0}
+paths:
+ /body:
+ get: {summary: no responses}`))
+ require.NoError(t, err)
+ built, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ validator := NewResponseBodyValidator(&built.Model)
+ t.Cleanup(validator.Release)
+ response := &http.Response{StatusCode: 200, Header: make(http.Header), Body: http.NoBody}
+ valid, validationErrors := validator.ValidateResponseBody(responseRequest(t), response)
+ require.True(t, valid, validationErrors)
+}
+
+func TestEveryStandardResponseBodyCodec(t *testing.T) {
+ var multipartBody bytes.Buffer
+ multipartWriter := multipart.NewWriter(&multipartBody)
+ part, err := multipartWriter.CreateFormField("name")
+ require.NoError(t, err)
+ _, err = part.Write([]byte("value"))
+ require.NoError(t, err)
+ require.NoError(t, multipartWriter.Close())
+
+ var zipBody bytes.Buffer
+ zipWriter := zip.NewWriter(&zipBody)
+ file, err := zipWriter.Create("value.txt")
+ require.NoError(t, err)
+ _, err = file.Write([]byte("value"))
+ require.NoError(t, err)
+ require.NoError(t, zipWriter.Close())
+ zipLimits := content.ZipLimits{CompressedSize: int64(zipBody.Len()) + 1, ExpandedSize: 1024, Entries: 2, ExpansionRatio: 10}
+
+ tests := []struct {
+ name string
+ mediaType string
+ contentType string
+ schema string
+ body []byte
+ opts []config.Option
+ }{
+ {"yaml", "application/yaml", "application/yaml", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("name: value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"xml", "application/xml", "application/xml", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"form", "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "type: object\nrequired: [name]\nproperties: {name: {type: string}}", []byte("name=value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"multipart", "multipart/form-data", multipartWriter.FormDataContentType(), "type: object\nrequired: [name]\nproperties: {name: {type: string}}", multipartBody.Bytes(), []config.Option{config.WithStandardBodyDecoders()}},
+ {"text", "text/plain", "text/plain", "type: string", []byte("value"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"csv", "text/csv", "text/csv", "type: string", []byte("name,value\none,two\n"), []config.Option{config.WithStandardBodyDecoders()}},
+ {"binary", "application/octet-stream", "application/octet-stream", "type: string\nformat: binary", []byte{0, 1, 2}, []config.Option{config.WithStandardBodyDecoders()}},
+ {"zip", "application/zip", "application/zip", "type: string", zipBody.Bytes(), []config.Option{config.WithZipBodyDecoder(zipLimits)}},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ model := responseModel(t, test.mediaType, test.schema)
+ validator := NewResponseBodyValidator(model, test.opts...)
+ defer validator.Release()
+ response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {test.contentType}, "X-Rate": {"2"}}, Body: io.NopCloser(bytes.NewReader(test.body))}
+ valid, validationErrors := validator.ValidateResponseBody(responseRequest(t), response)
+ require.True(t, valid, validationErrors)
+ restored, readErr := io.ReadAll(response.Body)
+ require.NoError(t, readErr)
+ assert.Equal(t, test.body, restored)
+ })
+ }
+}
diff --git a/responses/response_body.go b/responses/response_body.go
index 4d8e8ffb..1efd9740 100644
--- a/responses/response_body.go
+++ b/responses/response_body.go
@@ -10,6 +10,7 @@ import (
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/internal/bodycodec"
)
// ResponseBodyValidator is an interface that defines the methods for validating response bodies for Operations.
@@ -34,6 +35,7 @@ type ResponseBodyValidator interface {
// NewResponseBodyValidator will create a new ResponseBodyValidator from an OpenAPI 3+ document
func NewResponseBodyValidator(document *v3.Document, opts ...config.Option) ResponseBodyValidator {
options := config.NewValidationOptions(opts...)
+ bodycodec.Apply(options)
return &responseBodyValidator{options: options, document: document}
}
diff --git a/responses/validate_body.go b/responses/validate_body.go
index 82d77968..06c7c7a9 100644
--- a/responses/validate_body.go
+++ b/responses/validate_body.go
@@ -5,7 +5,7 @@ package responses
import (
"bytes"
- "encoding/json"
+ stderrors "errors"
"fmt"
"io"
"net/http"
@@ -17,10 +17,11 @@ import (
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/content"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/bodycodec"
"github.com/pb33f/libopenapi-validator/paths"
- "github.com/pb33f/libopenapi-validator/schema_validation"
)
func (v *responseBodyValidator) ValidateResponseBody(
@@ -77,11 +78,11 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
}
if foundResponse != nil {
- if foundResponse.Content != nil { // only validate if we have content types.
+ if v.options.ValidateResponseBody && foundResponse.Content != nil { // only validate if we have content types.
// check content type has been defined in the contract
if mediaType, ok := foundResponse.Content.Get(mediaTypeSting); ok {
validationErrors = append(validationErrors,
- v.checkResponseSchema(request, response, mediaTypeSting, mediaType)...)
+ v.checkResponseSchema(request, response, contentType, mediaType, operation)...)
} else {
// check that the operation *actually* returns a body. (i.e. a 204 response)
if foundResponse.Content != nil && orderedmap.Len(foundResponse.Content) > 0 {
@@ -95,10 +96,12 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
// no code match, check for default response
if operation.Responses.Default != nil && operation.Responses.Default.Content != nil {
// check content type has been defined in the contract
- if mediaType, ok := operation.Responses.Default.Content.Get(mediaTypeSting); ok {
+ if !v.options.ValidateResponseBody {
+ foundResponse = operation.Responses.Default
+ } else if mediaType, ok := operation.Responses.Default.Content.Get(mediaTypeSting); ok {
foundResponse = operation.Responses.Default
validationErrors = append(validationErrors,
- v.checkResponseSchema(request, response, contentType, mediaType)...)
+ v.checkResponseSchema(request, response, contentType, mediaType, operation)...)
} else {
// check that the operation *actually* returns a body. (i.e. a 204 response)
if operation.Responses.Default.Content != nil && orderedmap.Len(operation.Responses.Default.Content) > 0 {
@@ -107,7 +110,7 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
errors.ResponseContentTypeNotFound(operation, request, response, codeStr, true))
}
}
- } else {
+ } else if v.options.ValidateResponseStatus {
// TODO: add support for '2XX' and '3XX' responses in the contract
// no default, no code match, nothing!
validationErrors = append(validationErrors,
@@ -137,6 +140,7 @@ func (v *responseBodyValidator) checkResponseSchema(
response *http.Response,
contentType string,
mediaType *v3.MediaType,
+ operation *v3.Operation,
) []*errors.ValidationError {
var validationErrors []*errors.ValidationError
@@ -144,64 +148,89 @@ func (v *responseBodyValidator) checkResponseSchema(
return validationErrors
}
- // currently, we can only validate JSON, XML and URL Encoded based responses, so check for the presence
- // of 'json' (what ever it may be) and for XML/URLEncoded content type so we can perform a schema check on it.
- // anything other than JSON XML, or URL Encoded will be ignored.
-
- isXml := schema_validation.IsXMLContentType(contentType)
- isUrlEncoded := schema_validation.IsURLEncodedContentType(contentType)
isJson := strings.Contains(strings.ToLower(contentType), helpers.JSONType)
- xmlValid := isXml && v.options.AllowXMLBodyValidation
- urlEncodedValid := isUrlEncoded && v.options.AllowURLEncodedBodyValidation
-
- if !isJson && !xmlValid && !urlEncodedValid {
- return validationErrors
- }
-
schema := mediaType.Schema.Schema()
+ decoder, normalized, parameters := v.options.BodyRegistry.Decoder(contentType)
+ if decoder == nil && isJson {
+ decoder = content.JSONDecoder()
+ }
+ if decoder == nil {
+ if !v.options.RejectUnsupportedBodyContent {
+ return validationErrors
+ }
+ return []*errors.ValidationError{{
+ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%d response body has no registered decoder", response.StatusCode),
+ Reason: fmt.Sprintf("no body decoder is registered for %s", contentType),
+ Context: &content.FailureContext{Request: request, Response: response, Operation: operation, MediaType: mediaType, Schema: schema},
+ }}
+ }
+ if response == nil || response.Body == nil || response.Body == http.NoBody {
+ validationResponse := response
+ if response != nil && response.Body == nil {
+ copyResponse := *response
+ copyResponse.Body = http.NoBody
+ validationResponse = ©Response
+ }
+ _, bodyErrors := ValidateResponseSchema(&ValidateResponseSchemaInput{
+ Request: request, Response: validationResponse, Schema: schema,
+ Version: helpers.VersionToFloat(v.document.Version), Options: []config.Option{config.WithExistingOpts(v.options)},
+ })
+ return bodyErrors
+ }
+ responseBody, readErr := io.ReadAll(response.Body)
+ _ = response.Body.Close()
+ response.Body = io.NopCloser(bytes.NewReader(responseBody))
+ if readErr != nil {
+ return []*errors.ValidationError{{
+ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema,
+ Message: "response body could not be read", Reason: "The response body cannot be decoded: " + readErr.Error(), Context: schema,
+ }}
+ }
- if !isJson {
- if response != nil && response.Body != http.NoBody {
- responseBody, _ := io.ReadAll(response.Body)
- _ = response.Body.Close()
-
- stringedBody := string(responseBody)
- var jsonBody any
- var prevalidationErrors []*errors.ValidationError
-
- switch {
- case xmlValid:
- jsonBody, prevalidationErrors = schema_validation.TransformXMLToSchemaJSON(stringedBody, schema)
- case urlEncodedValid:
- jsonBody, prevalidationErrors = schema_validation.TransformURLEncodedToSchemaJSON(stringedBody, schema, mediaType.Encoding)
- }
-
- if len(prevalidationErrors) > 0 {
- return prevalidationErrors
- }
-
- transformedBytes, err := json.Marshal(jsonBody)
- if err != nil {
- switch {
- case isXml:
- return []*errors.ValidationError{errors.InvalidXMLParsing(err.Error(), stringedBody)}
- case isUrlEncoded:
- return []*errors.ValidationError{errors.InvalidURLEncodedParsing(err.Error(), stringedBody)}
- }
- }
-
- response.Body = io.NopCloser(bytes.NewBuffer(transformedBytes))
+ var decodedValue any
+ decoded := false
+ var decodeErr error
+ decodedValue, decodeErr = decoder.Decode(&content.DecodeInput{
+ Context: request.Context(), Body: bytes.NewReader(responseBody), Header: response.Header,
+ MediaType: normalized, Parameters: parameters, Schema: schema, Encoding: mediaType.Encoding, Direction: content.Response,
+ })
+ if decodeErr == nil {
+ decodedValue, decodeErr = content.Canonicalize(decodedValue)
+ }
+ if decodeErr != nil {
+ var transformErrors *bodycodec.ValidationErrors
+ if stderrors.As(decodeErr, &transformErrors) {
+ return transformErrors.Errors
+ }
+ structured := &content.DecodingError{MediaType: normalized, Direction: content.Response, Err: decodeErr}
+ if isJson {
+ return []*errors.ValidationError{{
+ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%s response body for '%s' failed to validate schema", request.Method, request.URL.Path),
+ Reason: "The response body cannot be decoded: " + structured.Error(),
+ Context: &content.FailureContext{Request: request, Response: response, Operation: operation, MediaType: mediaType, Schema: schema},
+ }}
}
+ return []*errors.ValidationError{{
+ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%d response body could not be decoded", response.StatusCode), Reason: structured.Error(),
+ Context: &content.FailureContext{Request: request, Response: response, Operation: operation, MediaType: mediaType, Schema: schema},
+ }}
}
+ decoded = true
// Validate response schema
valid, vErrs := ValidateResponseSchema(&ValidateResponseSchemaInput{
- Request: request,
- Response: response,
- Schema: schema,
- Version: helpers.VersionToFloat(v.document.Version),
- Options: []config.Option{config.WithExistingOpts(v.options)},
+ Request: request,
+ Response: response,
+ Schema: schema,
+ Version: helpers.VersionToFloat(v.document.Version),
+ Options: []config.Option{config.WithExistingOpts(v.options)},
+ DecodedValue: decodedValue,
+ RawBody: responseBody,
+ ValueDecoded: decoded,
})
if !valid {
diff --git a/responses/validate_response.go b/responses/validate_response.go
index 5503c033..e307b863 100644
--- a/responses/validate_response.go
+++ b/responses/validate_response.go
@@ -31,11 +31,14 @@ var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`)
// ValidateResponseSchemaInput contains parameters for response schema validation.
type ValidateResponseSchemaInput struct {
- Request *http.Request // Required: The HTTP request (for context)
- Response *http.Response // Required: The HTTP response to validate
- Schema *base.Schema // Required: The OpenAPI schema to validate against
- Version float32 // Required: OpenAPI version (3.0 or 3.1)
- Options []config.Option // Optional: Functional options (defaults applied if empty/nil)
+ Request *http.Request // Required: The HTTP request (for context)
+ Response *http.Response // Required: The HTTP response to validate
+ Schema *base.Schema // Required: The OpenAPI schema to validate against
+ Version float32 // Required: OpenAPI version (3.0 or 3.1)
+ Options []config.Option // Optional: Functional options (defaults applied if empty/nil)
+ DecodedValue any // Optional: A value produced by a registered body decoder
+ RawBody []byte // Optional: Original bytes used for diagnostics with DecodedValue
+ ValueDecoded bool // Distinguishes an explicitly decoded nil from the legacy JSON path
}
// ValidateResponseSchema will validate the response body for a http.Response pointer. The request is used to
@@ -148,28 +151,34 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr
return false, validationErrors
}
- responseBody, ioErr := io.ReadAll(response.Body)
- if ioErr != nil {
- // cannot decode the response body, so it's not valid
- validationErrors = append(validationErrors, &liberrors.ValidationError{
- ValidationType: helpers.ResponseBodyValidation,
- ValidationSubType: helpers.Schema,
- Message: fmt.Sprintf("%s response body for '%s' cannot be read, it's empty or malformed",
- request.Method, request.URL.Path),
- Reason: fmt.Sprintf("The response body cannot be decoded: %s", ioErr.Error()),
- SpecLine: 1,
- SpecCol: 0,
- HowToFix: "ensure body is not empty",
- Context: schema,
- })
- return false, validationErrors
+ responseBody := input.RawBody
+ if !input.ValueDecoded {
+ var ioErr error
+ responseBody, ioErr = io.ReadAll(response.Body)
+ if ioErr != nil {
+ // cannot decode the response body, so it's not valid
+ validationErrors = append(validationErrors, &liberrors.ValidationError{
+ ValidationType: helpers.ResponseBodyValidation,
+ ValidationSubType: helpers.Schema,
+ Message: fmt.Sprintf("%s response body for '%s' cannot be read, it's empty or malformed",
+ request.Method, request.URL.Path),
+ Reason: fmt.Sprintf("The response body cannot be decoded: %s", ioErr.Error()),
+ SpecLine: 1,
+ SpecCol: 0,
+ HowToFix: "ensure body is not empty",
+ Context: schema,
+ })
+ return false, validationErrors
+ }
}
// close the request body, so it can be re-read later by another player in the chain
- _ = response.Body.Close()
- response.Body = io.NopCloser(bytes.NewBuffer(responseBody))
+ if !input.ValueDecoded {
+ _ = response.Body.Close()
+ response.Body = io.NopCloser(bytes.NewBuffer(responseBody))
+ }
- var decodedObj interface{}
+ decodedObj := input.DecodedValue
if len(responseBody) > 0 {
// Per RFC7231, a response to a HEAD request MUST NOT include a message body.
@@ -193,7 +202,10 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr
})
return false, validationErrors
}
- err := json.Unmarshal(responseBody, &decodedObj)
+ var err error
+ if !input.ValueDecoded {
+ err = json.Unmarshal(responseBody, &decodedObj)
+ }
if err != nil {
// cannot decode the response body, so it's not valid
validationErrors = append(validationErrors, &liberrors.ValidationError{
diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go
index 2de7d36f..884704c7 100644
--- a/responses/validate_response_test.go
+++ b/responses/validate_response_test.go
@@ -18,6 +18,7 @@ import (
"github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi-validator/config"
+ liberrors "github.com/pb33f/libopenapi-validator/errors"
validatorhelpers "github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/schema_validation"
)
@@ -472,6 +473,52 @@ func TestValidateResponseSchema_EmptyBodySkipsValidation(t *testing.T) {
assert.Empty(t, errs)
}
+func TestValidateResponseSchema_UnreadableBodyReturnsStructuredError(t *testing.T) {
+ schema := parseSchemaFromSpec(t, `type: object`, 3.1)
+
+ valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
+ Request: postRequest(),
+ Response: &http.Response{StatusCode: http.StatusOK, Body: &errorReader{}},
+ Schema: schema,
+ Version: 3.1,
+ })
+
+ assert.False(t, valid)
+ require.Len(t, errs, 1)
+ assert.Equal(t, validatorhelpers.ResponseBodyValidation, errs[0].ValidationType)
+ assert.Equal(t, validatorhelpers.Schema, errs[0].ValidationSubType)
+ assert.Contains(t, errs[0].Message, "response body for '/test' cannot be read")
+ assert.Equal(t, "The response body cannot be decoded: some io error", errs[0].Reason)
+ assert.Equal(t, "ensure body is not empty", errs[0].HowToFix)
+ assert.Same(t, schema, errs[0].Context)
+}
+
+func TestValidateResponseSchema_MalformedJSONReturnsStructuredErrorAndRestoresBody(t *testing.T) {
+ schema := parseSchemaFromSpec(t, `type: object`, 3.1)
+ const malformed = `{"name":`
+ response := responseWithBody(malformed)
+
+ valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
+ Request: postRequest(),
+ Response: response,
+ Schema: schema,
+ Version: 3.1,
+ })
+
+ assert.False(t, valid)
+ require.Len(t, errs, 1)
+ assert.Equal(t, validatorhelpers.ResponseBodyValidation, errs[0].ValidationType)
+ assert.Equal(t, validatorhelpers.Schema, errs[0].ValidationSubType)
+ assert.Contains(t, errs[0].Message, "response body for '/test' failed to validate schema")
+ assert.Contains(t, errs[0].Reason, "The response body cannot be decoded: unexpected end of JSON input")
+ assert.Equal(t, liberrors.HowToFixInvalidSchema, errs[0].HowToFix)
+ assert.Same(t, schema, errs[0].Context)
+
+ replayed, err := io.ReadAll(response.Body)
+ require.NoError(t, err)
+ assert.Equal(t, malformed, string(replayed))
+}
+
func TestValidateResponseSchema_CachedSchemaWithoutRenderedNodeFallsBackToRenderedBytes(t *testing.T) {
schema := parseSchemaFromSpec(t, `anyOf:
- type: string
diff --git a/router/router.go b/router/router.go
new file mode 100644
index 00000000..89d07ef0
--- /dev/null
+++ b/router/router.go
@@ -0,0 +1,538 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+// Package router matches HTTP requests to OpenAPI operations without running validation.
+//
+// A Router returns the effective server, path template, path item, operation,
+// decoded server variables, and raw and decoded path parameters. Standalone
+// routers enforce OpenAPI server precedence by default; WithPathOnlyMatching
+// provides the validator's backward-compatible path-only behavior.
+package router
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/pb33f/libopenapi/orderedmap"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pb33f/libopenapi-validator/radix"
+)
+
+var (
+ // ErrPathNotFound identifies requests that do not match an effective server and path.
+ ErrPathNotFound = errors.New("openapi path not found")
+ // ErrMethodNotAllowed identifies requests whose path exists but method does not.
+ // FindRoute returns a partial Route alongside this error.
+ ErrMethodNotAllowed = errors.New("openapi operation not found")
+)
+
+// RegexCache stores compiled path expressions. config.RegexCache satisfies this contract.
+// Values stored by the router are *regexp.Regexp instances.
+type RegexCache interface {
+ // Load returns a previously cached value for key.
+ Load(key any) (value any, ok bool)
+ // Store associates value with key.
+ Store(key, value any)
+}
+
+// PathLookup performs fast standard-template lookup. radix.PathLookup satisfies this contract.
+type PathLookup interface {
+ // Lookup returns the matched path item, its OpenAPI path template, and whether a match was found.
+ Lookup(string) (*v3.PathItem, string, bool)
+}
+
+// Router locates an OpenAPI route for a request.
+type Router interface {
+ // FindRoute returns the route selected for a request.
+ //
+ // A method mismatch returns a non-nil partial Route and an error matching
+ // ErrMethodNotAllowed. A server or path mismatch returns a nil Route and an
+ // error matching ErrPathNotFound.
+ FindRoute(*http.Request) (*Route, error)
+ // Release drops document and router-owned lookup references. Injected lookups
+ // and regex caches are borrowed and are never released by the router.
+ Release()
+}
+
+// Route describes the OpenAPI operation selected for a request.
+type Route struct {
+ Document *v3.Document // Document is the OpenAPI document used for matching.
+ Server *v3.Server // Server is the matched server, or nil for the implicit relative server.
+ Path string // Path is the matched OpenAPI path template.
+ PathItem *v3.PathItem // PathItem is the matched OpenAPI path item.
+ Method string // Method is the request method, including additional methods.
+ Operation *v3.Operation // Operation is nil on a method mismatch.
+ // RawPathParams contains escaped parameter values exactly as matched in the URL path.
+ RawPathParams map[string]string
+ // PathParams contains URL-decoded operation path parameter values.
+ PathParams map[string]string
+ // ServerParams contains URL-decoded server variable values.
+ ServerParams map[string]string
+}
+
+// RouteError retains partial match context while supporting errors.Is.
+type RouteError struct {
+ Kind error // Kind is ErrPathNotFound or ErrMethodNotAllowed.
+ Route *Route // Route is populated for method mismatches and nil for path misses.
+}
+
+// Error returns the stable error message for the route failure kind.
+func (e *RouteError) Error() string {
+ if e == nil || e.Kind == nil {
+ return "openapi route error"
+ }
+ return e.Kind.Error()
+}
+
+// Unwrap exposes the stable route error kind.
+func (e *RouteError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Kind
+}
+
+type options struct {
+ lookup PathLookup
+ regexCache RegexCache
+ pathOnly bool
+}
+
+// Option configures Router construction.
+type Option func(*options)
+
+// WithPathLookup borrows a path lookup. The router never releases it.
+func WithPathLookup(lookup PathLookup) Option {
+ return func(o *options) { o.lookup = lookup }
+}
+
+// WithRegexCache borrows a compiled-regex cache. The router never releases it.
+func WithRegexCache(cache RegexCache) Option {
+ return func(o *options) { o.regexCache = cache }
+}
+
+// WithPathOnlyMatching preserves validator-compatible document base-path matching.
+func WithPathOnlyMatching() Option {
+ return func(o *options) { o.pathOnly = true }
+}
+
+type routeFinder struct {
+ mu sync.RWMutex
+ document *v3.Document
+ lookup PathLookup
+ ownedLookup *radix.PathTree
+ regexCache RegexCache
+ pathOnly bool
+ servers []*v3.Server
+ paths map[string]*compiledPath
+ serverMatch map[*v3.Server]*compiledServer
+}
+
+type compiledPath struct {
+ expression *regexp.Regexp
+ names []string
+}
+
+// NewRouter creates an immutable, concurrency-safe router.
+// Strict OpenAPI server matching is enabled unless WithPathOnlyMatching is supplied.
+func NewRouter(document *v3.Document, opts ...Option) Router {
+ o := &options{}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(o)
+ }
+ }
+ r := &routeFinder{document: document, lookup: o.lookup, regexCache: o.regexCache, pathOnly: o.pathOnly}
+ if r.lookup == nil {
+ r.ownedLookup = radix.BuildPathTree(document)
+ r.lookup = r.ownedLookup
+ }
+ if !r.pathOnly {
+ r.servers = collectServers(document)
+ }
+ r.paths = compileDocumentPaths(document, r.regexCache)
+ r.serverMatch = compileDocumentServers(r.servers)
+ return r
+}
+
+func (r *routeFinder) FindRoute(request *http.Request) (*Route, error) {
+ if r == nil {
+ return nil, &RouteError{Kind: ErrPathNotFound}
+ }
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if request == nil || request.URL == nil || r.document == nil || r.document.Paths == nil || r.document.Paths.PathItems == nil {
+ return nil, &RouteError{Kind: ErrPathNotFound}
+ }
+
+ if r.pathOnly {
+ return r.find(request, compatibilityPath(request, r.document), compatibilityServer(request, r.document), nil)
+ }
+
+ if len(r.servers) == 0 {
+ return r.find(request, request.URL.EscapedPath(), nil, nil)
+ }
+
+ var partial *Route
+ for _, server := range r.servers {
+ path, params, ok := matchCompiledServer(request, server, r.serverMatch[server])
+ if !ok {
+ continue
+ }
+ route, err := r.find(request, path, server, params)
+ if route == nil {
+ continue
+ }
+ if !serverIsEffective(server, route.Operation, route.PathItem, r.document) {
+ continue
+ }
+ if err == nil {
+ return route, nil
+ }
+ partial = route
+ }
+ implicit, implicitErr := r.find(request, request.URL.EscapedPath(), nil, nil)
+ if implicit != nil && usesImplicitServer(implicit.Operation, implicit.PathItem, r.document) {
+ if implicitErr == nil {
+ return implicit, nil
+ }
+ if partial == nil {
+ partial = implicit
+ }
+ }
+ if partial != nil {
+ return partial, &RouteError{Kind: ErrMethodNotAllowed, Route: partial}
+ }
+ return nil, &RouteError{Kind: ErrPathNotFound}
+}
+
+func usesImplicitServer(operation *v3.Operation, item *v3.PathItem, document *v3.Document) bool {
+ return (operation == nil || len(operation.Servers) == 0) &&
+ (item == nil || len(item.Servers) == 0) &&
+ (document == nil || len(document.Servers) == 0)
+}
+
+func (r *routeFinder) find(request *http.Request, requestPath string, server *v3.Server, serverParams map[string]string) (*Route, error) {
+ if requestPath == "" {
+ requestPath = "/"
+ }
+ pathItem, path, found := r.lookup.Lookup(requestPath)
+ if found && !simpleRadixTemplate(path) {
+ compiled := r.compiledPath(path, requestPath)
+ found = compiled != nil && compiled.expression.MatchString(requestPath)
+ }
+ if found && (!r.pathOnly && !sameTrailingSlash(path, requestPath)) {
+ found = false
+ }
+ if !found {
+ pathItem, path = r.regexLookup(requestPath, request.Method)
+ found = pathItem != nil
+ }
+ if !found {
+ return nil, &RouteError{Kind: ErrPathNotFound}
+ }
+
+ operation := operationForMethod(pathItem, request.Method)
+ raw, decoded := r.extractPathParams(path, requestPath)
+ route := &Route{
+ Document: r.document,
+ Server: server,
+ Path: path,
+ PathItem: pathItem,
+ Method: request.Method,
+ Operation: operation,
+ RawPathParams: raw,
+ PathParams: decoded,
+ ServerParams: serverParams,
+ }
+ if operation == nil {
+ return route, &RouteError{Kind: ErrMethodNotAllowed, Route: route}
+ }
+ return route, nil
+}
+
+type candidate struct {
+ path string
+ item *v3.PathItem
+ score int
+ hasMethod bool
+}
+
+func (r *routeFinder) regexLookup(requestPath, method string) (*v3.PathItem, string) {
+ candidates := make([]candidate, 0)
+ for pair := orderedmap.First(r.document.Paths.PathItems); pair != nil; pair = pair.Next() {
+ compiled := r.compiledPath(pair.Key(), requestPath)
+ if compiled != nil && compiled.expression.MatchString(requestPath) {
+ candidates = append(candidates, candidate{pair.Key(), pair.Value(), specificity(pair.Key()), operationForMethod(pair.Value(), method) != nil})
+ }
+ }
+ if len(candidates) == 0 {
+ return nil, ""
+ }
+ sort.SliceStable(candidates, func(i, j int) bool {
+ if candidates[i].hasMethod != candidates[j].hasMethod {
+ return candidates[i].hasMethod
+ }
+ if candidates[i].score != candidates[j].score {
+ return candidates[i].score > candidates[j].score
+ }
+ return candidates[i].path < candidates[j].path
+ })
+ return candidates[0].item, candidates[0].path
+}
+
+func (r *routeFinder) Release() {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.ownedLookup != nil {
+ r.ownedLookup.Release()
+ }
+ r.document = nil
+ r.lookup = nil
+ r.ownedLookup = nil
+ r.regexCache = nil
+ r.servers = nil
+ r.paths = nil
+ r.serverMatch = nil
+}
+
+func operationForMethod(item *v3.PathItem, method string) *v3.Operation {
+ if item == nil {
+ return nil
+ }
+ switch method {
+ case http.MethodGet:
+ return item.Get
+ case http.MethodPut:
+ return item.Put
+ case http.MethodPost:
+ return item.Post
+ case http.MethodDelete:
+ return item.Delete
+ case http.MethodOptions:
+ return item.Options
+ case http.MethodHead:
+ if item.Head != nil {
+ return item.Head
+ }
+ return item.Get
+ case http.MethodPatch:
+ return item.Patch
+ case http.MethodTrace:
+ return item.Trace
+ case "QUERY":
+ return item.Query
+ }
+ if item.AdditionalOperations != nil {
+ for pair := orderedmap.First(item.AdditionalOperations); pair != nil; pair = pair.Next() {
+ if strings.EqualFold(pair.Key(), method) {
+ return pair.Value()
+ }
+ }
+ }
+ return nil
+}
+
+func sameTrailingSlash(template, requestPath string) bool {
+ return strings.HasSuffix(template, "/") == strings.HasSuffix(requestPath, "/")
+}
+
+func simpleRadixTemplate(path string) bool {
+ for _, segment := range strings.Split(strings.Trim(path, "/"), "/") {
+ if !strings.ContainsAny(segment, "{}") {
+ continue
+ }
+ if len(segment) < 3 || segment[0] != '{' || segment[len(segment)-1] != '}' || strings.ContainsAny(segment[1:len(segment)-1], ":.*;") {
+ return false
+ }
+ }
+ return true
+}
+
+func normalizeFragment(path, requestPath string) string {
+ if !strings.Contains(requestPath, "#") {
+ if i := strings.IndexByte(path, '#'); i >= 0 {
+ return path[:i]
+ }
+ }
+ return path
+}
+
+func specificity(path string) int {
+ score := 0
+ for _, segment := range strings.Split(path, "/") {
+ if segment == "" {
+ continue
+ }
+ if strings.Contains(segment, "{") && strings.Contains(segment, "}") {
+ score++
+ } else {
+ score += 1000
+ }
+ }
+ return score
+}
+
+func compileDocumentPaths(document *v3.Document, cache RegexCache) map[string]*compiledPath {
+ compiled := make(map[string]*compiledPath)
+ if document == nil || document.Paths == nil || document.Paths.PathItems == nil {
+ return compiled
+ }
+ for pair := orderedmap.First(document.Paths.PathItems); pair != nil; pair = pair.Next() {
+ for _, path := range []string{pair.Key(), normalizeFragment(pair.Key(), "")} {
+ if _, exists := compiled[path]; exists {
+ continue
+ }
+ if cache != nil {
+ if value, ok := cache.Load(path); ok {
+ if expression, valid := value.(*regexp.Regexp); valid {
+ if names, err := templateNames(path); err == nil {
+ compiled[path] = &compiledPath{expression: expression, names: names}
+ continue
+ }
+ }
+ }
+ }
+ expression, names, err := compileTemplate(path, "[^/]*")
+ if err == nil {
+ compiled[path] = &compiledPath{expression: expression, names: names}
+ if cache != nil {
+ cache.Store(path, expression)
+ }
+ }
+ }
+ }
+ return compiled
+}
+
+func templateNames(template string) ([]string, error) {
+ indices, err := braceIndices(template)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(indices)/2)
+ for i := 0; i < len(indices); i += 2 {
+ parts := strings.SplitN(template[indices[i]+1:indices[i+1]-1], ":", 2)
+ name := strings.TrimSuffix(strings.TrimLeft(parts[0], ".;"), "*")
+ if name == "" {
+ return nil, fmt.Errorf("invalid route parameter in %q", template)
+ }
+ names = append(names, name)
+ }
+ return names, nil
+}
+
+func (r *routeFinder) compiledPath(path, requestPath string) *compiledPath {
+ if r == nil {
+ return nil
+ }
+ normalized := normalizeFragment(path, requestPath)
+ if compiled := r.paths[normalized]; compiled != nil {
+ return compiled
+ }
+ expression, names, err := compileTemplate(normalized, "[^/]*")
+ if err != nil {
+ return nil
+ }
+ return &compiledPath{expression: expression, names: names}
+}
+
+func compileTemplate(template, defaultPattern string) (*regexp.Regexp, []string, error) {
+ indices, err := braceIndices(template)
+ if err != nil {
+ return nil, nil, err
+ }
+ var pattern strings.Builder
+ pattern.WriteByte('^')
+ names := make([]string, 0, len(indices)/2)
+ end := 0
+ for i := 0; i < len(indices); i += 2 {
+ pattern.WriteString(regexp.QuoteMeta(template[end:indices[i]]))
+ end = indices[i+1]
+ parts := strings.SplitN(template[indices[i]+1:end-1], ":", 2)
+ name := strings.TrimSuffix(strings.TrimLeft(parts[0], ".;"), "*")
+ match := defaultPattern
+ if len(parts) == 2 {
+ match = parts[1]
+ }
+ if name == "" || match == "" {
+ return nil, nil, fmt.Errorf("invalid route parameter in %q", template)
+ }
+ names = append(names, name)
+ pattern.WriteByte('(')
+ pattern.WriteString(match)
+ pattern.WriteByte(')')
+ }
+ pattern.WriteString(regexp.QuoteMeta(template[end:]))
+ pattern.WriteByte('$')
+ expression, err := regexp.Compile(pattern.String())
+ if err != nil {
+ return nil, nil, err
+ }
+ if expression.NumSubexp() != len(names) {
+ return nil, nil, fmt.Errorf("route %s contains capturing groups", template)
+ }
+ return expression, names, nil
+}
+
+func braceIndices(value string) ([]int, error) {
+ level, start := 0, 0
+ var indices []int
+ for i := 0; i < len(value); i++ {
+ switch value[i] {
+ case '{':
+ level++
+ if level == 1 {
+ start = i
+ }
+ case '}':
+ level--
+ if level == 0 {
+ indices = append(indices, start, i+1)
+ } else if level < 0 {
+ return nil, fmt.Errorf("unbalanced braces in %q", value)
+ }
+ }
+ }
+ if level != 0 {
+ return nil, fmt.Errorf("unbalanced braces in %q", value)
+ }
+ return indices, nil
+}
+
+func (r *routeFinder) extractPathParams(template, requestPath string) (map[string]string, map[string]string) {
+ compiled := r.compiledPath(template, requestPath)
+ if compiled == nil || len(compiled.names) == 0 {
+ return nil, nil
+ }
+ matches := compiled.expression.FindStringSubmatch(requestPath)
+ if len(matches) != len(compiled.names)+1 {
+ return nil, nil
+ }
+ raw := make(map[string]string, len(compiled.names))
+ decoded := make(map[string]string, len(compiled.names))
+ for i, name := range compiled.names {
+ raw[name] = matches[i+1]
+ decoded[name] = decodePathValue(matches[i+1])
+ }
+ return raw, decoded
+}
+
+func decodePathValue(value string) string {
+ decoded, err := url.PathUnescape(value)
+ if err != nil {
+ return value
+ }
+ return decoded
+}
diff --git a/router/router_test.go b/router/router_test.go
new file mode 100644
index 00000000..9e7572b7
--- /dev/null
+++ b/router/router_test.go
@@ -0,0 +1,560 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package router
+
+import (
+ "crypto/tls"
+ "errors"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sync"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi/orderedmap"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+)
+
+func model(t testing.TB, specification string) *v3.Document {
+ t.Helper()
+ document, err := libopenapi.NewDocument([]byte(specification))
+ require.NoError(t, err)
+ built, buildErr := document.BuildV3Model()
+ require.NoError(t, buildErr)
+ return &built.Model
+}
+
+const routingSpec = `openapi: 3.1.0
+info: {title: routing, version: 1.0.0}
+servers:
+ - url: https://{tenant}.example.com:{port}/doc/{version}
+ variables:
+ tenant: {default: acme}
+ port: {default: "8443", enum: ["8443", "9443"]}
+ version: {default: v1}
+paths:
+ /pets/mine:
+ get: {responses: {"200": {description: ok}}}
+ /pets/{id}:
+ servers:
+ - url: /path/{version}
+ variables:
+ version: {default: v2}
+ get:
+ servers:
+ - url: /operation/{version}
+ variables:
+ version: {default: v3}
+ responses: {"200": {description: ok}}
+ head: {responses: {"200": {description: head}}}
+ /fallback/{id}:
+ get: {responses: {"200": {description: ok}}}
+ /entities('{id}'):
+ get: {responses: {"200": {description: ok}}}
+ /orders/{id:[0-9]+}:
+ get: {responses: {"200": {description: ok}}}
+`
+
+func TestRouterStrictMatchingAndContext(t *testing.T) {
+ r := NewRouter(model(t, routingSpec))
+ t.Cleanup(r.Release)
+
+ req := httptestRequest(http.MethodGet, "http://ignored/operation/v3/pets/a%2Fb")
+ route, err := r.FindRoute(req)
+ require.NoError(t, err)
+ require.NotNil(t, route)
+ assert.Equal(t, "/pets/{id}", route.Path)
+ assert.Equal(t, http.MethodGet, route.Method)
+ assert.NotNil(t, route.Operation)
+ assert.Equal(t, "a%2Fb", route.RawPathParams["id"])
+ assert.Equal(t, "a/b", route.PathParams["id"])
+ assert.Equal(t, "v3", route.ServerParams["version"])
+ assert.Equal(t, "/operation/{version}", route.Server.URL)
+ assert.Same(t, route.Document.Paths.PathItems.GetOrZero("/pets/{id}"), route.PathItem)
+}
+
+func TestRouterLiteralPrecedenceAndHeadBehavior(t *testing.T) {
+ r := NewRouter(model(t, routingSpec))
+ t.Cleanup(r.Release)
+
+ literal, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/pets/mine"))
+ require.NoError(t, err)
+ assert.Equal(t, "/pets/mine", literal.Path)
+ assert.Empty(t, literal.PathParams)
+
+ explicit, err := r.FindRoute(httptestRequest(http.MethodHead, "http://ignored/path/v2/pets/7"))
+ require.NoError(t, err)
+ assert.Same(t, explicit.PathItem.Head, explicit.Operation)
+
+ fallback, err := r.FindRoute(httptestRequest(http.MethodHead, "https://acme.example.com:8443/doc/v1/fallback/7"))
+ require.NoError(t, err)
+ assert.Same(t, fallback.PathItem.Get, fallback.Operation)
+}
+
+func TestRouterMethodAndPathErrors(t *testing.T) {
+ r := NewRouter(model(t, routingSpec))
+ t.Cleanup(r.Release)
+
+ route, err := r.FindRoute(httptestRequest(http.MethodPost, "http://ignored/path/v2/pets/7"))
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrMethodNotAllowed)
+ require.NotNil(t, route)
+ assert.Nil(t, route.Operation)
+ assert.Equal(t, "/pets/{id}", route.Path)
+ var routeErr *RouteError
+ require.True(t, errors.As(err, &routeErr))
+ assert.Same(t, route, routeErr.Route)
+
+ route, err = r.FindRoute(httptestRequest(http.MethodGet, "http://ignored/doc/v1/missing"))
+ assert.Nil(t, route)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+}
+
+func TestRouterServerPrecedenceAndValidation(t *testing.T) {
+ r := NewRouter(model(t, routingSpec))
+ t.Cleanup(r.Release)
+
+ for _, target := range []string{
+ "http://ignored/doc/v1/pets/7",
+ "http://ignored/path/v2/pets/7",
+ "https://acme.example.com:7443/doc/v1/pets/mine",
+ } {
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, target))
+ assert.Nil(t, route, target)
+ assert.ErrorIs(t, err, ErrPathNotFound, target)
+ }
+
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/pets/mine"))
+ require.NoError(t, err)
+ assert.Equal(t, map[string]string{"tenant": "acme", "port": "8443", "version": "v1"}, route.ServerParams)
+}
+
+func TestRouterRegexFallbackAndTrailingSlash(t *testing.T) {
+ r := NewRouter(model(t, routingSpec))
+ t.Cleanup(r.Release)
+
+ odata, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/entities('42')"))
+ require.NoError(t, err)
+ assert.Equal(t, "42", odata.PathParams["id"])
+
+ custom, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/orders/123"))
+ require.NoError(t, err)
+ assert.Equal(t, "123", custom.PathParams["id"])
+
+ missing, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/orders/nope"))
+ assert.Nil(t, missing)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+
+ missing, err = r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/pets/mine/"))
+ assert.Nil(t, missing)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+}
+
+func TestRouterMatrixLabelExplodedAndMultipleSegmentParameters(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: templates, version: 1.0.0}
+paths:
+ /matrix/{;id}:
+ get: {responses: {"200": {description: ok}}}
+ /label/{.id}:
+ get: {responses: {"200": {description: ok}}}
+ /exploded/{id*}:
+ get: {responses: {"200": {description: ok}}}
+ /pair/{x}-{y}:
+ get: {responses: {"200": {description: ok}}}`)
+ r := NewRouter(doc)
+ t.Cleanup(r.Release)
+ tests := []struct {
+ target string
+ path string
+ raw map[string]string
+ }{
+ {"http://example.com/matrix/;id=5", "/matrix/{;id}", map[string]string{"id": ";id=5"}},
+ {"http://example.com/label/.a%2Fb", "/label/{.id}", map[string]string{"id": ".a%2Fb"}},
+ {"http://example.com/exploded/a,b", "/exploded/{id*}", map[string]string{"id": "a,b"}},
+ {"http://example.com/pair/left-right", "/pair/{x}-{y}", map[string]string{"x": "left", "y": "right"}},
+ }
+ for _, test := range tests {
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, test.target))
+ require.NoError(t, err)
+ assert.Equal(t, test.path, route.Path)
+ assert.Equal(t, test.raw, route.RawPathParams)
+ }
+}
+
+func TestRouterImplicitServerAndPathOnlyCompatibility(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+paths:
+ /things/{id}:
+ get: {responses: {"200": {description: ok}}}`)
+ strict := NewRouter(doc)
+ t.Cleanup(strict.Release)
+ route, err := strict.FindRoute(httptestRequest(http.MethodGet, "http://example.com/things/1"))
+ require.NoError(t, err)
+ assert.Nil(t, route.Server)
+ assert.Nil(t, route.ServerParams)
+
+ compatible := NewRouter(model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+servers: [{url: /doc/v1}]
+paths:
+ /pets/mine:
+ get: {responses: {"200": {description: ok}}}`), WithPathOnlyMatching())
+ t.Cleanup(compatible.Release)
+ route, err = compatible.FindRoute(httptestRequest(http.MethodGet, "https://wrong.example/doc/v1/pets/mine"))
+ require.NoError(t, err)
+ assert.Equal(t, "/pets/mine", route.Path)
+}
+
+func TestRouterImplicitServerSurvivesUnrelatedOperationOverride(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: mixed servers, version: 1.0.0}
+paths:
+ /implicit:
+ get: {responses: {"200": {description: ok}}}
+ /explicit:
+ get:
+ servers: [{url: /api}]
+ responses: {"200": {description: ok}}`)
+ r := NewRouter(doc)
+ t.Cleanup(r.Release)
+
+ implicit, err := r.FindRoute(httptestRequest(http.MethodGet, "http://example.com/implicit"))
+ require.NoError(t, err)
+ assert.Equal(t, "/implicit", implicit.Path)
+ assert.Nil(t, implicit.Server)
+
+ explicit, err := r.FindRoute(httptestRequest(http.MethodGet, "http://example.com/api/explicit"))
+ require.NoError(t, err)
+ assert.Equal(t, "/explicit", explicit.Path)
+ require.NotNil(t, explicit.Server)
+ assert.Equal(t, "/api", explicit.Server.URL)
+
+ partial, err := r.FindRoute(httptestRequest(http.MethodPost, "http://example.com/implicit"))
+ assert.ErrorIs(t, err, ErrMethodNotAllowed)
+ require.NotNil(t, partial)
+ assert.Equal(t, "/implicit", partial.Path)
+}
+
+type borrowedLookup struct {
+ item *v3.PathItem
+ released bool
+}
+
+func (b *borrowedLookup) Lookup(string) (*v3.PathItem, string, bool) {
+ return b.item, "/borrowed/{id}", b.item != nil
+}
+
+func (b *borrowedLookup) Release() { b.released = true }
+
+func TestRouterBorrowedLookupAndRelease(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+paths:
+ /borrowed/{id}:
+ get: {responses: {"200": {description: ok}}}`)
+ lookup := &borrowedLookup{item: doc.Paths.PathItems.GetOrZero("/borrowed/{id}")}
+ r := NewRouter(doc, WithPathLookup(lookup), WithPathOnlyMatching())
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, "http://example.com/anything"))
+ require.NoError(t, err)
+ assert.Equal(t, "/borrowed/{id}", route.Path)
+ r.Release()
+ assert.False(t, lookup.released)
+ route, err = r.FindRoute(httptestRequest(http.MethodGet, "http://example.com/anything"))
+ assert.Nil(t, route)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+ r.Release()
+}
+
+type regexCache struct{ sync.Map }
+
+func TestRouterRegexCacheAndConcurrentLookup(t *testing.T) {
+ cache := ®exCache{}
+ r := NewRouter(model(t, routingSpec), WithRegexCache(cache))
+ t.Cleanup(r.Release)
+
+ const workers = 24
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ for range workers {
+ go func() {
+ defer wg.Done()
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/entities('42')"))
+ assert.NoError(t, err)
+ assert.NotNil(t, route)
+ }()
+ }
+ wg.Wait()
+ _, cached := cache.Load("/entities('{id}')")
+ assert.True(t, cached)
+}
+
+func TestRouterUsesPrecompiledRegexCache(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: cache, version: 1.0.0}
+paths:
+ /cached/{id}:
+ get: {responses: {"200": {description: ok}}}`)
+ cache := ®exCache{}
+ cache.Store("/cached/{id}", regexp.MustCompile(`^/cached/([^/]*)$`))
+ r := NewRouter(doc, WithRegexCache(cache))
+ t.Cleanup(r.Release)
+ route, err := r.FindRoute(httptestRequest(http.MethodGet, "http://example.com/cached/value"))
+ require.NoError(t, err)
+ assert.Equal(t, "value", route.PathParams["id"])
+ assert.Len(t, compileDocumentServers([]*v3.Server{{URL: "/{"}}), 0)
+ _, err = templateNames("/{")
+ assert.Error(t, err)
+ _, err = templateNames("/{}")
+ assert.Error(t, err)
+}
+
+func TestRouterNilAndErrorContracts(t *testing.T) {
+ var nilRouter *routeFinder
+ route, err := nilRouter.FindRoute(nil)
+ assert.Nil(t, route)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+ nilRouter.Release()
+
+ r := NewRouter(nil, nil)
+ route, err = r.FindRoute(&http.Request{})
+ assert.Nil(t, route)
+ assert.ErrorIs(t, err, ErrPathNotFound)
+ r.Release()
+
+ assert.Equal(t, "openapi route error", (*RouteError)(nil).Error())
+ assert.Nil(t, (*RouteError)(nil).Unwrap())
+ assert.Equal(t, "openapi route error", (&RouteError{}).Error())
+ assert.Equal(t, ErrPathNotFound.Error(), (&RouteError{Kind: ErrPathNotFound}).Error())
+}
+
+func TestRouterAdditionalOperationAndDeterministicAmbiguity(t *testing.T) {
+ doc := model(t, `openapi: 3.2.0
+info: {title: test, version: 1.0.0}
+paths:
+ /{first}/fixed:
+ query: {responses: {"200": {description: ok}}}
+ /fixed/{second}:
+ query: {responses: {"200": {description: ok}}}`)
+ r := NewRouter(doc)
+ t.Cleanup(r.Release)
+ route, err := r.FindRoute(httptestRequest("QUERY", "http://example.com/fixed/fixed"))
+ require.NoError(t, err)
+ assert.Equal(t, "/fixed/{second}", route.Path)
+}
+
+func TestTemplateHelpersRejectInvalidPatterns(t *testing.T) {
+ _, _, err := compileTemplate("/{", "[^/]*")
+ assert.Error(t, err)
+ _, _, err = compileTemplate("/{name:}", "[^/]*")
+ assert.Error(t, err)
+ _, _, err = compileTemplate("/{name:(x)}", "[^/]*")
+ assert.Error(t, err)
+ _, _, err = compileTemplate("/{name:[}", "[^/]*")
+ assert.Error(t, err)
+ _, err = braceIndices("/bad}")
+ assert.Error(t, err)
+ _, _, err = compileServerTemplate("/{")
+ assert.Error(t, err)
+ _, _, err = compileServerTemplate("/{}")
+ assert.Error(t, err)
+
+ raw, decoded := (&routeFinder{}).extractPathParams("/{", "/x")
+ assert.Nil(t, raw)
+ assert.Nil(t, decoded)
+ var nilFinder *routeFinder
+ assert.Nil(t, nilFinder.compiledPath("/x", "/x"))
+ assert.Equal(t, "%zz", decodePathValue("%zz"))
+ assert.Equal(t, "a/b", decodePathValue("a%2Fb"))
+}
+
+func TestOperationExtractionAllMethods(t *testing.T) {
+ operations := []*v3.Operation{{}, {}, {}, {}, {}, {}, {}, {}, {}}
+ item := &v3.PathItem{
+ Get: operations[0], Put: operations[1], Post: operations[2], Delete: operations[3],
+ Options: operations[4], Head: operations[5], Patch: operations[6], Trace: operations[7], Query: operations[8],
+ }
+ methods := []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete, http.MethodOptions, http.MethodHead, http.MethodPatch, http.MethodTrace, "QUERY"}
+ for i, method := range methods {
+ assert.Same(t, operations[i], operationForMethod(item, method))
+ }
+ item.Head = nil
+ assert.Same(t, item.Get, operationForMethod(item, http.MethodHead))
+ assert.Nil(t, operationForMethod(nil, http.MethodGet))
+ assert.Nil(t, operationForMethod(item, "UNKNOWN"))
+ item.AdditionalOperations = orderedmap.New[string, *v3.Operation]()
+ additional := &v3.Operation{}
+ item.AdditionalOperations.Set("CUSTOM", additional)
+ assert.Same(t, additional, operationForMethod(item, "custom"))
+}
+
+func TestRegexSelectionBranchesAndFragments(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+paths:
+ /{left}/fixed:
+ post: {responses: {"200": {description: ok}}}
+ /fixed/{right}:
+ get: {responses: {"200": {description: ok}}}
+ /{one}/{two}:
+ get: {responses: {"200": {description: ok}}}
+ /fixed/{value}#fragment:
+ get: {responses: {"200": {description: ok}}}`)
+ r := &routeFinder{document: doc}
+ item, path := r.regexLookup("/fixed/fixed", http.MethodGet)
+ assert.NotNil(t, item)
+ assert.Equal(t, "/fixed/{right}", path)
+ item, path = r.regexLookup("/fixed/fixed", http.MethodPost)
+ assert.NotNil(t, item)
+ assert.Equal(t, "/{left}/fixed", path)
+ item, path = r.regexLookup("/none", http.MethodGet)
+ assert.Nil(t, item)
+ assert.Empty(t, path)
+ assert.Equal(t, "/fixed/{value}", normalizeFragment("/fixed/{value}#fragment", "/fixed/x"))
+ assert.Equal(t, "/fixed/{value}#fragment", normalizeFragment("/fixed/{value}#fragment", "/fixed/x#fragment"))
+ assert.Equal(t, 2000, specificity("//literal/fixed"))
+}
+
+func TestFindEmptyPathAndPathParameterMismatch(t *testing.T) {
+ doc := model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+paths:
+ /:
+ get: {responses: {"200": {description: ok}}}`)
+ r := NewRouter(doc).(*routeFinder)
+ t.Cleanup(r.Release)
+ route, err := r.find(httptestRequest(http.MethodGet, "http://example.com/"), "", nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, "/", route.Path)
+ raw, decoded := r.extractPathParams("/{id}", "/no/match")
+ assert.Nil(t, raw)
+ assert.Nil(t, decoded)
+}
+
+func TestServerHelperEdges(t *testing.T) {
+ assert.Nil(t, collectServers(nil))
+ doc := &v3.Document{Servers: []*v3.Server{nil, {URL: "/one"}}}
+ servers := collectServers(doc)
+ require.Len(t, servers, 1)
+
+ _, _, ok := matchServer(nil, &v3.Server{URL: "/"})
+ assert.False(t, ok)
+ _, _, ok = matchCompiledServer(httptestRequest(http.MethodGet, "http://example.com/x"), &v3.Server{URL: "/"}, nil)
+ assert.False(t, ok)
+ _, _, ok = matchServer(&http.Request{}, &v3.Server{URL: "/"})
+ assert.False(t, ok)
+ _, _, ok = matchServer(httptestRequest(http.MethodGet, "http://example.com/x"), nil)
+ assert.False(t, ok)
+
+ request := &http.Request{Method: http.MethodGet, URL: &url.URL{Path: "/api/pets"}, Host: "secure.example.com", TLS: &tls.ConnectionState{}}
+ path, _, ok := matchServer(request, &v3.Server{URL: "https://secure.example.com/api"})
+ assert.True(t, ok)
+ assert.Equal(t, "/pets", path)
+ request.TLS = nil
+ path, _, ok = matchServer(request, &v3.Server{URL: "http://secure.example.com/api"})
+ assert.True(t, ok)
+ assert.Equal(t, "/pets", path)
+
+ path, _, ok = matchServer(httptestRequest(http.MethodGet, "http://example.com/api"), &v3.Server{URL: "api/"})
+ assert.True(t, ok)
+ assert.Equal(t, "/", path)
+ path, _, ok = matchServer(httptestRequest(http.MethodGet, "http://example.com/anything"), &v3.Server{})
+ assert.True(t, ok)
+ assert.Equal(t, "/anything", path)
+ _, _, ok = matchServer(httptestRequest(http.MethodGet, "http://example.com/x"), &v3.Server{URL: "/{"})
+ assert.False(t, ok)
+ assert.Nil(t, serverVariable(nil, "x"))
+ assert.Nil(t, serverVariable(&v3.Server{}, "x"))
+}
+
+func TestCompatibilityPathEdges(t *testing.T) {
+ request := &http.Request{URL: &url.URL{Path: "plain", Fragment: "frag"}}
+ assert.Equal(t, "/plain#frag", compatibilityPath(request, nil))
+ doc := &v3.Document{Servers: []*v3.Server{nil, {URL: "http://[::1"}, {URL: "/api"}}}
+ request.URL = &url.URL{Path: "/api/items"}
+ assert.Equal(t, "/items", compatibilityPath(request, doc))
+ assert.Nil(t, compatibilityServer(nil, doc))
+ assert.Nil(t, compatibilityServer(&http.Request{}, doc))
+ assert.Nil(t, compatibilityServer(request, nil))
+ assert.Same(t, doc.Servers[2], compatibilityServer(request, doc))
+ assert.Nil(t, compatibilityServer(httptestRequest(http.MethodGet, "http://example.com/other"), doc))
+}
+
+func TestConcurrentLookupAndRelease(t *testing.T) {
+ r := NewRouter(model(t, `openapi: 3.1.0
+info: {title: test, version: 1.0.0}
+paths:
+ /items/{id}:
+ get: {responses: {"200": {description: ok}}}`))
+ request := httptestRequest(http.MethodGet, "http://example.com/items/1")
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for range 50 {
+ _, _ = r.FindRoute(request)
+ }
+ }()
+ }
+ r.Release()
+ wg.Wait()
+}
+
+func httptestRequest(method, target string) *http.Request {
+ parsed, err := url.Parse(target)
+ if err != nil {
+ panic(err)
+ }
+ return &http.Request{Method: method, URL: parsed, Host: parsed.Host, Header: make(http.Header)}
+}
+
+func BenchmarkRouterStatic(b *testing.B) {
+ r := NewRouter(model(b, routingSpec))
+ b.Cleanup(r.Release)
+ request := httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/pets/mine")
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = r.FindRoute(request)
+ }
+}
+
+func BenchmarkRouterTemplated(b *testing.B) {
+ r := NewRouter(model(b, routingSpec))
+ b.Cleanup(r.Release)
+ request := httptestRequest(http.MethodGet, "http://ignored/operation/v3/pets/123")
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = r.FindRoute(request)
+ }
+}
+
+func BenchmarkRouterRegexFallback(b *testing.B) {
+ r := NewRouter(model(b, routingSpec))
+ b.Cleanup(r.Release)
+ request := httptestRequest(http.MethodGet, "https://acme.example.com:8443/doc/v1/entities('42')")
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = r.FindRoute(request)
+ }
+}
+
+func BenchmarkRouterCompatibilityStatic(b *testing.B) {
+ r := NewRouter(model(b, `openapi: 3.1.0
+info: {title: benchmark, version: 1.0.0}
+paths:
+ /health:
+ get: {responses: {"204": {description: ok}}}`), WithPathOnlyMatching())
+ b.Cleanup(r.Release)
+ request := httptestRequest(http.MethodGet, "http://example.com/health")
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = r.FindRoute(request)
+ }
+}
diff --git a/router/server.go b/router/server.go
new file mode 100644
index 00000000..aaed2451
--- /dev/null
+++ b/router/server.go
@@ -0,0 +1,233 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package router
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+
+ "github.com/pb33f/libopenapi/orderedmap"
+
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+)
+
+type compiledServer struct {
+ expression *regexp.Regexp
+ names []string
+ absolute bool
+}
+
+func compileDocumentServers(servers []*v3.Server) map[*v3.Server]*compiledServer {
+ compiled := make(map[*v3.Server]*compiledServer, len(servers))
+ for _, server := range servers {
+ template, absolute := normalizeServerTemplate(server)
+ expression, names, err := compileServerTemplate(template)
+ if err == nil {
+ compiled[server] = &compiledServer{expression: expression, names: names, absolute: absolute}
+ }
+ }
+ return compiled
+}
+
+func collectServers(document *v3.Document) []*v3.Server {
+ if document == nil {
+ return nil
+ }
+ seen := make(map[*v3.Server]struct{})
+ var servers []*v3.Server
+ add := func(values []*v3.Server) {
+ for _, server := range values {
+ if server == nil {
+ continue
+ }
+ if _, ok := seen[server]; !ok {
+ seen[server] = struct{}{}
+ servers = append(servers, server)
+ }
+ }
+ }
+ add(document.Servers)
+ if document.Paths != nil && document.Paths.PathItems != nil {
+ for pair := orderedmap.First(document.Paths.PathItems); pair != nil; pair = pair.Next() {
+ item := pair.Value()
+ add(item.Servers)
+ for operation := item.GetOperations().First(); operation != nil; operation = operation.Next() {
+ add(operation.Value().Servers)
+ }
+ }
+ }
+ return servers
+}
+
+func serverIsEffective(server *v3.Server, operation *v3.Operation, item *v3.PathItem, document *v3.Document) bool {
+ var effective []*v3.Server
+ if operation != nil && len(operation.Servers) > 0 {
+ effective = operation.Servers
+ } else if item != nil && len(item.Servers) > 0 {
+ effective = item.Servers
+ } else if document != nil {
+ effective = document.Servers
+ }
+ for _, candidate := range effective {
+ if candidate == server {
+ return true
+ }
+ }
+ return false
+}
+
+func matchServer(request *http.Request, server *v3.Server) (string, map[string]string, bool) {
+ if request == nil || request.URL == nil || server == nil {
+ return "", nil, false
+ }
+ template, absolute := normalizeServerTemplate(server)
+ expression, names, err := compileServerTemplate(template)
+ if err != nil {
+ return "", nil, false
+ }
+ return matchCompiledServer(request, server, &compiledServer{expression: expression, names: names, absolute: absolute})
+}
+
+func normalizeServerTemplate(server *v3.Server) (string, bool) {
+ template := server.URL
+ if template == "" {
+ template = "/"
+ }
+ absolute := strings.Contains(template, "://")
+ if !absolute && !strings.HasPrefix(template, "/") {
+ template = "/" + template
+ }
+ template = strings.TrimSuffix(template, "/")
+ if template == "" {
+ template = "/"
+ }
+ return template, absolute
+}
+
+func matchCompiledServer(request *http.Request, server *v3.Server, compiled *compiledServer) (string, map[string]string, bool) {
+ if request == nil || request.URL == nil || server == nil || compiled == nil {
+ return "", nil, false
+ }
+ target := request.URL.EscapedPath()
+ if compiled.absolute {
+ scheme := request.URL.Scheme
+ if scheme == "" {
+ if request.TLS != nil {
+ scheme = "https"
+ } else {
+ scheme = "http"
+ }
+ }
+ host := request.URL.Host
+ if host == "" {
+ host = request.Host
+ }
+ target = scheme + "://" + host + target
+ }
+
+ matches := compiled.expression.FindStringSubmatch(target)
+ if matches == nil {
+ return "", nil, false
+ }
+ params := make(map[string]string, len(compiled.names))
+ for i, name := range compiled.names {
+ value := decodePathValue(matches[i+1])
+ if variable := serverVariable(server, name); variable != nil && len(variable.Enum) > 0 && !contains(variable.Enum, value) {
+ return "", nil, false
+ }
+ params[name] = value
+ }
+ rest := matches[len(matches)-1]
+ if rest == "" {
+ rest = "/"
+ }
+ return rest, params, true
+}
+
+func compileServerTemplate(template string) (*regexp.Regexp, []string, error) {
+ if template == "/" {
+ expression, err := regexp.Compile(`^(/.*|/)$`)
+ return expression, nil, err
+ }
+ indices, err := braceIndices(template)
+ if err != nil {
+ return nil, nil, err
+ }
+ var pattern strings.Builder
+ pattern.WriteByte('^')
+ names := make([]string, 0, len(indices)/2)
+ end := 0
+ for i := 0; i < len(indices); i += 2 {
+ pattern.WriteString(regexp.QuoteMeta(template[end:indices[i]]))
+ end = indices[i+1]
+ name := template[indices[i]+1 : end-1]
+ if name == "" {
+ return nil, nil, &url.Error{Op: "parse", URL: template, Err: url.InvalidHostError("empty server variable")}
+ }
+ names = append(names, name)
+ pattern.WriteString("([^/?#]+)")
+ }
+ pattern.WriteString(regexp.QuoteMeta(template[end:]))
+ pattern.WriteString("(/.*|$)")
+ expression, err := regexp.Compile(pattern.String())
+ return expression, names, err
+}
+
+func serverVariable(server *v3.Server, name string) *v3.ServerVariable {
+ if server == nil || server.Variables == nil {
+ return nil
+ }
+ return server.Variables.GetOrZero(name)
+}
+
+func contains(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+func compatibilityPath(request *http.Request, document *v3.Document) string {
+ path := request.URL.EscapedPath()
+ if document != nil {
+ for _, server := range document.Servers {
+ if server == nil {
+ continue
+ }
+ parsed, err := url.Parse(server.URL)
+ if err == nil && parsed.Path != "" && strings.HasPrefix(path, parsed.Path) {
+ path = strings.TrimPrefix(path, parsed.Path)
+ break
+ }
+ }
+ }
+ if request.URL.Fragment != "" {
+ path += "#" + request.URL.Fragment
+ }
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ return path
+}
+
+func compatibilityServer(request *http.Request, document *v3.Document) *v3.Server {
+ if request == nil || request.URL == nil || document == nil {
+ return nil
+ }
+ path := request.URL.EscapedPath()
+ for _, server := range document.Servers {
+ if server == nil {
+ continue
+ }
+ parsed, err := url.Parse(server.URL)
+ if err == nil && parsed.Path != "" && strings.HasPrefix(path, parsed.Path) {
+ return server
+ }
+ }
+ return nil
+}
diff --git a/schema_validation/directional_schema_test.go b/schema_validation/directional_schema_test.go
index a7aa5abc..5b5ad6a2 100644
--- a/schema_validation/directional_schema_test.go
+++ b/schema_validation/directional_schema_test.go
@@ -99,7 +99,7 @@ components:
$ref: '#/components/schemas/Error'`, "Error")
rendered, err = RenderSchemaForValidation(schema, SchemaValidationPurposeRequestBody)
- require.Error(t, err)
+ require.NoError(t, err)
require.NotNil(t, rendered)
}
diff --git a/schema_validation/openapi_schemas/load_schema.go b/schema_validation/openapi_schemas/load_schema.go
index 2a5abe5c..d36b8eaf 100644
--- a/schema_validation/openapi_schemas/load_schema.go
+++ b/schema_validation/openapi_schemas/load_schema.go
@@ -1,7 +1,8 @@
-// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
-// Package openapi_schemas contains the OpenAPI 3.0 and 3.1 schemas that are loaded from libopenapi, or our own
+// Package openapi_schemas contains legacy OpenAPI 3.0 and 3.1 schema loaders.
+// Document validation uses the embedded 3.0, 3.1, and 3.2 schemas selected by libopenapi.
// fork of the official OpenAPI repo specifications. Using an MD5 hash, we can compare the local version against
// the remote version and determine if they differ, if they do - load the remote version.
package openapi_schemas
diff --git a/schema_validation/schema_resources.go b/schema_validation/schema_resources.go
index dc4f7c66..c881b7f1 100644
--- a/schema_validation/schema_resources.go
+++ b/schema_validation/schema_resources.go
@@ -10,13 +10,13 @@ import (
"strconv"
"strings"
- "github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi/datamodel/high/base"
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/libopenapi/utils"
"github.com/santhosh-tekuri/jsonschema/v6"
"go.yaml.in/yaml/v4"
+ "github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/helpers"
)
@@ -28,6 +28,8 @@ var renderSchemaWithRefs = func(schema *base.Schema) ([]byte, error) {
return schema.Render()
}
+var renderSchemaForValidation = RenderSchemaForValidation
+
// CompiledValidationSchema contains a schema compiled for validation plus the rendered schema context.
type CompiledValidationSchema struct {
RenderedInline []byte
@@ -189,7 +191,7 @@ func renderRootSchemaForValidation(schema *base.Schema, purpose SchemaValidation
return nil, fmt.Errorf("schema does not have low-level information and cannot be rendered")
}
- rendered, err := RenderSchemaForValidation(schema, purpose)
+ rendered, err := renderSchemaForValidation(schema, purpose)
if err == nil {
return rendered, nil
}
diff --git a/schema_validation/schema_resources_test.go b/schema_validation/schema_resources_test.go
index 27eba221..5ab1a639 100644
--- a/schema_validation/schema_resources_test.go
+++ b/schema_validation/schema_resources_test.go
@@ -10,15 +10,16 @@ import (
"testing"
"github.com/pb33f/libopenapi"
- validatorcache "github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/datamodel/high/base"
- lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/testify/assert"
"github.com/pb33f/testify/require"
"go.yaml.in/yaml/v4"
+ lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
+
+ validatorcache "github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi-validator/config"
)
@@ -233,10 +234,15 @@ components:
schema := model.Model.Components.Schemas.GetOrZero("Node").Schema()
originalRenderSchemaWithRefs := renderSchemaWithRefs
+ originalRenderSchemaForValidation := renderSchemaForValidation
+ renderSchemaForValidation = func(*base.Schema, SchemaValidationPurpose) (*RenderedValidationSchema, error) {
+ return nil, assert.AnError
+ }
renderSchemaWithRefs = func(*base.Schema) ([]byte, error) {
return nil, assert.AnError
}
t.Cleanup(func() {
+ renderSchemaForValidation = originalRenderSchemaForValidation
renderSchemaWithRefs = originalRenderSchemaWithRefs
})
diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go
index c98d92d8..02f838dc 100644
--- a/schema_validation/validate_document.go
+++ b/schema_validation/validate_document.go
@@ -218,7 +218,7 @@ func buildDocumentDecodeError(reason, context string) *liberrors.ValidationError
}
}
-// ValidateOpenAPIDocument will validate an OpenAPI document against the OpenAPI 2, 3.0 and 3.1 schemas (depending on version)
+// ValidateOpenAPIDocument validates an OpenAPI document against the embedded OpenAPI 2, 3.0, 3.1, or 3.2 schema selected by libopenapi.
// It will return true if the document is valid, false if it is not and a slice of ValidationError pointers.
func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bool, []*liberrors.ValidationError) {
return ValidateOpenAPIDocumentWithPrecompiled(doc, nil, opts...)
@@ -378,6 +378,9 @@ func ValidateOpenAPIDocumentWithPrecompiled(doc libopenapi.Document, compiledSch
// location of the violation within the rendered schema.
violation.Line = line
violation.Column = located.Column
+ if source, err := yaml.Marshal(located); err == nil {
+ violation.ReferenceObject = strings.TrimSpace(string(source))
+ }
} else {
// handles property name validation errors that don't provide useful InstanceLocation
applyPropertyNameFallback(propertyInfo, info.RootNode.Content[0], violation)
@@ -388,14 +391,20 @@ func ValidateOpenAPIDocumentWithPrecompiled(doc libopenapi.Document, compiledSch
}
// add the error to the list
- validationErrors = append(validationErrors, &liberrors.ValidationError{
+ documentError := &liberrors.ValidationError{
ValidationType: helpers.Schema,
Message: "Document does not pass validation",
Reason: fmt.Sprintf("OpenAPI document is not valid according "+
"to the %s specification", info.Version),
SchemaValidationErrors: schemaValidationErrors,
HowToFix: liberrors.HowToFixInvalidSchema,
- })
+ }
+ if len(schemaValidationErrors) > 0 {
+ documentError.SpecLine = schemaValidationErrors[0].Line
+ documentError.SpecCol = schemaValidationErrors[0].Column
+ documentError.Context = schemaValidationErrors[0].FieldPath
+ }
+ validationErrors = append(validationErrors, documentError)
}
if len(validationErrors) > 0 {
return false, validationErrors
diff --git a/schema_validation/validate_document_test.go b/schema_validation/validate_document_test.go
index a02ef597..662d1102 100644
--- a/schema_validation/validate_document_test.go
+++ b/schema_validation/validate_document_test.go
@@ -606,3 +606,26 @@ func TestValidateDocument_SpecJSONBytesPath_Invalid(t *testing.T) {
assert.Len(t, errs, 1)
assert.NotEmpty(t, errs[0].SchemaValidationErrors)
}
+
+func TestValidateDocument_32Diagnostics(t *testing.T) {
+ validFixture, readErr := os.ReadFile("../test_specs/valid_32.yaml")
+ assert.NoError(t, readErr)
+ document, err := libopenapi.NewDocument(validFixture)
+ assert.NoError(t, err)
+ valid, errs := ValidateOpenAPIDocument(document)
+ assert.True(t, valid)
+ assert.Empty(t, errs)
+
+ invalidFixture, readErr := os.ReadFile("../test_specs/invalid_32.yaml")
+ assert.NoError(t, readErr)
+ document, err = libopenapi.NewDocument(invalidFixture)
+ assert.NoError(t, err)
+ valid, errs = ValidateOpenAPIDocument(document)
+ assert.False(t, valid)
+ if assert.NotEmpty(t, errs) && assert.NotEmpty(t, errs[0].SchemaValidationErrors) {
+ assert.Greater(t, errs[0].SpecLine, 0)
+ assert.GreaterOrEqual(t, errs[0].SpecCol, 0)
+ assert.Contains(t, fmt.Sprint(errs[0].Context), "info")
+ assert.NotEmpty(t, errs[0].SchemaValidationErrors[0].ReferenceObject)
+ }
+}
diff --git a/schema_validation/validate_schema_openapi_test.go b/schema_validation/validate_schema_openapi_test.go
index 603e857d..43f9ea44 100644
--- a/schema_validation/validate_schema_openapi_test.go
+++ b/schema_validation/validate_schema_openapi_test.go
@@ -1,4 +1,4 @@
-// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley
+// Copyright 2025-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT
package schema_validation
@@ -327,7 +327,7 @@ components:
t.Run("should fail rendering", func(t *testing.T) {
_, err := schema.Schema().RenderInline()
- assert.Error(t, err, "RenderInline should not error on circular refs")
+ assert.NoError(t, err, "RenderInline should support circular refs")
})
t.Run("should validate circular references", func(t *testing.T) {
diff --git a/test_specs/invalid_32.yaml b/test_specs/invalid_32.yaml
new file mode 100644
index 00000000..9ae7dc2d
--- /dev/null
+++ b/test_specs/invalid_32.yaml
@@ -0,0 +1,12 @@
+openapi: 3.2.0
+$self: https://example.com/openapi.yaml
+info:
+ title: Invalid OpenAPI 3.2 validation fixture
+ version:
+ - must-be-a-string
+paths:
+ /search:
+ query:
+ responses:
+ "200":
+ description: search result
diff --git a/test_specs/valid_32.yaml b/test_specs/valid_32.yaml
new file mode 100644
index 00000000..27fe101f
--- /dev/null
+++ b/test_specs/valid_32.yaml
@@ -0,0 +1,21 @@
+openapi: 3.2.0
+$self: https://example.com/openapi.yaml
+info:
+ title: OpenAPI 3.2 validation fixture
+ version: 1.0.0
+servers:
+ - name: production
+ url: https://api.example.com
+paths:
+ /search:
+ query:
+ responses:
+ "200":
+ description: search result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ok:
+ type: boolean
diff --git a/validator.go b/validator.go
index 021750b8..c5e74eda 100644
--- a/validator.go
+++ b/validator.go
@@ -17,11 +17,14 @@ import (
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi-validator/internal/bodycodec"
+ "github.com/pb33f/libopenapi-validator/internal/requeststate"
"github.com/pb33f/libopenapi-validator/parameters"
"github.com/pb33f/libopenapi-validator/paths"
"github.com/pb33f/libopenapi-validator/radix"
"github.com/pb33f/libopenapi-validator/requests"
"github.com/pb33f/libopenapi-validator/responses"
+ "github.com/pb33f/libopenapi-validator/router"
"github.com/pb33f/libopenapi-validator/schema_validation"
)
@@ -55,7 +58,7 @@ type Validator interface {
// The path, query, cookie and header parameters and request and response body are validated.
ValidateHttpRequestResponse(request *http.Request, response *http.Response) (bool, []*errors.ValidationError)
- // ValidateDocument will validate an OpenAPI 3+ document against the 3.0 or 3.1 OpenAPI 3+ specification
+ // ValidateDocument validates an OpenAPI document against the embedded 3.0, 3.1, or 3.2 specification schema.
ValidateDocument() (bool, []*errors.ValidationError)
// GetParameterValidator will return a parameters.ParameterValidator instance used to validate parameters
@@ -88,18 +91,32 @@ func NewValidator(document libopenapi.Document, opts ...config.Option) (Validato
// NewValidatorFromV3Model will create a new Validator from an OpenAPI Model
func NewValidatorFromV3Model(m *v3.Document, opts ...config.Option) Validator {
options := config.NewValidationOptions(opts...)
+ bodycodec.Apply(options)
// Build radix tree for O(k) path lookup (where k = path depth)
// Skip if path tree is disabled or a custom tree was provided
if options.PathTree == nil && !options.IsPathTreeDisabled() {
options.PathTree = radix.BuildPathTree(m)
}
+ var routerOptions []router.Option
+ if options.PathTree != nil {
+ routerOptions = append(routerOptions, router.WithPathLookup(options.PathTree))
+ }
+ if options.RegexCache != nil {
+ routerOptions = append(routerOptions, router.WithRegexCache(options.RegexCache))
+ }
+ if !options.StrictServerMatching {
+ routerOptions = append(routerOptions, router.WithPathOnlyMatching())
+ }
+ routeFinder := router.NewRouter(m, routerOptions...)
+ options.Router = routeFinder
+ options.BodyRegistry = options.BodyRegistry.Precompute(declaredMediaTypes(m))
// warm the schema caches by pre-compiling all schemas in the document
// (warmSchemaCaches checks for nil cache and skips if disabled)
warmSchemaCaches(m, options)
- v := &validator{options: options, v3Model: m}
+ v := &validator{options: options, v3Model: m, router: routeFinder}
// create a new parameter validator
v.paramValidator = parameters.NewParameterValidator(m, config.WithExistingOpts(options))
@@ -113,6 +130,62 @@ func NewValidatorFromV3Model(m *v3.Document, opts ...config.Option) Validator {
return v
}
+func declaredMediaTypes(document *v3.Document) []string {
+ if document == nil || document.Paths == nil || document.Paths.PathItems == nil {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ var mediaTypes []string
+ add := func(mediaType string) {
+ if _, ok := seen[mediaType]; !ok {
+ seen[mediaType] = struct{}{}
+ mediaTypes = append(mediaTypes, mediaType)
+ }
+ }
+ for path := document.Paths.PathItems.First(); path != nil; path = path.Next() {
+ pathItem := path.Value()
+ for operation := pathItem.GetOperations().First(); operation != nil; operation = operation.Next() {
+ op := operation.Value()
+ if op.RequestBody != nil && op.RequestBody.Content != nil {
+ for media := op.RequestBody.Content.First(); media != nil; media = media.Next() {
+ add(media.Key())
+ }
+ }
+ if op.Responses != nil {
+ if op.Responses.Codes != nil {
+ for response := op.Responses.Codes.First(); response != nil; response = response.Next() {
+ if response.Value() != nil && response.Value().Content != nil {
+ for media := response.Value().Content.First(); media != nil; media = media.Next() {
+ add(media.Key())
+ }
+ }
+ }
+ }
+ if op.Responses.Default != nil && op.Responses.Default.Content != nil {
+ for media := op.Responses.Default.Content.First(); media != nil; media = media.Next() {
+ add(media.Key())
+ }
+ }
+ }
+ for _, parameter := range op.Parameters {
+ if parameter != nil && parameter.Content != nil {
+ for media := parameter.Content.First(); media != nil; media = media.Next() {
+ add(media.Key())
+ }
+ }
+ }
+ }
+ for _, parameter := range pathItem.Parameters {
+ if parameter != nil && parameter.Content != nil {
+ for media := parameter.Content.First(); media != nil; media = media.Next() {
+ add(media.Key())
+ }
+ }
+ }
+ }
+ return mediaTypes
+}
+
func (v *validator) SetDocument(document libopenapi.Document) {
v.document = document
}
@@ -121,6 +194,10 @@ func (v *validator) Release() {
if v == nil {
return
}
+ if v.router != nil {
+ v.router.Release()
+ v.router = nil
+ }
releaseIfSupported(v.paramValidator)
releaseIfSupported(v.requestValidator)
releaseIfSupported(v.responseValidator)
@@ -200,20 +277,18 @@ func (v *validator) ValidateHttpRequestResponse(
request *http.Request,
response *http.Response,
) (bool, []*errors.ValidationError) {
- var pathItem *v3.PathItem
- var pathValue string
- var errs []*errors.ValidationError
-
- pathItem, errs, pathValue = paths.FindPath(request, v.v3Model, v.options)
- if pathItem == nil || errs != nil {
+ route, errs := paths.ResolveRoute(request, v.v3Model, v.options)
+ if route == nil || errs != nil {
return false, errs
}
+ restoreRoute := requeststate.AttachRoute(request, route)
+ defer restoreRoute()
responseBodyValidator := v.responseValidator
// validate request and response
- _, requestErrors := v.ValidateHttpRequestWithPathItem(request, pathItem, pathValue)
- _, responseErrors := responseBodyValidator.ValidateResponseBodyWithPathItem(request, response, pathItem, pathValue)
+ _, requestErrors := v.ValidateHttpRequestWithPathItem(request, route.PathItem, route.Path)
+ _, responseErrors := responseBodyValidator.ValidateResponseBodyWithPathItem(request, response, route.PathItem, route.Path)
if len(requestErrors) > 0 || len(responseErrors) > 0 {
return false, append(requestErrors, responseErrors...)
@@ -222,14 +297,37 @@ func (v *validator) ValidateHttpRequestResponse(
}
func (v *validator) ValidateHttpRequest(request *http.Request) (bool, []*errors.ValidationError) {
- pathItem, errs, foundPath := paths.FindPath(request, v.v3Model, v.options)
+ route, errs := paths.ResolveRoute(request, v.v3Model, v.options)
if len(errs) > 0 {
return false, errs
}
- return v.ValidateHttpRequestWithPathItem(request, pathItem, foundPath)
+ restoreRoute := requeststate.AttachRoute(request, route)
+ defer restoreRoute()
+ return v.ValidateHttpRequestWithPathItem(request, route.PathItem, route.Path)
}
func (v *validator) ValidateHttpRequestWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) {
+ restoreRoute := v.attachRoute(request, pathItem, pathValue)
+ defer restoreRoute()
+ if v.options != nil && v.options.RequestDefaults {
+ return v.validateWithRequestDefaults(request, pathItem, pathValue, v.validateHttpRequestWithPathItem)
+ }
+ return v.validateHttpRequestWithPathItem(request, pathItem, pathValue)
+}
+
+func (v *validator) validateHttpRequestWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) {
+ if v.options != nil && v.options.AuthenticationFunc != nil {
+ if _, err := requeststate.Snapshot(request); err != nil {
+ return false, []*errors.ValidationError{{
+ ValidationType: helpers.RequestBodyValidation,
+ ValidationSubType: helpers.Schema,
+ Message: "request body could not be read for authentication",
+ Reason: err.Error(),
+ HowToFix: "provide a readable request body",
+ }}
+ }
+ return v.ValidateHttpRequestSyncWithPathItem(request, pathItem, pathValue)
+ }
// create a new parameter validator
paramValidator := v.paramValidator
@@ -252,9 +350,11 @@ func (v *validator) ValidateHttpRequestWithPathItem(request *http.Request, pathI
paramValidator.ValidatePathParamsWithPathItem,
paramValidator.ValidateCookieParamsWithPathItem,
paramValidator.ValidateHeaderParamsWithPathItem,
- paramValidator.ValidateQueryParamsWithPathItem,
paramValidator.ValidateSecurityWithPathItem,
}
+ if v.options.ValidateRequestQuery {
+ validations = append(validations, paramValidator.ValidateQueryParamsWithPathItem)
+ }
// listen for validation errors on parameters. everything will run async.
paramListener := func(control chan struct{}, errorChan chan []*errors.ValidationError) {
@@ -310,7 +410,9 @@ func (v *validator) ValidateHttpRequestWithPathItem(request *http.Request, pathI
// build async functions
asyncFunctions := []validationFunctionAsync{
parameterValidationFunc,
- requestBodyValidationFunc,
+ }
+ if v.options.ValidateRequestBody {
+ asyncFunctions = append(asyncFunctions, requestBodyValidationFunc)
}
var validationErrors []*errors.ValidationError
@@ -333,14 +435,41 @@ func (v *validator) ValidateHttpRequestWithPathItem(request *http.Request, pathI
}
func (v *validator) ValidateHttpRequestSync(request *http.Request) (bool, []*errors.ValidationError) {
- pathItem, errs, foundPath := paths.FindPath(request, v.v3Model, v.options)
+ route, errs := paths.ResolveRoute(request, v.v3Model, v.options)
if len(errs) > 0 {
return false, errs
}
- return v.ValidateHttpRequestSyncWithPathItem(request, pathItem, foundPath)
+ restoreRoute := requeststate.AttachRoute(request, route)
+ defer restoreRoute()
+ return v.ValidateHttpRequestSyncWithPathItem(request, route.PathItem, route.Path)
}
func (v *validator) ValidateHttpRequestSyncWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) {
+ restoreRoute := v.attachRoute(request, pathItem, pathValue)
+ defer restoreRoute()
+ if v.options != nil && v.options.RequestDefaults {
+ return v.validateWithRequestDefaults(request, pathItem, pathValue, v.validateHttpRequestSyncWithPathItem)
+ }
+ return v.validateHttpRequestSyncWithPathItem(request, pathItem, pathValue)
+}
+
+func (v *validator) attachRoute(request *http.Request, pathItem *v3.PathItem, pathValue string) func() {
+ if requeststate.Route(request) != nil {
+ return func() {}
+ }
+ route := &router.Route{
+ Document: v.v3Model, Path: pathValue, PathItem: pathItem, Method: request.Method,
+ Operation: helpers.ExtractOperation(request, pathItem),
+ }
+ if v.options != nil && v.options.Router != nil {
+ if resolved, err := v.options.Router.FindRoute(request); err == nil && resolved != nil && resolved.PathItem == pathItem {
+ route = resolved
+ }
+ }
+ return requeststate.AttachRoute(request, route)
+}
+
+func (v *validator) validateHttpRequestSyncWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) {
// create a new parameter validator
paramValidator := v.paramValidator
@@ -350,22 +479,27 @@ func (v *validator) ValidateHttpRequestSyncWithPathItem(request *http.Request, p
validationErrors := make([]*errors.ValidationError, 0)
paramValidationErrors := make([]*errors.ValidationError, 0)
- for _, validateFunc := range []validationFunction{
+ validations := []validationFunction{
paramValidator.ValidatePathParamsWithPathItem,
paramValidator.ValidateCookieParamsWithPathItem,
paramValidator.ValidateHeaderParamsWithPathItem,
- paramValidator.ValidateQueryParamsWithPathItem,
paramValidator.ValidateSecurityWithPathItem,
- } {
+ }
+ if v.options.ValidateRequestQuery {
+ validations = append(validations, paramValidator.ValidateQueryParamsWithPathItem)
+ }
+ for _, validateFunc := range validations {
valid, pErrs := validateFunc(request, pathItem, pathValue)
if !valid {
paramValidationErrors = append(paramValidationErrors, pErrs...)
}
}
- valid, pErrs := reqBodyValidator.ValidateRequestBodyWithPathItem(request, pathItem, pathValue)
- if !valid {
- paramValidationErrors = append(paramValidationErrors, pErrs...)
+ if v.options.ValidateRequestBody {
+ valid, pErrs := reqBodyValidator.ValidateRequestBodyWithPathItem(request, pathItem, pathValue)
+ if !valid {
+ paramValidationErrors = append(paramValidationErrors, pErrs...)
+ }
}
validationErrors = append(validationErrors, paramValidationErrors...)
@@ -379,6 +513,7 @@ type validator struct {
paramValidator parameters.ParameterValidator
requestValidator requests.RequestBodyValidator
responseValidator responses.ResponseBodyValidator
+ router router.Router
}
func runValidation(control, doneChan chan struct{},
diff --git a/validator_32_test.go b/validator_32_test.go
new file mode 100644
index 00000000..f542204f
--- /dev/null
+++ b/validator_32_test.go
@@ -0,0 +1,64 @@
+// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package validator
+
+import (
+ "os"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+
+ "github.com/pb33f/libopenapi-validator/schema_validation"
+)
+
+func TestOpenAPI32DocumentValidationYAMLAndRootValidator(t *testing.T) {
+ specification, err := os.ReadFile("test_specs/valid_32.yaml")
+ require.NoError(t, err)
+ document, err := libopenapi.NewDocument(specification)
+ require.NoError(t, err)
+ valid, validationErrors := schema_validation.ValidateOpenAPIDocument(document)
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+
+ validator, constructionErrors := NewValidator(document)
+ require.Empty(t, constructionErrors)
+ t.Cleanup(validator.Release)
+ valid, validationErrors = validator.ValidateDocument()
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+}
+
+func TestOpenAPI32DocumentValidationJSON(t *testing.T) {
+ document, err := libopenapi.NewDocument([]byte(`{
+ "openapi": "3.2.0",
+ "$self": "https://example.com/openapi.json",
+ "info": {"title": "JSON 3.2", "version": "1.0.0"},
+ "paths": {
+ "/search": {
+ "query": {"responses": {"200": {"description": "ok"}}}
+ }
+ }
+}`))
+ require.NoError(t, err)
+ valid, validationErrors := schema_validation.ValidateOpenAPIDocument(document)
+ require.True(t, valid, validationErrors)
+ require.Empty(t, validationErrors)
+}
+
+func TestOpenAPI32InvalidDocumentDiagnostics(t *testing.T) {
+ specification, err := os.ReadFile("test_specs/invalid_32.yaml")
+ require.NoError(t, err)
+ document, err := libopenapi.NewDocument(specification)
+ require.NoError(t, err)
+ valid, validationErrors := schema_validation.ValidateOpenAPIDocument(document)
+ assert.False(t, valid)
+ require.NotEmpty(t, validationErrors)
+ assert.Greater(t, validationErrors[0].SpecLine, 0)
+ assert.GreaterOrEqual(t, validationErrors[0].SpecCol, 0)
+ assert.NotEmpty(t, validationErrors[0].SchemaValidationErrors)
+ assert.Contains(t, validationErrors[0].SchemaValidationErrors[0].FieldPath, "info")
+ assert.NotEmpty(t, validationErrors[0].SchemaValidationErrors[0].ReferenceObject)
+}