Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: property name extension, json path explorer #13

Merged
merged 3 commits into from
Jan 24, 2025
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
125 changes: 123 additions & 2 deletions cmd/wasm/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
package main

import (
"encoding/json"
"fmt"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
"github.com/speakeasy-api/jsonpath/pkg/overlay"
"gopkg.in/yaml.v3"
"reflect"
"syscall/js"
)

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

type ApplyOverlaySuccess struct {
Type string `json:"type"`
Result string `json:"result"`
}

func ApplyOverlay(originalYAML, overlayYAML string) (string, error) {
var orig yaml.Node
err := yaml.Unmarshal([]byte(originalYAML), &orig)
Expand All @@ -98,7 +106,31 @@ func ApplyOverlay(originalYAML, overlayYAML string) (string, error) {
var overlay overlay.Overlay
err = yaml.Unmarshal([]byte(overlayYAML), &overlay)
if err != nil {
return "", fmt.Errorf("failed to parse overlay schema: %w", err)
return "", fmt.Errorf("failed to parse overlay schema in ApplyOverlay: %w", err)
}

// 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
for i, action := range overlay.Actions {
parsed, pathErr := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension())
var node *yaml.Node
if pathErr != nil {
node, err = lookupOverlayActionTargetNode(overlayYAML, i)
if err != nil {
return "", err
}

return applyOverlayJSONPathError(pathErr, node)
}
if reflect.ValueOf(action.Update).IsZero() && action.Remove == false {
result := parsed.Query(&orig)

node, err = lookupOverlayActionTargetNode(overlayYAML, i)
if err != nil {
return "", err
}

return applyOverlayJSONPathIncomplete(result, node)
}
}

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

out, err = json.Marshal(ApplyOverlaySuccess{
Type: "success",
Result: string(out),
})

return string(out), err
}

type IncompleteOverlayErrorMessage struct {
Type string `json:"type"`
Line int `json:"line"`
Col int `json:"col"`
Result string `json:"result"`
}

func applyOverlayJSONPathIncomplete(result []*yaml.Node, node *yaml.Node) (string, error) {
yamlResult, err := yaml.Marshal(result)
if err != nil {
return "", err
}
out, err := json.Marshal(IncompleteOverlayErrorMessage{
Type: "incomplete",
Line: node.Line,
Col: node.Column,
Result: string(yamlResult),
})
return string(out), err
}

type JSONPathErrorMessage struct {
Type string `json:"type"`
Line int `json:"line"`
Col int `json:"col"`
ErrMessage string `json:"error"`
}

func applyOverlayJSONPathError(err error, node *yaml.Node) (string, error) {
// first lets see if we can find a target expression
out, err := json.Marshal(JSONPathErrorMessage{
Type: "error",
Line: node.Line,
Col: node.Column,
ErrMessage: err.Error(),
})
return string(out), err
}

func lookupOverlayActionTargetNode(overlayYAML string, i int) (*yaml.Node, error) {
var node struct {
Actions []struct {
Target yaml.Node `yaml:"target"`
} `yaml:"actions"`
}
err := yaml.Unmarshal([]byte(overlayYAML), &node)
if err != nil {
return nil, fmt.Errorf("failed to parse overlay schema in lookupOverlayActionTargetNode: %w", err)
}
if len(node.Actions) <= i {
return nil, fmt.Errorf("no action at index %d", i)
}
if reflect.ValueOf(node.Actions[i].Target).IsZero() {
return nil, fmt.Errorf("no target at index %d", i)
}
return &node.Actions[i].Target, nil
}

func Query(currentYAML, path string) (string, error) {
var orig yaml.Node
err := yaml.Unmarshal([]byte(currentYAML), &orig)
if err != nil {
return "", fmt.Errorf("failed to parse original schema in Query: %w", err)
}
parsed, err := jsonpath.NewPath(path, config.WithPropertyNameExtension())
if err != nil {
return "", err
}
result := parsed.Query(&orig)
// Marshal it back out
out, err := yaml.Marshal(result)
if err != nil {
return "", err
}
return string(out), nil
}

Expand Down Expand Up @@ -173,6 +287,13 @@ func main() {

return GetInfo(args[0].String())
}))
js.Global().Set("QueryJSONPath", promisify(func(args []js.Value) (string, error) {
if len(args) != 1 {
return "", fmt.Errorf("Query: expected 2 args, got %v", len(args))
}

return Query(args[0].String(), args[1].String())
}))

<-make(chan bool)
}
31 changes: 31 additions & 0 deletions pkg/jsonpath/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package config

type Option func(*config)

// WithPropertyNameExtension enables the use of the "~" character to access a property key.
// It is not enabled by default as this is outside of RFC 9535, but is important for several use-cases
func WithPropertyNameExtension() Option {
return func(cfg *config) {
cfg.propertyNameExtension = true
}
}

type Config interface {
PropertyNameEnabled() bool
}

type config struct {
propertyNameExtension bool
}

func (c *config) PropertyNameEnabled() bool {
return c.propertyNameExtension
}

func New(opts ...Option) Config {
cfg := &config{}
for _, opt := range opts {
opt(cfg)
}
return cfg
}
8 changes: 4 additions & 4 deletions pkg/jsonpath/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ type resolvedArgument struct {
nodes []*literal
}

