Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
17bc42a
added new strict module
daveshanley Dec 29, 2025
f1855c2
added logger and strict mode config details.
daveshanley Dec 29, 2025
6289bd9
Added strict errors
daveshanley Dec 29, 2025
4f7f328
added 3.2 to vocab
daveshanley Dec 29, 2025
8238c12
add strict mode to params
daveshanley Dec 29, 2025
33163c5
added strict mode support to responses
daveshanley Dec 29, 2025
0e138c0
added strict mode to requests
daveshanley Dec 29, 2025
b979adc
cleaned up compiler vocab
daveshanley Dec 29, 2025
586ed4e
validator tests
daveshanley Dec 29, 2025
b8a1450
flaky test
daveshanley Dec 29, 2025
9ae454c
Address #210
daveshanley Dec 29, 2025
6ca5d65
Address issue #136
daveshanley Dec 29, 2025
b718f1e
Address issue #181
daveshanley Dec 29, 2025
5638cc3
Address #183
daveshanley Dec 29, 2025
28f54d6
fixing more headers
daveshanley Dec 29, 2025
615655f
bump coverage
daveshanley Dec 29, 2025
72d4b2d
Address #184 and #183
daveshanley Dec 29, 2025
35bf259
Fix #192
daveshanley Dec 29, 2025
ec7fb03
fixed #192
daveshanley Dec 29, 2025
a0db03d
bumping coverage
daveshanley Dec 29, 2025
96f9bd7
more test coverage
daveshanley Dec 29, 2025
d2c3df4
MOAR COVERAGE
daveshanley Dec 30, 2025
3889869
bumping coverage
daveshanley Dec 30, 2025
90d43bc
fixed inting issues
daveshanley Dec 30, 2025
5ff35a6
MOAR COVERAGE!
daveshanley Dec 30, 2025
6f16aea
cleaning things up, adding coverage.
daveshanley Dec 30, 2025
3433268
continuing the slog up code coverage hill.
daveshanley Dec 30, 2025
bf76706
cleaning up code, fixing test coverage.
daveshanley Dec 30, 2025
d63c3bb
I think this is as high as I can push it (coverage)
daveshanley Dec 30, 2025
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
102 changes: 98 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package config

import (
"log/slog"

"github.com/santhosh-tekuri/jsonschema/v6"

"github.com/pb33f/libopenapi-validator/cache"
Expand Down Expand Up @@ -28,14 +30,21 @@ type ValidationOptions struct {
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)

// strict mode options - detect undeclared properties even when additionalProperties: true
StrictMode bool // Enable strict property validation
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
}

// Option Enables an 'Options pattern' approach
type Option func(*ValidationOptions)

// NewValidationOptions creates a new ValidationOptions instance with default values.
func NewValidationOptions(opts ...Option) *ValidationOptions {
// Create the set of default values
// create the set of default values
o := &ValidationOptions{
FormatAssertions: false,
ContentAssertions: false,
Expand All @@ -44,14 +53,11 @@ func NewValidationOptions(opts ...Option) *ValidationOptions {
SchemaCache: cache.NewDefaultCache(), // Enable caching by default
}

// Apply any supplied overrides
for _, opt := range opts {
if opt != nil {
opt(o)
}
}

// Done
return o
}

Expand All @@ -68,10 +74,23 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.Logger = options.Logger
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
o.StrictIgnoredHeaders = options.StrictIgnoredHeaders
o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge
}
}
}

// WithLogger sets the logger for validation debug/error output.
// If not set, logging is silent (nil logger is handled gracefully).
func WithLogger(logger *slog.Logger) Option {
return func(o *ValidationOptions) {
o.Logger = logger
}
}

