Skip to content
Merged
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
156 changes: 156 additions & 0 deletions pkg/jsonpath/jsonpath_plus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1347,3 +1347,159 @@ func contains(s, substr string) bool {
}
return false
}

func TestUnquotedBracketNotation(t *testing.T) {
tests := []struct {
name string
yaml string
path string
expected int
}{
{
name: "select GET operations with unquoted bracket",
yaml: `
paths:
/users:
get:
operationId: "getUsers"
post:
operationId: "createUser"
/items:
get:
operationId: "getItems"
delete:
operationId: "deleteItems"
`,
path: `$.paths[*][get]`,
expected: 2,
},
{
name: "union of unquoted bracket selectors",
yaml: `
paths:
/users:
get:
operationId: "getUsers"
post:
operationId: "createUser"
delete:
operationId: "deleteUsers"
/items:
get:
operationId: "getItems"
put:
operationId: "updateItems"
`,
path: `$.paths[*][get,post]`,
expected: 3,
},
{
name: "media type selector",
yaml: `
paths:
/users:
post:
requestBody:
content:
application/vnd.api+json:
schema:
type: object
application/json:
schema:
type: object
`,
path: `$.paths..content[application/vnd.api+json].schema`,
expected: 1,
},
{
name: "mixed name and integer selectors on mapping",
yaml: `
responses:
default:
description: "Default error"
"200":
description: "Success"
"400":
description: "Bad request"
"500":
description: "Server error"
`,
path: `$.responses[default,400,500]`,
expected: 3,
},
{
name: "integer index on mapping node (200 status code)",
yaml: `
responses:
"200":
description: "Success"
"404":
description: "Not Found"
`,
path: `$.responses[200]`,
expected: 1,
},
{
name: "array index still works as array index",
yaml: `
items:
- name: "first"
- name: "second"
- name: "third"
`,
path: `$.items[0]`,
expected: 1,
},
{
name: "nested brackets in filter stay as integers",
yaml: `
data:
- items:
- value: 5
- value: 10
- items:
- value: 15
`,
path: `$.data[?(@.items[0].value > 4)]`,
expected: 2,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(tt.yaml), &node)
assert.NoError(t, err)

path, err := NewPath(tt.path)
assert.NoError(t, err, "failed to parse path: %s", tt.path)

results := path.Query(&node)
assert.Len(t, results, tt.expected, "expected %d results, got %d for path %s", tt.expected, len(results), tt.path)
})
}
}

func TestUnquotedBracketStrictModeRejection(t *testing.T) {
// In strict RFC 9535 mode, unquoted brackets should not produce STRING_LITERAL
// Instead they produce STRING (from scanLiteral) which the parser treats differently
yamlData := `
paths:
/users:
get:
operationId: "getUsers"
`
var node yaml.Node
err := yaml.Unmarshal([]byte(yamlData), &node)
assert.NoError(t, err)

// In strict mode, $[get] should still parse but 'get' is a STRING not STRING_LITERAL
// The parser may or may not accept this, but the behavior differs from JSONPath Plus
path, parseErr := NewPath(`$.paths['/users'][get]`, config.WithStrictRFC9535())
if parseErr == nil {
results := path.Query(&node)
// In strict mode without unquoted bracket support, this may not find results
_ = results
}
// We just verify it doesn't panic
}
28 changes: 19 additions & 9 deletions pkg/jsonpath/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,19 @@ var contextVarTokenMap = map[token.Token]contextVarKind{

// JSONPath represents a JSONPath parser.
type JSONPath struct {
tokenizer *token.Tokenizer
tokens []token.TokenInfo
ast jsonPathAST
current int
mode []mode
config config.Config
tokenizer *token.Tokenizer
tokens []token.TokenInfo
ast jsonPathAST
current int
mode []mode
config config.Config
filterDepth int // tracks nesting depth inside filter expressions
}

// newParserPrivate creates a new JSONPath with the given tokens.
func newParserPrivate(tokenizer *token.Tokenizer, tokens []token.TokenInfo, opts ...config.Option) *JSONPath {
cfg := config.New(opts...)
return &JSONPath{tokenizer, tokens, jsonPathAST{lazyContextTracking: cfg.LazyContextTrackingEnabled()}, 0, []mode{modeNormal}, cfg}
return &JSONPath{tokenizer, tokens, jsonPathAST{lazyContextTracking: cfg.LazyContextTrackingEnabled(), jsonPathPlus: cfg.JSONPathPlusEnabled()}, 0, []mode{modeNormal}, cfg, 0}
}

// parse parses the JSONPath tokens and returns the root node of the AST.
Expand Down Expand Up @@ -155,6 +156,12 @@ func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) {
dotName := p.tokens[p.current].Literal
p.current += 1
return &innerSegment{segmentDotMemberName, dotName, nil}, nil
} else if firstToken.Token == token.INTEGER && p.config.JSONPathPlusEnabled() && p.current >= 3 {
// JSONPath Plus: treat .201 as a member name (common for HTTP status codes in OpenAPI).
// Only when we're past the root (p.current >= 3 means at least $, ., and something before this).
dotName := p.tokens[p.current].Literal
p.current += 1
return &innerSegment{segmentDotMemberName, dotName, nil}, nil
} else if firstToken.Token == token.BRACKET_LEFT {
prior := p.current
p.current += 1
Expand Down Expand Up @@ -244,7 +251,7 @@ func (p *JSONPath) parseSelector() (retSelector *selector, err error) {

p.current++

return &selector{kind: selectorSubKindArrayIndex, index: i}, nil
return &selector{kind: selectorSubKindArrayIndex, index: i, jsonPathPlus: p.config.JSONPathPlusEnabled() && p.filterDepth == 0}, nil
} else if p.tokens[p.current].Token == token.ARRAY_SLICE {
slice, err := p.parseSliceSelector()
if err != nil {
Expand Down Expand Up @@ -341,6 +348,8 @@ func (p *JSONPath) parseFilterSelector() (*selector, error) {
return nil, p.parseFailure(&p.tokens[p.current], "expected '?'")
}
p.current++
p.filterDepth++
defer func() { p.filterDepth-- }()

expr, err := p.parseLogicalOrExpr()
if err != nil {
Expand Down Expand Up @@ -783,8 +792,9 @@ func (p *JSONPath) parseLiteral() (*literal, error) {

type jsonPathAST struct {
// "$"
segments []*segment
segments []*segment
lazyContextTracking bool
jsonPathPlus bool // JSONPath Plus extensions enabled (unquoted brackets, mapping index fallback)
}

func (q jsonPathAST) ToString() string {
Expand Down
11 changes: 6 additions & 5 deletions pkg/jsonpath/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ type slice struct {
}

type selector struct {
kind selectorSubKind
name string
index int64
slice *slice
filter *filterSelector
kind selectorSubKind
name string
index int64
slice *slice
filter *filterSelector
jsonPathPlus bool // when true, enables MappingNode fallback for array index selectors
}

func (s selector) ToString() string {
Expand Down
Loading
Loading