Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 184 additions & 98 deletions jsonpath/jsonpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextVal might be a bit confusing since where are getting the current value(even if it is a part of the tree that we are iterating over), I suggest something along the lines of currPathVal.
might be a nit pick but I think it could improve readability.

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)
}
Loading