Skip to content

Commit

Permalink
Adds Timestamp type
Browse files Browse the repository at this point in the history
  • Loading branch information
gdavison committed Jun 20, 2023
1 parent f7f8d9d commit 335740f
Show file tree
Hide file tree
Showing 4 changed files with 685 additions and 0 deletions.
125 changes: 125 additions & 0 deletions internal/framework/types/timestamp_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package types

import (
"context"
"fmt"
"time"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/attr/xattr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)

type TimestampType struct {
basetypes.StringType
}

var (
_ basetypes.StringTypable = TimestampType{}
_ xattr.TypeWithValidate = TimestampType{}
)

func (typ TimestampType) ValueFromString(_ context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) {
if in.IsUnknown() {
return NewTimestampUnknown(), nil
}

if in.IsNull() {
return NewTimestampNull(), nil
}

s := in.ValueString()

var diags diag.Diagnostics
t, err := time.Parse(time.RFC3339, s)
if err != nil {
diags.AddError(
"Timestamp Type Validation Error",
fmt.Sprintf("Value %q cannot be parsed as a Timestamp.", s),
)
return nil, diags
}

return newTimestampValue(s, t), nil
}

func (typ TimestampType) ValueFromTerraform(_ context.Context, in tftypes.Value) (attr.Value, error) {
if !in.IsKnown() {
return NewTimestampUnknown(), nil
}

if in.IsNull() {
return NewTimestampNull(), nil
}

var s string
err := in.As(&s)
if err != nil {
return nil, err
}

t, err := time.Parse(time.RFC3339, s)
if err != nil {
return NewTimestampUnknown(), nil //nolint: nilerr // Must not return validation errors
}

return newTimestampValue(s, t), nil
}

func (typ TimestampType) ValueType(context.Context) attr.Value {
return TimestampValue{}
}

func (typ TimestampType) Equal(o attr.Type) bool {
other, ok := o.(TimestampType)
if !ok {
return false
}

return typ.StringType.Equal(other.StringType)
}

// String returns a human-friendly description of the TimestampType.
func (typ TimestampType) String() string {
return "types.TimestampType"
}

func (typ TimestampType) Validate(ctx context.Context, in tftypes.Value, path path.Path) diag.Diagnostics {
var diags diag.Diagnostics

if !in.IsKnown() || in.IsNull() {
return diags
}

var s string
err := in.As(&s)
if err != nil {
diags.AddAttributeError(
path,
"Invalid Terraform Value",
"An unexpected error occurred while attempting to convert a Terraform value to a string. "+
"This is generally an issue with the provider schema implementation. "+
"Please report the following to the provider developer:\n\n"+
"Path: "+path.String()+"\n"+
"Error: "+err.Error(),
)
return diags
}

_, err = time.Parse(time.RFC3339, s)
if err != nil {
diags.AddAttributeError(
path,
"Invalid Timestamp Value",
fmt.Sprintf("Value %q cannot be parsed as an RFC 3339 Timestamp.\n\n"+
"Path: %s\n"+
"Error: %s", s, path, err),
)
return diags
}

return diags
}
135 changes: 135 additions & 0 deletions internal/framework/types/timestamp_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package types_test

import (
"context"
"testing"
"time"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-go/tftypes"
fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types"
)

func TestTimestampTypeValueFromTerraform(t *testing.T) {
t.Parallel()

tests := map[string]struct {
val tftypes.Value
expected attr.Value
}{
"null value": {
val: tftypes.NewValue(tftypes.String, nil),
expected: fwtypes.NewTimestampNull(),
},
"unknown value": {
val: tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
expected: fwtypes.NewTimestampUnknown(),
},
"valid timestamp UTC": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34Z"),
expected: fwtypes.NewTimestampValue(time.Date(2023, time.June, 7, 15, 11, 34, 0, time.UTC)),
},
"valid timestamp zone": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34-06:00"),
expected: fwtypes.NewTimestampValue(time.Date(2023, time.June, 7, 15, 11, 34, 0, locationFromString(t, "America/Regina"))), // No DST
},
"invalid value": {
val: tftypes.NewValue(tftypes.String, "not ok"),
expected: fwtypes.NewTimestampUnknown(),
},
"invalid no zone": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34"),
expected: fwtypes.NewTimestampUnknown(),
},
"invalid date only": {
val: tftypes.NewValue(tftypes.String, "2023-06-07Z"),
expected: fwtypes.NewTimestampUnknown(),
},
}

