mirror of
https://github.com/k3s-io/kubernetes.git
synced 2026-07-22 17:51:28 +00:00
feat(validation-gen): add +k8s:unionMember and +k8s:unionDiscriminator tag validators and associated validate methods
This commit is contained in:
212
staging/src/k8s.io/apimachinery/pkg/api/validate/union.go
Normal file
212
staging/src/k8s.io/apimachinery/pkg/api/validate/union.go
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
Copyright 2024 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"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/operation"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
// ExtractorFn extracts a value from a parent object. Depending on the context,
|
||||
// that could be the value of a field or just whether that field was set or
|
||||
// not.
|
||||
// Note: obj is not guaranteed to be non-nil, need to handle nil obj in the
|
||||
// extractor.
|
||||
type ExtractorFn[T, V any] func(obj T) V
|
||||
|
||||
// UnionValidationOptions configures how union validation behaves
|
||||
type UnionValidationOptions struct {
|
||||
// ErrorForEmpty returns error when no fields are set (nil means no error)
|
||||
ErrorForEmpty func(fldPath *field.Path, allFields []string) *field.Error
|
||||
|
||||
// ErrorForMultiple returns error when multiple fields are set (nil means no error)
|
||||
ErrorForMultiple func(fldPath *field.Path, specifiedFields []string, allFields []string) *field.Error
|
||||
}
|
||||
|
||||
// Union verifies that exactly one member of a union is specified.
|
||||
//
|
||||
// UnionMembership must define all the members of the union.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// var UnionMembershipForABC := validate.NewUnionMembership([2]string{"a", "A"}, [2]string{"b", "B"}, [2]string{"c", "C"})
|
||||
// func ValidateABC(ctx context.Context, op operation.Operation, fldPath *field.Path, in *ABC) (errs fields.ErrorList) {
|
||||
// errs = append(errs, Union(ctx, op, fldPath, in, oldIn, UnionMembershipForABC,
|
||||
// func(in *ABC) bool { return in.A != nil },
|
||||
// func(in *ABC) bool { return in.B != ""},
|
||||
// func(in *ABC) bool { return in.C != 0 },
|
||||
// )...)
|
||||
// return errs
|
||||
// }
|
||||
func Union[T any](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, isSetFns ...ExtractorFn[T, bool]) field.ErrorList {
|
||||
options := UnionValidationOptions{
|
||||
ErrorForEmpty: func(fldPath *field.Path, allFields []string) *field.Error {
|
||||
return field.Invalid(fldPath, "",
|
||||
fmt.Sprintf("must specify one of: %s", strings.Join(allFields, ", ")))
|
||||
},
|
||||
ErrorForMultiple: func(fldPath *field.Path, specifiedFields []string, allFields []string) *field.Error {
|
||||
return field.Invalid(fldPath, fmt.Sprintf("{%s}", strings.Join(specifiedFields, ", ")),
|
||||
fmt.Sprintf("must specify exactly one of: %s", strings.Join(allFields, ", ")))
|
||||
},
|
||||
}
|
||||
|
||||
return unionValidate(op, fldPath, obj, oldObj, union, options, isSetFns...)
|
||||
}
|
||||
|
||||
// DiscriminatedUnion verifies specified union member matches the discriminator.
|
||||
//
|
||||
// UnionMembership must define all the members of the union and the discriminator.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// var UnionMembershipForABC := validate.NewDiscriminatedUnionMembership("type", [2]string{"a", "A"}, [2]string{"b" "B"}, [2]string{"c", "C"})
|
||||
// func ValidateABC(ctx context.Context, op operation.Operation, fldPath, *field.Path, in *ABC) (errs fields.ErrorList) {
|
||||
// errs = append(errs, DiscriminatedUnion(ctx, op, fldPath, in, oldIn, UnionMembershipForABC,
|
||||
// func(in *ABC) string { return string(in.Type) },
|
||||
// func(in *ABC) bool { return in.A != nil },
|
||||
// func(in *ABC) bool { return in.B != ""},
|
||||
// func(in *ABC) bool { return in.C != 0 },
|
||||
// )...)
|
||||
// return errs
|
||||
// }
|
||||
//
|
||||
// It is not an error for the discriminatorValue to be unknown. That must be
|
||||
// validated on its own.
|
||||
func DiscriminatedUnion[T any, D ~string](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, discriminatorExtractor ExtractorFn[T, D], isSetFns ...ExtractorFn[T, bool]) (errs field.ErrorList) {
|
||||
if len(union.members) != len(isSetFns) {
|
||||
return field.ErrorList{
|
||||
field.InternalError(fldPath,
|
||||
fmt.Errorf("number of extractors (%d) does not match number of union members (%d)",
|
||||
len(isSetFns), len(union.members))),
|
||||
}
|
||||
}
|
||||
var changed bool
|
||||
discriminatorValue := discriminatorExtractor(obj)
|
||||
if op.Type == operation.Update {
|
||||
oldDiscriminatorValue := discriminatorExtractor(oldObj)
|
||||
changed = discriminatorValue != oldDiscriminatorValue
|
||||
}
|
||||
|
||||
for i, fieldIsSet := range isSetFns {
|
||||
member := union.members[i]
|
||||
isDiscriminatedMember := string(discriminatorValue) == member.discriminatorValue
|
||||
newIsSet := fieldIsSet(obj)
|
||||
if op.Type == operation.Update && !changed {
|
||||
oldIsSet := fieldIsSet(oldObj)
|
||||
changed = changed || newIsSet != oldIsSet
|
||||
}
|
||||
if newIsSet && !isDiscriminatedMember {
|
||||
errs = append(errs, field.Invalid(fldPath.Child(member.fieldName), "",
|
||||
fmt.Sprintf("may only be specified when `%s` is %q", union.discriminatorName, member.discriminatorValue)))
|
||||
} else if !newIsSet && isDiscriminatedMember {
|
||||
errs = append(errs, field.Invalid(fldPath.Child(member.fieldName), "",
|
||||
fmt.Sprintf("must be specified when `%s` is %q", union.discriminatorName, discriminatorValue)))
|
||||
}
|
||||
}
|
||||
// If the union discriminator and membership is unchanged, we don't need to
|
||||
// re-validate.
|
||||
if op.Type == operation.Update && !changed {
|
||||
return nil
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
type member struct {
|
||||
fieldName, discriminatorValue string
|
||||
}
|
||||
|
||||
// UnionMembership represents an ordered list of field union memberships.
|
||||
type UnionMembership struct {
|
||||
discriminatorName string
|
||||
members []member
|
||||
}
|
||||
|
||||
// NewUnionMembership returns a new UnionMembership for the given list of members.
|
||||
//
|
||||
// Each member is a [2]string to provide a fieldName and discriminatorValue pair, where
|
||||
// [0] identifies the field name and [1] identifies the union member Name.
|
||||
//
|
||||
// Field names must be unique.
|
||||
func NewUnionMembership(member ...[2]string) *UnionMembership {
|
||||
return NewDiscriminatedUnionMembership("", member...)
|
||||
}
|
||||
|
||||
// NewDiscriminatedUnionMembership returns a new UnionMembership for the given discriminator field and list of members.
|
||||
// members are provided in the same way as for NewUnionMembership.
|
||||
func NewDiscriminatedUnionMembership(discriminatorFieldName string, members ...[2]string) *UnionMembership {
|
||||
u := &UnionMembership{}
|
||||
u.discriminatorName = discriminatorFieldName
|
||||
for _, fieldName := range members {
|
||||
u.members = append(u.members, member{fieldName: fieldName[0], discriminatorValue: fieldName[1]})
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// allFields returns a string listing all the field names of the member of a union for use in error reporting.
|
||||
func (u UnionMembership) allFields() []string {
|
||||
memberNames := make([]string, 0, len(u.members))
|
||||
for _, f := range u.members {
|
||||
memberNames = append(memberNames, fmt.Sprintf("`%s`", f.fieldName))
|
||||
}
|
||||
return memberNames
|
||||
}
|
||||
|
||||
func unionValidate[T any](op operation.Operation, fldPath *field.Path,
|
||||
obj, oldObj T, union *UnionMembership, options UnionValidationOptions, isSetFns ...ExtractorFn[T, bool],
|
||||
) field.ErrorList {
|
||||
if len(union.members) != len(isSetFns) {
|
||||
return field.ErrorList{
|
||||
field.InternalError(fldPath,
|
||||
fmt.Errorf("number of extractors (%d) does not match number of union members (%d)",
|
||||
len(isSetFns), len(union.members))),
|
||||
}
|
||||
}
|
||||
|
||||
var specifiedFields []string
|
||||
var changed bool
|
||||
for i, fieldIsSet := range isSetFns {
|
||||
newIsSet := fieldIsSet(obj)
|
||||
if op.Type == operation.Update && !changed {
|
||||
oldIsSet := fieldIsSet(oldObj)
|
||||
changed = changed || newIsSet != oldIsSet
|
||||
}
|
||||
if newIsSet {
|
||||
specifiedFields = append(specifiedFields, union.members[i].fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
// If the union membership is unchanged, we don't need to re-validate.
|
||||
if op.Type == operation.Update && !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs field.ErrorList
|
||||
|
||||
if len(specifiedFields) > 1 && options.ErrorForMultiple != nil {
|
||||
errs = append(errs, options.ErrorForMultiple(fldPath, specifiedFields, union.allFields()))
|
||||
}
|
||||
|
||||
if len(specifiedFields) == 0 && options.ErrorForEmpty != nil {
|
||||
errs = append(errs, options.ErrorForEmpty(fldPath, union.allFields()))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
Copyright 2021 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var discriminatedUnionValidator = types.Name{Package: libValidationPkg, Name: "DiscriminatedUnion"}
|
||||
var unionValidator = types.Name{Package: libValidationPkg, Name: "Union"}
|
||||
|
||||
var newDiscriminatedUnionMembership = types.Name{Package: libValidationPkg, Name: "NewDiscriminatedUnionMembership"}
|
||||
var newUnionMembership = types.Name{Package: libValidationPkg, Name: "NewUnionMembership"}
|
||||
var unionVariablePrefix = "unionMembershipFor"
|
||||
|
||||
func init() {
|
||||
// Unions are comprised of multiple tags that need to share information.
|
||||
// For field-based unions: tags are on struct fields, validation is on the struct
|
||||
// For item-based unions: tags are on list items (via +k8s:item), validation is on the list
|
||||
|
||||
// "shared" maps from field path strings (key) to union definitions (value)
|
||||
// key examples:
|
||||
// - struct union: "MyStruct" (validation on the struct type)
|
||||
// - list union: "Pipeline.Tasks" (validation on the list field)
|
||||
shared := map[string]unions{}
|
||||
RegisterTypeValidator(unionTypeOrFieldValidator{shared})
|
||||
RegisterFieldValidator(unionTypeOrFieldValidator{shared})
|
||||
RegisterTagValidator(unionDiscriminatorTagValidator{shared})
|
||||
RegisterTagValidator(unionMemberTagValidator{shared})
|
||||
}
|
||||
|
||||
type unionTypeOrFieldValidator struct {
|
||||
shared map[string]unions
|
||||
}
|
||||
|
||||
func (unionTypeOrFieldValidator) Init(_ Config) {}
|
||||
|
||||
func (unionTypeOrFieldValidator) Name() string {
|
||||
return "unionTypeOrFieldValidator"
|
||||
}
|
||||
|
||||
func (utfv unionTypeOrFieldValidator) GetValidations(context Context) (Validations, error) {
|
||||
// TODO: Add support for map items once map item validation is implemented
|
||||
|
||||
// Extract the most concrete type possible.
|
||||
if k := util.NonPointer(util.NativeType(context.Type)).Kind; k != types.Struct && k != types.Slice {
|
||||
return Validations{}, nil
|
||||
}
|
||||
|
||||
unions := utfv.shared[context.Path.String()]
|
||||
if len(unions) == 0 {
|
||||
return Validations{}, nil
|
||||
}
|
||||
|
||||
return processUnionValidations(context, unions, unionVariablePrefix,
|
||||
unionMemberTagName, unionValidator, discriminatedUnionValidator)
|
||||
}
|
||||
|
||||
func toSliceAny[T any](t []T) []any {
|
||||
result := make([]any, len(t))
|
||||
for i, v := range t {
|
||||
result[i] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const (
|
||||
unionDiscriminatorTagName = "k8s:unionDiscriminator"
|
||||
unionMemberTagName = "k8s:unionMember"
|
||||
)
|
||||
|
||||
type unionDiscriminatorTagValidator struct {
|
||||
shared map[string]unions
|
||||
}
|
||||
|
||||
func (unionDiscriminatorTagValidator) Init(_ Config) {}
|
||||
|
||||
func (unionDiscriminatorTagValidator) TagName() string {
|
||||
return unionDiscriminatorTagName
|
||||
}
|
||||
|
||||
// Shared between unionDiscriminatorTagValidator and unionMemberTagValidator.
|
||||
var unionTagValidScopes = sets.New(ScopeField, ScopeListVal)
|
||||
|
||||
func (unionDiscriminatorTagValidator) ValidScopes() sets.Set[Scope] {
|
||||
return unionTagValidScopes
|
||||
}
|
||||
|
||||
func (udtv unionDiscriminatorTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) {
|
||||
err := processDiscriminatorValidations(udtv.shared, context, tag)
|
||||
if err != nil {
|
||||
return Validations{}, err
|
||||
}
|
||||
// This tag does not actually emit any validations, it just accumulates
|
||||
// information. The validation is done by the unionTypeOrFieldValidator.
|
||||
return Validations{}, nil
|
||||
}
|
||||
|
||||
func (udtv unionDiscriminatorTagValidator) Docs() TagDoc {
|
||||
return TagDoc{
|
||||
Tag: udtv.TagName(),
|
||||
Scopes: udtv.ValidScopes().UnsortedList(),
|
||||
Description: "Indicates that this field is the discriminator for a union.",
|
||||
Args: []TagArgDoc{{
|
||||
Name: "union",
|
||||
Description: "<string>",
|
||||
Docs: "the name of the union, if more than one exists",
|
||||
Type: codetags.ArgTypeString,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
type unionMemberTagValidator struct {
|
||||
shared map[string]unions
|
||||
}
|
||||
|
||||
func (unionMemberTagValidator) Init(_ Config) {}
|
||||
|
||||
func (unionMemberTagValidator) TagName() string {
|
||||
return unionMemberTagName
|
||||
}
|
||||
|
||||
func (unionMemberTagValidator) ValidScopes() sets.Set[Scope] {
|
||||
return unionTagValidScopes
|
||||
}
|
||||
|
||||
func (umtv unionMemberTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) {
|
||||
err := processMemberValidations(umtv.shared, context, tag)
|
||||
if err != nil {
|
||||
return Validations{}, err
|
||||
}
|
||||
// This tag does not actually emit any validations, it just accumulates
|
||||
// information. The validation is done by the unionTypeOrFieldValidator.
|
||||
return Validations{}, nil
|
||||
}
|
||||
|
||||
func (umtv unionMemberTagValidator) Docs() TagDoc {
|
||||
return TagDoc{
|
||||
Tag: umtv.TagName(),
|
||||
Scopes: umtv.ValidScopes().UnsortedList(),
|
||||
Description: "Indicates that this field is a member of a union.",
|
||||
Args: []TagArgDoc{{
|
||||
Name: "union",
|
||||
Description: "<string>",
|
||||
Docs: "the name of the union, if more than one exists",
|
||||
Type: codetags.ArgTypeString,
|
||||
}, {
|
||||
Name: "memberName",
|
||||
Description: "<string>",
|
||||
Docs: "the discriminator value for this member",
|
||||
Default: "the field's name",
|
||||
Type: codetags.ArgTypeString,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// union defines how a union validation will be generated, based
|
||||
// on +k8s:unionMember and +k8s:unionDiscriminator tags found in a go struct.
|
||||
type union struct {
|
||||
// fields provides field information about all the members of the union.
|
||||
// Each item provides a fieldName and memberName pair, where [0] identifies
|
||||
// the field name and [1] identifies the union member Name. fields is index
|
||||
// aligned with fieldMembers.
|
||||
// If member name is not set, it defaults to the go struct field name.
|
||||
fields [][2]string
|
||||
// fieldMembers describes all the members of the union.
|
||||
fieldMembers []*types.Member
|
||||
|
||||
// discriminator is the name of the discriminator field
|
||||
discriminator *string
|
||||
// discriminatorMember describes the discriminator field.
|
||||
discriminatorMember *types.Member
|
||||
|
||||
// itemMatchers stores matcher criteria for list item unions.
|
||||
// key is the virtual field path (eg: "<path>/Pipeline.Tasks[{"name": "succeeded"}]"),
|
||||
// value is the parsed matcher map (eg: {"name": "succeeded"}).
|
||||
// Represents union members that are list items matching specific criteria
|
||||
itemMatchers map[string]map[string]any
|
||||
}
|
||||
|
||||
// unions represents all the unions for a go struct.
|
||||
type unions map[string]*union
|
||||
|
||||
// newUnion initializes a new union instance
|
||||
func newUnion() *union {
|
||||
return &union{
|
||||
fields: make([][2]string, 0),
|
||||
fieldMembers: make([]*types.Member, 0),
|
||||
itemMatchers: make(map[string]map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// getOrCreate gets a union by name, or initializes a new union by the given name.
|
||||
func (us unions) getOrCreate(name string) *union {
|
||||
var u *union
|
||||
var ok bool
|
||||
if u, ok = us[name]; !ok {
|
||||
u = newUnion()
|
||||
us[name] = u
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func processUnionValidations(context Context, unions unions, varPrefix string,
|
||||
tagName string, undiscriminatedValidator types.Name, discriminatedValidator types.Name,
|
||||
) (Validations, error) {
|
||||
result := Validations{}
|
||||
|
||||
// Sort the keys for stable output.
|
||||
keys := make([]string, 0, len(unions))
|
||||
for k := range unions {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, unionName := range keys {
|
||||
u := unions[unionName]
|
||||
if len(u.fieldMembers) > 0 || u.discriminator != nil || len(u.itemMatchers) > 0 {
|
||||
if len(u.fieldMembers) > 0 && len(u.itemMatchers) > 0 {
|
||||
return Validations{}, fmt.Errorf("cannot have both field members and item matchers")
|
||||
}
|
||||
nativeType := util.NonPointer(util.NativeType(context.Type))
|
||||
if nativeType.Kind == types.Struct && len(u.itemMatchers) > 0 {
|
||||
return Validations{}, fmt.Errorf("struct type cannot have item matchers")
|
||||
}
|
||||
if nativeType.Kind == types.Slice && len(u.fieldMembers) > 0 {
|
||||
return Validations{}, fmt.Errorf("slice type cannot have field members")
|
||||
}
|
||||
|
||||
// TODO: Avoid the "local" here. This was added to to avoid errors caused when the package is an empty string.
|
||||
// The correct package would be the output package but is not known here. This does not show up in generated code.
|
||||
// TODO: Append a consistent hash suffix to avoid generated name conflicts?
|
||||
varBaseName := sanitizeName(context.Path.String() + "_" + unionName) // unionName can be ""
|
||||
supportVarName := PrivateVar{Name: varPrefix + "_" + varBaseName, Package: "local"}
|
||||
|
||||
var extractorArgs []any
|
||||
ptrType := types.PointerTo(context.Type)
|
||||
|
||||
// Handle field unions
|
||||
for _, member := range u.fieldMembers {
|
||||
extractor := createMemberExtractor(ptrType, member)
|
||||
extractorArgs = append(extractorArgs, extractor)
|
||||
}
|
||||
|
||||
// Handle list item unions for lists
|
||||
if nativeType.Kind == types.Slice && len(u.itemMatchers) > 0 {
|
||||
elemType := util.NonPointer(nativeType.Elem)
|
||||
|
||||
// Sort matcher paths for stable output
|
||||
matcherPaths := make([]string, 0, len(u.itemMatchers))
|
||||
for path := range u.itemMatchers {
|
||||
matcherPaths = append(matcherPaths, path)
|
||||
}
|
||||
slices.Sort(matcherPaths)
|
||||
|
||||
for _, fullPath := range matcherPaths {
|
||||
matcher := u.itemMatchers[fullPath]
|
||||
extractor, err := createItemExtractor(context.Type, elemType, matcher)
|
||||
if err != nil {
|
||||
return Validations{}, err
|
||||
}
|
||||
extractorArgs = append(extractorArgs, extractor)
|
||||
}
|
||||
}
|
||||
|
||||
if u.discriminator != nil {
|
||||
supportVar := Variable(supportVarName,
|
||||
Function(tagName, DefaultFlags, newDiscriminatedUnionMembership,
|
||||
append([]any{*u.discriminator}, toSliceAny(getDisplayFields(u, context))...)...))
|
||||
result.Variables = append(result.Variables, supportVar)
|
||||
|
||||
discriminatorExtractor := FunctionLiteral{
|
||||
Parameters: []ParamResult{{Name: "obj", Type: ptrType}},
|
||||
Results: []ParamResult{{Type: types.String}},
|
||||
Body: fmt.Sprintf("if obj == nil {return \"\"}; return string(obj.%s)", u.discriminatorMember.Name), // Cast to string
|
||||
}
|
||||
|
||||
extraArgs := append([]any{supportVarName, discriminatorExtractor}, extractorArgs...)
|
||||
fn := Function(tagName, DefaultFlags, discriminatedValidator, extraArgs...)
|
||||
result.Functions = append(result.Functions, fn)
|
||||
} else {
|
||||
supportVar := Variable(supportVarName, Function(tagName, DefaultFlags, newUnionMembership, toSliceAny(getDisplayFields(u, context))...))
|
||||
result.Variables = append(result.Variables, supportVar)
|
||||
|
||||
extraArgs := append([]any{supportVarName}, extractorArgs...)
|
||||
fn := Function(tagName, DefaultFlags, undiscriminatedValidator, extraArgs...)
|
||||
result.Functions = append(result.Functions, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func createMemberExtractor(ptrType *types.Type, member *types.Member) FunctionLiteral {
|
||||
extractor := FunctionLiteral{
|
||||
Parameters: []ParamResult{{Name: "obj", Type: ptrType}},
|
||||
Results: []ParamResult{{Type: types.Bool}},
|
||||
}
|
||||
nt := util.NativeType(member.Type)
|
||||
switch nt.Kind {
|
||||
case types.Pointer, types.Map, types.Slice:
|
||||
extractor.Body = fmt.Sprintf("if obj == nil {return false}; return obj.%s != nil", member.Name)
|
||||
case types.Builtin:
|
||||
extractor.Body = fmt.Sprintf("if obj == nil {return false}; var z %s; return obj.%s != z", member.Type, member.Name)
|
||||
default:
|
||||
// This should be caught before we get here, but JIC.
|
||||
extractor.Body = fmt.Sprintf("if obj == nil {return false}; return false /* unsupported union member kind: %s */", nt.Kind)
|
||||
}
|
||||
return extractor
|
||||
}
|
||||
|
||||
// createItemExtractor creates an extractor function for list item union members.
|
||||
// It generates code that loops through the list to check if an item matching the criteria exists.
|
||||
func createItemExtractor(listType *types.Type, elemType *types.Type, matcher map[string]any) (FunctionLiteral, error) {
|
||||
var criteria []keyValuePair
|
||||
for key, value := range matcher {
|
||||
criteria = append(criteria, keyValuePair{
|
||||
key: key,
|
||||
value: fmt.Sprint(value),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort for stable output.
|
||||
slices.SortFunc(criteria, func(a, b keyValuePair) int {
|
||||
return strings.Compare(a.key, b.key)
|
||||
})
|
||||
|
||||
condition, err := buildMatchConditions(elemType, criteria, "list[i]")
|
||||
if err != nil {
|
||||
return FunctionLiteral{}, err
|
||||
}
|
||||
|
||||
extractor := FunctionLiteral{
|
||||
Parameters: []ParamResult{{Name: "list", Type: listType}},
|
||||
Results: []ParamResult{{Type: types.Bool}},
|
||||
Body: fmt.Sprintf(`for i := range list {
|
||||
if %s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false`, condition),
|
||||
}
|
||||
|
||||
return extractor, nil
|
||||
}
|
||||
|
||||
func processDiscriminatorValidations(shared map[string]unions, context Context, tag codetags.Tag) error {
|
||||
// This tag can apply to value and pointer fields, as well as typedefs
|
||||
// (which should never be pointers). We need to check the concrete type.
|
||||
if t := util.NonPointer(util.NativeType(context.Type)); t != types.String {
|
||||
return fmt.Errorf("can only be used on string types (%s)", rootTypeString(context.Type, t))
|
||||
}
|
||||
if shared[context.ParentPath.String()] == nil {
|
||||
shared[context.ParentPath.String()] = unions{}
|
||||
}
|
||||
unionArg, _ := tag.NamedArg("union") // optional
|
||||
u := shared[context.ParentPath.String()].getOrCreate(unionArg.Value)
|
||||
|
||||
var discriminatorFieldName string
|
||||
if jsonAnnotation, ok := tags.LookupJSON(*context.Member); ok {
|
||||
discriminatorFieldName = jsonAnnotation.Name
|
||||
u.discriminator = &discriminatorFieldName
|
||||
u.discriminatorMember = context.Member
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processMemberValidations(shared map[string]unions, context Context, tag codetags.Tag) error {
|
||||
var fieldName string
|
||||
var unionArg codetags.Arg
|
||||
|
||||
unionArg, _ = tag.NamedArg("union") // optional
|
||||
|
||||
if context.Scope == ScopeListVal {
|
||||
if context.Path == nil {
|
||||
return fmt.Errorf("no path for list val union member")
|
||||
}
|
||||
fieldName = context.Path.String() // eg: "<path>/Pipeline.Tasks[{"name": "succeeded"}]"
|
||||
} else {
|
||||
nt := util.NativeType(context.Member.Type)
|
||||
switch nt.Kind {
|
||||
case types.Pointer, types.Map, types.Slice, types.Builtin:
|
||||
// OK
|
||||
default:
|
||||
// In particular non-pointer structs are not supported.
|
||||
return fmt.Errorf("can only be used on nilable and primitive types (%s)", nt.Kind)
|
||||
}
|
||||
|
||||
jsonTag, ok := tags.LookupJSON(*context.Member)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q is a union member but has no JSON struct field tag", context.Member)
|
||||
}
|
||||
fieldName = jsonTag.Name
|
||||
if len(fieldName) == 0 {
|
||||
return fmt.Errorf("field %q is a union member but has no JSON name", context.Member)
|
||||
}
|
||||
}
|
||||
|
||||
if shared[context.ParentPath.String()] == nil {
|
||||
shared[context.ParentPath.String()] = unions{}
|
||||
}
|
||||
|
||||
var memberName string
|
||||
if memberNameArg, ok := tag.NamedArg("memberName"); ok { // optional
|
||||
memberName = memberNameArg.Value
|
||||
} else if context.Scope != ScopeListVal {
|
||||
memberName = context.Member.Name // default
|
||||
}
|
||||
|
||||
u := shared[context.ParentPath.String()].getOrCreate(unionArg.Value)
|
||||
u.fields = append(u.fields, [2]string{fieldName, memberName})
|
||||
|
||||
if context.Scope == ScopeListVal {
|
||||
matcher, err := extractMatcherFromPath(fieldName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract matcher from path %s: %w", fieldName, err)
|
||||
}
|
||||
u.itemMatchers[fieldName] = matcher
|
||||
} else {
|
||||
u.fieldMembers = append(u.fieldMembers, context.Member)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDisplayFields formats union field names for user-friendly error messages.
|
||||
// For list item unions, it converts paths like "<path>/Pipeline.Tasks[{\"name\": \"succeeded\"}]"
|
||||
// to readable formats like "Tasks[{\"name\": \"succeeded\"}]".
|
||||
func getDisplayFields(u *union, context Context) [][2]string {
|
||||
displayFields := make([][2]string, len(u.fields))
|
||||
listFieldName := context.Path.String()
|
||||
pathParts := strings.Split(listFieldName, ".")
|
||||
if len(pathParts) > 0 {
|
||||
listFieldName = pathParts[len(pathParts)-1]
|
||||
}
|
||||
for i, f := range u.fields {
|
||||
fieldName := f[0]
|
||||
memberName := f[1]
|
||||
if _, isItem := u.itemMatchers[fieldName]; isItem {
|
||||
// Extract the JSON part from the input
|
||||
bracketIndex := strings.Index(fieldName, "[")
|
||||
if bracketIndex != -1 {
|
||||
jsonPart := fieldName[bracketIndex:]
|
||||
fieldName = listFieldName + jsonPart
|
||||
}
|
||||
}
|
||||
displayFields[i] = [2]string{fieldName, memberName}
|
||||
}
|
||||
return displayFields
|
||||
}
|
||||
|
||||
// sanitizeName converts a string into a valid Go identifier
|
||||
func sanitizeName(name string) string {
|
||||
name = strings.ReplaceAll(name, ".", "_")
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
return re.ReplaceAllString(name, "_")
|
||||
}
|
||||
|
||||
// extractMatcherFromPath extracts the matcher criteria from a path like "Pipeline.Tasks[{"name": "succeeded"}]"
|
||||
func extractMatcherFromPath(path string) (map[string]any, error) {
|
||||
re := regexp.MustCompile(`\[({.*?})\]`)
|
||||
matches := re.FindStringSubmatch(path)
|
||||
if len(matches) < 2 {
|
||||
return nil, fmt.Errorf("no matcher criteria found in path")
|
||||
}
|
||||
|
||||
var matcher map[string]any
|
||||
if err := json.Unmarshal([]byte(matches[1]), &matcher); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse matcher JSON: %w", err)
|
||||
}
|
||||
return matcher, nil
|
||||
}
|
||||
Reference in New Issue
Block a user