diff --git a/jsonpath/jsonpath.go b/jsonpath/jsonpath.go index b41e393fb..06c2eca7b 100644 --- a/jsonpath/jsonpath.go +++ b/jsonpath/jsonpath.go @@ -2,130 +2,216 @@ package jsonpath import ( "encoding/json" + "errors" "fmt" "strconv" "strings" ) -// Eval is a half-baked json path implementation that needs some love -func Eval(path string, b []byte) (string, int, error) { - if len(path) == 0 && !(len(b) != 0 && b[0] == '[' && b[len(b)-1] == ']') { - // if there's no path AND the value is not a JSON array, then there's nothing to walk - return string(b), len(b), nil - } - var object interface{} - if err := json.Unmarshal(b, &object); err != nil { - return "", 0, err +// JSONPath represents a parsed JSONPath expression +type JSONPath struct { + tokens []token +} + +// tokenType defines the types of tokens in a JSONPath +type tokenType int + +const ( + tokenDot tokenType = iota // "." + tokenBracketOpen // "[" + tokenBracketClose // "]" + tokenIndex // numeric index like "0" + tokenKey // property name like "name" +) + +// token represents a single component of the JSONPath +type token struct { + Type tokenType + Value string +} + +// NewJSONPath creates a new JSONPath instance from a path string +func NewJSONPath(path string) (*JSONPath, error) { + tokens, err := tokenize(path) + if err != nil { + return nil, err } - return walk(path, object) + return &JSONPath{tokens: tokens}, nil } -// walk traverses the object and returns the value as a string as well as its length -func walk(path string, object interface{}) (string, int, error) { - var keys []string - startOfCurrentKey, bracketDepth := 0, 0 - for i := range path { - if path[i] == '[' { - bracketDepth++ - } else if path[i] == ']' { - bracketDepth-- +// Evaluate processes the JSONPath against JSON data +func (jp *JSONPath) Evaluate(data []byte) (string, int, error) { + var obj any + if err := json.Unmarshal(data, &obj); err != nil { + return "", 0, fmt.Errorf("invalid JSON: %v", err) + } + + if len(jp.tokens) == 0 { + // Empty path returns JSON representation; strings include quotes per test expectation + switch v := obj.(type) { + case string: + b, _ := json.Marshal(v) + return string(b), len(string(b)), nil // Include quotes in length + case float64, int, bool, []any, map[string]any, nil: + return formatValue(obj) } - // If we encounter a dot, we've reached the end of a key unless we're inside a bracket - if path[i] == '.' && bracketDepth == 0 { - keys = append(keys, path[startOfCurrentKey:i]) - startOfCurrentKey = i + 1 + return "", 0, fmt.Errorf("unsupported type: %T", obj) + } + + // If root is primitive and path exists, return primitive value + if _, ok := obj.(map[string]any); !ok { + if _, ok := obj.([]any); !ok { + // If root is primitive and path exists, return the primitive value only if it's the final result + return formatValue(obj) } } - if startOfCurrentKey <= len(path) { - keys = append(keys, path[startOfCurrentKey:]) + + return jp.walk(obj, 0) +} + +// tokenize breaks down a path string into tokens +func tokenize(path string) ([]token, error) { + var tokens []token + if path == "" { + return tokens, nil } - currentKey := keys[0] - switch value := extractValue(currentKey, object).(type) { - case map[string]interface{}: - newPath := strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1) - if path == newPath { - // If the path hasn't changed, it means we're at the end of the path - // So we'll treat it as a string by re-marshaling it to JSON since it's a map. - // Note that the output JSON will be minified. - b, err := json.Marshal(value) - return string(b), len(b), err - } - return walk(newPath, value) - case string: - if len(keys) > 1 { - return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey) + + path = strings.TrimSpace(path) + i := 0 + for i < len(path) { + switch path[i] { + case '.': + tokens = append(tokens, token{Type: tokenDot}) + i++ + case '[': + tokens = append(tokens, token{Type: tokenBracketOpen}) + i++ + start := i + for i < len(path) && path[i] != ']' { + i++ + } + if i >= len(path) { + return nil, errors.New("unclosed bracket in path") + } + index := strings.TrimSpace(path[start:i]) + if num, err := strconv.Atoi(index); err == nil { + tokens = append(tokens, token{Type: tokenIndex, Value: strconv.Itoa(num)}) + } else { + return nil, fmt.Errorf("invalid index: %s", index) + } + tokens = append(tokens, token{Type: tokenBracketClose}) + i++ + default: + start := i + for i < len(path) && path[i] != '.' && path[i] != '[' { + i++ + } + key := strings.TrimSpace(path[start:i]) + if key != "" { + tokens = append(tokens, token{Type: tokenKey, Value: key}) + } } - return value, len(value), nil - case []interface{}: - return fmt.Sprintf("%v", value), len(value), nil - case interface{}: - newValue := fmt.Sprintf("%v", value) - return newValue, len(newValue), nil - default: - return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value) } + return tokens, nil } -func extractValue(currentKey string, value interface{}) interface{} { - // Check if the current key ends with [#] - if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") { - var isNestedArray bool - var index string - startOfBracket, endOfBracket, bracketDepth := 0, 0, 0 - for i := range currentKey { - if currentKey[i] == '[' { - startOfBracket = i - bracketDepth++ - } else if currentKey[i] == ']' && bracketDepth == 1 { - bracketDepth-- - endOfBracket = i - index = currentKey[startOfBracket+1 : i] - if len(currentKey) > i+1 && currentKey[i+1] == '[' { - isNestedArray = true // there's more keys. +// walk recursively traverses the JSON structure +func (jp *JSONPath) walk(value any, tokenIdx int) (string, int, error) { + if tokenIdx >= len(jp.tokens) { + return formatValue(value) + } + + current := jp.tokens[tokenIdx] + + // Special case for root array access (e.g., "[0]"), required by JSONPath standard + if tokenIdx == 0 && current.Type == tokenBracketOpen { + if arr, ok := value.([]any); ok { + if tokenIdx+2 < len(jp.tokens) && jp.tokens[tokenIdx+1].Type == tokenIndex && jp.tokens[tokenIdx+2].Type == tokenBracketClose { + idx, _ := strconv.Atoi(jp.tokens[tokenIdx+1].Value) + if idx >= 0 && idx < len(arr) { + return jp.walk(arr[idx], tokenIdx+3) } - break + return "", 0, fmt.Errorf("index out of bounds: %d", idx) } } - arrayIndex, err := strconv.Atoi(index) - if err != nil { - return nil - } - currentKeyWithoutIndex := currentKey[:startOfBracket] - // if currentKeyWithoutIndex contains only an index (i.e. [0] or 0) - if len(currentKeyWithoutIndex) == 0 { - array, _ := value.([]interface{}) - if len(array) > arrayIndex { - if isNestedArray { - return extractValue(currentKey[endOfBracket+1:], array[arrayIndex]) + } + + switch current.Type { + case tokenKey: + if obj, ok := value.(map[string]any); ok { + if nextVal, exists := obj[current.Value]; exists { + if nextVal == nil { + return "", 0, fmt.Errorf("nil value at key: %s", current.Value) } - return array[arrayIndex] + return jp.walk(nextVal, tokenIdx+1) } - return nil + return "", 0, fmt.Errorf("key not found: %s", current.Value) + } + // If we can't proceed with the current key and there are more tokens, + // it's an invalid path + return "", 0, fmt.Errorf("cannot access key '%s' on non-object type", current.Value) + + case tokenBracketOpen: + if tokenIdx+2 >= len(jp.tokens) || jp.tokens[tokenIdx+2].Type != tokenBracketClose { + return "", 0, errors.New("invalid array syntax") } - if value == nil || value.(map[string]interface{})[currentKeyWithoutIndex] == nil { - return nil + if jp.tokens[tokenIdx+1].Type != tokenIndex { + return "", 0, errors.New("missing array index") } - // if currentKeyWithoutIndex contains both a key and an index (i.e. data[0]) - array, _ := value.(map[string]interface{})[currentKeyWithoutIndex].([]interface{}) - if len(array) > arrayIndex { - if isNestedArray { - return extractValue(currentKey[endOfBracket+1:], array[arrayIndex]) + idx, _ := strconv.Atoi(jp.tokens[tokenIdx+1].Value) + if arr, ok := value.([]any); ok { + if idx >= 0 && idx < len(arr) { + if arr[idx] == nil { + return "", 0, fmt.Errorf("nil value at index: %d", idx) + } + return jp.walk(arr[idx], tokenIdx+3) } - return array[arrayIndex] + return "", 0, fmt.Errorf("index out of bounds: %d", idx) } - return nil + return "", 0, fmt.Errorf("cannot access index on non-array type") + + case tokenDot: + return jp.walk(value, tokenIdx+1) + + default: + return "", 0, fmt.Errorf("unexpected token: %v", current) } - if valueAsSlice, ok := value.([]interface{}); ok { - // If the type is a slice, return it - // This happens when the body (value) is a JSON array - return valueAsSlice +} + +// formatValue converts a value to its string representation +func formatValue(value any) (string, int, error) { + switch v := value.(type) { + case nil: + return "null", 4, nil + case string: + return v, len(v), nil + case float64: + str := strconv.FormatFloat(v, 'f', -1, 64) + return str, len(str), nil + case int: + str := strconv.Itoa(v) + return str, len(str), nil + case bool: + str := fmt.Sprintf("%v", v) + return str, len(str), nil + case []any: + return fmt.Sprintf("%v", v), len(v), nil + case map[string]any: + b, err := json.Marshal(v) + if err != nil { + return "", 0, err + } + return string(b), len(string(b)), nil + default: + return "", 0, fmt.Errorf("unsupported type: %T", v) } - if valueAsMap, ok := value.(map[string]interface{}); ok { - // If the value is a map, then we get the currentKey from that map - // This happens when the body (value) is a JSON object - return valueAsMap[currentKey] +} + +// Eval provides one-shot evaluation of a JSONPath +func Eval(path string, data []byte) (string, int, error) { + jp, err := NewJSONPath(path) + if err != nil { + return "", 0, err } - // If the value is neither a map, nor a slice, nor an index, then we cannot retrieve the currentKey - // from said value. This usually happens when the body (value) is null. - return value + return jp.Evaluate(data) } diff --git a/jsonpath/jsonpath_test.go b/jsonpath/jsonpath_test.go index 83244c5c5..4822e4cc9 100644 --- a/jsonpath/jsonpath_test.go +++ b/jsonpath/jsonpath_test.go @@ -174,7 +174,224 @@ func TestEval(t *testing.T) { ExpectedOutputLength: 18, ExpectedError: false, }, + { + Name: "no-path-non-array", + Path: "", + Data: `{"key": "value"}`, + ExpectedOutput: `{"key":"value"}`, + ExpectedOutputLength: 15, + ExpectedError: false, + }, + { + Name: "nil-value-in-map", + Path: "key", + Data: `{"key": null}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "array-as-final-value", + Path: "data", + Data: `{"data": [1, 2, 3]}`, + ExpectedOutput: "[1 2 3]", + ExpectedOutputLength: 3, + ExpectedError: false, + }, + { + Name: "nested-array-out-of-bounds", + Path: "data[0][1]", + Data: `{"data": [["a"]]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "nil-map-value-before-array", + Path: "data[0]", + Data: `{"data": null}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "boolean-value", + Path: "flag", + Data: `{"flag": true}`, + ExpectedOutput: "true", + ExpectedOutputLength: 4, + ExpectedError: false, + }, + { + Name: "primitive-root-with-invalid-path", + Path: "key", + Data: `"hello"`, + ExpectedOutput: "hello", + ExpectedOutputLength: 5, + ExpectedError: false, + }, + { + Name: "empty-path-with-primitive", + Path: "", + Data: `"hello"`, + ExpectedOutput: `"hello"`, + ExpectedOutputLength: 7, + ExpectedError: false, + }, + { + Name: "empty-path-with-number", + Path: "", + Data: `42`, + ExpectedOutput: "42", + ExpectedOutputLength: 2, + ExpectedError: false, + }, + { + Name: "empty-path-with-boolean", + Path: "", + Data: `true`, + ExpectedOutput: "true", + ExpectedOutputLength: 4, + ExpectedError: false, + }, + { + Name: "malformed-path-unclosed-bracket", + Path: "data[0", + Data: `{"data": [1, 2]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "malformed-path-missing-close-bracket", + Path: "[0", + Data: `[1, 2]`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "invalid-array-syntax-no-index", + Path: "data[]", + Data: `{"data": [1, 2]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "array-negative-index", + Path: "data[-1]", + Data: `{"data": [1, 2, 3]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "empty-array", + Path: "data", + Data: `{"data": []}`, + ExpectedOutput: "[]", + ExpectedOutputLength: 0, + ExpectedError: false, + }, + { + Name: "unexpected-token-bracket-close", + Path: "data].key", + Data: `{"data": {"key": "value"}}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "key-on-array", + Path: "data.key", + Data: `{"data": [1, 2, 3]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "array-access-on-object", + Path: "data[0]", + Data: `{"data": {"key": "value"}}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "nil-value-in-array", + Path: "data[1]", + Data: `{"data": [1, null, 3]}`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "root-array-out-of-bounds-initial", + Path: "[3]", + Data: `[1, 2]`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "standalone-index-token", + Path: "0", + Data: `{"0": "zero"}`, + ExpectedOutput: "zero", + ExpectedOutputLength: 4, + ExpectedError: false, + }, + { + Name: "int-value", + Path: "number", + Data: `{"number": 42}`, + ExpectedOutput: "42", + ExpectedOutputLength: 2, + ExpectedError: false, + }, + { + Name: "nil-as-final-value", + Path: "", + Data: `null`, + ExpectedOutput: "null", + ExpectedOutputLength: 4, + ExpectedError: false, + }, + { + Name: "array-with-nil-element-as-final", + Path: "data", + Data: `{"data": [1, null, 3]}`, + ExpectedOutput: "[1 3]", + ExpectedOutputLength: 3, + ExpectedError: false, + }, + { + Name: "nested-map-as-final", + Path: "data", + Data: `{"data": {"inner": {"key": "value"}}}`, + ExpectedOutput: `{"inner":{"key":"value"}}`, + ExpectedOutputLength: 25, + ExpectedError: false, + }, + { + Name: "root-array-out-of-bounds-redundant", + Path: "[10]", + Data: `[1, 2, 3]`, + ExpectedOutput: "", + ExpectedOutputLength: 0, + ExpectedError: true, + }, + { + Name: "complex-nested-array", + Path: "data[0]", + Data: `{"data": [[1, 2, 3]]}`, + ExpectedOutput: "[1 2 3]", + ExpectedOutputLength: 3, + ExpectedError: false, + }, } + for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { output, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))