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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,18 @@ type RegexCache interface {
//
// 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
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
Logger *slog.Logger // Logger for debug/error output (nil = silent)
RegexEngine jsonschema.RegexpEngine
RegexCache RegexCache // Enable compiled regex caching
FormatAssertions bool
ContentAssertions bool
SecurityValidation 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
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.

// strict mode options - detect undeclared properties even when additionalProperties: true
StrictMode bool // Enable strict property validation
Expand Down Expand Up @@ -75,6 +77,8 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.Logger = options.Logger
o.AllowXMLBodyValidation = options.AllowXMLBodyValidation
o.AllowURLEncodedBodyValidation = options.AllowURLEncodedBodyValidation
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
Expand Down Expand Up @@ -161,6 +165,22 @@ func WithScalarCoercion() Option {
}
}

// WithXmlBodyValidation enables converting an XML body to a JSON when validating the schema from a request and response body
// The default option is set to false
func WithXmlBodyValidation() Option {
return func(o *ValidationOptions) {
o.AllowXMLBodyValidation = true
}
}

// WithURLEncodedBodyValidation enables converting an URL Encoded body to a JSON when validating the schema from a request and response body
// The default option is set to false
func WithURLEncodedBodyValidation() Option {
return func(o *ValidationOptions) {
o.AllowURLEncodedBodyValidation = 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.
Expand Down
50 changes: 35 additions & 15 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ func TestNewValidationOptions_Defaults(t *testing.T) {
assert.False(t, opts.FormatAssertions)
assert.False(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.True(t, opts.OpenAPIMode) // Default is true
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.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
}
Expand All @@ -32,8 +34,9 @@ func TestNewValidationOptions_WithNilOption(t *testing.T) {
assert.False(t, opts.FormatAssertions)
assert.False(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
}
Expand All @@ -44,8 +47,9 @@ func TestWithFormatAssertions(t *testing.T) {
assert.True(t, opts.FormatAssertions)
assert.False(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
}
Expand All @@ -56,8 +60,9 @@ func TestWithContentAssertions(t *testing.T) {
assert.False(t, opts.FormatAssertions)
assert.True(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
}
Expand Down Expand Up @@ -93,18 +98,22 @@ func TestWithExistingOpts(t *testing.T) {
// Create original options with all settings enabled
var testEngine jsonschema.RegexpEngine = nil
original := &ValidationOptions{
RegexEngine: testEngine,
RegexCache: &sync.Map{},
FormatAssertions: true,
ContentAssertions: true,
SecurityValidation: false,
RegexEngine: testEngine,
RegexCache: &sync.Map{},
FormatAssertions: true,
AllowXMLBodyValidation: true,
AllowURLEncodedBodyValidation: true,
ContentAssertions: true,
SecurityValidation: false,
}

// Create new options using existing options
opts := NewValidationOptions(WithExistingOpts(original))

assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.NotNil(t, opts.RegexCache)
assert.Equal(t, original.AllowXMLBodyValidation, opts.AllowXMLBodyValidation)
assert.Equal(t, original.AllowURLEncodedBodyValidation, opts.AllowURLEncodedBodyValidation)
assert.Equal(t, original.FormatAssertions, opts.FormatAssertions)
assert.Equal(t, original.ContentAssertions, opts.ContentAssertions)
assert.Equal(t, original.SecurityValidation, opts.SecurityValidation)
Expand All @@ -119,8 +128,9 @@ func TestWithExistingOpts_NilSource(t *testing.T) {
assert.False(t, opts.FormatAssertions)
assert.False(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
}
Expand All @@ -129,11 +139,13 @@ func TestMultipleOptions(t *testing.T) {
opts := NewValidationOptions(
WithFormatAssertions(),
WithContentAssertions(),
WithXmlBodyValidation(),
)

assert.True(t, opts.FormatAssertions)
assert.True(t, opts.ContentAssertions)
assert.True(t, opts.SecurityValidation)
assert.True(t, opts.AllowXMLBodyValidation)
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
Expand Down Expand Up @@ -180,6 +192,14 @@ func TestWithExistingOpts_PartialOverride(t *testing.T) {
assert.False(t, opts.SecurityValidation) // From original
}

func TestWithUrlEncodedBodyValidation(t *testing.T) {
opts := NewValidationOptions(
WithURLEncodedBodyValidation(),
)

assert.True(t, opts.AllowURLEncodedBodyValidation)
}

func TestComplexScenario(t *testing.T) {
// Test a complex real-world scenario
var mockEngine jsonschema.RegexpEngine = nil
Expand Down
30 changes: 19 additions & 11 deletions errors/parameters_howtofix.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,30 @@ const (
HowToFixParamInvalidBoolean string = "Convert the value '%s' into a true/false value"
HowToFixParamInvalidEnum string = "Instead of '%s', use one of the allowed values: '%s'"
HowToFixParamInvalidFormEncode string = "Use a form style encoding for parameter values, for example: '%s'"
HowToFixInvalidXml string = "Ensure xml is well-formed and matches schema structure"
HowToFixXmlPrefix string = "Make sure to prepend the correct prefix '%s' to the declared fields"
HowToFixXmlNamespace string = "Make sure to declare the 'xmlns:%s' with the correct namespace URI"
HowToFixFormDataReservedCharacters string = "Make sure to correcly encode specials characters to percent encoding, or set allowReserved to true"
HowToFixInvalidSchema string = "Ensure that the object being submitted, matches the schema correctly"
HowToFixInvalidTypeEncoding string = "Ensure that the object being submitted matches the property encoding Content-Type"
HowToFixParamInvalidSpaceDelimitedObjectExplode string = "When using 'explode' with space delimited parameters, " +
"they should be separated by spaces. For example: '%s'"
HowToFixParamInvalidPipeDelimitedObjectExplode string = "When using 'explode' with pipe delimited parameters, " +
"they should be separated by pipes '|'. For example: '%s'"
HowToFixParamInvalidDeepObjectMultipleValues string = "There can only be a single value per property name, " +
"deepObject parameters should contain the property key in square brackets next to the parameter name. For example: '%s'"
HowToFixInvalidJSON string = "The JSON submitted is invalid, please check the syntax"
HowToFixDecodingError = "The object can't be decoded, so make sure it's being encoded correctly according to the spec."
HowToFixInvalidContentType = "The content type is invalid, Use one of the %d supported types for this operation: %s"
HowToFixInvalidResponseCode = "The service is responding with a code that is not defined in the spec, fix the service or add the code to the specification"
HowToFixInvalidEncoding = "Ensure the correct encoding has been used on the object"
HowToFixMissingValue = "Ensure the value has been set"
HowToFixPath = "Check the path is correct, and check that the correct HTTP method has been used (e.g. GET, POST, PUT, DELETE)"
HowToFixPathMethod = "Add the missing operation to the contract for the path"
HowToFixInvalidMaxItems = "Reduce the number of items in the array to %d or less"
HowToFixInvalidMinItems = "Increase the number of items in the array to %d or more"
HowToFixMissingHeader = "Make sure the service responding sets the required headers with this response code"
HowToFixInvalidJSON string = "The JSON submitted is invalid, please check the syntax"
HowToFixInvalidUrlEncoded string = "Ensure URL Encoded submitted is well-formed and matches schema structure"
HowToFixDecodingError string = "The object can't be decoded, so make sure it's being encoded correctly according to the spec."
HowToFixInvalidContentType string = "The content type is invalid, Use one of the %d supported types for this operation: %s"
HowToFixInvalidResponseCode string = "The service is responding with a code that is not defined in the spec, fix the service or add the code to the specification"
HowToFixInvalidEncoding string = "Ensure the correct encoding has been used on the object"
HowToFixMissingValue string = "Ensure the value has been set"
HowToFixPath string = "Check the path is correct, and check that the correct HTTP method has been used (e.g. GET, POST, PUT, DELETE)"
HowToFixPathMethod string = "Add the missing operation to the contract for the path"
HowToFixInvalidMaxItems string = "Reduce the number of items in the array to %d or less"
HowToFixInvalidMinItems string = "Increase the number of items in the array to %d or more"
HowToFixMissingHeader string = "Make sure the service responding sets the required headers with this response code"
HowToFixInvalidRenderedSchema string = "Check the request schema for circular references or invalid structures"
HowToFixInvalidJsonSchema string = "Check the request schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs"
)
66 changes: 66 additions & 0 deletions errors/urlencoded_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package errors

import (
"fmt"

"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi/datamodel/high/base"
)

func InvalidURLEncodedParsing(reason, referenceObject string) *ValidationError {
return &ValidationError{
ValidationType: helpers.URLEncodedValidation,
ValidationSubType: helpers.Schema,
Message: "Unable to parse form-urlencoded body",
Reason: fmt.Sprintf("failed to parse form-urlencoded: %s", reason),
SchemaValidationErrors: []*SchemaValidationFailure{{
Reason: reason,
Location: "url encoded parsing",
ReferenceSchema: "",
ReferenceObject: referenceObject,
}},
HowToFix: HowToFixInvalidUrlEncoded,
}
}

func InvalidTypeEncoding(schema *base.Schema, name, contentType string) *ValidationError {
line := 1
col := 0
if low := schema.GoLow(); low != nil && low.Type.KeyNode != nil {
line = low.Type.KeyNode.Line
col = low.Type.KeyNode.Column
}

return &ValidationError{
ValidationType: helpers.URLEncodedValidation,
ValidationSubType: helpers.InvalidTypeEncoding,
Message: fmt.Sprintf("The value '%s' could not be parsed to the defined encoding", name),
Reason: fmt.Sprintf("The value '%s' is encoded as '%s' in the schema, however the value could not be parsed", name, contentType),
SpecLine: line,
SpecCol: col,
Context: schema,
HowToFix: HowToFixInvalidTypeEncoding,
}
}

func ReservedURLEncodedValue(schema *base.Schema, name, value string) *ValidationError {
line := 1
col := 0
if schema != nil {
if low := schema.GoLow(); low != nil && low.Type.KeyNode != nil {
line = low.Type.KeyNode.Line
col = low.Type.KeyNode.Column
}
}

return &ValidationError{
ValidationType: helpers.URLEncodedValidation,
ValidationSubType: helpers.ReservedValues,
Message: fmt.Sprintf("Form value '%s' contains reserved characters", name),
Reason: fmt.Sprintf("The form value '%s' contains reserved characters but allowReserved is false. Value: '%s'", name, value),
SpecLine: line,
SpecCol: col,
Context: schema,
HowToFix: HowToFixFormDataReservedCharacters,
}
}
57 changes: 57 additions & 0 deletions errors/urlencoded_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package errors

import (
"testing"

"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi/datamodel/high/base"
"github.com/stretchr/testify/assert"
)

func getURLEncodingTestSchema() *base.Schema {
spec := `openapi: 3.0.0
paths:
/pet:
get:
responses:
'200':
content:
application/x-www-form-urlencoded:
encoding:
animal:
contentType: application/json
schema:
type: object
properties:
animal:
type: object`

doc, _ := libopenapi.NewDocument([]byte(spec))
v3Doc, _ := doc.BuildV3Model()

return v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200").
Content.GetOrZero("application/x-www-form-urlencoded").Schema.Schema()
}

func TestInvalidURLEncodedParsing(t *testing.T) {
err := InvalidURLEncodedParsing("no data sent", "invalid-formdata")

assert.NotNil(t, (*err))
assert.Equal(t, (*err).SchemaValidationErrors[0].Location, "url encoded parsing")
assert.Equal(t, helpers.Schema, (*err).ValidationSubType)
}

func TestInvalidTypeEncoding(t *testing.T) {
err := InvalidTypeEncoding(getURLEncodingTestSchema(), "animal", helpers.JSONContentType)

assert.NotNil(t, (*err))
assert.Equal(t, helpers.InvalidTypeEncoding, (*err).ValidationSubType)
}

func TestReservedURLEncodedValue(t *testing.T) {
err := ReservedURLEncodedValue(getURLEncodingTestSchema(), "animal", "!")

assert.NotNil(t, (*err))
assert.Equal(t, helpers.ReservedValues, (*err).ValidationSubType)
}
Loading