diff --git a/DESIGN.md b/DESIGN.md index 5ac34cd9..79be00ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,41 +71,42 @@ For a given [OAS type](https://spec.openapis.org/oas/v3.1.0#data-types) and form > **NOTE:** All `Type` and `Format` fields below are native to OpenAPI Spec 3.x, with the exception of the format `set`, which is a custom field that only this generator tool is expected to support. -| Type (OAS) | Format (OAS) | Items type (OAS `array`) | Plugin Framework Attribute Type | -|------------|---------------------|--------------------------|---------------------------------------------------------------------------------------------| -| `integer` | - | - | `Int64Attribute` | -| `number` | `double` or `float` | - | `Float64Attribute` | -| `number` | (all others) | - | `NumberAttribute` | -| `string` | - | - | `StringAttribute` | -| `boolean` | - | - | `BoolAttribute` | -| `array` | - | `object` | `ListNestedAttribute` | -| `array` | - | (all others) | `ListAttribute` (nests with [element types](#oas-to-plugin-framework-element-types)) | -| `array` | `set` | `object` | `SetNestedAttribute` | -| `array` | `set` | (all others) | `SetAttribute` (nests with [element types](#oas-to-plugin-framework-element-types)) | -| `object` | - | - | `SingleNestedAttribute` | +| Type (OAS) | Format (OAS) | Other Criteria | Plugin Framework Attribute Type | +|------------|---------------------|----------------------------------------------|---------------------------------------------------------------------------------------------| +| `integer` | - | - | `Int64Attribute` | +| `number` | `double` or `float` | - | `Float64Attribute` | +| `number` | - | - | `NumberAttribute` | +| `string` | - | - | `StringAttribute` | +| `boolean` | - | - | `BoolAttribute` | +| `array` | - | `items.type == object` | `ListNestedAttribute` | +| `array` | - | `items.type == (any)` | `ListAttribute` (nests with [element types](#oas-to-plugin-framework-element-types)) | +| `array` | `set` | `items.type == object` | `SetNestedAttribute` | +| `array` | `set` | `items.type == (any)` | `SetAttribute` (nests with [element types](#oas-to-plugin-framework-element-types)) | +| `object` | - | `additionalProperties.type == object` | `MapNestedAttribute` | +| `object` | - | `additionalProperties.type == (any)` | `MapAttribute` (nests with [element types](#oas-to-plugin-framework-element-types)) | +| `object` | - | - | `SingleNestedAttribute` | #### Unsupported Attribute Types - `ListNestedBlock`, `SetNestedBlock`, and `SingleNestedBlock` - While the Plugin Framework supports blocks, the Plugin Framework team encourages provider developers to prefer `ListNestedAttribute`, `SetNestedAttribute`, and `SingleNestedAttribute` for new provider development. - `ObjectAttribute` - The generator will default to `SingleNestedAttribute` for object types to provide the additional schema information. -- `MapNestedAttribute`, and `MapAttribute` - - Mapping for these types is currently not supported, but will be considered in future versions. ### OAS to Plugin Framework Element Types -For attributes that don't have additional schema information (currently only `ListAttribute` and `SetAttribute`), the following rules will be applied for mapping from OAS type and format combinations, into Framework element types. - -| Type (OAS) | Format (OAS) | Items type (OAS `array`) | Plugin Framework Element Type | -|------------|---------------------|--------------------------|---------------------------------| -| `integer` | - | - | `Int64Type` | -| `number` | `double` or `float` | - | `Float64Type` | -| `number` | (all others) | - | `NumberType` | -| `string` | - | - | `StringType` | -| `boolean` | - | - | `BoolType` | -| `array` | - | (all) | `ListType` | -| `array` | `set` | (all) | `SetType` | -| `object` | - | - | `ObjectType` | +For attributes that don't have additional schema information (`ListAttribute`, `SetAttribute`, and `MapAttribute`), the following rules will be applied for mapping from OAS type and format combinations, into Framework element types. + +| Type (OAS) | Format (OAS) | Other Criteria | Plugin Framework Element Type | +|------------|---------------------|---------------------------------------|---------------------------------| +| `integer` | - | - | `Int64Type` | +| `number` | `double` or `float` | - | `Float64Type` | +| `number` | - | - | `NumberType` | +| `string` | - | - | `StringType` | +| `boolean` | - | - | `BoolType` | +| `array` | - | - | `ListType` | +| `array` | `set` | - | `SetType` | +| `object` | - | `additionalProperties.type == (any)` | `MapType` | +| `object` | - | - | `ObjectType` | ### Required, Computed, and Optional diff --git a/internal/cmd/testdata/edgecase/generated_framework_ir.json b/internal/cmd/testdata/edgecase/generated_framework_ir.json index 9758be2e..76c766f5 100644 --- a/internal/cmd/testdata/edgecase/generated_framework_ir.json +++ b/internal/cmd/testdata/edgecase/generated_framework_ir.json @@ -1,11 +1,54 @@ { "datasources": [ { - "name": "nested_lists", + "name": "map_test", "schema": { "attributes": [ { - "name": "main_list", + "name": "map_prop", + "map": { + "computed_optional_required": "computed_optional", + "element_type": { + "float64": {} + }, + "description": "This is a map of floats" + } + }, + { + "name": "mapnested_prop", + "map_nested": { + "computed_optional_required": "computed_optional", + "nested_object": { + "attributes": [ + { + "name": "bool_prop", + "bool": { + "computed_optional_required": "computed_optional", + "description": "Bool inside a map!" + } + }, + { + "name": "string_prop", + "string": { + "computed_optional_required": "computed_optional", + "description": "String inside a map!", + "sensitive": false + } + } + ] + }, + "description": "This is a map with a nested object" + } + } + ] + } + }, + { + "name": "nested_collections", + "schema": { + "attributes": [ + { + "name": "double_nested_list", "list": { "computed_optional_required": "computed_optional", "element_type": { @@ -15,14 +58,71 @@ } } }, - "description": "This list has another list nested underneath!" + "description": "This list has a list nested underneath!" + } + }, + { + "name": "double_nested_map", + "list": { + "computed_optional_required": "computed_optional", + "element_type": { + "map": { + "element_type": { + "string": {} + } + } + }, + "description": "This list has a map nested underneath!" + } + }, + { + "name": "double_nested_set", + "set": { + "computed_optional_required": "computed_optional", + "element_type": { + "set": { + "element_type": { + "string": {} + } + } + }, + "description": "This set has a set nested underneath!" + } + }, + { + "name": "triple_nested_map", + "list": { + "computed_optional_required": "computed_optional", + "element_type": { + "set": { + "element_type": { + "map": { + "element_type": { + "object": { + "attribute_types": [ + { + "name": "bool_prop", + "bool": {} + }, + { + "name": "string_prop", + "string": {} + } + ] + } + } + } + } + } + }, + "description": "This list has a set of maps nested underneath!" } } ] } }, { - "name": "set_collections", + "name": "set_test", "schema": { "attributes": [ { @@ -70,7 +170,50 @@ }, "resources": [ { - "name": "set_collections", + "name": "map_test", + "schema": { + "attributes": [ + { + "name": "map_prop", + "map": { + "computed_optional_required": "computed_optional", + "element_type": { + "string": {} + }, + "description": "This is a map of strings" + } + }, + { + "name": "mapnested_prop", + "map_nested": { + "computed_optional_required": "required", + "nested_object": { + "attributes": [ + { + "name": "bool_prop", + "bool": { + "computed_optional_required": "computed_optional", + "description": "Bool inside a map!" + } + }, + { + "name": "string_prop", + "string": { + "computed_optional_required": "computed_optional", + "description": "String inside a map!", + "sensitive": false + } + } + ] + }, + "description": "This is a map with a nested object" + } + } + ] + } + }, + { + "name": "set_test", "schema": { "attributes": [ { diff --git a/internal/cmd/testdata/edgecase/openapi_spec.yml b/internal/cmd/testdata/edgecase/openapi_spec.yml index f5d167f8..894885a7 100644 --- a/internal/cmd/testdata/edgecase/openapi_spec.yml +++ b/internal/cmd/testdata/edgecase/openapi_spec.yml @@ -4,9 +4,9 @@ info: description: This is a fake API spec that was built to test some of the less common API schema structures and their mapping in the OpenAPI to Framework code generator version: 1.0.0 paths: - /nested_lists: + /nested_collections: get: - summary: Test for nested lists (list within a list) + summary: Test for nested collections (list within a list, set within a list, map within a list) responses: "200": description: OK @@ -15,9 +15,52 @@ paths: schema: type: object properties: - main_list: - $ref: "#/components/schemas/multidimensional_array_schema" - /set_collections: + double_nested_list: + $ref: "#/components/schemas/double_nested_list_schema" + double_nested_set: + $ref: "#/components/schemas/double_nested_set_schema" + double_nested_map: + $ref: "#/components/schemas/double_nested_map_schema" + triple_nested_map: + $ref: "#/components/schemas/triple_nested_map_schema" + /map_test: + get: + summary: Test for MapNested attributes and Map attributes in a data source + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + mapnested_prop: + $ref: "#/components/schemas/mapnested_schema" + map_prop: + description: This is a map of floats + type: object + additionalProperties: + type: number + format: float + post: + summary: Test for MapNested attributes and Map attributes in a resource + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - mapnested_prop + properties: + mapnested_prop: + $ref: "#/components/schemas/mapnested_schema" + map_prop: + description: This is a map of strings + type: object + additionalProperties: + type: string + /set_test: get: summary: Test for SetNested attributes and Set attributes in a data source responses: @@ -57,13 +100,48 @@ paths: type: string components: schemas: - multidimensional_array_schema: - description: This list has another list nested underneath! + double_nested_list_schema: + description: This list has a list nested underneath! type: array items: type: array items: type: string + double_nested_set_schema: + description: This set has a set nested underneath! + type: array + format: set + items: + type: array + format: set + items: + type: string + double_nested_map_schema: + description: This list has a map nested underneath! + type: array + items: + type: object + description: This is a double nested map of strings + additionalProperties: + type: string + triple_nested_map_schema: + description: This list has a set of maps nested underneath! + type: array + items: + type: array + format: set + items: + type: object + description: This is a triple nested map of objects + additionalProperties: + type: object + properties: + string_prop: + description: String inside a map! + type: string + bool_prop: + description: Bool inside a map! + type: boolean setnested_schema: description: This is a set with a nested object type: array @@ -77,3 +155,15 @@ components: bool_prop: description: Bool inside a set! type: boolean + mapnested_schema: + description: This is a map with a nested object + type: object + additionalProperties: + type: object + properties: + string_prop: + description: String inside a map! + type: string + bool_prop: + description: Bool inside a map! + type: boolean diff --git a/internal/cmd/testdata/edgecase/tfopenapigen_config.yml b/internal/cmd/testdata/edgecase/tfopenapigen_config.yml index baa648c4..18bd1c07 100644 --- a/internal/cmd/testdata/edgecase/tfopenapigen_config.yml +++ b/internal/cmd/testdata/edgecase/tfopenapigen_config.yml @@ -2,20 +2,31 @@ provider: name: edgecase resources: - set_collections: + set_test: create: - path: /set_collections + path: /set_test method: POST read: - path: /set_collections + path: /set_test + method: GET + map_test: + create: + path: /map_test + method: POST + read: + path: /map_test method: GET data_sources: - nested_lists: + nested_collections: + read: + path: /nested_collections + method: GET + set_test: read: - path: /nested_lists + path: /set_test method: GET - set_collections: + map_test: read: - path: /set_collections + path: /map_test method: GET \ No newline at end of file diff --git a/internal/mapper/oas/attribute.go b/internal/mapper/oas/attribute.go index 6ad41bdc..ba60328c 100644 --- a/internal/mapper/oas/attribute.go +++ b/internal/mapper/oas/attribute.go @@ -51,6 +51,9 @@ func (s *OASSchema) BuildResourceAttribute(name string, computability schema.Com case util.OAS_type_array: return s.BuildCollectionResource(name, computability) case util.OAS_type_object: + if s.IsMap() { + return s.BuildMapResource(name, computability) + } return s.BuildSingleNestedResource(name, computability) default: return nil, fmt.Errorf("invalid schema type '%s'", s.Type) @@ -96,6 +99,9 @@ func (s *OASSchema) BuildDataSourceAttribute(name string, computability schema.C case util.OAS_type_array: return s.BuildCollectionDataSource(name, computability) case util.OAS_type_object: + if s.IsMap() { + return s.BuildMapDataSource(name, computability) + } return s.BuildSingleNestedDataSource(name, computability) default: return nil, fmt.Errorf("invalid schema type '%s'", s.Type) diff --git a/internal/mapper/oas/collection.go b/internal/mapper/oas/collection.go index 97b26a55..c31f9786 100644 --- a/internal/mapper/oas/collection.go +++ b/internal/mapper/oas/collection.go @@ -22,7 +22,8 @@ func (s *OASSchema) BuildCollectionResource(name string, computability schema.Co return nil, fmt.Errorf("failed to build array items schema for '%s'", name) } - if itemSchema.Type == util.OAS_type_object { + // If the items schema is a map (i.e. additionalProperties set to a schema), it cannot be a NestedAttribute + if itemSchema.Type == util.OAS_type_object && !itemSchema.IsMap() { objectAttributes, err := itemSchema.BuildResourceAttributes() if err != nil { return nil, fmt.Errorf("failed to map nested object schema proxy - %w", err) @@ -89,7 +90,8 @@ func (s *OASSchema) BuildCollectionDataSource(name string, computability schema. return nil, fmt.Errorf("failed to build array items schema for '%s'", name) } - if itemSchema.Type == util.OAS_type_object { + // If the items schema is a map (i.e. additionalProperties set to a schema), it cannot be a NestedAttribute + if itemSchema.Type == util.OAS_type_object && !itemSchema.IsMap() { objectAttributes, err := itemSchema.BuildDataSourceAttributes() if err != nil { return nil, fmt.Errorf("failed to map nested object schema proxy - %w", err) diff --git a/internal/mapper/oas/collection_test.go b/internal/mapper/oas/collection_test.go index eb13c3e5..ee3082c4 100644 --- a/internal/mapper/oas/collection_test.go +++ b/internal/mapper/oas/collection_test.go @@ -355,6 +355,112 @@ func TestBuildCollectionResource(t *testing.T) { }, }, }, + "list and set attribute - nested map results in element type": { + schema: &base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "list_with_map": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Description: "hey there! I'm a list with a nested map of objects.", + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_boolean": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"boolean"}, + Description: "this won't be added, since it will map to element type", + }), + "nested_string": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Description: "this won't be added, since it will map to element type", + }), + }, + }), + }), + }, + }), + "set_with_map": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Format: "set", + Description: "hey there! I'm a set with a nested map of objects.", + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_float64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "double", + Description: "this won't be added, since it will map to element type", + }), + "nested_int64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"integer"}, + Format: "int64", + Description: "this won't be added, since it will map to element type", + }), + }, + }), + }), + }, + }), + }, + }, + expectedAttributes: &[]resource.Attribute{ + { + Name: "list_with_map", + List: &resource.ListAttribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a list with a nested map of objects."), + ElementType: schema.ElementType{ + Map: &schema.MapType{ + ElementType: schema.ElementType{ + Object: &schema.ObjectType{ + AttributeTypes: []schema.ObjectAttributeType{ + { + Name: "nested_boolean", + Bool: &schema.BoolType{}, + }, + { + Name: "nested_string", + String: &schema.StringType{}, + }, + }, + }, + }, + }, + }, + }, + }, + { + Name: "set_with_map", + Set: &resource.SetAttribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a set with a nested map of objects."), + ElementType: schema.ElementType{ + Map: &schema.MapType{ + ElementType: schema.ElementType{ + Object: &schema.ObjectType{ + AttributeTypes: []schema.ObjectAttributeType{ + { + Name: "nested_float64", + Float64: &schema.Float64Type{}, + }, + { + Name: "nested_int64", + Int64: &schema.Int64Type{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for name, testCase := range testCases { @@ -714,6 +820,112 @@ func TestBuildCollectionDataSource(t *testing.T) { }, }, }, + "list and set attribute - nested map results in element type": { + schema: &base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "list_with_map": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Description: "hey there! I'm a list with a nested map of objects.", + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_boolean": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"boolean"}, + Description: "this won't be added, since it will map to element type", + }), + "nested_string": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Description: "this won't be added, since it will map to element type", + }), + }, + }), + }), + }, + }), + "set_with_map": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Format: "set", + Description: "hey there! I'm a set with a nested map of objects.", + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_float64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "double", + Description: "this won't be added, since it will map to element type", + }), + "nested_int64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"integer"}, + Format: "int64", + Description: "this won't be added, since it will map to element type", + }), + }, + }), + }), + }, + }), + }, + }, + expectedAttributes: &[]datasource.Attribute{ + { + Name: "list_with_map", + List: &datasource.ListAttribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a list with a nested map of objects."), + ElementType: schema.ElementType{ + Map: &schema.MapType{ + ElementType: schema.ElementType{ + Object: &schema.ObjectType{ + AttributeTypes: []schema.ObjectAttributeType{ + { + Name: "nested_boolean", + Bool: &schema.BoolType{}, + }, + { + Name: "nested_string", + String: &schema.StringType{}, + }, + }, + }, + }, + }, + }, + }, + }, + { + Name: "set_with_map", + Set: &datasource.SetAttribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a set with a nested map of objects."), + ElementType: schema.ElementType{ + Map: &schema.MapType{ + ElementType: schema.ElementType{ + Object: &schema.ObjectType{ + AttributeTypes: []schema.ObjectAttributeType{ + { + Name: "nested_float64", + Float64: &schema.Float64Type{}, + }, + { + Name: "nested_int64", + Int64: &schema.Int64Type{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/mapper/oas/element_type.go b/internal/mapper/oas/element_type.go index 2f5ef02d..64a8d4a4 100644 --- a/internal/mapper/oas/element_type.go +++ b/internal/mapper/oas/element_type.go @@ -23,6 +23,9 @@ func (s *OASSchema) BuildElementType() (schema.ElementType, error) { case util.OAS_type_array: return s.BuildCollectionElementType() case util.OAS_type_object: + if s.IsMap() { + return s.BuildMapElementType() + } return s.BuildObjectElementType() default: diff --git a/internal/mapper/oas/map.go b/internal/mapper/oas/map.go new file mode 100644 index 00000000..55b4ff13 --- /dev/null +++ b/internal/mapper/oas/map.go @@ -0,0 +1,129 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package oas + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-codegen-openapi/internal/mapper/util" + "github.com/hashicorp/terraform-plugin-codegen-spec/datasource" + "github.com/hashicorp/terraform-plugin-codegen-spec/resource" + "github.com/hashicorp/terraform-plugin-codegen-spec/schema" + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +func (s *OASSchema) BuildMapResource(name string, computability schema.ComputedOptionalRequired) (*resource.Attribute, error) { + // Maps are detected as `type: object`, with an `additionalProperties` field that is a schema. `additionalProperties` can + // also be a boolean (which we should ignore and map to an SingleNestedAttribute), so calling functions should call s.IsMap() first. + mapSchemaProxy, ok := s.Schema.AdditionalProperties.(*base.SchemaProxy) + if !ok { + return nil, fmt.Errorf("invalid map schema, expected type *base.SchemaProxy, got: %T", s.Schema.AdditionalProperties) + } + + mapSchema, err := BuildSchema(mapSchemaProxy, SchemaOpts{}, s.GlobalSchemaOpts) + if err != nil { + return nil, fmt.Errorf("error building map schema proxy - %w", err) + } + + if mapSchema.Type == util.OAS_type_object { + mapAttributes, err := mapSchema.BuildResourceAttributes() + if err != nil { + return nil, fmt.Errorf("failed to build nested object schema proxy - %w", err) + } + return &resource.Attribute{ + Name: name, + MapNested: &resource.MapNestedAttribute{ + NestedObject: resource.NestedAttributeObject{ + Attributes: *mapAttributes, + }, + ComputedOptionalRequired: computability, + Description: s.GetDescription(), + }, + }, nil + } + + elemType, err := mapSchema.BuildElementType() + if err != nil { + return nil, fmt.Errorf("failed to create map elem type - %w", err) + } + + return &resource.Attribute{ + Name: name, + Map: &resource.MapAttribute{ + ElementType: elemType, + ComputedOptionalRequired: computability, + Description: s.GetDescription(), + }, + }, nil +} + +func (s *OASSchema) BuildMapDataSource(name string, computability schema.ComputedOptionalRequired) (*datasource.Attribute, error) { + // Maps are detected as `type: object`, with an `additionalProperties` field that is a schema. `additionalProperties` can + // also be a boolean (which we should ignore and map to an SingleNestedAttribute), so calling functions should call s.IsMap() first. + mapSchemaProxy, ok := s.Schema.AdditionalProperties.(*base.SchemaProxy) + if !ok { + return nil, fmt.Errorf("invalid map schema, expected type *base.SchemaProxy, got: %T", s.Schema.AdditionalProperties) + } + + mapSchema, err := BuildSchema(mapSchemaProxy, SchemaOpts{}, s.GlobalSchemaOpts) + if err != nil { + return nil, fmt.Errorf("error building map schema proxy - %w", err) + } + + if mapSchema.Type == util.OAS_type_object { + mapAttributes, err := mapSchema.BuildDataSourceAttributes() + if err != nil { + return nil, fmt.Errorf("failed to build nested object schema proxy - %w", err) + } + return &datasource.Attribute{ + Name: name, + MapNested: &datasource.MapNestedAttribute{ + NestedObject: datasource.NestedAttributeObject{ + Attributes: *mapAttributes, + }, + ComputedOptionalRequired: computability, + Description: s.GetDescription(), + }, + }, nil + } + + elemType, err := mapSchema.BuildElementType() + if err != nil { + return nil, fmt.Errorf("failed to create map elem type - %w", err) + } + + return &datasource.Attribute{ + Name: name, + Map: &datasource.MapAttribute{ + ElementType: elemType, + ComputedOptionalRequired: computability, + Description: s.GetDescription(), + }, + }, nil +} + +func (s *OASSchema) BuildMapElementType() (schema.ElementType, error) { + // Maps are detected as `type: object`, with an `additionalProperties` field that is a schema. `additionalProperties` can + // also be a boolean (which we should ignore and map to an ObjectType), so calling functions should call s.IsMap() first. + mapSchemaProxy, ok := s.Schema.AdditionalProperties.(*base.SchemaProxy) + if !ok { + return schema.ElementType{}, fmt.Errorf("invalid map schema, expected type *base.SchemaProxy, got: %T", s.Schema.AdditionalProperties) + } + + mapSchema, err := BuildSchema(mapSchemaProxy, SchemaOpts{}, s.GlobalSchemaOpts) + if err != nil { + return schema.ElementType{}, fmt.Errorf("error building map schema proxy - %w", err) + } + + elemType, err := mapSchema.BuildElementType() + if err != nil { + return schema.ElementType{}, fmt.Errorf("failed to create map elem type - %w", err) + } + + return schema.ElementType{ + Map: &schema.MapType{ + ElementType: elemType, + }, + }, nil +} diff --git a/internal/mapper/oas/map_test.go b/internal/mapper/oas/map_test.go new file mode 100644 index 00000000..f756fe4f --- /dev/null +++ b/internal/mapper/oas/map_test.go @@ -0,0 +1,330 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package oas_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-codegen-openapi/internal/mapper/oas" + "github.com/hashicorp/terraform-plugin-codegen-spec/datasource" + "github.com/hashicorp/terraform-plugin-codegen-spec/resource" + "github.com/hashicorp/terraform-plugin-codegen-spec/schema" + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// TODO: add error tests + +func TestBuildMapResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema *base.Schema + expectedAttributes *[]resource.Attribute + }{ + "map nested attribute with props": { + schema: &base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_map_prop": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map nested type.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Required: []string{"nested_password_required"}, + Properties: map[string]*base.SchemaProxy{ + "nested_obj_prop": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Required: []string{"nested_int64_required"}, + Description: "hey there! I'm a single nested object type.", + Properties: map[string]*base.SchemaProxy{ + "nested_float64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "double", + Description: "hey there! I'm a nested float64 type.", + }), + "nested_int64_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"integer"}, + Format: "int64", + Description: "hey there! I'm a nested int64 type, required.", + }), + }, + }), + "nested_password_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Format: "password", + Description: "hey there! I'm a nested string type, required.", + }), + }, + }), + }), + }, + }, + expectedAttributes: &[]resource.Attribute{ + { + Name: "nested_map_prop", + MapNested: &resource.MapNestedAttribute{ + NestedObject: resource.NestedAttributeObject{ + Attributes: []resource.Attribute{ + { + Name: "nested_obj_prop", + SingleNested: &resource.SingleNestedAttribute{ + Attributes: []resource.Attribute{ + { + Name: "nested_float64", + Float64: &resource.Float64Attribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a nested float64 type."), + }, + }, + { + Name: "nested_int64_required", + Int64: &resource.Int64Attribute{ + ComputedOptionalRequired: schema.Required, + Description: pointer("hey there! I'm a nested int64 type, required."), + }, + }, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a single nested object type."), + }, + }, + { + Name: "nested_password_required", + String: &resource.StringAttribute{ + ComputedOptionalRequired: schema.Required, + Sensitive: pointer(true), + Description: pointer("hey there! I'm a nested string type, required."), + }, + }, + }, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a map nested type."), + }, + }, + }, + }, + "map attributes with element types": { + schema: &base.Schema{ + Type: []string{"object"}, + Required: []string{"map_with_strings_required"}, + Properties: map[string]*base.SchemaProxy{ + "map_with_floats": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map type with floats.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "float", + }), + }), + "map_with_strings_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map type with strings, required.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + }), + }), + }, + }, + expectedAttributes: &[]resource.Attribute{ + { + Name: "map_with_floats", + Map: &resource.MapAttribute{ + ElementType: schema.ElementType{ + Float64: &schema.Float64Type{}, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a map type with floats."), + }, + }, + { + Name: "map_with_strings_required", + Map: &resource.MapAttribute{ + ElementType: schema.ElementType{ + String: &schema.StringType{}, + }, + ComputedOptionalRequired: schema.Required, + Description: pointer("hey there! I'm a map type with strings, required."), + }, + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + schema := oas.OASSchema{Schema: testCase.schema} + attributes, err := schema.BuildResourceAttributes() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if diff := cmp.Diff(attributes, testCase.expectedAttributes); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestBuildMapDataSource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema *base.Schema + expectedAttributes *[]datasource.Attribute + }{ + + "map nested attribute with props": { + schema: &base.Schema{ + Type: []string{"object"}, + Properties: map[string]*base.SchemaProxy{ + "nested_map_prop": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map nested type.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Required: []string{"nested_password_required"}, + Properties: map[string]*base.SchemaProxy{ + "nested_obj_prop": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Required: []string{"nested_int64_required"}, + Description: "hey there! I'm a single nested object type.", + Properties: map[string]*base.SchemaProxy{ + "nested_float64": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "double", + Description: "hey there! I'm a nested float64 type.", + }), + "nested_int64_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"integer"}, + Format: "int64", + Description: "hey there! I'm a nested int64 type, required.", + }), + }, + }), + "nested_password_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Format: "password", + Description: "hey there! I'm a nested string type, required.", + }), + }, + }), + }), + }, + }, + expectedAttributes: &[]datasource.Attribute{ + { + Name: "nested_map_prop", + MapNested: &datasource.MapNestedAttribute{ + NestedObject: datasource.NestedAttributeObject{ + Attributes: []datasource.Attribute{ + { + Name: "nested_obj_prop", + SingleNested: &datasource.SingleNestedAttribute{ + Attributes: []datasource.Attribute{ + { + Name: "nested_float64", + Float64: &datasource.Float64Attribute{ + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a nested float64 type."), + }, + }, + { + Name: "nested_int64_required", + Int64: &datasource.Int64Attribute{ + ComputedOptionalRequired: schema.Required, + Description: pointer("hey there! I'm a nested int64 type, required."), + }, + }, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a single nested object type."), + }, + }, + { + Name: "nested_password_required", + String: &datasource.StringAttribute{ + ComputedOptionalRequired: schema.Required, + Sensitive: pointer(true), + Description: pointer("hey there! I'm a nested string type, required."), + }, + }, + }, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a map nested type."), + }, + }, + }, + }, + "map attributes with element types": { + schema: &base.Schema{ + Type: []string{"object"}, + Required: []string{"map_with_strings_required"}, + Properties: map[string]*base.SchemaProxy{ + "map_with_floats": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map type with floats.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"number"}, + Format: "float", + }), + }), + "map_with_strings_required": base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "hey there! I'm a map type with strings, required.", + AdditionalProperties: base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + }), + }), + }, + }, + expectedAttributes: &[]datasource.Attribute{ + { + Name: "map_with_floats", + Map: &datasource.MapAttribute{ + ElementType: schema.ElementType{ + Float64: &schema.Float64Type{}, + }, + ComputedOptionalRequired: schema.ComputedOptional, + Description: pointer("hey there! I'm a map type with floats."), + }, + }, + { + Name: "map_with_strings_required", + Map: &datasource.MapAttribute{ + ElementType: schema.ElementType{ + String: &schema.StringType{}, + }, + ComputedOptionalRequired: schema.Required, + Description: pointer("hey there! I'm a map type with strings, required."), + }, + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + schema := oas.OASSchema{Schema: testCase.schema} + attributes, err := schema.BuildDataSourceAttributes() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if diff := cmp.Diff(attributes, testCase.expectedAttributes); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/mapper/oas/oas_schema.go b/internal/mapper/oas/oas_schema.go index 83e6c01f..d7bb8e5d 100644 --- a/internal/mapper/oas/oas_schema.go +++ b/internal/mapper/oas/oas_schema.go @@ -40,6 +40,13 @@ type SchemaOpts struct { OverrideDescription string } +// IsMap will perform a type assertion on the `additionalProperties` field to determine if a map type +// is appropriate. See: https://json-schema.org/understanding-json-schema/reference/object.html#additional-properties +func (s *OASSchema) IsMap() bool { + _, isMap := s.Schema.AdditionalProperties.(*base.SchemaProxy) + return isMap +} + func (s *OASSchema) GetDescription() *string { if s.SchemaOpts.OverrideDescription != "" { return &s.SchemaOpts.OverrideDescription