From 96f4c5e81a849fb42f759017b60baf9bf824aa2f Mon Sep 17 00:00:00 2001 From: Christian Boitel Date: Mon, 19 Dec 2022 14:05:42 +0100 Subject: [PATCH 1/5] feat #582: implement openapi version validation --- .github/docs/openapi3.txt | 4 ++ openapi3/loader_paths_test.go | 2 +- openapi3/openapi3.go | 12 ++-- openapi3/openapi3_test.go | 10 ++-- openapi3/testdata/issue753.yml | 2 +- openapi3/validation_options.go | 15 +++++ openapi3/version.go | 43 ++++++++++++++ openapi3/version_test.go | 101 +++++++++++++++++++++++++++++++++ 8 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 openapi3/version.go create mode 100644 openapi3/version_test.go diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 82a5e0bc8..c162a667c 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -3,6 +3,7 @@ const TypeArray = "array" ... const FormatOfStringForUUIDOfRFC4122 = `^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$` ... const SerializationSimple = "simple" ... var SchemaErrorDetailsDisabled = false ... +var ErrInvalidVersion ... var CircularReferenceCounter = 3 var CircularReferenceError = "kin-openapi bug found: circular schema reference not handled" var DefaultReadFromURI = URIMapCache(ReadFromURIs(ReadFromHTTP(http.DefaultClient), ReadFromFile)) @@ -147,9 +148,12 @@ type ValidationOption func(options *ValidationOptions) func DisableSchemaDefaultsValidation() ValidationOption func DisableSchemaFormatValidation() ValidationOption func DisableSchemaPatternValidation() ValidationOption + func DisableVersionValidation() ValidationOption func EnableExamplesValidation() ValidationOption func EnableSchemaDefaultsValidation() ValidationOption func EnableSchemaFormatValidation() ValidationOption func EnableSchemaPatternValidation() ValidationOption + func EnableVersionValidation() ValidationOption type ValidationOptions struct{ ... } +type Version string type XML struct{ ... } diff --git a/openapi3/loader_paths_test.go b/openapi3/loader_paths_test.go index f7edc7374..d2c420639 100644 --- a/openapi3/loader_paths_test.go +++ b/openapi3/loader_paths_test.go @@ -9,7 +9,7 @@ import ( func TestPathsMustStartWithSlash(t *testing.T) { spec := ` -openapi: "3.0" +openapi: "3.0.0" info: version: "1.0" title: sample diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index e488a59e8..5d59c400d 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -12,7 +12,7 @@ import ( type T struct { Extensions map[string]interface{} `json:"-" yaml:"-"` - OpenAPI string `json:"openapi" yaml:"openapi"` // Required + OpenAPI Version `json:"openapi" yaml:"openapi"` // Required Components *Components `json:"components,omitempty" yaml:"components,omitempty"` Info *Info `json:"info" yaml:"info"` // Required Paths Paths `json:"paths" yaml:"paths"` // Required @@ -92,11 +92,15 @@ func (doc *T) AddServer(server *Server) { func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + var wrap func(error) error + + wrap = func(e error) error { return fmt.Errorf("invalid openapi value: %w", e) } if doc.OpenAPI == "" { - return errors.New("value of openapi must be a non-empty string") + return wrap(errors.New("must be a non-empty string")) + } + if err := doc.OpenAPI.Validate(ctx); err != nil { + return wrap(err) } - - var wrap func(error) error wrap = func(e error) error { return fmt.Errorf("invalid components: %w", e) } if v := doc.Components; v != nil { diff --git a/openapi3/openapi3_test.go b/openapi3/openapi3_test.go index 9a2714a1d..9d17223c4 100644 --- a/openapi3/openapi3_test.go +++ b/openapi3/openapi3_test.go @@ -96,7 +96,7 @@ func eqYAML(t *testing.T, expected, actual []byte) { } var specYAML = []byte(` -openapi: '3.0' +openapi: '3.0.0' info: title: MyAPI version: '0.1' @@ -153,7 +153,7 @@ components: var specJSON = []byte(` { - "openapi": "3.0", + "openapi": "3.0.0", "info": { "title": "MyAPI", "version": "0.1" @@ -266,7 +266,7 @@ func spec() *T { } example := map[string]string{"name": "Some example"} return &T{ - OpenAPI: "3.0", + OpenAPI: "3.0.0", Info: &Info{ Title: "MyAPI", Version: "0.1", @@ -422,12 +422,12 @@ components: { name: "version is missing", spec: strings.Replace(spec, version, "", 1), - expectedErr: "value of openapi must be a non-empty string", + expectedErr: "invalid openapi value: must be a non-empty string", }, { name: "version is empty string", spec: strings.Replace(spec, version, "openapi: ''", 1), - expectedErr: "value of openapi must be a non-empty string", + expectedErr: "invalid openapi value: must be a non-empty string", }, { name: "info section is missing", diff --git a/openapi3/testdata/issue753.yml b/openapi3/testdata/issue753.yml index 2123a6dbd..fb07b5e21 100644 --- a/openapi3/testdata/issue753.yml +++ b/openapi3/testdata/issue753.yml @@ -1,4 +1,4 @@ -openapi: '3' +openapi: '3.0.0' info: version: 0.0.1 title: 'test' diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index 24394baa2..e59978c62 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -13,6 +13,7 @@ type ValidationOptions struct { schemaFormatValidationEnabled bool schemaPatternValidationDisabled bool extraSiblingFieldsAllowed map[string]struct{} + versionValidationDisabled bool } type validationOptionsKey struct{} @@ -92,6 +93,20 @@ func DisableExamplesValidation() ValidationOption { } } +// EnableVersionValidation enables openapi version validation. +func EnableVersionValidation() ValidationOption { + return func(options *ValidationOptions) { + options.versionValidationDisabled = false + } +} + +// DisableVersionValidation disables openapi version validation. +func DisableVersionValidation() ValidationOption { + return func(options *ValidationOptions) { + options.versionValidationDisabled = true + } +} + // WithValidationOptions allows adding validation options to a context object that can be used when validating any OpenAPI type. func WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context { if len(opts) == 0 { diff --git a/openapi3/version.go b/openapi3/version.go new file mode 100644 index 000000000..8a03a67b0 --- /dev/null +++ b/openapi3/version.go @@ -0,0 +1,43 @@ +package openapi3 + +import ( + "context" + "errors" + "regexp" + "strconv" + "strings" +) + +var ( + versionRegex = regexp.MustCompile(`^3\.\d+\.\d+$`) + + // ErrInvalidVersion is used when version is invalid (not 3.x.y) + ErrInvalidVersion = errors.New("must be 3.x.y") +) + +// Version is specified by Version/Swagger standard version 3. +// must be a sring +type Version string + +// Validate returns an error if Schema does not comply with the Version spec. +func (oai Version) Validate(ctx context.Context) error { + if vo := getValidationOptions(ctx); !vo.versionValidationDisabled { + if !versionRegex.MatchString(string(oai)) { + return ErrInvalidVersion + } + } + return nil +} + +// MinorVersion returns minor version from string assuming 0 is the default +// It is meaningful if and only if version vas validated +func (oai Version) Minor() uint64 { + versionNumStrs := strings.Split(string(oai), ".") + if len(versionNumStrs) > 1 { + versionNum, err := strconv.ParseUint(versionNumStrs[1], 10, 64) + if err == nil { + return versionNum + } + } + return 0 +} diff --git a/openapi3/version_test.go b/openapi3/version_test.go new file mode 100644 index 000000000..eb5000d33 --- /dev/null +++ b/openapi3/version_test.go @@ -0,0 +1,101 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +type versionExample struct { + Title string + Values []Version + ExpectedValid bool +} + +var versionExamples = []versionExample{ + { + Title: "Nominal", + Values: []Version{ + "3.0.1", + "3.0.0", + "3.1.0", + "3.299.0", + }, + ExpectedValid: true, + }, + { + Title: "Invalid", + Values: []Version{ + "3.0.1.2", + "3.0", + "3", + "3.0.1-pre1", + "2.0.0", + "2.0", + "4.0.0", + }, + ExpectedValid: false, + }, +} + +func TestVersions(t *testing.T) { + for _, example := range versionExamples { + t.Run(example.Title, testVersion(t, example)) + } +} +func testVersion(t *testing.T, e versionExample) func(*testing.T) { + testCtx := context.Background() + return func(t *testing.T) { + for _, value := range e.Values { + if e.ExpectedValid { + assert.NoErrorf(t, value.Validate(testCtx), "valid value: %v", value) + } else { + assert.Errorf(t, value.Validate(testCtx), "invalid value: %v", value) + } + } + } +} + +type minorVersionExampleValues struct { + InputVersion Version + ExpectedMinorVersion uint64 +} + +type minorVersionExample struct { + Title string + Values []minorVersionExampleValues +} + +var minorVersionExamples = []minorVersionExample{ + { + Title: "Nominal", + Values: []minorVersionExampleValues{ + {InputVersion: "3.0.0", ExpectedMinorVersion: 0}, + {InputVersion: "3.1.0", ExpectedMinorVersion: 1}, + {InputVersion: "3.2.noway", ExpectedMinorVersion: 2}, + {InputVersion: "2.0.0", ExpectedMinorVersion: 0}, + {InputVersion: "4.0.0", ExpectedMinorVersion: 0}, + }, + }, + { + Title: "Invalid", + Values: []minorVersionExampleValues{ + {InputVersion: "xyz", ExpectedMinorVersion: 0}, + {InputVersion: "x.y.z", ExpectedMinorVersion: 0}, + }, + }, +} + +func TestMinorVersions(t *testing.T) { + for _, example := range minorVersionExamples { + t.Run(example.Title, testMinorVersion(t, example)) + } +} +func testMinorVersion(t *testing.T, e minorVersionExample) func(*testing.T) { + return func(t *testing.T) { + for _, value := range e.Values { + assert.Equal(t, value.ExpectedMinorVersion, value.InputVersion.Minor(), value.InputVersion) + } + } +} From 9c0054f4002f63bd526a3a7806c81c739a880538 Mon Sep 17 00:00:00 2001 From: Christian Boitel Date: Mon, 31 Jul 2023 13:32:21 +0200 Subject: [PATCH 2/5] feat #582: add minor version support to string formats --- .github/docs/openapi3.txt | 12 +- openapi3/schema_formats.go | 129 +++++++++++++- openapi3/schema_formats_options.go | 16 ++ openapi3/schema_formats_test.go | 275 +++++++++++++++++++++++++++-- 4 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 openapi3/schema_formats_options.go diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index c162a667c..094d48af4 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -9,17 +9,20 @@ var CircularReferenceError = "kin-openapi bug found: circular schema reference n var DefaultReadFromURI = URIMapCache(ReadFromURIs(ReadFromHTTP(http.DefaultClient), ReadFromFile)) var ErrURINotSupported = errors.New("unsupported URI") var IdentifierRegExp = regexp.MustCompile(identifierPattern) -var SchemaStringFormats = make(map[string]Format, 4) +var SchemaStringFormats = make(map[string]*Format, 4) func BoolPtr(value bool) *bool func DefaultRefNameResolver(ref string) string func DefineIPv4Format() func DefineIPv6Format() -func DefineStringFormat(name string, pattern string) -func DefineStringFormatCallback(name string, callback FormatCallback) +func DefineStringFormat(name string, pattern string, options ...SchemaFormatOption) +func DefineStringFormatCallback(name string, callback FormatCallback, options ...SchemaFormatOption) func Float64Ptr(value float64) *float64 func Int64Ptr(value int64) *int64 func ReadFromFile(loader *Loader, location *url.URL) ([]byte, error) func RegisterArrayUniqueItemsChecker(fn SliceUniqueItemsChecker) +func RestoreDefaultStringFormats() +func RestoreStringFormats(formatToRestore map[string]*Format) +func SaveStringFormats(map[string]*Format) map[string]*Format func Uint64Ptr(value uint64) *uint64 func ValidateIdentifier(value string) error func WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context @@ -108,6 +111,9 @@ type Schema struct{ ... } func NewStringSchema() *Schema func NewUUIDSchema() *Schema type SchemaError struct{ ... } +type SchemaFormatOption func(options *SchemaFormatOptions) + func FromOpenAPIMinorVersion(fromMinorVersion uint64) SchemaFormatOption +type SchemaFormatOptions struct{ ... } type SchemaRef struct{ ... } func NewSchemaRef(ref string, value *Schema) *SchemaRef type SchemaRefs []*SchemaRef diff --git a/openapi3/schema_formats.go b/openapi3/schema_formats.go index ea38400c2..b813f149d 100644 --- a/openapi3/schema_formats.go +++ b/openapi3/schema_formats.go @@ -21,26 +21,99 @@ type FormatCallback func(value string) error // Format represents a format validator registered by either DefineStringFormat or DefineStringFormatCallback type Format struct { + versionedFormats []*versionedFormat +} + +type versionedFormat struct { regexp *regexp.Regexp callback FormatCallback } +func (format *Format) add(minMinorVersion uint64, vFormat *versionedFormat) { + if format != nil { + if format.versionedFormats == nil { + format.versionedFormats = make([]*versionedFormat, minMinorVersion+1) + format.versionedFormats[minMinorVersion] = vFormat + } else { + numVersionedFormats := uint64(len(format.versionedFormats)) + if minMinorVersion >= numVersionedFormats { + // grow array + lastValue := format.versionedFormats[numVersionedFormats-1] + additionalEntries := make([]*versionedFormat, minMinorVersion+1-numVersionedFormats) + if lastValue != nil { + for i := 0; i < len(additionalEntries); i++ { + additionalEntries[i] = lastValue + } + } + format.versionedFormats = append(format.versionedFormats, additionalEntries...) + format.versionedFormats[minMinorVersion] = vFormat + return + } + for i := minMinorVersion; i < numVersionedFormats; i++ { + format.versionedFormats[i] = vFormat + } + } + } +} + +func (format Format) get(minorVersion uint64) *versionedFormat { + if format.versionedFormats != nil { + if minorVersion >= uint64(len(format.versionedFormats)) { + return format.versionedFormats[len(format.versionedFormats)-1] + } + return format.versionedFormats[minorVersion] + } + return nil +} + +func (format Format) DefinedForMinorVersion(minorVersion uint64) bool { + return format.get(minorVersion) != nil +} + // SchemaStringFormats allows for validating string formats -var SchemaStringFormats = make(map[string]Format, 4) +var SchemaStringFormats = make(map[string]*Format, 4) +var defaultSchemaStringFormats map[string]*Format // DefineStringFormat defines a new regexp pattern for a given format -func DefineStringFormat(name string, pattern string) { +// Will enforce regexp usage for minor versions of OpenAPI (3.Y.Z) +func DefineStringFormat(name string, pattern string, options ...SchemaFormatOption) { + var schemaFormatOptions SchemaFormatOptions + for _, option := range options { + option(&schemaFormatOptions) + } re, err := regexp.Compile(pattern) if err != nil { err := fmt.Errorf("format %q has invalid pattern %q: %w", name, pattern, err) panic(err) } - SchemaStringFormats[name] = Format{regexp: re} + updateSchemaStringFormats(name, schemaFormatOptions.fromOpenAPIMinorVersion, &versionedFormat{regexp: re}) +} + +func getSchemaStringFormats(name string, minorVersion uint64) *versionedFormat { + if currentStringFormat, found := SchemaStringFormats[name]; found { + return currentStringFormat.get(minorVersion) + } + return nil +} + +func updateSchemaStringFormats(name string, minMinorVersion uint64, vFormat *versionedFormat) { + if currentStringFormat, found := SchemaStringFormats[name]; found { + currentStringFormat.add(minMinorVersion, vFormat) + return + } + var newFormat Format + newFormat.add(minMinorVersion, vFormat) + SchemaStringFormats[name] = &newFormat } // DefineStringFormatCallback adds a validation function for a specific schema format entry -func DefineStringFormatCallback(name string, callback FormatCallback) { - SchemaStringFormats[name] = Format{callback: callback} +// Will enforce regexp usage for minor versions of OpenAPI (3.Y.Z) +func DefineStringFormatCallback(name string, callback FormatCallback, options ...SchemaFormatOption) { + var schemaFormatOptions SchemaFormatOptions + for _, option := range options { + option(&schemaFormatOptions) + } + updateSchemaStringFormats(name, schemaFormatOptions.fromOpenAPIMinorVersion, &versionedFormat{callback: callback}) } func validateIP(ip string) error { @@ -82,6 +155,51 @@ func validateIPv6(ip string) error { return nil } +// SaveStringFormats allows to save (obtain a deep copy) of your current string formats +// so you can later restore it if needed +func SaveStringFormats(map[string]*Format) map[string]*Format { + savedStringFormats := map[string]*Format{} + for name, value := range SchemaStringFormats { + var savedFormat Format + savedFormat.versionedFormats = make([]*versionedFormat, len(value.versionedFormats)) + for index, versionedFormatValue := range value.versionedFormats { + if versionedFormatValue != nil { + savedVersionedFormat := versionedFormat{ + regexp: versionedFormatValue.regexp, + callback: versionedFormatValue.callback, + } + savedFormat.versionedFormats[index] = &savedVersionedFormat + } + } + savedStringFormats[name] = &savedFormat + } + return savedStringFormats +} + +// RestoreStringFormats allows to restore string format back to default values +func RestoreStringFormats(formatToRestore map[string]*Format) { + restoredStringFormats := map[string]*Format{} + for name, value := range formatToRestore { + var restoredFormat Format + restoredFormat.versionedFormats = make([]*versionedFormat, len(value.versionedFormats)) + for index, versionedFormatValue := range value.versionedFormats { + if versionedFormatValue != nil { + restoredVersionedFormat := versionedFormat{ + regexp: versionedFormatValue.regexp, + callback: versionedFormatValue.callback, + } + restoredFormat.versionedFormats[index] = &restoredVersionedFormat + } + } + restoredStringFormats[name] = &restoredFormat + } + SchemaStringFormats = restoredStringFormats +} + +// RestoreDefaultStringFormats allows to restore string format back to default values +func RestoreDefaultStringFormats() { + RestoreStringFormats(defaultSchemaStringFormats) +} func init() { // Base64 // The pattern supports base64 and b./ase64url. Padding ('=') is supported. @@ -93,6 +211,7 @@ func init() { // date-time DefineStringFormat("date-time", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) + defaultSchemaStringFormats = SaveStringFormats(SchemaStringFormats) } // DefineIPv4Format opts in ipv4 format validation on top of OAS 3 spec diff --git a/openapi3/schema_formats_options.go b/openapi3/schema_formats_options.go new file mode 100644 index 000000000..6f77e1a3d --- /dev/null +++ b/openapi3/schema_formats_options.go @@ -0,0 +1,16 @@ +package openapi3 + +// SchemaFormatOption allows the modification of how the OpenAPI document is validated. +type SchemaFormatOption func(options *SchemaFormatOptions) + +// SchemaFormatOptions provides configuration for validating OpenAPI documents. +type SchemaFormatOptions struct { + fromOpenAPIMinorVersion uint64 +} + +// FromOpenAPIMinorVersion allows to declare a string format available only at some minor OpenAPI version +func FromOpenAPIMinorVersion(fromMinorVersion uint64) SchemaFormatOption { + return func(options *SchemaFormatOptions) { + options.fromOpenAPIMinorVersion = fromMinorVersion + } +} diff --git a/openapi3/schema_formats_test.go b/openapi3/schema_formats_test.go index 70092d6de..4dfb36068 100644 --- a/openapi3/schema_formats_test.go +++ b/openapi3/schema_formats_test.go @@ -16,9 +16,6 @@ func TestIssue430(t *testing.T) { NewStringSchema().WithFormat("ipv6"), ) - delete(SchemaStringFormats, "ipv4") - delete(SchemaStringFormats, "ipv6") - err := schema.Validate(context.Background()) require.NoError(t, err) @@ -46,11 +43,8 @@ func TestIssue430(t *testing.T) { require.Error(t, err, ErrOneOfConflict.Error()) } - DefineIPv4Format() - DefineIPv6Format() - for datum, isV4 := range data { - err = schema.VisitJSON(datum) + err = schema.VisitJSON(datum, SetOpenAPIMinorVersion(1)) require.NoError(t, err) if isV4 { require.Nil(t, validateIPv4(datum), "%q should be IPv4", datum) @@ -78,8 +72,6 @@ func TestFormatCallback_WrapError(t *testing.T) { } func TestReversePathInMessageSchemaError(t *testing.T) { - DefineIPv4Format() - SchemaErrorDetailsDisabled = true const spc = ` @@ -99,11 +91,11 @@ components: err = doc.Components.Schemas["Something"].Value.VisitJSON(map[string]interface{}{ `ip`: `123.0.0.11111`, - }) + }, SetOpenAPIMinorVersion(1)) - require.EqualError(t, err, `Error at "/ip": Not an IP address`) + // assert, do not require to ensure SchemaErrorDetailsDisabled can be set to false + assert.ErrorContains(t, err, `Error at "/ip"`) - delete(SchemaStringFormats, "ipv4") SchemaErrorDetailsDisabled = false } @@ -116,6 +108,7 @@ func TestUuidFormat(t *testing.T) { } DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) + defer RestoreDefaultStringFormats() testCases := []testCase{ { name: "invalid", @@ -154,3 +147,261 @@ func TestUuidFormat(t *testing.T) { }) } } +func TestStringFormatsStartingWithOpenAPIMinorVersion(t *testing.T) { + DefineStringFormat("test", "test0", FromOpenAPIMinorVersion(0)) + defer RestoreDefaultStringFormats() + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + assert.Equalf(t, "test0", SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test1", FromOpenAPIMinorVersion(1)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i > 0: + regexpString = "test1" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test5", FromOpenAPIMinorVersion(5)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i >= 1 && i < 5: + regexpString = "test1" + case i >= 5: + regexpString = "test5" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test2", FromOpenAPIMinorVersion(2)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i == 1: + regexpString = "test1" + case i >= 2: + regexpString = "test2" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test4", FromOpenAPIMinorVersion(4)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i == 1: + regexpString = "test1" + case i >= 2 && i < 4: + regexpString = "test2" + case i >= 4: + regexpString = "test4" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test3", FromOpenAPIMinorVersion(3)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i == 1: + regexpString = "test1" + case i == 2: + regexpString = "test2" + case i >= 3: + regexpString = "test3" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "test7", FromOpenAPIMinorVersion(7)) + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + var regexpString string + switch { + case i == 0: + regexpString = "test0" + case i == 1: + regexpString = "test1" + case i == 2: + regexpString = "test2" + case i >= 3 && i < 7: + regexpString = "test3" + case i >= 7: + regexpString = "test7" + } + assert.Equalf(t, regexpString, SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } + + DefineStringFormat("test", "testnew") + for i := uint64(0); i < 10; i++ { + if assert.Contains(t, SchemaStringFormats, "test") && + assert.NotNilf(t, SchemaStringFormats["test"].get(i), "%d", i) { + if assert.NotNilf(t, SchemaStringFormats["test"].get(i).regexp, "%d", i) { + assert.Equalf(t, "testnew", SchemaStringFormats["test"].get(i).regexp.String(), "%d", i) + } + assert.Nilf(t, SchemaStringFormats["test"].get(i).callback, "%d", i) + } + } +} + +func createCallBackError(minorVersion uint) error { + return fmt.Errorf("%d", minorVersion) +} +func createCallBack(callbackError error) FormatCallback { + return func(name string) error { + return callbackError + } +} + +func TestStringFormatsCallbackStartingWithOpenAPIMinorVersion(t *testing.T) { + defer RestoreDefaultStringFormats() + callbackError0 := createCallBackError(0) + DefineStringFormatCallback("testCallback", createCallBack(callbackError0)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + assert.Equal(t, callbackError0, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } + + callbackError1 := createCallBackError(1) + DefineStringFormatCallback("testCallback", createCallBack(callbackError1), FromOpenAPIMinorVersion(1)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + var err error + switch { + case i == 0: + err = callbackError0 + case i > 0: + err = callbackError1 + } + assert.Equal(t, err, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } + callbackError5 := createCallBackError(5) + DefineStringFormatCallback("testCallback", createCallBack(callbackError5), FromOpenAPIMinorVersion(5)) + assert.Equal(t, 6, len(SchemaStringFormats["testCallback"].versionedFormats)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + var err error + switch { + case i == 0: + err = callbackError0 + case i > 0 && i < 5: + err = callbackError1 + case i >= 5: + err = callbackError5 + } + assert.Equal(t, err, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } + callbackError3 := createCallBackError(3) + DefineStringFormatCallback("testCallback", createCallBack(callbackError3), FromOpenAPIMinorVersion(3)) + assert.Equal(t, 6, len(SchemaStringFormats["testCallback"].versionedFormats)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + var err error + switch { + case i == 0: + err = callbackError0 + case i > 0 && i < 3: + err = callbackError1 + case i >= 3: + err = callbackError3 + } + assert.Equal(t, err, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } + callbackError4 := createCallBackError(4) + DefineStringFormatCallback("testCallback", createCallBack(callbackError4), FromOpenAPIMinorVersion(4)) + assert.Equal(t, 6, len(SchemaStringFormats["testCallback"].versionedFormats)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + var err error + switch { + case i == 0: + err = callbackError0 + case i > 0 && i < 3: + err = callbackError1 + case i == 3: + err = callbackError3 + case i >= 4: + err = callbackError4 + } + assert.Equal(t, err, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } + callbackError99 := createCallBackError(99) + DefineStringFormatCallback("testCallback", createCallBack(callbackError99)) + assert.Equal(t, 6, len(SchemaStringFormats["testCallback"].versionedFormats)) + for i := uint64(0); i < 10; i++ { + if assert.NotNilf(t, SchemaStringFormats["testCallback"], "%d", i) && + assert.NotNilf(t, SchemaStringFormats["testCallback"].get(i), "%d", i) { + assert.Emptyf(t, SchemaStringFormats["testCallback"].get(i).regexp, "%d", i) + assert.Equal(t, callbackError99, SchemaStringFormats["testCallback"].get(i).callback("ignored"), "%d", i) + } + } +} From 21c4aa6b0d86b6d2092822d6ba81200e5d04b801 Mon Sep 17 00:00:00 2001 From: Christian Boitel Date: Mon, 31 Jul 2023 13:35:50 +0200 Subject: [PATCH 3/5] fix #582: enhance date string formats --- openapi3/schema_formats.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openapi3/schema_formats.go b/openapi3/schema_formats.go index b813f149d..5c1129673 100644 --- a/openapi3/schema_formats.go +++ b/openapi3/schema_formats.go @@ -205,11 +205,11 @@ func init() { // The pattern supports base64 and b./ase64url. Padding ('=') is supported. DefineStringFormat("byte", `(^$|^[a-zA-Z0-9+/\-_]*=*$)`) - // date - DefineStringFormat("date", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)$`) + // defined as full-date in https://www.rfc-editor.org/rfc/rfc3339#section-5.6 + DefineStringFormat("date", `^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|30|31)$`) - // date-time - DefineStringFormat("date-time", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) + // defined as date-time in https://www.rfc-editor.org/rfc/rfc3339#section-5.6 + DefineStringFormat("date-time", `^[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T(23:59:60|(([01][0-9]|2[0-3])(:[0-5][0-9]){2}))(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) defaultSchemaStringFormats = SaveStringFormats(SchemaStringFormats) } From bcbbb971e80eca846820a9c40c5064cf1cf05ff6 Mon Sep 17 00:00:00 2001 From: Christian Boitel Date: Mon, 31 Jul 2023 13:36:22 +0200 Subject: [PATCH 4/5] fix #582: define OpenAPI 3.1 string formats --- openapi3/schema_formats.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openapi3/schema_formats.go b/openapi3/schema_formats.go index 5c1129673..0c40b3764 100644 --- a/openapi3/schema_formats.go +++ b/openapi3/schema_formats.go @@ -211,6 +211,18 @@ func init() { // defined as date-time in https://www.rfc-editor.org/rfc/rfc3339#section-5.6 DefineStringFormat("date-time", `^[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T(23:59:60|(([01][0-9]|2[0-3])(:[0-5][0-9]){2}))(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) + // defined as uuid in https://www.rfc-editor.org/rfc/rfc4122 + DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122, FromOpenAPIMinorVersion(1)) + + // defined as ipv4 in + DefineStringFormatCallback("ipv4", validateIPv4, FromOpenAPIMinorVersion(1)) + + // defined as ipv6 in https://www.rfc-editor.org/rfc/rfc4122 + DefineStringFormatCallback("ipv6", validateIPv6, FromOpenAPIMinorVersion(1)) + + // hostname as defined in https://www.rfc-editor.org/rfc/rfc1123#section-2.1 + DefineStringFormat(`hostname`, `^[a-zA-Z0-9][a-zA-Z0-9-.]+[a-zA-Z0-9]$`, FromOpenAPIMinorVersion(1)) + defaultSchemaStringFormats = SaveStringFormats(SchemaStringFormats) } From 355d4741eede45458507c4be3906f8393a5661fa Mon Sep 17 00:00:00 2001 From: Christian Boitel Date: Mon, 31 Jul 2023 13:37:42 +0200 Subject: [PATCH 5/5] feat #582: use OpenAPI minor version during validation when available --- .github/docs/openapi3.txt | 5 + openapi3/issue735_test.go | 2 + openapi3/schema.go | 30 +- openapi3/schema_issue492_test.go | 2 +- openapi3/schema_test.go | 1085 +++++++++++++++--------- openapi3/schema_validation_settings.go | 7 + openapi3filter/middleware_test.go | 250 +++++- openapi3filter/validate_request.go | 6 + openapi3filter/validate_response.go | 5 +- 9 files changed, 953 insertions(+), 439 deletions(-) diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 094d48af4..637972e54 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -100,8 +100,12 @@ type Schema struct{ ... } func NewArraySchema() *Schema func NewBoolSchema() *Schema func NewBytesSchema() *Schema + func NewDateSchema() *Schema func NewDateTimeSchema() *Schema func NewFloat64Schema() *Schema + func NewHostnameSchema() *Schema + func NewIPv4Schema() *Schema + func NewIPv6Schema() *Schema func NewInt32Schema() *Schema func NewInt64Schema() *Schema func NewIntegerSchema() *Schema @@ -125,6 +129,7 @@ type SchemaValidationOption func(*schemaValidationSettings) func EnableFormatValidation() SchemaValidationOption func FailFast() SchemaValidationOption func MultiErrors() SchemaValidationOption + func SetOpenAPIMinorVersion(minorVersion uint64) SchemaValidationOption func SetSchemaErrorMessageCustomizer(f func(err *SchemaError) string) SchemaValidationOption func VisitAsRequest() SchemaValidationOption func VisitAsResponse() SchemaValidationOption diff --git a/openapi3/issue735_test.go b/openapi3/issue735_test.go index f7e420c5d..f3da3a797 100644 --- a/openapi3/issue735_test.go +++ b/openapi3/issue735_test.go @@ -20,6 +20,8 @@ func TestIssue735(t *testing.T) { DefineStringFormat("email", FormatOfStringForEmail) DefineIPv4Format() DefineIPv6Format() + // restore modified string formats used during this tests + defer RestoreDefaultStringFormats() testCases := []testCase{ { diff --git a/openapi3/schema.go b/openapi3/schema.go index 29da9efa5..9d7bf41e2 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -579,6 +579,13 @@ func NewStringSchema() *Schema { } } +func NewDateSchema() *Schema { + return &Schema{ + Type: TypeString, + Format: "date", + } +} + func NewDateTimeSchema() *Schema { return &Schema{ Type: TypeString, @@ -586,6 +593,13 @@ func NewDateTimeSchema() *Schema { } } +func NewHostnameSchema() *Schema { + return &Schema{ + Type: TypeString, + Format: "hostname", + } +} + func NewUUIDSchema() *Schema { return &Schema{ Type: TypeString, @@ -593,6 +607,20 @@ func NewUUIDSchema() *Schema { } } +func NewIPv4Schema() *Schema { + return &Schema{ + Type: TypeString, + Format: "ipv4", + } +} + +func NewIPv6Schema() *Schema { + return &Schema{ + Type: TypeString, + Format: "ipv6", + } +} + func NewBytesSchema() *Schema { return &Schema{ Type: TypeString, @@ -1628,7 +1656,7 @@ func (schema *Schema) visitJSONString(settings *schemaValidationSettings, value var formatStrErr string var formatErr error if format := schema.Format; format != "" { - if f, ok := SchemaStringFormats[format]; ok { + if f := getSchemaStringFormats(format, settings.openapiMinorVersion); f != nil { switch { case f.regexp != nil && f.callback == nil: if cp := f.regexp; !cp.MatchString(value) { diff --git a/openapi3/schema_issue492_test.go b/openapi3/schema_issue492_test.go index 4ad72abc9..c57db5bb3 100644 --- a/openapi3/schema_issue492_test.go +++ b/openapi3/schema_issue492_test.go @@ -46,5 +46,5 @@ info: "name": "kin-openapi", "time": "2001-02-03T04:05:06:789Z", }) - require.ErrorContains(t, err, `Error at "/time": string doesn't match the format "date-time" (regular expression "^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$")`) + require.ErrorContains(t, err, `Error at "/time": string doesn't match the format "date-time" (regular expression `) } diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index 1c2ed355f..f32d9fe3c 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) @@ -17,12 +18,11 @@ type schemaExample struct { Title string Schema *Schema Serialization interface{} - AllValid []interface{} - AllInvalid []interface{} + AllValid [][]interface{} // indexed by minor OpenAPI version supported + AllInvalid [][]interface{} // also indexed by minor OpenAPI version supported } func TestSchemas(t *testing.T) { - DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) for _, example := range schemaExamples { t.Run(example.Title, testSchema(t, example)) } @@ -46,19 +46,23 @@ func testSchema(t *testing.T, example schemaExample) func(*testing.T) { require.Equal(t, dataUnserialized, dataSchema) } for validateFuncIndex, validateFunc := range validateSchemaFuncs { - for index, value := range example.AllValid { - err := validateFunc(t, schema, value) - require.NoErrorf(t, err, "ValidateFunc #%d, AllValid #%d: %#v", validateFuncIndex, index, value) + for minorVersion, examples := range example.AllValid { + for index, value := range examples { + err := validateFunc(t, schema, value, SetOpenAPIMinorVersion(uint64(minorVersion))) + assert.NoErrorf(t, err, "ValidateFunc #%d, AllValid #%d: %#v", validateFuncIndex, index, value) + } } - for index, value := range example.AllInvalid { - err := validateFunc(t, schema, value) - require.Errorf(t, err, "ValidateFunc #%d, AllInvalid #%d: %#v", validateFuncIndex, index, value) + for minorVersion, examples := range example.AllInvalid { + for index, value := range examples { + err := validateFunc(t, schema, value, SetOpenAPIMinorVersion(uint64(minorVersion))) + assert.Errorf(t, err, "ValidateFunc #%d, AllInvalid #%d: %#v", validateFuncIndex, index, value) + } } } // NaN and Inf aren't valid JSON but are handled for index, value := range []interface{}{math.NaN(), math.Inf(-1), math.Inf(+1)} { err := schema.VisitJSON(value) - require.Errorf(t, err, "NaNAndInf #%d: %#v", index, value) + assert.Errorf(t, err, "NaNAndInf #%d: %#v", index, value) } } } @@ -96,16 +100,20 @@ var schemaExamples = []schemaExample{ // This OA3 schema is exactly this draft-04 schema: // {"not": {"type": "null"}} }, - AllValid: []interface{}{ - false, - true, - 3.14, - "", - []interface{}{}, - map[string]interface{}{}, + AllValid: [][]interface{}{ + { // OpenAPI 3.0 + false, + true, + 3.14, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, - AllInvalid: []interface{}{ - nil, + AllInvalid: [][]interface{}{ + { // OpenAPI 3.0 + nil, + }, }, }, @@ -118,16 +126,18 @@ var schemaExamples = []schemaExample{ // ,{type:array, items:{}}, type:object]} "nullable": true, }, - AllValid: []interface{}{ - nil, - false, - true, - 0, - 0.0, - 3.14, - "", - []interface{}{}, - map[string]interface{}{}, + AllValid: [][]interface{}{ + { + nil, + false, + true, + 0, + 0.0, + 3.14, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, @@ -138,18 +148,22 @@ var schemaExamples = []schemaExample{ "nullable": true, "type": "boolean", }, - AllValid: []interface{}{ - nil, - false, - true, + AllValid: [][]interface{}{ + { + nil, + false, + true, + }, }, - AllInvalid: []interface{}{ - 0, - 0.0, - 3.14, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + 0, + 0.0, + 3.14, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, @@ -166,16 +180,20 @@ var schemaExamples = []schemaExample{ map[string]interface{}{"type": "number"}, }, }, - AllValid: []interface{}{ - nil, - 42, - 4.2, + AllValid: [][]interface{}{ + { + nil, + 42, + 4.2, + }, }, - AllInvalid: []interface{}{ - true, - []interface{}{42}, - "bla", - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + true, + []interface{}{42}, + "bla", + map[string]interface{}{}, + }, }, }, @@ -185,16 +203,20 @@ var schemaExamples = []schemaExample{ Serialization: map[string]interface{}{ "type": "boolean", }, - AllValid: []interface{}{ - false, - true, + AllValid: [][]interface{}{ + { + false, + true, + }, }, - AllInvalid: []interface{}{ - nil, - 3.14, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, @@ -208,20 +230,24 @@ var schemaExamples = []schemaExample{ "minimum": 2.5, "maximum": 3.5, }, - AllValid: []interface{}{ - 2.5, - 3.14, - 3.5, + AllValid: [][]interface{}{ + { + 2.5, + 3.14, + 3.5, + }, }, - AllInvalid: []interface{}{ - nil, - false, - true, - 2.4, - 3.6, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + false, + true, + 2.4, + 3.6, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, @@ -236,20 +262,24 @@ var schemaExamples = []schemaExample{ "minimum": 2, "maximum": 5, }, - AllValid: []interface{}{ - 2, - 5, + AllValid: [][]interface{}{ + { + 2, + 5, + }, }, - AllInvalid: []interface{}{ - nil, - false, - true, - 1, - 6, - 3.5, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + false, + true, + 1, + 6, + 3.5, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, { @@ -259,21 +289,25 @@ var schemaExamples = []schemaExample{ "type": "integer", "format": "int64", }, - AllValid: []interface{}{ - 1, - 256, - 65536, - int64(math.MaxInt32) + 10, - int64(math.MinInt32) - 10, + AllValid: [][]interface{}{ + { + 1, + 256, + 65536, + int64(math.MaxInt32) + 10, + int64(math.MinInt32) - 10, + }, }, - AllInvalid: []interface{}{ - nil, - false, - 3.5, - true, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + false, + 3.5, + true, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, { @@ -283,23 +317,27 @@ var schemaExamples = []schemaExample{ "type": "integer", "format": "int32", }, - AllValid: []interface{}{ - 1, - 256, - 65536, - int64(math.MaxInt32), - int64(math.MaxInt32), + AllValid: [][]interface{}{ + { + 1, + 256, + 65536, + int64(math.MaxInt32), + int64(math.MaxInt32), + }, }, - AllInvalid: []interface{}{ - nil, - false, - 3.5, - int64(math.MaxInt32) + 10, - int64(math.MinInt32) - 10, - true, - "", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + false, + 3.5, + int64(math.MaxInt32) + 10, + int64(math.MinInt32) - 10, + true, + "", + []interface{}{}, + map[string]interface{}{}, + }, }, }, { @@ -314,23 +352,26 @@ var schemaExamples = []schemaExample{ "maxLength": 3, "pattern": "^[abc]+$", }, - AllValid: []interface{}{ - "ab", - "abc", + AllValid: [][]interface{}{ + { + "ab", + "abc", + }, }, - AllInvalid: []interface{}{ - nil, - false, - true, - 3.14, - "a", - "xy", - "aaaa", - []interface{}{}, - map[string]interface{}{}, + AllInvalid: [][]interface{}{ + { + nil, + false, + true, + 3.14, + "a", + "xy", + "aaaa", + []interface{}{}, + map[string]interface{}{}, + }, }, }, - { Title: "STRING: optional format 'uuid'", Schema: NewUUIDSchema(), @@ -338,30 +379,67 @@ var schemaExamples = []schemaExample{ "type": "string", "format": "uuid", }, - AllValid: []interface{}{ - "dd7d8481-81a3-407f-95f0-a2f1cb382a4b", - "dcba3901-2fba-48c1-9db2-00422055804e", - "ace8e3be-c254-4c10-8859-1401d9a9d52a", - "DD7D8481-81A3-407F-95F0-A2F1CB382A4B", - "DCBA3901-2FBA-48C1-9DB2-00422055804E", - "ACE8E3BE-C254-4C10-8859-1401D9A9D52A", - "dd7D8481-81A3-407f-95F0-A2F1CB382A4B", - "DCBA3901-2FBA-48C1-9db2-00422055804e", - "ACE8E3BE-c254-4C10-8859-1401D9A9D52A", - }, - AllInvalid: []interface{}{ + AllValid: [][]interface{}{ + { + "anything-not-definedin3.0", + }, + { + "dd7d8481-81a3-407f-95f0-a2f1cb382a4b", + "dcba3901-2fba-48c1-9db2-00422055804e", + "ace8e3be-c254-4c10-8859-1401d9a9d52a", + "DD7D8481-81A3-407F-95F0-A2F1CB382A4B", + "DCBA3901-2FBA-48C1-9DB2-00422055804E", + "ACE8E3BE-C254-4C10-8859-1401D9A9D52A", + "dd7D8481-81A3-407f-95F0-A2F1CB382A4B", + "DCBA3901-2FBA-48C1-9db2-00422055804e", + "ACE8E3BE-c254-4C10-8859-1401D9A9D52A", + }, + }, + AllInvalid: [][]interface{}{ nil, - "g39840b1-d0ef-446d-e555-48fcca50a90a", - "4cf3i040-ea14-4daa-b0b5-ea9329473519", - "aaf85740-7e27-4b4f-b4554-a03a43b1f5e3", - "56f5bff4-z4b6-48e6-a10d-b6cf66a83b04", - "G39840B1-D0EF-446D-E555-48FCCA50A90A", - "4CF3I040-EA14-4DAA-B0B5-EA9329473519", - "AAF85740-7E27-4B4F-B4554-A03A43B1F5E3", - "56F5BFF4-Z4B6-48E6-A10D-B6CF66A83B04", - "4CF3I040-EA14-4Daa-B0B5-EA9329473519", - "AAf85740-7E27-4B4F-B4554-A03A43b1F5E3", - "56F5Bff4-Z4B6-48E6-a10D-B6CF66A83B04", + { + nil, + "g39840b1-d0ef-446d-e555-48fcca50a90a", + "4cf3i040-ea14-4daa-b0b5-ea9329473519", + "aaf85740-7e27-4b4f-b4554-a03a43b1f5e3", + "56f5bff4-z4b6-48e6-a10d-b6cf66a83b04", + "G39840B1-D0EF-446D-E555-48FCCA50A90A", + "4CF3I040-EA14-4DAA-B0B5-EA9329473519", + "AAF85740-7E27-4B4F-B4554-A03A43B1F5E3", + "56F5BFF4-Z4B6-48E6-A10D-B6CF66A83B04", + "4CF3I040-EA14-4Daa-B0B5-EA9329473519", + "AAf85740-7E27-4B4F-B4554-A03A43b1F5E3", + "56F5Bff4-Z4B6-48E6-a10D-B6CF66A83B04", + }, + }, + }, + + { + Title: "STRING: format 'date'", + Schema: NewDateSchema(), + Serialization: map[string]interface{}{ + "type": "string", + "format": "date", + }, + AllValid: [][]interface{}{ + { + "2017-12-31", + "2017-01-01", + }, + }, + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + "2017-12-00", + "2017-12-32", + "2017-13-01", + "2017-00-31", + "99-09-09", + "2017-01-00", + "2017-01-32", + "2017-01-40", + }, }, }, @@ -372,53 +450,68 @@ var schemaExamples = []schemaExample{ "type": "string", "format": "date-time", }, - AllValid: []interface{}{ - "2017-12-31T11:59:59", - "2017-12-31T11:59:59Z", - "2017-12-31T11:59:59-11:30", - "2017-12-31T11:59:59+11:30", - "2017-12-31T11:59:59.999+11:30", - "2017-12-31T11:59:59.999Z", + AllValid: [][]interface{}{ + { + "2017-12-31T11:59:59", + "2017-12-31T11:59:59Z", + "2017-12-31T11:59:59-11:30", + "2017-12-31T11:59:59+11:30", + "2017-12-31T11:59:59.999+11:30", + "2017-12-31T11:59:59.999Z", + "2017-12-31T23:59:60", // leap second + }, }, - AllInvalid: []interface{}{ - nil, - 3.14, - "2017-12-31", - "2017-12-31T11:59:59\n", - "2017-12-31T11:59:59.+11:30", - "2017-12-31T11:59:59.Z", + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + "2017-12-31", + "2017-12-31T11:59:59\n", + "2017-12-31T11:59:59.+11:30", + "2017-12-31T11:59:59.Z", + "2017-12-00T11:59:59.Z", + "2017-12-32T11:59:59.Z", + "2017-12-40T11:59:59.Z", + "2017-12-00T11:59:59", + "2017-12-31T11:59:60", + "99-09-09T11:59:59", + }, }, }, { - Title: "STRING: format 'date-time'", + Title: "STRING: format 'byte'", Schema: NewBytesSchema(), Serialization: map[string]interface{}{ "type": "string", "format": "byte", }, - AllValid: []interface{}{ - "", - base64.StdEncoding.EncodeToString(func() []byte { - data := make([]byte, 0, 1024) - for i := 0; i < cap(data); i++ { - data = append(data, byte(i)) - } - return data - }()), - base64.URLEncoding.EncodeToString(func() []byte { - data := make([]byte, 0, 1024) - for i := 0; i < cap(data); i++ { - data = append(data, byte(i)) - } - return data - }()), + AllValid: [][]interface{}{ + { + "", + base64.StdEncoding.EncodeToString(func() []byte { + data := make([]byte, 0, 1024) + for i := 0; i < cap(data); i++ { + data = append(data, byte(i)) + } + return data + }()), + base64.URLEncoding.EncodeToString(func() []byte { + data := make([]byte, 0, 1024) + for i := 0; i < cap(data); i++ { + data = append(data, byte(i)) + } + return data + }()), + }, }, - AllInvalid: []interface{}{ - nil, - " ", - "\n\n", // a \n is ok for JSON but not for YAML decoder/encoder - "%", + AllInvalid: [][]interface{}{ + { + nil, + " ", + "\n\n", // a \n is ok for JSON but not for YAML decoder/encoder + "%", + }, }, }, @@ -440,25 +533,29 @@ var schemaExamples = []schemaExample{ "type": "number", }, }, - AllValid: []interface{}{ - []interface{}{ - 1, 2, - }, - []interface{}{ - 1, 2, 3, + AllValid: [][]interface{}{ + { + []interface{}{ + 1, 2, + }, + []interface{}{ + 1, 2, 3, + }, }, }, - AllInvalid: []interface{}{ - nil, - 3.14, - []interface{}{ - 1, - }, - []interface{}{ - 42, 42, - }, - []interface{}{ - 1, 2, 3, 4, + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + []interface{}{ + 1, + }, + []interface{}{ + 42, 42, + }, + []interface{}{ + 1, 2, 3, 4, + }, }, }, }, @@ -486,34 +583,38 @@ var schemaExamples = []schemaExample{ "type": "object", }, }, - AllValid: []interface{}{ - []interface{}{ - map[string]interface{}{ - "key1": 1, - "key2": 1, - // Additional properties will make object different - // By default additionalProperties is true - }, - map[string]interface{}{ - "key1": 1, - }, - }, - []interface{}{ - map[string]interface{}{ - "key1": 1, + AllValid: [][]interface{}{ + { + []interface{}{ + map[string]interface{}{ + "key1": 1, + "key2": 1, + // Additioanl properties will make object different + // By default additionalProperties is true + }, + map[string]interface{}{ + "key1": 1, + }, }, - map[string]interface{}{ - "key1": 2, + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 2, + }, }, }, }, - AllInvalid: []interface{}{ - []interface{}{ - map[string]interface{}{ - "key1": 1, - }, - map[string]interface{}{ - "key1": 1, + AllInvalid: [][]interface{}{ + { + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 1, + }, }, }, }, @@ -551,54 +652,58 @@ var schemaExamples = []schemaExample{ "type": "object", }, }, - AllValid: []interface{}{ - []interface{}{ - map[string]interface{}{ - "key1": []interface{}{ - 1, 2, + AllValid: [][]interface{}{ + { + []interface{}{ + map[string]interface{}{ + "key1": []interface{}{ + 1, 2, + }, }, - }, - map[string]interface{}{ - "key1": []interface{}{ - 3, 4, + map[string]interface{}{ + "key1": []interface{}{ + 3, 4, + }, }, }, - }, - []interface{}{ // Slice have items with the same value but with different index will treated as different slices - map[string]interface{}{ - "key1": []interface{}{ - 10, 9, + []interface{}{ // Slice have items with the same value but with different index will treated as different slices + map[string]interface{}{ + "key1": []interface{}{ + 10, 9, + }, }, - }, - map[string]interface{}{ - "key1": []interface{}{ - 9, 10, + map[string]interface{}{ + "key1": []interface{}{ + 9, 10, + }, }, }, }, }, - AllInvalid: []interface{}{ - []interface{}{ // Violate outer array uniqueItems: true - map[string]interface{}{ - "key1": []interface{}{ - 9, 9, + AllInvalid: [][]interface{}{ + { + []interface{}{ // Violate outer array uniqueItems: true + map[string]interface{}{ + "key1": []interface{}{ + 9, 9, + }, }, - }, - map[string]interface{}{ - "key1": []interface{}{ - 9, 9, + map[string]interface{}{ + "key1": []interface{}{ + 9, 9, + }, }, }, - }, - []interface{}{ // Violate inner(array in object) array uniqueItems: true - map[string]interface{}{ - "key1": []interface{}{ - 9, 9, + []interface{}{ // Violate inner(array in object) array uniqueItems: true + map[string]interface{}{ + "key1": []interface{}{ + 9, 9, + }, }, - }, - map[string]interface{}{ - "key1": []interface{}{ - 8, 8, + map[string]interface{}{ + "key1": []interface{}{ + 8, 8, + }, }, }, }, @@ -627,24 +732,28 @@ var schemaExamples = []schemaExample{ "type": "array", }, }, - AllValid: []interface{}{ - []interface{}{ - []interface{}{1, 2}, - []interface{}{3, 4}, - }, - []interface{}{ // Slice have items with the same value but with different index will treated as different slices - []interface{}{1, 2}, - []interface{}{2, 1}, + AllValid: [][]interface{}{ + { + []interface{}{ + []interface{}{1, 2}, + []interface{}{3, 4}, + }, + []interface{}{ // Slice have items with the same value but with different index will treated as different slices + []interface{}{1, 2}, + []interface{}{2, 1}, + }, }, }, - AllInvalid: []interface{}{ - []interface{}{ // Violate outer array uniqueItems: true - []interface{}{8, 9}, - []interface{}{8, 9}, - }, - []interface{}{ // Violate inner array uniqueItems: true - []interface{}{9, 9}, - []interface{}{8, 8}, + AllInvalid: [][]interface{}{ + { + []interface{}{ // Violate outer array uniqueItems: true + []interface{}{8, 9}, + []interface{}{8, 9}, + }, + []interface{}{ // Violate inner array uniqueItems: true + []interface{}{9, 9}, + []interface{}{8, 8}, + }, }, }, }, @@ -681,72 +790,76 @@ var schemaExamples = []schemaExample{ "type": "array", }, }, - AllValid: []interface{}{ - []interface{}{ - []interface{}{ - map[string]interface{}{ - "key1": 1, - }, - }, - []interface{}{ - map[string]interface{}{ - "key1": 2, - }, - }, - }, - []interface{}{ // Slice have items with the same value but with different index will treated as different slices + AllValid: [][]interface{}{ + { []interface{}{ - map[string]interface{}{ - "key1": 1, + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, }, - map[string]interface{}{ - "key1": 2, + []interface{}{ + map[string]interface{}{ + "key1": 2, + }, }, }, - []interface{}{ - map[string]interface{}{ - "key1": 2, + []interface{}{ // Slice have items with the same value but with different index will treated as different slices + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 2, + }, }, - map[string]interface{}{ - "key1": 1, + []interface{}{ + map[string]interface{}{ + "key1": 2, + }, + map[string]interface{}{ + "key1": 1, + }, }, }, }, }, - AllInvalid: []interface{}{ - []interface{}{ // Violate outer array uniqueItems: true - []interface{}{ - map[string]interface{}{ - "key1": 1, - }, - map[string]interface{}{ - "key1": 2, - }, - }, - []interface{}{ - map[string]interface{}{ - "key1": 1, - }, - map[string]interface{}{ - "key1": 2, - }, - }, - }, - []interface{}{ // Violate inner array uniqueItems: true - []interface{}{ - map[string]interface{}{ - "key1": 1, + AllInvalid: [][]interface{}{ + { + []interface{}{ // Violate outer array uniqueItems: true + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 2, + }, }, - map[string]interface{}{ - "key1": 1, + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 2, + }, }, }, - []interface{}{ - map[string]interface{}{ - "key1": 2, + []interface{}{ // Violate inner array uniqueItems: true + []interface{}{ + map[string]interface{}{ + "key1": 1, + }, + map[string]interface{}{ + "key1": 1, + }, }, - map[string]interface{}{ - "key1": 2, + []interface{}{ + map[string]interface{}{ + "key1": 2, + }, + map[string]interface{}{ + "key1": 2, + }, }, }, }, @@ -771,30 +884,34 @@ var schemaExamples = []schemaExample{ }, }, }, - AllValid: []interface{}{ - map[string]interface{}{}, - map[string]interface{}{ - "numberProperty": 3.14, - }, - map[string]interface{}{ - "numberProperty": 3.14, - "some prop": nil, + AllValid: [][]interface{}{ + { + map[string]interface{}{}, + map[string]interface{}{ + "numberProperty": 3.14, + }, + map[string]interface{}{ + "numberProperty": 3.14, + "some prop": nil, + }, }, }, - AllInvalid: []interface{}{ - nil, - false, - true, - 3.14, - "", - []interface{}{}, - map[string]interface{}{ - "numberProperty": "abc", - }, - map[string]interface{}{ - "numberProperty": 3.14, - "some prop": 42, - "third": "prop", + AllInvalid: [][]interface{}{ + { + nil, + false, + true, + 3.14, + "", + []interface{}{}, + map[string]interface{}{ + "numberProperty": "abc", + }, + map[string]interface{}{ + "numberProperty": 3.14, + "some prop": 42, + "third": "prop", + }, }, }, }, @@ -813,16 +930,20 @@ var schemaExamples = []schemaExample{ "type": "number", }, }, - AllValid: []interface{}{ - map[string]interface{}{}, - map[string]interface{}{ - "x": 3.14, - "y": 3.14, + AllValid: [][]interface{}{ + { + map[string]interface{}{}, + map[string]interface{}{ + "x": 3.14, + "y": 3.14, + }, }, }, - AllInvalid: []interface{}{ - map[string]interface{}{ - "x": "abc", + AllInvalid: [][]interface{}{ + { + map[string]interface{}{ + "x": "abc", + }, }, }, }, @@ -835,11 +956,13 @@ var schemaExamples = []schemaExample{ "type": "object", "additionalProperties": true, }, - AllValid: []interface{}{ - map[string]interface{}{}, - map[string]interface{}{ - "x": false, - "y": 3.14, + AllValid: [][]interface{}{ + { + map[string]interface{}{}, + map[string]interface{}{ + "x": false, + "y": 3.14, + }, }, }, }, @@ -868,16 +991,20 @@ var schemaExamples = []schemaExample{ }, }, }, - AllValid: []interface{}{ - false, - 2, - "abc", + AllValid: [][]interface{}{ + { + false, + 2, + "abc", + }, }, - AllInvalid: []interface{}{ - nil, - true, - 3.14, - "not this", + AllInvalid: [][]interface{}{ + { + nil, + true, + 3.14, + "not this", + }, }, }, @@ -911,14 +1038,18 @@ var schemaExamples = []schemaExample{ }, }, }, - AllValid: []interface{}{ - 1, - 2, - 3, + AllValid: [][]interface{}{ + { + 1, + 2, + 3, + }, }, - AllInvalid: []interface{}{ - 0, - 4, + AllInvalid: [][]interface{}{ + { + 0, + 4, + }, }, }, @@ -952,14 +1083,18 @@ var schemaExamples = []schemaExample{ }, }, }, - AllValid: []interface{}{ - 2, + AllValid: [][]interface{}{ + { + 2, + }, }, - AllInvalid: []interface{}{ - 0, - 1, - 3, - 4, + AllInvalid: [][]interface{}{ + { + 0, + 1, + 3, + 4, + }, }, }, @@ -993,14 +1128,168 @@ var schemaExamples = []schemaExample{ }, }, }, - AllValid: []interface{}{ - 1, - 3, + AllValid: [][]interface{}{ + { + 1, + 3, + }, + }, + AllInvalid: [][]interface{}{ + { + 0, + 2, + 4, + }, + }, + }, + { + Title: "STRING: format 'hostname'", + Schema: NewHostnameSchema(), + Serialization: map[string]interface{}{ + "type": "string", + "format": "hostname", + }, + AllValid: [][]interface{}{ + { // OpenAPI 3.0: hostname format not define so anything is fine + "a", + "ab", + "a_b", + }, + { + "abc", + "a-b", + "a0b", + "ab9", + "0ab9", + "a-b.domain", + "a-b.sub-domain.domain", + "0.1", + }, + }, + AllInvalid: [][]interface{}{ + nil, + { + nil, + 3.14, + "a", + "ab", + "a_b", + "~test", + }, + }, + }, + + { + Title: "STRING: format 'ipv4'", + Schema: NewIPv4Schema(), + Serialization: map[string]interface{}{ + "type": "string", + "format": "ipv4", + }, + AllValid: [][]interface{}{ + { // OpenAPI 3.0: hostname format not define so anything is fine + "notchecked", + "pi", + }, + { + "127.0.0.1", + "192.168.1.2", + "192.168.1.0", + "10.1.2.3", + }, + }, + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + false, + 1, + }, + { + nil, + 3.14, + true, + 2, + "a.b.c.d", + "192.168.1.y", + // "192.168.1.02", // fixed in go 1.17 + // "192.168.01.2", // fixed in go 1.17 + // "10.01.2.3", // fixed in go 1.17 + // "010.1.2.3", // fixed in go 1.17 + "256.168.1.2", + "192.256.1.2", + "192.168.256.2", + "192.168.1.256", + "255", + "1.2", + "1.1.1.1.", + "-1.2.3.4", + "1...4", + "1.2..4", + "1..3.4", + "1.2.3.4.5", + ".2.3.4", + }, + }, + }, + + { + Title: "STRING: format 'ipv6'", + Schema: NewIPv6Schema(), + Serialization: map[string]interface{}{ + "type": "string", + "format": "ipv6", + }, + AllValid: [][]interface{}{ + { // OpenAPI 3.0: hostname format not define so anything is fine + "notchecked", + }, + { + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "2001:0db8:85a3:0:0:8a2e:0370:7334", + "2001:0db8:85a3::8a2e:0370:7334", + "2001:0db8:85a3::8a2e:370:7334", + "2001:db8::", + "::1234:5678", + "::", + "::1", + "::ffff:0.0.0.0", + "2001:db8:3333:4444:5555:6666:1.2.3.4", + }, }, - AllInvalid: []interface{}{ - 0, - 2, - 4, + AllInvalid: [][]interface{}{ + { + nil, + 3.14, + true, + }, + { + nil, + 3.14, + false, + "", + "56FE::2159:5BBC::6594", // double :: + "a.b.c.d", + "127.0.0.1", + "::192.168.1.y", + // "::192.168.1.02", // fixed in go 1.17 + // ";;192.168.01.2", // fixed in go 1.17 + // "::10.01.2.3", // fixed in go 1.17 + // "::010.1.2.3", // fixed in go 1.17 + "::256.168.1.2", + "::192.256.1.2", + "::192.168.256.2", + "::192.168.1.256", + "::1.2", + "::1.1.1.1.", + "::-1.2.3.4", + "::1...4", + "::1.2..4", + "::1..3.4", + "::1.2.3.4.5", + "::.2.3.4", + "::1:2:3.5:4", + }, }, }, } diff --git a/openapi3/schema_validation_settings.go b/openapi3/schema_validation_settings.go index 17aad2fa7..7acca8fcd 100644 --- a/openapi3/schema_validation_settings.go +++ b/openapi3/schema_validation_settings.go @@ -20,6 +20,8 @@ type schemaValidationSettings struct { defaultsSet func() customizeMessageError func(err *SchemaError) string + + openapiMinorVersion uint64 // defaults to 0 (3.0.z) } // FailFast returns schema validation errors quicker. @@ -77,3 +79,8 @@ func newSchemaValidationSettings(opts ...SchemaValidationOption) *schemaValidati } return settings } + +// SetOpenAPIMinorVersion setting allows to define minor OpenAPI version schema must comply with +func SetOpenAPIMinorVersion(minorVersion uint64) SchemaValidationOption { + return func(s *schemaValidationSettings) { s.openapiMinorVersion = minorVersion } +} diff --git a/openapi3filter/middleware_test.go b/openapi3filter/middleware_test.go index 1260ac54c..c1de74858 100644 --- a/openapi3filter/middleware_test.go +++ b/openapi3filter/middleware_test.go @@ -58,6 +58,7 @@ paths: name: id schema: type: string + format: uuid required: true - in: query name: version @@ -91,6 +92,7 @@ components: properties: id: type: string + format: uuid contents: { $ref: '#/components/schemas/TestContents' } required: [id, contents] @@ -113,31 +115,56 @@ components: ` type validatorTestHandler struct { + urlRE *regexp.Regexp contentType string getBody, postBody string errBody string errStatusCode int } -const validatorOkResponse = `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}}` +const numValidatorOkResponse = `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}}` +const uuidValidatorOkResponse = `{"id": "D1911DAD-DEBA-4C87-8071-A94BD99D27FC", "contents": {"name": "foo", "expected": 9, "actual": 10}}` -func (h validatorTestHandler) withDefaults() validatorTestHandler { +func (h validatorTestHandler) withNumDefaults() validatorTestHandler { if h.contentType == "" { h.contentType = "application/json" } if h.getBody == "" { - h.getBody = validatorOkResponse + h.getBody = numValidatorOkResponse } if h.postBody == "" { - h.postBody = validatorOkResponse + h.postBody = numValidatorOkResponse } if h.errBody == "" { h.errBody = `{"code":"bad","message":"bad things"}` } + if h.urlRE == nil { + h.urlRE = numUrlRE + } return h } -var testUrlRE = regexp.MustCompile(`^/test(/\d+)?$`) +func (h validatorTestHandler) withUUIDDefaults() validatorTestHandler { + if h.contentType == "" { + h.contentType = "application/json" + } + if h.getBody == "" { + h.getBody = uuidValidatorOkResponse + } + if h.postBody == "" { + h.postBody = uuidValidatorOkResponse + } + if h.errBody == "" { + h.errBody = `{"code":"bad","message":"bad things"}` + } + if h.urlRE == nil { + h.urlRE = uuidUrlRE + } + return h +} + +var numUrlRE = regexp.MustCompile(`^/test(/\d+)?$`) +var uuidUrlRE = regexp.MustCompile(`^/test(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})?$`) func (h *validatorTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", h.contentType) @@ -146,7 +173,7 @@ func (h *validatorTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) w.Write([]byte(h.errBody)) return } - if !testUrlRE.MatchString(r.URL.Path) { + if h.urlRE != nil && !h.urlRE.MatchString(r.URL.Path) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(h.errBody)) return @@ -179,26 +206,93 @@ func TestValidator(t *testing.T) { body string } tests := []struct { - name string - handler validatorTestHandler - options []openapi3filter.ValidatorOption - request testRequest - response testResponse - strict bool + name string + openapiVersion openapi3.Version + handler validatorTestHandler + options []openapi3filter.ValidatorOption + request testRequest + response testResponse + strict bool }{{ - name: "valid GET", - handler: validatorTestHandler{}.withDefaults(), + name: "valid num GET 3.0", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "GET", path: "/test/42?version=1", }, response: testResponse{ - 200, validatorOkResponse, + 200, numValidatorOkResponse, + }, + strict: true, + }, { + name: "valid uuid GET 3.0", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withUUIDDefaults(), + request: testRequest{ + method: "GET", + path: "/test/FDB366E3-340F-4DE0-97BD-EF08FB3D4EF7?version=1", + }, + response: testResponse{ + 200, uuidValidatorOkResponse, + }, + strict: true, + }, { + name: "valid uuid GET 3.1", + openapiVersion: "3.1.0", + handler: validatorTestHandler{}.withUUIDDefaults(), + request: testRequest{ + method: "GET", + path: "/test/FDB366E3-340F-4DE0-97BD-EF08FB3D4EF7?version=1", + }, + response: testResponse{ + 200, uuidValidatorOkResponse, + }, + strict: true, + }, { + name: "invalid num GET 3.1; uuid expected", + openapiVersion: "3.1.0", + handler: validatorTestHandler{}.withNumDefaults(), + request: testRequest{ + method: "GET", + path: "/test/42?version=1", + }, + response: testResponse{ + 400, "bad request\n", + }, + strict: true, + }, { + name: "valid num POST 3.0", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 201, numValidatorOkResponse, + }, + strict: true, + }, { + name: "valid uuid POST 3.0", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withUUIDDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 201, uuidValidatorOkResponse, }, strict: true, }, { - name: "valid POST", - handler: validatorTestHandler{}.withDefaults(), + name: "valid uuid POST 3.1", + openapiVersion: "3.1.0", + handler: validatorTestHandler{}.withUUIDDefaults(), request: testRequest{ method: "POST", path: "/test?version=1", @@ -206,12 +300,30 @@ func TestValidator(t *testing.T) { contentType: "application/json", }, response: testResponse{ - 201, validatorOkResponse, + 201, uuidValidatorOkResponse, }, strict: true, }, { - name: "not found; no GET operation for /test", - handler: validatorTestHandler{}.withDefaults(), + name: "invalid response POST 3.1; uuid expected", + openapiVersion: "3.1.0", + handler: validatorTestHandler{ + getBody: numValidatorOkResponse, + postBody: numValidatorOkResponse, + }.withUUIDDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 500, "server error\n", + }, + strict: true, + }, { + name: "not found; no GET operation for /test", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "GET", path: "/test?version=1", @@ -221,8 +333,9 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "not found; no POST operation for /test/42", - handler: validatorTestHandler{}.withDefaults(), + name: "not found; no POST operation for /test/42", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "POST", path: "/test/42?version=1", @@ -232,8 +345,9 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid request; missing version", - handler: validatorTestHandler{}.withDefaults(), + name: "invalid request; missing version", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "GET", path: "/test/42", @@ -243,8 +357,9 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid POST request; wrong property type", - handler: validatorTestHandler{}.withDefaults(), + name: "invalid POST request; wrong property type", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "POST", path: "/test?version=1", @@ -256,8 +371,9 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid POST request; missing property", - handler: validatorTestHandler{}.withDefaults(), + name: "invalid POST request; missing property", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "POST", path: "/test?version=1", @@ -269,8 +385,9 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid POST request; extra property", - handler: validatorTestHandler{}.withDefaults(), + name: "invalid POST request; extra property", + openapiVersion: "3.0.0", + handler: validatorTestHandler{}.withNumDefaults(), request: testRequest{ method: "POST", path: "/test?version=1", @@ -282,12 +399,13 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "valid response; 404 error", + name: "valid response 3.0; 404 error", + openapiVersion: "3.0.0", handler: validatorTestHandler{ contentType: "application/json", errBody: `{"code": "404", "message": "not found"}`, errStatusCode: 404, - }.withDefaults(), + }.withNumDefaults(), request: testRequest{ method: "GET", path: "/test/42?version=1", @@ -297,11 +415,62 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid response; invalid error", + name: "valid response 3.1; 404 error", + openapiVersion: "3.1.0", + handler: validatorTestHandler{ + contentType: "application/json", + errBody: `{"code": "404", "message": "not found"}`, + errStatusCode: 404, + }.withNumDefaults(), + request: testRequest{ + method: "GET", + path: "/test/4506CEF4-FB58-41C7-A5D2-AC538C59F1BA?version=1", + }, + response: testResponse{ + 404, `{"code": "404", "message": "not found"}`, + }, + strict: true, + }, { + name: "invalid response 3.1; uuid expected", + openapiVersion: "3.1.0", + handler: validatorTestHandler{ + getBody: numValidatorOkResponse, + postBody: numValidatorOkResponse, + }.withUUIDDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 500, "server error\n", + }, + strict: true, + }, { + name: "invalid response 3.1; not strict", + openapiVersion: "3.1.0", + handler: validatorTestHandler{ + getBody: numValidatorOkResponse, + postBody: numValidatorOkResponse, + }.withUUIDDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 201, numValidatorOkResponse, + }, + strict: false, + }, { + name: "invalid response; invalid error", + openapiVersion: "3.0.0", handler: validatorTestHandler{ errBody: `"not found"`, errStatusCode: 404, - }.withDefaults(), + }.withNumDefaults(), request: testRequest{ method: "GET", path: "/test/42?version=1", @@ -311,10 +480,11 @@ func TestValidator(t *testing.T) { }, strict: true, }, { - name: "invalid POST response; not strict", + name: "invalid POST response; not strict", + openapiVersion: "3.0.0", handler: validatorTestHandler{ postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, - }.withDefaults(), + }.withNumDefaults(), request: testRequest{ method: "POST", path: "/test?version=1", @@ -327,12 +497,13 @@ func TestValidator(t *testing.T) { }, strict: false, }, { - name: "POST response status code not in spec (return 200, spec only has 201)", + name: "POST response status code not in spec (return 200, spec only has 201)", + openapiVersion: "3.0.0", handler: validatorTestHandler{ postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, errStatusCode: 200, errBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, - }.withDefaults(), + }.withNumDefaults(), options: []openapi3filter.ValidatorOption{openapi3filter.ValidationOptions(openapi3filter.Options{ IncludeResponseStatus: true, })}, @@ -365,6 +536,9 @@ func TestValidator(t *testing.T) { err = doc.Validate(loader.Context) require.NoError(t, err, "failed to validate with test server") + // Define version + doc.OpenAPI = test.openapiVersion + // Create the router and validator router, err := gorillamux.NewRouter(doc) require.NoError(t, err, "failed to create router") diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index db3b07532..2b7a47727 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -181,6 +181,9 @@ func ValidateParameter(ctx context.Context, input *RequestValidationInput, param if options.customSchemaErrorFunc != nil { opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) } + if input.Route != nil { + opts = append(opts, openapi3.SetOpenAPIMinorVersion(input.Route.Spec.OpenAPI.Minor())) + } if err = schema.VisitJSON(value, opts...); err != nil { return &RequestError{Input: input, Parameter: parameter, Err: err} } @@ -285,6 +288,9 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req if options.ExcludeReadOnlyValidations { opts = append(opts, openapi3.DisableReadOnlyValidation()) } + if input.Route != nil { + opts = append(opts, openapi3.SetOpenAPIMinorVersion(input.Route.Spec.OpenAPI.Minor())) + } // Validate JSON with the schema if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index 67bff3cda..6bad6ebe9 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -73,6 +73,9 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error if options.ExcludeWriteOnlyValidations { opts = append(opts, openapi3.DisableWriteOnlyValidation()) } + if route != nil { + opts = append(opts, openapi3.SetOpenAPIMinorVersion(route.Spec.OpenAPI.Minor())) + } headers := make([]string, 0, len(response.Headers)) for k := range response.Headers { @@ -148,7 +151,7 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error } // Validate data with the schema. - if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil { + if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse(), openapi3.SetOpenAPIMinorVersion(route.Spec.OpenAPI.Minor()))...); err != nil { schemaId := getSchemaIdentifier(contentType.Schema) schemaId = prependSpaceIfNeeded(schemaId) return &ResponseError{