Skip to content

Commit

Permalink
Spitball on state retrieval strategy.
Browse files Browse the repository at this point in the history
Spitball on what retrieving state in a way that lets users access it
with types and no type assertions may look like.
  • Loading branch information
paddycarver committed May 19, 2021
1 parent b6b3471 commit 2085e26
Show file tree
Hide file tree
Showing 7 changed files with 292 additions and 62 deletions.
38 changes: 38 additions & 0 deletions attr/type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package attr

This comment has been minimized.

Copy link
@paddycarver

paddycarver May 19, 2021

Author Contributor

I remain unhappy about this package division. I'll need to think more about some package architecture stuff to see what kind of design makes sense to me.


import (
"context"

"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)

// Type defines an interface for describing a kind of attribute. Types are
// collections of constraints and behaviors such that they can be reused on
// multiple attributes easily.
type Type interface {
// TerraformType returns the tftypes.Type that should be used to
// represent this type. This constrains what user input will be
// accepted and what kind of data can be set in state. The framework
// will use this to translate the Type to something Terraform
// can understand.
TerraformType(context.Context) tftypes.Type

// Validate returns any warnings or errors about the value that is
// being used to populate the Type. It is generally used to
// check the data format and ensure that it complies with the
// requirements of the Type.
//
// TODO: don't use tfprotov6.Diagnostic, use our type
Validate(context.Context, tftypes.Value) []*tfprotov6.Diagnostic

// Description returns a practitioner-friendly explanation of the type
// and the constraints of the data it accepts and returns. It will be
// combined with the Description associated with the Attribute.
Description(context.Context) string

// ValueFromTerraform returns an Value given a tftypes.Value. This is
// meant to convert the tftypes.Value into a more convenient Go type
// for the provider to consume the data with.
ValueFromTerraform(context.Context, tftypes.Value) (Value, error)
}
16 changes: 16 additions & 0 deletions attr/value.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package attr

import "context"

// Value defines an interface for describing data associated with an attribute.
// Values allow provider developers to specify data in a convenient format, and
// have it transparently be converted to formats Terraform understands.
type Value interface {
// ToTerraformValue returns the data contained in the Value as a Go
// type that tftypes.NewValue will accept.
ToTerraformValue(context.Context) (interface{}, error)

// Equal must return true if the Value is considered semantically equal
// to the Value passed as an argument.
Equal(Value) bool
}
145 changes: 145 additions & 0 deletions get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package tf

import (
"context"
"fmt"
"reflect"
"regexp"
"strings"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)

var attributeValueReflectType = reflect.TypeOf(new(attr.Value)).Elem()

type MyType struct {
Foo types.String `tfsdk:"foo"`
Bar types.List `tfsdk:"bar"`
}

This comment has been minimized.

Copy link
@paddycarver

paddycarver May 19, 2021

Author Contributor

Ignore this, this was scratch notes and isn't used.


type State struct {
Raw tftypes.Value
Schema Schema
}

func isValidFieldName(name string) bool {
re := regexp.MustCompile("^[a-z][a-z0-9_]*$")
return re.MatchString(name)
}

func (s State) As(ctx context.Context, in interface{}) error {
reflectValue := reflect.ValueOf(in)
reflectType := reflect.TypeOf(in)
reflectKind := reflectType.Kind()
for reflectKind == reflect.Interface || reflectKind == reflect.Ptr {
reflectValue = reflectValue.Elem()
reflectType = reflectValue.Type()
reflectKind = reflectType.Kind()
}

if reflectKind != reflect.Struct {
return fmt.Errorf("can only pass structs to As, can't use %s", reflectKind)
}

structFields := map[string]int{}
for i := 0; i < reflectType.NumField(); i++ {
field := reflectType.Field(i)
if field.PkgPath != "" {
// skip unexported fields
continue
}
tag := field.Tag.Get(`tfsdk`)
if tag == "-" {
// skip explicitly skipped fields
continue
}
if tag == "" {
return fmt.Errorf("Need a tfsdk tag on %s to use As", field.Name)
}
if !isValidFieldName(tag) {
return fmt.Errorf("Can't use %q as a field name, must only contain a-z (lowercase), underscores, and numbers, and must start with a letter.", tag)
}
if other, ok := structFields[tag]; ok {
return fmt.Errorf("Can't use %s as a field name for both %s and %s", tag, reflectType.Field(other).Name, field.Name)
}
structFields[tag] = i
}

var raw map[string]tftypes.Value
err := s.Raw.As(&raw)
if err != nil {
return fmt.Errorf("error asserting type of state: %w", err)
}

var stateMissing []string
var structMissing []string
for k := range structFields {
if _, ok := raw[k]; !ok {
stateMissing = append(stateMissing, k)
}
}
for k := range raw {
if _, ok := raw[k]; !ok {
structMissing = append(structMissing, k)
}
}
if len(stateMissing) > 0 || len(structMissing) > 0 {
var missing []string
if len(stateMissing) > 0 {
var fields string
if len(stateMissing) == 1 {
fields = stateMissing[0]
} else if len(stateMissing) == 2 {
fields = strings.Join(stateMissing, " and ")
} else {
stateMissing[len(stateMissing)-1] = "and " + stateMissing[len(stateMissing)-1]
fields = strings.Join(stateMissing, ", ")
}
missing = append(missing, fmt.Sprintf("Struct defines fields (%s) that weren't included in the request.", fields))
}
if len(structMissing) > 0 {
var fields string
if len(structMissing) == 1 {
fields = structMissing[0]
} else if len(structMissing) == 2 {
fields = strings.Join(structMissing, " and ")
} else {
structMissing[len(structMissing)-1] = "and " + structMissing[len(structMissing)-1]
fields = strings.Join(structMissing, ", ")
}
missing = append(missing, fmt.Sprintf("Struct defines fields (%s) that weren't included in the request.", fields))
}
return fmt.Errorf("Invalid struct definition for this request: " + strings.Join(missing, " "))
}
for tag, i := range structFields {
field := reflectType.Field(i)
if !field.Type.Implements(attributeValueReflectType) {
return fmt.Errorf("%s doesn't fill the attr.Value interface", field.Name)
}
fieldValue := reflectValue.Field(i)
if !fieldValue.CanSet() {
return fmt.Errorf("can't set %s", field.Name)
}

// find out how to instantiate new value of that type
// pull the attr.Type out of the schema
schemaAttr, ok := s.Schema.Attributes[tag]
if !ok {
return fmt.Errorf("Couldn't find a schema for %s", tag)
}
attrValue, err := schemaAttr.Type.ValueFromTerraform(ctx, raw[tag])
if err != nil {
return fmt.Errorf("Error converting %q from state: %w", tag, err)
}

newValue := reflect.ValueOf(attrValue)
if !newValue.Type().AssignableTo(field.Type) {
return fmt.Errorf("can't assign %s to %s for %s", newValue.Type().Name(), field.Type.Name(), field.Name)
}

fieldValue.Set(newValue)
}
return nil
}
80 changes: 80 additions & 0 deletions get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package tf

import (
"context"
"testing"

"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)

func TestStateAs(t *testing.T) {
schema := Schema{
Attributes: map[string]Attribute{
"foo": {
Type: types.StringType{},
Required: true,
},
"bar": {
Type: types.ListType{
ElemType: types.StringType{},
},
Required: true,
},
},
}

This comment has been minimized.

Copy link
@paddycarver

paddycarver May 19, 2021

Author Contributor

Schema needs to be defined anyways, so this bit isn't unique to this solution.

state := State{
Raw: tftypes.NewValue(tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"foo": tftypes.String,
"bar": tftypes.List{ElementType: tftypes.String},
},
}, map[string]tftypes.Value{
"foo": tftypes.NewValue(tftypes.String, "hello, world"),
"bar": tftypes.NewValue(tftypes.List{
ElementType: tftypes.String,
}, []tftypes.Value{
tftypes.NewValue(tftypes.String, "red"),
tftypes.NewValue(tftypes.String, "blue"),
tftypes.NewValue(tftypes.String, "green"),
}),
}),
Schema: schema,
}

This comment has been minimized.

Copy link
@paddycarver

paddycarver May 19, 2021

Author Contributor

state would be supplied by the framework, users would not construct it themselves.

type myType struct {
Foo types.String `tfsdk:"foo"`
Bar types.List `tfsdk:"bar"`
}
var val myType
err := state.As(context.Background(), &val)
if err != nil {
t.Errorf("Error running As: %s", err)
}
if val.Foo.Unknown {
t.Error("Expected Foo to be known")
}
if val.Foo.Null {
t.Error("Expected Foo to be non-null")
}
if val.Foo.Value != "hello, world" {
t.Errorf("Expected Foo to be %q, got %q", "hello, world", val.Foo.Value)
}
if val.Bar.Unknown {
t.Error("Expected Bar to be known")
}
if val.Bar.Null {
t.Errorf("Expected Bar to be non-null")
}
if len(val.Bar.Elems) != 3 {
t.Errorf("Expected Bar to have 3 elements, had %d", len(val.Bar.Elems))
}
if val.Bar.Elems[0].(types.String).Value != "red" {
t.Errorf("Expected Bar's first element to be %q, got %q", "red", val.Bar.Elems[0].(types.String).Value)

This comment has been minimized.

Copy link
@paddycarver

paddycarver May 19, 2021

Author Contributor

The types.List implementation accepts any attr.Type as elements, so users would need to type assert to get to the values of elements, and we can't really get around that.

But we can, sorta: a types.StringList can be strongly-typed to have types.String elements instead of attr.Type elements, and then no type assertion is needed. But there's an infinite number of possibilities for the type combinations that list can hold. We would need to limit the ones we provided built in, and provider developers could implement their own if they wanted strongly typed complex types. That's annoying, and more work than I'd like, but I can't think of any other way around that for complex types.

}
if val.Bar.Elems[1].(types.String).Value != "blue" {
t.Errorf("Expected Bar's second element to be %q, got %q", "blue", val.Bar.Elems[1].(types.String).Value)
}
if val.Bar.Elems[2].(types.String).Value != "green" {
t.Errorf("Expected Bar's third element to be %q, got %q", "green", val.Bar.Elems[2].(types.String).Value)
}
}
53 changes: 2 additions & 51 deletions schema.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package tf

