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) +}