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
116 changes: 116 additions & 0 deletions internal/emit/render_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// The identity terraform stores beside a resource's state to name the remote
// object it stands for, and which a list resource's results are expressed in.

package emit

import (
"fmt"
"strings"

ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation"
)

// identityAttribute is one attribute of a resource's identity.
type identityAttribute struct {
// Name is the terraform attribute name, which the list resource reads
// its value from the configuration or the element by.
Name string
// SchemaType is the identityschema attribute type, e.g. "StringAttribute".
SchemaType string
// Kind is the attribute's own kind, so a reader knows what the value is
// before it is spelled as a string.
Kind ir.AttributeType
}

// identitySchemaTypes is the identityschema spelling of each kind an identity
// may hold. The framework admits primitives and lists of primitives only, and
// an identity attribute is addressing or an id, so nothing else arises.
var identitySchemaTypes = map[ir.AttributeType]string{
ir.TypeString: "StringAttribute",
ir.TypeInt64: "Int64Attribute",
ir.TypeFloat64: "Float64Attribute",
ir.TypeBool: "BoolAttribute",
}

// resourceIdentity is the identity of one resource: its addressing
// attributes, then its id, in that order.
//
// The framework requires an identity to name at most one remote object per
// provider. An id alone does not where a parent scopes it — two repositories'
// hooks may share one — so the path parameters that locate the object are part
// of it. They are already root attributes of the schema, in path order ahead
// of the id, so this reads them off in the order they are declared.
//
// Empty when the resource carries no id, which is the one attribute an
// identity cannot do without.
func resourceIdentity(r *ir.Resource) []identityAttribute {
if r.Schema == nil {
return nil
}
addressing := addressingNames(r.Operations.Read, r.Operations.Create, r.Operations.Delete)

var out []identityAttribute
var carriesID bool
for _, attribute := range r.Schema.Attributes {
if attribute.Nested != nil || attribute.Unsupported {
continue
}
if attribute.Name != idAttributeName && !addressing[attribute.Name] {
continue
}
schemaType, ok := identitySchemaTypes[attribute.Kind]
if !ok {
continue
}
kind := attribute.Kind
if attribute.Name == idAttributeName {
carriesID = true
// The id is a string in the identity whatever the API keys its
// objects with. An identity names an object and is compared for
// equality; it is not the state value, and the list resource
// reaches its id through an accessor that already renders every
// scalar as a string.
kind, schemaType = ir.TypeString, identitySchemaTypes[ir.TypeString]
}
out = append(out, identityAttribute{
Name: attribute.Name,
SchemaType: schemaType,
Kind: kind,
})
}
if !carriesID {
return nil
}
return out
}

// identitySchemaDecls renders the identity schema's attribute declarations,
// ready to sit inside a map[string]identityschema.Attribute literal.
//
// Every attribute is required for import: an identity names one object, and a
// partial identity names none.
func identitySchemaDecls(identity []identityAttribute, depth int) string {
indent := strings.Repeat("\t", depth)
var b strings.Builder
for _, attribute := range identity {
fmt.Fprintf(&b, "%s%q: identityschema.%s{\n%s\tRequiredForImport: true,\n%s},\n",
indent, attribute.Name, attribute.SchemaType, indent, indent)
}
return b.String()
}

// identityModelFields renders the struct fields the identity decodes into.
func identityModelFields(identity []identityAttribute) string {
var b strings.Builder
for _, attribute := range identity {
fmt.Fprintf(&b, "\t%s %s `tfsdk:%q`\n",
ir.GoName(attribute.Name), identityValueType(attribute.Kind), attribute.Name)
}
return b.String()
}

