istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pkg/test/util/structpath/instance.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package structpath
    16  
    17  import (
    18  	"bytes"
    19  	"encoding/json"
    20  	"errors"
    21  	"fmt"
    22  	"reflect"
    23  	"regexp"
    24  	"strings"
    25  
    26  	"github.com/google/go-cmp/cmp"
    27  	"google.golang.org/protobuf/proto"
    28  	"k8s.io/client-go/util/jsonpath"
    29  
    30  	"istio.io/istio/pkg/test"
    31  	"istio.io/istio/pkg/util/protomarshal"
    32  )
    33  
    34  var (
    35  	fixupNumericJSONComparison = regexp.MustCompile(`([=<>]+)\s*([0-9]+)\s*\)`)
    36  	fixupAttributeReference    = regexp.MustCompile(`\[\s*'[^']+\s*'\s*]`)
    37  )
    38  
    39  type Instance struct {
    40  	structure     any
    41  	isJSON        bool
    42  	constraints   []constraint
    43  	creationError error
    44  }
    45  
    46  type constraint func() error
    47  
    48  // ForProto creates a structpath Instance by marshaling the proto to JSON and then evaluating over that
    49  // structure. This is the most generally useful form as serialization to JSON also automatically
    50  // converts proto.Any and proto.Struct to the serialized JSON forms which can then be evaluated
    51  // over. The downside is the loss of type fidelity for numeric types as JSON can only represent
    52  // floats.
    53  func ForProto(proto proto.Message) *Instance {
    54  	if proto == nil {
    55  		return newErrorInstance(errors.New("expected non-nil proto"))
    56  	}
    57  
    58  	parsed, err := protoToParsedJSON(proto)
    59  	if err != nil {
    60  		return newErrorInstance(err)
    61  	}
    62  
    63  	i := &Instance{
    64  		isJSON:    true,
    65  		structure: parsed,
    66  	}
    67  	i.structure = parsed
    68  	return i
    69  }
    70  
    71  func newErrorInstance(err error) *Instance {
    72  	return &Instance{
    73  		isJSON:        true,
    74  		creationError: err,
    75  	}
    76  }
    77  
    78  func protoToParsedJSON(message proto.Message) (any, error) {
    79  	// Convert proto to json and then parse into struct
    80  	jsonText, err := protomarshal.MarshalIndent(message, "  ")
    81  	if err != nil {
    82  		return nil, fmt.Errorf("failed to convert proto to JSON: %v", err)
    83  	}
    84  	var parsed any
    85  	err = json.Unmarshal(jsonText, &parsed)
    86  	if err != nil {
    87  		return nil, fmt.Errorf("failed to parse into JSON struct: %v", err)
    88  	}
    89  	return parsed, nil
    90  }
    91  
    92  func (i *Instance) Select(path string, args ...any) *Instance {
    93  	if i.creationError != nil {
    94  		// There was an error during the creation of this Instance. Just return the
    95  		// same instance since it will error on Check anyway.
    96  		return i
    97  	}
    98  
    99  	path = fmt.Sprintf(path, args...)
   100  	value, err := i.findValue(path)
   101  	if err != nil {
   102  		return newErrorInstance(err)
   103  	}
   104  	if value == nil {
   105  		return newErrorInstance(fmt.Errorf("cannot select non-existent path: %v", path))
   106  	}
   107  
   108  	// Success.
   109  	return &Instance{
   110  		isJSON:    i.isJSON,
   111  		structure: value,
   112  	}
   113  }
   114  
   115  func (i *Instance) appendConstraint(fn func() error) *Instance {
   116  	i.constraints = append(i.constraints, fn)
   117  	return i
   118  }
   119  
   120  func (i *Instance) Equals(expected any, path string, args ...any) *Instance {
   121  	path = fmt.Sprintf(path, args...)
   122  	return i.appendConstraint(func() error {
   123  		typeOf := reflect.TypeOf(expected)
   124  		protoMessageType := reflect.TypeOf((*proto.Message)(nil)).Elem()
   125  		if typeOf.Implements(protoMessageType) {
   126  			return i.equalsStruct(expected.(proto.Message), path)
   127  		}
   128  		switch kind := typeOf.Kind(); kind {
   129  		case reflect.String:
   130  			return i.equalsString(reflect.ValueOf(expected).String(), path)
   131  		case reflect.Bool:
   132  			return i.equalsBool(reflect.ValueOf(expected).Bool(), path)
   133  		case reflect.Float32, reflect.Float64:
   134  			return i.equalsNumber(reflect.ValueOf(expected).Float(), path)
   135  		case reflect.Int, reflect.Int8, reflect.Int32, reflect.Int64:
   136  			return i.equalsNumber(float64(reflect.ValueOf(expected).Int()), path)
   137  		case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
   138  			return i.equalsNumber(float64(reflect.ValueOf(expected).Uint()), path)
   139  		case protoMessageType.Kind():
   140  		}
   141  		// TODO: Add struct support
   142  		return fmt.Errorf("attempt to call Equals for unsupported type: %v", expected)
   143  	})
   144  }
   145  
   146  func (i *Instance) ContainSubstring(substr, path string) *Instance {
   147  	return i.appendConstraint(func() error {
   148  		value, err := i.execute(path)
   149  		if err != nil {
   150  			return err
   151  		}
   152  		if found := strings.Contains(value, substr); !found {
   153  			return fmt.Errorf("substring %v did not match: %v", substr, value)
   154  		}
   155  		return nil
   156  	})
   157  }
   158  
   159  func (i *Instance) equalsString(expected string, path string) error {
   160  	value, err := i.execute(path)
   161  	if err != nil {
   162  		return err
   163  	}
   164  	if value != expected {
   165  		return fmt.Errorf("expected %v but got %v for path %v", expected, value, path)
   166  	}
   167  	return nil
   168  }
   169  
   170  func (i *Instance) equalsNumber(expected float64, path string) error {
   171  	v, err := i.findValue(path)
   172  	if err != nil {
   173  		return err
   174  	}
   175  	result := reflect.ValueOf(v).Float()
   176  	if result != expected {
   177  		return fmt.Errorf("expected %v but got %v for path %v", expected, result, path)
   178  	}
   179  	return nil
   180  }
   181  
   182  func (i *Instance) equalsBool(expected bool, path string) error {
   183  	v, err := i.findValue(path)
   184  	if err != nil {
   185  		return err
   186  	}
   187  	result := reflect.ValueOf(v).Bool()
   188  	if result != expected {
   189  		return fmt.Errorf("expected %v but got %v for path %v", expected, result, path)
   190  	}
   191  	return nil
   192  }
   193  
   194  func (i *Instance) equalsStruct(proto proto.Message, path string) error {
   195  	jsonStruct, err := protoToParsedJSON(proto)
   196  	if err != nil {
   197  		return err
   198  	}
   199  	v, err := i.findValue(path)
   200  	if err != nil {
   201  		return err
   202  	}
   203  	diff := cmp.Diff(reflect.ValueOf(v).Interface(), jsonStruct)
   204  	if diff != "" {
   205  		return fmt.Errorf("structs did not match: %v", diff)
   206  	}
   207  	return nil
   208  }
   209  
   210  func (i *Instance) Exists(path string, args ...any) *Instance {
   211  	path = fmt.Sprintf(path, args...)
   212  	return i.appendConstraint(func() error {
   213  		v, err := i.findValue(path)
   214  		if err != nil {
   215  			return err
   216  		}
   217  		if v == nil {
   218  			return fmt.Errorf("no entry exists at path: %v", path)
   219  		}
   220  		return nil
   221  	})
   222  }
   223  
   224  func (i *Instance) NotExists(path string, args ...any) *Instance {
   225  	path = fmt.Sprintf(path, args...)
   226  	return i.appendConstraint(func() error {
   227  		parser := jsonpath.New("path")
   228  		err := parser.Parse(i.fixPath(path))
   229  		if err != nil {
   230  			return fmt.Errorf("invalid path: %v - %v", path, err)
   231  		}
   232  		values, err := parser.AllowMissingKeys(true).FindResults(i.structure)
   233  		if err != nil {
   234  			return fmt.Errorf("err finding results for path: %v - %v", path, err)
   235  		}
   236  		if len(values) == 0 {
   237  			return nil
   238  		}
   239  		if len(values[0]) > 0 {
   240  			return fmt.Errorf("expected no result but got: %v for path: %v", values[0], path)
   241  		}
   242  		return nil
   243  	})
   244  }
   245  
   246  // Check executes the set of constraints for this selection
   247  // and returns the first error encountered, or nil if all constraints
   248  // have been successfully met. All constraints are removed after them
   249  // check is performed.
   250  func (i *Instance) Check() error {
   251  	// After the check completes, clear out the constraints.
   252  	defer func() {
   253  		i.constraints = i.constraints[:0]
   254  	}()
   255  
   256  	// If there was a creation error, just return that immediately.
   257  	if i.creationError != nil {
   258  		return i.creationError
   259  	}
   260  
   261  	for _, c := range i.constraints {
   262  		if err := c(); err != nil {
   263  			return err
   264  		}
   265  	}
   266  	return nil
   267  }
   268  
   269  // CheckOrFail calls Check on this selection and fails the given test if an
   270  // error is encountered.
   271  func (i *Instance) CheckOrFail(t test.Failer) *Instance {
   272  	t.Helper()
   273  	if err := i.Check(); err != nil {
   274  		t.Fatal(err)
   275  	}
   276  	return i
   277  }
   278  
   279  func (i *Instance) execute(path string) (string, error) {
   280  	parser := jsonpath.New("path")
   281  	err := parser.Parse(i.fixPath(path))
   282  	if err != nil {
   283  		return "", fmt.Errorf("invalid path: %v - %v", path, err)
   284  	}
   285  	buf := new(bytes.Buffer)
   286  	err = parser.Execute(buf, i.structure)
   287  	if err != nil {
   288  		return "", fmt.Errorf("err finding results for path: %v - %v", path, err)
   289  	}
   290  	return buf.String(), nil
   291  }
   292  
   293  func (i *Instance) findValue(path string) (any, error) {
   294  	parser := jsonpath.New("path")
   295  	err := parser.Parse(i.fixPath(path))
   296  	if err != nil {
   297  		return nil, fmt.Errorf("invalid path: %v - %v", path, err)
   298  	}
   299  	values, err := parser.FindResults(i.structure)
   300  	if err != nil {
   301  		return nil, fmt.Errorf("err finding results for path: %v: %v. Structure: %v", path, err, i.structure)
   302  	}
   303  	if len(values) == 0 || len(values[0]) == 0 {
   304  		return nil, fmt.Errorf("no value for path: %v", path)
   305  	}
   306  	return values[0][0].Interface(), nil
   307  }
   308  
   309  // Fixes up some quirks in jsonpath handling.
   310  // See https://github.com/kubernetes/client-go/issues/553
   311  func (i *Instance) fixPath(path string) string {
   312  	// jsonpath doesn't handle numeric comparisons in a tolerant way. All json numbers are floats
   313  	// and filter expressions on the form {.x[?(@.some.value==123]} won't work but
   314  	// {.x[?(@.some.value==123.0]} will.
   315  	result := path
   316  	if i.isJSON {
   317  		template := "$1$2.0)"
   318  		result = fixupNumericJSONComparison.ReplaceAllString(path, template)
   319  	}
   320  	// jsonpath doesn't like map literal references that contain periods. I.e
   321  	// you can't do x['user.map'] but x.user\.map works so we just translate to that
   322  	result = string(fixupAttributeReference.ReplaceAllFunc([]byte(result), func(i []byte) []byte {
   323  		input := string(i)
   324  		input = strings.Replace(input, "[", "", 1)
   325  		input = strings.Replace(input, "]", "", 1)
   326  		input = strings.Replace(input, "'", "", 2)
   327  		parts := strings.Split(input, ".")
   328  		output := "."
   329  		for i := 0; i < len(parts)-1; i++ {
   330  			output += parts[i]
   331  			output += "\\."
   332  		}
   333  		output += parts[len(parts)-1]
   334  		return []byte(output)
   335  	}))
   336  
   337  	return result
   338  }