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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ store:

## JSONPath Plus Extensions

### Backtick member escapes

A single leading backtick selects a literal member name in dot notation or an
unquoted bracket selector. For example:

~~~text
$.schema.`$ref
$.content.`application/json.schema
$..`*
$[`$ref,`*]
~~~

There is no closing backtick: a trailing backtick is part of the key. Path
separators still delimit segments. Use quoted bracket notation for keys that
contain separators or whitespace, and inside filters: `@['$ref']`.

Quoted bracket names retain their RFC 9535 meaning, including literal backticks;
unlike the JavaScript implementation, `$['*']` selects the key `*`, and
`` $['`*'] `` selects the key `` `* ``. `String()` normalizes backtick escapes
to quoted bracket notation. `config.WithStrictRFC9535()` rejects unquoted
backtick escapes.

### Context Variables

Context variables provide information about the current evaluation context within filter expressions. They are prefixed with `@` and can be used in comparisons.
Expand Down
109 changes: 109 additions & 0 deletions pkg/jsonpath/backtick_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package jsonpath

import (
"testing"

"github.com/pb33f/go-yaml"
"github.com/pb33f/jsonpath/pkg/jsonpath/config"
"github.com/pb33f/testify/require"
)

func TestBacktickMemberSelection(t *testing.T) {
var root yaml.Node
require.NoError(t, yaml.Unmarshal([]byte("{\"$ref\":\"target\",\"$ref`\":\"trailing\",\"*\":\"star\",\"\":\"empty\",\"`*\":\"literal\",\"application/json\":{\"$ref\":\"nested\"},\"@number()\":\"type\",\"true\":\"boolean-name\",\"123\":\"numeric-name\",\"日本語\":\"unicode\",\"a-b\":\"hyphen\"}"), &root))
for _, tc := range []struct {
name, path, normalized string
values []string
}{
{"ref", "$.`$ref", "$['$ref']", []string{"target"}},
{"trailing backtick is literal", "$.`$ref`", "$['$ref`']", []string{"trailing"}},
{"wildcard is literal", "$.`*", "$['*']", []string{"star"}},
{"empty key", "$.`", "$['']", []string{"empty"}},
{"escaped backtick", "$.``*", "$['`*']", []string{"literal"}},
{"nested media type", "$.`application/json.`$ref", "$['application/json']['$ref']", []string{"nested"}},
{"recursive", "$..`$ref", "$..['$ref']", []string{"target", "nested"}},
{"bracket union", "$[`$ref,`*]", "$['$ref', '*']", []string{"target", "star"}},
{"mixed union", "$[`$ref,'*']", "$['$ref', '*']", []string{"target", "star"}},
{"type selector is literal", "$.`@number()", "$['@number()']", []string{"type"}},
{"boolean name", "$.`true", "$['true']", []string{"boolean-name"}},
{"numeric name", "$.`123", "$['123']", []string{"numeric-name"}},
{"unicode", "$.`日本語", "$['日本語']", []string{"unicode"}},
{"hyphen", "$.`a-b", "$['a-b']", []string{"hyphen"}},
{"missing", "$.`absent", "$['absent']", nil},
{"quoted backtick stays literal", "$['`*']", "$['`*']", []string{"literal"}},
} {
t.Run(tc.name, func(t *testing.T) {
for _, options := range [][]config.Option{nil, {config.WithSpectralCompatibility(), config.WithLazyContextTracking()}} {
path, err := NewPath(tc.path, options...)
require.NoError(t, err)
require.Equal(t, tc.normalized, path.String())
var values []string
for _, node := range path.Query(&root) {
values = append(values, node.Value)
}
require.Equal(t, tc.values, values)
normalized, err := NewPath(path.String(), config.WithStrictRFC9535())
require.NoError(t, err)
require.Equal(t, path.Query(&root), normalized.Query(&root))
}
})
}
}

func TestBacktickStrictAndInvalidContexts(t *testing.T) {
for _, expression := range []string{"$.`$ref", "$..`*", "$[`$ref]"} {
_, err := NewPath(expression, config.WithStrictRFC9535())
require.ErrorContains(t, err, "backtick member escapes require JSONPath Plus mode")
}
for _, expression := range []string{"`$ref", "$`$ref", "$.name`other", "$[?@.`$ref]", "$[`$ref", "$.`name]"} {
t.Run(expression, func(t *testing.T) {
_, err := NewPath(expression)
require.Error(t, err)
})
}
// Quoted keys remain RFC-compatible even when the key contains a backtick.
_, err := NewPath("$['`*']", config.WithStrictRFC9535())
require.NoError(t, err)
}

func TestBacktickNavigationAndMetadata(t *testing.T) {
var root yaml.Node
require.NoError(t, yaml.Unmarshal([]byte(`{"$ref":[{"name":"first"},{"name":"second"}]}`), &root))
path, err := NewPath("$.`$ref[1].name")
require.NoError(t, err)
require.True(t, path.IsSingular())
segments, err := path.GetSegmentInfo()
require.NoError(t, err)
require.Len(t, segments, 3)
require.Equal(t, "$ref", segments[0].Key)
require.Equal(t, int64(1), segments[1].Index)
require.Equal(t, "name", segments[2].Key)
nodes := path.Query(&root)
require.Len(t, nodes, 1)
require.Equal(t, "second", nodes[0].Value)

path, err = NewPath("$.`$ref~", config.WithPropertyNameExtension())
require.NoError(t, err)
nodes = path.Query(&root)
require.Len(t, nodes, 1)
require.Equal(t, "$ref", nodes[0].Value)

path, err = NewPath("$.`$ref^")
require.NoError(t, err)
nodes = path.Query(&root)
require.Len(t, nodes, 1)
require.Equal(t, root.Content[0], nodes[0])
}

