diff --git a/config/config.go b/config/config.go index 1b10724..c41a8ee 100644 --- a/config/config.go +++ b/config/config.go @@ -6,7 +6,9 @@ import "github.com/santhosh-tekuri/jsonschema/v6" // // Generally fluent With... style functions are used to establish the desired behavior. type ValidationOptions struct { - RegexEngine jsonschema.RegexpEngine + RegexEngine jsonschema.RegexpEngine + FormatAssertions bool + ContentAssertions bool } // Option Enables an 'Options pattern' approach @@ -15,11 +17,16 @@ type Option func(*ValidationOptions) // NewValidationOptions creates a new ValidationOptions instance with default values. func NewValidationOptions(opts ...Option) *ValidationOptions { // Create the set of default values - o := &ValidationOptions{} + o := &ValidationOptions{ + FormatAssertions: false, + ContentAssertions: false, + } // Apply any supplied overrides for _, opt := range opts { - opt(o) + if opt != nil { + opt(o) + } } // Done @@ -32,3 +39,17 @@ func WithRegexEngine(engine jsonschema.RegexpEngine) Option { o.RegexEngine = engine } } + +// WithFormatAssertions enables checks for 'format' assertions (such as date, date-time, uuid, etc) +func WithFormatAssertions() Option { + return func(o *ValidationOptions) { + o.FormatAssertions = true + } +} + +// WithContentAssertions enables checks for contentType, contentEncoding, etc +func WithContentAssertions() Option { + return func(o *ValidationOptions) { + o.ContentAssertions = true + } +} diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go new file mode 100644 index 0000000..27310b4 --- /dev/null +++ b/helpers/schema_compiler.go @@ -0,0 +1,74 @@ +package helpers + +import ( + "bytes" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/pb33f/libopenapi-validator/config" +) + +// ConfigureCompiler configures a JSON Schema compiler with the desired behavior. +func ConfigureCompiler(c *jsonschema.Compiler, o *config.ValidationOptions) { + if o == nil { + // Sanity + return + } + + // nil is the default so this is OK. + c.UseRegexpEngine(o.RegexEngine) + + // Enable Format assertions if required. + if o.FormatAssertions { + c.AssertFormat() + } + + // Content Assertions + if o.ContentAssertions { + c.AssertContent() + } +} + +// NewCompilerWithOptions mints a new JSON schema compiler with custom configuration. +func NewCompilerWithOptions(o *config.ValidationOptions) *jsonschema.Compiler { + // Build it + c := jsonschema.NewCompiler() + + // Configure it + ConfigureCompiler(c, o) + + // Return it + return c +} + +// NewCompiledSchema establishes a programmatic representation of a JSON Schema document that is used for validation. +func NewCompiledSchema(name string, jsonSchema []byte, o *config.ValidationOptions) (*jsonschema.Schema, error) { + // Fake-Up a resource name for the schema + resourceName := fmt.Sprintf("%s.json", name) + + // Establish a compiler with the desired configuration + compiler := NewCompilerWithOptions(o) + compiler.UseLoader(NewCompilerLoader()) + + // Decode the JSON Schema into a JSON blob. + decodedSchema, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonSchema)) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON schema: %w", err) + } + + // Give our schema to the compiler. + err = compiler.AddResource(resourceName, decodedSchema) + if err != nil { + return nil, fmt.Errorf("failed to add resource to schema compiler: %w", err) + } + + // Try to compile it. + jsch, err := compiler.Compile(resourceName) + if err != nil { + return nil, fmt.Errorf("failed to compile JSON schema: %w", err) + } + + // Done. + return jsch, nil +} diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go new file mode 100644 index 0000000..a457c89 --- /dev/null +++ b/helpers/schema_compiler_test.go @@ -0,0 +1,113 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pb33f/libopenapi-validator/config" +) + +// A few simple JSON Schemas +const stringSchema = `{ + "type": "string", + "format": "date", + "minLength": 10 +}` + +const objectSchema = `{ + "type": "object", + "title" : "Fish", + "properties" : { + "name" : { + "type": "string", + "description": "The given name of the fish" + }, + "species" : { + "type" : "string", + "enum" : [ "OTHER", "GUPPY", "PIKE", "BASS" ] + } + } +}` + +func Test_SchemaWithNilOptions(t *testing.T) { + jsch, err := NewCompiledSchema("test", []byte(stringSchema), nil) + + require.NoError(t, err, "Failed to compile Schema") + require.NotNil(t, jsch, "Did not return a compiled schema") +} + +func Test_SchemaWithDefaultOptions(t *testing.T) { + valOptions := config.NewValidationOptions() + jsch, err := NewCompiledSchema("test", []byte(stringSchema), valOptions) + + require.NoError(t, err, "Failed to compile Schema") + require.NotNil(t, jsch, "Did not return a compiled schema") +} + +func Test_SchemaWithOptions(t *testing.T) { + valOptions := config.NewValidationOptions(config.WithFormatAssertions(), config.WithContentAssertions()) + + jsch, err := NewCompiledSchema("test", []byte(stringSchema), valOptions) + + require.NoError(t, err, "Failed to compile Schema") + require.NotNil(t, jsch, "Did not return a compiled schema") +} + +func Test_ObjectSchema(t *testing.T) { + valOptions := config.NewValidationOptions() + jsch, err := NewCompiledSchema("test", []byte(objectSchema), valOptions) + + require.NoError(t, err, "Failed to compile Schema") + require.NotNil(t, jsch, "Did not return a compiled schema") +} + +func Test_ValidJSONSchemaWithInvalidContent(t *testing.T) { + // An example of a dubious JSON Schema + const badSchema = `{ + "type": "you-dont-know-me", + "format": "date", + "minLength": 10 +}` + + jsch, err := NewCompiledSchema("test", []byte(badSchema), nil) + + assert.Error(t, err, "Expected an error to be thrown") + assert.Nil(t, jsch, "invalid schema compiled!") +} + +func Test_MalformedSONSchema(t *testing.T) { + // An example of a JSON schema with malformed JSON + const badSchema = `{ + "type": "you-dont-know-me", + "format": "date" + "minLength": 10 +}` + + jsch, err := NewCompiledSchema("test", []byte(badSchema), nil) + + assert.Error(t, err, "Expected an error to be thrown") + assert.Nil(t, jsch, "invalid schema compiled!") +} + +func Test_ValidJSONSchemaWithIncompleteContent(t *testing.T) { + // An example of a dJSON schema with references to non-existent stuff + const incompleteSchema = `{ + "type": "object", + "title" : "unresolvable", + "properties" : { + "name" : { + "type": "string", + }, + "species" : { + "$ref": "#/$defs/speciesEnum" + } + } +}` + + jsch, err := NewCompiledSchema("test", []byte(incompleteSchema), nil) + + assert.Error(t, err, "Expected an error to be thrown") + assert.Nil(t, jsch, "invalid schema compiled!") +} diff --git a/parameters/cookie_parameters.go b/parameters/cookie_parameters.go index c068fcf..3c3f180 100644 --- a/parameters/cookie_parameters.go +++ b/parameters/cookie_parameters.go @@ -93,7 +93,8 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, "The cookie parameter", p.Name, helpers.ParameterValidation, - helpers.ParameterValidationQuery)...) + helpers.ParameterValidationQuery, + v.options)...) } } case helpers.Array: diff --git a/parameters/header_parameters.go b/parameters/header_parameters.go index 2fc4184..4fef7a7 100644 --- a/parameters/header_parameters.go +++ b/parameters/header_parameters.go @@ -115,7 +115,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, "The header parameter", p.Name, helpers.ParameterValidation, - helpers.ParameterValidationQuery)...) + helpers.ParameterValidationQuery, v.options)...) } case helpers.Array: diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go index 41ae277..a0301ca 100644 --- a/parameters/path_parameters.go +++ b/parameters/path_parameters.go @@ -139,6 +139,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p p.Name, helpers.ParameterValidation, helpers.ParameterValidationPath, + v.options, )...) case helpers.Integer, helpers.Number: @@ -161,6 +162,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p p.Name, helpers.ParameterValidation, helpers.ParameterValidationPath, + v.options, )...) case helpers.Boolean: @@ -221,7 +223,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p "The path parameter", p.Name, helpers.ParameterValidation, - helpers.ParameterValidationPath)...) + helpers.ParameterValidationPath, v.options)...) } case helpers.Array: diff --git a/parameters/query_parameters.go b/parameters/query_parameters.go index 940e068..694059d 100644 --- a/parameters/query_parameters.go +++ b/parameters/query_parameters.go @@ -173,7 +173,7 @@ doneLooking: "The query parameter", params[p].Name, helpers.ParameterValidation, - helpers.ParameterValidationQuery)...) + helpers.ParameterValidationQuery, v.options)...) if len(validationErrors) > numErrors { // we've already added an error for this, so we can skip the rest of the values break skipValues @@ -185,7 +185,7 @@ doneLooking: // only check if items is a schema, not a boolean if sch.Items != nil && sch.Items.IsA() { validationErrors = append(validationErrors, - ValidateQueryArray(sch, params[p], ef, contentWrapped)...) + ValidateQueryArray(sch, params[p], ef, contentWrapped, v.options)...) } } } @@ -209,7 +209,7 @@ doneLooking: "The query parameter (which is an array)", params[p].Name, helpers.ParameterValidation, - helpers.ParameterValidationQuery)...) + helpers.ParameterValidationQuery, v.options)...) break doneLooking } } @@ -252,5 +252,6 @@ func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, parameter.Name, helpers.ParameterValidation, helpers.ParameterValidationQuery, + v.options, ) } diff --git a/parameters/query_parameters_test.go b/parameters/query_parameters_test.go index 3a83870..e1398e3 100644 --- a/parameters/query_parameters_test.go +++ b/parameters/query_parameters_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/paths" ) @@ -669,6 +670,110 @@ paths: assert.Len(t, errors, 0) } +func TestNewValidator_QueryParamValidDateFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /a/fishy/on/a/dishy: + get: + parameters: + - name: fishy + in: query + required: true + schema: + type: string + format: date` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/a/fishy/on/a/dishy?fishy=2024-12-25", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestNewValidator_QueryParamInvalidDateFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /a/fishy/on/a/dishy: + get: + parameters: + - name: fishy + in: query + required: true + schema: + type: string + format: date` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/a/fishy/on/a/dishy?fishy=12/25/2024", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.False(t, valid) + assert.Len(t, errors, 1) +} + +func TestNewValidator_QueryParamValidDateTimeFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /a/fishy/on/a/dishy: + get: + parameters: + - name: fishy + in: query + required: true + schema: + type: string + format: date-time` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/a/fishy/on/a/dishy?fishy=2024-12-25T13:42:42Z", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestNewValidator_QueryParamInvalidDateTimeFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /a/fishy/on/a/dishy: + get: + parameters: + - name: fishy + in: query + required: true + schema: + type: string + format: date-time` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/a/fishy/on/a/dishy?fishy=2024-12-25", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.False(t, valid) + assert.Len(t, errors, 1) +} + func TestNewValidator_QueryParamValidTypeArrayString(t *testing.T) { spec := `openapi: 3.1.0 paths: @@ -2528,7 +2633,7 @@ paths: } errs := ValidateParameterSchema(s, rawObject, "cake", "burger", "lemons", - "pizza", "rice", "herbs") + "pizza", "rice", "herbs", nil) assert.Len(t, errs, 1) assert.Equal(t, "lemons 'pizza' is defined as an object, "+ @@ -2772,7 +2877,7 @@ paths: } errs := ValidateParameterSchema(s, rawObject, "cake", "burger", "lemons", - "pizza", "rice", "herbs") + "pizza", "rice", "herbs", nil) assert.Len(t, errs, 1) assert.Equal(t, "lemons 'pizza' is defined as an object, "+ diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 5a5eb8b..c2a5514 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -8,7 +8,6 @@ import ( "fmt" "net/url" "reflect" - "strings" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/utils" @@ -18,6 +17,7 @@ import ( stdError "errors" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" ) @@ -30,9 +30,15 @@ func ValidateSingleParameterSchema( name string, validationType string, subValType string, + o *config.ValidationOptions, ) (validationErrors []*errors.ValidationError) { - jsch := compileSchema(name, buildJsonRender(schema)) + // Attempt to compile the JSON Schema + jsch, err := helpers.NewCompiledSchema(name, buildJsonRender(schema), o) + if err != nil { + return + } + // Validate the object and report any errors. scErrs := jsch.Validate(rawObject) var werras *jsonschema.ValidationError if stdError.As(scErrs, &werras) { @@ -41,16 +47,6 @@ func ValidateSingleParameterSchema( return validationErrors } -// compileSchema create a new json schema compiler and add the schema to it. -func compileSchema(name string, jsonSchema []byte) *jsonschema.Schema { - compiler := jsonschema.NewCompiler() - compiler.UseLoader(helpers.NewCompilerLoader()) - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(jsonSchema))) // decode the schema into a json blob - _ = compiler.AddResource(fmt.Sprintf("%s.json", name), decodedSchema) - jsch, _ := compiler.Compile(fmt.Sprintf("%s.json", name)) - return jsch -} - // buildJsonRender build a JSON render of the schema. func buildJsonRender(schema *base.Schema) []byte { renderedSchema, _ := schema.Render() @@ -78,6 +74,7 @@ func ValidateParameterSchema( name, validationType, subValType string, + validationOptions *config.ValidationOptions, ) []*errors.ValidationError { var validationErrors []*errors.ValidationError @@ -108,11 +105,7 @@ func ValidateParameterSchema( validEncoding = true } // 3. create a new json schema compiler and add the schema to it - compiler := jsonschema.NewCompiler() - - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(jsonSchema))) - _ = compiler.AddResource(fmt.Sprintf("%s.json", name), decodedSchema) - jsch, _ := compiler.Compile(fmt.Sprintf("%s.json", name)) + jsch, _ := helpers.NewCompiledSchema(name, jsonSchema, validationOptions) // 4. validate the object against the schema var scErrs error diff --git a/parameters/validation_functions.go b/parameters/validation_functions.go index 1a3e273..5c63e77 100644 --- a/parameters/validation_functions.go +++ b/parameters/validation_functions.go @@ -12,6 +12,7 @@ import ( "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" ) @@ -98,7 +99,7 @@ func ValidateHeaderArray( // ValidateQueryArray will validate a query parameter that is an array func ValidateQueryArray( - sch *base.Schema, param *v3.Parameter, ef string, contentWrapped bool, + sch *base.Schema, param *v3.Parameter, ef string, contentWrapped bool, validationOptions *config.ValidationOptions, ) []*errors.ValidationError { var validationErrors []*errors.ValidationError itemsSchema := sch.Items.A.Schema() @@ -174,7 +175,7 @@ func ValidateQueryArray( "The query parameter (which is an array)", param.Name, helpers.ParameterValidation, - helpers.ParameterValidationQuery)...) + helpers.ParameterValidationQuery, validationOptions)...) case helpers.String: diff --git a/requests/validate_request.go b/requests/validate_request.go index 1e0eace..b283643 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -12,7 +12,6 @@ import ( "reflect" "regexp" "strconv" - "strings" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/santhosh-tekuri/jsonschema/v6" @@ -37,7 +36,7 @@ func ValidateRequestSchema( jsonSchema []byte, opts ...config.Option, ) (bool, []*errors.ValidationError) { - options := config.NewValidationOptions(opts...) + validationOptions := config.NewValidationOptions(opts...) var validationErrors []*errors.ValidationError @@ -110,12 +109,8 @@ func ValidateRequestSchema( return false, validationErrors } - compiler := jsonschema.NewCompiler() - compiler.UseRegexpEngine(options.RegexEngine) // Ensure any configured regex engine is used. - compiler.UseLoader(helpers.NewCompilerLoader()) - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(jsonSchema))) - _ = compiler.AddResource("requestBody.json", decodedSchema) - jsch, err := compiler.Compile("requestBody.json") + // Attempt to compile the JSON schema + jsch, err := helpers.NewCompiledSchema("requestBody", jsonSchema, validationOptions) if err != nil { validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.RequestBodyValidation, diff --git a/responses/validate_response.go b/responses/validate_response.go index a93fe7b..2c0a15c 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -12,7 +12,6 @@ import ( "reflect" "regexp" "strconv" - "strings" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/santhosh-tekuri/jsonschema/v6" @@ -129,13 +128,7 @@ func ValidateResponseSchema( } // create a new jsonschema compiler and add in the rendered JSON schema. - compiler := jsonschema.NewCompiler() - compiler.UseRegexpEngine(options.RegexEngine) - compiler.UseLoader(helpers.NewCompilerLoader()) - fName := fmt.Sprintf("%s.json", helpers.ResponseBodyValidation) - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(jsonSchema))) - _ = compiler.AddResource(fName, decodedSchema) - jsch, _ := compiler.Compile(fName) + jsch, _ := helpers.NewCompiledSchema(helpers.ResponseBodyValidation, jsonSchema, options) // validate the object against the schema scErrs := jsch.Validate(decodedObj) diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index 4a72afd..1c430ad 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -6,7 +6,6 @@ package schema_validation import ( "errors" "fmt" - "strings" "github.com/pb33f/libopenapi" "github.com/santhosh-tekuri/jsonschema/v6" @@ -29,15 +28,10 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo var validationErrors []*liberrors.ValidationError decodedDocument := *info.SpecJSON - compiler := jsonschema.NewCompiler() - compiler.UseRegexpEngine(options.RegexEngine) - compiler.UseLoader(helpers.NewCompilerLoader()) - - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(loadedSchema))) - - _ = compiler.AddResource("schema.json", decodedSchema) - jsch, _ := compiler.Compile("schema.json") + // Compile the JSON Schema + jsch, _ := helpers.NewCompiledSchema("schema", []byte(loadedSchema), options) + // Validate the document scErrs := jsch.Validate(decodedDocument) var schemaValidationErrors []*liberrors.SchemaValidationFailure diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 6dec2a6..8691bf2 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -12,7 +12,6 @@ import ( "reflect" "regexp" "strconv" - "strings" "sync" "github.com/pb33f/libopenapi/datamodel/high/base" @@ -128,13 +127,9 @@ func (s *schemaValidator) validateSchema(schema *base.Schema, payload []byte, de } } - compiler := jsonschema.NewCompiler() - compiler.UseRegexpEngine(s.options.RegexEngine) - compiler.UseLoader(helpers.NewCompilerLoader()) - decodedSchema, _ := jsonschema.UnmarshalJSON(strings.NewReader(string(jsonSchema))) - _ = compiler.AddResource("schema.json", decodedSchema) - jsch, err := compiler.Compile("schema.json") + // Build the compiled JSON Schema + jsch, err := helpers.NewCompiledSchema("schema", jsonSchema, s.options) var schemaValidationErrors []*liberrors.SchemaValidationFailure