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 }