Skip to content

Commit dbbb19d

Browse files
committed
Added more JSON Path Plus support for spectral specifics.
making sure this works with phil’s spectral queries.
1 parent ab33918 commit dbbb19d

File tree

6 files changed

+392
-64
lines changed

6 files changed

+392
-64
lines changed

pkg/jsonpath/jsonpath_plus_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,3 +1347,159 @@ func contains(s, substr string) bool {
13471347
}
13481348
return false
13491349
}
1350+
1351+
func TestUnquotedBracketNotation(t *testing.T) {
1352+
tests := []struct {
1353+
name string
1354+
yaml string
1355+
path string
1356+
expected int
1357+
}{
1358+
{
1359+
name: "select GET operations with unquoted bracket",
1360+
yaml: `
1361+
paths:
1362+
/users:
1363+
get:
1364+
operationId: "getUsers"
1365+
post:
1366+
operationId: "createUser"
1367+
/items:
1368+
get:
1369+
operationId: "getItems"
1370+
delete:
1371+
operationId: "deleteItems"
1372+
`,
1373+
path: `$.paths[*][get]`,
1374+
expected: 2,
1375+
},
1376+
{
1377+
name: "union of unquoted bracket selectors",
1378+
yaml: `
1379+
paths:
1380+
/users:
1381+
get:
1382+
operationId: "getUsers"
1383+
post:
1384+
operationId: "createUser"
1385+
delete:
1386+
operationId: "deleteUsers"
1387+
/items:
1388+
get:
1389+
operationId: "getItems"
1390+
put:
1391+
operationId: "updateItems"
1392+
`,
1393+
path: `$.paths[*][get,post]`,
1394+
expected: 3,
1395+
},
1396+
{
1397+
name: "media type selector",
1398+
yaml: `
1399+
paths:
1400+
/users:
1401+
post:
1402+
requestBody:
1403+
content:
1404+
application/vnd.api+json:
1405+
schema:
1406+
type: object
1407+
application/json:
1408+
schema:
1409+
type: object
1410+
`,
1411+
path: `$.paths..content[application/vnd.api+json].schema`,
1412+
expected: 1,
1413+
},
1414+
{
1415+
name: "mixed name and integer selectors on mapping",
1416+
yaml: `
1417+
responses:
1418+
default:
1419+
description: "Default error"
1420+
"200":
1421+
description: "Success"
1422+
"400":
1423+
description: "Bad request"
1424+
"500":
1425+
description: "Server error"
1426+
`,
1427+
path: `$.responses[default,400,500]`,
1428+
expected: 3,
1429+
},
1430+
{
1431+
name: "integer index on mapping node (200 status code)",
1432+
yaml: `
1433+
responses:
1434+
"200":
1435+
description: "Success"
1436+
"404":
1437+
description: "Not Found"
1438+
`,
1439+
path: `$.responses[200]`,
1440+
expected: 1,
1441+
},
1442+
{
1443+
name: "array index still works as array index",
1444+
yaml: `
1445+
items:
1446+
- name: "first"
1447+
- name: "second"
1448+
- name: "third"
1449+
`,
1450+
path: `$.items[0]`,
1451+
expected: 1,
1452+
},
1453+
{
1454+
name: "nested brackets in filter stay as integers",
1455+
yaml: `
1456+
data:
1457+
- items:
1458+
- value: 5
1459+
- value: 10
1460+
- items:
1461+
- value: 15
1462+
`,
1463+
path: `$.data[?(@.items[0].value > 4)]`,
1464+
expected: 2,
1465+
},
1466+
}
1467+
1468+
for _, tt := range tests {
1469+
t.Run(tt.name, func(t *testing.T) {
1470+
var node yaml.Node
1471+
err := yaml.Unmarshal([]byte(tt.yaml), &node)
1472+
assert.NoError(t, err)
1473+
1474+
path, err := NewPath(tt.path)
1475+
assert.NoError(t, err, "failed to parse path: %s", tt.path)
1476+
1477+
results := path.Query(&node)
1478+
assert.Len(t, results, tt.expected, "expected %d results, got %d for path %s", tt.expected, len(results), tt.path)
1479+
})
1480+
}
1481+
}
1482+
1483+
func TestUnquotedBracketStrictModeRejection(t *testing.T) {
1484+
// In strict RFC 9535 mode, unquoted brackets should not produce STRING_LITERAL
1485+
// Instead they produce STRING (from scanLiteral) which the parser treats differently
1486+
yamlData := `
1487+
paths:
1488+
/users:
1489+
get:
1490+
operationId: "getUsers"
1491+
`
1492+
var node yaml.Node
1493+
err := yaml.Unmarshal([]byte(yamlData), &node)
1494+
assert.NoError(t, err)
1495+
1496+
// In strict mode, $[get] should still parse but 'get' is a STRING not STRING_LITERAL
1497+
// The parser may or may not accept this, but the behavior differs from JSONPath Plus
1498+
path, parseErr := NewPath(`$.paths['/users'][get]`, config.WithStrictRFC9535())
1499+
if parseErr == nil {
1500+
results := path.Query(&node)
1501+
// In strict mode without unquoted bracket support, this may not find results
1502+
_ = results
1503+
}
1504+
// We just verify it doesn't panic
1505+
}

pkg/jsonpath/parser.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,19 @@ var contextVarTokenMap = map[token.Token]contextVarKind{
3030

3131
// JSONPath represents a JSONPath parser.
3232
type JSONPath struct {
33-
tokenizer *token.Tokenizer
34-
tokens []token.TokenInfo
35-
ast jsonPathAST
36-
current int
37-
mode []mode
38-
config config.Config
33+
tokenizer *token.Tokenizer
34+
tokens []token.TokenInfo
35+
ast jsonPathAST
36+
current int
37+
mode []mode
38+
config config.Config
39+
filterDepth int // tracks nesting depth inside filter expressions
3940
}
4041

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

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

245252
p.current++
246253

247-
return &selector{kind: selectorSubKindArrayIndex, index: i}, nil
254+
return &selector{kind: selectorSubKindArrayIndex, index: i, jsonPathPlus: p.config.JSONPathPlusEnabled() && p.filterDepth == 0}, nil
248255
} else if p.tokens[p.current].Token == token.ARRAY_SLICE {
249256
slice, err := p.parseSliceSelector()
250257
if err != nil {
@@ -341,6 +348,8 @@ func (p *JSONPath) parseFilterSelector() (*selector, error) {
341348
return nil, p.parseFailure(&p.tokens[p.current], "expected '?'")
342349
}
343350
p.current++
351+
p.filterDepth++
352+
defer func() { p.filterDepth-- }()
344353

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

784793
type jsonPathAST struct {
785794
// "$"
786-
segments []*segment
795+
segments []*segment
787796
lazyContextTracking bool
797+
jsonPathPlus bool // JSONPath Plus extensions enabled (unquoted brackets, mapping index fallback)
788798
}
789799

790800
func (q jsonPathAST) ToString() string {

pkg/jsonpath/selector.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ type slice struct {
2323
}
2424

2525
type selector struct {
26-
kind selectorSubKind
27-
name string
28-
index int64
29-
slice *slice
30-
filter *filterSelector
26+
kind selectorSubKind
27+
name string
28+
index int64
29+
slice *slice
30+
filter *filterSelector
31+
jsonPathPlus bool // when true, enables MappingNode fallback for array index selectors
3132
}
3233

3334
func (s selector) ToString() string {

0 commit comments

Comments
 (0)