// WithRegexEngine Assigns a custom regular-expression engine to be used during validation.
func WithRegexEngine(engine jsonschema.RegexpEngine) Option {
return func(o *ValidationOptions) {
Expand Down Expand Up @@ -150,3 +169,78 @@ func WithSchemaCache(cache cache.SchemaCache) Option {
o.SchemaCache = cache
}
}

// WithStrictMode enables strict property validation.
// In strict mode, undeclared properties are reported as errors even when
// additionalProperties: true would normally allow them.
//
// This is useful for API governance scenarios where you want to ensure
// clients only send properties that are explicitly documented in the
// OpenAPI specification.
func WithStrictMode() Option {
return func(o *ValidationOptions) {
o.StrictMode = true
}
}

// WithStrictIgnorePaths sets JSONPath patterns for paths to exclude from strict validation.
// Patterns use glob syntax:
// - * matches a single path segment
// - ** matches any depth (zero or more segments)
// - [*] matches any array index
// - \* escapes a literal asterisk
//
// Examples:
// - "$.body.metadata.*" - any property under metadata
// - "$.body.**.x-*" - any x-* property at any depth
// - "$.headers.X-*" - any header starting with X-
func WithStrictIgnorePaths(paths ...string) Option {
return func(o *ValidationOptions) {
o.StrictIgnorePaths = paths
}
}

// 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.
func WithStrictIgnoredHeaders(headers ...string) Option {
return func(o *ValidationOptions) {
o.StrictIgnoredHeaders = headers
o.strictIgnoredHeadersMerge = false
}
}

// WithStrictIgnoredHeadersExtra adds headers to the default ignored list.
// Unlike WithStrictIgnoredHeaders, this merges with the defaults rather
// than replacing them.
func WithStrictIgnoredHeadersExtra(headers ...string) Option {
return func(o *ValidationOptions) {
o.StrictIgnoredHeaders = headers
o.strictIgnoredHeadersMerge = true
}
}

// defaultIgnoredHeaders contains standard HTTP headers ignored by default.
// This is the fallback list used when no custom headers are configured.
var defaultIgnoredHeaders = []string{
"content-type", "content-length", "accept", "authorization",
"user-agent", "host", "connection", "accept-encoding",
"accept-language", "cache-control", "pragma", "origin",
"referer", "cookie", "date", "etag", "expires",
"if-match", "if-none-match", "if-modified-since",
"last-modified", "transfer-encoding", "vary", "x-forwarded-for",
"x-forwarded-proto", "x-real-ip", "x-request-id",
}

// GetEffectiveStrictIgnoredHeaders returns the list of headers to ignore
// based on configuration. Returns defaults if not configured, merged list
// if extra headers were added, or replaced list if headers were fully replaced.
func (o *ValidationOptions) GetEffectiveStrictIgnoredHeaders() []string {
if o.StrictIgnoredHeaders == nil {
return defaultIgnoredHeaders
}
if o.strictIgnoredHeadersMerge {
return append(defaultIgnoredHeaders, o.StrictIgnoredHeaders...)
}
return o.StrictIgnoredHeaders
}
103 changes: 103 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package config

import (
"log/slog"
"sync"
"testing"

Expand Down Expand Up @@ -368,3 +369,105 @@ func TestWithRegexpCache(t *testing.T) {

assert.NotNil(t, opts.RegexCache)
}

// Tests for strict mode configuration options

func TestWithStrictMode(t *testing.T) {
opts := NewValidationOptions(WithStrictMode())

assert.True(t, opts.StrictMode)
assert.Nil(t, opts.StrictIgnorePaths)
assert.Nil(t, opts.StrictIgnoredHeaders)
}

func TestWithStrictIgnorePaths(t *testing.T) {
paths := []string{"$.body.metadata.*", "$.headers.X-*"}
opts := NewValidationOptions(WithStrictIgnorePaths(paths...))

assert.Equal(t, paths, opts.StrictIgnorePaths)
assert.False(t, opts.StrictMode) // Not enabled by default
}

func TestWithStrictIgnoredHeaders(t *testing.T) {
headers := []string{"x-custom-header", "x-another-header"}
opts := NewValidationOptions(WithStrictIgnoredHeaders(headers...))

assert.Equal(t, headers, opts.StrictIgnoredHeaders)
assert.False(t, opts.strictIgnoredHeadersMerge)
}

func TestWithStrictIgnoredHeadersExtra(t *testing.T) {
headers := []string{"x-extra-header"}
opts := NewValidationOptions(WithStrictIgnoredHeadersExtra(headers...))

assert.Equal(t, headers, opts.StrictIgnoredHeaders)
assert.True(t, opts.strictIgnoredHeadersMerge)
}

func TestGetEffectiveStrictIgnoredHeaders_Default(t *testing.T) {
opts := NewValidationOptions()

headers := opts.GetEffectiveStrictIgnoredHeaders()

assert.NotNil(t, headers)
assert.Contains(t, headers, "content-type")
assert.Contains(t, headers, "authorization")
}

func TestGetEffectiveStrictIgnoredHeaders_Replace(t *testing.T) {
customHeaders := []string{"x-only-this"}
opts := NewValidationOptions(WithStrictIgnoredHeaders(customHeaders...))

headers := opts.GetEffectiveStrictIgnoredHeaders()

assert.Equal(t, customHeaders, headers)
assert.NotContains(t, headers, "content-type") // Default headers are replaced
}

func TestGetEffectiveStrictIgnoredHeaders_Merge(t *testing.T) {
extraHeaders := []string{"x-extra-header"}
opts := NewValidationOptions(WithStrictIgnoredHeadersExtra(extraHeaders...))

headers := opts.GetEffectiveStrictIgnoredHeaders()

// Should have both defaults and extras
assert.Contains(t, headers, "content-type") // From defaults
assert.Contains(t, headers, "x-extra-header") // From extras
assert.Contains(t, headers, "authorization") // From defaults
}

func TestWithLogger(t *testing.T) {
logger := slog.New(slog.NewTextHandler(nil, nil))
opts := NewValidationOptions(WithLogger(logger))

assert.Equal(t, logger, opts.Logger)
}

func TestWithExistingOpts_StrictFields(t *testing.T) {
original := &ValidationOptions{
StrictMode: true,
StrictIgnorePaths: []string{"$.body.*"},
StrictIgnoredHeaders: []string{"x-custom"},
strictIgnoredHeadersMerge: true,
Logger: slog.New(slog.NewTextHandler(nil, nil)),
}

opts := NewValidationOptions(WithExistingOpts(original))

assert.True(t, opts.StrictMode)
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 TestStrictModeWithIgnorePaths(t *testing.T) {
paths := []string{"$.body.metadata.*"}
opts := NewValidationOptions(
WithStrictMode(),
WithStrictIgnorePaths(paths...),
)

assert.True(t, opts.StrictMode)
assert.Equal(t, paths, opts.StrictIgnorePaths)
}
13 changes: 13 additions & 0 deletions errors/parameter_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ func HeaderParameterMissing(param *v3.Parameter) *ValidationError {
}
}

func CookieParameterMissing(param *v3.Parameter) *ValidationError {
return &ValidationError{
ValidationType: helpers.ParameterValidation,
ValidationSubType: helpers.ParameterValidationCookie,
Message: fmt.Sprintf("Cookie parameter '%s' is missing", param.Name),
Reason: fmt.Sprintf("The cookie parameter '%s' is defined as being required, "+
"however it's missing from the request", param.Name),
SpecLine: param.GoLow().Required.KeyNode.Line,
SpecCol: param.GoLow().Required.KeyNode.Column,
HowToFix: HowToFixMissingValue,
}
}

func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string) *ValidationError {
return &ValidationError{
ValidationType: helpers.ParameterValidation,
Expand Down
15 changes: 15 additions & 0 deletions errors/parameter_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ func TestHeaderParameterMissing(t *testing.T) {
require.Equal(t, HowToFixMissingValue, err.HowToFix)
}

func TestCookieParameterMissing(t *testing.T) {
param := createMockParameterWithSchema()

// Call the function
err := CookieParameterMissing(param)

// Validate the error
require.NotNil(t, err)
require.Equal(t, helpers.ParameterValidation, err.ValidationType)
require.Equal(t, helpers.ParameterValidationCookie, err.ValidationSubType)
require.Contains(t, err.Message, "Cookie parameter 'testParam' is missing")
require.Contains(t, err.Reason, "'testParam' is defined as being required")
require.Equal(t, HowToFixMissingValue, err.HowToFix)
}

func TestHeaderParameterCannotBeDecoded(t *testing.T) {
param := createMockParameterWithSchema()
val := "malformed_header_value"
Expand Down
Loading