diff --git a/docs/how_to.md b/docs/how_to.md index dce4de027..588d922b5 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1036,6 +1036,7 @@ x-terraform-id | boolean | If this meta attribute is present in an object defini x-terraform-field-name | string | This enables service providers to override the schema definition property name with a different one which will be the property name used in the terraform configuration file. This is mostly used to expose the internal property to a more user friendly name. If the extension is not present and the property name is not terraform compliant (following snake_case), an automatic conversion will be performed by the OpenAPI Terraform provider to make the name compliant (following Terraform's field name convention to be snake_case) x-terraform-field-status | boolean | If this meta attribute is present in a definition property, the value will be used as the status identifier when executing the polling mechanism on eligible async operations such as POST/PUT/DELETE. [x-terraform-ignore-order](#xTerraformIgnoreOrder) | boolean | If this meta attribute is present in a definition property of type list, when the plugin is updating the state for the property it will inspect the items of the list received from remote and compare with the local values and if the lists are the same but unordered the state will keep the users input. Please go to the `x-terraform-ignore-order` section to learn more about the different behaviours supported. +x-terraform-write-only | boolean | If this meta attribute is present in a definition property, when the plugin is reading or updating the state for the property it will always take the local state's value as the the value of the property. Any changes in the remote state for such properties will be ignored. ###### x-terraform-ignore-order diff --git a/openapi/common.go b/openapi/common.go index 4b98b68f6..02851a60a 100644 --- a/openapi/common.go +++ b/openapi/common.go @@ -131,12 +131,12 @@ func updateStateWithPayloadDataAndOptions(openAPIResource SpecResource, remoteDa } propValue := propertyRemoteValue + propertyLocalStateValue := resourceLocalData.Get(property.GetTerraformCompliantPropertyName()) if ignoreListOrderEnabled && property.shouldIgnoreOrder() { - desiredValue := resourceLocalData.Get(property.GetTerraformCompliantPropertyName()) - propValue = processIgnoreOrderIfEnabled(*property, desiredValue, propertyRemoteValue) + propValue = processIgnoreOrderIfEnabled(*property, propertyLocalStateValue, propertyRemoteValue) } - value, err := convertPayloadToLocalStateDataValue(property, propValue) + value, err := convertPayloadToLocalStateDataValue(property, propValue, propertyLocalStateValue) if err != nil { return err } @@ -194,72 +194,125 @@ func processIgnoreOrderIfEnabled(property SpecSchemaDefinitionProperty, inputPro return remoteValue } -func convertPayloadToLocalStateDataValue(property *SpecSchemaDefinitionProperty, propertyValue interface{}) (interface{}, error) { - if propertyValue == nil { - return nil, nil +func convertPayloadToLocalStateDataValue(property *SpecSchemaDefinitionProperty, propertyValue interface{}, propertyLocalStateValue interface{}) (interface{}, error) { + if property.WriteOnly { + return propertyLocalStateValue, nil } - dataValueKind := reflect.TypeOf(propertyValue).Kind() - switch dataValueKind { - case reflect.Map: - objectInput := map[string]interface{}{} - mapValue := propertyValue.(map[string]interface{}) - for propertyName, propertyValue := range mapValue { - schemaDefinitionProperty, err := property.SpecSchemaDefinition.getProperty(propertyName) - if err != nil { - return nil, err - } - var propValue interface{} - // Here we are processing the items of the list which are objects. In this case we need to keep the original - // types as Terraform honors property types for resource schemas attached to TypeList properties - propValue, err = convertPayloadToLocalStateDataValue(schemaDefinitionProperty, propertyValue) - if err != nil { - return nil, err - } - objectInput[schemaDefinitionProperty.GetTerraformCompliantPropertyName()] = propValue - } - // This is the work around put in place to have support for complex objects considering terraform sdk limitation to use - // blocks only for TypeList and TypeSet . In this case, we need to make sure that the json (which reflects to a map) - // gets translated to the expected array of one item that terraform expects. - if property.shouldUseLegacyTerraformSDKBlockApproachForComplexObjects() { - arrayInput := []interface{}{} - arrayInput = append(arrayInput, objectInput) - return arrayInput, nil - } - return objectInput, nil - case reflect.Slice, reflect.Array: + switch property.Type { + case TypeObject: + return convertObjectToLocalStateData(property, propertyValue, propertyLocalStateValue) + case TypeList: if isListOfPrimitives, _ := property.isTerraformListOfSimpleValues(); isListOfPrimitives { return propertyValue, nil } if property.isArrayOfObjectsProperty() { arrayInput := []interface{}{} - arrayValue := propertyValue.([]interface{}) - for _, arrayItem := range arrayValue { - objectValue, err := convertPayloadToLocalStateDataValue(property, arrayItem) + + arrayValue := make([]interface{}, 0) + if propertyValue != nil { + arrayValue = propertyValue.([]interface{}) + } + + localStateArrayValue := make([]interface{}, 0) + if propertyLocalStateValue != nil { + localStateArrayValue = propertyLocalStateValue.([]interface{}) + } + + for arrayIdx := 0; arrayIdx < intMax(len(arrayValue), len(localStateArrayValue)); arrayIdx++ { + var arrayItem interface{} = nil + if arrayIdx < len(arrayValue) { + arrayItem = arrayValue[arrayIdx] + } + var localStateArrayItem interface{} = nil + if arrayIdx < len(localStateArrayValue) { + localStateArrayItem = localStateArrayValue[arrayIdx] + } + objectValue, err := convertObjectToLocalStateData(property, arrayItem, localStateArrayItem) if err != nil { return err, nil } - arrayInput = append(arrayInput, objectValue) + if objectValue != nil { + arrayInput = append(arrayInput, objectValue) + } } return arrayInput, nil } return nil, fmt.Errorf("property '%s' is supposed to be an array objects", property.Name) - case reflect.String: + case TypeString: + if propertyValue == nil { + return nil, nil + } return propertyValue.(string), nil - case reflect.Int: - return propertyValue.(int), nil - case reflect.Float64: - // In golang, a number in JSON message is always parsed into float64. Hence, checking here if the property value is - // an actual int or if not then casting to float64 - if property.Type == TypeInt { - return int(propertyValue.(float64)), nil + case TypeInt: + if propertyValue == nil { + return nil, nil + } + // In golang, a number in JSON message is always parsed into float64, however testing/internal use can define the property value as a proper int. + if reflect.TypeOf(propertyValue).Kind() == reflect.Int { + return propertyValue.(int), nil + } + return int(propertyValue.(float64)), nil + case TypeFloat: + if propertyValue == nil { + return nil, nil } return propertyValue.(float64), nil - case reflect.Bool: + case TypeBool: + if propertyValue == nil { + return nil, nil + } return propertyValue.(bool), nil default: - return nil, fmt.Errorf("'%s' type not supported", dataValueKind) + return nil, fmt.Errorf("'%s' type not supported", property.Type) + } +} + +func convertObjectToLocalStateData(property *SpecSchemaDefinitionProperty, propertyValue interface{}, propertyLocalStateValue interface{}) (interface{}, error) { + objectInput := map[string]interface{}{} + + mapValue := make(map[string]interface{}) + if propertyValue != nil { + mapValue = propertyValue.(map[string]interface{}) + } + + localStateMapValue := make(map[string]interface{}) + if propertyLocalStateValue != nil { + if reflect.TypeOf(propertyLocalStateValue).Kind() == reflect.Map { + localStateMapValue = propertyLocalStateValue.(map[string]interface{}) + } else if reflect.TypeOf(propertyLocalStateValue).Kind() == reflect.Slice && len(propertyLocalStateValue.([]interface{})) == 1 { + localStateMapValue = propertyLocalStateValue.([]interface{})[0].(map[string]interface{}) // local state can store nested objects as a single item array + } + } + + for _, schemaDefinitionProperty := range property.SpecSchemaDefinition.Properties { + propertyName := schemaDefinitionProperty.Name + propertyValue := mapValue[propertyName] + + // Here we are processing the items of the list which are objects. In this case we need to keep the original + // types as Terraform honors property types for resource schemas attached to TypeList properties + propValue, err := convertPayloadToLocalStateDataValue(schemaDefinitionProperty, propertyValue, localStateMapValue[schemaDefinitionProperty.GetTerraformCompliantPropertyName()]) + if err != nil { + return nil, err + } + if propValue != nil { + objectInput[schemaDefinitionProperty.GetTerraformCompliantPropertyName()] = propValue + } + } + + if len(objectInput) == 0 { + return nil, nil + } + + // This is the work around put in place to have support for complex objects considering terraform sdk limitation to use + // blocks only for TypeList and TypeSet . In this case, we need to make sure that the json (which reflects to a map) + // gets translated to the expected array of one item that terraform expects. + if property.shouldUseLegacyTerraformSDKBlockApproachForComplexObjects() { + arrayInput := []interface{}{} + arrayInput = append(arrayInput, objectInput) + return arrayInput, nil } + return objectInput, nil } // setResourceDataProperty sets the expectedValue for the given schemaDefinitionPropertyName using the terraform compliant property name diff --git a/openapi/common_test.go b/openapi/common_test.go index 789c42380..cb7da3f62 100644 --- a/openapi/common_test.go +++ b/openapi/common_test.go @@ -645,7 +645,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with ", func() { property := newStringSchemaDefinitionPropertyWithDefaults("string_property", "", false, false, nil) dataValue := "someValue" - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the expected value with the right type string", func() { So(err, ShouldBeNil) So(resultValue, ShouldEqual, dataValue) @@ -655,7 +655,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with a bool property and a bool value", func() { property := newBoolSchemaDefinitionPropertyWithDefaults("bool_property", "", false, false, nil) dataValue := true - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the expected value with the right type boolean", func() { So(err, ShouldBeNil) So(resultValue, ShouldEqual, dataValue) @@ -665,7 +665,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with an int property and a int value", func() { property := newIntSchemaDefinitionPropertyWithDefaults("int_property", "", false, false, nil) dataValue := 10 - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the expected value with the right type int", func() { So(err, ShouldBeNil) So(resultValue, ShouldEqual, dataValue) @@ -674,7 +674,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with an float property and a float value", func() { property := newNumberSchemaDefinitionPropertyWithDefaults("float_property", "", false, false, nil) dataValue := 45.23 - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then error should be nil and the result value should be the expected value formatted string with the right type float", func() { So(err, ShouldBeNil) So(resultValue, ShouldEqual, dataValue) @@ -683,20 +683,20 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with an float property and a float value but the swagger property is an integer", func() { property := newIntSchemaDefinitionPropertyWithDefaults("int_property", "", false, false, nil) dataValue := 45 - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the expected value formatted string with the right type integer", func() { So(err, ShouldBeNil) So(resultValue, ShouldEqual, dataValue) So(resultValue, ShouldHaveSameTypeAs, int(dataValue)) }) }) - Convey("When convertPayloadToLocalStateDataValue is called with an list property and a with items object", func() { + Convey("When convertPayloadToLocalStateDataValue is called with a list property and with items object", func() { objectSchemaDefinition := &SpecSchemaDefinition{ Properties: SpecSchemaDefinitionProperties{ newIntSchemaDefinitionPropertyWithDefaults("example_int", "", true, false, nil), newStringSchemaDefinitionPropertyWithDefaults("example_string", "", true, false, nil), - newStringSchemaDefinitionPropertyWithDefaults("example_bool", "", true, false, nil), - newStringSchemaDefinitionPropertyWithDefaults("example_float", "", true, false, nil), + newBoolSchemaDefinitionPropertyWithDefaults("example_bool", "", true, false, nil), + newNumberSchemaDefinitionPropertyWithDefaults("example_float", "", true, false, nil), }, } objectDefault := map[string]interface{}{ @@ -707,7 +707,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { } property := newListSchemaDefinitionPropertyWithDefaults("slice_object_property", "", true, false, false, nil, TypeObject, objectSchemaDefinition) dataValue := []interface{}{objectDefault} - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the list containing the object items with the expected types (int, string, bool and float)", func() { So(err, ShouldBeNil) So(resultValue.([]interface{})[0].(map[string]interface{}), ShouldContainKey, "example_int") @@ -723,7 +723,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with a list property and an array with items string value", func() { property := newListSchemaDefinitionPropertyWithDefaults("slice_object_property", "", true, false, false, nil, TypeString, nil) dataValue := []interface{}{"value1"} - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the expected value with the right type array", func() { So(err, ShouldBeNil) So(resultValue.([]interface{}), ShouldContain, dataValue[0]) @@ -732,11 +732,12 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Convey("When convertPayloadToLocalStateDataValue is called with simple object property and an empty map as value", func() { property := &SpecSchemaDefinitionProperty{ - Name: "some_object", - Type: TypeObject, - Required: true, + Name: "some_object", + Type: TypeObject, + Required: true, + SpecSchemaDefinition: &SpecSchemaDefinition{}, } - resultValue, err := convertPayloadToLocalStateDataValue(property, map[string]interface{}{}) + resultValue, err := convertPayloadToLocalStateDataValue(property, map[string]interface{}{}, nil) Convey("Then the error should be nil and the result value should be the expected value with the right type array", func() { So(err, ShouldBeNil) So(resultValue.([]interface{}), ShouldNotBeEmpty) // By default objects' internal terraform schema is Type List with Max 1 elem *Resource @@ -747,7 +748,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { // Edge case Convey("When convertPayloadToLocalStateDataValue is called with a slice of map interfaces", func() { property := newListSchemaDefinitionPropertyWithDefaults("slice_object_property", "", true, false, false, nil, TypeString, nil) - _, err := convertPayloadToLocalStateDataValue(property, []map[string]interface{}{}) + _, err := convertPayloadToLocalStateDataValue(property, []map[string]interface{}{}, nil) Convey("Then the error should be nil", func() { So(err, ShouldBeNil) }) @@ -759,7 +760,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Type: TypeList, ArrayItemsType: schemaDefinitionPropertyType("unknown"), } - _, err := convertPayloadToLocalStateDataValue(property, []interface{}{}) + _, err := convertPayloadToLocalStateDataValue(property, []interface{}{}, nil) Convey("Then the error should match the expected one", func() { So(err.Error(), ShouldEqual, "property 'not_well_configured_property' is supposed to be an array objects") }) @@ -778,7 +779,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { "example_string_2": "something", } property := newObjectSchemaDefinitionPropertyWithDefaults("object_property", "", true, false, false, nil, objectSchemaDefinition) - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the list containing the object items all being string type (as terraform only supports maps of strings, hence values need to be stored as strings)", func() { So(err, ShouldBeNil) So(resultValue.([]interface{})[0].(map[string]interface{})["example_string"].(string), ShouldEqual, "http") @@ -792,8 +793,8 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { Properties: SpecSchemaDefinitionProperties{ newIntSchemaDefinitionPropertyWithDefaults("example_int", "", true, false, nil), newStringSchemaDefinitionPropertyWithDefaults("example_string", "", true, false, nil), - newStringSchemaDefinitionPropertyWithDefaults("example_bool", "", true, true, nil), - newStringSchemaDefinitionPropertyWithDefaults("example_float", "", true, false, nil), + newBoolSchemaDefinitionPropertyWithDefaults("example_bool", "", true, true, nil), + newNumberSchemaDefinitionPropertyWithDefaults("example_float", "", true, false, nil), }, } dataValue := map[string]interface{}{ @@ -803,7 +804,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { "example_float": 10.45, } property := newObjectSchemaDefinitionPropertyWithDefaults("object_property", "", true, false, false, nil, objectSchemaDefinition) - resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(property, dataValue, nil) Convey("Then the error should be nil and the result value should be the list containing the object items all being string type (as terraform only supports maps of strings, hence values need to be stored as strings)", func() { So(err, ShouldBeNil) So(resultValue.([]interface{})[0], ShouldContainKey, "example_int") @@ -843,7 +844,7 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { expectedPropertyWithNestedObjectName := "property_with_nested_object" propertyWithNestedObject := newObjectSchemaDefinitionPropertyWithDefaults(expectedPropertyWithNestedObjectName, "", true, false, false, dataValue, propertyWithNestedObjectSchemaDefinition) - resultValue, err := convertPayloadToLocalStateDataValue(propertyWithNestedObject, dataValue) + resultValue, err := convertPayloadToLocalStateDataValue(propertyWithNestedObject, dataValue, nil) Convey("Then the result returned should be the expected one", func() { So(err, ShouldBeNil) @@ -868,6 +869,105 @@ func TestConvertPayloadToLocalStateDataValue(t *testing.T) { So(resultValue.([]interface{})[0].(map[string]interface{})[nestedObject.Name].([]interface{})[0].(map[string]interface{})[nestedObjectSchemaDefinition.Properties[1].Name], ShouldEqual, nestedObjectSchemaDefinition.Properties[1].Default) }) }) + + Convey("When convertPayloadToLocalStateDataValue is called with complex objects with write-only properties", func() { + nestedArrayItemSchemaDefinition := &SpecSchemaDefinition{ + Properties: SpecSchemaDefinitionProperties{ + newIntSchemaDefinitionPropertyWithDefaults("nested_list_property", "", true, false, 456), + setSchemaDefinitionPropertyWriteOnly(newIntSchemaDefinitionPropertyWithDefaults("nested_list_property_password", "", true, false, nil)), + }, + } + + nestedObjectSchemaDefinition := &SpecSchemaDefinition{ + Properties: SpecSchemaDefinitionProperties{ + setSchemaDefinitionPropertyWriteOnly(newIntSchemaDefinitionPropertyWithDefaults("origin_port", "", true, false, 80)), + newStringSchemaDefinitionPropertyWithDefaults("protocol", "", true, false, "http"), + setSchemaDefinitionPropertyWriteOnly(newStringSchemaDefinitionPropertyWithDefaults("password", "", true, false, nil)), + setSchemaDefinitionPropertyWriteOnly(newListSchemaDefinitionPropertyWithDefaults("password_array", "", true, false, false, nil, TypeString, nil)), + newListSchemaDefinitionPropertyWithDefaults("nested_list", "", true, false, false, nil, TypeObject, nestedArrayItemSchemaDefinition), + }, + } + nestedObject := newObjectSchemaDefinitionPropertyWithDefaults("nested_object", "", true, false, false, nil, nestedObjectSchemaDefinition) + propertyWithNestedObjectSchemaDefinition := &SpecSchemaDefinition{ + Properties: SpecSchemaDefinitionProperties{ + idProperty, + nestedObject, + }, + } + // The below represents the JSON representation of the response payload received by the API + dataValue := map[string]interface{}{ + "id": propertyWithNestedObjectSchemaDefinition.Properties[0].Default, + "nested_object": map[string]interface{}{ + "origin_port": 12345, + "protocol": "tcp", + "nested_list": []interface{}{ + map[string]interface{}{ + "nested_list_property": 123, + "nested_list_property_password": "some changed value", + }, + }, + }, + } + + expectedPropertyWithNestedObjectName := "property_with_nested_object" + propertyWithNestedObject := newObjectSchemaDefinitionPropertyWithDefaults(expectedPropertyWithNestedObjectName, "", true, false, false, dataValue, propertyWithNestedObjectSchemaDefinition) + + Convey("When the local state is empty", func() { + resultValue, err := convertPayloadToLocalStateDataValue(propertyWithNestedObject, dataValue, nil) + + Convey("Then the result returned should be the expected one", func() { + So(err, ShouldBeNil) + + nestedObject := resultValue.([]interface{})[0].(map[string]interface{})["nested_object"].([]interface{})[0].(map[string]interface{}) + So(nestedObject["origin_port"], ShouldBeNil) + So(nestedObject["protocol"], ShouldEqual, "tcp") + So(nestedObject["password"], ShouldBeNil) + So(nestedObject["password_array"], ShouldBeNil) + + firstNestedListItem := nestedObject["nested_list"].([]interface{})[0].(map[string]interface{}) + So(firstNestedListItem["nested_list_property"], ShouldEqual, 123) + So(firstNestedListItem["nested_list_property_password"], ShouldBeNil) + }) + }) + + Convey("When the local state is populated", func() { + localStateValue := map[string]interface{}{ + "id": propertyWithNestedObjectSchemaDefinition.Properties[0].Default, + "nested_object": map[string]interface{}{ + "origin_port": 1111, + "protocol": "tcp", + "password": "secret", + "password_array": []interface{}{"secret1", "secret2"}, + "nested_list": []interface{}{ + map[string]interface{}{ + "nested_list_property": 555, + "nested_list_property_password": "local secret value", + }, + }, + }, + } + resultValue, err := convertPayloadToLocalStateDataValue(propertyWithNestedObject, dataValue, localStateValue) + + Convey("Then the result returned should be the expected one", func() { + So(err, ShouldBeNil) + + nestedObject := resultValue.([]interface{})[0].(map[string]interface{})["nested_object"].([]interface{})[0].(map[string]interface{}) + So(nestedObject["origin_port"], ShouldEqual, 1111) + So(nestedObject["protocol"], ShouldEqual, "tcp") + So(nestedObject["password"], ShouldEqual, "secret") + So(len(nestedObject["password_array"].([]interface{})), ShouldEqual, 2) + + passwordArray := nestedObject["password_array"].([]interface{}) + So(passwordArray[0], ShouldEqual, "secret1") + So(passwordArray[1], ShouldEqual, "secret2") + + So(len(nestedObject["nested_list"].([]interface{})), ShouldEqual, 1) + nestedListItem := nestedObject["nested_list"].([]interface{})[0].(map[string]interface{}) + So(nestedListItem["nested_list_property"], ShouldEqual, 123) + So(nestedListItem["nested_list_property_password"], ShouldEqual, "local secret value") + }) + }) + }) }) } diff --git a/openapi/helperutils_test.go b/openapi/helperutils_test.go index 73ce3f20c..2992f20f5 100644 --- a/openapi/helperutils_test.go +++ b/openapi/helperutils_test.go @@ -39,6 +39,11 @@ var numberZeroValueProperty = newNumberSchemaDefinitionPropertyWithDefaults("num var boolZeroValueProperty = newBoolSchemaDefinitionPropertyWithDefaults("bool_property", "", true, false, false) var sliceZeroValueProperty = newListSchemaDefinitionPropertyWithDefaults("slice_property", "", true, false, false, []interface{}{""}, TypeString, nil) +func setSchemaDefinitionPropertyWriteOnly(propertySchemaDefinition *SpecSchemaDefinitionProperty) *SpecSchemaDefinitionProperty { + propertySchemaDefinition.WriteOnly = true + return propertySchemaDefinition +} + func newStringSchemaDefinitionPropertyWithDefaults(name, preferredName string, required, readOnly bool, defaultValue interface{}) *SpecSchemaDefinitionProperty { return newStringSchemaDefinitionProperty(name, preferredName, required, readOnly, false, false, false, false, false, false, defaultValue) } diff --git a/openapi/openapi_spec_resource_schema_definition_property.go b/openapi/openapi_spec_resource_schema_definition_property.go index 57d8c32eb..c18623e5b 100644 --- a/openapi/openapi_spec_resource_schema_definition_property.go +++ b/openapi/openapi_spec_resource_schema_definition_property.go @@ -55,6 +55,7 @@ type SpecSchemaDefinitionProperty struct { Immutable bool IsIdentifier bool IsStatusIdentifier bool + WriteOnly bool // Default field is only for informative purposes to know what the openapi spec for the property stated the default value is // As per the openapi spec default attributes, the value is expected to be computed by the API Default interface{} diff --git a/openapi/openapi_v2_resource.go b/openapi/openapi_v2_resource.go index 3ab651992..caec42aeb 100644 --- a/openapi/openapi_v2_resource.go +++ b/openapi/openapi_v2_resource.go @@ -58,6 +58,7 @@ const extTfID = "x-terraform-id" const extTfComputed = "x-terraform-computed" const extTfIgnoreOrder = "x-terraform-ignore-order" const extIgnoreOrder = "x-ignore-order" +const extTfWriteOnly = "x-terraform-write-only" // Operation level extensions const extTfResourceTimeout = "x-terraform-resource-timeout" @@ -493,6 +494,10 @@ func (o *SpecV2Resource) createSchemaDefinitionProperty(propertyName string, pro schemaDefinitionProperty.IsStatusIdentifier = true } + if o.isBoolExtensionEnabled(property.Extensions, extTfWriteOnly) { + schemaDefinitionProperty.WriteOnly = true + } + // Use the default keyword in the parameter schema to specify the default value for an optional parameter. The default // value is the one that the server uses if the client does not supply the parameter value in the request. // Link: https://swagger.io/docs/specification/describing-parameters#default diff --git a/openapi/openapi_v2_resource_test.go b/openapi/openapi_v2_resource_test.go index 4a6cc86c8..23e58f25b 100644 --- a/openapi/openapi_v2_resource_test.go +++ b/openapi/openapi_v2_resource_test.go @@ -2311,6 +2311,25 @@ func TestCreateSchemaDefinitionProperty(t *testing.T) { }) }) + Convey(fmt.Sprintf("When createSchemaDefinitionProperty is called with a property schema that has the '%s' extension", extTfWriteOnly), func() { + expectedWriteOnlyValue := true + propertySchema := spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + extTfWriteOnly: expectedWriteOnlyValue, + }, + }, + } + schemaDefinitionProperty, err := r.createSchemaDefinitionProperty("propertyName", propertySchema, []string{}) + Convey("Then the error returned should be nil and the schemaDefinitionProperty should be configured as expected", func() { + So(err, ShouldBeNil) + So(schemaDefinitionProperty.WriteOnly, ShouldEqual, expectedWriteOnlyValue) + }) + }) + Convey(fmt.Sprintf("When createSchemaDefinitionProperty is called with an optional property schema that has the %s extension (this means the property is optional-computed, and the value computed is not known at runtime)", extTfComputed), func() { propertySchema := spec.Schema{ SchemaProps: spec.SchemaProps{ diff --git a/openapi/resource_factory.go b/openapi/resource_factory.go index 66875651d..f35695a4b 100644 --- a/openapi/resource_factory.go +++ b/openapi/resource_factory.go @@ -444,14 +444,17 @@ func (r resourceFactory) checkImmutableFields(updatedResourceLocalData *schema.R } func (r resourceFactory) validateImmutableProperty(property *SpecSchemaDefinitionProperty, remoteData interface{}, localData interface{}, checkObjectPropertiesUpdates bool) error { - if property.ReadOnly || property.IsParentProperty { + if property.ReadOnly || property.IsParentProperty || property.WriteOnly { return nil } switch property.Type { case TypeList: if property.Immutable { localList := localData.([]interface{}) - remoteList := remoteData.([]interface{}) + remoteList := make([]interface{}, 0) + if remoteList != nil { + remoteList = remoteData.([]interface{}) + } if len(localList) != len(remoteList) { return fmt.Errorf("user attempted to update an immutable list property ('%s') size: [user input list size: %d; actual list size: %d]", property.Name, len(localList), len(remoteList)) } @@ -468,7 +471,7 @@ func (r resourceFactory) validateImmutableProperty(property *SpecSchemaDefinitio localObj := localListObj.(map[string]interface{}) remoteObj := remoteListObj.(map[string]interface{}) for _, objectProp := range property.SpecSchemaDefinition.Properties { - err := r.validateImmutableProperty(objectProp, remoteObj[objectProp.Name], localObj[objectProp.Name], property.Immutable) + err := r.validateImmutableProperty(objectProp, remoteObj[objectProp.Name], localObj[objectProp.GetTerraformCompliantPropertyName()], property.Immutable) if err != nil { return fmt.Errorf("user attempted to update an immutable list of objects ('%s'): [user input: %s; actual: %s]", property.Name, localData, remoteData) } @@ -478,9 +481,12 @@ func (r resourceFactory) validateImmutableProperty(property *SpecSchemaDefinitio } case TypeObject: localObject := localData.(map[string]interface{}) - remoteObject := remoteData.(map[string]interface{}) + remoteObject := make(map[string]interface{}) + if remoteData != nil { + remoteObject = remoteData.(map[string]interface{}) + } for _, objProp := range property.SpecSchemaDefinition.Properties { - err := r.validateImmutableProperty(objProp, remoteObject[objProp.Name], localObject[objProp.Name], property.Immutable) + err := r.validateImmutableProperty(objProp, remoteObject[objProp.Name], localObject[objProp.GetTerraformCompliantPropertyName()], property.Immutable) if err != nil { return fmt.Errorf("user attempted to update an immutable object ('%s') property ('%s'): [user input: %s; actual: %s]", property.Name, objProp.Name, localData, remoteData) } diff --git a/openapi/resource_factory_test.go b/openapi/resource_factory_test.go index 4081b7b4b..c7dfd22f9 100644 --- a/openapi/resource_factory_test.go +++ b/openapi/resource_factory_test.go @@ -1731,6 +1731,25 @@ func TestCheckImmutableFields(t *testing.T) { expectedResult: nil, expectedError: errors.New("validation for immutable properties failed: user attempted to update an immutable property ('immutable_prop'): [user input: updatedImmutableValue; actual: originalImmutablePropertyValue]. Update operation was aborted; no updates were performed"), }, + { + name: "write-only properties are not validated for immutability", + inputProps: []*SpecSchemaDefinitionProperty{ + { + Name: "write_only_immutable_prop", + Type: TypeString, + Immutable: true, + WriteOnly: true, + }, + }, + inputClient: clientOpenAPIStub{ + responsePayload: map[string]interface{}{ + "write_only_immutable_prop": "some value that will be ignored", + "unknown_prop": "some value", + }, + }, + expectedResult: nil, + expectedError: nil, + }, } Convey("Given a resource factory", t, func() { diff --git a/openapi/utils.go b/openapi/utils.go index b006f55ba..8794aa5f1 100644 --- a/openapi/utils.go +++ b/openapi/utils.go @@ -47,3 +47,10 @@ func isURL(str string) bool { u, err := url.Parse(str) return err == nil && u.Scheme != "" && u.Host != "" } + +func intMax(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/tests/e2e/gray_box_cdns_test.go b/tests/e2e/gray_box_cdns_test.go index 9add2430e..33053afef 100644 --- a/tests/e2e/gray_box_cdns_test.go +++ b/tests/e2e/gray_box_cdns_test.go @@ -946,3 +946,192 @@ func createTerraformFile(expectedCDNLabel, expectedFirewallLabel string) string label = "%s" }`, openAPIResourceNameCDN, openAPIResourceInstanceNameCDN, expectedCDNLabel, openAPIResourceNameCDNFirewall, openAPIResourceInstanceNameCDNFirewall, openAPIResourceStateCDN, expectedFirewallLabel) } + +func TestAccCDN_WriteOnlyProperties(t *testing.T) { + swagger := `swagger: "2.0" +host: %s +schemes: +- "http" + +paths: + ###################### + #### CDN Resource #### + ###################### + + /v1/cdns: + x-terraform-resource-name: "cdn" + post: + summary: "Create cdn" + operationId: "ContentDeliveryNetworkCreateV1" + parameters: + - in: "body" + name: "body" + description: "Created CDN" + required: true + schema: + $ref: "#/definitions/ContentDeliveryNetworkV1" + responses: + 201: + description: "successful operation" + schema: + $ref: "#/definitions/ContentDeliveryNetworkV1" + /v1/cdns/{cdn_id}: + get: + summary: "Get cdn by id" + description: "" + operationId: "ContentDeliveryNetworkGetV1" + parameters: + - name: "cdn_id" + in: "path" + description: "The cdn id that needs to be fetched." + required: true + type: "string" + responses: + 200: + description: "successful operation" + schema: + $ref: "#/definitions/ContentDeliveryNetworkV1" + put: + summary: "Updated cdn" + operationId: "ContentDeliveryNetworkUpdateV1" + parameters: + - name: "id" + in: "path" + description: "cdn that needs to be updated" + required: true + type: "string" + - in: "body" + name: "body" + description: "Updated cdn object" + required: true + schema: + $ref: "#/definitions/ContentDeliveryNetworkV1" + responses: + 200: + description: "successful operation" + schema: + $ref: "#/definitions/ContentDeliveryNetworkV1" + delete: + summary: "Delete cdn" + operationId: "ContentDeliveryNetworkDeleteV1" + parameters: + - name: "id" + in: "path" + description: "The cdn that needs to be deleted" + required: true + type: "string" + responses: + 204: + description: "successful operation, no content is returned" +definitions: + ContentDeliveryNetworkV1: + type: "object" + required: + - label + - writeOnlyProperty + - objectWriteOnlyProp + properties: + id: + type: "string" + readOnly: true + label: + type: "string" + writeOnlyProperty: + type: "string" + x-terraform-write-only: true + listProp: + type: "array" + x-terraform-write-only: true + items: + type: "string" + objectWriteOnlyProp: + type: "object" + x-terraform-write-only: true + required: + - nestedProp + properties: + nestedProp: + type: "string" + x-terraform-write-only: true + nestedOptionalProp: + type: "string" + x-terraform-write-only: true + objectProp: + type: "object" + required: + - nestedProp + properties: + nestedProp: + type: "string" + x-terraform-write-only: true` + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := `{"id": "someid", "label": "some label", "objectProp":{}, "objectWriteOnlyProp": null}` + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + })) + apiHost := apiServer.URL[7:] + swaggerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + swaggerReturned := fmt.Sprintf(swagger, apiHost) + w.Write([]byte(swaggerReturned)) + })) + + p := openapi.ProviderOpenAPI{ProviderName: providerName} + provider, err := p.CreateSchemaProviderFromServiceConfiguration(&openapi.ServiceConfigStub{SwaggerURL: swaggerServer.URL}) + assert.NoError(t, err) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProviderFactories: testAccProviders(provider), + PreCheck: func() { testAccPreCheck(t, swaggerServer.URL) }, + Steps: []resource.TestStep{ + { + ExpectNonEmptyPlan: false, + Config: `# URI /v1/cdns/ +resource "openapi_cdn_v1" "my_cdn" { + label = "some label" + write_only_property = "some property value" + list_prop = ["value1", "value2"] + object_write_only_prop { + nested_prop = "some value" + nested_optional_prop = "optional val" + } + object_prop { + nested_prop = "some other value" + } +}`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "label", "some label"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "list_prop.#", "2"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "list_prop.0", "value1"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "list_prop.1", "value2"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "write_only_property", "some property value"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_write_only_prop.0.nested_prop", "some value"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_write_only_prop.0.nested_optional_prop", "optional val"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_prop.0.nested_prop", "some other value"), + ), + }, + { + ExpectNonEmptyPlan: false, + Config: `# URI /v1/cdns/ +resource "openapi_cdn_v1" "my_cdn" { + label = "some label" + write_only_property = "some new property" + list_prop = ["value3", "value4"] + object_write_only_prop { + nested_prop = "some new value" + nested_optional_prop = "optional new val" + } + object_prop { + nested_prop = "some other new value" + } +}`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "write_only_property", "some new property"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_write_only_prop.0.nested_prop", "some new value"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_write_only_prop.0.nested_optional_prop", "optional new val"), + resource.TestCheckResourceAttr(openAPIResourceStateCDN, "object_prop.0.nested_prop", "some other new value"), + ), + }, + }, + }) +}