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
22 changes: 22 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type ValidationOptions struct {
StrictIgnorePaths []string // Instance JSONPath patterns to exclude from strict checks
StrictIgnoredHeaders []string // Headers to always ignore in strict mode (nil = use defaults)
strictIgnoredHeadersMerge bool // Internal: true if merging with defaults
StrictRejectReadOnly bool // Reject readOnly properties in requests
StrictRejectWriteOnly bool // Reject writeOnly properties in responses
}

// Option Enables an 'Options pattern' approach
Expand Down Expand Up @@ -88,6 +90,8 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.StrictIgnorePaths = options.StrictIgnorePaths
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge
o.StrictRejectReadOnly = options.StrictRejectReadOnly
o.StrictRejectWriteOnly = options.StrictRejectWriteOnly
}
}
}
Expand Down Expand Up @@ -241,6 +245,24 @@ func WithStrictIgnorePaths(paths ...string) Option {
}
}

// WithStrictRejectReadOnly enables rejection of readOnly properties in requests.
// When enabled, readOnly properties present in request bodies are reported as
// validation errors instead of being silently skipped.
func WithStrictRejectReadOnly() Option {
return func(o *ValidationOptions) {
o.StrictRejectReadOnly = true
}
}

// WithStrictRejectWriteOnly enables rejection of writeOnly properties in responses.
// When enabled, writeOnly properties present in response bodies are reported as
// validation errors instead of being silently skipped.
func WithStrictRejectWriteOnly() Option {
return func(o *ValidationOptions) {
o.StrictRejectWriteOnly = true
}
}

