diff --git a/internal/emit/render_schema.go b/internal/emit/render_schema.go index acf040a..9b4cc0d 100644 --- a/internal/emit/render_schema.go +++ b/internal/emit/render_schema.go @@ -41,6 +41,25 @@ func (sb *schemaBuilder) rendersComputed() bool { return sb.kind == schemaResource || sb.kind == schemaDatasource } +// rendersSensitive reports whether the schema package declares Sensitive. +// The action and list packages do not: their attribute types carry +// DeprecationMessage but no Sensitive field, so declaring one would not +// compile. A secret passed as an action argument or a list filter is +// therefore unmarked, which is the framework's limit rather than a choice +// made here. +// +// The membership matches rendersComputed's today and is spelled separately +// because nothing ties the two facts together. +func (sb *schemaBuilder) rendersSensitive() bool { + return sb.kind == schemaResource || sb.kind == schemaDatasource +} + +// deprecationMessage is what a generated schema says about an attribute the +// document declares deprecated. OpenAPI's deprecated is a bare flag carrying +// no prose, and the framework's DeprecationMessage is the warning text a +// practitioner reads, so the sentence is the toolkit's own and is fixed. +const deprecationMessage = "This attribute is deprecated and may be removed in a future API version." + // schemaBuilder accumulates the imports one schema declaration needs as // it renders. type schemaBuilder struct { @@ -78,6 +97,17 @@ func (sb *schemaBuilder) attributeDecl(n node, depth int) string { fmt.Fprintf(&b, "%s\tMarkdownDescription: %s,\n", indent, strconv.Quote(desc)) } + // Sensitive keeps the value out of plan output and logs. It is a plain + // bool on the attribute type, so unlike a validator or a plan modifier + // it needs no import to travel with it. + if n.attr.Sensitive && sb.rendersSensitive() { + fmt.Fprintf(&b, "%s\tSensitive: true,\n", indent) + } + + if n.attr.Deprecated { + fmt.Fprintf(&b, "%s\tDeprecationMessage: %s,\n", indent, strconv.Quote(deprecationMessage)) + } + if (n.attr.Kind == ir.TypeList || n.attr.Kind == ir.TypeMap) && n.attr.Nested == nil { sb.imports.add("", "github.com/hashicorp/terraform-plugin-framework/types") fmt.Fprintf(&b, "%s\tElementType: %s,\n", indent, schemaTypeOf(n).ElementType) diff --git a/internal/emit/render_schema_test.go b/internal/emit/render_schema_test.go new file mode 100644 index 0000000..588f2d9 --- /dev/null +++ b/internal/emit/render_schema_test.go @@ -0,0 +1,74 @@ +package emit + +import ( + "strings" + "testing" + + ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation" +) + +// declOf renders one attribute through a builder of the given kind, which is +// the whole of what a presence, a sensitivity or a deprecation decides. +func declOf(kind schemaKind, attr ir.Attribute) string { + sb := &schemaBuilder{kind: kind, imports: newImportSet("example.com/m")} + return sb.attributeDecl(node{attr: attr}, 0) +} + +// TestUnit_AttributeDecl_MarksASecretSensitive proves a value the document +// declares write-only or formats as a password is kept out of terraform's +// output, and that an ordinary value is not. +func TestUnit_AttributeDecl_MarksASecretSensitive(t *testing.T) { + for _, kind := range []schemaKind{schemaResource, schemaDatasource} { + decl := declOf(kind, ir.Attribute{ + Name: "password", Kind: ir.TypeString, + ComputedOptionalRequired: ir.Optional, Sensitive: true, + }) + if !strings.Contains(decl, "Sensitive: true,") { + t.Errorf("kind %d does not mark a secret sensitive:\n%s", kind, decl) + } + } + + plain := declOf(schemaResource, ir.Attribute{ + Name: "name", Kind: ir.TypeString, ComputedOptionalRequired: ir.Optional, + }) + if strings.Contains(plain, "Sensitive") { + t.Errorf("an ordinary attribute is marked sensitive:\n%s", plain) + } +} + +// TestUnit_AttributeDecl_OmitsSensitiveWhereThePackageLacksIt proves the +// action and list packages get no Sensitive field: their attribute types do +// not declare one, so emitting it would not compile. +func TestUnit_AttributeDecl_OmitsSensitiveWhereThePackageLacksIt(t *testing.T) { + for _, kind := range []schemaKind{schemaAction, schemaListResource} { + decl := declOf(kind, ir.Attribute{ + Name: "password", Kind: ir.TypeString, + ComputedOptionalRequired: ir.Optional, Sensitive: true, + }) + if strings.Contains(decl, "Sensitive") { + t.Errorf("kind %d declares Sensitive, which its package has no field for:\n%s", kind, decl) + } + } +} + +// TestUnit_AttributeDecl_WarnsOnADeprecatedAttribute proves a deprecated +// attribute carries the warning in every schema package, all four of which +// declare DeprecationMessage. +func TestUnit_AttributeDecl_WarnsOnADeprecatedAttribute(t *testing.T) { + for _, kind := range []schemaKind{schemaResource, schemaDatasource, schemaAction, schemaListResource} { + decl := declOf(kind, ir.Attribute{ + Name: "legacy", Kind: ir.TypeString, + ComputedOptionalRequired: ir.Optional, Deprecated: true, + }) + if !strings.Contains(decl, `DeprecationMessage: "This attribute is deprecated and may be removed in a future API version.",`) { + t.Errorf("kind %d does not warn on a deprecated attribute:\n%s", kind, decl) + } + } + + current := declOf(schemaResource, ir.Attribute{ + Name: "name", Kind: ir.TypeString, ComputedOptionalRequired: ir.Optional, + }) + if strings.Contains(current, "DeprecationMessage") { + t.Errorf("an undeprecated attribute carries a warning:\n%s", current) + } +} diff --git a/internal/intermediate_representation/attribute_addressing.go b/internal/intermediate_representation/attribute_addressing.go new file mode 100644 index 0000000..d29546e --- /dev/null +++ b/internal/intermediate_representation/attribute_addressing.go @@ -0,0 +1,142 @@ +// The attributes that exist to address an object rather than to describe it: +// the id every entity carries, and the path parameters above it. No request +// or response body declares them, so nothing else in the derivation would +// produce them. + +package intermediate_representation + +// ensureID guarantees the id attribute every resource and datasource +// carries: computed, mapped from the response'schema id field when the schema +// declares one, otherwise synthesized from the item path parameter. +func ensureID(tree *AttributeTree, keyParam string, keyType AttributeType) { + for index := range tree.Attributes { + if tree.Attributes[index].Name == "id" { + tree.Attributes[index].ComputedOptionalRequired = Computed + tree.Attributes[index].RequiresReplace = false + return + } + } + wire := keyParam + if wire == "" { + wire = "id" + } + kind := keyType + if kind == "" { + kind = TypeString + } + tree.Attributes = append([]Attribute{{ + Name: "id", + WireName: wire, + Kind: kind, + ComputedOptionalRequired: Computed, + }}, tree.Attributes...) +} + +// ensureParentParameters gives every path parameter above the item key an +// attribute to be read from: required, and prepended in path order ahead of +// the id. +// +// An item path is not always /things/{id}. A parent-scoped API spells it +// /repos/{owner}/{repo}/rulesets/{ruleset_id}, and owner and repo appear in +// no request or response body — they are addressing, not content. Emission +// had nothing to feed them from and refused the entity, which on a +// thoroughly parent-scoped document is most of the API. +// +// A parent the body does declare is left as the body declares it; the +// document is a better authority on its own field than the URL is. Only a +// parameter no attribute answers is added. +func ensureParentParameters(tree *AttributeTree, parents []Parameter) { + if tree == nil || len(parents) == 0 { + return + } + // A name the tree already uses cannot be added again, whatever it holds: + // two attributes of one name is not a schema. Where the sitting tenant is + // an object, it is a different thing the document spells the same way — a + // repository's owner block beside the owner segment of its path — and it + // cannot answer the parameter either. Emission refuses the entity by + // name, which is a better answer than a renamed attribute nobody asked + // for or a schema that does not load. + declared := make(map[string]bool, len(tree.Attributes)) + for _, attribute := range tree.Attributes { + declared[attribute.Name] = true + } + + added := make([]Attribute, 0, len(parents)) + for _, parent := range parents { + name := snakeCase(parent.Name) + if declared[name] { + continue + } + declared[name] = true + kind := parent.Type + if kind == "" { + kind = TypeString + } + added = append(added, Attribute{ + Name: name, + WireName: parent.Name, + Kind: kind, + ComputedOptionalRequired: Required, + // Addressing is not editable: an object does not move to another + // parent in place, and every API that admits the move spells it + // as its own operation. + RequiresReplace: true, + }) + } + if len(added) == 0 { + return + } + tree.Attributes = append(added, tree.Attributes...) +} + +// addressingSchema is a collection path's addressing attributes as a tree of +// their own, for a list resource to declare as the configuration of its list +// block. Nil when the path takes no parameters. +// +// Every parameter is a parent: a collection path carries no item key, so +// there is no id to absorb the last one. None carries RequiresReplace — a +// list block declares a query, and a query has no plan for a modifier to act +// on. +func addressingSchema(parameters []Parameter) *AttributeTree { + if len(parameters) == 0 { + return nil + } + tree := &AttributeTree{} + ensureParentParameters(tree, parameters) + for index := range tree.Attributes { + tree.Attributes[index].RequiresReplace = false + } + return tree +} + +// parentParameters is an operation's path parameters above the item key: all +// of them but the last, which addresses the object itself and becomes the id. +func parentParameters(parameters []Parameter) []Parameter { + if len(parameters) < 2 { + return nil + } + return parameters[:len(parameters)-1] +} + +// requireKey turns the lookup key into the datasource'schema single required +// argument: the matching attribute becomes required, or a new one is +// prepended when the response object does not carry the key. +func requireKey(tree *AttributeTree, keyParam string, keyType AttributeType) { + name := snakeCase(keyParam) + for index := range tree.Attributes { + if tree.Attributes[index].Name == name { + tree.Attributes[index].ComputedOptionalRequired = Required + return + } + } + kind := keyType + if kind == "" { + kind = TypeString + } + tree.Attributes = append([]Attribute{{ + Name: name, + WireName: keyParam, + Kind: kind, + ComputedOptionalRequired: Required, + }}, tree.Attributes...) +} diff --git a/internal/intermediate_representation/attribute_types.go b/internal/intermediate_representation/attribute_types.go new file mode 100644 index 0000000..4b52650 --- /dev/null +++ b/internal/intermediate_representation/attribute_types.go @@ -0,0 +1,141 @@ +// How a declared type becomes a terraform attribute kind, and the refusals +// that follow when it cannot. + +package intermediate_representation + +import ( + "fmt" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/specmodel" +) + +// deriveType maps the schema shape onto an attribute type, refusing the +// shapes the toolkit does not model rather than guessing: an Unsupported +// attribute names its reason and generates nothing. +func deriveType(attribute *Attribute, flatPrimary flat, create, read, update *specmodel.Schema) { + switch { + case flatPrimary.declaredType == "string": + attribute.Kind = TypeString + case flatPrimary.declaredType == "boolean": + attribute.Kind = TypeBool + case flatPrimary.declaredType == "integer": + attribute.Kind = TypeInt64 + case flatPrimary.declaredType == "number": + attribute.Kind = TypeFloat64 + case flatPrimary.declaredType == "array": + deriveListType(attribute, create, read, update) + case flatPrimary.declaredType == "object" || (flatPrimary.declaredType == "" && len(flatPrimary.properties) > 0): + if len(flatPrimary.properties) == 0 { + deriveMapType(attribute, flatPrimary) + return + } + attribute.Kind = TypeObject + attribute.Nested = buildTree(create, read, update, false) + case flatPrimary.declaredType == "" && flatPrimary.hasUnion: + // resolveUnion collapses a union whose branches are all scalars. + // What is left has an object branch, which the generated SDK models + // as a composed type carrying an accessor per branch — an attribute + // per variant, not one collapsed type. + refuse(attribute, "oneOf/anyOf union with an object branch: it needs one attribute per variant, which the document alone does not name") + case flatPrimary.declaredType == "": + refuse(attribute, "no type declared") + default: + refuse(attribute, fmt.Sprintf("type %q is not supported", flatPrimary.declaredType)) + } +} + +// deriveMapType types an object that declares no properties. Only +// additionalProperties carrying a schema names the value type, which is +// what a map attribute needs; a bare boolean or nothing at all says the +// object has no declared shape, and the refusal says which was seen. +func deriveMapType(attribute *Attribute, flatPrimary flat) { + value := flatPrimary.additionalProperties + if value == nil { + if flatPrimary.additionalPropertiesDeclared { + refuse(attribute, "object whose additionalProperties is a bare boolean: it declares no value type to map") + return + } + refuse(attribute, "object declaring neither properties nor additionalProperties: it has no declared shape") + return + } + + flatValue := flatten(value) + switch { + case flatValue.declaredType == "string": + attribute.Kind, attribute.ElementType = TypeMap, TypeString + case flatValue.declaredType == "boolean": + attribute.Kind, attribute.ElementType = TypeMap, TypeBool + case flatValue.declaredType == "integer": + attribute.Kind, attribute.ElementType = TypeMap, TypeInt64 + case flatValue.declaredType == "number": + attribute.Kind, attribute.ElementType = TypeMap, TypeFloat64 + case flatValue.declaredType == "object" || (flatValue.declaredType == "" && len(flatValue.properties) > 0): + // A map of objects needs a nested model, nested state mapping and + // nested fixtures; only maps of scalars are modelled. + refuse(attribute, "map of objects: only maps of scalar values are modelled") + default: + refuse(attribute, fmt.Sprintf("map of %q values is not supported", flatValue.declaredType)) + } +} + +// deriveListType types an array attribute from its element schema, seen +// from both sides of the create/read fold. +func deriveListType(attribute *Attribute, create, read, update *specmodel.Schema) { + createItems, readItems, updateItems := flatten(create).items, flatten(read).items, flatten(update).items + primary := createItems + if primary == nil { + primary = readItems + } + flatItems := flatten(primary) + switch { + case flatItems.empty: + refuse(attribute, "array declares no items schema") + case flatItems.declaredType == "string": + attribute.Kind, attribute.ElementType = TypeList, TypeString + case flatItems.declaredType == "boolean": + attribute.Kind, attribute.ElementType = TypeList, TypeBool + case flatItems.declaredType == "integer": + attribute.Kind, attribute.ElementType = TypeList, TypeInt64 + case flatItems.declaredType == "number": + attribute.Kind, attribute.ElementType = TypeList, TypeFloat64 + case flatItems.declaredType == "object" || (flatItems.declaredType == "" && len(flatItems.properties) > 0): + if len(flatItems.properties) == 0 { + refuse(attribute, "array of free-form objects: map support is out of scope") + return + } + attribute.Kind, attribute.ElementType = TypeList, TypeObject + attribute.Nested = buildTree(createItems, readItems, updateItems, false) + default: + refuse(attribute, fmt.Sprintf("array of %q elements is not supported", flatItems.declaredType)) + } +} + +// refuse marks an attribute unsupported with the reason a person reads. +func refuse(attribute *Attribute, reason string) { + attribute.Kind = "" + attribute.Unsupported = true + attribute.UnsupportedReason = reason +} + +// mergeExtensions folds the read side'schema property extensions under the +// create side'schema, the create side winning a collision: the writable view is +// where behaviour annotations are authored. +func mergeExtensions(create, read specmodel.Extensions) specmodel.Extensions { + out := specmodel.Extensions{} + for key, value := range read { + out[key] = value + } + for key, value := range create { + out[key] = value + } + return out +} + +// renderEnum spells enum values for a validator, in document order. +func renderEnum(values []any) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, fmt.Sprintf("%v", value)) + } + return out +} diff --git a/internal/intermediate_representation/attributes.go b/internal/intermediate_representation/attributes.go index 2a33d00..6050455 100644 --- a/internal/intermediate_representation/attributes.go +++ b/internal/intermediate_representation/attributes.go @@ -1,7 +1,6 @@ package intermediate_representation import ( - "fmt" "sort" "strings" @@ -160,6 +159,9 @@ func foldBound[T int64 | float64](dst **T, declared *T) { } } +// passwordFormat is the format a document gives a value that is a secret. +const passwordFormat = "password" + // scalarTypes are the declared types a single terraform attribute holds // directly. var scalarTypes = map[string]bool{"string": true, "integer": true, "number": true, "boolean": true} @@ -455,6 +457,14 @@ func buildAttribute(wire string, attributeSite site) (Attribute, attributeEdges) attribute.MinLength, attribute.MaxLength = flatPrimary.minLength, flatPrimary.maxLength attribute.MinItems, attribute.MaxItems = flatPrimary.minItems, flatPrimary.maxItems + // A value the document says is a secret. Either declaration is enough on + // its own: format: password names what the value is, and writeOnly says + // the API takes it and never gives it back, which is what a credential + // does. Read from both sides — a request schema is where a secret is + // declared, and a response schema that names one describes the same field. + attribute.Sensitive = flatCreate.writeOnly || flatRead.writeOnly || + flatCreate.format == passwordFormat || flatRead.format == passwordFormat + // Every attribute lands in exactly one of five outcomes, and this is where // four of them are chosen (the fifth, omitted entirely, is decided by // deriveType marking the attribute unsupported). @@ -528,270 +538,3 @@ func buildAttribute(wire string, attributeSite site) (Attribute, attributeEdges) } return attribute, edges } - -// deriveType maps the schema shape onto an attribute type, refusing the -// shapes the toolkit does not model rather than guessing: an Unsupported -// attribute names its reason and generates nothing. -func deriveType(attribute *Attribute, flatPrimary flat, create, read, update *specmodel.Schema) { - switch { - case flatPrimary.declaredType == "string": - attribute.Kind = TypeString - case flatPrimary.declaredType == "boolean": - attribute.Kind = TypeBool - case flatPrimary.declaredType == "integer": - attribute.Kind = TypeInt64 - case flatPrimary.declaredType == "number": - attribute.Kind = TypeFloat64 - case flatPrimary.declaredType == "array": - deriveListType(attribute, create, read, update) - case flatPrimary.declaredType == "object" || (flatPrimary.declaredType == "" && len(flatPrimary.properties) > 0): - if len(flatPrimary.properties) == 0 { - deriveMapType(attribute, flatPrimary) - return - } - attribute.Kind = TypeObject - attribute.Nested = buildTree(create, read, update, false) - case flatPrimary.declaredType == "" && flatPrimary.hasUnion: - // resolveUnion collapses a union whose branches are all scalars. - // What is left has an object branch, which the generated SDK models - // as a composed type carrying an accessor per branch — an attribute - // per variant, not one collapsed type. - refuse(attribute, "oneOf/anyOf union with an object branch: it needs one attribute per variant, which the document alone does not name") - case flatPrimary.declaredType == "": - refuse(attribute, "no type declared") - default: - refuse(attribute, fmt.Sprintf("type %q is not supported", flatPrimary.declaredType)) - } -} - -// deriveMapType types an object that declares no properties. Only -// additionalProperties carrying a schema names the value type, which is -// what a map attribute needs; a bare boolean or nothing at all says the -// object has no declared shape, and the refusal says which was seen. -func deriveMapType(attribute *Attribute, flatPrimary flat) { - value := flatPrimary.additionalProperties - if value == nil { - if flatPrimary.additionalPropertiesDeclared { - refuse(attribute, "object whose additionalProperties is a bare boolean: it declares no value type to map") - return - } - refuse(attribute, "object declaring neither properties nor additionalProperties: it has no declared shape") - return - } - - flatValue := flatten(value) - switch { - case flatValue.declaredType == "string": - attribute.Kind, attribute.ElementType = TypeMap, TypeString - case flatValue.declaredType == "boolean": - attribute.Kind, attribute.ElementType = TypeMap, TypeBool - case flatValue.declaredType == "integer": - attribute.Kind, attribute.ElementType = TypeMap, TypeInt64 - case flatValue.declaredType == "number": - attribute.Kind, attribute.ElementType = TypeMap, TypeFloat64 - case flatValue.declaredType == "object" || (flatValue.declaredType == "" && len(flatValue.properties) > 0): - // A map of objects needs a nested model, nested state mapping and - // nested fixtures; only maps of scalars are modelled. - refuse(attribute, "map of objects: only maps of scalar values are modelled") - default: - refuse(attribute, fmt.Sprintf("map of %q values is not supported", flatValue.declaredType)) - } -} - -// deriveListType types an array attribute from its element schema, seen -// from both sides of the create/read fold. -func deriveListType(attribute *Attribute, create, read, update *specmodel.Schema) { - createItems, readItems, updateItems := flatten(create).items, flatten(read).items, flatten(update).items - primary := createItems - if primary == nil { - primary = readItems - } - flatItems := flatten(primary) - switch { - case flatItems.empty: - refuse(attribute, "array declares no items schema") - case flatItems.declaredType == "string": - attribute.Kind, attribute.ElementType = TypeList, TypeString - case flatItems.declaredType == "boolean": - attribute.Kind, attribute.ElementType = TypeList, TypeBool - case flatItems.declaredType == "integer": - attribute.Kind, attribute.ElementType = TypeList, TypeInt64 - case flatItems.declaredType == "number": - attribute.Kind, attribute.ElementType = TypeList, TypeFloat64 - case flatItems.declaredType == "object" || (flatItems.declaredType == "" && len(flatItems.properties) > 0): - if len(flatItems.properties) == 0 { - refuse(attribute, "array of free-form objects: map support is out of scope") - return - } - attribute.Kind, attribute.ElementType = TypeList, TypeObject - attribute.Nested = buildTree(createItems, readItems, updateItems, false) - default: - refuse(attribute, fmt.Sprintf("array of %q elements is not supported", flatItems.declaredType)) - } -} - -// refuse marks an attribute unsupported with the reason a person reads. -func refuse(attribute *Attribute, reason string) { - attribute.Kind = "" - attribute.Unsupported = true - attribute.UnsupportedReason = reason -} - -// mergeExtensions folds the read side'schema property extensions under the -// create side'schema, the create side winning a collision: the writable view is -// where behaviour annotations are authored. -func mergeExtensions(create, read specmodel.Extensions) specmodel.Extensions { - out := specmodel.Extensions{} - for key, value := range read { - out[key] = value - } - for key, value := range create { - out[key] = value - } - return out -} - -// renderEnum spells enum values for a validator, in document order. -func renderEnum(values []any) []string { - out := make([]string, 0, len(values)) - for _, value := range values { - out = append(out, fmt.Sprintf("%v", value)) - } - return out -} - -// ensureID guarantees the id attribute every resource and datasource -// carries: computed, mapped from the response'schema id field when the schema -// declares one, otherwise synthesized from the item path parameter. -func ensureID(tree *AttributeTree, keyParam string, keyType AttributeType) { - for index := range tree.Attributes { - if tree.Attributes[index].Name == "id" { - tree.Attributes[index].ComputedOptionalRequired = Computed - tree.Attributes[index].RequiresReplace = false - return - } - } - wire := keyParam - if wire == "" { - wire = "id" - } - kind := keyType - if kind == "" { - kind = TypeString - } - tree.Attributes = append([]Attribute{{ - Name: "id", - WireName: wire, - Kind: kind, - ComputedOptionalRequired: Computed, - }}, tree.Attributes...) -} - -// ensureParentParameters gives every path parameter above the item key an -// attribute to be read from: required, and prepended in path order ahead of -// the id. -// -// An item path is not always /things/{id}. A parent-scoped API spells it -// /repos/{owner}/{repo}/rulesets/{ruleset_id}, and owner and repo appear in -// no request or response body — they are addressing, not content. Emission -// had nothing to feed them from and refused the entity, which on a -// thoroughly parent-scoped document is most of the API. -// -// A parent the body does declare is left as the body declares it; the -// document is a better authority on its own field than the URL is. Only a -// parameter no attribute answers is added. -func ensureParentParameters(tree *AttributeTree, parents []Parameter) { - if tree == nil || len(parents) == 0 { - return - } - // A name the tree already uses cannot be added again, whatever it holds: - // two attributes of one name is not a schema. Where the sitting tenant is - // an object, it is a different thing the document spells the same way — a - // repository's owner block beside the owner segment of its path — and it - // cannot answer the parameter either. Emission refuses the entity by - // name, which is a better answer than a renamed attribute nobody asked - // for or a schema that does not load. - declared := make(map[string]bool, len(tree.Attributes)) - for _, attribute := range tree.Attributes { - declared[attribute.Name] = true - } - - added := make([]Attribute, 0, len(parents)) - for _, parent := range parents { - name := snakeCase(parent.Name) - if declared[name] { - continue - } - declared[name] = true - kind := parent.Type - if kind == "" { - kind = TypeString - } - added = append(added, Attribute{ - Name: name, - WireName: parent.Name, - Kind: kind, - ComputedOptionalRequired: Required, - // Addressing is not editable: an object does not move to another - // parent in place, and every API that admits the move spells it - // as its own operation. - RequiresReplace: true, - }) - } - if len(added) == 0 { - return - } - tree.Attributes = append(added, tree.Attributes...) -} - -// addressingSchema is a collection path's addressing attributes as a tree of -// their own, for a list resource to declare as the configuration of its list -// block. Nil when the path takes no parameters. -// -// Every parameter is a parent: a collection path carries no item key, so -// there is no id to absorb the last one. None carries RequiresReplace — a -// list block declares a query, and a query has no plan for a modifier to act -// on. -func addressingSchema(parameters []Parameter) *AttributeTree { - if len(parameters) == 0 { - return nil - } - tree := &AttributeTree{} - ensureParentParameters(tree, parameters) - for index := range tree.Attributes { - tree.Attributes[index].RequiresReplace = false - } - return tree -} - -// parentParameters is an operation's path parameters above the item key: all -// of them but the last, which addresses the object itself and becomes the id. -func parentParameters(parameters []Parameter) []Parameter { - if len(parameters) < 2 { - return nil - } - return parameters[:len(parameters)-1] -} - -// requireKey turns the lookup key into the datasource'schema single required -// argument: the matching attribute becomes required, or a new one is -// prepended when the response object does not carry the key. -func requireKey(tree *AttributeTree, keyParam string, keyType AttributeType) { - name := snakeCase(keyParam) - for index := range tree.Attributes { - if tree.Attributes[index].Name == name { - tree.Attributes[index].ComputedOptionalRequired = Required - return - } - } - kind := keyType - if kind == "" { - kind = TypeString - } - tree.Attributes = append([]Attribute{{ - Name: name, - WireName: keyParam, - Kind: kind, - ComputedOptionalRequired: Required, - }}, tree.Attributes...) -} diff --git a/internal/intermediate_representation/constraints_test.go b/internal/intermediate_representation/constraints_test.go index 7ea4f99..e835ab6 100644 --- a/internal/intermediate_representation/constraints_test.go +++ b/internal/intermediate_representation/constraints_test.go @@ -115,6 +115,62 @@ func TestUnit_Attribute_CarriesTheDeclaredConstraints(t *testing.T) { } } +// TestUnit_Attribute_MarksADeclaredSecretSensitive proves either declaration +// is enough on its own, and that neither marks an ordinary value. +func TestUnit_Attribute_MarksADeclaredSecretSensitive(t *testing.T) { + const spec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /accounts: + post: + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/Account'} + responses: + "201": + content: + application/json: + schema: {$ref: '#/components/schemas/Account'} + /accounts/{accountId}: + get: + responses: + "200": + content: + application/json: + schema: {$ref: '#/components/schemas/Account'} + delete: + responses: + "204": {description: gone} +components: + schemas: + Account: + type: object + properties: + formatted: + type: string + format: password + writeOnly: + type: string + writeOnly: true + both: + type: string + format: password + writeOnly: true + plain: + type: string +` + r := resourceByKey(t, mustDerive(t, spec, testConfig()), "account") + for _, name := range []string{"formatted", "write_only", "both"} { + if !attribute(t, r.Schema, name).Sensitive { + t.Errorf("%q is a declared secret and is not marked sensitive", name) + } + } + if attribute(t, r.Schema, "plain").Sensitive { + t.Error("an ordinary attribute is marked sensitive") + } +} + func assertBound(t *testing.T, name string, got *int64, want int64) { t.Helper() if got == nil { diff --git a/internal/intermediate_representation/model.go b/internal/intermediate_representation/model.go index 102792b..862f63c 100644 --- a/internal/intermediate_representation/model.go +++ b/internal/intermediate_representation/model.go @@ -328,6 +328,9 @@ type Attribute struct { // WriteOnly marks a property the API accepts on write and never // returns. WriteOnly bool `json:"write_only,omitempty"` + // Sensitive marks a value terraform must keep out of its output: the + // document either declares it write-only or formats it as a password. + Sensitive bool `json:"sensitive,omitempty"` // Deprecated marks a property the document declares deprecated. Deprecated bool `json:"deprecated,omitempty"` // UniqueItems marks a collection whose members are a set, so the order