feat(validation-gen): add +k8s:unionMember and +k8s:unionDiscriminator tag validators and associated validate methods

This commit is contained in:
Aaron Prindle
2025-07-16 21:57:30 +00:00
parent bfb42fc31c
commit 5bc9b69114
2 changed files with 709 additions and 0 deletions

View 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
}

View File

@@ -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
}