diff --git a/openapi3/components.go b/openapi3/components.go index 2f19943db..e9af26911 100644 --- a/openapi3/components.go +++ b/openapi3/components.go @@ -99,7 +99,7 @@ func (components *Components) Validate(ctx context.Context) (err error) { return } if err = v.Validate(ctx); err != nil { - return + return fmt.Errorf("%s: %s", k, err) } } diff --git a/openapi3/example.go b/openapi3/example.go index ee40d9e37..e63c78fa6 100644 --- a/openapi3/example.go +++ b/openapi3/example.go @@ -2,6 +2,7 @@ package openapi3 import ( "context" + "errors" "fmt" "github.com/go-openapi/jsonpointer" @@ -55,5 +56,12 @@ func (example *Example) UnmarshalJSON(data []byte) error { // Validate returns an error if Example does not comply with the OpenAPI spec. func (example *Example) Validate(ctx context.Context) error { - return nil // TODO + if example.Value != nil && example.ExternalValue != "" { + return errors.New("value and externalValue are mutually exclusive") + } + if example.Value == nil && example.ExternalValue == "" { + return errors.New("example has no value or externalValue field") + } + + return nil } diff --git a/openapi3/example_validation.go b/openapi3/example_validation.go new file mode 100644 index 000000000..4c75e360b --- /dev/null +++ b/openapi3/example_validation.go @@ -0,0 +1,5 @@ +package openapi3 + +func validateExampleValue(input interface{}, schema *Schema) error { + return schema.VisitJSON(input, MultiErrors()) +} diff --git a/openapi3/example_validation_test.go b/openapi3/example_validation_test.go new file mode 100644 index 000000000..85e158e6b --- /dev/null +++ b/openapi3/example_validation_test.go @@ -0,0 +1,426 @@ +package openapi3 + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExamplesSchemaValidation(t *testing.T) { + type testCase struct { + name string + requestSchemaExample string + responseSchemaExample string + mediaTypeRequestExample string + parametersExample string + componentExamples string + errContains string + } + + testCases := []testCase{ + { + name: "invalid_parameter_examples", + parametersExample: ` + examples: + param1example: + value: abcd + `, + errContains: "invalid paths: param1example", + }, + { + name: "valid_parameter_examples", + parametersExample: ` + examples: + param1example: + value: 1 + `, + }, + { + name: "invalid_parameter_example", + parametersExample: ` + example: abcd + `, + errContains: "invalid paths", + }, + { + name: "valid_parameter_example", + parametersExample: ` + example: 1 + `, + }, + { + name: "invalid_component_examples", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + `, + componentExamples: ` + examples: + BadUser: + value: + username: "]bad[" + email: bad + password: short + `, + errContains: "invalid paths: BadUser", + }, + { + name: "valid_component_examples", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + `, + componentExamples: ` + examples: + BadUser: + value: + username: good + email: good@mail.com + password: password + `, + }, + { + name: "invalid_mediatype_examples", + mediaTypeRequestExample: ` + example: + username: "]bad[" + email: bad + password: short + `, + errContains: "invalid paths", + }, + { + name: "valid_mediatype_examples", + mediaTypeRequestExample: ` + example: + username: good + email: good@mail.com + password: password + `, + }, + { + name: "invalid_schema_request_example", + requestSchemaExample: ` + example: + username: good + email: good@email.com + # missing password + `, + errContains: "invalid schema example", + }, + { + name: "valid_schema_request_example", + requestSchemaExample: ` + example: + username: good + email: good@email.com + password: password + `, + }, + { + name: "invalid_schema_response_example", + responseSchemaExample: ` + example: + user_id: 1 + # missing access_token + `, + errContains: "invalid schema example", + }, + { + name: "valid_schema_response_example", + responseSchemaExample: ` + example: + user_id: 1 + access_token: "abcd" + `, + }, + } + + testOptions := []struct { + name string + disableExamplesValidation bool + }{ + { + name: "examples_validation_disabled", + disableExamplesValidation: true, + }, + { + name: "examples_validation_enabled", + disableExamplesValidation: false, + }, + } + + t.Parallel() + + for _, testOption := range testOptions { + testOption := testOption + t.Run(testOption.name, func(t *testing.T) { + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + loader := NewLoader() + + spec := bytes.Buffer{} + spec.WriteString(` +openapi: 3.0.3 +info: + title: An API + version: 1.2.3.4 +paths: + /user: + post: + description: User creation. + operationId: createUser + parameters: + - name: param1 + in: 'query' + schema: + format: int64 + type: integer`) + spec.WriteString(tc.parametersExample) + spec.WriteString(` + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" +`) + spec.WriteString(tc.mediaTypeRequestExample) + spec.WriteString(` + description: Created user object + required: true + responses: + '204': + description: "success" + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserResponse" +components: + schemas: + CreateUserRequest:`) + spec.WriteString(tc.requestSchemaExample) + spec.WriteString(` + required: + - username + - email + - password + properties: + username: + type: string + pattern: "^[ a-zA-Z0-9_-]+$" + minLength: 3 + email: + type: string + pattern: "^[A-Za-z0-9+_.-]+@(.+)$" + password: + type: string + minLength: 7 + type: object + CreateUserResponse:`) + spec.WriteString(tc.responseSchemaExample) + spec.WriteString(` + description: represents the response to a User creation + required: + - access_token + - user_id + properties: + access_token: + type: string + user_id: + format: int64 + type: integer + type: object +`) + spec.WriteString(tc.componentExamples) + + doc, err := loader.LoadFromData(spec.Bytes()) + require.NoError(t, err) + + if testOption.disableExamplesValidation { + err = doc.Validate(loader.Context, DisableExamplesValidation()) + } else { + err = doc.Validate(loader.Context) + } + + if tc.errContains != "" && !testOption.disableExamplesValidation { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + } else { + require.NoError(t, err) + } + }) + } + }) + } +} + +func TestExampleObjectValidation(t *testing.T) { + type testCase struct { + name string + mediaTypeRequestExample string + componentExamples string + errContains string + } + + testCases := []testCase{ + { + name: "example_examples_mutually_exclusive", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + example: + username: good + email: real@email.com + password: validpassword +`, + errContains: "example and examples are mutually exclusive", + componentExamples: ` + examples: + BadUser: + value: + username: "]bad[" + email: bad + password: short +`, + }, + { + name: "example_without_value", + componentExamples: ` + examples: + BadUser: + description: empty user example +`, + errContains: "example has no value or externalValue field", + }, + { + name: "value_externalValue_mutual_exclusion", + componentExamples: ` + examples: + BadUser: + value: + username: good + email: real@email.com + password: validpassword + externalValue: 'http://example.com/examples/example' +`, + errContains: "value and externalValue are mutually exclusive", + }, + } + + testOptions := []struct { + name string + disableExamplesValidation bool + }{ + { + name: "examples_validation_disabled", + disableExamplesValidation: true, + }, + { + name: "examples_validation_enabled", + disableExamplesValidation: false, + }, + } + + t.Parallel() + + for _, testOption := range testOptions { + testOption := testOption + t.Run(testOption.name, func(t *testing.T) { + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + loader := NewLoader() + + spec := bytes.Buffer{} + spec.WriteString(` +openapi: 3.0.3 +info: + title: An API + version: 1.2.3.4 +paths: + /user: + post: + description: User creation. + operationId: createUser + parameters: + - name: param1 + in: 'query' + schema: + format: int64 + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" +`) + spec.WriteString(tc.mediaTypeRequestExample) + spec.WriteString(` + description: Created user object + required: true + responses: + '204': + description: "success" + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserResponse" +components: + schemas: + CreateUserRequest: + required: + - username + - email + - password + properties: + username: + type: string + pattern: "^[ a-zA-Z0-9_-]+$" + minLength: 3 + email: + type: string + pattern: "^[A-Za-z0-9+_.-]+@(.+)$" + password: + type: string + minLength: 7 + type: object + CreateUserResponse: + description: represents the response to a User creation + required: + - access_token + - user_id + properties: + access_token: + type: string + user_id: + format: int64 + type: integer + type: object +`) + spec.WriteString(tc.componentExamples) + + doc, err := loader.LoadFromData(spec.Bytes()) + require.NoError(t, err) + + if testOption.disableExamplesValidation { + err = doc.Validate(loader.Context, DisableExamplesValidation()) + } else { + err = doc.Validate(loader.Context) + } + + if tc.errContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + } else { + require.NoError(t, err) + } + }) + } + }) + } +} diff --git a/openapi3/media_type.go b/openapi3/media_type.go index fc95244c6..01df12ad0 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -2,6 +2,8 @@ package openapi3 import ( "context" + "errors" + "fmt" "github.com/go-openapi/jsonpointer" @@ -80,7 +82,28 @@ func (mediaType *MediaType) Validate(ctx context.Context) error { if err := schema.Validate(ctx); err != nil { return err } + if mediaType.Example != nil && mediaType.Examples != nil { + return errors.New("example and examples are mutually exclusive") + } + if validationOpts := getValidationOptions(ctx); validationOpts.ExamplesValidationDisabled { + return nil + } + if example := mediaType.Example; example != nil { + if err := validateExampleValue(example, schema.Value); err != nil { + return err + } + } else if examples := mediaType.Examples; examples != nil { + for k, v := range examples { + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("%s: %s", k, err) + } + if err := validateExampleValue(v.Value.Value, schema.Value); err != nil { + return fmt.Errorf("%s: %s", k, err) + } + } + } } + return nil } diff --git a/openapi3/parameter.go b/openapi3/parameter.go index 77834847d..f5d7d1f2f 100644 --- a/openapi3/parameter.go +++ b/openapi3/parameter.go @@ -303,16 +303,38 @@ func (parameter *Parameter) Validate(ctx context.Context) error { e := errors.New("parameter must contain exactly one of content and schema") return fmt.Errorf("parameter %q schema is invalid: %v", parameter.Name, e) } - if schema := parameter.Schema; schema != nil { - if err := schema.Validate(ctx); err != nil { - return fmt.Errorf("parameter %q schema is invalid: %v", parameter.Name, err) - } - } if content := parameter.Content; content != nil { if err := content.Validate(ctx); err != nil { return fmt.Errorf("parameter %q content is invalid: %v", parameter.Name, err) } } + + if schema := parameter.Schema; schema != nil { + if err := schema.Validate(ctx); err != nil { + return fmt.Errorf("parameter %q schema is invalid: %v", parameter.Name, err) + } + if parameter.Example != nil && parameter.Examples != nil { + return fmt.Errorf("parameter %q example and examples are mutually exclusive", parameter.Name) + } + if validationOpts := getValidationOptions(ctx); validationOpts.ExamplesValidationDisabled { + return nil + } + if example := parameter.Example; example != nil { + if err := validateExampleValue(example, schema.Value); err != nil { + return err + } + } else if examples := parameter.Examples; examples != nil { + for k, v := range examples { + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("%s: %s", k, err) + } + if err := validateExampleValue(v.Value.Value, schema.Value); err != nil { + return fmt.Errorf("%s: %s", k, err) + } + } + } + } + return nil } diff --git a/openapi3/schema.go b/openapi3/schema.go index 57f63fbd8..c59070ee1 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -753,6 +753,12 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) (err error) } } + if x := schema.Example; x != nil && !validationOpts.ExamplesValidationDisabled { + if err := validateExampleValue(x, schema); err != nil { + return fmt.Errorf("invalid schema example: %s", err) + } + } + return } diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index f6038ed10..5c0d01d2f 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -9,6 +9,7 @@ type ValidationOption func(options *ValidationOptions) type ValidationOptions struct { SchemaFormatValidationEnabled bool SchemaPatternValidationDisabled bool + ExamplesValidationDisabled bool } type validationOptionsKey struct{} @@ -27,6 +28,13 @@ func DisableSchemaPatternValidation() ValidationOption { } } +// DisableExamplesValidation disables all example schema validation. +func DisableExamplesValidation() ValidationOption { + return func(options *ValidationOptions) { + options.ExamplesValidationDisabled = true + } +} + // WithValidationOptions allows adding validation options to a context object that can be used when validationg any OpenAPI type. func WithValidationOptions(ctx context.Context, options *ValidationOptions) context.Context { return context.WithValue(ctx, validationOptionsKey{}, options)