Skip to content

Commit 692fbd3

Browse files
authored
stringvalidator: Add UTF-8 character count validators, clarify original length validators (#87)
Reference: #85
1 parent 3dd84a7 commit 692fbd3

16 files changed

+573
-31
lines changed

.changelog/87.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:enhancement
2+
stringvalidator: Added `UTF8LengthAtLeast`, `UTF8LengthAtMost`, and `UTF8LengthBetween` validators
3+
```

stringvalidator/length_at_least.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,12 @@ func (v lengthAtLeastValidator) ValidateString(ctx context.Context, request vali
4545
}
4646
}
4747

48-
// LengthAtLeast returns an AttributeValidator which ensures that any configured
49-
// attribute value:
48+
// LengthAtLeast returns an validator which ensures that any configured
49+
// attribute value is of single-byte character length greater than or equal
50+
// to the given minimum. Null (unconfigured) and unknown (known after apply)
51+
// values are skipped.
5052
//
51-
// - Is a string.
52-
// - Is of length greater than or equal to the given minimum.
53-
//
54-
// Null (unconfigured) and unknown (known after apply) values are skipped.
53+
// Use UTF8LengthAtLeast for checking multiple-byte characters.
5554
func LengthAtLeast(minLength int) validator.String {
5655
if minLength < 0 {
5756
return nil

stringvalidator/length_at_least_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,28 @@ func TestLengthAtLeastValidator(t *testing.T) {
2020
expectError bool
2121
}
2222
tests := map[string]testCase{
23-
"unknown String": {
23+
"unknown": {
2424
val: types.StringUnknown(),
2525
minLength: 1,
2626
},
27-
"null String": {
27+
"null": {
2828
val: types.StringNull(),
2929
minLength: 1,
3030
},
31-
"valid String": {
31+
"valid": {
3232
val: types.StringValue("ok"),
3333
minLength: 1,
3434
},
35-
"too short String": {
35+
"too short": {
3636
val: types.StringValue(""),
3737
minLength: 1,
3838
expectError: true,
3939
},
40+
"multiple byte characters": {
41+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
42+
val: types.StringValue("⇄"),
43+
minLength: 2,
44+
},
4045
}
4146

4247
for name, test := range tests {

stringvalidator/length_at_most.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,12 @@ func (v lengthAtMostValidator) ValidateString(ctx context.Context, request valid
4444
}
4545
}
4646

47-
// LengthAtMost returns an AttributeValidator which ensures that any configured
48-
// attribute value:
47+
// LengthAtMost returns an validator which ensures that any configured
48+
// attribute value is of single-byte character length less than or equal
49+
// to the given maximum. Null (unconfigured) and unknown (known after apply)
50+
// values are skipped.
4951
//
50-
// - Is a string.
51-
// - Is of length less than or equal to the given maximum.
52-
//
53-
// Null (unconfigured) and unknown (known after apply) values are skipped.
52+
// Use UTF8LengthAtMost for checking multiple-byte characters.
5453
func LengthAtMost(maxLength int) validator.String {
5554
if maxLength < 0 {
5655
return nil

stringvalidator/length_at_most_test.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,29 @@ func TestLengthAtMostValidator(t *testing.T) {
2020
expectError bool
2121
}
2222
tests := map[string]testCase{
23-
"unknown String": {
23+
"unknown": {
2424
val: types.StringUnknown(),
2525
maxLength: 1,
2626
},
27-
"null String": {
27+
"null": {
2828
val: types.StringNull(),
2929
maxLength: 1,
3030
},
31-
"valid String": {
31+
"valid": {
3232
val: types.StringValue("ok"),
3333
maxLength: 2,
3434
},
35-
"too long String": {
35+
"too long": {
3636
val: types.StringValue("not ok"),
3737
maxLength: 5,
3838
expectError: true,
3939
},
40+
"multiple byte characters": {
41+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
42+
val: types.StringValue("⇄"),
43+
maxLength: 2,
44+
expectError: true,
45+
},
4046
}
4147

4248
for name, test := range tests {

stringvalidator/length_between.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,12 @@ func (v lengthBetweenValidator) ValidateString(ctx context.Context, request vali
4444
}
4545
}
4646

47-
// LengthBetween returns an AttributeValidator which ensures that any configured
48-
// attribute value:
47+
// LengthBetween returns an validator which ensures that any configured
48+
// attribute value is of single-byte character length greater than the given
49+
// minimum and less than the given maximum. Null (unconfigured) and unknown
50+
// (known after apply) values are skipped.
4951
//
50-
// - Is a string.
51-
// - Is of length greater than the given minimum and less than the given maximum.
52-
//
53-
// Null (unconfigured) and unknown (known after apply) values are skipped.
52+
// Use UTF8LengthBetween for checking multiple-byte characters.
5453
func LengthBetween(minLength, maxLength int) validator.String {
5554
if minLength < 0 || maxLength < 0 || minLength > maxLength {
5655
return nil

stringvalidator/length_between_test.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,33 +21,39 @@ func TestLengthBetweenValidator(t *testing.T) {
2121
expectError bool
2222
}
2323
tests := map[string]testCase{
24-
"unknown String": {
24+
"unknown": {
2525
val: types.StringUnknown(),
2626
minLength: 1,
2727
maxLength: 3,
2828
},
29-
"null String": {
29+
"null": {
3030
val: types.StringNull(),
3131
minLength: 1,
3232
maxLength: 3,
3333
},
34-
"valid String": {
34+
"valid": {
3535
val: types.StringValue("ok"),
3636
minLength: 1,
3737
maxLength: 3,
3838
},
39-
"too long String": {
39+
"too long": {
4040
val: types.StringValue("not ok"),
4141
minLength: 1,
4242
maxLength: 3,
4343
expectError: true,
4444
},
45-
"too short String": {
45+
"too short": {
4646
val: types.StringValue(""),
4747
minLength: 1,
4848
maxLength: 3,
4949
expectError: true,
5050
},
51+
"multiple byte characters": {
52+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
53+
val: types.StringValue("⇄"),
54+
minLength: 2,
55+
maxLength: 4,
56+
},
5157
}
5258

5359
for name, test := range tests {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package stringvalidator
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"unicode/utf8"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
10+
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
11+
)
12+
13+
var _ validator.String = utf8LengthAtLeastValidator{}
14+
15+
// utf8LengthAtLeastValidator implements the validator.
16+
type utf8LengthAtLeastValidator struct {
17+
minLength int
18+
}
19+
20+
// Description describes the validation in plain text formatting.
21+
func (validator utf8LengthAtLeastValidator) Description(_ context.Context) string {
22+
return fmt.Sprintf("UTF-8 character count must be at least %d", validator.minLength)
23+
}
24+
25+
// MarkdownDescription describes the validation in Markdown formatting.
26+
func (validator utf8LengthAtLeastValidator) MarkdownDescription(ctx context.Context) string {
27+
return validator.Description(ctx)
28+
}
29+
30+
// Validate performs the validation.
31+
func (v utf8LengthAtLeastValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) {
32+
if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
33+
return
34+
}
35+
36+
value := request.ConfigValue.ValueString()
37+
38+
count := utf8.RuneCountInString(value)
39+
40+
if count < v.minLength {
41+
response.Diagnostics.Append(validatordiag.InvalidAttributeValueLengthDiagnostic(
42+
request.Path,
43+
v.Description(ctx),
44+
fmt.Sprintf("%d", count),
45+
))
46+
47+
return
48+
}
49+
}
50+
51+
// UTF8LengthAtLeast returns an validator which ensures that any configured
52+
// attribute value is of UTF-8 character count greater than or equal to the
53+
// given minimum. Null (unconfigured) and unknown (known after apply) values
54+
// are skipped.
55+
//
56+
// Use LengthAtLeast for checking single-byte character counts.
57+
func UTF8LengthAtLeast(minLength int) validator.String {
58+
if minLength < 0 {
59+
return nil
60+
}
61+
62+
return utf8LengthAtLeastValidator{
63+
minLength: minLength,
64+
}
65+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package stringvalidator_test
2+
3+
import (
4+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
5+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
6+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
7+
)
8+
9+
func ExampleUTF8LengthAtLeast() {
10+
// Used within a Schema method of a DataSource, Provider, or Resource
11+
_ = schema.Schema{
12+
Attributes: map[string]schema.Attribute{
13+
"example_attr": schema.StringAttribute{
14+
Required: true,
15+
Validators: []validator.String{
16+
// Validate UTF-8 character count must be at least 3 characters.
17+
stringvalidator.UTF8LengthAtLeast(3),
18+
},
19+
},
20+
},
21+
}
22+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package stringvalidator_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/path"
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
"github.com/hashicorp/terraform-plugin-framework/types"
10+
11+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
12+
)
13+
14+
func TestUTF8LengthAtLeastValidator(t *testing.T) {
15+
t.Parallel()
16+
17+
type testCase struct {
18+
val types.String
19+
minLength int
20+
expectError bool
21+
}
22+
tests := map[string]testCase{
23+
"unknown": {
24+
val: types.StringUnknown(),
25+
minLength: 1,
26+
},
27+
"null": {
28+
val: types.StringNull(),
29+
minLength: 1,
30+
},
31+
"valid single byte characters": {
32+
val: types.StringValue("ok"),
33+
minLength: 1,
34+
},
35+
"valid mixed byte characters": {
36+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
37+
val: types.StringValue("test⇄test"),
38+
minLength: 9,
39+
},
40+
"valid multiple byte characters": {
41+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
42+
val: types.StringValue("⇄"),
43+
minLength: 1,
44+
},
45+
"invalid single byte characters": {
46+
val: types.StringValue("ok"),
47+
minLength: 3,
48+
expectError: true,
49+
},
50+
"invalid mixed byte characters": {
51+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
52+
val: types.StringValue("test⇄test"),
53+
minLength: 10,
54+
expectError: true,
55+
},
56+
"invalid multiple byte characters": {
57+
// Rightwards Arrow Over Leftwards Arrow (U+21C4; 3 bytes)
58+
val: types.StringValue("⇄"),
59+
minLength: 2,
60+
expectError: true,
61+
},
62+
}
63+
64+
for name, test := range tests {
65+
name, test := name, test
66+
t.Run(name, func(t *testing.T) {
67+
request := validator.StringRequest{
68+
Path: path.Root("test"),
69+
PathExpression: path.MatchRoot("test"),
70+
ConfigValue: test.val,
71+
}
72+
response := validator.StringResponse{}
73+
stringvalidator.UTF8LengthAtLeast(test.minLength).ValidateString(context.Background(), request, &response)
74+
75+
if !response.Diagnostics.HasError() && test.expectError {
76+
t.Fatal("expected error, got no error")
77+
}
78+
79+
if response.Diagnostics.HasError() && !test.expectError {
80+
t.Fatalf("got unexpected error: %s", response.Diagnostics)
81+
}
82+
})
83+
}
84+
}

0 commit comments

Comments
 (0)