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_datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ func (e *serviceRenderer) datasourceModelImports(models string) string {
// datasourceMocks fills the responder context.
func (e *serviceRenderer) datasourceMocks(d *datasourceData, ds *ir.Datasource, spec fixtures.Fixture) {
d.RegistryName = ds.Names.TerraformType + ".data"
d.ResponseMaximal = string(spec.WireJSON(fixtures.ResponseMaximal))
d.ResponseMaximal = goStringLiteral(string(spec.WireJSON(fixtures.ResponseMaximal)))
d.ListPayload = listPayloadExpr(ds.ListEnvelopeKey, "[]map[string]any{object()}")
if ds.Operations.List != nil {
// A parent-scoped collection is requested with its addressing
Expand Down
16 changes: 16 additions & 0 deletions internal/emit/render_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,19 @@ func identityModelFields(identity []identityAttribute) string {
func identityValueType(kind ir.AttributeType) string {
return scalarSchemaType(kind).ValueType
}

// identitySetLines renders the statements that write a resource's identity
// from its model, indented to depth.
//
// The framework requires a resource declaring an identity schema to answer
// with the identity as well as the state: a create or read that leaves it
// unset is refused as a provider fault, whatever the API did.
func identitySetLines(identity []identityAttribute, model string, depth int) string {
indent := strings.Repeat("\t", depth)
var b strings.Builder
for _, attribute := range identity {
fmt.Fprintf(&b, "%sresp.Diagnostics.Append(resp.Identity.SetAttribute(ctx, path.Root(%q), %s.%s)...)\n",
indent, attribute.Name, model, ir.GoName(attribute.Name))
}
return b.String()
}
56 changes: 51 additions & 5 deletions internal/emit/render_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type resourceData struct {
// IdentityAttributes is the identity schema's attribute declarations,
// empty for a resource nothing lists.
IdentityAttributes string
// IdentitySets writes the identity beside the state. The framework
// refuses a create or read that declares an identity schema and leaves
// the identity unset, so the two are emitted together or not at all.
IdentitySets string

SchemaDescription string
SchemaAttributes string
Expand Down Expand Up @@ -75,9 +79,12 @@ type resourceData struct {
UpdatePlan callPlan
DeletePlan callPlan
CreateMapsResponse bool
UpdateMapsResponse bool
UpdateParamCopies string
MissingUpdate bool
// CreateIDFromResponse assigns the new object's id from a create
// response that is not the read model, and so is not mapped wholesale.
CreateIDFromResponse string
UpdateMapsResponse bool
UpdateParamCopies string
MissingUpdate bool

HasEC bool
ECDuration string
Expand Down Expand Up @@ -248,6 +255,7 @@ func (e *serviceRenderer) resourceCode(d *resourceData, r *ir.Resource, rb *sdkb
if identity := resourceIdentity(r); len(identity) > 0 {
e.identities[r.Names.Key] = identity
d.IdentityAttributes = identitySchemaDecls(identity, 3)
d.IdentitySets = identitySetLines(identity, "data", 1)
imports.add("identityschema", "github.com/hashicorp/terraform-plugin-framework/resource/identityschema")
}
}
Expand Down Expand Up @@ -378,6 +386,24 @@ func (e *serviceRenderer) resourceCRUD(d *resourceData, rb *sdkbind.ResourceBind
if d.CreateMapsResponse {
createPayload = "created"
}
// The create answers a type of its own, so nothing maps it — but it
// still carries the id, and the settling read has no other way to
// address what was just made.
if !d.CreateMapsResponse && rb.CreateIDAccess != "" {
for _, n := range nodes {
if n.attr.Name != idAttributeName || n.fb == nil {
continue
}
fn, cerr := readConvert(n.fb)
if cerr != nil {
break
}
createPayload = "created"
d.CreateIDFromResponse = fmt.Sprintf("data.%s = convert.%s(created.%s())",
ir.GoName(idAttributeName), fn, rb.CreateIDAccess)
break
}
}
if d.CreatePlan, err = buildCallPlan(createCall, createPayload, nodes, "data", respDiagnostics()); err != nil {
return fmt.Errorf("create: %w", err)
}
Expand Down Expand Up @@ -424,6 +450,12 @@ func (e *serviceRenderer) resourceCRUD(d *resourceData, rb *sdkbind.ResourceBind
if d.Singleton {
imports.add("", "github.com/hashicorp/terraform-plugin-framework/types")
}
if d.IdentitySets != "" {
imports.add("", "github.com/hashicorp/terraform-plugin-framework/path")
}
if d.CreateIDFromResponse != "" {
imports.add("", e.pc.Module+"/internal/services/common/convert")
}
e.addSDKImports(imports, d.CreatePlan.Assign, d.ReadPlan.Assign, d.DeletePlan.ClosureBody, d.UpdatePlan.Assign)
addPlanImports(imports, d.CreatePlan, d.ReadPlan, d.UpdatePlan, d.DeletePlan)
d.CRUDImports = imports.render()
Expand Down Expand Up @@ -493,8 +525,8 @@ func (e *serviceRenderer) resourceMocks(d *resourceData, r *ir.Resource, rb *sdk
return unrenderable("the read path %s declares no parameter segment for the mock to key on", r.Operations.Read.PathTemplate)
}
d.IDWire = idWire(rb.Fields)
d.ResponseMinimal = string(spec.WireJSON(fixtures.ResponseMinimal))
d.ResponseMaximal = string(spec.WireJSON(fixtures.ResponseMaximal))
d.ResponseMinimal = goStringLiteral(string(spec.WireJSON(fixtures.ResponseMinimal)))
d.ResponseMaximal = goStringLiteral(string(spec.WireJSON(fixtures.ResponseMaximal)))
d.CreateStatus = successStatus(r.Operations.Create, 201)
d.DeleteStatus = successStatus(r.Operations.Delete, 204)
d.HasDelete = true
Expand Down Expand Up @@ -535,6 +567,20 @@ func successStatus(op *ir.Operation, fallback int) int {
const unitEndpoint = "https://unit.invalid"

// mockURL is the literal URL one collection operation answers on.
// goStringLiteral renders a value as a finished Go string literal, its
// delimiters included, so a template embeds one expression rather than
// wrapping a value in quotes it cannot reason about.
//
// A raw literal keeps multi-line wire JSON legible, but no escape exists
// inside one, so a value carrying a backtick — a document's own example may —
// is spelled as an interpreted literal instead of ending the literal early.
func goStringLiteral(value string) string {
if !strings.Contains(value, "`") {
return "`" + value + "`"
}
return strconv.Quote(value)
}

func mockURL(pathTemplate string) string {
return unitEndpoint + pathTemplate
}
Expand Down
30 changes: 30 additions & 0 deletions internal/emit/render_resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package emit

import (
"strconv"
"strings"
"testing"
)

// TestUnit_Emit_AWireFixtureCarryingABacktickStaysOneLiteral pins the literal
// a value cannot end early. A document's own example may contain a backtick,
// and a raw literal has no escape for one, so the generated file would stop
// being Go at that character.
func TestUnit_Emit_AWireFixtureCarryingABacktickStaysOneLiteral(t *testing.T) {
plain := goStringLiteral("{\n \"id\": \"x\"\n}")
if plain != "`{\n \"id\": \"x\"\n}`" {
t.Errorf("a value with no backtick = %s, want a raw literal", plain)
}

quoted := goStringLiteral("the pattern `/` never matches")
if strings.HasPrefix(quoted, "`") {
t.Fatalf("a value carrying a backtick = %s, want an interpreted literal", quoted)
}
unquoted, err := strconv.Unquote(quoted)
if err != nil {
t.Fatalf("the rendered literal does not parse as one: %v", err)
}
if unquoted != "the pattern `/` never matches" {
t.Errorf("round trip = %q", unquoted)
}
}
121 changes: 111 additions & 10 deletions internal/fixtures/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ type Entry struct {
// Nested are the field values of an object attribute, or of the one
// element a list of objects carries.
Nested []Entry

// synthesised is the prefixed name this entry would carry had the
// document declared no example, and is empty unless the example is what
// displaced it. The prefix guard restores it when nothing else in the
// entity carries the prefix.
synthesised string
}

// Omission is one attribute that has no fixture value, and why.
Expand Down Expand Up @@ -111,9 +117,55 @@ func Derive(tree *ir.AttributeTree) Fixture {
}
s.Entries, s.Omissions = deriveTree(tree, nil)
s.applyVariant(tree)
s.keepOnePrefixed()
return s
}

// keepOnePrefixed restores one synthesised name when preferring declared
// examples has left the entity with no prefixed string at all.
//
// The audit's cleanup contract matches a live object by any one of its string
// fields carrying the prefix, so one is enough — and an entity whose every
// string is an example is otherwise indistinguishable from an object the
// toolkit did not create, which the prefix pass must never delete.
func (s *Fixture) keepOnePrefixed() {
if len(s.Entries) == 0 || anyPrefixed(s.Entries) {
return
}
// The first displaced name, in attribute-tree order, so the choice is a
// function of the document rather than of which field happens to be
// nameable.
restoreFirstSynthesised(s.Entries)
}

// anyPrefixed reports whether any scalar in the tree carries the prefix.
func anyPrefixed(values []Entry) bool {
for _, v := range values {
if s, ok := v.Scalar.(string); ok && strings.HasPrefix(s, NamePrefix) {
return true
}
if anyPrefixed(v.Nested) {
return true
}
}
return false
}

// restoreFirstSynthesised puts back the first entry's invented name and
// reports whether it found one to put back.
func restoreFirstSynthesised(values []Entry) bool {
for i := range values {
if values[i].synthesised != "" {
values[i].Scalar = values[i].synthesised
return true
}
if restoreFirstSynthesised(values[i].Nested) {
return true
}
}
return false
}

// applyVariant reads the tree's conditional-edge facts and, when the entity is
// multi-variant, pins the discriminator to one value and records which
// top-level attributes that value excludes and which it forces. A
Expand Down Expand Up @@ -328,9 +380,9 @@ func deriveTree(tree *ir.AttributeTree, path []string) ([]Entry, []Omission) {
v.Nested = nested
skips = append(skips, nestedSkips...)
case a.Kind == ir.TypeList:
v.Scalar = scalarFor(a.ElementType, a, at)
v.Scalar, v.synthesised = scalarFor(a.ElementType, a, at)
default:
v.Scalar = scalarFor(a.Kind, a, at)
v.Scalar, v.synthesised = scalarFor(a.Kind, a, at)
}
values = append(values, v)
}
Expand Down Expand Up @@ -364,27 +416,76 @@ func unifyByWire(values []Entry) {
// declares values, format-driven when it declares what the string holds,
// type-driven otherwise. A plain string carries the test prefix and the
// attribute path so no two attributes share a value.
func scalarFor(kind ir.AttributeType, a ir.Attribute, path []string) any {
func scalarFor(kind ir.AttributeType, a ir.Attribute, path []string) (any, string) {
switch kind {
case ir.TypeBool:
return true
if b, ok := a.Example.(bool); ok {
return b, ""
}
return true, ""
case ir.TypeInt64:
return int64(7)
return int64(boundedNumber(a, 7)), ""
case ir.TypeFloat64:
return 1.5
return boundedNumber(a, 1.5), ""
default:
if len(a.OneOf) > 0 {
return a.OneOf[0]
return a.OneOf[0], ""
}
if len(a.AdvisoryValues) > 0 {
return a.AdvisoryValues[0]
return a.AdvisoryValues[0], ""
}
name := NamePrefix + strings.ReplaceAll(strings.Join(path, "-"), "_", "-")
if formatted, ok := formatValue(a.Format, name); ok {
return formatted
return formatted, ""
}
// The document declared no format, so the invented name is the only
// thing saying what the value looks like — and it says "a string",
// which an API that wanted a URL refuses. An example is the vendor's
// own statement of a value that is accepted, so it wins; the name it
// displaces is kept for the prefix guard.
if example, ok := a.Example.(string); ok && example != "" {
return example, name
}
return name
return name, ""
}
}

// boundedNumber is fallback moved inside whatever range the document
// declares, preferring a declared example that the same range admits.
//
// A constant is refused by the API when the property declares a minimum above
// it or a maximum below it, and that refusal names the constant rather than
// the field it came from.
func boundedNumber(a ir.Attribute, fallback float64) float64 {
value := fallback
if example, ok := numeric(a.Example); ok {
value = example
}
if a.Minimum != nil && value < *a.Minimum {
value = *a.Minimum
}
if a.Maximum != nil && value > *a.Maximum {
value = *a.Maximum
}
return value
}

// numeric reads a document-declared number, which decodes as any of Go's
// numeric kinds depending on how it was spelled.
func numeric(value any) (float64, bool) {
switch n := value.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case uint64:
return float64(n), true
}
return 0, false
}

// ValueForSDKType is the fixture value a generated SDK's own type demands,
Expand Down
Loading