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

chore: implement wasm engine #143

Merged
merged 12 commits into from
Sep 18, 2024
1 change: 1 addition & 0 deletions wasm-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ default = ["console_error_panic_hook"]

[dependencies]
wasm-bindgen = "0.2"
unleash-types = "0.13.0"
unleash-yggdrasil = {path = "../unleash-yggdrasil"}
getrandom = { version = "0.2", features = ["js"] }

Expand Down
102 changes: 48 additions & 54 deletions wasm-engine/e2e-tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,50 @@
import { expect, test } from 'bun:test'
import yggdrasil from '../pkg/yggdrasil_engine'

test('Rule evaluates correctly', () => {
const context = {
userId: '7'
}

const result = yggdrasil.evaluate('user_id > 6', context)
expect(result).toBe(true)
})

test('Unknown base properties are ignored', () => {
const context = {
thisPropDoesNotExist: '7'
}

const result = yggdrasil.evaluate('user_id > 6', context)
expect(result).toBe(false)
})

test('Properties correctly propagate', () => {
const context = {
properties: {
customProperty: '7'
import { describe, beforeEach, test, expect } from 'bun:test'
import { Engine } from '../pkg/yggdrasil_engine'

describe('Client Spec Tests', () => {
let engine: Engine

beforeEach(() => {
engine = new Engine()
})

test('Client Spec', async () => {
const basePath = '../../client-specification/specifications'
const indexFile = Bun.file(`${basePath}/index.json`)
const testSuites = await indexFile.json()

for (const suite of testSuites) {
const suiteFile = Bun.file(`${basePath}/${suite}`)
const {
state,
tests: toggleTests = [],
variantTests = []
} = await suiteFile.json()

engine.takeState(state)

describe(`Suite: ${suite}`, () => {
for (const toggleTest of toggleTests) {
const toggleName = toggleTest.toggleName as string
const expectedResult = toggleTest.expectedResult as boolean

test(`Toggle Test: ${toggleTest.description}`, () => {
const result = engine.isEnabled(toggleName, toggleTest.context)
expect(result).toBe(expectedResult)
})
}

for (const variantTest of variantTests) {
const toggleName = variantTest.toggleName as string
const expectedResult = JSON.stringify(variantTest.expectedResult)

test(`Variant Test: ${variantTest.description}`, () => {
const result = engine.checkVariant(toggleName, variantTest.context)
const jsonResult = JSON.stringify(result)
expect(jsonResult).toBe(expectedResult)
})
}
})
}
}

const result = yggdrasil.evaluate('context["customProperty"] > 6', context)
expect(result).toBe(true)
})

test('Invalid rules raise an error', () => {
expect(() => {
yggdrasil.evaluate('This is not a valid rule', {})
}).toThrow()
})

test('Context can be empty but not null or undefined', () => {
const rule = 'user_id > 6'

yggdrasil.evaluate(rule, {}) // should not throw

expect(() => {
yggdrasil.evaluate(rule, null)
}).toThrow()

expect(() => {
yggdrasil.evaluate(rule, undefined)
}).toThrow()

expect(() => {
// @ts-expect-error
yggdrasil.evaluate(rule)
}).toThrow()
})
})
112 changes: 69 additions & 43 deletions wasm-engine/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,50 +1,76 @@
use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::from_value;

use unleash_yggdrasil::{
state::EnrichedContext, strategy_parsing::compile_rule, Context as YggdrasilContext,
};
use wasm_bindgen::prelude::*;

#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Context {
pub user_id: Option<String>,
pub session_id: Option<String>,
pub environment: Option<String>,
pub app_name: Option<String>,
pub current_time: Option<String>,
pub remote_address: Option<String>,
pub group_id: Option<String>,
pub properties: Option<HashMap<String, String>>,
}
use unleash_yggdrasil::{Context, EngineState};
use unleash_types::client_features::ClientFeatures;

impl Context {
fn to_context(&self) -> EnrichedContext {
let yggdrasil_context = YggdrasilContext {
user_id: self.user_id.clone(),
session_id: self.session_id.clone(),
environment: self.environment.clone(),
app_name: self.app_name.clone(),
current_time: self.current_time.clone(),
remote_address: self.remote_address.clone(),
properties: self.properties.clone(),
};

EnrichedContext::from(
yggdrasil_context,
self.group_id.clone().unwrap_or("".into()),
None,
)
}
#[wasm_bindgen]
pub struct Engine {
engine: EngineState,
}

#[wasm_bindgen]
pub fn evaluate(dsl_fragment: &str, context: JsValue) -> Result<bool, JsValue> {
let internal_context: Context = from_value(context)?;
let context = internal_context.to_context();
let rule = compile_rule(dsl_fragment).map_err(|e| JsValue::from_str(&format!("{:?}", e)))?;
Ok((rule)(&context))
}
impl Engine {
#[wasm_bindgen(constructor)]
pub fn new() -> Engine {
Engine {
engine: EngineState::default(),
}
}

#[wasm_bindgen(js_name = takeState)]
pub fn take_state(&mut self, state: JsValue) -> Result<(), JsValue> {
let state: ClientFeatures = from_value(state)
.map_err(|e| JsValue::from_str(&format!("Error parsing state: {}", e)))?;

if let Some(warnings) = self.engine.take_state(state) {
let warnings_json = serde_wasm_bindgen::to_value(&warnings)
.map_err(|e| JsValue::from_str(&format!("Error serializing warnings: {}", e)))?;
return Err(JsValue::from_str(&format!("Warnings: {:?}", warnings_json)));
}

Ok(())
}

#[wasm_bindgen(js_name = isEnabled)]
pub fn is_enabled(&self, toggle_name: &str, context: JsValue) -> Result<bool, JsValue> {
let context: Context = serde_wasm_bindgen::from_value(context)
.map_err(|e| JsValue::from_str(&format!("Invalid context object: {}", e)))?;
Ok(self.engine.is_enabled(toggle_name, &context, &None))
}

#[wasm_bindgen(js_name = checkVariant)]
pub fn check_variant(&self, toggle_name: &str, context: JsValue) -> Result<JsValue, JsValue> {
let context: Context = serde_wasm_bindgen::from_value(context)
.map_err(|e| JsValue::from_str(&format!("Invalid context object: {}", e)))?;
let variant = self
.engine
.check_variant(toggle_name, &context, &None)
.unwrap_or_default();

serde_wasm_bindgen::to_value(&variant)
.map_err(|e| JsValue::from_str(&format!("Error serializing variant: {}", e)))
}

#[wasm_bindgen(js_name = getMetrics)]
pub fn get_metrics(&mut self) -> Result<JsValue, JsValue> {
if let Some(metrics) = self.engine.get_metrics() {
serde_wasm_bindgen::to_value(&metrics)
.map_err(|e| JsValue::from_str(&format!("Failed to serialize metrics: {}", e)))
} else {
Ok(JsValue::from_str("{}"))
}
}

#[wasm_bindgen(js_name = countToggle)]
pub fn count_toggle(&self, toggle_name: &str, enabled: bool) -> Result<(), JsValue> {
self.engine.count_toggle(toggle_name, enabled);
Ok(())
}

#[wasm_bindgen(js_name = countVariant)]
pub fn count_variant(&self, toggle_name: &str, variant_name: &str) -> Result<(), JsValue> {
self.engine.count_variant(toggle_name, variant_name);
Ok(())
}
}
Loading