diff --git a/openapi3/example_validation.go b/openapi3/example_validation.go index 4c75e360b..fb7a1da16 100644 --- a/openapi3/example_validation.go +++ b/openapi3/example_validation.go @@ -1,5 +1,16 @@ package openapi3 -func validateExampleValue(input interface{}, schema *Schema) error { - return schema.VisitJSON(input, MultiErrors()) +import "context" + +func validateExampleValue(ctx context.Context, input interface{}, schema *Schema) error { + opts := make([]SchemaValidationOption, 0, 2) + + if vo := getValidationOptions(ctx); vo.examplesValidationAsReq { + opts = append(opts, VisitAsRequest()) + } else if vo.examplesValidationAsRes { + opts = append(opts, VisitAsResponse()) + } + opts = append(opts, MultiErrors()) + + return schema.VisitJSON(input, opts...) } diff --git a/openapi3/example_validation_test.go b/openapi3/example_validation_test.go index f6a495ace..79288c299 100644 --- a/openapi3/example_validation_test.go +++ b/openapi3/example_validation_test.go @@ -9,13 +9,16 @@ import ( func TestExamplesSchemaValidation(t *testing.T) { type testCase struct { - name string - requestSchemaExample string - responseSchemaExample string - mediaTypeRequestExample string - parametersExample string - componentExamples string - errContains string + name string + requestSchemaExample string + responseSchemaExample string + mediaTypeRequestExample string + mediaTypeResponseExample string + readWriteOnlyMediaTypeRequestExample string + readWriteOnlyMediaTypeResponseExample string + parametersExample string + componentExamples string + errContains string } testCases := []testCase{ @@ -26,7 +29,7 @@ func TestExamplesSchemaValidation(t *testing.T) { param1example: value: abcd `, - errContains: "invalid paths: invalid path /user: invalid operation POST: param1example", + errContains: `invalid paths: invalid path /user: invalid operation POST: param1example`, }, { name: "valid_parameter_examples", @@ -41,7 +44,7 @@ func TestExamplesSchemaValidation(t *testing.T) { parametersExample: ` example: abcd `, - errContains: "invalid path /user: invalid operation POST: invalid example", + errContains: `invalid path /user: invalid operation POST: invalid example`, }, { name: "valid_parameter_example", @@ -64,7 +67,7 @@ func TestExamplesSchemaValidation(t *testing.T) { email: bad password: short `, - errContains: "invalid paths: invalid path /user: invalid operation POST: example BadUser", + errContains: `invalid paths: invalid path /user: invalid operation POST: example BadUser`, }, { name: "valid_component_examples", @@ -90,7 +93,7 @@ func TestExamplesSchemaValidation(t *testing.T) { email: bad password: short `, - errContains: "invalid path /user: invalid operation POST: invalid example", + errContains: `invalid path /user: invalid operation POST: invalid example`, }, { name: "valid_mediatype_examples", @@ -109,7 +112,7 @@ func TestExamplesSchemaValidation(t *testing.T) { email: good@email.com # missing password `, - errContains: "schema \"CreateUserRequest\": invalid example", + errContains: `schema "CreateUserRequest": invalid example`, }, { name: "valid_schema_request_example", @@ -127,7 +130,7 @@ func TestExamplesSchemaValidation(t *testing.T) { user_id: 1 # missing access_token `, - errContains: "schema \"CreateUserResponse\": invalid example", + errContains: `schema "CreateUserResponse": invalid example`, }, { name: "valid_schema_response_example", @@ -135,7 +138,64 @@ func TestExamplesSchemaValidation(t *testing.T) { example: user_id: 1 access_token: "abcd" - `, + `, + }, + { + name: "valid_readonly_writeonly_examples", + readWriteOnlyMediaTypeRequestExample: ` + examples: + ReadWriteOnlyRequest: + $ref: '#/components/examples/ReadWriteOnlyRequestData' +`, + readWriteOnlyMediaTypeResponseExample: ` + examples: + ReadWriteOnlyResponse: + $ref: '#/components/examples/ReadWriteOnlyResponseData' +`, + componentExamples: ` + examples: + ReadWriteOnlyRequestData: + value: + username: user + password: password + ReadWriteOnlyResponseData: + value: + user_id: 4321 + `, + }, + { + name: "invalid_readonly_request_examples", + readWriteOnlyMediaTypeRequestExample: ` + examples: + ReadWriteOnlyRequest: + $ref: '#/components/examples/ReadWriteOnlyRequestData' +`, + componentExamples: ` + examples: + ReadWriteOnlyRequestData: + value: + username: user + password: password + user_id: 4321 +`, + errContains: `ReadWriteOnlyRequest: readOnly property "user_id" in request`, + }, + { + name: "invalid_writeonly_response_examples", + readWriteOnlyMediaTypeResponseExample: ` + examples: + ReadWriteOnlyResponse: + $ref: '#/components/examples/ReadWriteOnlyResponseData' +`, + componentExamples: ` + examples: + ReadWriteOnlyResponseData: + value: + password: password + user_id: 4321 +`, + + errContains: `ReadWriteOnlyResponse: writeOnly property "password" in response`, }, } @@ -198,7 +258,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateUserResponse" + $ref: "#/components/schemas/CreateUserResponse"`) + spec.WriteString(tc.mediaTypeResponseExample) + spec.WriteString(` + /readWriteOnly: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ReadWriteOnlyData" + required: true`) + spec.WriteString(tc.readWriteOnlyMediaTypeRequestExample) + spec.WriteString(` + responses: + '201': + description: a response + content: + application/json: + schema: + $ref: "#/components/schemas/ReadWriteOnlyData"`) + spec.WriteString(tc.readWriteOnlyMediaTypeResponseExample) + spec.WriteString(` components: schemas: CreateUserRequest:`) @@ -223,7 +304,6 @@ components: CreateUserResponse:`) spec.WriteString(tc.responseSchemaExample) spec.WriteString(` - description: represents the response to a User creation required: - access_token - user_id @@ -234,6 +314,28 @@ components: format: int64 type: integer type: object + ReadWriteOnlyData: + required: + # only required in request + - username + - password + # only required in response + - user_id + properties: + username: + type: string + default: default + writeOnly: true # only sent in a request + password: + type: string + default: default + writeOnly: true # only sent in a request + user_id: + format: int64 + default: 1 + type: integer + readOnly: true # only returned in a response + type: object `) spec.WriteString(tc.componentExamples) @@ -278,7 +380,7 @@ func TestExampleObjectValidation(t *testing.T) { email: real@email.com password: validpassword `, - errContains: "invalid path /user: invalid operation POST: example and examples are mutually exclusive", + errContains: `invalid path /user: invalid operation POST: example and examples are mutually exclusive`, componentExamples: ` examples: BadUser: @@ -295,7 +397,7 @@ func TestExampleObjectValidation(t *testing.T) { BadUser: description: empty user example `, - errContains: "invalid components: example \"BadUser\": no value or externalValue field", + errContains: `invalid components: example "BadUser": no value or externalValue field`, }, { name: "value_externalValue_mutual_exclusion", @@ -308,7 +410,7 @@ func TestExampleObjectValidation(t *testing.T) { password: validpassword externalValue: 'http://example.com/examples/example' `, - errContains: "invalid components: example \"BadUser\": value and externalValue are mutually exclusive", + errContains: `invalid components: example "BadUser": value and externalValue are mutually exclusive`, }, } diff --git a/openapi3/media_type.go b/openapi3/media_type.go index 1ae7c996a..3500334f7 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -88,12 +88,12 @@ func (mediaType *MediaType) Validate(ctx context.Context) error { return errors.New("example and examples are mutually exclusive") } - if validationOpts := getValidationOptions(ctx); validationOpts.ExamplesValidationDisabled { + if vo := getValidationOptions(ctx); vo.ExamplesValidationDisabled { return nil } if example := mediaType.Example; example != nil { - if err := validateExampleValue(example, schema.Value); err != nil { + if err := validateExampleValue(ctx, example, schema.Value); err != nil { return fmt.Errorf("invalid example: %w", err) } } @@ -109,7 +109,7 @@ func (mediaType *MediaType) Validate(ctx context.Context) error { if err := v.Validate(ctx); err != nil { return fmt.Errorf("example %s: %w", k, err) } - if err := validateExampleValue(v.Value.Value, schema.Value); err != nil { + if err := validateExampleValue(ctx, v.Value.Value, schema.Value); err != nil { return fmt.Errorf("example %s: %w", k, err) } } diff --git a/openapi3/parameter.go b/openapi3/parameter.go index 73a39a54f..fa07d6555 100644 --- a/openapi3/parameter.go +++ b/openapi3/parameter.go @@ -318,11 +318,12 @@ func (parameter *Parameter) Validate(ctx context.Context) error { 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 { + + if vo := getValidationOptions(ctx); vo.ExamplesValidationDisabled { return nil } if example := parameter.Example; example != nil { - if err := validateExampleValue(example, schema.Value); err != nil { + if err := validateExampleValue(ctx, example, schema.Value); err != nil { return fmt.Errorf("invalid example: %w", err) } } else if examples := parameter.Examples; examples != nil { @@ -336,7 +337,7 @@ func (parameter *Parameter) Validate(ctx context.Context) error { if err := v.Validate(ctx); err != nil { return fmt.Errorf("%s: %w", k, err) } - if err := validateExampleValue(v.Value.Value, schema.Value); err != nil { + if err := validateExampleValue(ctx, v.Value.Value, schema.Value); err != nil { return fmt.Errorf("%s: %w", k, err) } } diff --git a/openapi3/request_body.go b/openapi3/request_body.go index c97563a11..d28133c96 100644 --- a/openapi3/request_body.go +++ b/openapi3/request_body.go @@ -109,5 +109,10 @@ func (requestBody *RequestBody) Validate(ctx context.Context) error { if requestBody.Content == nil { return errors.New("content of the request body is required") } + + if vo := getValidationOptions(ctx); !vo.ExamplesValidationDisabled { + vo.examplesValidationAsReq, vo.examplesValidationAsRes = true, false + } + return requestBody.Content.Validate(ctx) } diff --git a/openapi3/response.go b/openapi3/response.go index 62361ad74..37325bbb7 100644 --- a/openapi3/response.go +++ b/openapi3/response.go @@ -115,6 +115,9 @@ func (response *Response) Validate(ctx context.Context) error { if response.Description == nil { return errors.New("a short description of the response is required") } + if vo := getValidationOptions(ctx); !vo.ExamplesValidationDisabled { + vo.examplesValidationAsReq, vo.examplesValidationAsRes = false, true + } if content := response.Content; content != nil { if err := content.Validate(ctx); err != nil { diff --git a/openapi3/schema.go b/openapi3/schema.go index b38d25707..596ec04b6 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -769,7 +769,7 @@ 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 { + if err := validateExampleValue(ctx, x, schema); err != nil { return fmt.Errorf("invalid example: %w", err) } } @@ -1449,6 +1449,8 @@ func (schema *Schema) visitJSONObject(settings *schemaValidationSettings, value return schema.expectedType(settings, TypeObject) } + var me MultiError + if settings.asreq || settings.asrep { properties := make([]string, 0, len(schema.Properties)) for propName := range schema.Properties { @@ -1457,19 +1459,28 @@ func (schema *Schema) visitJSONObject(settings *schemaValidationSettings, value sort.Strings(properties) for _, propName := range properties { propSchema := schema.Properties[propName] + reqRO := settings.asreq && propSchema.Value.ReadOnly + repWO := settings.asrep && propSchema.Value.WriteOnly + if value[propName] == nil { - if dlft := propSchema.Value.Default; dlft != nil { + if dlft := propSchema.Value.Default; dlft != nil && !reqRO && !repWO { value[propName] = dlft if f := settings.defaultsSet; f != nil { settings.onceSettingDefaults.Do(f) } } } + + if value[propName] != nil { + if reqRO { + me = append(me, fmt.Errorf("readOnly property %q in request", propName)) + } else if repWO { + me = append(me, fmt.Errorf("writeOnly property %q in response", propName)) + } + } } } - var me MultiError - // "properties" properties := schema.Properties lenValue := int64(len(value)) diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index 5c0d01d2f..d8900878a 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -5,11 +5,12 @@ import "context" // ValidationOption allows the modification of how the OpenAPI document is validated. type ValidationOption func(options *ValidationOptions) -// ValidationOptions provide configuration for validating OpenAPI documents. +// ValidationOptions provides configuration for validating OpenAPI documents. type ValidationOptions struct { - SchemaFormatValidationEnabled bool - SchemaPatternValidationDisabled bool - ExamplesValidationDisabled bool + SchemaFormatValidationEnabled bool + SchemaPatternValidationDisabled bool + ExamplesValidationDisabled bool + examplesValidationAsReq, examplesValidationAsRes bool } type validationOptionsKey struct{} diff --git a/openapi3filter/validate_readonly_test.go b/openapi3filter/validate_readonly_test.go index 1152ec886..bad6c961a 100644 --- a/openapi3filter/validate_readonly_test.go +++ b/openapi3filter/validate_readonly_test.go @@ -2,8 +2,9 @@ package openapi3filter import ( "bytes" - "encoding/json" + "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/require" @@ -12,82 +13,218 @@ import ( legacyrouter "github.com/getkin/kin-openapi/routers/legacy" ) -func TestValidatingRequestBodyWithReadOnlyProperty(t *testing.T) { - const spec = `{ - "openapi": "3.0.3", - "info": { - "version": "1.0.0", - "title": "title", - "description": "desc", - "contact": { - "email": "email" - } - }, - "paths": { - "/accounts": { - "post": { - "description": "Create a new account", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["_id"], - "properties": { - "_id": { - "type": "string", - "description": "Unique identifier for this object.", - "pattern": "[0-9a-v]+$", - "minLength": 20, - "maxLength": 20, - "readOnly": true - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Successfully created a new account" - }, - "400": { - "description": "The server could not understand the request due to invalid syntax", - } - } - } - } - } -} -` +func TestReadOnlyWriteOnlyPropertiesValidation(t *testing.T) { + type testCase struct { + name string + requestSchema string + responseSchema string + requestBody string + responseBody string + responseErrContains string + requestErrContains string + } - type Request struct { - ID string `json:"_id"` + testCases := []testCase{ + { + name: "valid_readonly_in_response_and_valid_writeonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "writeOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "readOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + }, + { + name: "valid_readonly_in_response_and_invalid_readonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "readOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "readOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + requestErrContains: `readOnly property "_id" in request`, + }, + { + name: "invalid_writeonly_in_response_and_valid_writeonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "writeOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "writeOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + responseErrContains: `writeOnly property "access_token" in response`, + }, + { + name: "invalid_writeonly_in_response_and_invalid_readonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "readOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "writeOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + responseErrContains: `writeOnly property "access_token" in response`, + requestErrContains: `readOnly property "_id" in request`, + }, } - sl := openapi3.NewLoader() - doc, err := sl.LoadFromData([]byte(spec)) - require.NoError(t, err) - err = doc.Validate(sl.Context) - require.NoError(t, err) - router, err := legacyrouter.NewRouter(doc) - require.NoError(t, err) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spec := bytes.Buffer{} + spec.WriteString(`{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "title" + }, + "paths": { + "/accounts": { + "post": { + "description": "Create a new account", + "requestBody": { + "required": true, + "content": { + "application/json": {`) + spec.WriteString(tc.requestSchema) + spec.WriteString(`} + } + }, + "responses": { + "201": { + "description": "Successfully created a new account", + "content": { + "application/json": {`) + spec.WriteString(tc.responseSchema) + spec.WriteString(`} + } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax", + } + } + } + } + } + }`) + + sl := openapi3.NewLoader() + doc, err := sl.LoadFromData(spec.Bytes()) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/accounts", strings.NewReader(tc.requestBody)) + require.NoError(t, err) + httpReq.Header.Add(headerCT, "application/json") - b, err := json.Marshal(Request{ID: "bt6kdc3d0cvp6u8u3ft0"}) - require.NoError(t, err) + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) - httpReq, err := http.NewRequest(http.MethodPost, "/accounts", bytes.NewReader(b)) - require.NoError(t, err) - httpReq.Header.Add(headerCT, "application/json") + reqValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } - route, pathParams, err := router.FindRoute(httpReq) - require.NoError(t, err) + if tc.requestSchema != "" { + err = ValidateRequest(sl.Context, reqValidationInput) - err = ValidateRequest(sl.Context, &RequestValidationInput{ - Request: httpReq, - PathParams: pathParams, - Route: route, - }) - require.NoError(t, err) + if tc.requestErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.requestErrContains) + } else { + require.NoError(t, err) + } + } + + if tc.responseSchema != "" { + err = ValidateResponse(sl.Context, &ResponseValidationInput{ + RequestValidationInput: reqValidationInput, + Status: 201, + Header: httpReq.Header, + Body: io.NopCloser(strings.NewReader(tc.responseBody)), + }) + + if tc.responseErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.responseErrContains) + } else { + require.NoError(t, err) + } + } + }) + } } diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index f19123e53..ffb7a1f5a 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -121,7 +121,7 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error } opts := make([]openapi3.SchemaValidationOption, 0, 2) // 2 potential opts here - opts = append(opts, openapi3.VisitAsRequest()) + opts = append(opts, openapi3.VisitAsResponse()) if options.MultiError { opts = append(opts, openapi3.MultiErrors()) } diff --git a/openapi3filter/validation_error_test.go b/openapi3filter/validation_error_test.go index b9151b878..c1d4630c1 100644 --- a/openapi3filter/validation_error_test.go +++ b/openapi3filter/validation_error_test.go @@ -372,15 +372,6 @@ func getValidationTests(t *testing.T) []*validationTest { Title: `property "name" is missing`, Source: &ValidationErrorSource{Pointer: "/category/tags/0/name"}}, }, - { - // TODO: Add support for validating readonly properties to upstream validator. - name: "error - readonly object attribute", - args: validationArgs{ - r: newPetstoreRequest(t, http.MethodPost, "/pet", - bytes.NewBufferString(`{"id":213,"name":"Bahama","photoUrls":[]}}`)), - }, - //wantErr: true, - }, { name: "error - wrong attribute type", args: validationArgs{