diff --git a/config/config.go b/config/config.go index 40f2d0b..eb324e1 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ type ValidationOptions struct { RegexEngine jsonschema.RegexpEngine FormatAssertions bool ContentAssertions bool + Formats map[string]func(v any) error } // Option Enables an 'Options pattern' approach @@ -39,6 +40,7 @@ func WithExistingOpts(options *ValidationOptions) Option { o.RegexEngine = options.RegexEngine o.FormatAssertions = options.FormatAssertions o.ContentAssertions = options.ContentAssertions + o.Formats = options.Formats } } @@ -62,3 +64,16 @@ func WithContentAssertions() Option { o.ContentAssertions = true } } + +// WithCustomFormat adds custom formats and their validators that checks for custom 'format' assertions +// When you add different validators with the same name, they will be overridden, +// and only the last registration will take effect. +func WithCustomFormat(name string, validator func(v any) error) Option { + return func(o *ValidationOptions) { + if o.Formats == nil { + o.Formats = make(map[string]func(v any) error) + } + + o.Formats[name] = validator + } +} diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go index 5021eac..cb71068 100644 --- a/helpers/schema_compiler.go +++ b/helpers/schema_compiler.go @@ -28,6 +28,14 @@ func ConfigureCompiler(c *jsonschema.Compiler, o *config.ValidationOptions) { if o.ContentAssertions { c.AssertContent() } + + // Register custom formats + for n, v := range o.Formats { + c.RegisterFormat(&jsonschema.Format{ + Name: n, + Validate: v, + }) + } } // NewCompilerWithOptions mints a new JSON schema compiler with custom configuration. diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go index a457c89..c11a53b 100644 --- a/helpers/schema_compiler_test.go +++ b/helpers/schema_compiler_test.go @@ -1,7 +1,9 @@ package helpers import ( + "fmt" "testing" + "unicode" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,6 +25,11 @@ const objectSchema = `{ "name" : { "type": "string", "description": "The given name of the fish" + }, + "name" : { + "type": "string", + "format": "capital", + "description": "The given name of the fish" }, "species" : { "type" : "string", @@ -47,7 +54,28 @@ func Test_SchemaWithDefaultOptions(t *testing.T) { } func Test_SchemaWithOptions(t *testing.T) { - valOptions := config.NewValidationOptions(config.WithFormatAssertions(), config.WithContentAssertions()) + valOptions := config.NewValidationOptions( + config.WithFormatAssertions(), + config.WithContentAssertions(), + config.WithCustomFormat("capital", func(v any) error { + s, ok := v.(string) + if !ok { + return fmt.Errorf("expected string") + } + + if s == "" { + return nil + } + + r := []rune(s)[0] + + if !unicode.IsUpper(r) { + return fmt.Errorf("expected first latter to be uppercase") + } + + return nil + }), + ) jsch, err := NewCompiledSchema("test", []byte(stringSchema), valOptions) diff --git a/validator_test.go b/validator_test.go index 2f3c980..2888555 100644 --- a/validator_test.go +++ b/validator_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "unicode" "github.com/dlclark/regexp2" "github.com/pb33f/libopenapi" @@ -175,6 +176,212 @@ func TestNewValidator_WithRegex(t *testing.T) { assert.Empty(t, valErrs) } +func TestNewValidator_WithCustomFormat_NoErrors(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + format: capital + patties: + type: integer + vegetarian: + type: boolean` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err, "Failed to load spec") + require.NotNil(t, doc, "Failed to load spec") + + v, errs := NewValidator( + doc, + config.WithFormatAssertions(), + config.WithCustomFormat("capital", func(v any) error { + s, ok := v.(string) + if !ok { + return fmt.Errorf("expected string") + } + + if s == "" { + return nil + } + + r := []rune(s)[0] + + if !unicode.IsUpper(r) { + return fmt.Errorf("expected first latter to be uppercase") + } + + return nil + }), + ) + require.Empty(t, errs, "Failed to build validator") + require.NotNil(t, v, "Failed to build validator") + + body := map[string]interface{}{ + "name": "Big Mac", + "patties": 2, + "vegetarian": true, + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateHttpRequest(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_WithCustomFormat_FormatError(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + format: capital + patties: + type: integer + vegetarian: + type: boolean` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err, "Failed to load spec") + require.NotNil(t, doc, "Failed to load spec") + + v, errs := NewValidator( + doc, + config.WithFormatAssertions(), + config.WithCustomFormat("capital", func(v any) error { + s, ok := v.(string) + if !ok { + return fmt.Errorf("expected string") + } + + if s == "" { + return nil + } + + r := []rune(s)[0] + + if !unicode.IsUpper(r) { + return fmt.Errorf("expected first latter to be uppercase") + } + + return nil + }), + ) + require.Empty(t, errs, "Failed to build validator") + require.NotNil(t, v, "Failed to build validator") + + v.GetRequestBodyValidator() + + body := map[string]interface{}{ + "name": "big mac", + "patties": 2, + "vegetarian": true, + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateHttpRequest(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "POST request body for '/burgers/createBurger' failed to validate schema", errors[0].Message) + require.Len(t, errors[0].SchemaValidationErrors, 1) + require.NotNil(t, errors[0].SchemaValidationErrors[0]) + assert.Equal(t, "/properties/name/format", errors[0].SchemaValidationErrors[0].Location) + assert.Equal(t, "'big mac' is not valid capital: expected first latter to be uppercase", errors[0].SchemaValidationErrors[0].Reason) +} + +func TestNewValidator_WithCustomFormat_NoErrorsWhenFormatAssertionDisabled(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + format: capital + patties: + type: integer + vegetarian: + type: boolean` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err, "Failed to load spec") + require.NotNil(t, doc, "Failed to load spec") + + v, errs := NewValidator( + doc, + config.WithCustomFormat("capital", func(v any) error { + s, ok := v.(string) + if !ok { + return fmt.Errorf("expected string") + } + + if s == "" { + return nil + } + + r := []rune(s)[0] + + if !unicode.IsUpper(r) { + return fmt.Errorf("expected first latter to be uppercase") + } + + return nil + }), + ) + require.Empty(t, errs, "Failed to build validator") + require.NotNil(t, v, "Failed to build validator") + + v.GetRequestBodyValidator() + + body := map[string]interface{}{ + "name": "big mac", + "patties": 2, + "vegetarian": true, + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateHttpRequest(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + func TestNewValidator_BadDoc(t *testing.T) { spec := `swagger: 2.0`