func BenchmarkBacktickCompile(b *testing.B) {
for _, expression := range []string{"$.paths.*.*.requestBody.content['application/json'].schema['$ref']", "$.paths.*.*.requestBody.content.`application/json.schema.`$ref"} {
b.Run(expression, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewPath(expression); err != nil {
b.Fatal(err)
}
}
})
}
}
7 changes: 5 additions & 2 deletions pkg/jsonpath/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) {
return nil, p.parseFailure(nil, "unexpected end of input")
}
firstToken := p.tokens[p.current]
if firstToken.Token == token.WILDCARD {
if firstToken.Token == token.ESCAPED_NAME {
p.current++
return &innerSegment{kind: segmentLongHand, selectors: []*selector{{kind: selectorSubKindName, name: firstToken.Literal}}}, nil
} else if firstToken.Token == token.WILDCARD {
p.current += 1
return &innerSegment{segmentDotWildcard, "", nil}, nil
} else if firstToken.Token == token.STRING {
Expand Down Expand Up @@ -219,7 +222,7 @@ func (p *JSONPath) parseSelector() (retSelector *selector, err error) {
}()

// name-selector = string-literal
if p.tokens[p.current].Token == token.STRING_LITERAL {
if p.tokens[p.current].Token == token.STRING_LITERAL || p.tokens[p.current].Token == token.ESCAPED_NAME {
name := p.tokens[p.current].Literal
p.current++
return &selector{kind: selectorSubKindName, name: name}, nil
Expand Down
40 changes: 40 additions & 0 deletions pkg/jsonpath/token/backtick_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package token

import (
"testing"

"github.com/pb33f/jsonpath/pkg/jsonpath/config"
"github.com/pb33f/testify/require"
)

func TestBacktickTokens(t *testing.T) {
for _, tc := range []struct {
expression string
tokens []TokenInfo
}{
{"$.`$ref.name", []TokenInfo{
{Token: ROOT, Line: 1, Column: 0, Len: 1},
{Token: CHILD, Line: 1, Column: 1, Len: 1},
{Token: ESCAPED_NAME, Line: 1, Column: 2, Len: 5, Literal: "$ref"},
{Token: CHILD, Line: 1, Column: 7, Len: 1},
{Token: STRING, Line: 1, Column: 8, Len: 4, Literal: "name"},
}},
{"$[`*,`$ref]", []TokenInfo{
{Token: ROOT, Line: 1, Column: 0, Len: 1},
{Token: BRACKET_LEFT, Line: 1, Column: 1, Len: 1},
{Token: ESCAPED_NAME, Line: 1, Column: 2, Len: 2, Literal: "*"},
{Token: COMMA, Line: 1, Column: 4, Len: 1},
{Token: ESCAPED_NAME, Line: 1, Column: 5, Len: 5, Literal: "$ref"},
{Token: BRACKET_RIGHT, Line: 1, Column: 10, Len: 1},
}},
} {
t.Run(tc.expression, func(t *testing.T) {
tokens := NewTokenizer(tc.expression).Tokenize()
require.Equal(t, tc.tokens, []TokenInfo(tokens))
require.False(t, tokens.IsSimple())
})
}
require.Equal(t, "ESCAPED_NAME", ESCAPED_NAME.String())
tokens := NewTokenizer("$.`$ref", config.WithStrictRFC9535()).Tokenize()
require.Equal(t, ILLEGAL, tokens[2].Token)
}
28 changes: 28 additions & 0 deletions pkg/jsonpath/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ const (
REGEX
STRICT_EQ
STRICT_NE

// ESCAPED_NAME is a JSONPath Plus member name prefixed with a backtick.
ESCAPED_NAME
)

var SimpleTokens = [...]Token{
Expand All @@ -225,6 +228,7 @@ var SimpleTokens = [...]Token{
}

var tokens = [...]string{
ESCAPED_NAME: "ESCAPED_NAME",
ILLEGAL: "ILLEGAL",
STRING: "STRING",
INTEGER: "INTEGER",
Expand Down Expand Up @@ -459,6 +463,12 @@ func (t *Tokenizer) Tokenize() Tokens {
}

switch ch := t.input[t.pos]; {
case ch == '`':
if t.config.JSONPathPlusEnabled() && !t.isInFilterContext() {
t.scanEscapedName()
} else {
t.addToken(ILLEGAL, 1, "backtick member escapes require JSONPath Plus mode outside filters; use quoted bracket notation")
}
case ch == '$':
if config.SpectralCompatibilityEnabled(t.config) && t.spectralDollarMemberCanStartHere() {
t.addToken(STRING, 4, "$ref")
Expand Down Expand Up @@ -667,6 +677,24 @@ func (t *Tokenizer) Tokenize() Tokens {
return t.tokens
}

// scanEscapedName consumes one prefix backtick and the following member name.
// Path separators still delimit segments; a second backtick is literal, not a
// closing quote. Quoted bracket names use scanString and remain unchanged.
func (t *Tokenizer) scanEscapedName() {
start := t.pos
end := start + 1
for end < len(t.input) {
ch := t.input[end]
if ch == '.' || ch == '[' || ch == ']' || ch == ',' || ch == '^' || ch == '~' || ch == '\'' || ch == '"' || ch == '\n' || isSpace(ch) {
break
}
end++
}
t.addToken(ESCAPED_NAME, end-start, t.input[start+1:end])
t.pos = end - 1
t.column += end - start - 1
}

func (t *Tokenizer) spectralDollarMemberCanStartHere() bool {
if len(t.tokens) == 0 || t.tokens[len(t.tokens)-1].Token != CHILD || !strings.HasPrefix(t.input[t.pos:], "$ref") {
return false
Expand Down
Loading