diff --git a/internal/emit/render_identity.go b/internal/emit/render_identity.go new file mode 100644 index 0000000..aa4cd22 --- /dev/null +++ b/internal/emit/render_identity.go @@ -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 +} diff --git a/internal/emit/render_listresource.go b/internal/emit/render_listresource.go index b9b2131..ae6fdd4 100644 --- a/internal/emit/render_listresource.go +++ b/internal/emit/render_listresource.go @@ -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 @@ -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 { @@ -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, } @@ -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") @@ -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) @@ -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 { @@ -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 } diff --git a/internal/emit/render_resource.go b/internal/emit/render_resource.go index 358017c..b1f58dd 100644 --- a/internal/emit/render_resource.go +++ b/internal/emit/render_resource.go @@ -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 @@ -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 diff --git a/internal/emit/services.go b/internal/emit/services.go index 617e202..8dc84c9 100644 --- a/internal/emit/services.go +++ b/internal/emit/services.go @@ -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] @@ -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 diff --git a/internal/templates/services/list-resource/list_resource_test.go.tmpl b/internal/templates/services/list-resource/list_resource_test.go.tmpl index 23b4dd0..0eb2581 100644 --- a/internal/templates/services/list-resource/list_resource_test.go.tmpl +++ b/internal/templates/services/list-resource/list_resource_test.go.tmpl @@ -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 @@ -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), @@ -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 }}} diff --git a/internal/templates/services/list-resource/model.go.tmpl b/internal/templates/services/list-resource/model.go.tmpl index b021534..64393ef 100644 --- a/internal/templates/services/list-resource/model.go.tmpl +++ b/internal/templates/services/list-resource/model.go.tmpl @@ -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 }} diff --git a/internal/templates/services/resource/resource.go.tmpl b/internal/templates/services/resource/resource.go.tmpl index b5fc515..49bea23 100644 --- a/internal/templates/services/resource/resource.go.tmpl +++ b/internal/templates/services/resource/resource.go.tmpl @@ -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 {