Add enumExclude support to enum validator

This commit is contained in:
Joe Betz
2025-08-18 15:56:02 -04:00
parent ed170c1c0a
commit 64d9ddcf9d
2 changed files with 222 additions and 61 deletions

View File

@@ -25,16 +25,60 @@ import (
"k8s.io/apimachinery/pkg/util/validation/field"
)
// Enum verifies that the specified value is one of the valid symbols.
// This is for string enums only.
func Enum[T ~string](_ context.Context, op operation.Operation, fldPath *field.Path, value, _ *T, symbols sets.Set[T]) field.ErrorList {
// Enum verifies that a given value is a member of a set of enum values.
// Exclude Rules that apply when options are enabled or disabled are also considered.
// If ANY exclude rule matches for a value, that value is excluded from the enum when validating.
func Enum[T ~string](_ context.Context, op operation.Operation, fldPath *field.Path, value, _ *T, values sets.Set[T], exclusions []EnumExclusion[T]) field.ErrorList {
if value == nil {
return nil
}
if !symbols.Has(*value) {
symbolList := symbols.UnsortedList()
slices.Sort(symbolList)
return field.ErrorList{field.NotSupported[T](fldPath, *value, symbolList)}
if !values.Has(*value) {
return field.ErrorList{field.NotSupported[T](fldPath, *value, supportedValues(op, values, exclusions))}
}
for _, rule := range exclusions {
if rule.ExcludeWhenEnabled == op.HasOption(rule.Option) && *value == rule.Value {
return field.ErrorList{field.NotSupported[T](fldPath, *value, supportedValues(op, values, exclusions))}
}
}
return nil
}
// supportedValues returns a sorted list of supported values.
// Excluded enum values are not included in the list.
func supportedValues[T ~string](op operation.Operation, values sets.Set[T], exclusions []EnumExclusion[T]) []T {
res := make([]T, 0, len(values))
for key := range values {
if isExcluded(op, exclusions, key) {
continue
}
res = append(res, key)
}
slices.Sort(res)
return res
}
// NewExclusions prepares a set of enum exclusion rules for validation.
func NewExclusions[T ~string](rules ...EnumExclusion[T]) []EnumExclusion[T] {
return rules
}
// EnumExclusion represents a single enum exclusion rule.
type EnumExclusion[T ~string] struct {
// Value specifies the enum value to be conditionally excluded.
Value T
// ExcludeWhenEnabled determines the condition for exclusion.
// If true, the value is excluded if the option is present.
// If false, the value is excluded if the option is NOT present.
ExcludeWhenEnabled bool
// Option is the name of the feature option that controls the exclusion.
Option string
}
func isExcluded[T ~string](op operation.Operation, exclusions []EnumExclusion[T], value T) bool {
for _, rule := range exclusions {
if rule.Value == value && rule.ExcludeWhenEnabled == op.HasOption(rule.Option) {
return true
}
}
return false
}

View File

@@ -27,38 +27,43 @@ import (
func TestEnum(t *testing.T) {
cases := []struct {
value string
valid sets.Set[string]
err bool
name string
value string
valid sets.Set[string]
expectErr string
}{{
value: "a",
valid: sets.New("a", "b", "c"),
err: false,
name: "valid value",
value: "a",
valid: sets.New("a", "b", "c"),
expectErr: "",
}, {
value: "x",
valid: sets.New("c", "a", "b"),
err: true,
name: "invalid value",
value: "x",
valid: sets.New("a", "b", "c"),
expectErr: `fldpath: Unsupported value: "x": supported values: "a", "b", "c"`,
}}
for i, tc := range cases {
result := Enum(context.Background(), operation.Operation{}, field.NewPath("fldpath"), &tc.value, nil, tc.valid)
if len(result) > 0 && !tc.err {
t.Errorf("case %d: unexpected failure: %v", i, fmtErrs(result))
continue
}
if len(result) == 0 && tc.err {
t.Errorf("case %d: unexpected success", i)
continue
}
if len(result) > 0 {
if len(result) > 1 {
t.Errorf("case %d: unexepected multi-error: %v", i, fmtErrs(result))
continue
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
op := operation.Operation{Type: operation.Create}
errs := Enum(context.Background(), op, field.NewPath("fldpath"), &tc.value, nil, tc.valid, nil)
if tc.expectErr == "" {
if len(errs) > 0 {
t.Fatalf("expected no error, but got: %v", errs)
}
} else {
if len(errs) == 0 {
t.Fatal("expected an error, but got none")
}
if len(errs) > 1 {
t.Fatalf("expected a single error, but got: %v", errs)
}
if errs[0].Error() != tc.expectErr {
t.Errorf("expected error %q, but got %q", tc.expectErr, errs[0].Error())
}
}
if want, got := `supported values: "a", "b", "c"`, result[0].Detail; got != want {
t.Errorf("case %d: wrong error, expected: %q, got: %q", i, want, got)
}
}
})
}
}
@@ -71,37 +76,149 @@ func TestEnumTypedef(t *testing.T) {
)
cases := []struct {
value StringType
valid sets.Set[StringType]
err bool
name string
value StringType
valid sets.Set[StringType]
expectErr string
}{{
value: "foo",
valid: sets.New(NotStringFoo, NotStringBar, NotStringQux),
err: false,
name: "valid value",
value: "foo",
valid: sets.New(NotStringFoo, NotStringBar, NotStringQux),
expectErr: "",
}, {
value: "x",
valid: sets.New(NotStringFoo, NotStringBar, NotStringQux),
err: true,
name: "invalid value",
value: "x",
valid: sets.New(NotStringFoo, NotStringBar, NotStringQux),
expectErr: `fldpath: Unsupported value: "x": supported values: "bar", "foo", "qux"`,
}}
for i, tc := range cases {
result := Enum(context.Background(), operation.Operation{}, field.NewPath("fldpath"), &tc.value, nil, tc.valid)
if len(result) > 0 && !tc.err {
t.Errorf("case %d: unexpected failure: %v", i, fmtErrs(result))
continue
}
if len(result) == 0 && tc.err {
t.Errorf("case %d: unexpected success", i)
continue
}
if len(result) > 0 {
if len(result) > 1 {
t.Errorf("case %d: unexepected multi-error: %v", i, fmtErrs(result))
continue
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
op := operation.Operation{Type: operation.Create}
errs := Enum(context.Background(), op, field.NewPath("fldpath"), &tc.value, nil, tc.valid, nil)
if tc.expectErr == "" {
if len(errs) > 0 {
t.Fatalf("expected no error, but got: %v", errs)
}
} else {
if len(errs) == 0 {
t.Fatal("expected an error, but got none")
}
if len(errs) > 1 {
t.Fatalf("expected a single error, but got: %v", errs)
}
if errs[0].Error() != tc.expectErr {
t.Errorf("expected error %q, but got %q", tc.expectErr, errs[0].Error())
}
}
if want, got := `supported values: "bar", "foo", "qux"`, result[0].Detail; got != want {
t.Errorf("case %d: wrong error, expected: %q, got: %q", i, want, got)
}
}
})
}
}
func TestEnumExclude(t *testing.T) {
type TestEnum string
const (
ValueA TestEnum = "A"
ValueB TestEnum = "B"
ValueC TestEnum = "C"
ValueD TestEnum = "D"
)
const (
FeatureA = "FeatureA"
FeatureB = "FeatureB"
)
testEnumValues := sets.New(ValueA, ValueB, ValueC, ValueD)
testEnumExclusions := []EnumExclusion[TestEnum]{
{Value: ValueA, Option: FeatureA, ExcludeWhen: true},
{Value: ValueB, Option: FeatureB, ExcludeWhen: false},
{Value: ValueD, Option: FeatureA, ExcludeWhen: true},
{Value: ValueD, Option: FeatureB, ExcludeWhen: false},
}
testCases := []struct {
name string
value TestEnum
opts []string
expectErr string
}{
{
name: "no options, A is valid",
value: ValueA,
},
{
name: "no options, B is invalid",
value: ValueB,
expectErr: `fld: Unsupported value: "B": supported values: "A", "C"`,
},
{
name: "no options, D is invalid",
value: ValueD,
expectErr: `fld: Unsupported value: "D": supported values: "A", "C"`,
},
{
name: "FeatureA enabled, A is invalid",
value: ValueA,
opts: []string{FeatureA},
expectErr: `fld: Unsupported value: "A": supported values: "C"`,
},
{
name: "FeatureA enabled, B is invalid",
value: ValueB,
opts: []string{FeatureA},
expectErr: `fld: Unsupported value: "B": supported values: "C"`,
},
{
name: "FeatureB enabled, A is valid",
value: ValueA,
opts: []string{FeatureB},
},
{
name: "FeatureB enabled, B is valid",
value: ValueB,
opts: []string{FeatureB},
},
{
name: "FeatureA and FeatureB enabled, A is invalid",
value: ValueA,
opts: []string{FeatureA, FeatureB},
expectErr: `fld: Unsupported value: "A": supported values: "B", "C"`,
},
{
name: "FeatureA and FeatureB enabled, B is valid",
value: ValueB,
opts: []string{FeatureA, FeatureB},
},
{
name: "FeatureA and FeatureB enabled, D is invalid",
value: ValueD,
opts: []string{FeatureA, FeatureB},
expectErr: `fld: Unsupported value: "D": supported values: "B", "C"`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
op := operation.Operation{Type: operation.Create, Options: tc.opts}
errs := Enum(context.Background(), op, field.NewPath("fld"), &tc.value, nil, testEnumValues, testEnumExclusions)
if tc.expectErr == "" {
if len(errs) > 0 {
t.Fatalf("expected no error, but got: %v", errs)
}
} else {
if len(errs) == 0 {
t.Fatal("expected an error, but got none")
}
if len(errs) > 1 {
t.Fatalf("expected a single error, but got: %v", errs)
}
if errs[0].Error() != tc.expectErr {
t.Errorf("expected error %q, but got %q", tc.expectErr, errs[0].Error())
}
}
})
}
}