// identityValueType is the framework value type one identity attribute is
// held as.
func identityValueType(kind ir.AttributeType) string {
return scalarSchemaType(kind).ValueType
}
63 changes: 55 additions & 8 deletions internal/emit/render_listresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ type listResourceData struct {
SchemaDescription string
SchemaAttributes string
ConfigModel string
ListPlan callPlan
Collection string
ResultLines string
// IdentityFields are the struct fields a streamed identity decodes
// into, and IdentitySchema the schema the generated test stands it up
// against. Both come from the resource being listed.
IdentityFields string
IdentitySchema string
// ResourceCtor builds the resource being listed, so the test can read
// the schema terraform would supply.
ResourceCtor string
ListPlan callPlan
Collection string
ResultLines string

CollectionURL string
CollectionPattern string
Expand All @@ -50,6 +58,11 @@ type listResourceData struct {
// needs no entity prefix.
const listConfigModelName = "listConfigModel"

// resourcePackageAlias is what a list resource's test imports the resource
// package under. The two packages take the same name from one entity, so one
// of them has to be renamed at the import site.
const resourcePackageAlias = "listedresource"

// listResource renders one list-only entity's file set.
func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListResourceBinding) ([]File, error) {
if lb.List == nil {
Expand All @@ -65,6 +78,7 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
Type: lr.Names.Pascal + "ListResource",
TerraformType: lr.Names.TerraformType,
ClientType: "*sdk." + e.bindings.SDK.ClientTypeName,
ResourceCtor: resourcePackageAlias + ".New" + lr.Names.Pascal + "Resource()",
AuthGitHubApp: e.pc.AuthGitHubApp,
ProviderName: e.pc.ProviderName,
}
Expand Down Expand Up @@ -95,11 +109,17 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
d.Collection = "result." + lb.CollectionAccess
}

resultLines, err := listResultLines(nodes)
identity := e.identities[lr.Names.Key]
if len(identity) == 0 {
return nil, unrenderable("the resource it lists declares no identity, and a list result is an identity")
}
resultLines, err := listResultLines(nodes, identity, configNodes)
if err != nil {
return nil, fmt.Errorf("list: %w", err)
}
d.ResultLines = resultLines
d.IdentityFields = identityModelFields(identity)
d.IdentitySchema = identitySchemaDecls(identity, 2)

imports := newImportSet(e.pc.Module)
imports.add("", "context")
Expand Down Expand Up @@ -163,6 +183,10 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
testImports.add("", e.pc.Module+"/internal/client")
testImports.add("", e.pc.Module+"/internal/mocks")
testImports.add(d.Package, d.PackagePath)
// The resource being listed, for its schema and its identity schema:
// terraform supplies both at runtime, and NewListResult reads them off
// the request. Aliased because the two packages are named alike.
testImports.add(resourcePackageAlias, e.packagePath(kindResources, lr.Names))
d.TestImports = testImports.render()

dir := e.dir(kindListResources, lr.Names)
Expand Down Expand Up @@ -201,14 +225,36 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
return files, nil
}

