From d2061bc5a4dc33e7dc4906d0d5555c6398abcee8 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Mon, 26 Feb 2024 14:35:12 -0300 Subject: [PATCH] feat: support `contains` to compare json objects (#3687) * feat: support `contains` to compare json objects * add test for json in span attribute * add suggested tests by Daniel * add doc * add test with deep comparison with arrays * Update docs/docs/concepts/expressions.mdx Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- docs/docs/concepts/expressions.mdx | 24 +++++ server/assertions/comparator/basic.go | 100 ++++++++++++++++++++ server/assertions/comparator/comparators.go | 5 +- server/expression/comparison.go | 14 +-- server/expression/executor_test.go | 68 +++++++++++++ server/expression/types/types.go | 16 +++- server/traces/span_entitiess.go | 4 +- 7 files changed, 221 insertions(+), 10 deletions(-) diff --git a/docs/docs/concepts/expressions.mdx b/docs/docs/concepts/expressions.mdx index 27e0f21c20..bc971d9177 100644 --- a/docs/docs/concepts/expressions.mdx +++ b/docs/docs/concepts/expressions.mdx @@ -21,6 +21,7 @@ Tracetest allows you to add expressions when writing your tests. They are a nice * Arithmetic operations * String interpolation * Filters +* JSON comparison ### Reference Span Attributes @@ -139,3 +140,26 @@ attr:myapp.operations | get_index 2 | type = "string" # check if attribute is either a number of a string ["number", "string"] contains attr:my_attribute | type ``` + +### JSON Comparison + +When working with APIs, it's very common for developers to check if the response contains data while ignoring all the noise a response can have. For this, you can use the `contains` comparator in JSON objects. It works just like [Jest's toMatchObject](https://jestjs.io/pt-BR/docs/expect#tomatchobjectobject). + +The order of the attributes doesn't matter and the left side of the expression can contain more attributes than the right side. + +Some examples: + +```css +# If user is 32 years old, it passes +'{"name": "john", "age": 32}' contains '{"age": 32}' +``` + +```css +# If any of the users is called "maria", it passes +'[{"name": "john", "age": 32}, {"name": "maria", "age": 63}]' contains '{"name": "maria"}' +``` + +```css +# In this case, both ages must be part of the JSON array +'[{"name": "john", "age": 32}, {"name": "maria", "age": 63}]' contains '[{"age": 63}, {"age": 32}]' +``` diff --git a/server/assertions/comparator/basic.go b/server/assertions/comparator/basic.go index de11530a28..194a3e0114 100644 --- a/server/assertions/comparator/basic.go +++ b/server/assertions/comparator/basic.go @@ -1,6 +1,7 @@ package comparator import ( + "encoding/json" "fmt" "strconv" "strings" @@ -18,6 +19,7 @@ var ( NotContains, StartsWith, EndsWith, + JsonContains, } ) @@ -228,3 +230,101 @@ func (c endsWith) Compare(expected, actual string) error { func (c endsWith) String() string { return "endsWith" } + +// JsonContains +var JsonContains Comparator = jsonContains{} + +type jsonContains struct{} + +func (c jsonContains) Compare(right, left string) error { + supersetMap, err := c.parseJson(left) + if err != nil { + return fmt.Errorf("left side is not a JSON object") + } + + subsetMap, err := c.parseJson(right) + if err != nil { + return fmt.Errorf("left side is not a JSON object") + } + + return c.compare(supersetMap, subsetMap) +} + +func (c jsonContains) parseJson(input string) ([]map[string]any, error) { + if strings.HasPrefix(input, "[") && strings.HasSuffix(input, "]") { + output := []map[string]any{} + err := json.Unmarshal([]byte(input), &output) + if err != nil { + return []map[string]any{}, fmt.Errorf("invalid JSON array") + } + + return output, nil + } + + object := map[string]any{} + err := json.Unmarshal([]byte(input), &object) + if err != nil { + return []map[string]any{}, fmt.Errorf("invalid JSON object") + } + + return []map[string]any{object}, nil +} + +func (c jsonContains) compare(left, right []map[string]any) error { + for i, expected := range right { + err := c.anyMatches(left, expected) + if err != nil { + return fmt.Errorf("left side array doesn't match item %d of right side: %w", i, ErrNoMatch) + } + } + + return nil +} + +func (c jsonContains) anyMatches(array []map[string]any, expected map[string]any) error { + for _, left := range array { + err := c.compareObjects(left, expected) + if err == nil { + return nil + } + } + + return ErrNoMatch +} + +func (c jsonContains) compareObjects(left, right map[string]any) error { + for key, value := range right { + leftValue, ok := left[key] + if !ok { + return fmt.Errorf(`field "%s" not found on left side: %w`, key, ErrNoMatch) + } + + if leftValueMap, ok := leftValue.(map[string]any); ok { + rightValueMap, ok := value.(map[string]any) + if !ok { + return fmt.Errorf(`field: "%s": %w`, key, ErrWrongType) + } + + err := c.compareObjects(leftValueMap, rightValueMap) + if err != nil { + return fmt.Errorf(`field "%s": %w`, key, err) + } + + return nil + } + + // This minimizes the change of a panic due to comparing two []interface{} types + leftValueString, _ := json.Marshal(leftValue) + rightValueString, _ := json.Marshal(value) + + if string(leftValueString) != string(rightValueString) { + return fmt.Errorf(`field "%s": %w`, key, ErrNoMatch) + } + } + + return nil +} + +func (c jsonContains) String() string { + return "json-contains" +} diff --git a/server/assertions/comparator/comparators.go b/server/assertions/comparator/comparators.go index 658fd53d8b..2118a0ef03 100644 --- a/server/assertions/comparator/comparators.go +++ b/server/assertions/comparator/comparators.go @@ -8,8 +8,9 @@ type Comparator interface { } var ( - ErrNoMatch = fmt.Errorf("no match") - ErrNotFound = fmt.Errorf("not found") + ErrNoMatch = fmt.Errorf("no match") + ErrNotFound = fmt.Errorf("not found") + ErrWrongType = fmt.Errorf("wrong type") ) type Registry interface { diff --git a/server/expression/comparison.go b/server/expression/comparison.go index 03254c5793..859f886df8 100644 --- a/server/expression/comparison.go +++ b/server/expression/comparison.go @@ -4,10 +4,17 @@ import ( "fmt" "github.com/kubeshop/tracetest/server/assertions/comparator" + "github.com/kubeshop/tracetest/server/expression/types" "github.com/kubeshop/tracetest/server/expression/value" ) func compare(comparatorName string, leftValue, rightValue value.Value) error { + if rightValue.Value().Type == types.TypeJson && comparatorName == "contains" { + if leftValue.Value().Type == types.TypeJson || leftValue.Value().Type == types.TypeArray { + comparatorName = "json-contains" + } + } + comparatorFunction, err := comparator.DefaultRegistry().Get(comparatorName) if err != nil { return fmt.Errorf("comparator not supported: %w", err) @@ -17,12 +24,7 @@ func compare(comparatorName string, leftValue, rightValue value.Value) error { return compareArrayContains(leftValue, rightValue) } - err = comparatorFunction.Compare(rightValue.String(), leftValue.String()) - if err == comparator.ErrNoMatch { - return ErrNoMatch - } - - return nil + return comparatorFunction.Compare(rightValue.String(), leftValue.String()) } func compareArrayContains(array, expected value.Value) error { diff --git a/server/expression/executor_test.go b/server/expression/executor_test.go index 6480f46072..a52ff2d0f7 100644 --- a/server/expression/executor_test.go +++ b/server/expression/executor_test.go @@ -373,6 +373,74 @@ func TestArrayExecution(t *testing.T) { Query: `[31,35,39] contains 42`, ShouldPass: false, }, + { + Name: "should_identify_array_instead_of_json", + Query: `["{}", "{}", "{}"] | type = "array"`, + ShouldPass: true, + }, + } + + executeTestCases(t, testCases) +} + +func TestJSONExecution(t *testing.T) { + testCases := []executorTestCase{ + { + Name: "should_identify_json_input", + Query: `'{"name": "john", "age": 32, "email": "john@company.com"}' | type = "json"`, + ShouldPass: true, + }, + { + Name: "should_be_able_to_compare_with_subset", + Query: `'{"name": "john", "age": 32, "email": "john@company.com"}' contains '{"email": "john@company.com", "name": "john"}'`, + ShouldPass: true, + }, + { + Name: "should_be_able_to_compare_with_subset_ignoring_order", + Query: `'{"name": "john", "age": 32, "email": "john@company.com"}' contains '{"email": "john@company.com", "name": "john", "age": 32}'`, + ShouldPass: true, + }, + { + Name: "should_be_able_to_compare_deep_objects_in_subset", + Query: `'{"name": "john", "age": 32, "email": "john@company.com", "company": {"name": "Company", "address": "1234 Agora Street"}}' contains '{"email": "john@company.com", "name": "john", "company": {"name": "Company"}}'`, + ShouldPass: true, + }, + { + Name: "should_be_able_to_compare_array_of_json_objects", + Query: `'[{"name": "john", "age": 32}, {"name": "Maria", "age": 63}]' contains '[{"age": 63}, {"age": 32}]'`, + ShouldPass: true, + }, + { + Name: "should_match_complete_arrays", + Query: `'{"numbers": [0,1,2,3,4]}' contains '{"numbers": [0,1,2,3,4]}'`, + ShouldPass: true, + }, + { + Name: "should_fail_when_array_doesnt_match_size_and_order", + Query: `'{"numbers": [0,1,2,3,4]}' contains '{"numbers": [0,1]}'`, + ShouldPass: false, + }, + { + Name: "should_fail_when_array_contains_same_elements_but_different_types", + Query: `'{"numbers": [0,1,2,3,4]}' contains '{"numbers": [0,1,"2","3",4]}'`, + ShouldPass: false, + }, + { + Name: "should_be_able_to_compare_deep_objects_with_arrays_in_subset", + Query: `'{"name": "john", "age": 32, "email": "john@company.com", "company": {"name": "Company", "address": "1234 Agora Street", "telephones": ["01", "02", "03"]}}' contains '{"email": "john@company.com", "name": "john", "company": {"name": "Company", "telephones": ["01", "02", "03"]}}'`, + ShouldPass: true, + }, + { + Name: "should_identify_json_input_from_attribute", + Query: `attr:tracetest.response.body contains '{"name": "john"}'`, + ShouldPass: true, + AttributeDataStore: expression.AttributeDataStore{ + Span: traces.Span{ + ID: id.NewRandGenerator().SpanID(), + Attributes: traces.NewAttributes().Set("tracetest.response.body", `{"name": "john", "age": 32, "email": "john@company.com"}`), + }, + }, + }, } executeTestCases(t, testCases) diff --git a/server/expression/types/types.go b/server/expression/types/types.go index 377858f48f..e9c4c00d6c 100644 --- a/server/expression/types/types.go +++ b/server/expression/types/types.go @@ -1,6 +1,9 @@ package types -import "regexp" +import ( + "encoding/json" + "regexp" +) type Type uint @@ -12,11 +15,13 @@ const ( TypeDuration TypeVariable TypeArray + TypeJson ) var typeNames = map[Type]string{ TypeNil: "nil", TypeString: "string", + TypeJson: "json", TypeNumber: "number", TypeAttribute: "attribute", TypeDuration: "duration", @@ -37,7 +42,16 @@ func GetType(value string) Type { return TypeDuration } + if err := json.Unmarshal([]byte(value), &map[string]any{}); err == nil { + return TypeJson + } + if arrayRegex.Match([]byte(value)) { + var jsonArray = []map[string]any{} + if err := json.Unmarshal([]byte(value), &jsonArray); err == nil { + return TypeJson + } + return TypeArray } diff --git a/server/traces/span_entitiess.go b/server/traces/span_entitiess.go index 7163d70703..9d86c4e832 100644 --- a/server/traces/span_entitiess.go +++ b/server/traces/span_entitiess.go @@ -108,10 +108,12 @@ func (a Attributes) Get(key string) string { return v } -func (a Attributes) Set(key, value string) { +func (a Attributes) Set(key, value string) Attributes { a.lock() defer a.unlock() a.values[key] = value + + return a } func (a Attributes) Delete(key string) {