func (a functionArgument) Eval(node *yaml.Node, root *yaml.Node) resolvedArgument {
func (a functionArgument) Eval(idx index, node *yaml.Node, root *yaml.Node) resolvedArgument {
if a.literal != nil {
return resolvedArgument{kind: functionArgTypeLiteral, literal: a.literal}
} else if a.filterQuery != nil {
result := a.filterQuery.Query(node, root)
result := a.filterQuery.Query(idx, node, root)
lits := make([]*literal, len(result))
for i, node := range result {
lit := nodeToLiteral(node)
Expand All @@ -119,10 +119,10 @@ func (a functionArgument) Eval(node *yaml.Node, root *yaml.Node) resolvedArgumen
return resolvedArgument{kind: functionArgTypeLiteral, literal: lits[0]}
}
} else if a.logicalExpr != nil {
res := a.logicalExpr.Matches(node, root)
res := a.logicalExpr.Matches(idx, node, root)
return resolvedArgument{kind: functionArgTypeLiteral, literal: &literal{bool: &res}}
} else if a.functionExpr != nil {
res := a.functionExpr.Evaluate(node, root)
res := a.functionExpr.Evaluate(idx, node, root)
return resolvedArgument{kind: functionArgTypeLiteral, literal: &res}
}
return resolvedArgument{}
Expand Down
7 changes: 4 additions & 3 deletions pkg/jsonpath/jsonpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@ package jsonpath

import (
"fmt"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
"gopkg.in/yaml.v3"
)

func NewPath(input string) (*JSONPath, error) {
tokenizer := token.NewTokenizer(input)
func NewPath(input string, opts ...config.Option) (*JSONPath, error) {
tokenizer := token.NewTokenizer(input, opts...)
tokens := tokenizer.Tokenize()
for i := 0; i < len(tokens); i++ {
if tokens[i].Token == token.ILLEGAL {
return nil, fmt.Errorf(tokenizer.ErrorString(&tokens[i], "unexpected token"))
}
}
parser := newParserPrivate(tokenizer, tokens)
parser := newParserPrivate(tokenizer, tokens, opts...)
err := parser.parse()
if err != nil {
return nil, err
Expand Down
13 changes: 9 additions & 4 deletions pkg/jsonpath/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package jsonpath
import (
"errors"
"fmt"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
"strconv"
"strings"
Expand All @@ -24,11 +25,12 @@ type JSONPath struct {
ast jsonPathAST
current int
mode []mode
config config.Config
}

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

// parse parses the JSONPath tokens and returns the root node of the AST.
Expand Down Expand Up @@ -88,7 +90,7 @@ func (p *JSONPath) parseSegment() (*segment, error) {
if err != nil {
return nil, err
}
return &segment{Descendant: child}, nil
return &segment{kind: segmentKindDescendant, descendant: child}, nil
} else if currentToken.Token == token.CHILD || currentToken.Token == token.BRACKET_LEFT {
if currentToken.Token == token.CHILD {
p.current++
Expand All @@ -97,7 +99,10 @@ func (p *JSONPath) parseSegment() (*segment, error) {
if err != nil {
return nil, err
}
return &segment{Child: child}, nil
return &segment{kind: segmentKindChild, child: child}, nil
} else if p.config.PropertyNameEnabled() && currentToken.Token == token.PROPERTY_NAME {
p.current++
return &segment{kind: segmentKindProperyName}, nil
}
return nil, p.parseFailure(&currentToken, "unexpected token when parsing segment")
}
Expand Down
82 changes: 82 additions & 0 deletions pkg/jsonpath/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jsonpath_test

import (
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
"github.com/stretchr/testify/require"
"testing"
)
Expand Down Expand Up @@ -89,3 +90,84 @@ func TestParser(t *testing.T) {
})
}
}

func TestParserPropertyNameExtension(t *testing.T) {
tests := []struct {
name string
input string
enabled bool
valid bool
}{
{
name: "Simple property name disabled",
input: "$.store~",
enabled: false,
valid: false,
},
{
name: "Simple property name enabled",
input: "$.store~",
enabled: true,
valid: true,
},
{
name: "Property name in filter disabled",
input: "$[?(@~)]",
enabled: false,
valid: false,
},
{
name: "Property name in filter enabled",
input: "$[?(@~)]",
enabled: true,
valid: true,
},
{
name: "Property name with bracket notation enabled",
input: "$['store']~",
enabled: true,
valid: true,
},
{
name: "Property name with bracket notation disabled",
input: "$['store']~",
enabled: false,
valid: false,
},
{
name: "Chained property names enabled",
input: "$.store~.name~",
enabled: true,
valid: true,
},
{
name: "Property name in complex filter enabled",
input: "$[?(@~ && @.price < 10)]",
enabled: true,
valid: true,
},
{
name: "Property name in complex filter disabled",
input: "$[?(@~ && @.price < 10)]",
enabled: false,
valid: false,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var opts []config.Option
if test.enabled {
opts = append(opts, config.WithPropertyNameExtension())
}

path, err := jsonpath.NewPath(test.input, opts...)
if !test.valid {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, test.input, path.String())
})
}
}
Loading
Loading