diff --git a/eval/eval.go b/eval/eval.go index aabec37..21b0de6 100644 --- a/eval/eval.go +++ b/eval/eval.go @@ -15,16 +15,22 @@ package eval import ( + "encoding/json" "fmt" "reflect" "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/ext" - "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" k8s "k8s.io/apiserver/pkg/cel/library" ) +type EvalResponse struct { + Result any `json:"result"` + Cost *uint64 `json:"cost, omitempty"` +} + var celEnvOptions = []cel.EnvOption{ cel.EagerlyValidateDeclarations(true), cel.DefaultUTCTimeZone(true), @@ -59,14 +65,39 @@ func Eval(exp string, input map[string]any) (string, error) { if err != nil { return "", fmt.Errorf("failed to instantiate CEL program: %w", err) } - val, _, err := prog.Eval(input) + val, costTracker, err := prog.Eval(input) if err != nil { return "", fmt.Errorf("failed to evaluate: %w", err) } - jsonData, err := val.ConvertToNative(reflect.TypeOf(&structpb.Value{})) + + response, err := generateResponse(val, costTracker) + if err != nil { + return "", fmt.Errorf("failed to generate the response: %w", err) + } + + out, err := json.Marshal(response) if err != nil { return "", fmt.Errorf("failed to marshal the output: %w", err) } - out := protojson.Format(jsonData.(*structpb.Value)) - return out, nil + return string(out), nil +} + +func getResults(val *ref.Val) (any, error) { + if value, err := (*val).ConvertToNative(reflect.TypeOf(&structpb.Value{})); err != nil { + return nil, err + } else { + return value, nil + } +} + +func generateResponse(val ref.Val, costTracker *cel.EvalDetails) (*EvalResponse, error) { + result, evalError := getResults(&val) + if evalError != nil { + return nil, evalError + } + cost := costTracker.ActualCost() + return &EvalResponse{ + Result: result, + Cost: cost, + }, nil } diff --git a/eval/eval_test.go b/eval/eval_test.go index 9723928..108fe19 100644 --- a/eval/eval_test.go +++ b/eval/eval_test.go @@ -15,7 +15,8 @@ package eval import ( - "strings" + "encoding/json" + "reflect" "testing" ) @@ -34,13 +35,13 @@ func TestEval(t *testing.T) { tests := []struct { name string exp string - want string + want any wantErr bool }{ { name: "lte", exp: "object.replicas <= 5", - want: "true", + want: true, }, { name: "error", @@ -50,66 +51,73 @@ func TestEval(t *testing.T) { { name: "url", exp: "isURL(object.href) && url(object.href).getScheme() == 'https' && url(object.href).getEscapedPath() == '/path'", - want: "true", + want: true, }, { name: "query", exp: "url(object.href).getQuery()", - want: `{"query": ["val"]}`, + want: map[string]any{ + "query": []any{"val"}, + }, }, { name: "regex", exp: "object.image.find('v[0-9]+.[0-9]+.[0-9]*$')", - want: `"v0.0.0"`, + want: "v0.0.0", }, { name: "list", exp: "object.items.isSorted() && object.items.sum() == 6 && object.items.max() == 3 && object.items.indexOf(1) == 0", - want: "true", + want: true, }, { name: "optional", exp: `object.?foo.orValue("fallback")`, - want: `"fallback"`, + want: "fallback", }, { name: "strings", exp: "object.abc.join(', ')", - want: `"a, b, c"`, + want: "a, b, c", }, { name: "cross type numeric comparisons", exp: "object.replicas > 1.4", - want: "true", + want: true, }, { name: "split", exp: "object.image.split(':').size() == 2", - want: "true", + want: true, }, { name: "quantity", exp: `isQuantity(object.memory) && quantity(object.memory).add(quantity("700M")).sub(1).isLessThan(quantity("2G"))`, - want: "true", + want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Eval(tt.exp, input) + if (err != nil) != tt.wantErr { t.Errorf("Eval() error = %v, wantErr %v", err, tt.wantErr) return } - if stripWhitespace(got) != stripWhitespace(tt.want) { - t.Errorf("Eval() got = %v, want %v", got, tt.want) + + if !tt.wantErr { + evalResponse := EvalResponse{} + if err := json.Unmarshal([]byte(got), &evalResponse); err != nil { + t.Errorf("Eval() error = %v", err) + } + + if !reflect.DeepEqual(tt.want, evalResponse.Result) { + t.Errorf("Expected %v\n, received %v", tt.want, evalResponse.Result) + } + if evalResponse.Cost == nil || *evalResponse.Cost <= 0 { + t.Errorf("Expected Cost, returned %v", evalResponse.Cost) + } } }) } } - -func stripWhitespace(a string) string { - a = strings.ReplaceAll(a, " ", "") - a = strings.ReplaceAll(a, "\n", "") - a = strings.ReplaceAll(a, "\t", "") - return strings.ReplaceAll(a, "\r", "") -} diff --git a/go.mod b/go.mod index 3740857..007cf8d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/undistro/cel-playground -go 1.20 +go 1.21 require ( github.com/google/cel-go v0.16.0 diff --git a/web/assets/css/styles.css b/web/assets/css/styles.css index ab4a8b7..07f3cc9 100644 --- a/web/assets/css/styles.css +++ b/web/assets/css/styles.css @@ -598,3 +598,8 @@ footer .langdef { background: #e3e3e3; border-radius: 4px; } + +.cost__header { + width: 25%; + text-align: left; +} \ No newline at end of file diff --git a/web/assets/js/main.js b/web/assets/js/main.js index 298fbff..5e83a2e 100644 --- a/web/assets/js/main.js +++ b/web/assets/js/main.js @@ -31,17 +31,29 @@ if (!WebAssembly.instantiateStreaming) { const celEditor = new AceEditor("cel-input"); const dataEditor = new AceEditor("data-input"); +const output = document.getElementById("output"); +const costElem = document.getElementById("cost"); + +function setCost(cost) { + costElem.innerText = costElem.textContent = cost; +} function run() { const data = dataEditor.getValue(); const expression = celEditor.getValue(); - const output = document.getElementById("output"); + const cost = document.getElementById("cost"); output.value = "Evaluating..."; + setCost("") + const result = eval(expression, data); const { output: resultOutput, isError } = result; - output.value = `${resultOutput}`; + var response = JSON.parse(resultOutput); + + output.value = JSON.stringify(response.result); + setCost(response.cost); + output.style.color = isError ? "red" : "white"; } @@ -304,6 +316,8 @@ fetch("../assets/data.json") ); celEditor.setValue(example.cel, -1); dataEditor.setValue(example.data, -1); + setCost(""); + output.value = ""; }); }) .catch((err) => { diff --git a/web/assets/main.wasm.gz b/web/assets/main.wasm.gz index ced49a6..b4a89e5 100755 Binary files a/web/assets/main.wasm.gz and b/web/assets/main.wasm.gz differ diff --git a/web/dist/wasm_exec.js b/web/dist/wasm_exec.js index e6c8921..bc6f210 100644 --- a/web/dist/wasm_exec.js +++ b/web/dist/wasm_exec.js @@ -113,6 +113,10 @@ this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); } + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + const getInt64 = (addr) => { const low = this.mem.getUint32(addr + 0, true); const high = this.mem.getInt32(addr + 4, true); @@ -206,7 +210,10 @@ const timeOrigin = Date.now() - performance.now(); this.importObject = { - go: { + _gotest: { + add: (a, b) => a + b, + }, + gojs: { // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). @@ -269,7 +276,7 @@ this._resume(); } }, - getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + getInt64(sp + 8), )); this.mem.setInt32(sp + 16, id, true); }, diff --git a/web/index.html b/web/index.html index 9779371..2bc350b 100644 --- a/web/index.html +++ b/web/index.html @@ -144,7 +144,15 @@
- Output + + Output + + + + Cost: + + +