Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions internal/emit/render_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions internal/emit/render_schema_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
142 changes: 142 additions & 0 deletions internal/intermediate_representation/attribute_addressing.go
Original file line number Diff line number Diff line change
@@ -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...)
}
Loading
Loading