// WithStrictIgnoredHeaders replaces the default ignored headers list entirely.
// Use this to fully control which headers are ignored in strict mode.
// For the default list, see the strict package's DefaultIgnoredHeaders.
Expand Down
14 changes: 14 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,8 @@ func TestWithLogger(t *testing.T) {
func TestWithExistingOpts_StrictFields(t *testing.T) {
original := &ValidationOptions{
StrictMode: true,
StrictRejectReadOnly: true,
StrictRejectWriteOnly: true,
StrictIgnorePaths: []string{"$.body.*"},
StrictIgnoredHeaders: []string{"x-custom"},
strictIgnoredHeadersMerge: true,
Expand All @@ -475,12 +477,24 @@ func TestWithExistingOpts_StrictFields(t *testing.T) {
opts := NewValidationOptions(WithExistingOpts(original))

assert.True(t, opts.StrictMode)
assert.True(t, opts.StrictRejectReadOnly)
assert.True(t, opts.StrictRejectWriteOnly)
assert.Equal(t, original.StrictIgnorePaths, opts.StrictIgnorePaths)
assert.Equal(t, original.StrictIgnoredHeaders, opts.StrictIgnoredHeaders)
assert.True(t, opts.strictIgnoredHeadersMerge)
assert.Equal(t, original.Logger, opts.Logger)
}

func TestWithStrictRejectReadOnly(t *testing.T) {
opts := NewValidationOptions(WithStrictRejectReadOnly())
assert.True(t, opts.StrictRejectReadOnly)
}

func TestWithStrictRejectWriteOnly(t *testing.T) {
opts := NewValidationOptions(WithStrictRejectWriteOnly())
assert.True(t, opts.StrictRejectWriteOnly)
}

func TestStrictModeWithIgnorePaths(t *testing.T) {
paths := []string{"$.body.metadata.*"}
opts := NewValidationOptions(
Expand Down
68 changes: 63 additions & 5 deletions errors/strict_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import (
// StrictValidationType is the validation type for strict mode errors.
const StrictValidationType = "strict"

// StrictValidationSubTypes for different kinds of undeclared values.
// StrictValidationSubTypes for different kinds of strict validation errors.
const (
StrictSubTypeProperty = "undeclared-property"
StrictSubTypeHeader = "undeclared-header"
StrictSubTypeQuery = "undeclared-query-param"
StrictSubTypeCookie = "undeclared-cookie"
StrictSubTypeProperty = "undeclared-property"
StrictSubTypeHeader = "undeclared-header"
StrictSubTypeQuery = "undeclared-query-param"
StrictSubTypeCookie = "undeclared-cookie"
StrictSubTypeReadOnlyProperty = "readonly-property"
StrictSubTypeWriteOnlyProperty = "writeonly-property"
)

// UndeclaredPropertyError creates a ValidationError for an undeclared property.
Expand Down Expand Up @@ -132,6 +134,62 @@ func UndeclaredCookieError(
}
}

// ReadOnlyPropertyError creates a ValidationError for a readOnly property in a request.
func ReadOnlyPropertyError(
path string,
name string,
value any,
requestPath string,
requestMethod string,
specLine int,
specCol int,
) *ValidationError {
return &ValidationError{
ValidationType: StrictValidationType,
ValidationSubType: StrictSubTypeReadOnlyProperty,
Message: fmt.Sprintf("request property '%s' at '%s' is readOnly and should not be sent in the request",
name, path),
Reason: fmt.Sprintf("Strict mode: property '%s' is marked readOnly in the schema",
name),
HowToFix: fmt.Sprintf("Remove the readOnly annotation from '%s' in the schema, "+
"remove it from the request, or add '%s' to StrictIgnorePaths", name, path),
RequestPath: requestPath,
RequestMethod: requestMethod,
ParameterName: name,
Context: truncateForContext(value),
SpecLine: specLine,
SpecCol: specCol,
}
}

// WriteOnlyPropertyError creates a ValidationError for a writeOnly property in a response.
func WriteOnlyPropertyError(
path string,
name string,
value any,
requestPath string,
requestMethod string,
specLine int,
specCol int,
) *ValidationError {
return &ValidationError{
ValidationType: StrictValidationType,
ValidationSubType: StrictSubTypeWriteOnlyProperty,
Message: fmt.Sprintf("response property '%s' at '%s' is writeOnly and should not be returned in the response",
name, path),
Reason: fmt.Sprintf("Strict mode: property '%s' is marked writeOnly in the schema",
name),
HowToFix: fmt.Sprintf("Remove the writeOnly annotation from '%s' in the schema, "+
"remove it from the response, or add '%s' to StrictIgnorePaths", name, path),
RequestPath: requestPath,
RequestMethod: requestMethod,
ParameterName: name,
Context: truncateForContext(value),
SpecLine: specLine,
SpecCol: specCol,
}
}

// truncateForContext creates a truncated string representation for error context.
func truncateForContext(v any) string {
switch val := v.(type) {
Expand Down
52 changes: 52 additions & 0 deletions errors/strict_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,58 @@ func TestUndeclaredCookieError(t *testing.T) {
assert.Equal(t, "tracking", err.ParameterName)
}

func TestReadOnlyPropertyError(t *testing.T) {
err := ReadOnlyPropertyError(
"$.body.id",
"id",
"user-123",
"/users",
"POST",
10,
5,
)

assert.NotNil(t, err)
assert.Equal(t, StrictValidationType, err.ValidationType)
assert.Equal(t, StrictSubTypeReadOnlyProperty, err.ValidationSubType)
assert.Contains(t, err.Message, "readOnly")
assert.Contains(t, err.Message, "'id'")
assert.Contains(t, err.Message, "'$.body.id'")
assert.Contains(t, err.Reason, "readOnly")
assert.Contains(t, err.HowToFix, "id")
assert.Equal(t, "/users", err.RequestPath)
assert.Equal(t, "POST", err.RequestMethod)
assert.Equal(t, "id", err.ParameterName)
assert.Equal(t, 10, err.SpecLine)
assert.Equal(t, 5, err.SpecCol)
}

func TestWriteOnlyPropertyError(t *testing.T) {
err := WriteOnlyPropertyError(
"$.body.password",
"password",
"secret",
"/users/123",
"GET",
20,
3,
)

assert.NotNil(t, err)
assert.Equal(t, StrictValidationType, err.ValidationType)
assert.Equal(t, StrictSubTypeWriteOnlyProperty, err.ValidationSubType)
assert.Contains(t, err.Message, "writeOnly")
assert.Contains(t, err.Message, "'password'")
assert.Contains(t, err.Message, "'$.body.password'")
assert.Contains(t, err.Reason, "writeOnly")
assert.Contains(t, err.HowToFix, "password")
assert.Equal(t, "/users/123", err.RequestPath)
assert.Equal(t, "GET", err.RequestMethod)
assert.Equal(t, "password", err.ParameterName)
assert.Equal(t, 20, err.SpecLine)
assert.Equal(t, 3, err.SpecCol)
}

func TestTruncateForContext_String(t *testing.T) {
// Short string should not be truncated
short := truncateForContext("short")
Expand Down
44 changes: 44 additions & 0 deletions requests/validate_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,50 @@ paths:
assert.Len(t, errors, 0)
}

func TestValidateBody_StrictMode_ReadOnlyProperty(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/users:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
id:
type: string
readOnly: true
name:
type: string`

doc, _ := libopenapi.NewDocument([]byte(spec))

m, _ := doc.BuildV3Model()
v := NewRequestBodyValidator(&m.Model,
config.WithStrictMode(),
config.WithStrictRejectReadOnly(),
)

body := map[string]interface{}{
"id": "user-123",
"name": "John",
}

bodyBytes, _ := json.Marshal(body)

request, _ := http.NewRequest(http.MethodPost, "https://things.com/users",
bytes.NewBuffer(bodyBytes))
request.Header.Set("Content-Type", "application/json")

valid, errors := v.ValidateRequestBody(request)

assert.False(t, valid)
assert.Len(t, errors, 1)
assert.Contains(t, errors[0].Message, "readOnly")
assert.Contains(t, errors[0].Message, "id")
}

func TestValidateRequestBody_XMLMarshalError(t *testing.T) {
spec := []byte(`
openapi: 3.1.0
Expand Down
34 changes: 22 additions & 12 deletions requests/validate_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,18 +332,28 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V

if !strictResult.Valid {
for _, undeclared := range strictResult.UndeclaredValues {
validationErrors = append(validationErrors,
errors.UndeclaredPropertyError(
undeclared.Path,
undeclared.Name,
undeclared.Value,
undeclared.DeclaredProperties,
undeclared.Direction.String(),
request.URL.Path,
request.Method,
undeclared.SpecLine,
undeclared.SpecCol,
))
switch undeclared.Type {
case strict.TypeReadOnlyProperty:
validationErrors = append(validationErrors,
errors.ReadOnlyPropertyError(
undeclared.Path, undeclared.Name, undeclared.Value,
request.URL.Path, request.Method,
undeclared.SpecLine, undeclared.SpecCol,
))
default:
validationErrors = append(validationErrors,
errors.UndeclaredPropertyError(
undeclared.Path,
undeclared.Name,
undeclared.Value,
undeclared.DeclaredProperties,
undeclared.Direction.String(),
request.URL.Path,
request.Method,
undeclared.SpecLine,
undeclared.SpecCol,
))
}
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions responses/validate_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,50 @@ paths:
assert.Len(t, errs, 0)
}

func TestValidateBody_StrictMode_WriteOnlyProperty(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/users/123:
get:
responses:
'200':
content:
application/json:
schema:
type: object
properties:
name:
type: string
password:
type: string
writeOnly: true`

doc, _ := libopenapi.NewDocument([]byte(spec))

m, _ := doc.BuildV3Model()
v := NewResponseBodyValidator(&m.Model,
config.WithStrictMode(),
config.WithStrictRejectWriteOnly(),
)

request, _ := http.NewRequest(http.MethodGet, "https://things.com/users/123", nil)

responseBody := `{"name": "John", "password": "secret"}`
response := &http.Response{
Header: http.Header{},
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(responseBody)),
}
response.Header.Set("Content-Type", "application/json")

valid, errs := v.ValidateResponseBody(request, response)

assert.False(t, valid)
assert.Len(t, errs, 1)
assert.Contains(t, errs[0].Message, "writeOnly")
assert.Contains(t, errs[0].Message, "password")
}

func TestValidateBody_ValidURLEncodedBody(t *testing.T) {
spec := `openapi: 3.1.0
paths:
Expand Down
Loading
Loading