diff --git a/README.md b/README.md index f2c20050..cf758339 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ [![discord](https://img.shields.io/discord/923258363540815912)](https://discord.gg/x7VACVuEGP) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](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) +}