// listResultLines renders the per-element body of the results iterator:
// the identity id, the display name, and the push.
func listResultLines(nodes []node) (string, error) {
// listResultLines renders the per-element body of the results iterator: the
// identity, the display name, and the push.
//
// The identity is the resource's, not this entity's invention — terraform
// reads the schema it must conform to off the resource being listed. Its id
// comes from the element; every other attribute is addressing, which the list
// block's configuration supplied to make the call.
func listResultLines(nodes []node, identity []identityAttribute, config []node) (string, error) {
idNode, ok := findIdentityNode(nodes)
if !ok {
return "", unrenderable("the element carries no scalar id attribute to publish as the list identity")
}

configured := map[string]bool{}
for _, n := range config {
configured[n.attr.Name] = true
}
fields := make([]string, 0, len(identity))
for _, attribute := range identity {
if attribute.Name == idAttributeName {
fields = append(fields, "ID: types.StringValue(id)")
continue
}
if !configured[attribute.Name] {
return "", unrenderable(
"the list block cannot supply %q, which the resource's identity names", attribute.Name)
}
fields = append(fields, ir.GoName(attribute.Name)+": config."+ir.GoName(attribute.Name))
}

displayNode := idNode
for _, name := range []string{"name", "display_name", "title"} {
if n, found := findStringNode(nodes, name); found {
Expand All @@ -225,7 +271,8 @@ func listResultLines(nodes []node) (string, error) {
} else {
b.WriteString("\t\t\tresult.DisplayName = id\n")
}
b.WriteString("\t\t\tresult.Diagnostics.Append(result.Identity.Set(ctx, identityModel{ID: types.StringValue(id)})...)\n")
fmt.Fprintf(&b, "\t\t\tresult.Diagnostics.Append(result.Identity.Set(ctx, identityModel{%s})...)\n",
strings.Join(fields, ", "))
return b.String(), nil
}

Expand Down
15 changes: 15 additions & 0 deletions internal/emit/render_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type resourceData struct {

HasImport bool
ImportAttr string
// IdentityAttributes is the identity schema's attribute declarations,
// empty for a resource nothing lists.
IdentityAttributes string

SchemaDescription string
SchemaAttributes string
Expand Down Expand Up @@ -236,6 +239,18 @@ func (e *serviceRenderer) resourceCode(d *resourceData, r *ir.Resource, rb *sdkb
sb := &schemaBuilder{kind: schemaResource, imports: imports, deps: deps, rootDepth: 3}
d.SchemaAttributes = sb.attributeDecls(nodes, 3)

// A resource declares an identity when it is listed: a list resource's
// results are identities, and the framework reads the schema they
// conform to off the resource. Recorded so the list resource emits
// results in exactly this shape.
if e.listed[r.Names.Key] {
if identity := resourceIdentity(r); len(identity) > 0 {
e.identities[r.Names.Key] = identity
d.IdentityAttributes = identitySchemaDecls(identity, 3)
imports.add("identityschema", "github.com/hashicorp/terraform-plugin-framework/resource/identityschema")
}
}

description := entityDescription(r.Schema, "Manages the "+r.Names.Key+" entity.")
if r.CoManagementNote != "" {
description += " " + r.CoManagementNote
Expand Down
15 changes: 15 additions & 0 deletions internal/emit/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ func RenderServices(pc ProviderCore, m *ir.Model, b *sdkbind.Bindings) (*Service
// which list resources may follow.
served := map[string]bool{}

// A list resource's results are identities, and the identity schema they
// conform to belongs to the resource. Only a resource that is listed
// declares one, so the resource loop has to know before it renders.
listed := map[string]bool{}
for i := range m.ListResources {
listed[m.ListResources[i].Names.Key] = true
}
e.listed, e.identities = listed, map[string][]identityAttribute{}

for i := range m.Resources {
r := &m.Resources[i]
rb := b.Resources[r.Names.Key]
Expand Down Expand Up @@ -198,6 +207,12 @@ type serviceRenderer struct {
// report reads this to tell a removal that cost something from one that
// cost nothing.
keptUnbound map[string]bool
// listed names the resources a list resource lists, so only those
// declare an identity schema.
listed map[string]bool
// identities is each listed resource's identity, recorded as the
// resource renders so its list resource emits results in the same shape.
identities map[string][]identityAttribute
}

// Binding kinds, spelled the way sdkbind.Removal spells them so a removal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ package {{ .Package }}_test
//go:embed tests/responses/list.json
var listResponse string

// identitySchema mirrors what terraform supplies at runtime, so the
// streamed identities have somewhere to land.
// identitySchema is the {{ .TerraformType }} resource's identity schema,
// which is what terraform supplies at runtime. Rendered from the same
// derivation as the resource's own, so the two cannot disagree.
var identitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"id": identityschema.StringAttribute{RequiredForImport: true},
},
{{ .IdentitySchema }} },
}

// TestUnit{{ .Pascal }}ListResource_Schema holds the metadata and the
Expand Down Expand Up @@ -87,8 +87,17 @@ func TestUnit{{ .Pascal }}ListResource_List(t *testing.T) {
t.Fatalf("configure diagnostics: %v", confResp.Diagnostics)
}

// Terraform supplies both schemas at runtime: NewListResult builds every
// result against them, and reads them off the request.
resourceSchemaResp := &resource.SchemaResponse{}
{{ .ResourceCtor }}.Schema(ctx, resource.SchemaRequest{}, resourceSchemaResp)
if resourceSchemaResp.Diagnostics.HasError() {
t.Fatalf("the listed resource's schema: %v", resourceSchemaResp.Diagnostics)
}

stream := &list.ListResultsStream{}
lr.List(ctx, list.ListRequest{
ResourceSchema: resourceSchemaResp.Schema,
ResourceIdentitySchema: identitySchema,
{{- if .ConfigValue }}
Config: listConfig(t, lr),
Expand All @@ -111,5 +120,4 @@ func TestUnit{{ .Pascal }}ListResource_List(t *testing.T) {

// identityModelForTest mirrors the identity schema for reading results.
type identityModelForTest struct {
ID types.String `tfsdk:"id"`
}
{{ .IdentityFields }}}
7 changes: 4 additions & 3 deletions internal/templates/services/list-resource/model.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
)

// identityModel is the identity every listed instance publishes.
// identityModel is what names one listed instance remotely. It mirrors the
// {{ .TerraformType }} resource's identity schema, which is the shape
// terraform reads a list result against.
type identityModel struct {
ID types.String `tfsdk:"id"`
}
{{ .IdentityFields }}}
{{ if .ConfigModel }}
// listConfigModel mirrors the list block's configuration.
{{ .ConfigModel }}
Expand Down
15 changes: 15 additions & 0 deletions internal/templates/services/resource/resource.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,22 @@ var (
{{- if .HasImport }}
_ resource.ResourceWithImportState = (*{{ .Type }})(nil)
{{- end }}
{{- if .IdentityAttributes }}
_ resource.ResourceWithIdentity = (*{{ .Type }})(nil)
{{- end }}
)
{{ if .IdentityAttributes }}
// IdentitySchema declares what names one {{ .TerraformType }} remotely: the
// addressing that locates it, and its id. Terraform stores it beside the
// state, and {{ .TerraformType }}'s list resource streams results in this
// shape.
func (r *{{ .Type }}) IdentitySchema(_ context.Context, _ resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) {
resp.IdentitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
{{ .IdentityAttributes }} },
}
}
{{- end }}

// New{{ .Pascal }}Resource builds the resource for registration.
func New{{ .Pascal }}Resource() resource.Resource {
Expand Down