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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 24 additions & 15 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -93,18 +97,20 @@ 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
opts := NewValidationOptions(WithExistingOpts(original))

assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.NotNil(t, opts.RegexCache)
assert.Equal(t, original.AllowXMLBodyValidation, opts.AllowXMLBodyValidation)
assert.Equal(t, original.FormatAssertions, opts.FormatAssertions)
assert.Equal(t, original.ContentAssertions, opts.ContentAssertions)
assert.Equal(t, original.SecurityValidation, opts.SecurityValidation)
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
52 changes: 46 additions & 6 deletions requests/validate_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
package requests

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

Expand All @@ -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) {
Expand All @@ -24,6 +28,22 @@ func (v *requestBodyValidator) ValidateRequestBody(request *http.Request) (bool,
return v.ValidateRequestBodyWithPathItem(request, pathItem, foundPath)
}

func generateXmlValidationError(err error, referenceObject string) []*errors.ValidationError {
return []*errors.ValidationError{{
ValidationType: helpers.RequestBodyValidation,
ValidationSubType: helpers.Schema,
Message: "xml example is malformed",
Reason: fmt.Sprintf("failed to parse xml: %s", err.Error()),
SchemaValidationErrors: []*errors.SchemaValidationFailure{{
Reason: err.Error(),
Location: "xml parsing",
ReferenceSchema: "",
ReferenceObject: referenceObject,
}},
HowToFix: "ensure xml is well-formed and matches schema structure",
}}
}

func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) {
if pathItem == nil {
return false, []*errors.ValidationError{{
Expand Down Expand Up @@ -66,12 +86,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
Expand All @@ -80,6 +94,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, err := schema_validation.TransformXMLToSchemaJSON(stringedBody, schema)
if err != nil {
return false, generateXmlValidationError(err, stringedBody)
}

transformedBytes, err := json.Marshal(jsonBody)
if err != nil {
return false, generateXmlValidationError(err, stringedBody)
}

request.Body = io.NopCloser(bytes.NewBuffer(transformedBytes))
}
}

validationSucceeded, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{
Request: request,
Schema: schema,
Expand Down
119 changes: 119 additions & 0 deletions requests/validate_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 := "<name>cheeseburger</name><cost>23</cost>"

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.RequestBodyValidation, 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 := "<Burger><name>cheeseburger</name><cost>23</cost></Burger>"

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