import (
"context"

"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)
import "github.com/hashicorp/terraform-plugin-framework/attr"

const (
NestingModeSingle NestingMode = 0
Expand Down Expand Up @@ -39,7 +34,7 @@ type Attribute struct {
// want to use one of the types in the types package.
//
// If Type is set, Attributes cannot be.
Type AttributeType
Type attr.Type

// Attributes can have their own, nested attributes. This nested map of
// attributes behaves exactly like the map of attributes on the Schema
Expand Down Expand Up @@ -98,52 +93,8 @@ type Attribute struct {
DeprecationMessage string
}

// AttributeType defines an interface for describing a kind of attribute.
// AttributeTypes are collections of constraints and behaviors such that they
// can be reused on multiple attributes easily.
type AttributeType interface {
// TerraformType returns the tftypes.Type that should be used to
// represent this type. This constrains what user input will be
// accepted and what kind of data can be set in state. The framework
// will use this to translate the AttributeType to something Terraform
// can understand.
TerraformType(context.Context) tftypes.Type

// Validate returns any warnings or errors about the value that is
// being used to populate the AttributeType. It is generally used to
// check the data format and ensure that it complies with the
// requirements of the AttributeType.
//
// TODO: don't use tfprotov6.Diagnostic, use our type
Validate(context.Context, tftypes.Value) []*tfprotov6.Diagnostic

// Description returns a practitioner-friendly explanation of the type
// and the constraints of the data it accepts and returns. It will be
// combined with the Description associated with the Attribute.
Description(context.Context, StringKind) string

// ValueFromTerraform returns an AttributeValue given a tftypes.Value.
// This is meant to convert the tftypes.Value into a more convenient Go
// type for the provider to consume the data with.
ValueFromTerraform(context.Context, tftypes.Value) (AttributeValue, error)
}

// StringKind represents a kind of string formatting.
type StringKind uint8

// NestingMode represents a specific way a group of attributes can be nested.
type NestingMode uint8

// AttributeValue defines an interface for describing data associated with an
// attribute. AttributeValues allow provider developers to specify data in a
// convenient format, and have it transparently be converted to formats
// Terraform understands.
type AttributeValue interface {
// ToTerraformValue returns the data contained in the AttributeValue as
// a Go type that tftypes.NewValue will accept.
ToTerraformValue(context.Context) (interface{}, error)

// Equal must return true if the AttributeValue is considered
// semantically equal to the AttributeValue passed as an argument.
Equal(AttributeValue) bool
}
Loading

0 comments on commit 2085e26

Please sign in to comment.