Skip to content

Commit 0df1814

Browse files
authored
feat: property name extension, json path explorer (#13)
* chore: implement a property name extension * chore: make it great * chore: wip
1 parent 0f29914 commit 0df1814

File tree

19 files changed

+741
-126
lines changed

19 files changed

+741
-126
lines changed

cmd/wasm/functions.go

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
package main
44

55
import (
6+
"encoding/json"
67
"fmt"
78
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
9+
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
810
"github.com/speakeasy-api/jsonpath/pkg/overlay"
911
"gopkg.in/yaml.v3"
12+
"reflect"
1013
"syscall/js"
1114
)
1215

@@ -27,7 +30,7 @@ func CalculateOverlay(originalYAML, targetYAML, existingOverlay string) (string,
2730
var existingOverlayDocument overlay.Overlay
2831
err = yaml.Unmarshal([]byte(existingOverlay), &existingOverlayDocument)
2932
if err != nil {
30-
return "", fmt.Errorf("failed to parse overlay schema: %w", err)
33+
return "", fmt.Errorf("failed to parse overlay schema in CalculateOverlay: %w", err)
3134
}
3235
// now modify the original using the existing overlay
3336
err = existingOverlayDocument.ApplyTo(&orig)
@@ -88,6 +91,11 @@ func GetInfo(originalYAML string) (string, error) {
8891
}`, nil
8992
}
9093

94+
type ApplyOverlaySuccess struct {
95+
Type string `json:"type"`
96+
Result string `json:"result"`
97+
}
98+
9199
func ApplyOverlay(originalYAML, overlayYAML string) (string, error) {
92100
var orig yaml.Node
93101
err := yaml.Unmarshal([]byte(originalYAML), &orig)
@@ -98,7 +106,31 @@ func ApplyOverlay(originalYAML, overlayYAML string) (string, error) {
98106
var overlay overlay.Overlay
99107
err = yaml.Unmarshal([]byte(overlayYAML), &overlay)
100108
if err != nil {
101-
return "", fmt.Errorf("failed to parse overlay schema: %w", err)
109+
return "", fmt.Errorf("failed to parse overlay schema in ApplyOverlay: %w", err)
110+
}
111+
112+
// check to see if we have an overlay with an error, or a partial overlay: i.e. any overlay actions are missing an update or remove
113+
for i, action := range overlay.Actions {
114+
parsed, pathErr := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension())
115+
var node *yaml.Node
116+
if pathErr != nil {
117+
node, err = lookupOverlayActionTargetNode(overlayYAML, i)
118+
if err != nil {
119+
return "", err
120+
}
121+
122+
return applyOverlayJSONPathError(pathErr, node)
123+
}
124+
if reflect.ValueOf(action.Update).IsZero() && action.Remove == false {
125+
result := parsed.Query(&orig)
126+
127+
node, err = lookupOverlayActionTargetNode(overlayYAML, i)
128+
if err != nil {
129+
return "", err
130+
}
131+
132+
return applyOverlayJSONPathIncomplete(result, node)
133+
}
102134
}
103135

104136
err = overlay.ApplyTo(&orig)
@@ -116,6 +148,88 @@ func ApplyOverlay(originalYAML, overlayYAML string) (string, error) {
116148
return "", fmt.Errorf("failed to marshal result: %w", err)
117149
}
118150

151+
out, err = json.Marshal(ApplyOverlaySuccess{
152+
Type: "success",
153+
Result: string(out),
154+
})
155+
156+
return string(out), err
157+
}
158+
159+
type IncompleteOverlayErrorMessage struct {
160+
Type string `json:"type"`
161+
Line int `json:"line"`
162+
Col int `json:"col"`
163+
Result string `json:"result"`
164+
}
165+
166+
func applyOverlayJSONPathIncomplete(result []*yaml.Node, node *yaml.Node) (string, error) {
167+
yamlResult, err := yaml.Marshal(result)
168+
if err != nil {
169+
return "", err
170+
}
171+
out, err := json.Marshal(IncompleteOverlayErrorMessage{
172+
Type: "incomplete",
173+
Line: node.Line,
174+
Col: node.Column,
175+
Result: string(yamlResult),
176+
})
177+
return string(out), err
178+
}
179+
180+
type JSONPathErrorMessage struct {
181+
Type string `json:"type"`
182+
Line int `json:"line"`
183+
Col int `json:"col"`
184+
ErrMessage string `json:"error"`
185+
}
186+
187+
func applyOverlayJSONPathError(err error, node *yaml.Node) (string, error) {
188+
// first lets see if we can find a target expression
189+
out, err := json.Marshal(JSONPathErrorMessage{
190+
Type: "error",
191+
Line: node.Line,
192+
Col: node.Column,
193+
ErrMessage: err.Error(),
194+
})
195+
return string(out), err
196+
}
197+
198+
func lookupOverlayActionTargetNode(overlayYAML string, i int) (*yaml.Node, error) {
199+
var node struct {
200+
Actions []struct {
201+
Target yaml.Node `yaml:"target"`
202+
} `yaml:"actions"`
203+
}
204+
err := yaml.Unmarshal([]byte(overlayYAML), &node)
205+
if err != nil {
206+
return nil, fmt.Errorf("failed to parse overlay schema in lookupOverlayActionTargetNode: %w", err)
207+
}
208+
if len(node.Actions) <= i {
209+
return nil, fmt.Errorf("no action at index %d", i)
210+
}
211+
if reflect.ValueOf(node.Actions[i].Target).IsZero() {
212+
return nil, fmt.Errorf("no target at index %d", i)
213+
}
214+
return &node.Actions[i].Target, nil
215+
}
216+
217+
func Query(currentYAML, path string) (string, error) {
218+
var orig yaml.Node
219+
err := yaml.Unmarshal([]byte(currentYAML), &orig)
220+
if err != nil {
221+
return "", fmt.Errorf("failed to parse original schema in Query: %w", err)
222+
}
223+
parsed, err := jsonpath.NewPath(path, config.WithPropertyNameExtension())
224+
if err != nil {
225+
return "", err
226+
}
227+
result := parsed.Query(&orig)
228+
// Marshal it back out
229+
out, err := yaml.Marshal(result)
230+
if err != nil {
231+
return "", err
232+
}
119233
return string(out), nil
120234
}
121235

@@ -173,6 +287,13 @@ func main() {
173287

174288
return GetInfo(args[0].String())
175289
}))
290+
js.Global().Set("QueryJSONPath", promisify(func(args []js.Value) (string, error) {
291+
if len(args) != 1 {
292+
return "", fmt.Errorf("Query: expected 2 args, got %v", len(args))
293+
}
294+
295+
return Query(args[0].String(), args[1].String())
296+
}))
176297

177298
<-make(chan bool)
178299
}

pkg/jsonpath/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package config
2+
3+
type Option func(*config)
4+
5+
// WithPropertyNameExtension enables the use of the "~" character to access a property key.
6+
// It is not enabled by default as this is outside of RFC 9535, but is important for several use-cases
7+
func WithPropertyNameExtension() Option {
8+
return func(cfg *config) {
9+
cfg.propertyNameExtension = true
10+
}
11+
}
12+
13+
type Config interface {
14+
PropertyNameEnabled() bool
15+
}
16+
17+
type config struct {
18+
propertyNameExtension bool
19+
}
20+
21+
func (c *config) PropertyNameEnabled() bool {
22+
return c.propertyNameExtension
23+
}
24+
25+
func New(opts ...Option) Config {
26+
cfg := &config{}
27+
for _, opt := range opts {
28+
opt(cfg)
29+
}
30+
return cfg
31+
}

pkg/jsonpath/filter.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ type resolvedArgument struct {
103103
nodes []*literal
104104
}
105105

106-
func (a functionArgument) Eval(node *yaml.Node, root *yaml.Node) resolvedArgument {
106+
func (a functionArgument) Eval(idx index, node *yaml.Node, root *yaml.Node) resolvedArgument {
107107
if a.literal != nil {
108108
return resolvedArgument{kind: functionArgTypeLiteral, literal: a.literal}
109109
} else if a.filterQuery != nil {
110-
result := a.filterQuery.Query(node, root)
110+
result := a.filterQuery.Query(idx, node, root)
111111
lits := make([]*literal, len(result))
112112
for i, node := range result {
113113
lit := nodeToLiteral(node)
@@ -119,10 +119,10 @@ func (a functionArgument) Eval(node *yaml.Node, root *yaml.Node) resolvedArgumen
119119
return resolvedArgument{kind: functionArgTypeLiteral, literal: lits[0]}
120120
}
121121
} else if a.logicalExpr != nil {
122-
res := a.logicalExpr.Matches(node, root)
122+
res := a.logicalExpr.Matches(idx, node, root)
123123
return resolvedArgument{kind: functionArgTypeLiteral, literal: &literal{bool: &res}}
124124
} else if a.functionExpr != nil {
125-
res := a.functionExpr.Evaluate(node, root)
125+
res := a.functionExpr.Evaluate(idx, node, root)
126126
return resolvedArgument{kind: functionArgTypeLiteral, literal: &res}
127127
}
128128
return resolvedArgument{}

pkg/jsonpath/jsonpath.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@ package jsonpath
22

33
import (
44
"fmt"
5+
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
56
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
67
"gopkg.in/yaml.v3"
78
)
89

9-
func NewPath(input string) (*JSONPath, error) {
10-
tokenizer := token.NewTokenizer(input)
10+
func NewPath(input string, opts ...config.Option) (*JSONPath, error) {
11+
tokenizer := token.NewTokenizer(input, opts...)
1112
tokens := tokenizer.Tokenize()
1213
for i := 0; i < len(tokens); i++ {
1314
if tokens[i].Token == token.ILLEGAL {
1415
return nil, fmt.Errorf(tokenizer.ErrorString(&tokens[i], "unexpected token"))
1516
}
1617
}
17-
parser := newParserPrivate(tokenizer, tokens)
18+
parser := newParserPrivate(tokenizer, tokens, opts...)
1819
err := parser.parse()
1920
if err != nil {
2021
return nil, err

pkg/jsonpath/parser.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package jsonpath
33
import (
44
"errors"
55
"fmt"
6+
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
67
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
78
"strconv"
89
"strings"
@@ -24,11 +25,12 @@ type JSONPath struct {
2425
ast jsonPathAST
2526
current int
2627
mode []mode
28+
config config.Config
2729
}
2830

2931
// newParserPrivate creates a new JSONPath with the given tokens.
30-
func newParserPrivate(tokenizer *token.Tokenizer, tokens []token.TokenInfo) *JSONPath {
31-
return &JSONPath{tokenizer, tokens, jsonPathAST{}, 0, []mode{modeNormal}}
32+
func newParserPrivate(tokenizer *token.Tokenizer, tokens []token.TokenInfo, opts ...config.Option) *JSONPath {
33+
return &JSONPath{tokenizer, tokens, jsonPathAST{}, 0, []mode{modeNormal}, config.New(opts...)}
3234
}
3335

3436
// parse parses the JSONPath tokens and returns the root node of the AST.
@@ -88,7 +90,7 @@ func (p *JSONPath) parseSegment() (*segment, error) {
8890
if err != nil {
8991
return nil, err
9092
}
91-
return &segment{Descendant: child}, nil
93+
return &segment{kind: segmentKindDescendant, descendant: child}, nil
9294
} else if currentToken.Token == token.CHILD || currentToken.Token == token.BRACKET_LEFT {
9395
if currentToken.Token == token.CHILD {
9496
p.current++
@@ -97,7 +99,10 @@ func (p *JSONPath) parseSegment() (*segment, error) {
9799
if err != nil {
98100
return nil, err
99101
}
100-
return &segment{Child: child}, nil
102+
return &segment{kind: segmentKindChild, child: child}, nil
103+
} else if p.config.PropertyNameEnabled() && currentToken.Token == token.PROPERTY_NAME {
104+
p.current++
105+
return &segment{kind: segmentKindProperyName}, nil
101106
}
102107
return nil, p.parseFailure(&currentToken, "unexpected token when parsing segment")
103108
}

pkg/jsonpath/parser_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package jsonpath_test
22

33
import (
44
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
5+
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
56
"github.com/stretchr/testify/require"
67
"testing"
78
)
@@ -89,3 +90,84 @@ func TestParser(t *testing.T) {
8990
})
9091
}
9192
}
93+
94+
func TestParserPropertyNameExtension(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
input string
98+
enabled bool
99+
valid bool
100+
}{
101+
{
102+
name: "Simple property name disabled",
103+
input: "$.store~",
104+
enabled: false,
105+
valid: false,
106+
},
107+
{
108+
name: "Simple property name enabled",
109+
input: "$.store~",
110+
enabled: true,
111+
valid: true,
112+
},
113+
{
114+
name: "Property name in filter disabled",
115+
input: "$[?(@~)]",
116+
enabled: false,
117+
valid: false,
118+
},
119+
{
120+
name: "Property name in filter enabled",
121+
input: "$[?(@~)]",
122+
enabled: true,
123+
valid: true,
124+
},
125+
{
126+
name: "Property name with bracket notation enabled",
127+
input: "$['store']~",
128+
enabled: true,
129+
valid: true,
130+
},
131+
{
132+
name: "Property name with bracket notation disabled",
133+
input: "$['store']~",
134+
enabled: false,
135+
valid: false,
136+
},
137+
{
138+
name: "Chained property names enabled",
139+
input: "$.store~.name~",
140+
enabled: true,
141+
valid: true,
142+
},
143+
{
144+
name: "Property name in complex filter enabled",
145+
input: "$[?(@~ && @.price < 10)]",
146+
enabled: true,
147+
valid: true,
148+
},
149+
{
150+
name: "Property name in complex filter disabled",
151+
input: "$[?(@~ && @.price < 10)]",
152+
enabled: false,
153+
valid: false,
154+
},
155+
}
156+
157+
for _, test := range tests {
158+
t.Run(test.name, func(t *testing.T) {
159+
var opts []config.Option
160+
if test.enabled {
161+
opts = append(opts, config.WithPropertyNameExtension())
162+
}
163+
164+
path, err := jsonpath.NewPath(test.input, opts...)
165+
if !test.valid {
166+
require.Error(t, err)
167+
return
168+
}
169+
require.NoError(t, err)
170+
require.Equal(t, test.input, path.String())
171+
})
172+
}
173+
}

0 commit comments

Comments
 (0)