Skip to content

Commit 0a37d41

Browse files
feat: add custom type for time with second level precision (#42)
Summary: In this PR, we are adding a custom string type which validates that input adheres to RFC3339 format for timestamps, and also only supports second-level precision and normalizstion to UTC for comparison. To fix Kong/terraform-provider-konnect#248.
1 parent 4ba6e22 commit 0a37d41

File tree

3 files changed

+297
-0
lines changed

3 files changed

+297
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package timetypes
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/attr"
8+
"github.com/hashicorp/terraform-plugin-framework/diag"
9+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
10+
"github.com/hashicorp/terraform-plugin-go/tftypes"
11+
)
12+
13+
var (
14+
_ basetypes.StringTypable = (*RFC3339PreciseToSecondType)(nil)
15+
)
16+
17+
// RFC3339PreciseToSecondType is an attribute type that represents a valid RFC 3339 string, but only to second precision.
18+
// This type is useful for timestamps that don't require millisecond precision.
19+
type RFC3339PreciseToSecondType struct {
20+
basetypes.StringType
21+
}
22+
23+
// String returns a human-readable string of the type name.
24+
func (t RFC3339PreciseToSecondType) String() string {
25+
return "timetypes.RFC3339PreciseToSecondType"
26+
}
27+
28+
// ValueType returns the Value type.
29+
func (t RFC3339PreciseToSecondType) ValueType(ctx context.Context) attr.Value {
30+
return RFC3339PreciseToSecond{}
31+
}
32+
33+
// Equal returns true if the given type is equivalent.
34+
func (t RFC3339PreciseToSecondType) Equal(o attr.Type) bool {
35+
other, ok := o.(RFC3339PreciseToSecondType)
36+
37+
if !ok {
38+
return false
39+
}
40+
41+
return t.StringType.Equal(other.StringType)
42+
}
43+
44+
// ValueFromString returns a StringValuable type given a StringValue.
45+
func (t RFC3339PreciseToSecondType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) {
46+
return RFC3339PreciseToSecond{
47+
StringValue: in,
48+
}, nil
49+
}
50+
51+
// ValueFromTerraform returns a Value given a tftypes.Value. This is meant to convert the tftypes.Value into a more convenient Go type
52+
// for the provider to consume the data with.
53+
func (t RFC3339PreciseToSecondType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
54+
attrValue, err := t.StringType.ValueFromTerraform(ctx, in)
55+
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
stringValue, ok := attrValue.(basetypes.StringValue)
61+
62+
if !ok {
63+
return nil, fmt.Errorf("unexpected value type of %T", attrValue)
64+
}
65+
66+
stringValuable, diags := t.ValueFromString(ctx, stringValue)
67+
68+
if diags.HasError() {
69+
return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags)
70+
}
71+
72+
return stringValuable, nil
73+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package timetypes
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/attr"
9+
"github.com/hashicorp/terraform-plugin-framework/attr/xattr"
10+
"github.com/hashicorp/terraform-plugin-framework/diag"
11+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
12+
)
13+
14+
var (
15+
_ basetypes.StringValuable = (*RFC3339PreciseToSecond)(nil)
16+
_ xattr.ValidateableAttribute = (*RFC3339PreciseToSecond)(nil)
17+
)
18+
19+
// RFC3339PreciseToSecond represents a valid RFC3339-formatted string. It supports second-level precision and ignores milliseconds.
20+
// Semantic equality for this type normalizes timestamps to UTC, truncates the milliseconds, and compares the results.
21+
type RFC3339PreciseToSecond struct {
22+
basetypes.StringValue
23+
}
24+
25+
// Type returns an RFC3339PreciseToSecondType.
26+
func (v RFC3339PreciseToSecond) Type(_ context.Context) attr.Type {
27+
return RFC3339PreciseToSecondType{}
28+
}
29+
30+
// Equal returns true if the given value is equivalent.
31+
func (v RFC3339PreciseToSecond) Equal(o attr.Value) bool {
32+
other, ok := o.(RFC3339PreciseToSecond)
33+
34+
if !ok {
35+
return false
36+
}
37+
38+
return v.StringValue.Equal(other.StringValue)
39+
}
40+
41+
// StringSemanticEquals returns true if the given RFC3339 timestamp is semantically equal the current RFC3339 timestamp.
42+
// This comparison ignores milliseconds, and normalizes both timestamps to UTC before comparison.
43+
//
44+
// Examples:
45+
// - `2023-07-25T22:43:16+02:00` is semantically equal to `2023-07-25T20:43:16Z`
46+
// - `2023-07-25T20:43:16.05Z` is semantically equal to `2023-07-25T20:43:16Z`
47+
func (v RFC3339PreciseToSecond) StringSemanticEquals(_ context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) {
48+
var diags diag.Diagnostics
49+
50+
newValue, ok := newValuable.(RFC3339PreciseToSecond)
51+
if !ok {
52+
diags.AddError(
53+
"Semantic Equality Check Error",
54+
"An unexpected value type was received while performing semantic equality checks. "+
55+
"Please report this to the provider developers.\n\n"+
56+
"Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+
57+
"Got Value Type: "+fmt.Sprintf("%T", newValuable),
58+
)
59+
60+
return false, diags
61+
}
62+
63+
newRFC3339time, _ := time.Parse(time.RFC3339, newValue.ValueString())
64+
currentRFC3339time, _ := time.Parse(time.RFC3339, v.ValueString())
65+
66+
return normalize(currentRFC3339time) == normalize(newRFC3339time), diags
67+
}
68+
69+
// Helper function to normalize a time.Time to UTC and truncate to second precision, and return as RFC3339 string.
70+
func normalize(t time.Time) string {
71+
return t.UTC().Truncate(time.Second).Format(time.RFC3339)
72+
}
73+
74+
// This type requires the value to be a String value in valid RFC 3339 format
75+
func (v RFC3339PreciseToSecond) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) {
76+
if v.IsUnknown() || v.IsNull() {
77+
return
78+
}
79+
80+
if _, err := time.Parse(time.RFC3339, v.ValueString()); err != nil {
81+
resp.Diagnostics.Append(
82+
diag.WithPath(req.Path, diag.NewErrorDiagnostic("Invalid attribute value", "The attribute value must be a string in RFC3339 format.")),
83+
)
84+
85+
return
86+
}
87+
}
88+
89+
// NewRFC3339PreciseToSecondNull creates an RFC3339PreciseToSecond with a null value. Determine whether the value is null via IsNull method.
90+
func NewRFC3339PreciseToSecondNull() RFC3339PreciseToSecond {
91+
return RFC3339PreciseToSecond{
92+
StringValue: basetypes.NewStringNull(),
93+
}
94+
}
95+
96+
// NewRFC3339PreciseToSecondUnknown creates an RFC3339PreciseToSecond with an unknown value. Determine whether the value is unknown via IsUnknown method.
97+
func NewRFC3339PreciseToSecondUnknown() RFC3339PreciseToSecond {
98+
return RFC3339PreciseToSecond{
99+
StringValue: basetypes.NewStringUnknown(),
100+
}
101+
}
102+
103+
// NewRFC3339PreciseToSecondValue creates an RFC3339PreciseToSecond with a known value or raises an error
104+
// diagnostic if the string is not RFC3339 format.
105+
func NewRFC3339PreciseToSecondValue(value string) (RFC3339PreciseToSecond, diag.Diagnostics) {
106+
_, err := time.Parse(time.RFC3339, value)
107+
108+
if err != nil {
109+
// Returning an unknown value will guarantee that, as a last resort,
110+
// Terraform will return an error if attempting to store into state.
111+
return NewRFC3339PreciseToSecondUnknown(), diag.Diagnostics{}
112+
}
113+
114+
return RFC3339PreciseToSecond{
115+
StringValue: basetypes.NewStringValue(value),
116+
}, nil
117+
}
118+
119+
// NewRFC3339PreciseToSecondValueMust creates an RFC3339PreciseToSecond with a known value or raises a panic
120+
// if the string is not RFC3339 format.
121+
// Used in unit tests.
122+
func NewRFC3339PreciseToSecondValueMust(value string) RFC3339PreciseToSecond {
123+
_, err := time.Parse(time.RFC3339, value)
124+
125+
if err != nil {
126+
panic(fmt.Sprintf("Invalid RFC3339 String Value (%s): %s", value, err))
127+
}
128+
129+
return RFC3339PreciseToSecond{
130+
StringValue: basetypes.NewStringValue(value),
131+
}
132+
}
133+
134+
// NewRFC3339PreciseToSecondPointerValue creates an RFC3339PreciseToSecond with a null value if nil, a known
135+
// value, or raises an error diagnostic if the string is not RFC3339 format.
136+
func NewRFC3339PreciseToSecondPointerValue(value *string) (RFC3339PreciseToSecond, diag.Diagnostics) {
137+
if value == nil {
138+
return NewRFC3339PreciseToSecondNull(), nil
139+
}
140+
141+
return NewRFC3339PreciseToSecondValue(*value)
142+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package timetypes
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/diag"
8+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
9+
)
10+
11+
func TestRFC3339_Precise_to_Second_StringSemanticEquals(t *testing.T) {
12+
t.Parallel()
13+
14+
testCases := map[string]struct {
15+
currentRFC3339time RFC3339PreciseToSecond
16+
givenRFC3339time basetypes.StringValuable
17+
expectedMatch bool
18+
expectedDiags diag.Diagnostics
19+
}{
20+
"Semantically equal: Z suffix and positive zero local offset": {
21+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
22+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16+00:00"),
23+
expectedMatch: true,
24+
},
25+
"Semantically equal: Z suffix and negative zero local offset": {
26+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
27+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16-00:00"),
28+
expectedMatch: true,
29+
},
30+
"Semantically equal: negative zero and positive zero local offset": {
31+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16-00:00"),
32+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16+00:00"),
33+
expectedMatch: true,
34+
},
35+
"Exacty equal": {
36+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
37+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
38+
expectedMatch: true,
39+
},
40+
"Semantically equal: with different offsets for same time": {
41+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T21:43:16-02:00"),
42+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-16T01:43:16+02:00"),
43+
expectedMatch: true,
44+
},
45+
"Semantically equal: with difference in milliseconds": {
46+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T21:43:16Z"),
47+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T21:43:16.101Z"),
48+
expectedMatch: true,
49+
},
50+
"Not equal - different dates": {
51+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
52+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-16T23:43:16Z"),
53+
expectedMatch: false,
54+
},
55+
"Not equal - different times": {
56+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
57+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:01:16Z"),
58+
expectedMatch: false,
59+
},
60+
"Not equal - different offsets": {
61+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
62+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16+03:00"),
63+
expectedMatch: false,
64+
},
65+
"Not equal - UTC time and local time": {
66+
currentRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T23:43:16Z"),
67+
givenRFC3339time: NewRFC3339PreciseToSecondValueMust("2027-12-15T20:43:16-03:00"),
68+
expectedMatch: true,
69+
},
70+
}
71+
for name, testCase := range testCases {
72+
t.Run(name, func(t *testing.T) {
73+
t.Parallel()
74+
75+
match, _ := testCase.currentRFC3339time.StringSemanticEquals(context.Background(), testCase.givenRFC3339time)
76+
77+
if testCase.expectedMatch != match {
78+
t.Errorf("Expected StringSemanticEquals to return: %t, but got: %t", testCase.expectedMatch, match)
79+
}
80+
})
81+
}
82+
}

0 commit comments

Comments
 (0)