diff --git a/helpers/version.go b/helpers/version.go new file mode 100644 index 0000000..d59e509 --- /dev/null +++ b/helpers/version.go @@ -0,0 +1,15 @@ +package helpers + +import ( + "strings" +) + +// VersionToFloat converts a version string to a float32 for easier comparison. +func VersionToFloat(version string) float32 { + switch { + case strings.HasPrefix(version, "3.0"): + return 3.0 + default: + return 3.1 + } +} diff --git a/helpers/version_test.go b/helpers/version_test.go new file mode 100644 index 0000000..56bae29 --- /dev/null +++ b/helpers/version_test.go @@ -0,0 +1,63 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVersionToFloat(t *testing.T) { + tests := []struct { + name string + version string + expected float32 + }{ + { + name: "OpenAPI 3.0", + version: "3.0", + expected: 3.0, + }, + { + name: "OpenAPI 3.0.0", + version: "3.0.0", + expected: 3.0, + }, + { + name: "OpenAPI 3.0.3", + version: "3.0.3", + expected: 3.0, + }, + { + name: "OpenAPI 3.1", + version: "3.1", + expected: 3.1, + }, + { + name: "OpenAPI 3.1.0", + version: "3.1.0", + expected: 3.1, + }, + { + name: "OpenAPI 3.1.1", + version: "3.1.1", + expected: 3.1, + }, + { + name: "default to 3.1 for unknown version", + version: "4.0", + expected: 3.1, + }, + { + name: "default to 3.1 for empty string", + version: "", + expected: 3.1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := VersionToFloat(tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/requests/validate_body.go b/requests/validate_body.go index b0618d0..a8c91b0 100644 --- a/requests/validate_body.go +++ b/requests/validate_body.go @@ -108,7 +108,7 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req }) } - validationSucceeded, validationErrors := ValidateRequestSchema(request, schema, renderedInline, renderedJSON, config.WithExistingOpts(v.options)) + validationSucceeded, validationErrors := ValidateRequestSchema(request, schema, renderedInline, renderedJSON, helpers.VersionToFloat(v.document.Version), config.WithExistingOpts(v.options)) errors.PopulateValidationErrors(validationErrors, request, pathValue) diff --git a/requests/validate_request.go b/requests/validate_request.go index 64550cb..67cdd28 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -34,6 +34,7 @@ func ValidateRequestSchema( schema *base.Schema, renderedSchema, jsonSchema []byte, + version float32, opts ...config.Option, ) (bool, []*errors.ValidationError) { validationOptions := config.NewValidationOptions(opts...) @@ -110,7 +111,7 @@ func ValidateRequestSchema( } // Attempt to compile the JSON schema - jsch, err := helpers.NewCompiledSchema("requestBody", jsonSchema, validationOptions) + jsch, err := helpers.NewCompiledSchemaWithVersion("requestBody", jsonSchema, validationOptions, version) if err != nil { validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.RequestBodyValidation, diff --git a/requests/validate_request_test.go b/requests/validate_request_test.go index aa7b1d9..73eed4a 100644 --- a/requests/validate_request_test.go +++ b/requests/validate_request_test.go @@ -15,6 +15,7 @@ func TestValidateRequestSchema(t *testing.T) { request *http.Request schema *base.Schema renderedSchema, jsonSchema []byte + version float32 assertValidRequestSchema assert.BoolAssertionFunc expectedErrorsCount int }{ @@ -31,6 +32,7 @@ properties: exclusiveMinimum: true minimum: !!float 10`), jsonSchema: []byte(`{"properties":{"exclusiveNumber":{"description":"This number starts its journey where most numbers are too scared to begin!","exclusiveMinimum":true,"minimum":10,"type":"number"}},"type":"object"}`), + version: 3.1, assertValidRequestSchema: assert.False, expectedErrorsCount: 1, }, @@ -47,6 +49,7 @@ properties: exclusiveMinimum: 12 minimum: 12`), jsonSchema: []byte(`{"properties":{"exclusiveNumber":{"type":"number","description":"This number is properly constrained by a numeric exclusive minimum.","exclusiveMinimum":12,"minimum":12}},"type":"object"}`), + version: 3.1, assertValidRequestSchema: assert.True, expectedErrorsCount: 0, }, @@ -57,20 +60,58 @@ properties: }, renderedSchema: []byte(`type: object properties: - greeting: - type: string - description: A simple greeting - example: "Hello, world!"`), + greeting: + type: string + description: A simple greeting + example: "Hello, world!"`), jsonSchema: []byte(`{"properties":{"greeting":{"type":"string","description":"A simple greeting","example":"Hello, world!"}},"type":"object"}`), + version: 3.1, assertValidRequestSchema: assert.True, expectedErrorsCount: 0, }, + "PassWithNullablePropertyInOpenAPI30": { + request: postRequestWithBody(`{"name": "John", "middleName": null}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + name: + type: string + description: User's first name + middleName: + type: string + nullable: true + description: User's middle name (optional)`), + jsonSchema: []byte(`{"properties":{"name":{"type":"string","description":"User's first name"},"middleName":{"type":"string","nullable":true,"description":"User's middle name (optional)"}},"type":"object"}`), + version: 3.0, + assertValidRequestSchema: assert.True, + expectedErrorsCount: 0, + }, + "PassWithNullablePropertyInOpenAPI31": { + request: postRequestWithBody(`{"name": "John", "middleName": null}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + name: + type: string + description: User's first name + middleName: + type: string + nullable: true + description: User's middle name (optional)`), + jsonSchema: []byte(`{"properties":{"name":{"type":"string","description":"User's first name"},"middleName":{"type":"string","nullable":true,"description":"User's middle name (optional)"}},"type":"object"}`), + version: 3.1, + assertValidRequestSchema: assert.False, + expectedErrorsCount: 1, + }, } { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() - valid, errors := ValidateRequestSchema(tc.request, tc.schema, tc.renderedSchema, tc.jsonSchema) + valid, errors := ValidateRequestSchema(tc.request, tc.schema, tc.renderedSchema, tc.jsonSchema, tc.version) tc.assertValidRequestSchema(t, valid) assert.Len(t, errors, tc.expectedErrorsCount) @@ -98,7 +139,7 @@ properties: valid, errors := ValidateRequestSchema(postRequestWithBody(`{"exclusiveNumber": 13}`), &base.Schema{ Type: []string{"object"}, - }, renderedSchema, jsonSchema) + }, renderedSchema, jsonSchema, 3.1) assert.False(t, valid) assert.Len(t, errors, 1) diff --git a/responses/validate_body.go b/responses/validate_body.go index 801a088..7d42bde 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -186,7 +186,7 @@ func (v *responseBodyValidator) checkResponseSchema( if len(renderedInline) > 0 && len(renderedJSON) > 0 && schema != nil { // render the schema, to be used for validation - valid, vErrs := ValidateResponseSchema(request, response, schema, renderedInline, renderedJSON, config.WithRegexEngine(v.options.RegexEngine)) + valid, vErrs := ValidateResponseSchema(request, response, schema, renderedInline, renderedJSON, helpers.VersionToFloat(v.document.Version), config.WithRegexEngine(v.options.RegexEngine)) if !valid { validationErrors = append(validationErrors, vErrs...) } diff --git a/responses/validate_response.go b/responses/validate_response.go index 7011533..f31b68b 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -38,6 +38,7 @@ func ValidateResponseSchema( schema *base.Schema, renderedSchema, jsonSchema []byte, + version float32, opts ...config.Option, ) (bool, []*errors.ValidationError) { options := config.NewValidationOptions(opts...) @@ -128,7 +129,7 @@ func ValidateResponseSchema( } // create a new jsonschema compiler and add in the rendered JSON schema. - jsch, err := helpers.NewCompiledSchema(helpers.ResponseBodyValidation, jsonSchema, options) + jsch, err := helpers.NewCompiledSchemaWithVersion(helpers.ResponseBodyValidation, jsonSchema, options, version) if err != nil { // schema compilation failed, return validation error instead of panicking violation := &errors.SchemaValidationFailure{ diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go new file mode 100644 index 0000000..8313abe --- /dev/null +++ b/responses/validate_response_test.go @@ -0,0 +1,166 @@ +package responses + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/stretchr/testify/assert" +) + +func TestValidateResponseSchema(t *testing.T) { + for name, tc := range map[string]struct { + request *http.Request + response *http.Response + schema *base.Schema + renderedSchema, jsonSchema []byte + version float32 + assertValidResponseSchema assert.BoolAssertionFunc + expectedErrorsCount int + }{ + "FailOnBooleanExclusiveMinimum": { + request: postRequest(), + response: responseWithBody(`{"exclusiveNumber": 13}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + exclusiveNumber: + type: number + description: This number starts its journey where most numbers are too scared to begin! + exclusiveMinimum: true + minimum: !!float 10`), + jsonSchema: []byte(`{"properties":{"exclusiveNumber":{"description":"This number starts its journey where most numbers are too scared to begin!","exclusiveMinimum":true,"minimum":10,"type":"number"}},"type":"object"}`), + version: 3.1, + assertValidResponseSchema: assert.False, + expectedErrorsCount: 1, + }, + "PassWithCorrectExclusiveMinimum": { + request: postRequest(), + response: responseWithBody(`{"exclusiveNumber": 15}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + exclusiveNumber: + type: number + description: This number is properly constrained by a numeric exclusive minimum. + exclusiveMinimum: 12 + minimum: 12`), + jsonSchema: []byte(`{"properties":{"exclusiveNumber":{"type":"number","description":"This number is properly constrained by a numeric exclusive minimum.","exclusiveMinimum":12,"minimum":12}},"type":"object"}`), + version: 3.1, + assertValidResponseSchema: assert.True, + expectedErrorsCount: 0, + }, + "PassWithValidStringType": { + request: postRequest(), + response: responseWithBody(`{"greeting": "Hello, world!"}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + greeting: + type: string + description: A simple greeting + example: "Hello, world!"`), + jsonSchema: []byte(`{"properties":{"greeting":{"type":"string","description":"A simple greeting","example":"Hello, world!"}},"type":"object"}`), + version: 3.1, + assertValidResponseSchema: assert.True, + expectedErrorsCount: 0, + }, + "PassWithNullablePropertyInOpenAPI30": { + request: postRequest(), + response: responseWithBody(`{"name": "John", "middleName": null}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + name: + type: string + description: User's first name + middleName: + type: string + nullable: true + description: User's middle name (optional)`), + jsonSchema: []byte(`{"properties":{"name":{"type":"string","description":"User's first name"},"middleName":{"type":"string","nullable":true,"description":"User's middle name (optional)"}},"type":"object"}`), + version: 3.0, + assertValidResponseSchema: assert.True, + expectedErrorsCount: 0, + }, + "PassWithNullablePropertyInOpenAPI31": { + request: postRequest(), + response: responseWithBody(`{"name": "John", "middleName": null}`), + schema: &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema: []byte(`type: object +properties: + name: + type: string + description: User's first name + middleName: + type: string + nullable: true + description: User's middle name (optional)`), + jsonSchema: []byte(`{"properties":{"name":{"type":"string","description":"User's first name"},"middleName":{"type":"string","nullable":true,"description":"User's middle name (optional)"}},"type":"object"}`), + version: 3.1, + assertValidResponseSchema: assert.False, + expectedErrorsCount: 1, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + valid, errors := ValidateResponseSchema(tc.request, tc.response, tc.schema, tc.renderedSchema, tc.jsonSchema, tc.version) + + tc.assertValidResponseSchema(t, valid) + assert.Len(t, errors, tc.expectedErrorsCount) + }) + } +} + +func postRequest() *http.Request { + req, _ := http.NewRequest(http.MethodPost, "/test", io.NopCloser(strings.NewReader(""))) + return req +} + +func responseWithBody(payload string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(payload))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + } +} + +func TestInvalidMin(t *testing.T) { + renderedSchema := []byte(`type: object +properties: + exclusiveNumber: + type: number + description: This number starts its journey where most numbers are too scared to begin! + exclusiveMinimum: true + minimum: !!float 10`) + + jsonSchema := []byte(`{"properties":{"exclusiveNumber":{"description":"This number starts its journey where most numbers are too scared to begin!","exclusiveMinimum":true,"minimum":10,"type":"number"}},"type":"object"}`) + + valid, errors := ValidateResponseSchema( + postRequest(), + responseWithBody(`{"exclusiveNumber": 13}`), + &base.Schema{ + Type: []string{"object"}, + }, + renderedSchema, + jsonSchema, + 3.1, + ) + + assert.False(t, valid) + assert.Len(t, errors, 1) +}