From a81327381549ee214b4e9c04393e3b1a79f4e041 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 15 Nov 2024 21:03:14 -0800 Subject: [PATCH 01/35] feat: fastly compute service to perform assignments (FF-3552) --- .github/workflows/ci.yml | 10 ++++++++-- Cargo.toml | 9 +++++---- fastly-edge-assignments/.cargo/config.toml | 2 ++ fastly-edge-assignments/Cargo.toml | 11 +++++++++++ fastly-edge-assignments/README.md | 3 +++ fastly-edge-assignments/fastly.toml | 11 +++++++++++ fastly-edge-assignments/rust-toolchain.toml | 3 +++ fastly-edge-assignments/src/main.rs | 11 +++++++++++ 8 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 fastly-edge-assignments/.cargo/config.toml create mode 100644 fastly-edge-assignments/Cargo.toml create mode 100644 fastly-edge-assignments/README.md create mode 100644 fastly-edge-assignments/fastly.toml create mode 100644 fastly-edge-assignments/rust-toolchain.toml create mode 100644 fastly-edge-assignments/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bf28d77..a1a76073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,12 @@ jobs: submodules: true - run: npm ci - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose --all-targets - - run: cargo test --verbose + # Add WASM target + - run: rustup target add wasm32-wasi + # Build non-WASM targets + - run: cargo build --verbose --all-targets --workspace --exclude fastly-edge-assignments + # Build WASM target separately + - run: cargo build --verbose -p fastly-edge-assignments --target wasm32-wasi + # Run tests (excluding WASM package) + - run: cargo test --verbose --workspace --exclude fastly-edge-assignments - run: cargo doc --verbose diff --git a/Cargo.toml b/Cargo.toml index 273f6fae..6548074f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "2" members = [ - "eppo_core", - "rust-sdk", - "python-sdk", - "ruby-sdk/ext/eppo_client", + "eppo_core", + "rust-sdk", + "python-sdk", + "ruby-sdk/ext/eppo_client", + "fastly-edge-assignments", ] [patch.crates-io] diff --git a/fastly-edge-assignments/.cargo/config.toml b/fastly-edge-assignments/.cargo/config.toml new file mode 100644 index 00000000..6b77899c --- /dev/null +++ b/fastly-edge-assignments/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasi" diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml new file mode 100644 index 00000000..66066608 --- /dev/null +++ b/fastly-edge-assignments/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fastly-edge-assignments" +version = "0.1.0" +edition = "2021" +# Remove this line if you want to be able to publish this crate as open source on crates.io. +# Otherwise, `publish = false` prevents an accidental `cargo publish` from revealing private source. +publish = false + +[dependencies] +eppo_core = { version = "=4.1.0", path = "../eppo_core" } +fastly = "0.11.0" diff --git a/fastly-edge-assignments/README.md b/fastly-edge-assignments/README.md new file mode 100644 index 00000000..6c6800f7 --- /dev/null +++ b/fastly-edge-assignments/README.md @@ -0,0 +1,3 @@ +# Eppo Assignments on Fastly Compute@Edge + +TODO: Add a description diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml new file mode 100644 index 00000000..6c5a4705 --- /dev/null +++ b/fastly-edge-assignments/fastly.toml @@ -0,0 +1,11 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = ["engineering@geteppo.com"] +name = "Eppo Assignments on Fastly Compute@Edge" +description = "Edge compute service for pre-computed Eppo flag assignments" +language = "rust" +manifest_version = 3 + +[scripts] +build = "cargo build --bin fastly-compute-project --release --target wasm32-wasi --color always" diff --git a/fastly-edge-assignments/rust-toolchain.toml b/fastly-edge-assignments/rust-toolchain.toml new file mode 100644 index 00000000..5914a562 --- /dev/null +++ b/fastly-edge-assignments/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +targets = [ "wasm32-wasi" ] diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs new file mode 100644 index 00000000..26e322ed --- /dev/null +++ b/fastly-edge-assignments/src/main.rs @@ -0,0 +1,11 @@ +use fastly::http::StatusCode; +use fastly::{Error, Request, Response}; + +#[fastly::main] +fn main(req: Request) -> Result { + // Create an HTTP OK response + let response = Response::from_status(StatusCode::OK) + .with_body_text_plain("Request processed successfully"); + + Ok(response) +} From e985863d80de25ce7756869dd7611cca783ab96e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 5 Nov 2024 09:58:02 -0800 Subject: [PATCH 02/35] convert from reqwest blocking to non-blocking to conform to wasm target --- .vscode/settings.json | 3 +++ eppo_core/Cargo.toml | 3 ++- eppo_core/src/configuration_fetcher.rs | 26 ++++++++++++++------------ eppo_core/src/poller_thread.rs | 6 +++++- sdk-test-data | 2 +- 5 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7b016a89 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/eppo_core/Cargo.toml b/eppo_core/Cargo.toml index 50db47ef..9275d6d4 100644 --- a/eppo_core/Cargo.toml +++ b/eppo_core/Cargo.toml @@ -26,11 +26,12 @@ log = { version = "0.4.21", features = ["kv", "kv_serde"] } md5 = "0.7.0" rand = "0.8.5" regex = "1.10.4" -reqwest = { version = "0.12.4", features = ["blocking", "json"] } +reqwest = { version = "0.12.4", features = ["json"] } semver = { version = "1.0.22", features = ["serde"] } serde = { version = "1.0.198", features = ["derive", "rc"] } serde_json = "1.0.116" thiserror = "1.0.60" +tokio = { version = "1.34.0", features = ["rt", "time"] } url = "2.5.0" # pyo3 dependencies diff --git a/eppo_core/src/configuration_fetcher.rs b/eppo_core/src/configuration_fetcher.rs index 4e446c09..9f91bf23 100644 --- a/eppo_core/src/configuration_fetcher.rs +++ b/eppo_core/src/configuration_fetcher.rs @@ -20,7 +20,7 @@ const BANDIT_ENDPOINT: &'static str = "/flag-config/v1/bandits"; /// A client that fetches Eppo configuration from the server. pub struct ConfigurationFetcher { // Client holds a connection pool internally, so we're reusing the client between requests. - client: reqwest::blocking::Client, + client: reqwest::Client, config: ConfigurationFetcherConfig, /// If we receive a 401 Unauthorized error during a request, it means the API key is not /// valid. We cache this error so we don't issue additional requests to the server. @@ -29,7 +29,7 @@ pub struct ConfigurationFetcher { impl ConfigurationFetcher { pub fn new(config: ConfigurationFetcherConfig) -> ConfigurationFetcher { - let client = reqwest::blocking::Client::new(); + let client = reqwest::Client::new(); ConfigurationFetcher { client, @@ -38,24 +38,24 @@ impl ConfigurationFetcher { } } - pub fn fetch_configuration(&mut self) -> Result { + pub async fn fetch_configuration(&mut self) -> Result { if self.unauthorized { return Err(Error::Unauthorized); } - let ufc = self.fetch_ufc_configuration()?; + let ufc = self.fetch_ufc_configuration().await?; let bandits = if ufc.compiled.flag_to_bandit_associations.is_empty() { // We don't need bandits configuration if there are no bandits. None } else { - Some(self.fetch_bandits_configuration()?) + Some(self.fetch_bandits_configuration().await?) }; Ok(Configuration::from_server_response(ufc, bandits)) } - fn fetch_ufc_configuration(&mut self) -> Result { + async fn fetch_ufc_configuration(&mut self) -> Result { let url = Url::parse_with_params( &format!("{}{}", self.config.base_url, UFC_ENDPOINT), &[ @@ -68,7 +68,7 @@ impl ConfigurationFetcher { .map_err(|err| Error::InvalidBaseUrl(err))?; log::debug!(target: "eppo", "fetching UFC flags configuration"); - let response = self.client.get(url).send()?; + let response = self.client.get(url).send().await?; let response = response.error_for_status().map_err(|err| { if err.status() == Some(StatusCode::UNAUTHORIZED) { @@ -82,15 +82,17 @@ impl ConfigurationFetcher { } })?; - let configuration = - UniversalFlagConfig::from_json(self.config.sdk_metadata, response.bytes()?.into())?; + let configuration = UniversalFlagConfig::from_json( + self.config.sdk_metadata, + response.bytes().await?.into(), + )?; log::debug!(target: "eppo", "successfully fetched UFC flags configuration"); Ok(configuration) } - fn fetch_bandits_configuration(&mut self) -> Result { + async fn fetch_bandits_configuration(&mut self) -> Result { let url = Url::parse_with_params( &format!("{}{}", self.config.base_url, BANDIT_ENDPOINT), &[ @@ -103,7 +105,7 @@ impl ConfigurationFetcher { .map_err(|err| Error::InvalidBaseUrl(err))?; log::debug!(target: "eppo", "fetching UFC bandits configuration"); - let response = self.client.get(url).send()?; + let response = self.client.get(url).send().await?; let response = response.error_for_status().map_err(|err| { if err.status() == Some(StatusCode::UNAUTHORIZED) { @@ -117,7 +119,7 @@ impl ConfigurationFetcher { } })?; - let configuration = response.json()?; + let configuration = response.json().await?; log::debug!(target: "eppo", "successfully fetched UFC bandits configuration"); diff --git a/eppo_core/src/poller_thread.rs b/eppo_core/src/poller_thread.rs index 3eef4ed9..2db0e24c 100644 --- a/eppo_core/src/poller_thread.rs +++ b/eppo_core/src/poller_thread.rs @@ -131,9 +131,13 @@ impl PollerThread { std::thread::Builder::new() .name("eppo-poller".to_owned()) .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); loop { log::debug!(target: "eppo", "fetching new configuration"); - let result = fetcher.fetch_configuration(); + let result = runtime.block_on(fetcher.fetch_configuration()); match result { Ok(configuration) => { store.set_configuration(Arc::new(configuration)); diff --git a/sdk-test-data b/sdk-test-data index 11dace62..7db46318 160000 --- a/sdk-test-data +++ b/sdk-test-data @@ -1 +1 @@ -Subproject commit 11dace62a7ce97792bd54e48aaf354f62a034d63 +Subproject commit 7db46318cf74a3286a06afc448358cd379ae0cb9 From c8717eedc8bc2c98d259a6f6a527a7e11a5fbe3a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 15 Nov 2024 21:08:45 -0800 Subject: [PATCH 03/35] remove vscode settings --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7b016a89..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file From 5956e65048cb60bd69be37b1c8f7d0b2cb1646f1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 15 Nov 2024 21:16:42 -0800 Subject: [PATCH 04/35] makefile for local development --- Makefile | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 3f2cf23a..dc53cf6d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -# Make settings - @see https://tech.davis-hansson.com/p/make/ SHELL := bash .ONESHELL: .SHELLFLAGS := -eu -o pipefail -c @@ -6,23 +5,43 @@ SHELL := bash MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules -# Log levels -DEBUG := $(shell printf "\e[2D\e[35m") -INFO := $(shell printf "\e[2D\e[36m🔵 ") -OK := $(shell printf "\e[2D\e[32m🟢 ") -WARN := $(shell printf "\e[2D\e[33m🟡 ") -ERROR := $(shell printf "\e[2D\e[31m🔴 ") -END := $(shell printf "\e[0m") +WASM_TARGET=wasm32-wasi +FASTLY_PACKAGE=fastly-edge-assignments +BUILD_DIR=target/$(WASM_TARGET)/release +WASM_FILE=$(BUILD_DIR)/$(FASTLY_PACKAGE).wasm -.PHONY: default -default: help - -## help - Print help message. +# Help target for easy documentation .PHONY: help -help: Makefile - @echo "usage: make " - @sed -n 's/^##//p' $< +help: + @echo "Available targets:" + @echo " all - Default target (build workspace)" + @echo " workspace-build - Build the entire workspace excluding the Fastly package" + @echo " workspace-test - Test the entire workspace excluding the Fastly package" + @echo " fastly-edge-assignments-build - Build only the Fastly package for WASM" + @echo " fastly-edge-assignments-test - Test only the Fastly package" + @echo " clean - Clean all build artifacts" .PHONY: test test: ${testDataDir} npm test + +# Build the entire workspace excluding the `fastly-edge-assignments` package +.PHONY: workspace-build +workspace-build: + cargo build --workspace --exclude $(FASTLY_PACKAGE) + +# Run tests for the entire workspace excluding the `fastly-edge-assignments` package +.PHONY: workspace-test +workspace-test: + cargo test --workspace --exclude $(FASTLY_PACKAGE) + +# Build only the `fastly-edge-assignments` package for WASM +.PHONY: fastly-edge-assignments-build +fastly-edge-assignments-build: + rustup target add $(WASM_TARGET) + cargo build --release --target $(WASM_TARGET) --package $(FASTLY_PACKAGE) + +# Test only the `fastly-edge-assignments` package +.PHONY: fastly-edge-assignments-test +fastly-edge-assignments-test: + cargo test --target $(WASM_TARGET) --package $(FASTLY_PACKAGE) From a3f37c723aa0b12130b7c0e39d6f808aa7bba4b8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 00:24:10 -0800 Subject: [PATCH 05/35] split into multiple handlers --- fastly-edge-assignments/.gitignore | 2 + fastly-edge-assignments/Cargo.toml | 2 + fastly-edge-assignments/fastly.toml | 2 +- .../src/handlers/assignments.rs | 67 +++++++++++++++++++ .../src/handlers/health.rs | 5 ++ fastly-edge-assignments/src/handlers/mod.rs | 7 ++ fastly-edge-assignments/src/main.rs | 37 ++++++++-- 7 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 fastly-edge-assignments/.gitignore create mode 100644 fastly-edge-assignments/src/handlers/assignments.rs create mode 100644 fastly-edge-assignments/src/handlers/health.rs create mode 100644 fastly-edge-assignments/src/handlers/mod.rs diff --git a/fastly-edge-assignments/.gitignore b/fastly-edge-assignments/.gitignore new file mode 100644 index 00000000..5b148861 --- /dev/null +++ b/fastly-edge-assignments/.gitignore @@ -0,0 +1,2 @@ +pkg/ +bin/ \ No newline at end of file diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml index 66066608..d5f6267d 100644 --- a/fastly-edge-assignments/Cargo.toml +++ b/fastly-edge-assignments/Cargo.toml @@ -9,3 +9,5 @@ publish = false [dependencies] eppo_core = { version = "=4.1.0", path = "../eppo_core" } fastly = "0.11.0" +serde_json = "1.0.132" +serde = "1.0.192" \ No newline at end of file diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml index 6c5a4705..736e1057 100644 --- a/fastly-edge-assignments/fastly.toml +++ b/fastly-edge-assignments/fastly.toml @@ -8,4 +8,4 @@ language = "rust" manifest_version = 3 [scripts] -build = "cargo build --bin fastly-compute-project --release --target wasm32-wasi --color always" +build = "cargo build --bin fastly-edge-assignments --release --target wasm32-wasi --color always" diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs new file mode 100644 index 00000000..64e69d11 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -0,0 +1,67 @@ +use fastly::http::StatusCode; +use fastly::{Error, KVStore, Request, Response}; +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +struct RequestBody { + subject_key: String, + subject_attributes: HashMap, + #[serde(rename = "banditActions")] + #[serde(skip_serializing_if = "Option::is_none")] + bandit_actions: Option>, +} + +const KV_STORE_NAME: &str = "edge-assignment-kv-store"; + +pub fn handle_assignments(mut req: Request) -> Result { + // Parse the apiKey from the request + let api_key = req.get_query_parameter("sdk_key").unwrap_or_default(); + + // Parse the request body + let body: RequestBody = serde_json::from_slice(&req.take_body_bytes())?; + let subject_key = body.subject_key; + let subject_attributes = body.subject_attributes; + let bandit_actions = body.bandit_actions; + + // Construct an KVStore instance which is connected to the KV Store named `my-store` + //[Documentation for the KVStore open method can be found here](https://docs.rs/fastly/latest/fastly/struct.KVStore.html#method.open) + let mut kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; + + let mut kv_store_item = kv_store.lookup("my_key")?; + let kv_store_item_body = kv_store_item.take_body(); + + // Parse the response from the KV store + + //let ufc_config_json: Value = kv_store_item_body.take_body_json()?; + //let ufc_config = parse_ufc_configuration(kv_store_item_body.into_bytes()); + //let client = offline_init(api_key, ufc_config); + + // let flag_keys: Vec = ufc_config_json["flags"] + // .as_object() + // .unwrap() + // .keys() + // .cloned() + // .collect(); + + let flag_keys: Vec = Vec::from("flag1"); + + // let mut assignment_cache: HashMap = HashMap::new(); + + //for flag_key in &flag_keys { + // let subject_key = eppo_core::Str::from(subject_key); + // let assignment = client.get_assignment(flag_key, &subject_key, &subject_attributes); + // let variation_value: eppo::AssignmentValue = match assignment { + // Ok(Some(value)) => value.clone(), + // Ok(None) => eppo::AssignmentValue::Json(Arc::new(json!(null))), + // Err(_) => eppo::AssignmentValue::Json(Arc::new(json!(null))), + // }; + // assignment_cache.insert(flag_key.to_string(), variation_value); + //} + + // Create an HTTP OK response + let response = Response::from_status(StatusCode::OK) + .with_body_text_plain("Request processed successfully"); + + Ok(response) +} diff --git a/fastly-edge-assignments/src/handlers/health.rs b/fastly-edge-assignments/src/handlers/health.rs new file mode 100644 index 00000000..47ca6a23 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/health.rs @@ -0,0 +1,5 @@ +use fastly::{http::StatusCode, Error, Request, Response}; + +pub fn handle_health(_req: Request) -> Result { + Ok(Response::from_status(StatusCode::OK).with_body_text_plain("OK")) +} diff --git a/fastly-edge-assignments/src/handlers/mod.rs b/fastly-edge-assignments/src/handlers/mod.rs new file mode 100644 index 00000000..a6b6ae69 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/mod.rs @@ -0,0 +1,7 @@ +// Declare submodules +pub mod assignments; +pub mod health; + +// Re-export items to make them more convenient to use +pub use assignments::handle_assignments; +pub use health::handle_health; diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs index 26e322ed..995cb1a1 100644 --- a/fastly-edge-assignments/src/main.rs +++ b/fastly-edge-assignments/src/main.rs @@ -1,11 +1,36 @@ -use fastly::http::StatusCode; +mod handlers; + +use fastly::http::{Method, StatusCode}; use fastly::{Error, Request, Response}; +// fn parse_ufc_configuration(ufc_config_json: Vec) -> UniversalFlagConfig { +// //let config_json_bytes: Vec = serde_json::to_vec(&ufc_config_json).unwrap(); +// UniversalFlagConfig::from_json( +// SdkMetadata { +// name: "rust-sdk", +// version: "4.0.1", +// }, +// ufc_config_json, +// ) +// .unwrap() +// } + +// fn offline_init(api_key: &str, ufc_config: UniversalFlagConfig) -> eppo::Client { +// let config = Configuration::from_server_response(ufc_config, None); +// let config_store = eppo_core::configuration_store::ConfigurationStore::new(); +// config_store.set_configuration(Arc::new(config)); +// let client = eppo::Client::new_with_configuration_store( +// ClientConfig::from_api_key(api_key), +// config_store.into(), +// ); +// return client; +// } + #[fastly::main] fn main(req: Request) -> Result { - // Create an HTTP OK response - let response = Response::from_status(StatusCode::OK) - .with_body_text_plain("Request processed successfully"); - - Ok(response) + match (req.get_method(), req.get_path()) { + (&Method::POST, "/assignments") => handlers::handle_assignments(req), + (&Method::GET, "/health") => handlers::handle_health(req), + _ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body_text_plain("Not Found")), + } } From 48d47aac15e67daa7e36f155af43dfebe9ecd10d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 01:03:24 -0800 Subject: [PATCH 06/35] its a thing of beauty --- fastly-edge-assignments/Cargo.toml | 4 +-- .../src/handlers/assignments.rs | 35 ++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml index d5f6267d..72568806 100644 --- a/fastly-edge-assignments/Cargo.toml +++ b/fastly-edge-assignments/Cargo.toml @@ -8,6 +8,6 @@ publish = false [dependencies] eppo_core = { version = "=4.1.0", path = "../eppo_core" } -fastly = "0.11.0" +fastly = { version = "0.11.0", features = ["logging"] } serde_json = "1.0.132" -serde = "1.0.192" \ No newline at end of file +serde = "1.0.192" diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 64e69d11..a6c2e0f8 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,4 +1,5 @@ use fastly::http::StatusCode; +use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; use serde::Deserialize; use std::collections::HashMap; @@ -13,10 +14,13 @@ struct RequestBody { } const KV_STORE_NAME: &str = "edge-assignment-kv-store"; +const SDK_KEY_QUERY_PARAM: &str = "sdk_key"; pub fn handle_assignments(mut req: Request) -> Result { // Parse the apiKey from the request - let api_key = req.get_query_parameter("sdk_key").unwrap_or_default(); + let api_key = req + .get_query_parameter(SDK_KEY_QUERY_PARAM) + .unwrap_or_default(); // Parse the request body let body: RequestBody = serde_json::from_slice(&req.take_body_bytes())?; @@ -25,10 +29,33 @@ pub fn handle_assignments(mut req: Request) -> Result { let bandit_actions = body.bandit_actions; // Construct an KVStore instance which is connected to the KV Store named `my-store` - //[Documentation for the KVStore open method can be found here](https://docs.rs/fastly/latest/fastly/struct.KVStore.html#method.open) - let mut kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; + // [Documentation for the KVStore open method can be found here](https://docs.rs/fastly/latest/fastly/struct.KVStore.html#method.open) + let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; + + let kv_store_item = match kv_store.lookup("my_key") { + Ok(item) => item, + Err(e) => { + let (status, message) = match e { + // Return unauthorized if the key does not exist. + // Our protocol lets the client know that the SDK key has not had a UFC + // configuration pre-computed for it in the KV Store. + KVStoreError::ItemNotFound => ( + StatusCode::UNAUTHORIZED, + "SDK key not found in KV store".to_string(), + ), + _ => { + fastly::log::error!("KV Store error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Unexpected KV Store error".to_string(), + ) + } + }; + + return Ok(Response::from_status(status).with_body_text_plain(&message)); + } + }; - let mut kv_store_item = kv_store.lookup("my_key")?; let kv_store_item_body = kv_store_item.take_body(); // Parse the response from the KV store From 56e5c9641de5753efc022914012925b7e4efccf4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 01:28:57 -0800 Subject: [PATCH 07/35] coming along nicely --- fastly-edge-assignments/Cargo.toml | 3 +- .../src/handlers/assignments.rs | 119 ++++++++++++------ fastly-edge-assignments/src/main.rs | 12 -- 3 files changed, 81 insertions(+), 53 deletions(-) diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml index 72568806..2ed99cbe 100644 --- a/fastly-edge-assignments/Cargo.toml +++ b/fastly-edge-assignments/Cargo.toml @@ -7,7 +7,8 @@ edition = "2021" publish = false [dependencies] +chrono = "0.4.19" eppo_core = { version = "=4.1.0", path = "../eppo_core" } -fastly = { version = "0.11.0", features = ["logging"] } +fastly = "0.11.0" serde_json = "1.0.132" serde = "1.0.192" diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index a6c2e0f8..eabb4401 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,7 +1,9 @@ +use eppo_core::ufc::{AssignmentValue, UniversalFlagConfig}; +use eppo_core::SdkMetadata; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Deserialize)] @@ -13,17 +15,49 @@ struct RequestBody { bandit_actions: Option>, } +#[derive(Debug, Serialize)] +struct AssignmentsResponse { + assignments: HashMap, + timestamp: i64, +} + const KV_STORE_NAME: &str = "edge-assignment-kv-store"; const SDK_KEY_QUERY_PARAM: &str = "sdk_key"; +fn kv_store_key(api_key: &str) -> String { + format!("ufc-by-sdk-key-{}", api_key) +} + pub fn handle_assignments(mut req: Request) -> Result { - // Parse the apiKey from the request - let api_key = req - .get_query_parameter(SDK_KEY_QUERY_PARAM) - .unwrap_or_default(); + // Extract the API key first before we consume the request + let api_key = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { + Some(key) if !key.is_empty() => key.to_string(), // Convert to owned String + _ => { + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain("Missing required query parameter: sdk_key")); + } + }; - // Parse the request body - let body: RequestBody = serde_json::from_slice(&req.take_body_bytes())?; + // Now we can consume the request body + let body: RequestBody = match serde_json::from_slice::(&req.take_body_bytes()) { + Ok(body) => { + if body.subject_key.is_empty() { + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain("subject_key is required and cannot be empty")); + } + body + } + Err(e) => { + let error_message = if e.to_string().contains("subject_key") { + "subject_key is required in the request body" + } else { + "Invalid request body format" + }; + return Ok( + Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(error_message) + ); + } + }; let subject_key = body.subject_key; let subject_attributes = body.subject_attributes; let bandit_actions = body.bandit_actions; @@ -32,7 +66,7 @@ pub fn handle_assignments(mut req: Request) -> Result { // [Documentation for the KVStore open method can be found here](https://docs.rs/fastly/latest/fastly/struct.KVStore.html#method.open) let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; - let kv_store_item = match kv_store.lookup("my_key") { + let mut kv_store_item = match kv_store.lookup(&kv_store_key(&api_key)) { Ok(item) => item, Err(e) => { let (status, message) = match e { @@ -44,7 +78,7 @@ pub fn handle_assignments(mut req: Request) -> Result { "SDK key not found in KV store".to_string(), ), _ => { - fastly::log::error!("KV Store error: {:?}", e); + //fastly::log::error!("KV Store error: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Unexpected KV Store error".to_string(), @@ -56,39 +90,44 @@ pub fn handle_assignments(mut req: Request) -> Result { } }; - let kv_store_item_body = kv_store_item.take_body(); - // Parse the response from the KV store + let kv_store_item_body = kv_store_item.take_body(); + let ufc_config = match UniversalFlagConfig::from_json( + SdkMetadata { + name: "fastly-edge-assignments", + version: "0.1.0", + }, + kv_store_item_body.into_bytes(), + ) { + Ok(config) => config, + Err(e) => { + //fastly::log::error!("Failed to parse UFC config: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Invalid configuration format in KV store")); + } + }; - //let ufc_config_json: Value = kv_store_item_body.take_body_json()?; - //let ufc_config = parse_ufc_configuration(kv_store_item_body.into_bytes()); - //let client = offline_init(api_key, ufc_config); - - // let flag_keys: Vec = ufc_config_json["flags"] - // .as_object() - // .unwrap() - // .keys() - // .cloned() - // .collect(); - - let flag_keys: Vec = Vec::from("flag1"); - - // let mut assignment_cache: HashMap = HashMap::new(); - - //for flag_key in &flag_keys { - // let subject_key = eppo_core::Str::from(subject_key); - // let assignment = client.get_assignment(flag_key, &subject_key, &subject_attributes); - // let variation_value: eppo::AssignmentValue = match assignment { - // Ok(Some(value)) => value.clone(), - // Ok(None) => eppo::AssignmentValue::Json(Arc::new(json!(null))), - // Err(_) => eppo::AssignmentValue::Json(Arc::new(json!(null))), - // }; - // assignment_cache.insert(flag_key.to_string(), variation_value); - //} - - // Create an HTTP OK response - let response = Response::from_status(StatusCode::OK) - .with_body_text_plain("Request processed successfully"); + let assignments_response = AssignmentsResponse { + // todo: push this to eppo_core + assignments: HashMap::new(), + // assignments: ufc_config + // .compiled + // .flags + // .keys() + // .map(|flag_key| (flag_key.clone(), AssignmentValue::String("test".into()))) + // .collect(), + timestamp: chrono::Utc::now().timestamp(), + }; + // Create an HTTP OK response with the assignments + let response = match Response::from_status(StatusCode::OK).with_body_json(&assignments_response) + { + Ok(response) => response, + Err(e) => { + // fastly::log::error!("Failed to serialize response: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Failed to serialize response")); + } + }; Ok(response) } diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs index 995cb1a1..3aab3b90 100644 --- a/fastly-edge-assignments/src/main.rs +++ b/fastly-edge-assignments/src/main.rs @@ -3,18 +3,6 @@ mod handlers; use fastly::http::{Method, StatusCode}; use fastly::{Error, Request, Response}; -// fn parse_ufc_configuration(ufc_config_json: Vec) -> UniversalFlagConfig { -// //let config_json_bytes: Vec = serde_json::to_vec(&ufc_config_json).unwrap(); -// UniversalFlagConfig::from_json( -// SdkMetadata { -// name: "rust-sdk", -// version: "4.0.1", -// }, -// ufc_config_json, -// ) -// .unwrap() -// } - // fn offline_init(api_key: &str, ufc_config: UniversalFlagConfig) -> eppo::Client { // let config = Configuration::from_server_response(ufc_config, None); // let config_store = eppo_core::configuration_store::ConfigurationStore::new(); From 3a4b644a00807042c03af97316a11de5d484fdc1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 09:59:36 -0800 Subject: [PATCH 08/35] good --- fastly-edge-assignments/src/handlers/assignments.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index eabb4401..4cf335bf 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -58,6 +58,7 @@ pub fn handle_assignments(mut req: Request) -> Result { ); } }; + let subject_key = body.subject_key; let subject_attributes = body.subject_attributes; let bandit_actions = body.bandit_actions; From 59cb251e1d3bd315e0efa0079690708495f38dbe Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 15:07:05 -0800 Subject: [PATCH 09/35] local test kv-store! --- fastly-edge-assignments/fastly.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml index 736e1057..16323feb 100644 --- a/fastly-edge-assignments/fastly.toml +++ b/fastly-edge-assignments/fastly.toml @@ -9,3 +9,9 @@ manifest_version = 3 [scripts] build = "cargo build --bin fastly-edge-assignments --release --target wasm32-wasi --color always" + +[local_server] + [local_server.kv_stores] + [[local_server.kv_stores.edge-assignment-kv-store]] + key = "ufc-by-sdk-key-your_sdk_key_value" + file = "../sdk-test-data/ufc/flags-v1.json" From a159065a58f15100fd377bbbd815b8a4e2372bc4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 10:48:05 -0800 Subject: [PATCH 10/35] feat: add `get_subject_assignments` method to eppo_core to compute all assignments for a given subject against the cached flags (FF-3571) --- eppo_core/src/eval/eval_assignment.rs | 78 +++++++++++++++++++++++++-- eppo_core/src/eval/evaluator.rs | 16 +++++- eppo_core/src/eval/mod.rs | 2 +- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/eppo_core/src/eval/eval_assignment.rs b/eppo_core/src/eval/eval_assignment.rs index 4096190e..de795f05 100644 --- a/eppo_core/src/eval/eval_assignment.rs +++ b/eppo_core/src/eval/eval_assignment.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use chrono::{DateTime, Utc}; @@ -91,6 +91,56 @@ pub fn get_assignment_details( (result_with_details, event) } +pub fn get_subject_assignments( + configuration: Option<&Configuration>, + subject_key: &Str, + subject_attributes: &Arc, + now: DateTime, +) -> HashMap, EvaluationError>> { + get_assignments_with_visitor( + configuration, + &mut NoopEvalVisitor, + subject_key, + subject_attributes, + now, + ) +} + +/** + * Evaluate all flags for the given subject and return a map of flag keys to assignments. + * + * If an error occurs while evaluating a flag, + * it is returned for the resulting flag key. + */ +pub(super) fn get_assignments_with_visitor( + configuration: Option<&Configuration>, + visitor: &mut V, + subject_key: &Str, + subject_attributes: &Arc, + now: DateTime, +) -> HashMap, EvaluationError>> { + configuration.map_or_else(HashMap::new, |config| { + config + .flags + .compiled + .flags + .keys() + .map(|flag_key| { + let result = get_assignment_with_visitor( + Some(&config), + visitor, + flag_key.as_str(), + subject_key, + subject_attributes, + None, + now, + ); + (flag_key.to_string(), result) + }) + .collect() + }) +} + // Exposed for use in bandit evaluation. pub(super) fn get_assignment_with_visitor( configuration: Option<&Configuration>, @@ -317,7 +367,7 @@ mod tests { eval_details::{ AllocationEvaluationCode, AllocationEvaluationDetails, FlagEvaluationCode, }, - get_assignment, get_assignment_details, + get_assignment, get_assignment_details, get_subject_assignments, }, ufc::{RuleWire, UniversalFlagConfig, ValueWire, VariationType}, Attributes, Configuration, SdkMetadata, Str, @@ -451,6 +501,8 @@ mod tests { for subject in test_file.subjects { print!("test subject {:?} ... ", subject.subject_key); + + // Verify that get_assignment returns the desired result. let result = get_assignment( Some(&config), &test_file.flag, @@ -461,7 +513,7 @@ mod tests { ) .unwrap_or(None); - let result_assingment = result + let result_assignment = result .as_ref() .map(|assignment| &assignment.value) .unwrap_or(&default_assignment); @@ -469,7 +521,25 @@ mod tests { .into_assignment_value(test_file.variation_type) .unwrap(); - assert_eq!(result_assingment, &expected_assignment); + assert_eq!(result_assignment, &expected_assignment); + + // Verify that get_subject_assignments returns the same result. + let subject_assignments = get_subject_assignments( + Some(&config), + &subject.subject_key, + &subject.subject_attributes, + now, + ); + let subject_assignment_for_flag = subject_assignments + .get(&test_file.flag) + .and_then(|result| result.as_ref().ok()) + .and_then(|opt| opt.as_ref()) + .map(|a| &a.value) + .unwrap_or(&default_assignment); + + // Compare against the original expected assignment instead of result_assignment + assert_eq!(subject_assignment_for_flag, &expected_assignment); + println!("ok"); } } diff --git a/eppo_core/src/eval/evaluator.rs b/eppo_core/src/eval/evaluator.rs index 431d3f86..f426abb6 100644 --- a/eppo_core/src/eval/evaluator.rs +++ b/eppo_core/src/eval/evaluator.rs @@ -12,7 +12,7 @@ use crate::{ use super::{ eval_details::{EvaluationDetails, EvaluationResultWithDetails}, get_assignment, get_assignment_details, get_bandit_action, get_bandit_action_details, - BanditResult, + get_subject_assignments, BanditResult, }; pub struct EvaluatorConfig { @@ -49,6 +49,20 @@ impl Evaluator { ) } + pub fn get_subject_assignments( + &self, + subject_key: &Str, + subject_attributes: &Arc, + ) -> HashMap, EvaluationError>> { + let config = self.get_configuration(); + get_subject_assignments( + config.as_ref().map(AsRef::as_ref), + subject_key, + subject_attributes, + Utc::now(), + ) + } + pub fn get_assignment_details( &self, flag_key: &str, diff --git a/eppo_core/src/eval/mod.rs b/eppo_core/src/eval/mod.rs index 7d59b864..d27c9f0d 100644 --- a/eppo_core/src/eval/mod.rs +++ b/eppo_core/src/eval/mod.rs @@ -7,6 +7,6 @@ mod evaluator; pub mod eval_details; -pub use eval_assignment::{get_assignment, get_assignment_details}; +pub use eval_assignment::{get_assignment, get_assignment_details, get_subject_assignments}; pub use eval_bandits::{get_bandit_action, get_bandit_action_details, BanditResult}; pub use evaluator::{Evaluator, EvaluatorConfig}; From 6e704ef8036f21fda5a725e761bb20298b1a285b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 11:00:15 -0800 Subject: [PATCH 11/35] simplify unnecessary ownership transfer --- eppo_core/src/eval/eval_assignment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eppo_core/src/eval/eval_assignment.rs b/eppo_core/src/eval/eval_assignment.rs index de795f05..e02ec928 100644 --- a/eppo_core/src/eval/eval_assignment.rs +++ b/eppo_core/src/eval/eval_assignment.rs @@ -129,13 +129,13 @@ pub(super) fn get_assignments_with_visitor( let result = get_assignment_with_visitor( Some(&config), visitor, - flag_key.as_str(), + flag_key, subject_key, subject_attributes, None, now, ); - (flag_key.to_string(), result) + (flag_key.clone(), result) }) .collect() }) From bccce04c89dc3919c3975fc184e848580df9c9a0 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 11:00:15 -0800 Subject: [PATCH 12/35] e2e evaluation --- .../src/handlers/assignments.rs | 50 +++++++++++++++---- fastly-edge-assignments/src/main.rs | 11 ---- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 4cf335bf..3a889f8f 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,15 +1,18 @@ +use eppo_core::configuration_store::ConfigurationStore; +use eppo_core::eval::{Evaluator, EvaluatorConfig}; use eppo_core::ufc::{AssignmentValue, UniversalFlagConfig}; -use eppo_core::SdkMetadata; +use eppo_core::{Attributes, Configuration, SdkMetadata}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::Arc; #[derive(Debug, Deserialize)] struct RequestBody { subject_key: String, - subject_attributes: HashMap, + subject_attributes: Arc, #[serde(rename = "banditActions")] #[serde(skip_serializing_if = "Option::is_none")] bandit_actions: Option>, @@ -108,15 +111,42 @@ pub fn handle_assignments(mut req: Request) -> Result { } }; + let configuration = Configuration::from_server_response(ufc_config, None); + let configuration_store = ConfigurationStore::new(); + configuration_store.set_configuration(Arc::new(configuration)); + let evaluator = Evaluator::new(EvaluatorConfig { + configuration_store: Arc::new(configuration_store), + sdk_metadata: SdkMetadata { + name: "fastly-edge-assignments", + version: "0.1.0", + }, + }); + + let subject_assignments = match evaluator + .get_subject_assignments(&eppo_core::Str::from(subject_key), &subject_attributes) + .into_iter() + .map(|(key, value)| value.map(|opt_assignment| (key, opt_assignment))) + .collect::, _>>() + { + Ok(assignments) => assignments + .into_iter() + .filter_map(|(key, opt_assignment)| { + opt_assignment.map(|assignment| (key, assignment.value)) + }) + .collect::>(), + Err(e) => { + // If we encounter an error in any of the assignments, return an internal server error. + // + // If any of the assignments produces an error during evaluation, the collection will short-circuit and return that first error. + // It won't continue processing the remaining assignments. + //fastly::log::error!("Assignment evaluation error: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain(&format!("Failed to evaluate assignment: {}", e))); + } + }; + let assignments_response = AssignmentsResponse { - // todo: push this to eppo_core - assignments: HashMap::new(), - // assignments: ufc_config - // .compiled - // .flags - // .keys() - // .map(|flag_key| (flag_key.clone(), AssignmentValue::String("test".into()))) - // .collect(), + assignments: subject_assignments, timestamp: chrono::Utc::now().timestamp(), }; diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs index 3aab3b90..f7377b7c 100644 --- a/fastly-edge-assignments/src/main.rs +++ b/fastly-edge-assignments/src/main.rs @@ -3,17 +3,6 @@ mod handlers; use fastly::http::{Method, StatusCode}; use fastly::{Error, Request, Response}; -// fn offline_init(api_key: &str, ufc_config: UniversalFlagConfig) -> eppo::Client { -// let config = Configuration::from_server_response(ufc_config, None); -// let config_store = eppo_core::configuration_store::ConfigurationStore::new(); -// config_store.set_configuration(Arc::new(config)); -// let client = eppo::Client::new_with_configuration_store( -// ClientConfig::from_api_key(api_key), -// config_store.into(), -// ); -// return client; -// } - #[fastly::main] fn main(req: Request) -> Result { match (req.get_method(), req.get_path()) { From c338d31e0568299276a65c12855c5f579e24b43c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 17 Nov 2024 19:31:19 -0800 Subject: [PATCH 13/35] remove space end of filename --- .../{rust-toolchain.toml => rust-toolchain.toml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename fastly-edge-assignments/{rust-toolchain.toml => rust-toolchain.toml} (100%) diff --git a/fastly-edge-assignments/rust-toolchain.toml b/fastly-edge-assignments/rust-toolchain.toml similarity index 100% rename from fastly-edge-assignments/rust-toolchain.toml rename to fastly-edge-assignments/rust-toolchain.toml From ff73c4718ecef81991df4b294a650339ff1726e2 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 18 Nov 2024 13:14:46 -0800 Subject: [PATCH 14/35] Revert "feat: add `get_subject_assignments` method to eppo_core to compute all assignments for a given subject against the cached flags (FF-3571)" This reverts commit a159065a58f15100fd377bbbd815b8a4e2372bc4. --- eppo_core/src/eval/eval_assignment.rs | 78 ++------------------------- eppo_core/src/eval/evaluator.rs | 16 +----- eppo_core/src/eval/mod.rs | 2 +- 3 files changed, 6 insertions(+), 90 deletions(-) diff --git a/eppo_core/src/eval/eval_assignment.rs b/eppo_core/src/eval/eval_assignment.rs index e02ec928..4096190e 100644 --- a/eppo_core/src/eval/eval_assignment.rs +++ b/eppo_core/src/eval/eval_assignment.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use chrono::{DateTime, Utc}; @@ -91,56 +91,6 @@ pub fn get_assignment_details( (result_with_details, event) } -pub fn get_subject_assignments( - configuration: Option<&Configuration>, - subject_key: &Str, - subject_attributes: &Arc, - now: DateTime, -) -> HashMap, EvaluationError>> { - get_assignments_with_visitor( - configuration, - &mut NoopEvalVisitor, - subject_key, - subject_attributes, - now, - ) -} - -/** - * Evaluate all flags for the given subject and return a map of flag keys to assignments. - * - * If an error occurs while evaluating a flag, - * it is returned for the resulting flag key. - */ -pub(super) fn get_assignments_with_visitor( - configuration: Option<&Configuration>, - visitor: &mut V, - subject_key: &Str, - subject_attributes: &Arc, - now: DateTime, -) -> HashMap, EvaluationError>> { - configuration.map_or_else(HashMap::new, |config| { - config - .flags - .compiled - .flags - .keys() - .map(|flag_key| { - let result = get_assignment_with_visitor( - Some(&config), - visitor, - flag_key, - subject_key, - subject_attributes, - None, - now, - ); - (flag_key.clone(), result) - }) - .collect() - }) -} - // Exposed for use in bandit evaluation. pub(super) fn get_assignment_with_visitor( configuration: Option<&Configuration>, @@ -367,7 +317,7 @@ mod tests { eval_details::{ AllocationEvaluationCode, AllocationEvaluationDetails, FlagEvaluationCode, }, - get_assignment, get_assignment_details, get_subject_assignments, + get_assignment, get_assignment_details, }, ufc::{RuleWire, UniversalFlagConfig, ValueWire, VariationType}, Attributes, Configuration, SdkMetadata, Str, @@ -501,8 +451,6 @@ mod tests { for subject in test_file.subjects { print!("test subject {:?} ... ", subject.subject_key); - - // Verify that get_assignment returns the desired result. let result = get_assignment( Some(&config), &test_file.flag, @@ -513,7 +461,7 @@ mod tests { ) .unwrap_or(None); - let result_assignment = result + let result_assingment = result .as_ref() .map(|assignment| &assignment.value) .unwrap_or(&default_assignment); @@ -521,25 +469,7 @@ mod tests { .into_assignment_value(test_file.variation_type) .unwrap(); - assert_eq!(result_assignment, &expected_assignment); - - // Verify that get_subject_assignments returns the same result. - let subject_assignments = get_subject_assignments( - Some(&config), - &subject.subject_key, - &subject.subject_attributes, - now, - ); - let subject_assignment_for_flag = subject_assignments - .get(&test_file.flag) - .and_then(|result| result.as_ref().ok()) - .and_then(|opt| opt.as_ref()) - .map(|a| &a.value) - .unwrap_or(&default_assignment); - - // Compare against the original expected assignment instead of result_assignment - assert_eq!(subject_assignment_for_flag, &expected_assignment); - + assert_eq!(result_assingment, &expected_assignment); println!("ok"); } } diff --git a/eppo_core/src/eval/evaluator.rs b/eppo_core/src/eval/evaluator.rs index f426abb6..431d3f86 100644 --- a/eppo_core/src/eval/evaluator.rs +++ b/eppo_core/src/eval/evaluator.rs @@ -12,7 +12,7 @@ use crate::{ use super::{ eval_details::{EvaluationDetails, EvaluationResultWithDetails}, get_assignment, get_assignment_details, get_bandit_action, get_bandit_action_details, - get_subject_assignments, BanditResult, + BanditResult, }; pub struct EvaluatorConfig { @@ -49,20 +49,6 @@ impl Evaluator { ) } - pub fn get_subject_assignments( - &self, - subject_key: &Str, - subject_attributes: &Arc, - ) -> HashMap, EvaluationError>> { - let config = self.get_configuration(); - get_subject_assignments( - config.as_ref().map(AsRef::as_ref), - subject_key, - subject_attributes, - Utc::now(), - ) - } - pub fn get_assignment_details( &self, flag_key: &str, diff --git a/eppo_core/src/eval/mod.rs b/eppo_core/src/eval/mod.rs index d27c9f0d..7d59b864 100644 --- a/eppo_core/src/eval/mod.rs +++ b/eppo_core/src/eval/mod.rs @@ -7,6 +7,6 @@ mod evaluator; pub mod eval_details; -pub use eval_assignment::{get_assignment, get_assignment_details, get_subject_assignments}; +pub use eval_assignment::{get_assignment, get_assignment_details}; pub use eval_bandits::{get_bandit_action, get_bandit_action_details, BanditResult}; pub use evaluator::{Evaluator, EvaluatorConfig}; From 48d2ebcc857b7a32876ac35c67d5ff058aea119f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 18 Nov 2024 13:51:12 -0800 Subject: [PATCH 15/35] tidy up error logging --- fastly-edge-assignments/fastly.toml | 2 +- .../src/handlers/assignments.rs | 145 +++++++++--------- 2 files changed, 74 insertions(+), 73 deletions(-) diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml index 16323feb..8078d9cb 100644 --- a/fastly-edge-assignments/fastly.toml +++ b/fastly-edge-assignments/fastly.toml @@ -13,5 +13,5 @@ build = "cargo build --bin fastly-edge-assignments --release --target wasm32-was [local_server] [local_server.kv_stores] [[local_server.kv_stores.edge-assignment-kv-store]] - key = "ufc-by-sdk-key-your_sdk_key_value" + key = "ufc-by-sdk-key-prefix-X47WxuYv" file = "../sdk-test-data/ufc/flags-v1.json" diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 3a889f8f..c5a40998 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -13,9 +13,10 @@ use std::sync::Arc; struct RequestBody { subject_key: String, subject_attributes: Arc, - #[serde(rename = "banditActions")] - #[serde(skip_serializing_if = "Option::is_none")] - bandit_actions: Option>, + // TODO: Add bandit actions + // #[serde(rename = "banditActions")] + // #[serde(skip_serializing_if = "Option::is_none")] + // bandit_actions: Option>, } #[derive(Debug, Serialize)] @@ -25,64 +26,71 @@ struct AssignmentsResponse { } const KV_STORE_NAME: &str = "edge-assignment-kv-store"; -const SDK_KEY_QUERY_PARAM: &str = "sdk_key"; +const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` +const SDK_KEY_PREFIX_LENGTH: usize = 8; -fn kv_store_key(api_key: &str) -> String { - format!("ufc-by-sdk-key-{}", api_key) +const SDK_NAME: &str = "fastly-edge-assignments"; +const SDK_VERSION: &str = "0.1.0"; + +fn kv_store_key(sdk_key_prefix: &str) -> String { + format!("ufc-by-sdk-key-prefix-{}", sdk_key_prefix) } pub fn handle_assignments(mut req: Request) -> Result { - // Extract the API key first before we consume the request - let api_key = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { - Some(key) if !key.is_empty() => key.to_string(), // Convert to owned String + // Extract the SDK key prefix first before we consume the request + let sdk_key_prefix = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { + Some(key) if !key.is_empty() => key.chars().take(SDK_KEY_PREFIX_LENGTH).collect::(), _ => { - return Ok(Response::from_status(StatusCode::BAD_REQUEST) - .with_body_text_plain("Missing required query parameter: sdk_key")); - } - }; - - // Now we can consume the request body - let body: RequestBody = match serde_json::from_slice::(&req.take_body_bytes()) { - Ok(body) => { - if body.subject_key.is_empty() { - return Ok(Response::from_status(StatusCode::BAD_REQUEST) - .with_body_text_plain("subject_key is required and cannot be empty")); - } - body - } - Err(e) => { - let error_message = if e.to_string().contains("subject_key") { - "subject_key is required in the request body" - } else { - "Invalid request body format" - }; return Ok( - Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(error_message) + Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( + "Missing required query parameter: {}", + SDK_KEY_QUERY_PARAM + )), ); } }; - let subject_key = body.subject_key; - let subject_attributes = body.subject_attributes; - let bandit_actions = body.bandit_actions; + // Deserialize the request body into a struct + let (subject_key, subject_attributes): (eppo_core::Str, Arc) = + match serde_json::from_slice::(&req.take_body_bytes()) { + Ok(body) => { + if body.subject_key.is_empty() { + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain("subject_key is required and cannot be empty")); + } + ( + eppo_core::Str::from(body.subject_key), + body.subject_attributes, + ) + } + Err(e) => { + let error_message = if e.to_string().contains("subject_key") { + "subject_key is required in the request body" + } else { + "Invalid request body format" + }; + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain(error_message)); + } + }; - // Construct an KVStore instance which is connected to the KV Store named `my-store` - // [Documentation for the KVStore open method can be found here](https://docs.rs/fastly/latest/fastly/struct.KVStore.html#method.open) + // Open the KV store let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; - let mut kv_store_item = match kv_store.lookup(&kv_store_key(&api_key)) { + let mut kv_store_item = match kv_store.lookup(&kv_store_key(&sdk_key_prefix)) { Ok(item) => item, Err(e) => { let (status, message) = match e { - // Return unauthorized if the key does not exist. - // Our protocol lets the client know that the SDK key has not had a UFC - // configuration pre-computed for it in the KV Store. - KVStoreError::ItemNotFound => ( - StatusCode::UNAUTHORIZED, - "SDK key not found in KV store".to_string(), - ), + KVStoreError::ItemNotFound => { + eprintln!("Missing configuration for SDK key: {}", sdk_key_prefix); + + // Return unauthorized if the key does not exist. + // Our protocol lets the client know that the SDK key has not had a UFC + // configuration pre-computed for it in the KV Store. + (StatusCode::UNAUTHORIZED, "Invalid SDK key.".to_string()) + } _ => { - //fastly::log::error!("KV Store error: {:?}", e); + eprintln!("KV Store error: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Unexpected KV Store error".to_string(), @@ -98,53 +106,46 @@ pub fn handle_assignments(mut req: Request) -> Result { let kv_store_item_body = kv_store_item.take_body(); let ufc_config = match UniversalFlagConfig::from_json( SdkMetadata { - name: "fastly-edge-assignments", - version: "0.1.0", + name: SDK_NAME, + version: SDK_VERSION, }, kv_store_item_body.into_bytes(), ) { Ok(config) => config, Err(e) => { - //fastly::log::error!("Failed to parse UFC config: {:?}", e); + eprintln!("Failed to parse UFC config: {:?}", e); return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Invalid configuration format in KV store")); } }; let configuration = Configuration::from_server_response(ufc_config, None); + let flag_keys = configuration.flag_keys(); let configuration_store = ConfigurationStore::new(); configuration_store.set_configuration(Arc::new(configuration)); let evaluator = Evaluator::new(EvaluatorConfig { configuration_store: Arc::new(configuration_store), sdk_metadata: SdkMetadata { - name: "fastly-edge-assignments", - version: "0.1.0", + name: SDK_NAME, + version: SDK_VERSION, }, }); - let subject_assignments = match evaluator - .get_subject_assignments(&eppo_core::Str::from(subject_key), &subject_attributes) - .into_iter() - .map(|(key, value)| value.map(|opt_assignment| (key, opt_assignment))) - .collect::, _>>() - { - Ok(assignments) => assignments - .into_iter() - .filter_map(|(key, opt_assignment)| { - opt_assignment.map(|assignment| (key, assignment.value)) - }) - .collect::>(), - Err(e) => { - // If we encounter an error in any of the assignments, return an internal server error. - // - // If any of the assignments produces an error during evaluation, the collection will short-circuit and return that first error. - // It won't continue processing the remaining assignments. - //fastly::log::error!("Assignment evaluation error: {:?}", e); - return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain(&format!("Failed to evaluate assignment: {}", e))); - } - }; + let subject_assignments = flag_keys + .iter() + .filter_map(|key| { + match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) { + Ok(Some(assignment)) => Some((key.clone(), assignment.value)), + Ok(None) => None, + Err(e) => { + eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); + None + } + } + }) + .collect::>(); + // Create the response let assignments_response = AssignmentsResponse { assignments: subject_assignments, timestamp: chrono::Utc::now().timestamp(), @@ -155,7 +156,7 @@ pub fn handle_assignments(mut req: Request) -> Result { { Ok(response) => response, Err(e) => { - // fastly::log::error!("Failed to serialize response: {:?}", e); + eprintln!("Failed to serialize response: {:?}", e); return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Failed to serialize response")); } From 3219a03ad5a6bfd06ce84f16827b68e891210429 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 20 Nov 2024 08:14:25 -0800 Subject: [PATCH 16/35] edge function dep on 4.1.1 --- fastly-edge-assignments/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml index 2ed99cbe..6ffddecd 100644 --- a/fastly-edge-assignments/Cargo.toml +++ b/fastly-edge-assignments/Cargo.toml @@ -8,7 +8,7 @@ publish = false [dependencies] chrono = "0.4.19" -eppo_core = { version = "=4.1.0", path = "../eppo_core" } +eppo_core = { version = "=4.1.1", path = "../eppo_core" } fastly = "0.11.0" serde_json = "1.0.132" serde = "1.0.192" From d2886969f6d69ca5ac2448255604681f64a78682 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 20 Nov 2024 08:28:23 -0800 Subject: [PATCH 17/35] generate token hash --- fastly-edge-assignments/Cargo.toml | 2 + .../src/handlers/assignments.rs | 38 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml index 6ffddecd..14ff822f 100644 --- a/fastly-edge-assignments/Cargo.toml +++ b/fastly-edge-assignments/Cargo.toml @@ -7,8 +7,10 @@ edition = "2021" publish = false [dependencies] +base64-url = "2.0.0" chrono = "0.4.19" eppo_core = { version = "=4.1.1", path = "../eppo_core" } fastly = "0.11.0" serde_json = "1.0.132" serde = "1.0.192" +sha2 = "0.10.0" diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index c5a40998..63d64e1a 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -6,6 +6,7 @@ use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; @@ -27,19 +28,24 @@ struct AssignmentsResponse { const KV_STORE_NAME: &str = "edge-assignment-kv-store"; const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` -const SDK_KEY_PREFIX_LENGTH: usize = 8; const SDK_NAME: &str = "fastly-edge-assignments"; const SDK_VERSION: &str = "0.1.0"; -fn kv_store_key(sdk_key_prefix: &str) -> String { - format!("ufc-by-sdk-key-prefix-{}", sdk_key_prefix) +fn kv_store_key(token_hash: &str) -> String { + format!("ufc-by-sdk-key-token-hash-{}", token_hash) +} + +fn token_hash(sdk_key: String) -> String { + let mut hasher = Sha256::new(); + hasher.update(sdk_key.as_bytes()); + base64_url::encode(&hasher.finalize()) } pub fn handle_assignments(mut req: Request) -> Result { - // Extract the SDK key prefix first before we consume the request - let sdk_key_prefix = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { - Some(key) if !key.is_empty() => key.chars().take(SDK_KEY_PREFIX_LENGTH).collect::(), + // Extract the SDK key and generate a token hash matching the pre-defined encoding. + let token_hash = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { + Some(key) if !key.is_empty() => token_hash(key.to_string()), _ => { return Ok( Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( @@ -77,12 +83,12 @@ pub fn handle_assignments(mut req: Request) -> Result { // Open the KV store let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; - let mut kv_store_item = match kv_store.lookup(&kv_store_key(&sdk_key_prefix)) { + let mut kv_store_item = match kv_store.lookup(&kv_store_key(&token_hash)) { Ok(item) => item, Err(e) => { let (status, message) = match e { KVStoreError::ItemNotFound => { - eprintln!("Missing configuration for SDK key: {}", sdk_key_prefix); + eprintln!("Missing configuration for SDK key: {}", token_hash); // Return unauthorized if the key does not exist. // Our protocol lets the client know that the SDK key has not had a UFC @@ -163,3 +169,19 @@ pub fn handle_assignments(mut req: Request) -> Result { }; Ok(response) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_hash() { + // Test case with a known SDK key and its expected hash + let sdk_key = "5qCSVzH1lCI11.ZWg9ZDhlYnhsLmV2ZW50cy5lcHBvLmxvY2FsaG9zdA".to_string(); + let expected_hash = "V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM"; + + let result = token_hash(sdk_key); + + assert_eq!(result, expected_hash); + } +} From be2d5ac3360929d81d573e06e77cd2e88581f954 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 20 Nov 2024 13:55:59 -0800 Subject: [PATCH 18/35] fastly local --- fastly-edge-assignments/fastly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml index 8078d9cb..122618f6 100644 --- a/fastly-edge-assignments/fastly.toml +++ b/fastly-edge-assignments/fastly.toml @@ -13,5 +13,5 @@ build = "cargo build --bin fastly-edge-assignments --release --target wasm32-was [local_server] [local_server.kv_stores] [[local_server.kv_stores.edge-assignment-kv-store]] - key = "ufc-by-sdk-key-prefix-X47WxuYv" + key = "ufc-by-sdk-key-token-hash-V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM" file = "../sdk-test-data/ufc/flags-v1.json" From 9f99865192f8165ff1ad519fb5364ca2acb6291c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 20 Nov 2024 22:34:10 -0800 Subject: [PATCH 19/35] conform to desired API --- eppo_core/src/ufc/assignment.rs | 45 +++++++++++++ .../src/handlers/assignments.rs | 66 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/eppo_core/src/ufc/assignment.rs b/eppo_core/src/ufc/assignment.rs index 594472d0..a441c05d 100644 --- a/eppo_core/src/ufc/assignment.rs +++ b/eppo_core/src/ufc/assignment.rs @@ -231,6 +231,51 @@ impl AssignmentValue { _ => None, } } + + /// Returns the type of the variation as a string. + /// + /// # Returns + /// - A string representing the type of the variation ("STRING", "INTEGER", "NUMERIC", "BOOLEAN", or "JSON"). + /// + /// # Examples + /// ``` + /// # use eppo_core::ufc::AssignmentValue; + /// let value = AssignmentValue::String("example".into()); + /// assert_eq!(value.variation_type(), "STRING"); + /// ``` + pub fn variation_type(&self) -> &'static str { + match self { + AssignmentValue::String(_) => "STRING", + AssignmentValue::Integer(_) => "INTEGER", + AssignmentValue::Numeric(_) => "NUMERIC", + AssignmentValue::Boolean(_) => "BOOLEAN", + AssignmentValue::Json(_) => "JSON", + } + } + + /// Returns the raw value of the variation. + /// + /// # Returns + /// - A JSON Value containing the variation value. + /// + /// # Examples + /// ``` + /// # use eppo_core::ufc::AssignmentValue; + /// # use serde_json::json; + /// let value = AssignmentValue::String("example".into()); + /// assert_eq!(value.variation_value(), json!("example")); + /// ``` + pub fn variation_value(&self) -> serde_json::Value { + match self { + AssignmentValue::String(s) => serde_json::Value::String(s.to_string()), + AssignmentValue::Integer(i) => serde_json::Value::Number((*i).into()), + AssignmentValue::Numeric(n) => serde_json::Value::Number( + serde_json::Number::from_f64(*n).unwrap_or_else(|| serde_json::Number::from(0)), + ), + AssignmentValue::Boolean(b) => serde_json::Value::Bool(*b), + AssignmentValue::Json(j) => j.as_ref().clone(), + } + } } #[cfg(feature = "pyo3")] diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 63d64e1a..359c141b 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,6 +1,6 @@ use eppo_core::configuration_store::ConfigurationStore; use eppo_core::eval::{Evaluator, EvaluatorConfig}; -use eppo_core::ufc::{AssignmentValue, UniversalFlagConfig}; +use eppo_core::ufc::UniversalFlagConfig; use eppo_core::{Attributes, Configuration, SdkMetadata}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; @@ -20,10 +20,37 @@ struct RequestBody { // bandit_actions: Option>, } +// Response + +#[derive(Debug, Serialize)] +#[serde(rename_all = "UPPERCASE")] +enum AssignmentFormat { + Precomputed, +} + +#[derive(Debug, Serialize)] +struct Environment { + name: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FlagAssignment { + allocation_key: String, + variation_key: String, + variation_type: String, + variation_value: serde_json::Value, + extra_logging: HashMap, + do_log: bool, +} + #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] struct AssignmentsResponse { - assignments: HashMap, - timestamp: i64, + created_at: i64, + format: AssignmentFormat, + environment: Environment, + flags: HashMap, } const KV_STORE_NAME: &str = "edge-assignment-kv-store"; @@ -141,7 +168,28 @@ pub fn handle_assignments(mut req: Request) -> Result { .iter() .filter_map(|key| { match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) { - Ok(Some(assignment)) => Some((key.clone(), assignment.value)), + Ok(Some(assignment)) => { + // Extract event data if available, otherwise skip this assignment + assignment.event.as_ref().map(|event| { + ( + key.clone(), + FlagAssignment { + allocation_key: event.base.allocation.to_string(), + variation_key: event.base.variation.to_string(), + // TODO: We need to get the variation type from the UFC config. + variation_type: assignment.value.variation_type().to_string(), + variation_value: assignment.value.variation_value(), + extra_logging: event + .base + .extra_logging + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(), + do_log: true, + }, + ) + }) + } Ok(None) => None, Err(e) => { eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); @@ -153,8 +201,14 @@ pub fn handle_assignments(mut req: Request) -> Result { // Create the response let assignments_response = AssignmentsResponse { - assignments: subject_assignments, - timestamp: chrono::Utc::now().timestamp(), + created_at: chrono::Utc::now().timestamp(), + format: AssignmentFormat::Precomputed, + // TODO: Need to figure out how to access the environment name. + // from the UFC configuration but it's not public in the compiled config. + environment: Environment { + name: "UNKNOWN".to_string(), + }, + flags: subject_assignments, }; // Create an HTTP OK response with the assignments From 78fe3b53301774d43f1fae1d6455359aec7e99a9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 11:35:34 -0800 Subject: [PATCH 20/35] add CORS support --- fastly-edge-assignments/src/main.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs index f7377b7c..665a755c 100644 --- a/fastly-edge-assignments/src/main.rs +++ b/fastly-edge-assignments/src/main.rs @@ -5,9 +5,24 @@ use fastly::{Error, Request, Response}; #[fastly::main] fn main(req: Request) -> Result { - match (req.get_method(), req.get_path()) { + // Handle CORS preflight requests + if req.get_method() == Method::OPTIONS { + return Ok(Response::from_status(StatusCode::NO_CONTENT) + .with_header("Access-Control-Allow-Origin", "*") + .with_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .with_header("Access-Control-Allow-Headers", "Content-Type") + .with_header("Access-Control-Max-Age", "86400")); + } + + // Handle regular requests and add CORS headers to the response + let response = match (req.get_method(), req.get_path()) { (&Method::POST, "/assignments") => handlers::handle_assignments(req), (&Method::GET, "/health") => handlers::handle_health(req), _ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body_text_plain("Not Found")), - } + }?; + + // Add CORS headers to all responses + Ok(response + .with_header("Access-Control-Allow-Origin", "*") + .with_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")) } From f2f27af4bc74c93cb1eb757a4df2297002dfce9a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 16:29:21 -0800 Subject: [PATCH 21/35] version from cargo --- fastly-edge-assignments/src/handlers/assignments.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 359c141b..d0cc879c 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -57,7 +57,7 @@ const KV_STORE_NAME: &str = "edge-assignment-kv-store"; const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` const SDK_NAME: &str = "fastly-edge-assignments"; -const SDK_VERSION: &str = "0.1.0"; +const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); fn kv_store_key(token_hash: &str) -> String { format!("ufc-by-sdk-key-token-hash-{}", token_hash) From f1bcf3d0e83eb2b4bbd5bfb806c9323aa4be65ee Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 17:07:51 -0800 Subject: [PATCH 22/35] use Datetime --- fastly-edge-assignments/src/handlers/assignments.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index d0cc879c..34787208 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -47,7 +47,7 @@ struct FlagAssignment { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AssignmentsResponse { - created_at: i64, + created_at: chrono::DateTime, format: AssignmentFormat, environment: Environment, flags: HashMap, @@ -201,7 +201,7 @@ pub fn handle_assignments(mut req: Request) -> Result { // Create the response let assignments_response = AssignmentsResponse { - created_at: chrono::Utc::now().timestamp(), + created_at: chrono::Utc::now(), format: AssignmentFormat::Precomputed, // TODO: Need to figure out how to access the environment name. // from the UFC configuration but it's not public in the compiled config. From 4ada237816a543bbfc308a8a6c683b39302ac751 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 17:28:22 -0800 Subject: [PATCH 23/35] String --- fastly-edge-assignments/src/handlers/assignments.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 34787208..08e22b88 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -59,7 +59,7 @@ const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named const SDK_NAME: &str = "fastly-edge-assignments"; const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); -fn kv_store_key(token_hash: &str) -> String { +fn kv_store_key(token_hash: String) -> String { format!("ufc-by-sdk-key-token-hash-{}", token_hash) } @@ -110,7 +110,7 @@ pub fn handle_assignments(mut req: Request) -> Result { // Open the KV store let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; - let mut kv_store_item = match kv_store.lookup(&kv_store_key(&token_hash)) { + let mut kv_store_item = match kv_store.lookup(&kv_store_key(token_hash.clone())) { Ok(item) => item, Err(e) => { let (status, message) = match e { From 81019882a960f82682022e986e0ff64bb334da12 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 17:29:39 -0800 Subject: [PATCH 24/35] no copy --- fastly-edge-assignments/src/handlers/assignments.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 08e22b88..63f009aa 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -63,7 +63,7 @@ fn kv_store_key(token_hash: String) -> String { format!("ufc-by-sdk-key-token-hash-{}", token_hash) } -fn token_hash(sdk_key: String) -> String { +fn token_hash(sdk_key: &str) -> String { let mut hasher = Sha256::new(); hasher.update(sdk_key.as_bytes()); base64_url::encode(&hasher.finalize()) @@ -72,7 +72,7 @@ fn token_hash(sdk_key: String) -> String { pub fn handle_assignments(mut req: Request) -> Result { // Extract the SDK key and generate a token hash matching the pre-defined encoding. let token_hash = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { - Some(key) if !key.is_empty() => token_hash(key.to_string()), + Some(key) if !key.is_empty() => token_hash(key), _ => { return Ok( Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( @@ -231,7 +231,7 @@ mod tests { #[test] fn test_token_hash() { // Test case with a known SDK key and its expected hash - let sdk_key = "5qCSVzH1lCI11.ZWg9ZDhlYnhsLmV2ZW50cy5lcHBvLmxvY2FsaG9zdA".to_string(); + let sdk_key = "5qCSVzH1lCI11.ZWg9ZDhlYnhsLmV2ZW50cy5lcHBvLmxvY2FsaG9zdA"; let expected_hash = "V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM"; let result = token_hash(sdk_key); From 50ed0eccf7cfd4f8230b563d797489e1eb1e1608 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 17:33:49 -0800 Subject: [PATCH 25/35] use VariationType --- eppo_core/src/ufc/assignment.rs | 17 ++++++++++------- .../src/handlers/assignments.rs | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/eppo_core/src/ufc/assignment.rs b/eppo_core/src/ufc/assignment.rs index a441c05d..65b76b7b 100644 --- a/eppo_core/src/ufc/assignment.rs +++ b/eppo_core/src/ufc/assignment.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::{events::AssignmentEvent, Str}; +use crate::ufc::VariationType; + /// Result of assignment evaluation. #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -240,16 +242,17 @@ impl AssignmentValue { /// # Examples /// ``` /// # use eppo_core::ufc::AssignmentValue; + /// # use eppo_core::ufc::VariationType; /// let value = AssignmentValue::String("example".into()); - /// assert_eq!(value.variation_type(), "STRING"); + /// assert_eq!(value.variation_type(), VariationType::String); /// ``` - pub fn variation_type(&self) -> &'static str { + pub fn variation_type(&self) -> VariationType { match self { - AssignmentValue::String(_) => "STRING", - AssignmentValue::Integer(_) => "INTEGER", - AssignmentValue::Numeric(_) => "NUMERIC", - AssignmentValue::Boolean(_) => "BOOLEAN", - AssignmentValue::Json(_) => "JSON", + AssignmentValue::String(_) => VariationType::String, + AssignmentValue::Integer(_) => VariationType::Integer, + AssignmentValue::Numeric(_) => VariationType::Numeric, + AssignmentValue::Boolean(_) => VariationType::Boolean, + AssignmentValue::Json(_) => VariationType::Json, } } diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 63f009aa..dbb63c14 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,6 +1,6 @@ use eppo_core::configuration_store::ConfigurationStore; use eppo_core::eval::{Evaluator, EvaluatorConfig}; -use eppo_core::ufc::UniversalFlagConfig; +use eppo_core::ufc::{UniversalFlagConfig, VariationType}; use eppo_core::{Attributes, Configuration, SdkMetadata}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; @@ -38,7 +38,7 @@ struct Environment { struct FlagAssignment { allocation_key: String, variation_key: String, - variation_type: String, + variation_type: VariationType, variation_value: serde_json::Value, extra_logging: HashMap, do_log: bool, @@ -177,7 +177,7 @@ pub fn handle_assignments(mut req: Request) -> Result { allocation_key: event.base.allocation.to_string(), variation_key: event.base.variation.to_string(), // TODO: We need to get the variation type from the UFC config. - variation_type: assignment.value.variation_type().to_string(), + variation_type: assignment.value.variation_type(), variation_value: assignment.value.variation_value(), extra_logging: event .base From 38f71eb09a40d95a8bb3c508b43c99037c317692 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 18:25:15 -0800 Subject: [PATCH 26/35] move structs to core --- eppo_core/src/lib.rs | 1 + eppo_core/src/precomputed_assignments.rs | 52 ++++++++++++++ eppo_core/src/ufc/models.rs | 9 +++ .../src/handlers/assignments.rs | 69 ++++--------------- 4 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 eppo_core/src/precomputed_assignments.rs diff --git a/eppo_core/src/lib.rs b/eppo_core/src/lib.rs index fe1d770a..a4bbf30d 100644 --- a/eppo_core/src/lib.rs +++ b/eppo_core/src/lib.rs @@ -55,6 +55,7 @@ pub mod configuration_store; pub mod eval; pub mod events; pub mod poller_thread; +pub mod precomputed_assignments; #[cfg(feature = "pyo3")] pub mod pyo3; pub mod sharder; diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs new file mode 100644 index 00000000..831b1724 --- /dev/null +++ b/eppo_core/src/precomputed_assignments.rs @@ -0,0 +1,52 @@ +use crate::ufc::{AssignmentFormat, Environment, VariationType}; +use crate::{Attributes, Str}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; + +// Request +#[derive(Debug, Deserialize)] +pub struct PrecomputedAssignmentsServiceRequestBody { + pub subject_key: String, + pub subject_attributes: Arc, + // TODO: Add bandit actions + // #[serde(rename = "banditActions")] + // #[serde(skip_serializing_if = "Option::is_none")] + // bandit_actions: Option>, +} + +// Response +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlagAssignment { + pub allocation_key: String, + pub variation_key: String, + pub variation_type: VariationType, + pub variation_value: serde_json::Value, + pub extra_logging: HashMap, + pub do_log: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrecomputedAssignmentsServiceResponse { + created_at: chrono::DateTime, + format: AssignmentFormat, + environment: Environment, + flags: HashMap, +} + +impl PrecomputedAssignmentsServiceResponse { + pub fn new(environment_name: Str, flags: HashMap) -> Self { + Self { + created_at: chrono::Utc::now(), + format: AssignmentFormat::Precomputed, + environment: { + Environment { + name: environment_name, + } + }, + flags, + } + } +} diff --git a/eppo_core/src/ufc/models.rs b/eppo_core/src/ufc/models.rs index 1c2d5c98..9e5b093a 100644 --- a/eppo_core/src/ufc/models.rs +++ b/eppo_core/src/ufc/models.rs @@ -18,6 +18,7 @@ pub type Timestamp = chrono::DateTime; pub(crate) struct UniversalFlagConfigWire { /// When configuration was last updated. pub created_at: Timestamp, + pub format: AssignmentFormat, /// Environment this configuration belongs to. pub environment: Environment, /// Flags configuration. @@ -31,6 +32,14 @@ pub(crate) struct UniversalFlagConfigWire { pub bandits: HashMap>, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum AssignmentFormat { + Client, + Precomputed, + Server, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Environment { diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index dbb63c14..eaaaa597 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -1,58 +1,17 @@ use eppo_core::configuration_store::ConfigurationStore; use eppo_core::eval::{Evaluator, EvaluatorConfig}; -use eppo_core::ufc::{UniversalFlagConfig, VariationType}; -use eppo_core::{Attributes, Configuration, SdkMetadata}; +use eppo_core::precomputed_assignments::{ + FlagAssignment, PrecomputedAssignmentsServiceRequestBody, PrecomputedAssignmentsServiceResponse, +}; +use eppo_core::ufc::UniversalFlagConfig; +use eppo_core::{Attributes, Configuration, SdkMetadata, Str}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; -#[derive(Debug, Deserialize)] -struct RequestBody { - subject_key: String, - subject_attributes: Arc, - // TODO: Add bandit actions - // #[serde(rename = "banditActions")] - // #[serde(skip_serializing_if = "Option::is_none")] - // bandit_actions: Option>, -} - -// Response - -#[derive(Debug, Serialize)] -#[serde(rename_all = "UPPERCASE")] -enum AssignmentFormat { - Precomputed, -} - -#[derive(Debug, Serialize)] -struct Environment { - name: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlagAssignment { - allocation_key: String, - variation_key: String, - variation_type: VariationType, - variation_value: serde_json::Value, - extra_logging: HashMap, - do_log: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AssignmentsResponse { - created_at: chrono::DateTime, - format: AssignmentFormat, - environment: Environment, - flags: HashMap, -} - const KV_STORE_NAME: &str = "edge-assignment-kv-store"; const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` @@ -85,7 +44,9 @@ pub fn handle_assignments(mut req: Request) -> Result { // Deserialize the request body into a struct let (subject_key, subject_attributes): (eppo_core::Str, Arc) = - match serde_json::from_slice::(&req.take_body_bytes()) { + match serde_json::from_slice::( + &req.take_body_bytes(), + ) { Ok(body) => { if body.subject_key.is_empty() { return Ok(Response::from_status(StatusCode::BAD_REQUEST) @@ -200,16 +161,10 @@ pub fn handle_assignments(mut req: Request) -> Result { .collect::>(); // Create the response - let assignments_response = AssignmentsResponse { - created_at: chrono::Utc::now(), - format: AssignmentFormat::Precomputed, - // TODO: Need to figure out how to access the environment name. - // from the UFC configuration but it's not public in the compiled config. - environment: Environment { - name: "UNKNOWN".to_string(), - }, - flags: subject_assignments, - }; + let assignments_response = PrecomputedAssignmentsServiceResponse::new( + Str::from_static_str("UNKNOWN"), + subject_assignments, + ); // Create an HTTP OK response with the assignments let response = match Response::from_status(StatusCode::OK).with_body_json(&assignments_response) From 266de8aa00aa004026f24e63be0eb17052b64144 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 18:42:56 -0800 Subject: [PATCH 27/35] extra logging --- eppo_core/src/precomputed_assignments.rs | 5 ++++- fastly-edge-assignments/src/handlers/assignments.rs | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs index 831b1724..5f17d773 100644 --- a/eppo_core/src/precomputed_assignments.rs +++ b/eppo_core/src/precomputed_assignments.rs @@ -23,7 +23,10 @@ pub struct FlagAssignment { pub variation_key: String, pub variation_type: VariationType, pub variation_value: serde_json::Value, - pub extra_logging: HashMap, + /// Additional user-defined logging fields for capturing extra information related to the + /// assignment. + #[serde(flatten)] + pub extra_logging: HashMap, pub do_log: bool, } diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index eaaaa597..a2a738b1 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -137,14 +137,13 @@ pub fn handle_assignments(mut req: Request) -> Result { FlagAssignment { allocation_key: event.base.allocation.to_string(), variation_key: event.base.variation.to_string(), - // TODO: We need to get the variation type from the UFC config. variation_type: assignment.value.variation_type(), variation_value: assignment.value.variation_value(), extra_logging: event .base .extra_logging .iter() - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .map(|(k, v)| (k.clone(), v.clone())) .collect(), do_log: true, }, From c3c90f89c69892162bc9f98895ba37bac28dcfc7 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 18:48:06 -0800 Subject: [PATCH 28/35] refactor FlagAssignment to model --- eppo_core/src/precomputed_assignments.rs | 22 +++++++++++++++++- .../src/handlers/assignments.rs | 23 ++----------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs index 5f17d773..ff141604 100644 --- a/eppo_core/src/precomputed_assignments.rs +++ b/eppo_core/src/precomputed_assignments.rs @@ -1,4 +1,5 @@ -use crate::ufc::{AssignmentFormat, Environment, VariationType}; +use crate::events::AssignmentEvent; +use crate::ufc::{Assignment, AssignmentFormat, Environment, VariationType}; use crate::{Attributes, Str}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -30,6 +31,25 @@ pub struct FlagAssignment { pub do_log: bool, } +impl FlagAssignment { + pub fn try_from_assignment(assignment: Assignment) -> Option { + // Extract event data if available, otherwise return None + assignment.event.as_ref().map(|event| Self { + allocation_key: event.base.allocation.to_string(), + variation_key: event.base.variation.to_string(), + variation_type: assignment.value.variation_type(), + variation_value: assignment.value.variation_value(), + extra_logging: event + .base + .extra_logging + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + do_log: true, + }) + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PrecomputedAssignmentsServiceResponse { diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index a2a738b1..50f8066c 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -129,27 +129,8 @@ pub fn handle_assignments(mut req: Request) -> Result { .iter() .filter_map(|key| { match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) { - Ok(Some(assignment)) => { - // Extract event data if available, otherwise skip this assignment - assignment.event.as_ref().map(|event| { - ( - key.clone(), - FlagAssignment { - allocation_key: event.base.allocation.to_string(), - variation_key: event.base.variation.to_string(), - variation_type: assignment.value.variation_type(), - variation_value: assignment.value.variation_value(), - extra_logging: event - .base - .extra_logging - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - do_log: true, - }, - ) - }) - } + Ok(Some(assignment)) => FlagAssignment::try_from_assignment(assignment) + .map(|flag_assignment| (key.clone(), flag_assignment)), Ok(None) => None, Err(e) => { eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); From b9ca60af9938f0fad8414fe52883f9c6422015d1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 18:49:34 -0800 Subject: [PATCH 29/35] use Str --- eppo_core/src/precomputed_assignments.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs index ff141604..e275f813 100644 --- a/eppo_core/src/precomputed_assignments.rs +++ b/eppo_core/src/precomputed_assignments.rs @@ -1,4 +1,3 @@ -use crate::events::AssignmentEvent; use crate::ufc::{Assignment, AssignmentFormat, Environment, VariationType}; use crate::{Attributes, Str}; use serde::{Deserialize, Serialize}; @@ -20,8 +19,8 @@ pub struct PrecomputedAssignmentsServiceRequestBody { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct FlagAssignment { - pub allocation_key: String, - pub variation_key: String, + pub allocation_key: Str, + pub variation_key: Str, pub variation_type: VariationType, pub variation_value: serde_json::Value, /// Additional user-defined logging fields for capturing extra information related to the @@ -35,8 +34,8 @@ impl FlagAssignment { pub fn try_from_assignment(assignment: Assignment) -> Option { // Extract event data if available, otherwise return None assignment.event.as_ref().map(|event| Self { - allocation_key: event.base.allocation.to_string(), - variation_key: event.base.variation.to_string(), + allocation_key: event.base.allocation.clone(), + variation_key: event.base.variation.clone(), variation_type: assignment.value.variation_type(), variation_value: assignment.value.variation_value(), extra_logging: event From 26300db56bfe735f17c143eef416ba85cfec4ba1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:31:31 -0800 Subject: [PATCH 30/35] build responses in core --- eppo_core/src/precomputed_assignments.rs | 20 +++++++++++++++---- .../src/handlers/assignments.rs | 9 +++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs index e275f813..5490ae56 100644 --- a/eppo_core/src/precomputed_assignments.rs +++ b/eppo_core/src/precomputed_assignments.rs @@ -1,5 +1,5 @@ use crate::ufc::{Assignment, AssignmentFormat, Environment, VariationType}; -use crate::{Attributes, Str}; +use crate::{Attributes, Configuration, Str}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -32,7 +32,16 @@ pub struct FlagAssignment { impl FlagAssignment { pub fn try_from_assignment(assignment: Assignment) -> Option { - // Extract event data if available, otherwise return None + // WARNING! There is a problem here. The event is only populated for splits + // that have `do_log` set to true in the wire format. This means that + // all the ones present here are logged, but any splits that are not + // logged are not present here. + // + // This is a problem for us because we want to be able to return + // precomputed assignments for any split, logged or not, since we + // want to be able to return them for all flags. + // + // We need to fix this. assignment.event.as_ref().map(|event| Self { allocation_key: event.base.allocation.clone(), variation_key: event.base.variation.clone(), @@ -59,13 +68,16 @@ pub struct PrecomputedAssignmentsServiceResponse { } impl PrecomputedAssignmentsServiceResponse { - pub fn new(environment_name: Str, flags: HashMap) -> Self { + pub fn from_configuration( + configuration: Arc, + flags: HashMap, + ) -> Self { Self { created_at: chrono::Utc::now(), format: AssignmentFormat::Precomputed, environment: { Environment { - name: environment_name, + name: configuration.flags.compiled.environment.name.clone(), } }, flags, diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 50f8066c..3bc54684 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -4,7 +4,7 @@ use eppo_core::precomputed_assignments::{ FlagAssignment, PrecomputedAssignmentsServiceRequestBody, PrecomputedAssignmentsServiceResponse, }; use eppo_core::ufc::UniversalFlagConfig; -use eppo_core::{Attributes, Configuration, SdkMetadata, Str}; +use eppo_core::{Attributes, Configuration, SdkMetadata}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; @@ -114,9 +114,10 @@ pub fn handle_assignments(mut req: Request) -> Result { }; let configuration = Configuration::from_server_response(ufc_config, None); + let configuration = Arc::new(configuration); let flag_keys = configuration.flag_keys(); let configuration_store = ConfigurationStore::new(); - configuration_store.set_configuration(Arc::new(configuration)); + configuration_store.set_configuration(configuration.clone()); let evaluator = Evaluator::new(EvaluatorConfig { configuration_store: Arc::new(configuration_store), sdk_metadata: SdkMetadata { @@ -141,8 +142,8 @@ pub fn handle_assignments(mut req: Request) -> Result { .collect::>(); // Create the response - let assignments_response = PrecomputedAssignmentsServiceResponse::new( - Str::from_static_str("UNKNOWN"), + let assignments_response = PrecomputedAssignmentsServiceResponse::from_configuration( + configuration, subject_assignments, ); From 26c446627981920d06ebf9a97ac72dadf80e4a67 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:36:01 -0800 Subject: [PATCH 31/35] tidy --- .../src/handlers/assignments.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index 3bc54684..eb08ab7e 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -30,17 +30,18 @@ fn token_hash(sdk_key: &str) -> String { pub fn handle_assignments(mut req: Request) -> Result { // Extract the SDK key and generate a token hash matching the pre-defined encoding. - let token_hash = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) { - Some(key) if !key.is_empty() => token_hash(key), - _ => { - return Ok( - Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( - "Missing required query parameter: {}", - SDK_KEY_QUERY_PARAM - )), - ); - } + let Some(token) = req + .get_query_parameter(SDK_KEY_QUERY_PARAM) + .filter(|it| !it.is_empty()) + else { + return Ok( + Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( + "Missing required query parameter: {}", + SDK_KEY_QUERY_PARAM + )), + ); }; + let token_hash = token_hash(token); // Deserialize the request body into a struct let (subject_key, subject_attributes): (eppo_core::Str, Arc) = From 7b4eb91f4597b5da68981e8aa72daf03b1b3c1b6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:41:15 -0800 Subject: [PATCH 32/35] add format --- eppo_core/src/ufc/models.rs | 1 + python-sdk/tests/test_initial_configuration.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eppo_core/src/ufc/models.rs b/eppo_core/src/ufc/models.rs index 9e5b093a..2a024a94 100644 --- a/eppo_core/src/ufc/models.rs +++ b/eppo_core/src/ufc/models.rs @@ -549,6 +549,7 @@ mod tests { &r#" { "createdAt": "2024-07-18T00:00:00Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": { "success": { diff --git a/python-sdk/tests/test_initial_configuration.py b/python-sdk/tests/test_initial_configuration.py index cd9a2885..7624bc0e 100644 --- a/python-sdk/tests/test_initial_configuration.py +++ b/python-sdk/tests/test_initial_configuration.py @@ -22,7 +22,7 @@ def test_with_initial_configuration(): base_url="http://localhost:8378/api", assignment_logger=AssignmentLogger(), initial_configuration=Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":{}}' ), ) ) From 95219d40abe36ac165d3896ccc68f1889d691283 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:44:14 -0800 Subject: [PATCH 33/35] str --- eppo_core/src/precomputed_assignments.rs | 2 +- fastly-edge-assignments/src/handlers/assignments.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs index 5490ae56..d5e7f0e7 100644 --- a/eppo_core/src/precomputed_assignments.rs +++ b/eppo_core/src/precomputed_assignments.rs @@ -7,7 +7,7 @@ use std::sync::Arc; // Request #[derive(Debug, Deserialize)] pub struct PrecomputedAssignmentsServiceRequestBody { - pub subject_key: String, + pub subject_key: Str, pub subject_attributes: Arc, // TODO: Add bandit actions // #[serde(rename = "banditActions")] diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs index eb08ab7e..fe3ae064 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -53,10 +53,7 @@ pub fn handle_assignments(mut req: Request) -> Result { return Ok(Response::from_status(StatusCode::BAD_REQUEST) .with_body_text_plain("subject_key is required and cannot be empty")); } - ( - eppo_core::Str::from(body.subject_key), - body.subject_attributes, - ) + (body.subject_key, body.subject_attributes) } Err(e) => { let error_message = if e.to_string().contains("subject_key") { From 81d1f55bc2b53a95fff4998c616e93a370cba21e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:45:30 -0800 Subject: [PATCH 34/35] format --- python-sdk/tests/test_configuration.py | 2 +- python-sdk/tests/test_initial_configuration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python-sdk/tests/test_configuration.py b/python-sdk/tests/test_configuration.py index 81f9087f..9b5887d6 100644 --- a/python-sdk/tests/test_configuration.py +++ b/python-sdk/tests/test_configuration.py @@ -112,7 +112,7 @@ def test_init_invalid_format(self): """flags is specified as array instead of object""" with pytest.raises(Exception): Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":[]}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":[]}' ) def test_get_bandits_configuration(self): diff --git a/python-sdk/tests/test_initial_configuration.py b/python-sdk/tests/test_initial_configuration.py index 7624bc0e..cea252e9 100644 --- a/python-sdk/tests/test_initial_configuration.py +++ b/python-sdk/tests/test_initial_configuration.py @@ -40,7 +40,7 @@ def test_update_configuration(): client.set_configuration( Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":{}}' ) ) From 9622a21c5842b0e621dde10c54710bc17f4ba005 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 22 Nov 2024 19:54:49 -0800 Subject: [PATCH 35/35] one more format --- python-sdk/tests/test_configuration.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python-sdk/tests/test_configuration.py b/python-sdk/tests/test_configuration.py index 9b5887d6..1f55661e 100644 --- a/python-sdk/tests/test_configuration.py +++ b/python-sdk/tests/test_configuration.py @@ -8,6 +8,7 @@ FLAGS_CONFIG = json.dumps( { "createdAt": "2024-09-09T10:18:15.988Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": {}, } @@ -16,6 +17,7 @@ FLAGS_CONFIG_WITH_BANDITS = json.dumps( { "createdAt": "2024-09-09T10:18:15.988Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": {}, "bandits": { @@ -145,5 +147,6 @@ def test_configuration_some(): flag_config = configuration.get_flags_configuration() result = json.loads(flag_config) + assert result["format"] == "SERVER" assert result["environment"] == {"name": "Test"} assert "numeric_flag" in result["flags"]