diff --git a/config/config.go b/config/config.go index f46acce..fa95c65 100644 --- a/config/config.go +++ b/config/config.go @@ -21,16 +21,17 @@ 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 when validating a request/response body. // strict mode options - detect undeclared properties even when additionalProperties: true StrictMode bool // Enable strict property validation @@ -75,6 +76,7 @@ func WithExistingOpts(options *ValidationOptions) Option { o.Formats = options.Formats o.SchemaCache = options.SchemaCache o.Logger = options.Logger + o.AllowXMLBodyValidation = options.AllowXMLBodyValidation o.StrictMode = options.StrictMode o.StrictIgnorePaths = options.StrictIgnorePaths o.StrictIgnoredHeaders = options.StrictIgnoredHeaders @@ -161,6 +163,14 @@ 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 + } +} + // 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 dddd739..40ea92d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -19,8 +19,9 @@ 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.Nil(t, opts.RegexEngine) assert.Nil(t, opts.RegexCache) } @@ -32,8 +33,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 +46,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 +59,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 +97,12 @@ 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, + ContentAssertions: true, + SecurityValidation: false, } // Create new options using existing options @@ -105,6 +110,7 @@ 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.FormatAssertions, opts.FormatAssertions) assert.Equal(t, original.ContentAssertions, opts.ContentAssertions) assert.Equal(t, original.SecurityValidation, opts.SecurityValidation) @@ -119,8 +125,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 +136,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) diff --git a/errors/parameters_howtofix.go b/errors/parameters_howtofix.go index e20a150..bec9bcd 100644 --- a/errors/parameters_howtofix.go +++ b/errors/parameters_howtofix.go @@ -12,6 +12,9 @@ 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" HowToFixInvalidSchema string = "Ensure that the object being submitted, matches the schema correctly" HowToFixParamInvalidSpaceDelimitedObjectExplode string = "When using 'explode' with space delimited parameters, " + "they should be separated by spaces. For example: '%s'" @@ -19,15 +22,17 @@ const ( "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" + 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/xml_errors.go b/errors/xml_errors.go new file mode 100644 index 0000000..5c81f0c --- /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 0000000..5c4552a --- /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 1faecff..53fe27a 100644 --- a/helpers/constants.go +++ b/helpers/constants.go @@ -11,6 +11,9 @@ const ( ParameterValidationCookie = "cookie" RequestValidation = "request" RequestBodyValidation = "requestBody" + XmlValidation = "xmlValidation" + XmlValidationPrefix = "prefix" + XmlValidationNamespace = "namespace" Schema = "schema" ResponseBodyValidation = "response" RequestBodyContentType = "contentType" diff --git a/requests/validate_body.go b/requests/validate_body.go index e9aad70..c8ce603 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,32 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req // extract schema from media type schema := mediaType.Schema.Schema() + if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) { + // we currently only support JSON and XML validation for request bodies + // this will capture *everything* that contains some form of 'json' in the content type + if !v.options.AllowXMLBodyValidation || !schema_validation.IsXMLContentType(contentType) { + return true, nil + } + + if request != nil && request.Body != nil { + requestBody, _ := io.ReadAll(request.Body) + _ = request.Body.Close() + + stringedBody := string(requestBody) + jsonBody, prevalidationErrors := schema_validation.TransformXMLToSchemaJSON(stringedBody, schema) + if len(prevalidationErrors) > 0 { + return false, prevalidationErrors + } + + transformedBytes, err := json.Marshal(jsonBody) + if err != nil { + return false, []*errors.ValidationError{errors.InvalidXmlParsing(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 bc96085..7d98fe2 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,121 @@ paths: assert.True(t, valid) assert.Len(t, errors, 0) } + +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 f1ce93c..8383200 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 ae09b30..ca28970 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,55 @@ 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 and XML based responses, so check for the presence + // of 'json' (what ever it may be) and for XML content type so we can perform a schema check on it. + // anything other than JSON or XML, will be ignored. + + isXml := schema_validation.IsXMLContentType(contentType) + + if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) && (!v.options.AllowXMLBodyValidation || !isXml) { + return validationErrors + } + + schema := mediaType.Schema.Schema() + + if isXml { + if response != nil && response.Body != http.NoBody { + responseBody, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + + stringedBody := string(responseBody) + jsonBody, prevalidationErrors := schema_validation.TransformXMLToSchemaJSON(stringedBody, schema) + + if len(prevalidationErrors) > 0 { + return prevalidationErrors + } + + transformedBytes, err := json.Marshal(jsonBody) + if err != nil { + return []*errors.ValidationError{errors.InvalidXmlParsing(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 a40bdd1..5d9a00f 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1593,6 +1593,208 @@ paths: assert.Len(t, errs, 0) } +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/validate_schema.go b/schema_validation/validate_schema.go index 5740f1e..15942e4 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_xml.go b/schema_validation/validate_xml.go index ad30bd8..8df685f 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 1d3faad..29e3c42 100644 --- a/schema_validation/validate_xml_test.go +++ b/schema_validation/validate_xml_test.go @@ -1,12 +1,12 @@ // 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/stretchr/testify/assert" ) @@ -751,15 +751,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 +771,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 +893,7 @@ paths: xml: wrapped: true items: + additionalProperties: false type: object properties: value: @@ -914,11 +917,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 +939,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 +957,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 +974,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 +1003,232 @@ 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) +}