Skip to content

Commit 3c4fb49

Browse files
ySnoopyDogydaveshanley
authored andcommitted
add xml conversion to bodies
1 parent 26d43b7 commit 3c4fb49

8 files changed

Lines changed: 495 additions & 53 deletions

File tree

config/config.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,17 @@ type RegexCache interface {
2121
//
2222
// Generally fluent With... style functions are used to establish the desired behavior.
2323
type ValidationOptions struct {
24-
RegexEngine jsonschema.RegexpEngine
25-
RegexCache RegexCache // Enable compiled regex caching
26-
FormatAssertions bool
27-
ContentAssertions bool
28-
SecurityValidation bool
29-
OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation
30-
AllowScalarCoercion bool // Enable string->boolean/number coercion
31-
Formats map[string]func(v any) error
32-
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
33-
Logger *slog.Logger // Logger for debug/error output (nil = silent)
24+
RegexEngine jsonschema.RegexpEngine
25+
RegexCache RegexCache // Enable compiled regex caching
26+
FormatAssertions bool
27+
ContentAssertions bool
28+
SecurityValidation bool
29+
OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation
30+
AllowScalarCoercion bool // Enable string->boolean/number coercion
31+
Formats map[string]func(v any) error
32+
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
33+
Logger *slog.Logger // Logger for debug/error output (nil = silent)
34+
AllowXMLBodyValidation bool // Allows to convert XML to JSON when validating a request/response body.
3435

3536
// strict mode options - detect undeclared properties even when additionalProperties: true
3637
StrictMode bool // Enable strict property validation
@@ -75,6 +76,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
7576
o.Formats = options.Formats
7677
o.SchemaCache = options.SchemaCache
7778
o.Logger = options.Logger
79+
o.AllowXMLBodyValidation = options.AllowXMLBodyValidation
7880
o.StrictMode = options.StrictMode
7981
o.StrictIgnorePaths = options.StrictIgnorePaths
8082
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
@@ -161,6 +163,14 @@ func WithScalarCoercion() Option {
161163
}
162164
}
163165

166+
// WithXmlBodyValidation enables converting an XML body to a JSON when validating the schema from a request and response body
167+
// The default option is set to false
168+
func WithXmlBodyValidation() Option {
169+
return func(o *ValidationOptions) {
170+
o.AllowXMLBodyValidation = true
171+
}
172+
}
173+
164174
// WithSchemaCache sets a custom cache implementation or disables caching if nil.
165175
// Pass nil to disable schema caching and skip cache warming during validator initialization.
166176
// The default cache is a thread-safe sync.Map wrapper.

config/config_test.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ func TestNewValidationOptions_Defaults(t *testing.T) {
1919
assert.False(t, opts.FormatAssertions)
2020
assert.False(t, opts.ContentAssertions)
2121
assert.True(t, opts.SecurityValidation)
22-
assert.True(t, opts.OpenAPIMode) // Default is true
23-
assert.False(t, opts.AllowScalarCoercion) // Default is false
22+
assert.True(t, opts.OpenAPIMode) // Default is true
23+
assert.False(t, opts.AllowScalarCoercion) // Default is false
24+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
2425
assert.Nil(t, opts.RegexEngine)
2526
assert.Nil(t, opts.RegexCache)
2627
}
@@ -32,8 +33,9 @@ func TestNewValidationOptions_WithNilOption(t *testing.T) {
3233
assert.False(t, opts.FormatAssertions)
3334
assert.False(t, opts.ContentAssertions)
3435
assert.True(t, opts.SecurityValidation)
35-
assert.True(t, opts.OpenAPIMode) // Default is true
36-
assert.False(t, opts.AllowScalarCoercion) // Default is false
36+
assert.True(t, opts.OpenAPIMode) // Default is true
37+
assert.False(t, opts.AllowScalarCoercion) // Default is false
38+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
3739
assert.Nil(t, opts.RegexEngine)
3840
assert.Nil(t, opts.RegexCache)
3941
}
@@ -44,8 +46,9 @@ func TestWithFormatAssertions(t *testing.T) {
4446
assert.True(t, opts.FormatAssertions)
4547
assert.False(t, opts.ContentAssertions)
4648
assert.True(t, opts.SecurityValidation)
47-
assert.True(t, opts.OpenAPIMode) // Default is true
48-
assert.False(t, opts.AllowScalarCoercion) // Default is false
49+
assert.True(t, opts.OpenAPIMode) // Default is true
50+
assert.False(t, opts.AllowScalarCoercion) // Default is false
51+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
4952
assert.Nil(t, opts.RegexEngine)
5053
assert.Nil(t, opts.RegexCache)
5154
}
@@ -56,8 +59,9 @@ func TestWithContentAssertions(t *testing.T) {
5659
assert.False(t, opts.FormatAssertions)
5760
assert.True(t, opts.ContentAssertions)
5861
assert.True(t, opts.SecurityValidation)
59-
assert.True(t, opts.OpenAPIMode) // Default is true
60-
assert.False(t, opts.AllowScalarCoercion) // Default is false
62+
assert.True(t, opts.OpenAPIMode) // Default is true
63+
assert.False(t, opts.AllowScalarCoercion) // Default is false
64+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
6165
assert.Nil(t, opts.RegexEngine)
6266
assert.Nil(t, opts.RegexCache)
6367
}
@@ -93,18 +97,20 @@ func TestWithExistingOpts(t *testing.T) {
9397
// Create original options with all settings enabled
9498
var testEngine jsonschema.RegexpEngine = nil
9599
original := &ValidationOptions{
96-
RegexEngine: testEngine,
97-
RegexCache: &sync.Map{},
98-
FormatAssertions: true,
99-
ContentAssertions: true,
100-
SecurityValidation: false,
100+
RegexEngine: testEngine,
101+
RegexCache: &sync.Map{},
102+
FormatAssertions: true,
103+
AllowXMLBodyValidation: true,
104+
ContentAssertions: true,
105+
SecurityValidation: false,
101106
}
102107

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

106111
assert.Nil(t, opts.RegexEngine) // Both should be nil
107112
assert.NotNil(t, opts.RegexCache)
113+
assert.Equal(t, original.AllowXMLBodyValidation, opts.AllowXMLBodyValidation)
108114
assert.Equal(t, original.FormatAssertions, opts.FormatAssertions)
109115
assert.Equal(t, original.ContentAssertions, opts.ContentAssertions)
110116
assert.Equal(t, original.SecurityValidation, opts.SecurityValidation)
@@ -119,8 +125,9 @@ func TestWithExistingOpts_NilSource(t *testing.T) {
119125
assert.False(t, opts.FormatAssertions)
120126
assert.False(t, opts.ContentAssertions)
121127
assert.True(t, opts.SecurityValidation)
122-
assert.True(t, opts.OpenAPIMode) // Default is true
123-
assert.False(t, opts.AllowScalarCoercion) // Default is false
128+
assert.True(t, opts.OpenAPIMode) // Default is true
129+
assert.False(t, opts.AllowScalarCoercion) // Default is false
130+
assert.False(t, opts.AllowXMLBodyValidation) // Default is false
124131
assert.Nil(t, opts.RegexEngine)
125132
assert.Nil(t, opts.RegexCache)
126133
}
@@ -129,11 +136,13 @@ func TestMultipleOptions(t *testing.T) {
129136
opts := NewValidationOptions(
130137
WithFormatAssertions(),
131138
WithContentAssertions(),
139+
WithXmlBodyValidation(),
132140
)
133141

134142
assert.True(t, opts.FormatAssertions)
135143
assert.True(t, opts.ContentAssertions)
136144
assert.True(t, opts.SecurityValidation)
145+
assert.True(t, opts.AllowXMLBodyValidation)
137146
assert.True(t, opts.OpenAPIMode) // Default is true
138147
assert.False(t, opts.AllowScalarCoercion) // Default is false
139148
assert.Nil(t, opts.RegexEngine)

requests/validate_body.go

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
package requests
55

66
import (
7+
"bytes"
8+
"encoding/json"
79
"fmt"
10+
"io"
811
"net/http"
912
"strings"
1013

@@ -14,6 +17,7 @@ import (
1417
"github.com/pb33f/libopenapi-validator/errors"
1518
"github.com/pb33f/libopenapi-validator/helpers"
1619
"github.com/pb33f/libopenapi-validator/paths"
20+
"github.com/pb33f/libopenapi-validator/schema_validation"
1721
)
1822

1923
func (v *requestBodyValidator) ValidateRequestBody(request *http.Request) (bool, []*errors.ValidationError) {
@@ -66,12 +70,6 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
6670
return false, []*errors.ValidationError{errors.RequestContentTypeNotFound(operation, request, pathValue)}
6771
}
6872

69-
// we currently only support JSON validation for request bodies
70-
// this will capture *everything* that contains some form of 'json' in the content type
71-
if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) {
72-
return true, nil
73-
}
74-
7573
// Nothing to validate
7674
if mediaType.Schema == nil {
7775
return true, nil
@@ -80,6 +78,55 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req
8078
// extract schema from media type
8179
schema := mediaType.Schema.Schema()
8280

81+
if !strings.Contains(strings.ToLower(contentType), helpers.JSONType) {
82+
// we currently only support JSON and XML validation for request bodies
83+
// this will capture *everything* that contains some form of 'json' in the content type
84+
if !v.options.AllowXMLBodyValidation || !schema_validation.IsXMLContentType(contentType) {
85+
return true, nil
86+
}
87+
88+
if request != nil && request.Body != nil {
89+
requestBody, _ := io.ReadAll(request.Body)
90+
_ = request.Body.Close()
91+
92+
jsonBody, err := schema_validation.TransformXMLToSchemaJSON(string(requestBody), schema)
93+
if err != nil {
94+
return false, []*errors.ValidationError{{
95+
ValidationType: helpers.RequestBodyValidation,
96+
ValidationSubType: helpers.Schema,
97+
Message: "xml example is malformed",
98+
Reason: fmt.Sprintf("failed to parse xml: %s", err.Error()),
99+
SchemaValidationErrors: []*errors.SchemaValidationFailure{{
100+
Reason: err.Error(),
101+
Location: "xml parsing",
102+
ReferenceSchema: "",
103+
ReferenceObject: string(requestBody),
104+
}},
105+
HowToFix: "ensure xml is well-formed and matches schema structure",
106+
}}
107+
}
108+
109+
transformedBytes, err := json.Marshal(jsonBody)
110+
if err != nil {
111+
return false, []*errors.ValidationError{{
112+
ValidationType: helpers.RequestBodyValidation,
113+
ValidationSubType: helpers.Schema,
114+
Message: "xml example is malformed",
115+
Reason: fmt.Sprintf("failed to parse converted xml to json: %s", err.Error()),
116+
SchemaValidationErrors: []*errors.SchemaValidationFailure{{
117+
Reason: err.Error(),
118+
Location: "xml to json parsing",
119+
ReferenceSchema: "",
120+
ReferenceObject: string(requestBody),
121+
}},
122+
HowToFix: "ensure xml is well-formed and matches schema structure",
123+
}}
124+
}
125+
126+
request.Body = io.NopCloser(bytes.NewBuffer(transformedBytes))
127+
}
128+
}
129+
83130
validationSucceeded, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{
84131
Request: request,
85132
Schema: schema,

requests/validate_body_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/stretchr/testify/require"
1717

1818
"github.com/pb33f/libopenapi-validator/config"
19+
"github.com/pb33f/libopenapi-validator/helpers"
1920
"github.com/pb33f/libopenapi-validator/paths"
2021
)
2122

@@ -1576,3 +1577,121 @@ paths:
15761577
assert.True(t, valid)
15771578
assert.Len(t, errors, 0)
15781579
}
1580+
1581+
func TestValidateBody_XmlRequest(t *testing.T) {
1582+
spec := `openapi: 3.1.0
1583+
paths:
1584+
/burgers/createBurger:
1585+
post:
1586+
requestBody:
1587+
content:
1588+
application/xml:
1589+
schema:
1590+
type: object
1591+
required:
1592+
- name
1593+
properties:
1594+
name:
1595+
type: string
1596+
patties:
1597+
type: integer
1598+
xml:
1599+
name: cost`
1600+
1601+
doc, _ := libopenapi.NewDocument([]byte(spec))
1602+
1603+
m, _ := doc.BuildV3Model()
1604+
v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
1605+
1606+
body := "<name>cheeseburger</name><cost>23</cost>"
1607+
1608+
request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
1609+
bytes.NewBuffer([]byte(body)))
1610+
request.Header.Set("Content-Type", "application/xml")
1611+
1612+
valid, errors := v.ValidateRequestBody(request)
1613+
1614+
assert.True(t, valid)
1615+
assert.Len(t, errors, 0)
1616+
}
1617+
1618+
func TestValidateBody_XmlMalformedRequest(t *testing.T) {
1619+
spec := `openapi: 3.1.0
1620+
paths:
1621+
/burgers/createBurger:
1622+
post:
1623+
requestBody:
1624+
content:
1625+
application/xml:
1626+
schema:
1627+
type: object
1628+
required:
1629+
- name
1630+
properties:
1631+
name:
1632+
type: string
1633+
patties:
1634+
type: integer
1635+
xml:
1636+
name: cost`
1637+
1638+
doc, _ := libopenapi.NewDocument([]byte(spec))
1639+
1640+
m, _ := doc.BuildV3Model()
1641+
v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
1642+
1643+
body := ""
1644+
1645+
request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
1646+
bytes.NewBuffer([]byte(body)))
1647+
request.Header.Set("Content-Type", "application/xml")
1648+
1649+
valid, errors := v.ValidateRequestBody(request)
1650+
1651+
assert.False(t, valid)
1652+
assert.Len(t, errors, 1)
1653+
1654+
err := errors[0]
1655+
assert.Equal(t, helpers.RequestBodyValidation, err.ValidationType)
1656+
assert.Contains(t, err.Reason, "failed to parse xml")
1657+
}
1658+
1659+
func TestValidateBody_XmlRequestTransformations(t *testing.T) {
1660+
spec := `openapi: 3.1.0
1661+
paths:
1662+
/burgers/createBurger:
1663+
post:
1664+
requestBody:
1665+
content:
1666+
application/xml:
1667+
schema:
1668+
type: object
1669+
xml:
1670+
name: Burger
1671+
required:
1672+
- name
1673+
- patties
1674+
properties:
1675+
name:
1676+
type: string
1677+
patties:
1678+
type: integer
1679+
xml:
1680+
name: cost`
1681+
1682+
doc, _ := libopenapi.NewDocument([]byte(spec))
1683+
1684+
m, _ := doc.BuildV3Model()
1685+
v := NewRequestBodyValidator(&m.Model, config.WithXmlBodyValidation())
1686+
1687+
body := "<Burger><name>cheeseburger</name><cost>23</cost></Burger>"
1688+
1689+
request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger",
1690+
bytes.NewBuffer([]byte(body)))
1691+
request.Header.Set("Content-Type", "application/xml")
1692+
1693+
valid, errors := v.ValidateRequestBody(request)
1694+
1695+
assert.True(t, valid)
1696+
assert.Len(t, errors, 0)
1697+
}

0 commit comments

Comments
 (0)