for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()

ctx := context.Background()
val, err := fwtypes.TimestampType{}.ValueFromTerraform(ctx, test.val)

if err != nil {
t.Fatalf("got unexpected error: %s", err)
}

if !test.expected.Equal(val) {
t.Errorf("unexpected diff\nwanted: %s\ngot: %s", test.expected, val)
}
})
}
}

func TestTimestampTypeValidate(t *testing.T) {
t.Parallel()

type testCase struct {
val tftypes.Value
expectError bool
}
tests := map[string]testCase{
"not a string": {
val: tftypes.NewValue(tftypes.Bool, true),
expectError: true,
},
"unknown string": {
val: tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
},
"null string": {
val: tftypes.NewValue(tftypes.String, nil),
},
"valid timestamp UTC": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34Z"),
},
"valid timestamp zone": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34-06:00"),
},
"invalid string": {
val: tftypes.NewValue(tftypes.String, "not ok"),
expectError: true,
},
"invalid no zone": {
val: tftypes.NewValue(tftypes.String, "2023-06-07T15:11:34"),
expectError: true,
},
"invalid date only": {
val: tftypes.NewValue(tftypes.String, "2023-06-07Z"),
expectError: true,
},
}

for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()

ctx := context.Background()

diags := fwtypes.TimestampType{}.Validate(ctx, test.val, path.Root("test"))

if !diags.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}

if diags.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %#v", diags)
}
})
}
}

func locationFromString(t *testing.T, s string) *time.Location {
location, err := time.LoadLocation(s)
if err != nil {
t.Fatalf("loading time.Location %q: %s", s, err)
}

return location
}
80 changes: 80 additions & 0 deletions internal/framework/types/timestamp_value.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package types

import (
"context"
"time"

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

func NewTimestampNull() TimestampValue {
return TimestampValue{
StringValue: types.StringNull(),
}
}

func NewTimestampUnknown() TimestampValue {
return TimestampValue{
StringValue: types.StringUnknown(),
}
}

func NewTimestampValue(t time.Time) TimestampValue {
return newTimestampValue(t.Format(time.RFC3339), t)
}

func NewTimestampValueString(s string) (TimestampValue, error) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return TimestampValue{}, err
}
return newTimestampValue(s, t), nil
}

func newTimestampValue(s string, t time.Time) TimestampValue {
return TimestampValue{
StringValue: types.StringValue(s),
value: t,
}
}

var (
_ basetypes.StringValuable = TimestampValue{}
)

type TimestampValue struct {
basetypes.StringValue

// value contains the parsed value, if not Null or Unknown.
value time.Time
}

func (val TimestampValue) Type(_ context.Context) attr.Type {
return TimestampType{}
}

func (val TimestampValue) Equal(other attr.Value) bool {
o, ok := other.(TimestampValue)

if !ok {
return false
}

if val.StringValue.IsUnknown() {
return o.StringValue.IsUnknown()
}

if val.StringValue.IsNull() {
return o.StringValue.IsNull()
}

return val.value.Equal(o.value)
}

// ValueTimestamp returns the known time.Time value. If Timestamp is null or unknown, returns 0.
// To get the value as a string, use ValueString.
func (val TimestampValue) ValueTimestamp() time.Time {
return val.value
}
Loading

0 comments on commit 335740f

Please sign in to comment.