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
2 changes: 1 addition & 1 deletion internal/emit/render_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (e *serviceRenderer) action(a *ir.Action, ab *sdkbind.ActionBinding) ([]Fil
}
}

plan, err := buildCallPlan(ab.Invoke, "", nodes, "data")
plan, err := buildCallPlan(ab.Invoke, "", nodes, "data", respDiagnostics())
if err != nil {
return nil, fmt.Errorf("invoke: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/emit/render_datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func (e *serviceRenderer) lookupDatasource(d *datasourceData, ds *ir.Datasource,
d.Models = renderModelDecls(decls)
d.ModelImports = e.datasourceModelImports(d.Models)

plan, err := buildCallPlan(db.Read, "remote", nodes, "data")
plan, err := buildCallPlan(db.Read, "remote", nodes, "data", respDiagnostics())
if err != nil {
return fixtures.Fixture{}, fmt.Errorf("read: %w", err)
}
Expand Down Expand Up @@ -304,7 +304,7 @@ func (e *serviceRenderer) companionDatasource(d *datasourceData, ds *ir.Datasour
for _, a := range companionAddressing(ds) {
addressingNodes = append(addressingNodes, node{attr: a})
}
listPlan, err := buildCallPlan(db.List, "result", addressingNodes, "data")
listPlan, err := buildCallPlan(db.List, "result", addressingNodes, "data", respDiagnostics())
if err != nil {
return fixtures.Fixture{}, fmt.Errorf("list: %w", err)
}
Expand Down Expand Up @@ -384,7 +384,7 @@ func itemPayloadExpr(db *sdkbind.DatasourceBinding) string {
func readPlanWithoutParams(call *sdkbind.Call, payloadName string) (callPlan, error) {
stripped := *call
stripped.Params = nil
return buildCallPlan(&stripped, payloadName, nil, "data")
return buildCallPlan(&stripped, payloadName, nil, "data", respDiagnostics())
}

// companionItemTree finds the items attribute's element tree.
Expand Down
58 changes: 48 additions & 10 deletions internal/emit/render_listresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,28 @@ type listResourceData struct {
TestImports string

SchemaDescription string
SchemaAttributes string
ConfigModel string
ListPlan callPlan
Collection string
ResultLines string

CollectionURL string
CollectionURL string
CollectionPattern string

ListResponse string
ExpectedFirstID string
ConfigValue string
TestClientConfig string
AuthGitHubApp bool
ProviderName string
}

// listConfigModelName is the struct the list block's configuration decodes
// into, and the local List reads it as. Unexported and per-package, so it
// needs no entity prefix.
const listConfigModelName = "listConfigModel"

// 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,17 +75,20 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
}
d.SchemaDescription = strconv.Quote(description)

// The call's path parameters are read from the list block's own
// configuration, not from an element: a list resource has no object to
// address, so the practitioner supplies the scope the collection path
// names.
configNodes := joinTree(lr.AddressingSchema, nil, addressingNames(&lr.ListOperation))

listOp := lr.ListOperation
plan, err := buildCallPlan(lb.List, "result", nodes, "data")
plan, err := buildCallPlan(lb.List, "result", configNodes, "config", streamDiagnostics())
if err != nil {
return nil, fmt.Errorf("list: %w", err)
}
if plan.Payload == "" {
return nil, unrenderable("list: the bound list call yields no payload")
}
if plan.ParamDecls != "" {
return nil, unrenderable("list: a list resource cannot supply path parameters; the call needs %q", lb.List.Params[0].Wire)
}
d.ListPlan = plan
d.Collection = "result"
if lb.CollectionAccess != "" {
Expand All @@ -95,8 +108,14 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
imports.add("listschema", "github.com/hashicorp/terraform-plugin-framework/list/schema")
imports.add("", "github.com/hashicorp/terraform-plugin-framework/resource")
imports.add("sdk", e.bindings.SDK.ImportPath)
sb := &schemaBuilder{kind: schemaListResource, imports: imports}
d.SchemaAttributes = sb.attributeDecls(configNodes, 3)
d.Imports = imports.render()

if len(configNodes) > 0 {
d.ConfigModel = renderModelDecls(buildModels(listConfigModelName, lr.Names.Pascal+"ListConfig", configNodes, nil))
}

listImports := newImportSet(e.pc.Module)
listImports.add("", "context")
listImports.add("", "github.com/hashicorp/terraform-plugin-framework/diag")
Expand All @@ -110,7 +129,18 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
d.ListImports = listImports.render()

spec := deriveFixtures(lr.Schema, nodes)
d.CollectionURL = mockURL(listOp.PathTemplate)
configSpec := deriveFixtures(lr.AddressingSchema, configNodes)
// A parameterised collection path is requested with the addressing
// substituted in, so the mock matches the shape rather than the template,
// and the unit test stands a configuration up to be read from. The
// pattern is a regex and travels separately: it carries backslashes a
// quoted Go string cannot hold.
if len(configNodes) > 0 {
d.CollectionPattern = mockPattern(listOp.PathTemplate)
d.ConfigValue = tftypesValue(configSpec.Entries, 1)
} else {
d.CollectionURL = mockURL(listOp.PathTemplate)
}
item := strings.TrimSuffix(string(spec.WireJSON(fixtures.ResponseMaximal)), "\n")
d.ListResponse = listResponseJSON(lr.ListEnvelopeKey, item)
d.ExpectedFirstID = expectedID(spec)
Expand All @@ -125,6 +155,10 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
testImports.add("", "github.com/hashicorp/terraform-plugin-framework/resource")
testImports.add("identityschema", "github.com/hashicorp/terraform-plugin-framework/resource/identityschema")
testImports.add("", "github.com/hashicorp/terraform-plugin-framework/types")
if d.ConfigValue != "" {
testImports.add("", "github.com/hashicorp/terraform-plugin-framework/tfsdk")
testImports.add("", "github.com/hashicorp/terraform-plugin-go/tftypes")
}
testImports.add("", "github.com/jarcoal/httpmock")
testImports.add("", e.pc.Module+"/internal/client")
testImports.add("", e.pc.Module+"/internal/mocks")
Expand Down Expand Up @@ -155,7 +189,8 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso

files = append(files, rawFile(path.Join(dir, "tests/responses/list.json"), lr.Names.Key, []byte(d.ListResponse)))

example, err := listExample(lr.Names.Key, lr.Names.TerraformType, e.pc.ProviderName)
example, err := listExample(lr.Names.Key, lr.Names.TerraformType, e.pc.ProviderName,
configSpec.HCL(fixtures.ConfigMinimal))
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -268,12 +303,15 @@ func (e *serviceRenderer) testClientConfig() string {
}
}

// listExample renders the terraform query example.
func listExample(source, terraformType, providerName string) ([]byte, error) {
// listExample renders the terraform query example. configBody is the
// addressing the list block requires, already rendered as HCL assignments,
// empty for a collection path that takes no parameters.
func listExample(source, terraformType, providerName, configBody string) ([]byte, error) {
header, err := hashHeader(source)
if err != nil {
return nil, err
}
body := fmt.Sprintf("%s\nlist %q \"example\" {\n provider = %s\n}\n", header, terraformType, providerName)
body := fmt.Sprintf("%s\nlist %q \"example\" {\n provider = %s\n%s}\n",
header, terraformType, providerName, configBody)
return []byte(body), nil
}
124 changes: 124 additions & 0 deletions internal/emit/render_listresource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package emit

import (
"strings"
"testing"

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

// scopedListResource is the fictional tree with its list resource moved
// behind a parent-scoped collection path, which is the shape most of a
// parent-scoped document takes.
func scopedListResource(t *testing.T) *ServiceFiles {
t.Helper()
m, b := fictionalModel(), fictionalBindings()
m.ListResources[0].ListOperation.PathTemplate = "/v7/tenants/{tenantId}/audit-events"
m.ListResources[0].ListOperation.PathParameters = []ir.Parameter{{Name: "tenantId", Type: ir.TypeString}}
m.ListResources[0].AddressingSchema = &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "tenant_id", WireName: "tenantId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Required},
}}
b.ListResources["audit_event"].List.Params = []sdkbind.CallParam{
{Local: "tenantId", GoType: "string", Wire: "tenantId"}}

out, err := RenderServices(fictionalProviderCore(), m, b)
if err != nil {
t.Fatalf("a parent-scoped list resource must render: %v", err)
}
return out
}

// TestUnit_ListResource_ReadsItsPathParametersFromTheListBlock proves a
// collection path's parameters are declared as the list block's own
// configuration and read from there, rather than refusing the entity.
func TestUnit_ListResource_ReadsItsPathParametersFromTheListBlock(t *testing.T) {
out := scopedListResource(t)
dir := "internal/services/list-resources/audit/v7/audit_event/"

schema := string(fileByPath(t, out, dir+"list_resource.go").Content)
for _, want := range []string{
"Attributes: map[string]listschema.Attribute{",
`"tenant_id": listschema.StringAttribute{`,
"Required:",
} {
if !strings.Contains(schema, want) {
t.Errorf("the config schema does not carry %q:\n%s", want, schema)
}
}

model := string(fileByPath(t, out, dir+"model.go").Content)
if !strings.Contains(model, "type listConfigModel struct {") ||
!strings.Contains(model, "TenantID types.String `tfsdk:\"tenant_id\"`") {
t.Errorf("model.go does not declare the config model:\n%s", model)
}

list := string(fileByPath(t, out, dir+"list.go").Content)
for _, want := range []string{
"var config listConfigModel",
"req.Config.Get(ctx, &config)",
"tenantId := config.TenantID.ValueString()",
} {
if !strings.Contains(list, want) {
t.Errorf("List does not read its addressing from the configuration, missing %q:\n%s", want, list)
}
}
}

// TestUnit_ListResource_MocksAParameterisedPathByShape proves the generated
// unit test matches the request by pattern, because a parameterised path is
// requested with the addressing substituted in rather than as the template,
// and that it stands a configuration up for List to read.
func TestUnit_ListResource_MocksAParameterisedPathByShape(t *testing.T) {
out := scopedListResource(t)
test := string(fileByPath(t, out,
"internal/services/list-resources/audit/v7/audit_event/list_resource_test.go").Content)

for _, want := range []string{
"httpmock.RegisterResponder(\"GET\", `=~^",
`/v7/tenants/([^/]+)/audit-events$`,
"func listConfig(t *testing.T, lr list.ListResource) tfsdk.Config {",
"listConfig(t, lr),",
} {
if !strings.Contains(test, want) {
t.Errorf("the generated test does not carry %q:\n%s", want, test)
}
}
if strings.Contains(test, `"{{`) {
t.Errorf("the generated test carries an unrendered action:\n%s", test)
}
}

// TestUnit_ListResource_ExampleSuppliesTheRequiredAddressing proves the
// emitted query example sets the attributes the list block requires, so it
// is a configuration terraform would accept rather than one it would reject.
func TestUnit_ListResource_ExampleSuppliesTheRequiredAddressing(t *testing.T) {
out := scopedListResource(t)
example := string(fileByPath(t, out,
"examples/list-resources/petstore_audit_event/list-resource.tfquery.hcl").Content)

if !strings.Contains(example, "tenant_id = ") {
t.Errorf("the example does not supply the required addressing:\n%s", example)
}
}

// TestUnit_ListResource_WithoutAddressingDeclaresNoConfiguration proves a
// collection path that takes no parameters is unchanged: an empty list
// block, no config model, and the mock matched by exact URL.
func TestUnit_ListResource_WithoutAddressingDeclaresNoConfiguration(t *testing.T) {
out := renderFictional(t)
dir := "internal/services/list-resources/audit/v7/audit_event/"

if schema := string(fileByPath(t, out, dir+"list_resource.go").Content); strings.Contains(schema, "Attributes: map[string]listschema.Attribute{") {
t.Errorf("an unparameterised collection path must declare an empty list block:\n%s", schema)
}
if model := string(fileByPath(t, out, dir+"model.go").Content); strings.Contains(model, "listConfigModel") {
t.Errorf("an unparameterised collection path needs no config model:\n%s", model)
}
if list := string(fileByPath(t, out, dir+"list.go").Content); strings.Contains(list, "req.Config.Get") {
t.Errorf("an unparameterised collection path reads no configuration:\n%s", list)
}
if test := string(fileByPath(t, out, dir+"list_resource_test.go").Content); !strings.Contains(test, `httpmock.RegisterResponder("GET", "https://unit.invalid/v7/audit-events"`) {
t.Errorf("an unparameterised collection path is mocked by exact URL:\n%s", test)
}
}
Loading
Loading