From c04c60f4086a596301643adb8c4e785a8215f612 Mon Sep 17 00:00:00 2001 From: Aaron Prindle Date: Fri, 6 Feb 2026 03:02:10 +0000 Subject: [PATCH] Implement declarative modal validation (+k8s:discriminator and +k8s:member) This introduces state-based validation to declarative validation via the +k8s:discriminator and +k8s:member tags. Key changes: - Added validate.Modal runtime function to handle state-based logic, including full ratcheting support (skipping validation on Update if discriminator and value are unchanged). - Implemented +k8s:discriminator to identify the controlling field (supports string, int, and bool). - Implemented +k8s:member to map discriminator values to validation rules. - Added implicit "forbidden-by-default" logic for unhandled modes, with support for pointer, slice, and map types. --- .../apimachinery/pkg/api/validate/mode.go | 75 +++ .../pkg/api/validate/mode_test.go | 224 +++++++++ .../output_tests/tags/mode/doc.go | 120 +++++ .../output_tests/tags/mode/doc_test.go | 204 +++++++++ .../tags/mode/zz_generated.validations.go | 415 +++++++++++++++++ .../cmd/validation-gen/validators/mode.go | 429 ++++++++++++++++++ 6 files changed, 1467 insertions(+) create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go new file mode 100644 index 00000000000..191273198cc --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go @@ -0,0 +1,75 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// ModalRule defines a validation to apply for a specific mode value. +type ModalRule[T any] struct { + Value string + Validation ValidateFunc[T] +} + +// Modal validates a value based on a discriminator value. +// It iterates through the rules and applies the first one that matches the discriminator. +// If no rule matches, it applies the defaultValidation if provided. +// +// It performs ratcheting: if the operation is an Update, and neither the discriminator +// nor the value (checked via equiv) have changed, validation is skipped. +func Modal[T any, D comparable, P any](ctx context.Context, op operation.Operation, structPath *field.Path, + obj, oldObj *P, fieldName string, getMemberValue func(*P) T, getDiscriminator func(*P) D, + equiv MatchFunc[T], defaultValidation ValidateFunc[T], rules []ModalRule[T], +) field.ErrorList { + value := getMemberValue(obj) + var oldValue T + var oldDiscriminator D + + if oldObj != nil { + oldValue = getMemberValue(oldObj) + oldDiscriminator = getDiscriminator(oldObj) + } + + discriminator := getDiscriminator(obj) + + if op.Type == operation.Update && oldObj != nil && discriminator == oldDiscriminator && equiv(value, oldValue) { + return nil + } + + fldPath := structPath.Child(fieldName) + dStr := fmt.Sprintf("%v", discriminator) + for _, rule := range rules { + if rule.Value == dStr { + if rule.Validation == nil { + return nil + } + return rule.Validation(ctx, op, fldPath, value, oldValue) + } + } + + if defaultValidation != nil { + return defaultValidation(ctx, op, fldPath, value, oldValue) + } + + return nil + +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go new file mode 100644 index 00000000000..306a4196084 --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go @@ -0,0 +1,224 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "context" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestModal(t *testing.T) { + errMatch := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "match error")} + errDefault := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "default error")} + + mockValid := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return nil + } + mockErrorMatch := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errMatch + } + mockErrorDefault := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errDefault + } + + mockEqual := func(a, b *string) bool { + return a == b + } + + testCases := []struct { + name string + opType operation.Type + discriminator any + oldDiscriminator any + value *string + oldValue *string + rules []ModalRule[*string] + defaultValidation ValidateFunc[*string] + expected field.ErrorList + }{ + { + name: "matches rule, returns valid", + opType: operation.Create, + discriminator: "A", + oldDiscriminator: "A", + rules: []ModalRule[*string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "matches rule, returns error", + opType: operation.Create, + discriminator: "B", + oldDiscriminator: "B", + rules: []ModalRule[*string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "ratcheting: update, unchanged, skips validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "B", // unchanged + value: nil, + oldValue: nil, // unchanged + rules: []ModalRule[*string]{ + {Value: "B", Validation: mockErrorMatch}, // would fail if run + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "ratcheting: update, discriminator changed, runs validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "A", // changed + value: nil, + oldValue: nil, + rules: []ModalRule[*string]{ + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "matches rule with nil validation, returns valid", + opType: operation.Create, + discriminator: "A", + rules: []ModalRule[*string]{ + {Value: "A", Validation: nil}, + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "no match, runs default", + opType: operation.Create, + discriminator: "C", + rules: []ModalRule[*string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errDefault, + }, + { + name: "no match, nil default, returns valid", + opType: operation.Create, + discriminator: "C", + rules: []ModalRule[*string]{ + {Value: "A", Validation: mockValid}, + }, + defaultValidation: nil, + expected: nil, + }, + { + name: "int discriminator matches", + opType: operation.Create, + discriminator: 1, + rules: []ModalRule[*string]{ + {Value: "1", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "bool discriminator matches", + opType: operation.Create, + discriminator: true, + rules: []ModalRule[*string]{ + {Value: "true", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Handle nil oldDiscriminator for tests that don't specify it + oldDisc := tc.oldDiscriminator + if oldDisc == nil { + oldDisc = tc.discriminator + } + + // We need separate Modal calls because D is different per test case + var got field.ErrorList + + switch d := tc.discriminator.(type) { + case int: + od, _ := oldDisc.(int) + type IntP struct { + Val *string + Disc int + } + newObj := &IntP{Val: tc.value, Disc: d} + var oldObj *IntP + if tc.opType == operation.Update { + oldObj = &IntP{Val: tc.oldValue, Disc: od} + } + getVal := func(p *IntP) *string { return p.Val } + getDisc := func(p *IntP) int { return p.Disc } + + got = Modal[*string, int, IntP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) + + case bool: + od, _ := oldDisc.(bool) + type BoolP struct { + Val *string + Disc bool + } + newObj := &BoolP{Val: tc.value, Disc: d} + var oldObj *BoolP + if tc.opType == operation.Update { + oldObj = &BoolP{Val: tc.oldValue, Disc: od} + } + getVal := func(p *BoolP) *string { return p.Val } + getDisc := func(p *BoolP) bool { return p.Disc } + got = Modal[*string, bool, BoolP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) + + case string: + od, _ := oldDisc.(string) + type StringP struct { + Val *string + Disc string + } + newObj := &StringP{Val: tc.value, Disc: d} + var oldObj *StringP + if tc.opType == operation.Update { + oldObj = &StringP{Val: tc.oldValue, Disc: od} + } + getVal := func(p *StringP) *string { return p.Val } + getDisc := func(p *StringP) string { return p.Disc } + got = Modal[*string, string, StringP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) + } + + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("got %v want %v", got, tc.expected) + } + }) + } +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go new file mode 100644 index 00000000000..f03cae372bf --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +k8s:validation-gen=TypeMeta +// +k8s:validation-gen-scheme-registry=k8s.io/code-generator/cmd/validation-gen/testscheme.Scheme + +// This is a test package. +package mode + +import "k8s.io/code-generator/cmd/validation-gen/testscheme" + +var localSchemeBuilder = testscheme.New() + +type StrictUnion struct { + TypeMeta int + + // +k8s:discriminator + D1 string `json:"d1"` + + // +k8s:member("A")=+k8s:required + FieldA *string `json:"fieldA,omitempty"` + + // +k8s:member("B")=+k8s:required + FieldB *string `json:"fieldB,omitempty"` +} + +type SharedField struct { + TypeMeta int + + // +k8s:discriminator + D1 string `json:"d1"` + + // Valid in A and B, implicitly forbidden in C. + // +k8s:member("A")=+k8s:optional + // +k8s:member("B")=+k8s:optional + FieldA *string `json:"fieldA,omitempty"` +} + +type ChainedValidation struct { + TypeMeta int + + // +k8s:discriminator + D1 string `json:"d1"` + + // In mode A, it is required AND must have maxLength 5. + // +k8s:member("A")=+k8s:required + // +k8s:member("A")=+k8s:maxLength=5 + FieldA *string `json:"fieldA,omitempty"` +} + +type ImplicitForbidden struct { + TypeMeta int + + // +k8s:discriminator + D1 string `json:"d1"` + + // Field is only mentioned for mode A. Mode B should implicitly forbid it. + // +k8s:member("A")=+k8s:optional + FieldA *string `json:"fieldA,omitempty"` +} + +type NonStringDiscriminator struct { + TypeMeta int + + // +k8s:discriminator(name:"Bool") + D1 bool `json:"d1"` + + // +k8s:member(discriminator:"Bool", value:"true")=+k8s:required + FieldA *string `json:"fieldA,omitempty"` + + // +k8s:discriminator(name:"Int") + D2 int `json:"d2"` + + // +k8s:member(discriminator:"Int", value:"1")=+k8s:required + FieldB *string `json:"fieldB,omitempty"` +} + +type MultipleDiscriminators struct { + TypeMeta int + + // +k8s:discriminator(name:"D1") + D1 string `json:"d1"` + + // +k8s:discriminator(name:"D2") + D2 string `json:"d2"` + + // +k8s:member(discriminator:"D1", value:"A")=+k8s:required + FieldA *string `json:"fieldA,omitempty"` + + // +k8s:member(discriminator:"D2", value:"B")=+k8s:required + FieldB *string `json:"fieldB,omitempty"` +} + +type Collections struct { + TypeMeta int + + // +k8s:discriminator + D1 string `json:"d1"` + + // +k8s:member("A")=+k8s:optional + ListField []string `json:"listField,omitempty"` + + // +k8s:member("A")=+k8s:optional + MapField map[string]string `json:"mapField,omitempty"` +} + +type TypeMeta int diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go new file mode 100644 index 00000000000..9c4bb9eaca7 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mode + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" +) + +func TestStrictUnion(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Mode A: FieldA required, FieldB implicitly forbidden + st.Value(&StrictUnion{D1: "A", FieldA: ptr.To("val")}).ExpectValid() + st.Value(&StrictUnion{D1: "A"}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldA"), ""), + }) + st.Value(&StrictUnion{D1: "A", FieldA: ptr.To("val"), FieldB: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldB"), ""), + }) + + // Mode B: FieldA implicitly forbidden, FieldB required + st.Value(&StrictUnion{D1: "B", FieldB: ptr.To("val")}).ExpectValid() + st.Value(&StrictUnion{D1: "B"}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldB"), ""), + }) + st.Value(&StrictUnion{D1: "B", FieldA: ptr.To("val"), FieldB: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldA"), ""), + }) +} + +func TestSharedField(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Valid (optional) in A and B + st.Value(&SharedField{D1: "A"}).ExpectValid() + st.Value(&SharedField{D1: "A", FieldA: ptr.To("val")}).ExpectValid() + st.Value(&SharedField{D1: "B"}).ExpectValid() + st.Value(&SharedField{D1: "B", FieldA: ptr.To("val")}).ExpectValid() + + // Forbidden in C + st.Value(&SharedField{D1: "C", FieldA: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldA"), ""), + }) +} + +func TestChainedValidation(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Mode A: Required AND maxLength 5 + st.Value(&ChainedValidation{D1: "A", FieldA: ptr.To("abc")}).ExpectValid() + st.Value(&ChainedValidation{D1: "A"}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldA"), ""), + }) + st.Value(&ChainedValidation{D1: "A", FieldA: ptr.To("too-long")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.TooLong(field.NewPath("fieldA"), "too-long", 5), + }) + + // Mode B: Unlisted, so implicitly forbidden + st.Value(&ChainedValidation{D1: "B", FieldA: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldA"), ""), + }) +} + +func TestImplicitForbidden(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Mode A: Optional + st.Value(&ImplicitForbidden{D1: "A"}).ExpectValid() + st.Value(&ImplicitForbidden{D1: "A", FieldA: ptr.To("val")}).ExpectValid() + + // Mode B: Not listed, so implicitly Forbidden + st.Value(&ImplicitForbidden{D1: "B", FieldA: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldA"), ""), + }) +} + +func TestNonStringDiscriminator(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Bool mode + st.Value(&NonStringDiscriminator{D1: true, FieldA: ptr.To("val")}).ExpectValid() + st.Value(&NonStringDiscriminator{D1: true}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldA"), ""), + }) + st.Value(&NonStringDiscriminator{D1: false, FieldA: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldA"), ""), + }) + + // Int mode + st.Value(&NonStringDiscriminator{D2: 1, FieldB: ptr.To("val")}).ExpectValid() + st.Value(&NonStringDiscriminator{D2: 1}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldB"), ""), + }) + st.Value(&NonStringDiscriminator{D2: 2, FieldB: ptr.To("val")}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("fieldB"), ""), + }) +} + +func TestMultipleDiscriminators(t *testing.T) { + st := localSchemeBuilder.Test(t) + + st.Value(&MultipleDiscriminators{ + D1: "A", + D2: "B", + FieldA: ptr.To("valA"), + FieldB: ptr.To("valB"), + }).ExpectValid() + + st.Value(&MultipleDiscriminators{ + D1: "A", + D2: "B", + }).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Required(field.NewPath("fieldA"), ""), + field.Required(field.NewPath("fieldB"), ""), + }) +} + +func TestCollections(t *testing.T) { + st := localSchemeBuilder.Test(t) + + // Mode A: Collections are valid (optional) + st.Value(&Collections{ + D1: "A", + }).ExpectValid() + + st.Value(&Collections{ + D1: "A", + ListField: []string{"item"}, + MapField: map[string]string{"key": "val"}, + }).ExpectValid() + + // Mode B: Unlisted, so implicitly forbidden + st.Value(&Collections{ + D1: "B", + ListField: []string{"item"}, + }).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("listField"), ""), + }) + + st.Value(&Collections{ + D1: "B", + MapField: map[string]string{"key": "val"}, + }).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.Forbidden(field.NewPath("mapField"), ""), + }) +} + +func TestRatcheting(t *testing.T) { + mkTest := func() *ChainedValidation { + return &ChainedValidation{ + D1: "A", + FieldA: ptr.To("too-long-string"), + } + } + + st := localSchemeBuilder.Test(t) + + // 1. New object is invalid + st.Value(mkTest()).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.TooLong(field.NewPath("fieldA"), "too-long-string", 5), + }) + + // 2. Unchanged update is valid (ratcheting) + st.Value(mkTest()).OldValue(mkTest()).ExpectValid() + + // 3. Changed value re-validates (and fails) + mkDifferent := func() *ChainedValidation { + return &ChainedValidation{ + D1: "A", + FieldA: ptr.To("also-too-long"), + } + } + st.Value(mkTest()).OldValue(mkDifferent()).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.TooLong(field.NewPath("fieldA"), "too-long-string", 5), + }) + + // 4. Changed discriminator re-validates (and fails) + mkDifferentDisc := func() *ChainedValidation { + return &ChainedValidation{ + D1: "B", // Discriminator changed from B -> A + FieldA: ptr.To("too-long-string"), + } + } + st.Value(mkTest()).OldValue(mkDifferentDisc()).ExpectMatches(field.ErrorMatcher{}.ByType().ByField(), field.ErrorList{ + field.TooLong(field.NewPath("fieldA"), "too-long-string", 5), + }) +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go new file mode 100644 index 00000000000..16c7659ca4d --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go @@ -0,0 +1,415 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by validation-gen. DO NOT EDIT. + +package mode + +import ( + context "context" + fmt "fmt" + + operation "k8s.io/apimachinery/pkg/api/operation" + safe "k8s.io/apimachinery/pkg/api/safe" + validate "k8s.io/apimachinery/pkg/api/validate" + field "k8s.io/apimachinery/pkg/util/validation/field" + testscheme "k8s.io/code-generator/cmd/validation-gen/testscheme" +) + +func init() { localSchemeBuilder.Register(RegisterValidations) } + +// RegisterValidations adds validation functions to the given scheme. +// Public to allow building arbitrary schemes. +func RegisterValidations(scheme *testscheme.Scheme) error { + // type ChainedValidation + scheme.AddValidationFunc((*ChainedValidation)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_ChainedValidation(ctx, op, nil /* fldPath */, obj.(*ChainedValidation), safe.Cast[*ChainedValidation](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type Collections + scheme.AddValidationFunc((*Collections)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_Collections(ctx, op, nil /* fldPath */, obj.(*Collections), safe.Cast[*Collections](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type ImplicitForbidden + scheme.AddValidationFunc((*ImplicitForbidden)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_ImplicitForbidden(ctx, op, nil /* fldPath */, obj.(*ImplicitForbidden), safe.Cast[*ImplicitForbidden](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type MultipleDiscriminators + scheme.AddValidationFunc((*MultipleDiscriminators)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_MultipleDiscriminators(ctx, op, nil /* fldPath */, obj.(*MultipleDiscriminators), safe.Cast[*MultipleDiscriminators](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type NonStringDiscriminator + scheme.AddValidationFunc((*NonStringDiscriminator)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_NonStringDiscriminator(ctx, op, nil /* fldPath */, obj.(*NonStringDiscriminator), safe.Cast[*NonStringDiscriminator](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type SharedField + scheme.AddValidationFunc((*SharedField)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_SharedField(ctx, op, nil /* fldPath */, obj.(*SharedField), safe.Cast[*SharedField](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + // type StrictUnion + scheme.AddValidationFunc((*StrictUnion)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_StrictUnion(ctx, op, nil /* fldPath */, obj.(*StrictUnion), safe.Cast[*StrictUnion](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + return nil +} + +// Validate_ChainedValidation validates an instance of ChainedValidation according +// to declarative validation rules in the API schema. +func Validate_ChainedValidation(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *ChainedValidation) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ChainedValidation) *string { return obj.FieldA }, func(obj *ChainedValidation) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + errs = append(errs, validate.MaxLength(ctx, op, fldPath, obj, oldObj, 5)...) + return errs + }, + }, + })...) + + // field ChainedValidation.TypeMeta has no validation + // field ChainedValidation.D1 has no validation + // field ChainedValidation.FieldA has no validation + return errs +} + +// Validate_Collections validates an instance of Collections according +// to declarative validation rules in the API schema. +func Validate_Collections(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *Collections) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "listField", func(obj *Collections) []string { return obj.ListField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenSlice(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[[]string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.OptionalSlice(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "mapField", func(obj *Collections) map[string]string { return obj.MapField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenMap(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[map[string]string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.OptionalMap(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field Collections.TypeMeta has no validation + // field Collections.D1 has no validation + // field Collections.ListField has no validation + // field Collections.MapField has no validation + return errs +} + +// Validate_ImplicitForbidden validates an instance of ImplicitForbidden according +// to declarative validation rules in the API schema. +func Validate_ImplicitForbidden(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *ImplicitForbidden) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ImplicitForbidden) *string { return obj.FieldA }, func(obj *ImplicitForbidden) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field ImplicitForbidden.TypeMeta has no validation + // field ImplicitForbidden.D1 has no validation + // field ImplicitForbidden.FieldA has no validation + return errs +} + +// Validate_MultipleDiscriminators validates an instance of MultipleDiscriminators according +// to declarative validation rules in the API schema. +func Validate_MultipleDiscriminators(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *MultipleDiscriminators) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *MultipleDiscriminators) *string { return obj.FieldA }, func(obj *MultipleDiscriminators) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *MultipleDiscriminators) *string { return obj.FieldB }, func(obj *MultipleDiscriminators) string { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "B", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field MultipleDiscriminators.TypeMeta has no validation + // field MultipleDiscriminators.D1 has no validation + // field MultipleDiscriminators.D2 has no validation + // field MultipleDiscriminators.FieldA has no validation + // field MultipleDiscriminators.FieldB has no validation + return errs +} + +// Validate_NonStringDiscriminator validates an instance of NonStringDiscriminator according +// to declarative validation rules in the API schema. +func Validate_NonStringDiscriminator(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *NonStringDiscriminator) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *NonStringDiscriminator) *string { return obj.FieldA }, func(obj *NonStringDiscriminator) bool { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "true", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *NonStringDiscriminator) *string { return obj.FieldB }, func(obj *NonStringDiscriminator) int { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "1", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field NonStringDiscriminator.TypeMeta has no validation + // field NonStringDiscriminator.D1 has no validation + // field NonStringDiscriminator.FieldA has no validation + // field NonStringDiscriminator.D2 has no validation + // field NonStringDiscriminator.FieldB has no validation + return errs +} + +// Validate_SharedField validates an instance of SharedField according +// to declarative validation rules in the API schema. +func Validate_SharedField(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *SharedField) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *SharedField) *string { return obj.FieldA }, func(obj *SharedField) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + { + Value: "B", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field SharedField.TypeMeta has no validation + // field SharedField.D1 has no validation + // field SharedField.FieldA has no validation + return errs +} + +// Validate_StrictUnion validates an instance of StrictUnion according +// to declarative validation rules in the API schema. +func Validate_StrictUnion(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *StrictUnion) (errs field.ErrorList) { + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *StrictUnion) *string { return obj.FieldA }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "A", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *StrictUnion) *string { return obj.FieldB }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) + return errs + }, []validate.ModalRule[*string]{ + { + Value: "B", + Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs := field.ErrorList{} + earlyReturn := false + if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return errs + } + return errs + }, + }, + })...) + + // field StrictUnion.TypeMeta has no validation + // field StrictUnion.D1 has no validation + // field StrictUnion.FieldA has no validation + // field StrictUnion.FieldB has no validation + return errs +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go new file mode 100644 index 00000000000..c0c7ae70f0a --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go @@ -0,0 +1,429 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validators + +import ( + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/code-generator/cmd/validation-gen/util" + "k8s.io/gengo/v2/codetags" + "k8s.io/gengo/v2/parser/tags" + "k8s.io/gengo/v2/types" +) + +const ( + discriminatorTagName = "k8s:discriminator" + memberTagName = "k8s:member" +) + +func init() { + RegisterTagValidator(&modeTagValidator{modeDefinitions}) + RegisterTagValidator(&memberTagValidator{modeDefinitions, nil}) + RegisterTypeValidator(&modeTypeOrFieldValidator{modeDefinitions}) + RegisterFieldValidator(&modeTypeOrFieldValidator{modeDefinitions}) +} + +// modeDefinitions stores all mode definitions found by tag validators. +// Key is the struct path. +var modeDefinitions = map[string]modeGroups{} + +type modeGroups map[string]*modeGroup + +type modeGroup struct { + name string + discriminatorMember *types.Member + // members maps field names to their rules in this mode group. + members map[string]*fieldModeRules +} + +type fieldModeRules struct { + member *types.Member + rules []modeRule +} + +type modeRule struct { + value string + validations Validations +} + +func (mg modeGroups) getOrCreate(name string) *modeGroup { + if name == "" { + name = "default" + } + g, ok := mg[name] + if !ok { + g = &modeGroup{ + name: name, + members: make(map[string]*fieldModeRules), + } + mg[name] = g + } + return g +} + +type modeTagValidator struct { + shared map[string]modeGroups +} + +func (mtv *modeTagValidator) Init(_ Config) {} + +func (mtv *modeTagValidator) TagName() string { + return discriminatorTagName +} + +func (mtv *modeTagValidator) ValidScopes() sets.Set[Scope] { + return sets.New(ScopeField) +} + +func (mtv *modeTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { + if util.NativeType(context.Type).Kind == types.Pointer { + return Validations{}, fmt.Errorf("can only be used on non-pointer types") + } + + if t := util.NonPointer(util.NativeType(context.Type)); t.Kind != types.Builtin || (t.Name.Name != "string" && t.Name.Name != "bool" && !types.IsInteger(t)) { + return Validations{}, fmt.Errorf("can only be used on string, bool or integer types (%s)", rootTypeString(context.Type, t)) + } + + if mtv.shared[context.ParentPath.String()] == nil { + mtv.shared[context.ParentPath.String()] = make(modeGroups) + } + modeName := "" + if nameArg, ok := tag.NamedArg("name"); ok { + modeName = nameArg.Value + } + group := mtv.shared[context.ParentPath.String()].getOrCreate(modeName) + if group.discriminatorMember != nil && group.discriminatorMember != context.Member { + return Validations{}, fmt.Errorf("duplicate discriminator: %q", modeName) + } + group.discriminatorMember = context.Member + + return Validations{}, nil +} + +func (mtv *modeTagValidator) Docs() TagDoc { + return TagDoc{ + Tag: mtv.TagName(), + StabilityLevel: Alpha, + Scopes: mtv.ValidScopes().UnsortedList(), + Description: "Indicates that this field is a discriminator for state-based validation.", + Args: []TagArgDoc{{ + Name: "name", + Description: "", + Docs: "the name of the discriminator group, if more than one exists", + Type: codetags.ArgTypeString, + }}, + } +} + +type memberTagValidator struct { + shared map[string]modeGroups + validator Validator +} + +func (mtv *memberTagValidator) Init(cfg Config) { + mtv.validator = cfg.Validator +} + +func (mtv *memberTagValidator) TagName() string { + return memberTagName +} + +func (mtv *memberTagValidator) ValidScopes() sets.Set[Scope] { + return sets.New(ScopeField) +} + +var disallowedPayloadTags = sets.New( + listTypeTagName, + ListMapKeyTagName, + unionDiscriminatorTagName, + unionMemberTagName, + zeroOrOneOfMemberTagName, + discriminatorTagName, + memberTagName, +) + +func (mtv *memberTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { + if tag.ValueTag == nil { + return Validations{}, fmt.Errorf("missing required payload") + } + + if disallowedPayloadTags.Has(tag.ValueTag.Name) { + return Validations{}, fmt.Errorf("unsupported payload tag: %q", tag.ValueTag.Name) + } + + modeName := "" + if modeArg, ok := tag.NamedArg("discriminator"); ok { + modeName = modeArg.Value + } + + value := "" + if valArg, ok := tag.NamedArg("value"); ok { + value = valArg.Value + } else if len(tag.Args) > 0 && tag.Args[0].Name == "" { + // Positional argument + value = tag.Args[0].Value + } else { + return Validations{}, fmt.Errorf("missing required value") + } + + if mtv.shared[context.ParentPath.String()] == nil { + mtv.shared[context.ParentPath.String()] = make(modeGroups) + } + group := mtv.shared[context.ParentPath.String()].getOrCreate(modeName) + + fieldName := context.Member.Name + if rules, ok := group.members[fieldName]; ok { + if rules.member != context.Member { + return Validations{}, fmt.Errorf("internal error: member mismatch for field %q", fieldName) + } + } else { + group.members[fieldName] = &fieldModeRules{ + member: context.Member, + } + } + + payloadValidations, err := mtv.validator.ExtractTagValidations(context, *tag.ValueTag) + if err != nil { + return Validations{}, err + } + + group.members[fieldName].rules = append(group.members[fieldName].rules, modeRule{ + value: value, + validations: payloadValidations, + }) + + return Validations{}, nil +} + +func (mtv *memberTagValidator) Docs() TagDoc { + return TagDoc{ + Tag: mtv.TagName(), + StabilityLevel: Alpha, + Scopes: mtv.ValidScopes().UnsortedList(), + Description: "Indicates that this field's validation depends on a discriminator.", + Args: []TagArgDoc{{ + Name: "", // positional + Description: "", + Docs: "the value of the discriminator for which this validation applies", + Type: codetags.ArgTypeString, + }, { + Name: "discriminator", + Description: "", + Docs: "the name of the discriminator group", + Type: codetags.ArgTypeString, + }, { + Name: "value", + Description: "", + Docs: "the value of the discriminator for which this validation applies", + Type: codetags.ArgTypeString, + }}, + PayloadsType: codetags.ValueTypeTag, + PayloadsRequired: true, + } +} + +type modeTypeOrFieldValidator struct { + shared map[string]modeGroups +} + +func (modeTypeOrFieldValidator) Init(_ Config) {} + +func (modeTypeOrFieldValidator) Name() string { + return "modeTypeOrFieldValidator" +} + +func (mtfv *modeTypeOrFieldValidator) GetValidations(context Context) (Validations, error) { + // Extract the most concrete type possible. + if k := util.NonPointer(util.NativeType(context.Type)).Kind; k != types.Struct { + return Validations{}, nil + } + + groups, ok := mtfv.shared[context.Path.String()] + if !ok || len(groups) == 0 { + return Validations{}, nil + } + + var result Validations + + // Sort group names for deterministic output + groupNames := make([]string, 0, len(groups)) + for name := range groups { + groupNames = append(groupNames, name) + } + slices.Sort(groupNames) + + for _, gn := range groupNames { + group := groups[gn] + if group.discriminatorMember == nil { + if len(group.members) > 0 { + if gn == "default" { + return Validations{}, fmt.Errorf("missing discriminator") + } + return Validations{}, fmt.Errorf("missing discriminator for group %q", gn) + } + continue + } + + fieldNames := make([]string, 0, len(group.members)) + for name := range group.members { + fieldNames = append(fieldNames, name) + } + slices.Sort(fieldNames) + + for _, fn := range fieldNames { + rules := group.members[fn] + v, err := mtfv.generateModeFieldValidation(context, group, rules) + if err != nil { + return Validations{}, err + } + result.Add(v) + } + } + + return result, nil +} + +func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Context, group *modeGroup, rules *fieldModeRules) (Validations, error) { + fieldType := rules.member.Type + + // Use the nilable form to handle missing values. + nilableFieldType := fieldType + fieldExprPrefix := "" + if !util.IsNilableType(nilableFieldType) { + nilableFieldType = types.PointerTo(nilableFieldType) + fieldExprPrefix = "&" + } + + // Get the JSON name of the field + jsonName := rules.member.Name + if jt, ok := tags.LookupJSON(*rules.member); ok { + jsonName = jt.Name + } + + // Default validation is Forbidden + defaultForbidden, err := mtfv.getForbiddenValidation(fieldType) + if err != nil { + return Validations{}, err + } + + // Prepare ModalRules + // Aggregate rules by value + rulesByValue := make(map[string]Validations) + var values []string + for _, rule := range rules.rules { + if _, ok := rulesByValue[rule.value]; !ok { + values = append(values, rule.value) + } + v := rulesByValue[rule.value] + v.Add(rule.validations) + rulesByValue[rule.value] = v + } + slices.Sort(values) + + var modalRules []any + for _, val := range values { + ruleValidations := rulesByValue[val] + + wrapper := MultiWrapperFunction{ + Functions: ruleValidations.Functions, + ObjType: nilableFieldType, + } + + modalRules = append(modalRules, StructLiteral{ + Type: types.Name{Package: libValidationPkg, Name: "ModalRule"}, + TypeArgs: []*types.Type{nilableFieldType}, + Fields: []StructLiteralField{ + {Name: "Value", Value: val}, + {Name: "Validation", Value: wrapper}, + }, + }) + } + + modalValidator := types.Name{Package: libValidationPkg, Name: "Modal"} + + rulesSlice := SliceLiteral{ + ElementType: types.Name{Package: libValidationPkg, Name: "ModalRule"}, + ElementTypeArgs: []*types.Type{nilableFieldType}, + Elements: modalRules, + } + + // getValue extractor + getValue := FunctionLiteral{ + Parameters: []ParamResult{{Name: "obj", Type: types.PointerTo(context.Type)}}, + Results: []ParamResult{{Type: nilableFieldType}}, + Body: fmt.Sprintf("return %sobj.%s", fieldExprPrefix, rules.member.Name), + } + + // getDiscriminator extractor + discriminatorType := group.discriminatorMember.Type + getDiscriminator := FunctionLiteral{ + Parameters: []ParamResult{{Name: "obj", Type: types.PointerTo(context.Type)}}, + Results: []ParamResult{{Type: discriminatorType}}, + Body: fmt.Sprintf("return obj.%s", group.discriminatorMember.Name), + } + + // Calculate equivArg for ratcheting the value + var equivArg any + if util.IsDirectComparable(util.NonPointer(util.NativeType(fieldType))) { + equivArg = Identifier(validateDirectEqualPtr) + } else { + equivArg = Identifier(validateSemanticDeepEqual) + } + + fn := Function(discriminatorTagName, DefaultFlags, modalValidator, + Literal(fmt.Sprintf("%q", jsonName)), + getValue, + getDiscriminator, + equivArg, + defaultForbidden, + rulesSlice, + ) + + return Validations{Functions: []FunctionGen{fn}}, nil +} + +func (mtfv *modeTypeOrFieldValidator) getForbiddenValidation(t *types.Type) (any, error) { + var forbidden types.Name + nt := util.NativeType(t) + switch nt.Kind { + case types.Slice: + forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenSlice"} + case types.Map: + forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenMap"} + case types.Pointer: + forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenPointer"} + case types.Struct: + return nil, fmt.Errorf("can only be used on pointers to structs") + default: + forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenValue"} + } + + fg := Function(forbiddenTagName, DefaultFlags, forbidden) + + // Use the nilable form to match standard validation function signatures. + wrapperObjType := t + if !util.IsNilableType(t) { + wrapperObjType = types.PointerTo(t) + } + + return MultiWrapperFunction{ + Functions: []FunctionGen{fg}, + ObjType: wrapperObjType, + }, nil +}