diff --git a/config/config.go b/config/config.go
index f46acce0..ddd15b79 100644
--- a/config/config.go
+++ b/config/config.go
@@ -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
@@ -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
@@ -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.
diff --git a/config/config_test.go b/config/config_test.go
index dddd739a..9dea2602 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -93,11 +98,13 @@ 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
@@ -105,6 +112,8 @@ func TestWithExistingOpts(t *testing.T) {
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)
@@ -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)
}
@@ -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)
@@ -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
diff --git a/errors/parameters_howtofix.go b/errors/parameters_howtofix.go
index e20a1508..b884700f 100644
--- a/errors/parameters_howtofix.go
+++ b/errors/parameters_howtofix.go
@@ -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"
)
diff --git a/errors/urlencoded_errors.go b/errors/urlencoded_errors.go
new file mode 100644
index 00000000..9adebf48
--- /dev/null
+++ b/errors/urlencoded_errors.go
@@ -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,
+ }
+}
diff --git a/errors/urlencoded_errors_test.go b/errors/urlencoded_errors_test.go
new file mode 100644
index 00000000..3bcade5e
--- /dev/null
+++ b/errors/urlencoded_errors_test.go
@@ -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)
+}
diff --git a/errors/xml_errors.go b/errors/xml_errors.go
new file mode 100644
index 00000000..6fe51550
--- /dev/null
+++ b/errors/xml_errors.go
@@ -0,0 +1,105 @@
+package errors
+
+import (
+ "fmt"
+
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+)
+
+func MissingPrefix(schema *base.Schema, prefix 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.XmlValidation,
+ ValidationSubType: helpers.XmlValidationPrefix,
+ Message: fmt.Sprintf("The prefix '%s' is defined in the schema, however it's missing from the xml", prefix),
+ Reason: fmt.Sprintf("The prefix '%s' is defined in the schema, however it's missing from the xml content", prefix),
+ SpecLine: line,
+ SpecCol: col,
+ Context: schema,
+ HowToFix: fmt.Sprintf(HowToFixXmlPrefix, prefix),
+ }
+}
+
+func InvalidPrefix(schema *base.Schema, prefix 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.XmlValidation,
+ ValidationSubType: helpers.XmlValidationPrefix,
+ Message: fmt.Sprintf("The prefix '%s' defined in the schema differs from the xml", prefix),
+ Reason: fmt.Sprintf("The prefix '%s' is defined in the schema, however the xml sent and invalid prefix", prefix),
+ SpecCol: col,
+ SpecLine: line,
+ Context: schema,
+ HowToFix: fmt.Sprintf(HowToFixXmlPrefix, prefix),
+ }
+}
+
+func MissingNamespace(schema *base.Schema, namespace 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.XmlValidation,
+ ValidationSubType: helpers.XmlValidationNamespace,
+ Message: fmt.Sprintf("The namespace '%s' is defined in the schema, however it's missing from the xml", namespace),
+ Reason: fmt.Sprintf("The namespace '%s' is defined in the schema, however it's missing from the xml content", namespace),
+ SpecLine: line,
+ SpecCol: col,
+ Context: schema,
+ HowToFix: fmt.Sprintf(HowToFixXmlNamespace, namespace),
+ }
+}
+
+func InvalidNamespace(schema *base.Schema, namespace, expectedNamespace, prefix 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.XmlValidation,
+ ValidationSubType: helpers.XmlValidationNamespace,
+ Message: fmt.Sprintf("The namespace from prefix '%s' differs from the xml", prefix),
+ Reason: fmt.Sprintf("The namespace from prefix '%s' is declared as '%s' in the schema, however in xml is declared as '%s'",
+ prefix, expectedNamespace, namespace),
+ SpecLine: line,
+ SpecCol: col,
+ Context: schema,
+ HowToFix: fmt.Sprintf(HowToFixXmlNamespace, namespace),
+ }
+}
+
+func InvalidXMLParsing(reason, referenceObject string) *ValidationError {
+ return &ValidationError{
+ ValidationType: helpers.XmlValidation,
+ ValidationSubType: helpers.Schema,
+ Message: "xml example is malformed",
+ Reason: fmt.Sprintf("failed to parse xml: %s", reason),
+ SchemaValidationErrors: []*SchemaValidationFailure{{
+ Reason: reason,
+ Location: "xml parsing",
+ ReferenceSchema: "",
+ ReferenceObject: referenceObject,
+ }},
+ HowToFix: HowToFixInvalidXml,
+ }
+}
diff --git a/errors/xml_errors_test.go b/errors/xml_errors_test.go
new file mode 100644
index 00000000..88b9db23
--- /dev/null
+++ b/errors/xml_errors_test.go
@@ -0,0 +1,77 @@
+// Copyright 2023-2024 Princess Beef Heavy Industries, LLC / Dave Shanley
+// https://pb33f.io
+
+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 getTestSchema() *base.Schema {
+ spec := `openapi: 3.0.0
+paths:
+ /pet:
+ get:
+ responses:
+ '200':
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ age:
+ type: integer
+ xml:
+ name: Cat`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+ v3Doc, _ := doc.BuildV3Model()
+
+ return v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200").
+ Content.GetOrZero("application/xml").Schema.Schema()
+}
+
+func TestMissingPrefixError(t *testing.T) {
+ schema := getTestSchema()
+ err := MissingPrefix(schema, "prx")
+
+ assert.NotNil(t, *err)
+ assert.Equal(t, helpers.XmlValidationPrefix, (*err).ValidationSubType)
+}
+
+func TestMissingNamespaceError(t *testing.T) {
+ schema := getTestSchema()
+ err := MissingNamespace(schema, "http://ex.c")
+
+ assert.NotNil(t, *err)
+ assert.Equal(t, helpers.XmlValidationNamespace, (*err).ValidationSubType)
+}
+
+func TestInvalidPrefixError(t *testing.T) {
+ schema := getTestSchema()
+ err := InvalidPrefix(schema, "prx")
+
+ assert.NotNil(t, *err)
+ assert.Equal(t, helpers.XmlValidationPrefix, (*err).ValidationSubType)
+}
+
+func TestInvalidNamespaceError(t *testing.T) {
+ schema := getTestSchema()
+ err := InvalidNamespace(schema, "other", "http://ex.c", "prx")
+
+ assert.NotNil(t, *err)
+ assert.Equal(t, helpers.XmlValidationNamespace, (*err).ValidationSubType)
+}
+
+func TestInvalidParsing(t *testing.T) {
+ err := InvalidXMLParsing("no data sent", "invalid-xml")
+
+ assert.NotNil(t, (*err))
+ assert.Equal(t, (*err).SchemaValidationErrors[0].Location, "xml parsing")
+ assert.Equal(t, helpers.Schema, (*err).ValidationSubType)
+}
diff --git a/helpers/constants.go b/helpers/constants.go
index 1faecff0..f1e1f3b6 100644
--- a/helpers/constants.go
+++ b/helpers/constants.go
@@ -11,6 +11,12 @@ const (
ParameterValidationCookie = "cookie"
RequestValidation = "request"
RequestBodyValidation = "requestBody"
+ XmlValidation = "xmlValidation"
+ XmlValidationPrefix = "prefix"
+ XmlValidationNamespace = "namespace"
+ URLEncodedValidation = "urlEncodedValidation"
+ InvalidTypeEncoding = "invalidTypeEncoding"
+ ReservedValues = "reservedValues"
Schema = "schema"
ResponseBodyValidation = "response"
RequestBodyContentType = "contentType"
@@ -48,6 +54,7 @@ const (
Form = "form"
Query = "query"
JSONContentType = "application/json"
+ URLEncodedContentType = "application/x-www-form-urlencoded"
JSONType = "json"
ContentTypeHeader = "Content-Type"
AuthorizationHeader = "Authorization"
diff --git a/requests/validate_body.go b/requests/validate_body.go
index e9aad701..30838c38 100644
--- a/requests/validate_body.go
+++ b/requests/validate_body.go
@@ -4,7 +4,10 @@
package requests
import (
+ "bytes"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"strings"
@@ -14,6 +17,7 @@ import (
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/paths"
+ "github.com/pb33f/libopenapi-validator/schema_validation"
)
func (v *requestBodyValidator) ValidateRequestBody(request *http.Request) (bool, []*errors.ValidationError) {
@@ -66,12 +70,6 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
return false, []*errors.ValidationError{errors.RequestContentTypeNotFound(operation, request, pathValue)}
}
- // we currently only support JSON validation for request bodies
- // this will capture *everything* that contains some form of 'json' in the content type
- if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) {
- return true, nil
- }
-
// Nothing to validate
if mediaType.Schema == nil {
return true, nil
@@ -80,6 +78,53 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
// extract schema from media type
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 {
+ return true, nil
+ }
+
+ if request != nil && request.Body != nil {
+ requestBody, _ := io.ReadAll(request.Body)
+ _ = request.Body.Close()
+
+ 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)}
+ }
+ }
+
+ request.Body = io.NopCloser(bytes.NewBuffer(transformedBytes))
+ }
+ }
+
validationSucceeded, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{
Request: request,
Schema: schema,
diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go
index bc96085a..00bf6378 100644
--- a/requests/validate_body_test.go
+++ b/requests/validate_body_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pb33f/libopenapi-validator/config"
+ "github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/paths"
)
@@ -1576,3 +1577,244 @@ paths:
assert.True(t, valid)
assert.Len(t, errors, 0)
}
+
+func TestValidateRequestBody_XMLMarshalError(t *testing.T) {
+ spec := []byte(`
+openapi: 3.1.0
+info:
+ title: Test Spec
+ version: 1.0.0
+paths:
+ /test:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ bad_number:
+ type: number
+ responses:
+ '200':
+ description: Success
+`)
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/test",
+ bytes.NewBuffer([]byte("NaN")))
+ request.Header.Set("Content-Type", "application/xml")
+
+ valid, errors := v.ValidateRequestBody(request)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Equal(t, errors[0].Message, "xml example is malformed")
+}
+
+func TestValidateRequestBody_URLEncodedMarshalError(t *testing.T) {
+ spec := []byte(`
+openapi: 3.1.0
+info:
+ title: Test Spec
+ version: 1.0.0
+paths:
+ /test:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ bad_number:
+ type: number
+ responses:
+ '200':
+ description: Success
+`)
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithURLEncodedBodyValidation())
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/test",
+ bytes.NewBuffer([]byte("bad_number=NaN")))
+ request.Header.Set("Content-Type", helpers.URLEncodedContentType)
+
+ valid, errors := v.ValidateRequestBody(request)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Equal(t, errors[0].Message, "Unable to parse form-urlencoded body")
+}
+
+func TestValidateBody_URLEncodedRequest(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithURLEncodedBodyValidation())
+
+ body := "name=cheeseburger&patties=23"
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
+ bytes.NewBuffer([]byte(body)))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ valid, errors := v.ValidateRequestBody(request)
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+
+ body = "name=cheeseburger&patties=23.4"
+
+ request, _ = http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
+ bytes.NewBuffer([]byte(body)))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ valid, errors = v.ValidateRequestBody(request)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+}
+
+func TestValidateBody_XmlRequest(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ requestBody:
+ content:
+ application/xml:
+ schema:
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer
+ xml:
+ name: cost`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := "cheeseburger23"
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
+ bytes.NewBuffer([]byte(body)))
+ request.Header.Set("Content-Type", "application/xml")
+
+ valid, errors := v.ValidateRequestBody(request)
+
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
+
+func TestValidateBody_XmlMalformedRequest(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ requestBody:
+ content:
+ application/xml:
+ schema:
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer
+ xml:
+ name: cost`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := ""
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
+ bytes.NewBuffer([]byte(body)))
+ request.Header.Set("Content-Type", "application/xml")
+
+ valid, errors := v.ValidateRequestBody(request)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+
+ err := errors[0]
+ assert.Equal(t, helpers.XmlValidation, err.ValidationType)
+ assert.Contains(t, err.Reason, "failed to parse xml")
+}
+
+func TestValidateBody_XmlRequestTransformations(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ requestBody:
+ content:
+ application/xml:
+ schema:
+ type: object
+ xml:
+ name: Burger
+ required:
+ - name
+ - patties
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer
+ xml:
+ name: cost`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := "cheeseburger23"
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
+ bytes.NewBuffer([]byte(body)))
+ request.Header.Set("Content-Type", "application/xml")
+
+ valid, errors := v.ValidateRequestBody(request)
+
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
diff --git a/requests/validate_request.go b/requests/validate_request.go
index f1ce93c0..8383200d 100644
--- a/requests/validate_request.go
+++ b/requests/validate_request.go
@@ -100,7 +100,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V
SpecLine: 1,
SpecCol: 0,
SchemaValidationErrors: []*errors.SchemaValidationFailure{violation},
- HowToFix: "check the request schema for circular references or invalid structures",
+ HowToFix: errors.HowToFixInvalidRenderedSchema,
Context: referenceSchema,
})
return false, validationErrors
diff --git a/responses/validate_body.go b/responses/validate_body.go
index ae09b307..b5fa997a 100644
--- a/responses/validate_body.go
+++ b/responses/validate_body.go
@@ -4,7 +4,10 @@
package responses
import (
+ "bytes"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"strconv"
"strings"
@@ -17,6 +20,7 @@ import (
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/paths"
+ "github.com/pb33f/libopenapi-validator/schema_validation"
)
func (v *responseBodyValidator) ValidateResponseBody(
@@ -131,26 +135,73 @@ func (v *responseBodyValidator) checkResponseSchema(
) []*errors.ValidationError {
var validationErrors []*errors.ValidationError
- // currently, we can only validate JSON based responses, so check for the presence
- // of 'json' in the content type (what ever it may be) so we can perform a schema check on it.
- // anything other than JSON, will be ignored.
- if strings.Contains(strings.ToLower(contentType), helpers.JSONType) {
- // extract schema from media type
- if mediaType.Schema != nil {
- schema := mediaType.Schema.Schema()
-
- // 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)},
- })
- if !valid {
- validationErrors = append(validationErrors, vErrs...)
+ if mediaType.Schema == nil {
+ 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()
+
+ 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))
}
}
+
+ // 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)},
+ })
+
+ if !valid {
+ validationErrors = append(validationErrors, vErrs...)
+ }
+
return validationErrors
}
diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go
index cfeb5a42..21cfb74d 100644
--- a/responses/validate_body_test.go
+++ b/responses/validate_body_test.go
@@ -44,7 +44,7 @@ func newvalidateResponseTestBed(
t.Fatalf("failed to build v3 model: %v", err)
}
- tb := validateResponseTestBed{responseBodyValidator: NewResponseBodyValidator(&m.Model)}
+ tb := validateResponseTestBed{responseBodyValidator: NewResponseBodyValidator(&m.Model, config.WithXmlBodyValidation(), config.WithURLEncodedBodyValidation())}
tb.httpTestServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tb.responseHandlerFunc != nil {
tb.responseHandlerFunc(w, r)
@@ -1282,6 +1282,129 @@ components:
"Expected error about circular reference or JSON pointer not found, got: %s", errors[0].Reason)
}
+func TestValidateResponseBody_XMLMarshalError(t *testing.T) {
+ tb := newvalidateResponseTestBed(
+ t,
+ []byte(`
+openapi: 3.1.0
+info:
+ title: Test Spec
+ version: 1.0.0
+paths:
+ /test:
+ get:
+ responses:
+ '200':
+ description: Success
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ bad_number:
+ type: number
+`,
+ ),
+ )
+
+ req, res := tb.makeRequestWithReponse(
+ t,
+ http.MethodGet,
+ "/test",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("NaN"))
+ },
+ )
+
+ valid, errors := tb.responseBodyValidator.ValidateResponseBody(req, res)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Equal(t, errors[0].Message, "xml example is malformed")
+}
+
+func TestValidateResponseBody_URLEncodedMarshalError(t *testing.T) {
+ tb := newvalidateResponseTestBed(
+ t,
+ []byte(`
+openapi: 3.1.0
+info:
+ title: Test Spec
+ version: 1.0.0
+paths:
+ /test:
+ get:
+ responses:
+ '200':
+ description: Success
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ bad_number:
+ type: number
+`,
+ ),
+ )
+
+ req, res := tb.makeRequestWithReponse(
+ t,
+ http.MethodGet,
+ "/test",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, helpers.URLEncodedContentType)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("bad_number=NaN"))
+ },
+ )
+
+ valid, errors := tb.responseBodyValidator.ValidateResponseBody(req, res)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Equal(t, errors[0].Message, "Unable to parse form-urlencoded body")
+}
+
+func TestValidateResponseBody_NilSchema(t *testing.T) {
+ tb := newvalidateResponseTestBed(
+ t,
+ []byte(`
+openapi: 3.1.0
+info:
+ title: Test Spec
+ version: 1.0.0
+paths:
+ /test:
+ get:
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json: {}
+`,
+ ),
+ )
+
+ req, res := tb.makeRequestWithReponse(
+ t,
+ http.MethodGet,
+ "/test",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, helpers.JSONContentType)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(nil)
+ },
+ )
+
+ valid, errors := tb.responseBodyValidator.ValidateResponseBody(req, res)
+
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
+
func TestValidateBody_CheckHeader(t *testing.T) {
spec := `openapi: "3.0.0"
info:
@@ -1498,6 +1621,292 @@ paths:
assert.Len(t, errs, 0)
}
+func TestValidateBody_ValidURLEncodedBody(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model, config.WithURLEncodedBodyValidation())
+
+ body := "name=test&patties=2"
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.URLEncodedContentType)
+
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, helpers.URLEncodedContentType)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ handler(res, request)
+ response := res.Result()
+
+ valid, errors := v.ValidateResponseBody(request, response)
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
+
+func TestValidateBody_InvalidURLEncoded(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model, config.WithURLEncodedBodyValidation())
+
+ body := "name=test&patties=true"
+
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.URLEncodedContentType)
+
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, helpers.URLEncodedContentType)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ handler(res, request)
+ response := res.Result()
+
+ valid, errors := v.ValidateResponseBody(request, response)
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+}
+
+func TestValidateBody_ValidXmlDecode(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := "test2"
+
+ // build a request
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.JSONContentType)
+
+ // simulate a request/response
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ // fire the request
+ handler(res, request)
+
+ // record response
+ response := res.Result()
+
+ // validate!
+ valid, errors := v.ValidateResponseBody(request, response)
+
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
+
+func TestValidateBody_ValidXmlFailedValidation(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := "20text"
+
+ // build a request
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.JSONContentType)
+
+ // simulate a request/response
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ // fire the request
+ handler(res, request)
+
+ // record response
+ response := res.Result()
+
+ // validate!
+ valid, errors := v.ValidateResponseBody(request, response)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Len(t, errors[0].SchemaValidationErrors, 1)
+}
+
+func TestValidateBody_IgnoreXmlValidation(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer
+ vegetarian:
+ type: boolean`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model)
+
+ body := "invalidbodycausenoxml"
+
+ // build a request
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.JSONContentType)
+
+ // simulate a request/response
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ // fire the request
+ handler(res, request)
+
+ // record response
+ response := res.Result()
+
+ // validate!
+ valid, errors := v.ValidateResponseBody(request, response)
+
+ assert.True(t, valid)
+ assert.Len(t, errors, 0)
+}
+
+func TestValidateBody_InvalidXmlParse(t *testing.T) {
+ spec := `openapi: 3.1.0
+paths:
+ /burgers/createBurger:
+ post:
+ responses:
+ default:
+ content:
+ application/xml:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ patties:
+ type: integer
+ vegetarian:
+ type: boolean`
+
+ doc, _ := libopenapi.NewDocument([]byte(spec))
+
+ m, _ := doc.BuildV3Model()
+ v := NewResponseBodyValidator(&m.Model, config.WithXmlBodyValidation())
+
+ body := ""
+
+ // build a request
+ request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", bytes.NewReader([]byte(body)))
+ request.Header.Set(helpers.ContentTypeHeader, helpers.JSONContentType)
+
+ // simulate a request/response
+ res := httptest.NewRecorder()
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(helpers.ContentTypeHeader, "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body))
+ }
+
+ // fire the request
+ handler(res, request)
+
+ // record response
+ response := res.Result()
+
+ // validate!
+ valid, errors := v.ValidateResponseBody(request, response)
+
+ assert.False(t, valid)
+ assert.Len(t, errors, 1)
+ assert.Equal(t, "xml example is malformed", errors[0].Message)
+}
+
type errorReader struct{}
func (er *errorReader) Read(p []byte) (n int, err error) {
diff --git a/schema_validation/urlencoded_validator.go b/schema_validation/urlencoded_validator.go
new file mode 100644
index 00000000..ebfbd7c7
--- /dev/null
+++ b/schema_validation/urlencoded_validator.go
@@ -0,0 +1,61 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package schema_validation
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
+
+ "github.com/pb33f/libopenapi-validator/config"
+ liberrors "github.com/pb33f/libopenapi-validator/errors"
+)
+
+// URLEncodedValidator is an interface that defines methods for validating URL encoded strings against OpenAPI schemas.
+// There are 2 methods for validating URL encoded:
+//
+// ValidateURLEncodedString validates an URL encoded string against a schema, applying OpenAPI object transformations.
+// ValidateURLEncodedStringWithVersion - version-aware URL encoded validation that allows OpenAPI 3.0 keywords when version is specified.
+type URLEncodedValidator interface {
+ // ValidateURLEncodedString validates an URL encoded string against a schema, applying OpenAPI object transformations.
+ // Uses OpenAPI 3.1+ validation by default (strict JSON Schema compliance).
+ ValidateURLEncodedString(schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding], urlEncodedString string) (bool, []*liberrors.ValidationError)
+
+ // ValidateURLEncodedStringWithVersion validates an URL encoded string with version-specific rules.
+ // When version is 3.0, OpenAPI 3.0-specific keywords like 'nullable' are allowed and processed.
+ // When version is 3.1+, OpenAPI 3.0-specific keywords like 'nullable' will cause validation to fail.
+ ValidateURLEncodedStringWithVersion(schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding], urlEncodedString string, version float32) (bool, []*liberrors.ValidationError)
+}
+
+type urlEncodedValidator struct {
+ schemaValidator *schemaValidator
+ logger *slog.Logger
+}
+
+// NewURLEncodedValidatorWithLogger creates a new URLEncodedValidator instance with a custom logger.
+func NewURLEncodedValidatorWithLogger(logger *slog.Logger, opts ...config.Option) URLEncodedValidator {
+ options := config.NewValidationOptions(opts...)
+ // Create an internal schema validator for JSON validation after URLEncoded transformation
+ sv := &schemaValidator{options: options, logger: logger}
+ return &urlEncodedValidator{schemaValidator: sv, logger: logger}
+}
+
+// NewURLEncodedValidator creates a new URLEncodedValidator instance with default logging configuration.
+func NewURLEncodedValidator(opts ...config.Option) URLEncodedValidator {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
+ Level: slog.LevelError,
+ }))
+ return NewURLEncodedValidatorWithLogger(logger, opts...)
+}
+
+func (x *urlEncodedValidator) ValidateURLEncodedString(schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding], urlEncodedString string) (bool, []*liberrors.ValidationError) {
+ return x.validateURLEncodedWithVersion(schema, encoding, urlEncodedString, x.logger, 3.1)
+}
+
+func (x *urlEncodedValidator) ValidateURLEncodedStringWithVersion(schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding], urlEncodedString string, version float32) (bool, []*liberrors.ValidationError) {
+ return x.validateURLEncodedWithVersion(schema, encoding, urlEncodedString, x.logger, version)
+}
diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go
index fa511f74..e8e2b43a 100644
--- a/schema_validation/validate_schema.go
+++ b/schema_validation/validate_schema.go
@@ -1,6 +1,5 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT
-
package schema_validation
import (
diff --git a/schema_validation/validate_urlencoded.go b/schema_validation/validate_urlencoded.go
new file mode 100644
index 00000000..f908db3f
--- /dev/null
+++ b/schema_validation/validate_urlencoded.go
@@ -0,0 +1,392 @@
+package schema_validation
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
+)
+
+var rxReserved = regexp.MustCompile(`[:/?#\[\]@!$&'()*+,;=]`)
+
+func TransformURLEncodedToSchemaJSON(bodyString string, schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding]) (map[string]any, []*errors.ValidationError) {
+ rawValues, err := url.ParseQuery(bodyString)
+ if err != nil {
+ return nil, []*errors.ValidationError{errors.InvalidURLEncodedParsing("empty form-urlencoded context", bodyString)}
+ }
+
+ jsonMap := unflattenValues(rawValues)
+
+ var validationErrors []*errors.ValidationError
+
+ if schema != nil {
+ if schema.Properties != nil {
+ for pair := orderedmap.First(schema.Properties); pair != nil; pair = pair.Next() {
+ propName := pair.Key()
+ propSchema := pair.Value().Schema()
+
+ var contentEncoding *v3.Encoding
+ var allowReserved bool
+
+ if encoding != nil {
+ contentEncoding = encoding.GetOrZero(propName)
+
+ if contentEncoding != nil {
+ allowReserved = contentEncoding.AllowReserved
+ }
+ }
+
+ if val, exists := jsonMap[propName]; exists {
+ newVal, err := applyEncodingRules(val, contentEncoding, propSchema)
+ if err != nil {
+ contentType := ""
+ if contentEncoding != nil {
+ contentType = contentEncoding.ContentType
+ }
+
+ validationErrors = append(validationErrors, errors.InvalidTypeEncoding(propSchema, propName, contentType))
+ } else {
+ jsonMap[propName] = newVal
+ val = newVal
+ }
+
+ validateEncodingRecursive(propName, val, allowReserved, &validationErrors, propSchema)
+ }
+ }
+ }
+
+ coerced := coerceValue(jsonMap, schema)
+ if asMap, ok := coerced.(map[string]any); ok {
+ jsonMap = asMap
+ }
+ }
+
+ return jsonMap, validationErrors
+}
+
+func applyEncodingRules(data any, encoding *v3.Encoding, schema *base.Schema) (any, error) {
+ style := "form"
+ explode := true
+ contentType := ""
+
+ if encoding != nil {
+ contentType = encoding.ContentType
+
+ if encoding.Style != "" {
+ style = encoding.Style
+ contentType = ""
+ }
+
+ if encoding.AllowReserved {
+ contentType = ""
+ }
+
+ if encoding.Explode != nil {
+ explode = *encoding.Explode
+ contentType = ""
+ } else if style != "form" {
+ explode = false
+ }
+ }
+
+ if contentType != "" && !IsURLEncodedContentType(contentType) && !strings.Contains(contentType, "text/plain") {
+ if strVal, ok := data.(string); ok {
+ if strings.Contains(contentType, helpers.JSONContentType) {
+ var parsed any
+ if err := json.Unmarshal([]byte(strVal), &parsed); err == nil {
+ return parsed, nil
+ }
+ return nil, fmt.Errorf("value matches content-type '%s' but could not be parsed", contentType)
+ }
+ }
+ }
+
+ if isArraySchema(schema) {
+ if strVal, ok := data.(string); ok {
+ if !explode {
+ switch style {
+ case helpers.Form:
+ return strings.Split(strVal, ","), nil
+ case helpers.SpaceDelimited:
+ return strings.Split(strVal, " "), nil
+ case helpers.PipeDelimited:
+ return strings.Split(strVal, "|"), nil
+ }
+ }
+ }
+ }
+
+ if style == helpers.DeepObject {
+ if _, ok := data.(map[string]any); !ok {
+ return data, nil
+ }
+ }
+
+ return data, nil
+}
+
+func unflattenValues(values url.Values) map[string]any {
+ result := make(map[string]any)
+
+ for k, v := range values {
+ if strings.Contains(k, "[") {
+ buildDeepMap(result, k, v)
+ } else {
+ if len(v) == 1 {
+ result[k] = v[0]
+ } else {
+ result[k] = v
+ }
+ }
+ }
+ return result
+}
+
+func buildDeepMap(root map[string]any, key string, value []string) {
+ parts := strings.FieldsFunc(key, func(r rune) bool {
+ return r == '[' || r == ']'
+ })
+
+ current := root
+ for i, part := range parts {
+ isLeaf := i == len(parts)-1
+
+ if isLeaf {
+ if len(value) == 1 {
+ current[part] = value[0]
+ } else {
+ current[part] = value
+ }
+ } else {
+ if _, ok := current[part]; !ok {
+ current[part] = make(map[string]any)
+ }
+ if nextMap, ok := current[part].(map[string]any); ok {
+ current = nextMap
+ } else {
+ return
+ }
+ }
+ }
+}
+
+func validateEncodingRecursive(path string, val any, allowReserved bool, errs *[]*errors.ValidationError, schema *base.Schema) {
+ if allowReserved {
+ return
+ }
+
+ switch v := val.(type) {
+ case string:
+ if rxReserved.MatchString(v) {
+ *errs = append(*errs, errors.ReservedURLEncodedValue(schema, path, v))
+ }
+ case []any:
+ for i, item := range v {
+ validateEncodingRecursive(fmt.Sprintf("%s[%d]", path, i), item, allowReserved, errs, schema)
+ }
+ case map[string]any:
+ for k, item := range v {
+ validateEncodingRecursive(fmt.Sprintf("%s[%s]", path, k), item, allowReserved, errs, schema)
+ }
+ case []string:
+ for i, item := range v {
+ if rxReserved.MatchString(item) {
+ *errs = append(*errs, errors.ReservedURLEncodedValue(schema, fmt.Sprintf("%s[%d]", path, i), item))
+ }
+ }
+ }
+}
+
+func coerceValue(data any, schema *base.Schema) any {
+ if schema == nil {
+ return data
+ }
+
+ targetTypes := []string{}
+ if len(schema.Type) > 0 {
+ targetTypes = append(targetTypes, schema.Type...)
+ }
+
+ extractTypes := func(proxies []*base.SchemaProxy) {
+ for _, proxy := range proxies {
+ sch := proxy.Schema()
+ if len(sch.Type) > 0 {
+ targetTypes = append(targetTypes, sch.Type...)
+ }
+ }
+ }
+ extractTypes(schema.AllOf)
+ extractTypes(schema.OneOf)
+ extractTypes(schema.AnyOf)
+
+ if len(targetTypes) == 0 {
+ return data
+ }
+
+ for _, t := range targetTypes {
+ converted, ok := tryConvert(data, t, schema)
+ if ok {
+ return converted
+ }
+ }
+ return data
+}
+
+func tryConvert(data any, targetType string, schema *base.Schema) (any, bool) {
+ var strVal string
+ var isString bool
+
+ switch v := data.(type) {
+ case string:
+ strVal = v
+ isString = true
+ case []string:
+ if len(v) > 0 {
+ strVal = v[0]
+ isString = true
+ }
+ }
+
+ switch targetType {
+ case helpers.Integer:
+ if !isString || strVal == "" {
+ return nil, false
+ }
+ i, err := strconv.ParseInt(strVal, 10, 64)
+ if err == nil {
+ return i, true
+ }
+
+ case helpers.Number:
+ if !isString || strVal == "" {
+ return nil, false
+ }
+ f, err := strconv.ParseFloat(strVal, 64)
+ if err == nil {
+ return f, true
+ }
+
+ case helpers.Boolean:
+ if !isString {
+ return nil, false
+ }
+ b, err := strconv.ParseBool(strVal)
+ if err == nil {
+ return b, true
+ }
+
+ case helpers.String:
+ if isString {
+ return strVal, true
+ }
+ return fmt.Sprintf("%v", data), true
+
+ case helpers.Array:
+ var arr []any
+ itemSchema := getSchemaItem(schema)
+
+ if vSlice, ok := data.([]any); ok {
+ for _, s := range vSlice {
+ arr = append(arr, coerceValue(s, itemSchema))
+ }
+ return arr, true
+ }
+
+ if vStringSlice, ok := data.([]string); ok {
+ for _, s := range vStringSlice {
+ arr = append(arr, coerceValue(s, itemSchema))
+ }
+ return arr, true
+ }
+
+ if vMap, ok := data.(map[string]any); ok {
+ keys := make([]int, 0, len(vMap))
+ mapIsArray := true
+ for k := range vMap {
+ idx, err := strconv.Atoi(k)
+ if err != nil {
+ mapIsArray = false
+ break
+ }
+ keys = append(keys, idx)
+ }
+ if mapIsArray {
+ sort.Ints(keys)
+ for _, k := range keys {
+ val := vMap[strconv.Itoa(k)]
+ arr = append(arr, coerceValue(val, itemSchema))
+ }
+ return arr, true
+ }
+ }
+
+ if isString {
+ arr = append(arr, coerceValue(strVal, itemSchema))
+ return arr, true
+ }
+
+ case helpers.Object:
+ if m, ok := data.(map[string]any); ok {
+ newMap := make(map[string]any)
+ for k, v := range m {
+ newMap[k] = v
+ }
+ if schema.Properties != nil {
+ for pair := orderedmap.First(schema.Properties); pair != nil; pair = pair.Next() {
+ propName := pair.Key()
+ if val, exists := newMap[propName]; exists {
+ newMap[propName] = coerceValue(val, pair.Value().Schema())
+ }
+ }
+ }
+ return newMap, true
+ }
+ }
+
+ return nil, false
+}
+
+func isArraySchema(schema *base.Schema) bool {
+ if schema == nil {
+ return false
+ }
+
+ return slices.Contains(schema.Type, helpers.Array)
+}
+
+func getSchemaItem(schema *base.Schema) *base.Schema {
+ if schema.Items != nil && schema.Items.IsA() {
+ return schema.Items.A.Schema()
+ }
+ return nil
+}
+
+func (v *urlEncodedValidator) validateURLEncodedWithVersion(schema *base.Schema, encoding *orderedmap.Map[string, *v3.Encoding], bodyString string, log *slog.Logger, version float32) (bool, []*errors.ValidationError) {
+ if schema == nil {
+ log.Info("schema is empty and cannot be validated")
+ return false, nil
+ }
+
+ transformedJSON, prevalidationErrors := TransformURLEncodedToSchemaJSON(bodyString, schema, encoding)
+ if len(prevalidationErrors) > 0 {
+ return false, prevalidationErrors
+ }
+
+ return v.schemaValidator.validateSchemaWithVersion(schema, nil, transformedJSON, log, version)
+}
+
+func IsURLEncodedContentType(mediaType string) bool {
+ mt := strings.ToLower(strings.TrimSpace(mediaType))
+ return strings.HasPrefix(mt, helpers.URLEncodedContentType)
+}
diff --git a/schema_validation/validate_urlencoded_test.go b/schema_validation/validate_urlencoded_test.go
new file mode 100644
index 00000000..4fb928ca
--- /dev/null
+++ b/schema_validation/validate_urlencoded_test.go
@@ -0,0 +1,497 @@
+package schema_validation
+
+import (
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ derrors "github.com/pb33f/libopenapi-validator/errors"
+ "github.com/pb33f/libopenapi-validator/helpers"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsURLEncodedContentType(t *testing.T) {
+ tests := []struct {
+ input string
+ expected bool
+ }{
+ {"application/x-www-form-urlencoded", true},
+ {"APPLICATION/X-WWW-FORM-URLENCODED", true},
+ {"application/x-www-form-urlencoded; charset=utf-8", true},
+ {"application/json", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ assert.Equal(t, tt.expected, IsURLEncodedContentType(tt.input))
+ }
+}
+
+func TestUnflattenValues(t *testing.T) {
+ vals := map[string][]string{
+ "simple": {"val"},
+ "arr[]": {"1", "2"},
+ "obj[prop]": {"v1"},
+ "deep[a][b]": {"v2"},
+ "double": {"1", "2"},
+ }
+
+ result := unflattenValues(vals)
+
+ assert.Equal(t, "val", result["simple"])
+ assert.Equal(t, []string{"1", "2"}, result["arr"])
+ assert.Equal(t, []string{"1", "2"}, result["double"])
+
+ obj, ok := result["obj"].(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, "v1", obj["prop"])
+
+ deep, ok := result["deep"].(map[string]any)
+ assert.True(t, ok)
+ inner, ok := deep["a"].(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, "v2", inner["b"])
+}
+
+func TestBuildDeepMap_BranchCoverage(t *testing.T) {
+ root := make(map[string]any)
+
+ root["collision"] = "string_value"
+
+ buildDeepMap(root, "collision[sub]", []string{"val"})
+
+ assert.Equal(t, "string_value", root["collision"])
+
+ root2 := make(map[string]any)
+ buildDeepMap(root2, "arr[key]", []string{"a", "b"})
+
+ inner := root2["arr"].(map[string]any)
+ assert.Equal(t, []string{"a", "b"}, inner["key"])
+}
+
+func TestTransformURLEncodedToSchemaJSON(t *testing.T) {
+ t.Run("Malformed URL Encoding", func(t *testing.T) {
+ res, errs := TransformURLEncodedToSchemaJSON("bad_encoding=%zz", nil, nil)
+ assert.Nil(t, res)
+ assert.Len(t, errs, 1)
+ assert.Equal(t, helpers.URLEncodedValidation, errs[0].ValidationType)
+ })
+
+ t.Run("Schema is Nil", func(t *testing.T) {
+ res, errs := TransformURLEncodedToSchemaJSON("foo=bar", nil, nil)
+ assert.Empty(t, errs)
+ assert.Equal(t, "bar", res["foo"])
+ })
+
+ t.Run("Apply Encoding Rules & Reserved Characters", func(t *testing.T) {
+ props := orderedmap.New[string, *base.SchemaProxy]()
+ props.Set("jsonField", base.CreateSchemaProxy(&base.Schema{Type: []string{helpers.Object}}))
+ props.Set("restricted", base.CreateSchemaProxy(&base.Schema{Type: []string{helpers.String}}))
+
+ schema := &base.Schema{Properties: props}
+
+ encodings := orderedmap.New[string, *v3.Encoding]()
+ encodings.Set("jsonField", &v3.Encoding{ContentType: helpers.JSONContentType})
+ encodings.Set("restricted", &v3.Encoding{AllowReserved: false})
+
+ body := `jsonField={"id":1}&restricted=badvalue!`
+
+ res, errs := TransformURLEncodedToSchemaJSON(body, schema, encodings)
+
+ assert.IsType(t, map[string]any{}, res["jsonField"])
+
+ assert.Len(t, errs, 1)
+ assert.Contains(t, errs[0].Message, "contains reserved characters")
+ })
+
+ t.Run("Encoding Error (Invalid JSON content type)", func(t *testing.T) {
+ props := orderedmap.New[string, *base.SchemaProxy]()
+ props.Set("badJson", base.CreateSchemaProxy(&base.Schema{}))
+ schema := &base.Schema{Properties: props}
+
+ encodings := orderedmap.New[string, *v3.Encoding]()
+ encodings.Set("badJson", &v3.Encoding{ContentType: helpers.JSONContentType})
+
+ res, errs := TransformURLEncodedToSchemaJSON(`badJson={invalid`, schema, encodings)
+ assert.Len(t, errs, 1)
+
+ assert.Equal(t, helpers.URLEncodedValidation, errs[0].ValidationType)
+
+ assert.Equal(t, "{invalid", res["badJson"])
+ })
+
+ t.Run("Coercion triggered", func(t *testing.T) {
+ props := orderedmap.New[string, *base.SchemaProxy]()
+ props.Set("num", base.CreateSchemaProxy(&base.Schema{Type: []string{helpers.Integer}}))
+ schema := &base.Schema{Properties: props, Type: []string{helpers.Object}}
+
+ res, errs := TransformURLEncodedToSchemaJSON("num=123", schema, nil)
+ assert.Empty(t, errs)
+ assert.Equal(t, int64(123), res["num"])
+ })
+}
+
+func TestApplyEncodingRules(t *testing.T) {
+ boolPtr := func(b bool) *bool { return &b }
+
+ t.Run("DeepObject Style", func(t *testing.T) {
+ enc := &v3.Encoding{Style: "deepObject"}
+
+ res, _ := applyEncodingRules("not-map", enc, nil)
+ assert.Equal(t, "not-map", res)
+
+ m := map[string]any{"k": "v"}
+ res2, _ := applyEncodingRules(m, enc, nil)
+ assert.Equal(t, m, res2)
+ })
+
+ t.Run("Array Delimiters", func(t *testing.T) {
+ schema := &base.Schema{Type: []string{helpers.Array}}
+
+ encSpace := &v3.Encoding{Style: "spaceDelimited"}
+ res, _ := applyEncodingRules("a b c", encSpace, schema)
+ assert.Equal(t, []string{"a", "b", "c"}, res)
+
+ encPipe := &v3.Encoding{Style: "pipeDelimited"}
+ res, _ = applyEncodingRules("a|b|c", encPipe, schema)
+ assert.Equal(t, []string{"a", "b", "c"}, res)
+
+ encForm := &v3.Encoding{Style: "form", Explode: boolPtr(false)}
+ res, _ = applyEncodingRules("a,b,c", encForm, schema)
+ assert.Equal(t, []string{"a", "b", "c"}, res)
+ })
+}
+
+func TestValidateEncodingRecursive(t *testing.T) {
+ var errs []*derrors.ValidationError
+
+ validateEncodingRecursive("p", "val!", true, &errs, nil)
+ assert.Empty(t, errs)
+
+ validateEncodingRecursive("p", "val!", false, &errs, nil)
+ assert.Len(t, errs, 1)
+
+ errs = nil
+ validateEncodingRecursive("arr", []any{"ok", "bad!"}, false, &errs, nil)
+ assert.Len(t, errs, 1)
+
+ errs = nil
+ validateEncodingRecursive("map", map[string]any{"k": "bad!"}, false, &errs, nil)
+ assert.Len(t, errs, 1)
+
+ errs = nil
+ validateEncodingRecursive("s_arr", []string{"ok", "bad!"}, false, &errs, nil)
+ assert.Len(t, errs, 1)
+}
+
+func TestCoerceValue(t *testing.T) {
+ schemaInt := &base.Schema{Type: []string{helpers.Integer}}
+ schemaNum := &base.Schema{Type: []string{helpers.Number}}
+ schemaBool := &base.Schema{Type: []string{helpers.Boolean}}
+ schemaStr := &base.Schema{Type: []string{helpers.String}}
+
+ t.Run("Complex Schema Aggregation (AllOf)", func(t *testing.T) {
+ s := &base.Schema{
+ AllOf: []*base.SchemaProxy{
+ base.CreateSchemaProxy(schemaInt),
+ },
+ }
+ res := coerceValue("123", s)
+ assert.Equal(t, int64(123), res)
+ })
+
+ t.Run("No Target Types", func(t *testing.T) {
+ res := coerceValue("val", &base.Schema{})
+ assert.Equal(t, "val", res)
+ res = coerceValue("newVal", nil)
+ assert.Equal(t, "newVal", res)
+ })
+
+ t.Run("String Slice input (take first)", func(t *testing.T) {
+ res := coerceValue([]string{"123"}, schemaInt)
+ assert.Equal(t, int64(123), res)
+ })
+
+ t.Run("Integer Conversions", func(t *testing.T) {
+ assert.Equal(t, "abc", coerceValue("abc", schemaInt))
+ assert.Equal(t, "", coerceValue("", schemaInt))
+ assert.Equal(t, 123, coerceValue(123, schemaInt))
+ assert.Equal(t, int64(123), coerceValue("123", schemaInt))
+ })
+
+ t.Run("Number Conversions", func(t *testing.T) {
+ assert.Equal(t, 12.34, coerceValue("12.34", schemaNum))
+ assert.Equal(t, "abc", coerceValue("abc", schemaNum))
+ assert.Equal(t, 13.2, coerceValue(13.2, schemaNum))
+ assert.Equal(t, 5, coerceValue(5, nil))
+ })
+
+ t.Run("Boolean Conversions", func(t *testing.T) {
+ assert.Equal(t, true, coerceValue("true", schemaBool))
+ assert.Equal(t, 123, coerceValue(123, schemaBool))
+ })
+
+ t.Run("String Conversions", func(t *testing.T) {
+ assert.Equal(t, "val", coerceValue("val", schemaStr))
+ assert.Equal(t, "123", coerceValue(123, schemaStr))
+ })
+
+ t.Run("Array Conversions", func(t *testing.T) {
+ arrSchema := &base.Schema{
+ Type: []string{helpers.Array},
+ Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: base.CreateSchemaProxy(&base.Schema{
+ Type: []string{helpers.Integer},
+ })},
+ }
+
+ noItem := coerceValue("a", &base.Schema{
+ Type: []string{helpers.Array},
+ })
+ assert.Equal(t, []any{"a"}, noItem)
+
+ res1 := coerceValue([]any{"1", "2"}, arrSchema)
+ assert.Equal(t, []any{int64(1), int64(2)}, res1)
+
+ res2 := coerceValue([]string{"1", "2"}, arrSchema)
+ assert.Equal(t, []any{int64(1), int64(2)}, res2)
+
+ mapInput := map[string]any{"1": "20", "0": "10"}
+ res3 := coerceValue(mapInput, arrSchema)
+ assert.IsType(t, []any{}, res3)
+ sliceRes := res3.([]any)
+ assert.Equal(t, int64(10), sliceRes[0])
+ assert.Equal(t, int64(20), sliceRes[1])
+
+ mapBad := map[string]any{"foo": "bar"}
+ res4 := coerceValue(mapBad, arrSchema)
+ assert.Equal(t, mapBad, res4)
+
+ res5 := coerceValue("10", arrSchema)
+ assert.Equal(t, []any{int64(10)}, res5)
+ })
+
+ t.Run("Object Conversions", func(t *testing.T) {
+ objSchema := &base.Schema{
+ Type: []string{helpers.Object},
+ Properties: orderedmap.New[string, *base.SchemaProxy](),
+ }
+ objSchema.Properties.Set("num", base.CreateSchemaProxy(schemaInt))
+
+ input := map[string]any{"num": "55", "other": "val"}
+
+ res := coerceValue(input, objSchema)
+ resMap, ok := res.(map[string]any)
+ assert.True(t, ok)
+ assert.Equal(t, int64(55), resMap["num"])
+ assert.Equal(t, "val", resMap["other"])
+ })
+}
+
+func TestIsArraySchema(t *testing.T) {
+ assert.False(t, isArraySchema(nil))
+ assert.False(t, isArraySchema(&base.Schema{Type: []string{"string"}}))
+ assert.True(t, isArraySchema(&base.Schema{Type: []string{"array"}}))
+}
+
+func TestComplexBodies(t *testing.T) {
+ spec := `{
+ "openapi": "3.1.0",
+ "paths": {
+ "/posts": {
+ "put": {
+ "requestBody": {
+ "content": {
+ "application/x-www-form-urlencoded": {
+ "encoding": {
+ "payload": {
+ "contentType": "application/json"
+ },
+ "title": {
+ "allowReserved": true
+ },
+ "pipeArr": {
+ "style": "pipeDelimited"
+ },
+ "spaceArr": {
+ "style": "spaceDelimited"
+ },
+ "unexplodedArr": {
+ "explode": false
+ }
+ },
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {"oneOf": [
+ {
+ "type": ["boolean"]
+ },
+ {
+ "type": ["integer"]
+ }]}
+ }
+ }
+ },
+ "bool": {
+ "type": ["boolean"],
+ "enum": [false]
+ },
+ "reserved": {
+ "type": ["string"]
+ },
+ "title": {
+ "type": ["string"]
+ },
+ "pipeArr": {
+ "type": "array",
+ "items": {"type": "integer"}
+ },
+ "spaceArr": {
+ "type": "array",
+ "items": {"type": "integer"}
+ },
+ "unexplodedArr": {
+ "type": "array",
+ "items": {"type": "integer"}
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "hey": {
+ "type": "array",
+ "items": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "required": ["title", "bool"],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ }
+ }
+ }
+ }
+}
+`
+
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ assert.NoError(t, err)
+
+ v3Doc, err := doc.BuildV3Model()
+ assert.NoError(t, err)
+
+ contentSchema := v3Doc.Model.Paths.PathItems.GetOrZero("/posts").Put.RequestBody.Content.GetOrZero("application/x-www-form-urlencoded")
+ schema := contentSchema.Schema.Schema()
+ encoding := contentSchema.Encoding
+
+ v := NewURLEncodedValidator()
+
+ valid, errs := v.ValidateURLEncodedString(schema, encoding, "bool=false&title=test&content[0][name]=true")
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "bool=false&title=test&content[0][name]=4")
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "bool=false&title=test&content[0][name]=4.4")
+ assert.False(t, valid)
+ assert.Len(t, errs, 1)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "bool=true&title=test&content[0][name]=true")
+ assert.False(t, valid)
+ assert.Len(t, errs, 1)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "bool=false&content[0][name]=true")
+ assert.False(t, valid)
+ assert.Len(t, errs, 1)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "bool=false&title")
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&payload={"hey": [true, false]}`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&payload={"hey": [2], "adittional": false}`)
+ assert.False(t, valid)
+ assert.Len(t, errs, 1)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title=do not use #`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&reserved=do not use #`)
+ assert.False(t, valid)
+ assert.Len(t, errs, 1)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&pipeArr=1|2|3`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&spaceArr=1 2 3`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&spaceArr=1%202%203`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, `bool=false&title&unexplodedArr=1,2,3`)
+ assert.True(t, valid)
+ assert.Len(t, errs, 0)
+}
+
+func TestValidateURLEncoded(t *testing.T) {
+ spec := `openapi: 3.0.0
+paths:
+ /collection:
+ get:
+ responses:
+ '200':
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object`
+
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ assert.NoError(t, err)
+
+ v3Doc, err := doc.BuildV3Model()
+ assert.NoError(t, err)
+
+ contentSchema := v3Doc.Model.Paths.PathItems.GetOrZero("/collection").Get.Responses.Codes.GetOrZero("200").Content.GetOrZero("application/x-www-form-urlencoded")
+ schema := contentSchema.Schema.Schema()
+ encoding := contentSchema.Encoding
+
+ v := NewURLEncodedValidator()
+
+ valid, errs := v.ValidateURLEncodedStringWithVersion(schema, encoding, "a=1", 3.1)
+ assert.True(t, valid)
+ assert.Empty(t, errs)
+
+ valid, _ = v.ValidateURLEncodedStringWithVersion(nil, nil, "a=1", 3.1)
+ assert.False(t, valid)
+
+ valid, errs = v.ValidateURLEncodedString(schema, encoding, "a=1")
+ assert.True(t, valid)
+ assert.Empty(t, errs)
+
+ valid, _ = v.ValidateURLEncodedString(nil, nil, "a=1")
+ assert.False(t, valid)
+}
diff --git a/schema_validation/validate_xml.go b/schema_validation/validate_xml.go
index ad30bd85..19eaa7da 100644
--- a/schema_validation/validate_xml.go
+++ b/schema_validation/validate_xml.go
@@ -1,12 +1,12 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT
-
package schema_validation
import (
"encoding/json"
"fmt"
"log/slog"
+ "strconv"
"strings"
"github.com/pb33f/libopenapi/datamodel/high/base"
@@ -18,86 +18,250 @@ import (
)
func (x *xmlValidator) validateXMLWithVersion(schema *base.Schema, xmlString string, log *slog.Logger, version float32) (bool, []*liberrors.ValidationError) {
- var validationErrors []*liberrors.ValidationError
-
if schema == nil {
log.Info("schema is empty and cannot be validated")
- return false, validationErrors
+ return false, nil
}
// parse xml and transform to json structure matching schema
- transformedJSON, err := transformXMLToSchemaJSON(xmlString, schema)
- if err != nil {
- violation := &liberrors.SchemaValidationFailure{
- Reason: err.Error(),
- Location: "xml parsing",
- ReferenceSchema: "",
- ReferenceObject: xmlString,
- }
- validationErrors = append(validationErrors, &liberrors.ValidationError{
- ValidationType: helpers.RequestBodyValidation,
- ValidationSubType: helpers.Schema,
- Message: "xml example is malformed",
- Reason: fmt.Sprintf("failed to parse xml: %s", err.Error()),
- SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
- HowToFix: "ensure xml is well-formed and matches schema structure",
- })
- return false, validationErrors
+ transformedJSON, prevalidationErrors := TransformXMLToSchemaJSON(xmlString, schema)
+ if len(prevalidationErrors) > 0 {
+ return false, prevalidationErrors
}
// validate transformed json against schema using existing validator
return x.schemaValidator.validateSchemaWithVersion(schema, nil, transformedJSON, log, version)
}
-// transformXMLToSchemaJSON converts xml to json structure matching openapi schema.
+// TransformXMLToSchemaJSON converts xml to json structure matching openapi schema.
// applies xml object transformations: name, attribute, wrapped.
-func transformXMLToSchemaJSON(xmlString string, schema *base.Schema) (interface{}, error) {
+func TransformXMLToSchemaJSON(xmlString string, schema *base.Schema) (any, []*liberrors.ValidationError) {
if xmlString == "" {
- return nil, fmt.Errorf("empty xml content")
+ return nil, []*liberrors.ValidationError{liberrors.InvalidXMLParsing("empty xml content", xmlString)}
}
- // parse xml using goxml2json with type conversion for numbers only
- // note: we convert floats and ints, but not booleans, since xml content
- // may legitimately contain "true"/"false" as string values
- jsonBuf, err := xj.Convert(strings.NewReader(xmlString), xj.WithTypeConverter(xj.Float, xj.Int))
+ // parse xml using goxml2json. we convert types manually
+ jsonBuf, err := xj.Convert(strings.NewReader(xmlString))
if err != nil {
- return nil, fmt.Errorf("malformed xml: %w", err)
+ return nil, []*liberrors.ValidationError{liberrors.InvalidXMLParsing(fmt.Sprintf("malformed xml: %s", err.Error()), xmlString)}
+ }
+
+ jsonBytes := jsonBuf.Bytes()
+
+ // the smallest valid XML possible "" generates a 10 bytes buffer.
+ // any other invalid XML generates a smaller buffer
+ if len(jsonBytes) < 10 {
+ return nil, []*liberrors.ValidationError{liberrors.InvalidXMLParsing("malformed xml", xmlString)}
}
- // decode to interface{}
- var rawJSON interface{}
- if err := json.Unmarshal(jsonBuf.Bytes(), &rawJSON); err != nil {
- return nil, fmt.Errorf("failed to decode json: %w", err)
+ var rawJSON any
+ if err := json.Unmarshal(jsonBytes, &rawJSON); err != nil {
+ return nil, []*liberrors.ValidationError{liberrors.InvalidXMLParsing(fmt.Sprintf("failed to decode converted xml to json: %s", err.Error()), xmlString)}
}
+ xmlNsMap := make(map[string]string, 2)
+
// apply openapi xml object transformations
- transformed := applyXMLTransformations(rawJSON, schema)
- return transformed, nil
+ return applyXMLTransformations(rawJSON, schema, &xmlNsMap)
+}
+
+func validateXmlNs(dataMap *map[string]any, schema *base.Schema, propName string, xmlNsMap *map[string]string) []*liberrors.ValidationError {
+ var validationErrors []*liberrors.ValidationError
+
+ if dataMap == nil || schema == nil || xmlNsMap == nil {
+ return validationErrors
+ }
+
+ if propName != "" {
+ if val, exists := (*dataMap)[propName]; exists {
+ if converted, ok := val.(map[string]any); ok {
+ dataMap = &converted
+ }
+ }
+ }
+
+ if schema.XML.Prefix != "" {
+ attrKey := "-" + schema.XML.Prefix
+
+ val, exists := (*dataMap)[attrKey]
+
+ if exists {
+ if ns, ok := val.(string); ok {
+ (*xmlNsMap)[schema.XML.Prefix] = ns
+ (*xmlNsMap)[ns] = schema.XML.Prefix
+
+ if schema.XML.Namespace != "" && schema.XML.Namespace != ns {
+ validationErrors = append(validationErrors,
+ liberrors.InvalidNamespace(schema, ns, schema.XML.Namespace, schema.XML.Prefix))
+ }
+ }
+
+ delete((*dataMap), attrKey)
+ } else {
+ validationErrors = append(validationErrors, liberrors.MissingPrefix(schema, schema.XML.Prefix))
+ }
+ }
+
+ if schema.XML.Namespace != "" {
+ _, exists := (*xmlNsMap)[schema.XML.Namespace]
+
+ if !exists {
+ validationErrors = append(validationErrors, liberrors.MissingNamespace(schema, schema.XML.Namespace))
+ }
+ }
+
+ return validationErrors
+}
+
+func convertBasedOnSchema(propName, xmlName string, propValue any, schema *base.Schema, xmlNsMap *map[string]string) (any, []*liberrors.ValidationError) {
+ var xmlNsErrors []*liberrors.ValidationError
+
+ types := schema.Type
+
+ extractTypes := func(proxies []*base.SchemaProxy) {
+ for _, proxy := range proxies {
+ sch := proxy.Schema()
+ if len(sch.Type) > 0 {
+ types = append(types, sch.Type...)
+ }
+ }
+ }
+
+ extractTypes(schema.AllOf)
+ extractTypes(schema.OneOf)
+ extractTypes(schema.AnyOf)
+
+ convertedValue := propValue
+
+typesLoop:
+ for _, pType := range types {
+ // because in XML everything is a string, we try to convert the value to the
+ // actual expected type, so the normal schema validation should pass with correct types
+ switch pType {
+ case helpers.Integer:
+ textValue, isString := propValue.(string)
+
+ if isString {
+ converted, err := strconv.ParseInt(textValue, 10, 64)
+
+ if err == nil {
+ convertedValue = converted
+ break typesLoop
+ }
+ }
+ case helpers.Number:
+ textValue, isString := propValue.(string)
+
+ if isString {
+ converted, err := strconv.ParseFloat(textValue, 64)
+ if err == nil {
+ convertedValue = converted
+ break typesLoop
+ }
+ }
+
+ case helpers.Boolean:
+ textValue, isString := propValue.(string)
+
+ if isString {
+ converted, err := strconv.ParseBool(textValue)
+ if err == nil {
+ convertedValue = converted
+ break typesLoop
+ }
+ }
+
+ case helpers.Array:
+ convertedValue = propValue
+
+ if schema.XML != nil && schema.XML.Wrapped {
+ convertedValue = unwrapArrayElement(propValue, propName, schema)
+ }
+
+ if schema.Items != nil && schema.Items.A != nil {
+ itemSchema := schema.Items.A.Schema()
+
+ arr, isArr := convertedValue.([]any)
+
+ if !isArr {
+ arr = []any{
+ convertedValue,
+ }
+ }
+
+ for index, item := range arr {
+ converted, errs := convertBasedOnSchema(propName, xmlName, item, itemSchema, xmlNsMap)
+
+ if len(errs) > 0 {
+ xmlNsErrors = append(xmlNsErrors, errs...)
+ }
+
+ arr[index] = converted
+ }
+
+ convertedValue = arr
+ break typesLoop
+ }
+ case helpers.Object:
+ objectValue, isObject := propValue.(map[string]any)
+
+ if isObject {
+ newValue, xmlErrors := applyXMLTransformations(objectValue, schema, xmlNsMap)
+
+ if len(xmlErrors) > 0 {
+ xmlNsErrors = append(xmlNsErrors, xmlErrors...)
+ continue typesLoop
+ }
+
+ convertedValue = newValue
+ break typesLoop
+ }
+ }
+ }
+
+ return convertedValue, xmlNsErrors
}
// applyXMLTransformations applies openapi xml object rules to match json schema.
-// handles xml.name (root unwrapping), xml.attribute (dash prefix), xml.wrapped (array unwrapping).
-func applyXMLTransformations(data interface{}, schema *base.Schema) interface{} {
- if schema == nil {
- return data
+// handles xml.name (root unwrapping), xml.attribute (dash prefix), xml.wrapped (array unwrapping),
+// xml.prefix (check existance), xml.namespace (check if exists and match).
+// we delete all attributes, prefixes, and namespaces found in the data interface; therefore, undeclared items
+// are sent in the body for validation, so that 'additionalProperties: false' can detect it.
+func applyXMLTransformations(data any, schema *base.Schema, xmlNsMap *map[string]string) (any, []*liberrors.ValidationError) {
+ if schema == nil || data == nil || xmlNsMap == nil {
+ return data, nil
}
// unwrap root element if xml.name is set on schema
if schema.XML != nil && schema.XML.Name != "" {
- if dataMap, ok := data.(map[string]interface{}); ok {
+ if dataMap, ok := data.(map[string]any); ok {
if wrapped, exists := dataMap[schema.XML.Name]; exists {
data = wrapped
}
}
}
+ var xmlNsErrors []*liberrors.ValidationError
+
// transform properties based on their xml configurations
- if dataMap, ok := data.(map[string]interface{}); ok {
+ if dataMap, ok := data.(map[string]any); ok {
if schema.Properties == nil || schema.Properties.Len() == 0 {
- return data
- }
+ if schema.XML != nil && (schema.XML.Prefix != "" || schema.XML.Namespace != "") {
+ namespaceErrors := validateXmlNs(&dataMap, schema, "", xmlNsMap)
- transformed := make(map[string]interface{}, schema.Properties.Len())
+ if len(namespaceErrors) > 0 {
+ xmlNsErrors = append(xmlNsErrors, namespaceErrors...)
+ } else {
+ if content, has := dataMap["#content"]; has {
+ if stringContent, ok := content.(string); ok {
+ data = stringContent
+ }
+ }
+ }
+ }
+
+ return data, xmlNsErrors
+ }
for pair := schema.Properties.First(); pair != nil; pair = pair.Next() {
propName := pair.Key()
@@ -107,49 +271,76 @@ func applyXMLTransformations(data interface{}, schema *base.Schema) interface{}
continue
}
- // determine xml element name (defaults to property name)
xmlName := propName
- if propSchema.XML != nil && propSchema.XML.Name != "" {
- xmlName = propSchema.XML.Name
+
+ if propSchema.XML != nil {
+ // determine xml element name (defaults to property name)
+ if propSchema.XML.Name != "" {
+ xmlName = propSchema.XML.Name
+ }
}
- // handle xml.attribute: true - attributes are prefixed with dash
- if propSchema.XML != nil && propSchema.XML.Attribute {
- attrKey := "-" + xmlName
- if val, exists := dataMap[attrKey]; exists {
- transformed[propName] = val
- continue
+ if propSchema.XML != nil {
+ namespaceErrors := validateXmlNs(&dataMap, propSchema, xmlName, xmlNsMap)
+
+ if len(namespaceErrors) > 0 {
+ xmlNsErrors = append(xmlNsErrors, namespaceErrors...)
+ }
+
+ // handle xml.attribute: true - attributes are prefixed with dash
+ if propSchema.XML.Attribute {
+ attrKey := "-" + xmlName
+ if val, exists := dataMap[attrKey]; exists {
+ // If the value is an attribute, it cannot have a namespace
+ convertedValue, _ := convertBasedOnSchema(propName, xmlName, val, propSchema, xmlNsMap)
+ dataMap[propName] = convertedValue
+ delete(dataMap, attrKey)
+ continue
+ }
}
}
// handle regular elements
if val, exists := dataMap[xmlName]; exists {
- // handle wrapped arrays: unwrap container element
- if len(propSchema.Type) > 0 && propSchema.Type[0] == "array" &&
- propSchema.XML != nil && propSchema.XML.Wrapped {
- val = unwrapArrayElement(val, propSchema)
+ if mapObject, ok := val.(map[string]any); ok {
+ if content, has := mapObject["#content"]; has {
+ if stringContent, ok := content.(string); ok {
+ val = stringContent
+ }
+ }
+ }
+
+ convertedValue, nsErrors := convertBasedOnSchema(propName, xmlName, val, propSchema, xmlNsMap)
+
+ if len(nsErrors) > 0 {
+ xmlNsErrors = append(xmlNsErrors, nsErrors...)
}
- transformed[propName] = val
+ dataMap[propName] = convertedValue
+
+ if propName != xmlName {
+ delete(dataMap, xmlName)
+ }
}
}
-
- return transformed
}
- return data
+ return data, xmlNsErrors
}
// unwrapArrayElement removes wrapping element from xml arrays when xml.wrapped is true.
// example: {"items": {"item": [...]}} becomes [...]
-func unwrapArrayElement(val interface{}, propSchema *base.Schema) interface{} {
- wrapMap, ok := val.(map[string]interface{})
+func unwrapArrayElement(val any, itemName string, propSchema *base.Schema) any {
+ wrapMap, ok := val.(map[string]any)
if !ok {
return val
}
+ if propSchema.XML.Name != "" {
+ itemName = propSchema.XML.Name
+ }
+
// determine item element name
- itemName := "item"
if propSchema.Items != nil && propSchema.Items.A != nil {
itemSchema := propSchema.Items.A.Schema()
if itemSchema != nil && itemSchema.XML != nil && itemSchema.XML.Name != "" {
diff --git a/schema_validation/validate_xml_test.go b/schema_validation/validate_xml_test.go
index 1d3faad7..1b6292f5 100644
--- a/schema_validation/validate_xml_test.go
+++ b/schema_validation/validate_xml_test.go
@@ -1,13 +1,14 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT
-
package schema_validation
import (
"testing"
"github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/libopenapi/orderedmap"
"github.com/stretchr/testify/assert"
)
@@ -751,15 +752,16 @@ func TestValidateXML_NilSchema(t *testing.T) {
func TestValidateXML_NilSchemaInTransformation(t *testing.T) {
// directly test applyXMLTransformations with nil schema (line 94)
- result := applyXMLTransformations(map[string]interface{}{"test": "value"}, nil)
+ xmlNsMap := make(map[string]string, 2)
+ result, err := applyXMLTransformations(map[string]interface{}{"test": "value"}, nil, &xmlNsMap)
assert.NotNil(t, result)
+ assert.Len(t, err, 0)
assert.Equal(t, map[string]interface{}{"test": "value"}, result)
}
func TestValidateXML_TransformWithNilPropertySchemaProxy(t *testing.T) {
// directly test applyXMLTransformations when a property schema proxy returns nil (line 119)
// this can happen with circular refs or unresolved refs in edge cases
-
// create a schema with properties but we'll simulate a nil schema scenario
// by testing the transformation directly
data := map[string]interface{}{
@@ -770,8 +772,9 @@ func TestValidateXML_TransformWithNilPropertySchemaProxy(t *testing.T) {
schema := &base.Schema{
Properties: nil, // will trigger line 109 early return
}
-
- result := applyXMLTransformations(data, schema)
+ xmlNsMap := make(map[string]string, 2)
+ result, err := applyXMLTransformations(data, schema, &xmlNsMap)
+ assert.Len(t, err, 0)
assert.Equal(t, data, result)
}
@@ -891,6 +894,7 @@ paths:
xml:
wrapped: true
items:
+ additionalProperties: false
type: object
properties:
value:
@@ -914,11 +918,12 @@ paths:
// wrapper contains items with wrong name (item instead of record)
// this tests the fallback path where unwrapped element is not found
xmlWithWrongItemName := `- test
`
- valid, validationErrors := validator.ValidateXMLString(schema, xmlWithWrongItemName)
+ valid, _ := validator.ValidateXMLString(schema, xmlWithWrongItemName)
+ assert.False(t, valid)
- // it should still process (might fail schema validation but won't crash)
- _ = valid
- assert.NotNil(t, validationErrors)
+ xmlWithWrightItemName := `test`
+ valid, _ = validator.ValidateXMLString(schema, xmlWithWrightItemName)
+ assert.True(t, valid)
}
func TestValidateXML_DirectArrayValue(t *testing.T) {
@@ -935,7 +940,7 @@ func TestValidateXML_DirectArrayValue(t *testing.T) {
// when val is already an array (not a map), it should return as-is
arrayVal := []interface{}{"one", "two", "three"}
- result := unwrapArrayElement(arrayVal, schema)
+ result := unwrapArrayElement(arrayVal, "", schema)
assert.Equal(t, arrayVal, result)
}
@@ -953,16 +958,16 @@ func TestValidateXML_UnwrapArrayElementMissingItem(t *testing.T) {
// wrapper map contains wrong key - should return map as-is (line 177)
wrapperMap := map[string]interface{}{"wrongKey": []interface{}{"one", "two"}}
- result := unwrapArrayElement(wrapperMap, schema)
+ result := unwrapArrayElement(wrapperMap, "", schema)
assert.Equal(t, wrapperMap, result)
}
func TestTransformXMLToSchemaJSON_EmptyString(t *testing.T) {
// test empty string error path (line 68)
schema := &base.Schema{}
- _, err := transformXMLToSchemaJSON("", schema)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "empty xml")
+ _, err := TransformXMLToSchemaJSON("", schema)
+ assert.Len(t, err, 1)
+ assert.Contains(t, err[0].Reason, "empty xml content")
}
func TestApplyXMLTransformations_NoXMLName(t *testing.T) {
@@ -970,8 +975,10 @@ func TestApplyXMLTransformations_NoXMLName(t *testing.T) {
schema := &base.Schema{
Properties: nil,
}
+ xmlNsMap := make(map[string]string, 2)
data := map[string]interface{}{"Cat": map[string]interface{}{"nice": "true"}}
- result := applyXMLTransformations(data, schema)
+ result, err := applyXMLTransformations(data, schema, &xmlNsMap)
+ assert.Len(t, err, 0)
assert.Equal(t, data, result)
}
@@ -997,3 +1004,251 @@ func TestIsXMLContentType(t *testing.T) {
})
}
}
+
+func TestTransformXMLToSchemaJSON_InvalixXml(t *testing.T) {
+ schema := &base.Schema{}
+ _, err := TransformXMLToSchemaJSON("<", schema)
+ assert.Len(t, err, 1)
+ assert.Contains(t, err[0].Reason, "malformed xml")
+}
+
+func TestValidateXmlNs_NoData(t *testing.T) {
+ errors := validateXmlNs(nil, nil, "", nil)
+ assert.Len(t, errors, 0)
+}
+
+func getXmlTestSchema(t *testing.T) *base.Schema {
+ spec := `openapi: 3.1
+paths:
+ /collection:
+ get:
+ responses:
+ '200':
+ content:
+ application/xml:
+ schema:
+ type: object
+ additionalProperties: false
+ properties:
+ body:
+ type: object
+ required:
+ - id
+ - success
+ - payload
+ xml:
+ prefix: t
+ namespace: http://assert.t
+ name: reqBody
+ properties:
+ id:
+ type: integer
+ xml:
+ attribute: true
+ success:
+ xml:
+ name: ok
+ prefix: j
+ namespace: http://j.j
+ type: boolean
+ payload:
+ oneOf:
+ - type: integer
+ - type: object
+ data:
+ type: array
+ xml:
+ wrapped: true
+ name: list
+ items:
+ additionalProperties: false
+ type: object
+ required:
+ - value
+ properties:
+ value:
+ type: string
+ xml:
+ namespace: http://prop.arr
+ prefix: arr
+ xml:
+ name: record
+ prefix: unt
+ namespace: http://expect.t
+ xml:
+ name: Collection`
+
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ assert.NoError(t, err)
+
+ v3Doc, err := doc.BuildV3Model()
+ assert.NoError(t, err)
+
+ schema := v3Doc.Model.Paths.PathItems.GetOrZero("/collection").Get.Responses.Codes.GetOrZero("200").
+ Content.GetOrZero("application/xml").Schema.Schema()
+
+ return schema
+}
+
+func TestValidateXmlNs_InvalidPrefix(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+ xmlPayload := ``
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, helpers.XmlValidationPrefix, err[0].ValidationSubType)
+}
+
+func TestValidateXmlNs_InvalidNamespace(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+ xmlPayload := ``
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, helpers.XmlValidationNamespace, err[0].ValidationSubType)
+}
+
+func TestValidateXmlNs_InvalidNamespaceInRoot(t *testing.T) {
+ spec := `openapi: 3.0.0
+paths:
+ /pet:
+ get:
+ responses:
+ '200':
+ content:
+ application/xml:
+ schema:
+ type: object
+ xml:
+ name: Cat
+ prefix: c
+ namespace: http://cat.ca`
+
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ assert.NoError(t, err)
+
+ v3Doc, err := doc.BuildV3Model()
+ assert.NoError(t, err)
+
+ schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200").
+ Content.GetOrZero("application/xml").Schema.Schema()
+
+ validator := NewXMLValidator()
+ xmlPayload := ``
+
+ valid, validationErrors := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, "The namespace from prefix 'c' differs from the xml", validationErrors[0].Message)
+ assert.Equal(t, helpers.XmlValidationNamespace, validationErrors[0].ValidationSubType)
+}
+
+func TestValidateXmlNs_CorrectNamespaceInRoot(t *testing.T) {
+ spec := `openapi: 3.0.0
+paths:
+ /pet:
+ get:
+ responses:
+ '200':
+ content:
+ application/xml:
+ schema:
+ type: string
+ xml:
+ name: Cat
+ prefix: c
+ namespace: http://cat.ca`
+
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ assert.NoError(t, err)
+
+ v3Doc, err := doc.BuildV3Model()
+ assert.NoError(t, err)
+
+ schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200").
+ Content.GetOrZero("application/xml").Schema.Schema()
+
+ validator := NewXMLValidator()
+ xmlPayload := `meow`
+
+ valid, validationErrors := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.True(t, valid)
+ assert.Len(t, validationErrors, 0)
+}
+
+func TestConvertBasedOnSchema_XmlSuccessfullyConverted(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+
+ xmlPayload := `true2
+Text
`
+
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.True(t, valid)
+ assert.Len(t, err, 0)
+}
+
+func TestConvertBasedOnSchema_MissingPrefixInObjectProperties(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+
+ xmlPayload := `true2
+Text
`
+
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, helpers.XmlValidationPrefix, err[0].ValidationSubType)
+ assert.Equal(t, "The prefix 'j' is defined in the schema, however it's missing from the xml", err[0].Message)
+}
+
+func TestConvertBasedOnSchema_MissingPrefixInArrayItemProperties(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+
+ xmlPayload := `true2
+Text
`
+
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, helpers.XmlValidationPrefix, err[0].ValidationSubType)
+ assert.Equal(t, "The prefix 'arr' is defined in the schema, however it's missing from the xml", err[0].Message)
+}
+
+func TestApplyXMLTransformations_IncorrectSchema(t *testing.T) {
+ schema := getXmlTestSchema(t)
+ validator := NewXMLValidator()
+
+ xmlPayload := `NotBooleanNotInteger
+Text
`
+
+ valid, err := validator.ValidateXMLString(schema, xmlPayload)
+
+ assert.False(t, valid)
+ assert.Equal(t, "got string, want boolean", err[0].SchemaValidationErrors[0].Reason)
+ assert.Equal(t, "schema does not pass validation", err[0].Message)
+}
+
+func TestApplyXMLTransformations_NilPropSchema(t *testing.T) {
+ schema := &base.Schema{
+ Properties: orderedmap.New[string, *base.SchemaProxy](),
+ }
+
+ emptyProxy := &base.SchemaProxy{}
+ schema.Properties.Set("broken_ref_prop", emptyProxy)
+
+ data := map[string]any{
+ "broken_ref_prop": "some_value",
+ }
+ xmlNsMap := make(map[string]string)
+
+ result, errs := applyXMLTransformations(data, schema, &xmlNsMap)
+
+ assert.Len(t, errs, 0)
+ assert.NotNil(t, result)
+}