diff --git a/Cargo.lock b/Cargo.lock index d1c0f2f3d..28ed9787d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,17 +351,32 @@ dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", - "anstyle-wincon", + "anstyle-wincon 1.0.1", "colorchoice", "is-terminal", "utf8parse", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon 3.0.11", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -391,6 +406,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.75" @@ -1537,7 +1563,7 @@ version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1458a1df40e1e2afebb7ab60ce55c1fa8f431146205aa5f4887e0b111c27636" dependencies = [ - "anstream", + "anstream 0.3.2", "anstyle", "bitflags 1.3.2", "clap_lex 0.5.0", @@ -2292,6 +2318,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c012a26a7f605efc424dd53697843a72be7dc86ad2d01f7814337794a12231d" +dependencies = [ + "anstream 0.6.21", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -2587,6 +2636,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.28" @@ -3048,6 +3106,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "0.14.26" @@ -3240,6 +3304,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.9.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3299,6 +3383,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "iso8601" version = "0.6.1" @@ -3430,6 +3520,26 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -3914,6 +4024,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -3989,6 +4100,33 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 1.0.2", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.9.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -4148,6 +4286,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -5828,7 +5972,10 @@ dependencies = [ "async-trait", "aws-smithy-types", "chrono", + "derive_more 0.99.17", + "env_logger", "log", + "notify", "open-feature", "reqwest", "serde", @@ -6719,9 +6866,9 @@ checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" @@ -7021,6 +7168,12 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.42.0" @@ -7063,6 +7216,24 @@ dependencies = [ "windows-targets 0.52.0", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -7108,6 +7279,23 @@ dependencies = [ "windows_x86_64_msvc 0.52.0", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7126,6 +7314,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -7144,6 +7338,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -7162,6 +7362,18 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -7180,6 +7392,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -7198,6 +7416,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -7216,6 +7440,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7234,6 +7464,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/crates/cac_client/src/lib.rs b/crates/cac_client/src/lib.rs index 5848d1f57..ce40eaec5 100644 --- a/crates/cac_client/src/lib.rs +++ b/crates/cac_client/src/lib.rs @@ -160,20 +160,11 @@ impl Client { prefix: Option>, ) -> Result { let cac = self.config.read().await; - let mut config = cac.to_owned(); - if let Some(prefix_list) = prefix { - config = config.filter_by_prefix(&HashSet::from_iter(prefix_list)); - } - - let dimension_filtered_config = query_data - .filter(|query_map| !query_map.is_empty()) - .map(|query_map| config.filter_by_dimensions(&query_map)); - - if let Some(filtered_config) = dimension_filtered_config { - config = filtered_config; - }; + let filtered_config = cac + .to_owned() + .filter(query_data.as_ref(), prefix.map(HashSet::from_iter).as_ref()); - Ok(config) + Ok(filtered_config) } pub async fn get_last_modified(&self) -> DateTime { diff --git a/crates/context_aware_config/src/helpers.rs b/crates/context_aware_config/src/helpers.rs index 981c55abe..38072c4bf 100644 --- a/crates/context_aware_config/src/helpers.rs +++ b/crates/context_aware_config/src/helpers.rs @@ -6,11 +6,14 @@ use actix_web::{ }; use chrono::Utc; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; -use fred::{interfaces::KeysInterface, types::Expiration}; +use fred::types::Expiration; use serde_json::{Map, Value, json}; use service_utils::{ helpers::get_from_env_or_default, - redis::{CONFIG_KEY_SUFFIX, CONFIG_VERSION_KEY_SUFFIX, LAST_MODIFIED_KEY_SUFFIX}, + redis::{ + CONFIG_KEY_SUFFIX, CONFIG_VERSION_KEY_SUFFIX, LAST_MODIFIED_KEY_SUFFIX, + redis_set_data, + }, }; use service_utils::{ helpers::{fetch_dimensions_info_map, generate_snowflake_id}, @@ -250,45 +253,24 @@ pub async fn put_config_in_redis( let last_modified_at_key = format!("{}{LAST_MODIFIED_KEY_SUFFIX}", **schema_name); let config_version_key = format!("{}{CONFIG_VERSION_KEY_SUFFIX}", **schema_name); - redis_pool - .set::<(), String, String>( - config_key, - parsed_config, - expiration.clone(), - None, - false, - ) - .await - .map_err(|e| { - log::warn!("failed to set config in redis: {}", e); - unexpected_error!("failed to set config in redis") - })?; - redis_pool - .set::<(), String, String>( - last_modified_at_key, - config_version.created_at.to_rfc2822(), - expiration.clone(), - None, - false, - ) - .await - .map_err(|e| { - log::warn!("failed to set last_modified_key in redis: {}", e); - unexpected_error!("failed to set last_modified_key in redis") - })?; - redis_pool - .set::<(), String, i64>( - config_version_key, - config_version.id, - expiration, - None, - false, - ) - .await - .map_err(|e| { - log::warn!("failed to set config_version_key in redis: {}", e); - unexpected_error!("failed to set config_version_key in redis") - })?; + redis_set_data(redis_pool, config_key, parsed_config, expiration.clone()).await?; + + redis_set_data( + redis_pool, + last_modified_at_key, + config_version.created_at.to_rfc2822(), + expiration.clone(), + ) + .await?; + + redis_set_data( + redis_pool, + config_version_key, + config_version.id, + expiration, + ) + .await?; + Ok(()) } diff --git a/crates/experimentation_platform/src/api/experiment_config/handlers.rs b/crates/experimentation_platform/src/api/experiment_config/handlers.rs index daf1f20dc..8e72fe694 100644 --- a/crates/experimentation_platform/src/api/experiment_config/handlers.rs +++ b/crates/experimentation_platform/src/api/experiment_config/handlers.rs @@ -157,6 +157,7 @@ fn get_experiment_config_db( workspace_context: &WorkspaceContext, ) -> superposition::DieselResult { let filters = filters.into_inner(); + let dimension_match_strategy = filters.dimension_match_strategy.unwrap_or_default(); let exp_list = { let mut experiment_list: Vec = experiments::experiments @@ -173,7 +174,7 @@ fn get_experiment_config_db( } if !dimension_params.is_empty() { - let filter_fn = match filters.dimension_match_strategy.unwrap_or_default() { + let filter_fn = match dimension_match_strategy { DimensionMatchStrategy::Exact => Experiment::get_satisfied, DimensionMatchStrategy::Subset => Experiment::filter_by_eval, }; @@ -193,7 +194,7 @@ fn get_experiment_config_db( .load::(conn)?; if !dimension_params.is_empty() { - let filter_fn = match filters.dimension_match_strategy.unwrap_or_default() { + let filter_fn = match dimension_match_strategy { DimensionMatchStrategy::Exact => ExperimentGroup::get_satisfied, DimensionMatchStrategy::Subset => ExperimentGroup::filter_by_eval, }; diff --git a/crates/experimentation_platform/src/api/experiment_groups/helpers.rs b/crates/experimentation_platform/src/api/experiment_groups/helpers.rs index 81eb04336..eff634d79 100644 --- a/crates/experimentation_platform/src/api/experiment_groups/helpers.rs +++ b/crates/experimentation_platform/src/api/experiment_groups/helpers.rs @@ -5,16 +5,14 @@ use chrono::{DateTime, Utc}; use diesel::{ BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper, }; -use fred::{ - prelude::{KeysInterface, RedisPool}, - types::Expiration, -}; +use fred::{prelude::RedisPool, types::Expiration}; use serde_json::Value; use service_utils::{ helpers::{generate_snowflake_id, get_from_env_or_default}, redis::{ EXPERIMENT_CONFIG_LAST_MODIFIED_KEY_SUFFIX, EXPERIMENT_GROUPS_LAST_MODIFIED_KEY_SUFFIX, EXPERIMENT_GROUPS_LIST_KEY_SUFFIX, + redis_set_data, }, service::types::{AppState, SchemaName, WorkspaceContext}, }; @@ -506,41 +504,23 @@ pub async fn put_experiment_groups_in_redis( let key_ttl: i64 = get_from_env_or_default("REDIS_KEY_TTL", 604800); let expiration = Some(Expiration::EX(key_ttl)); - pool.set::<(), String, String>( + redis_set_data( + pool, config_modified_at_key, last_modified.clone(), expiration.clone(), - None, - false, ) - .await - .map_err(|e| { - log::warn!( - "failed to set experiment config last_modified_key in redis: {}", - e - ); - unexpected_error!("failed to set experiment config last_modified_key in redis") - })?; + .await?; - pool.set::<(), String, String>( + redis_set_data( + pool, last_modified_at_key, last_modified, expiration.clone(), - None, - false, ) - .await - .map_err(|e| { - log::warn!("failed to set experiment last_modified_key in redis: {}", e); - unexpected_error!("failed to set experiment last_modified_key in redis") - })?; + .await?; - pool.set::<(), String, String>(key, serialized, expiration, None, false) - .await - .map_err(|e| { - log::warn!("Failed to write experiment groups to redis: {}", e); - unexpected_error!("Failed to write experiment groups to redis: {}", e) - })?; + redis_set_data(pool, key, serialized, expiration).await?; log::debug!("Successfully updated experiment groups cache in Redis"); Ok(()) diff --git a/crates/experimentation_platform/src/api/experiments/helpers.rs b/crates/experimentation_platform/src/api/experiments/helpers.rs index fd418077a..a3e52eee3 100644 --- a/crates/experimentation_platform/src/api/experiments/helpers.rs +++ b/crates/experimentation_platform/src/api/experiments/helpers.rs @@ -9,16 +9,13 @@ use diesel::{ pg::PgConnection, r2d2::{ConnectionManager, PooledConnection}, }; -use fred::{ - prelude::{KeysInterface, RedisPool}, - types::Expiration, -}; +use fred::{prelude::RedisPool, types::Expiration}; use serde_json::{Map, Value}; use service_utils::{ helpers::get_from_env_or_default, redis::{ EXPERIMENT_CONFIG_LAST_MODIFIED_KEY_SUFFIX, EXPERIMENTS_LAST_MODIFIED_KEY_SUFFIX, - EXPERIMENTS_LIST_KEY_SUFFIX, + EXPERIMENTS_LIST_KEY_SUFFIX, redis_set_data, }, service::types::{AppState, ExperimentationFlags, SchemaName, WorkspaceContext}, }; @@ -858,40 +855,23 @@ pub async fn put_experiments_in_redis( let key_ttl: i64 = get_from_env_or_default("REDIS_KEY_TTL", 604800); let expiration = Some(Expiration::EX(key_ttl)); - pool.set::<(), String, String>( + redis_set_data( + pool, config_modified_at_key, last_modified.clone(), expiration.clone(), - None, - false, ) - .await - .map_err(|e| { - log::warn!( - "failed to set experiment config last_modified_key in redis: {}", - e - ); - unexpected_error!("failed to set experiment config last_modified_key in redis") - })?; + .await?; - pool.set::<(), String, String>( + redis_set_data( + pool, last_modified_at_key, last_modified, expiration.clone(), - None, - false, ) - .await - .map_err(|e| { - log::warn!("failed to set experiment last_modified_key in redis: {}", e); - unexpected_error!("failed to set experiment last_modified_key in redis") - })?; - pool.set::<(), String, String>(key, serialized, expiration, None, false) - .await - .map_err(|e| { - log::warn!("Failed to write experiments to redis: {}", e); - unexpected_error!("Failed to write experiments to redis: {}", e) - })?; + .await?; + + redis_set_data(pool, key, serialized, expiration).await?; log::debug!("Successfully updated experiments cache in Redis"); Ok(()) diff --git a/crates/service_utils/src/redis.rs b/crates/service_utils/src/redis.rs index af479866f..590dfefef 100644 --- a/crates/service_utils/src/redis.rs +++ b/crates/service_utils/src/redis.rs @@ -1,8 +1,9 @@ use fred::{ prelude::{KeysInterface, RedisClient, RedisPool}, - types::Expiration, + types::{Expiration, RedisValue}, }; use serde::{Serialize, de::DeserializeOwned}; +use superposition_macros::unexpected_error; use superposition_types::{DBConnection, result as superposition}; use crate::{ @@ -114,3 +115,18 @@ where })?; Ok(value) } + +pub async fn redis_set_data>( + pool: &RedisPool, + key_name: String, + value: T, + expiration: Option, +) -> superposition::Result<()> { + let key = key_name.clone(); + pool.set::<(), String, T>(key_name, value, expiration, None, false) + .await + .map_err(|e| { + log::warn!("Failed to set {} in redis: {}", key, e); + unexpected_error!("failed to set {} in redis", key) + }) +} diff --git a/crates/superposition_core/src/experiment.rs b/crates/superposition_core/src/experiment.rs index cbb55335d..924b55d6b 100644 --- a/crates/superposition_core/src/experiment.rs +++ b/crates/superposition_core/src/experiment.rs @@ -68,6 +68,12 @@ pub struct FfiExperimentGroup { pub buckets: Buckets, } +impl Experimental for FfiExperimentGroup { + fn get_condition(&self) -> &Condition { + &self.context + } +} + impl From for FfiExperimentGroup { fn from(experiment_group: ExperimentGroup) -> Self { Self { @@ -97,6 +103,12 @@ pub type Experiments = Vec; pub type ExperimentGroups = Vec; +#[derive(Debug, Clone)] +pub struct ExperimentConfig { + pub experiments: Experiments, + pub experiment_groups: ExperimentGroups, +} + pub fn get_applicable_variants( dimensions_info: &HashMap, experiments: Experiments, @@ -104,22 +116,19 @@ pub fn get_applicable_variants( query_data: &Map, identifier: &str, prefix: Option>, -) -> Result, String> { +) -> Vec { let context = evaluate_local_cohorts(dimensions_info, query_data); let buckets = get_applicable_buckets_from_group(experiment_groups, &context, identifier); let experiments: HashMap = - get_satisfied_experiments(experiments, &context, prefix)? + get_satisfied_experiments(experiments, &context, prefix) .into_iter() .map(|exp| (exp.id.clone(), exp)) .collect(); - let applicable_variants = - get_applicable_variants_from_group_response(&experiments, &context, &buckets); - - Ok(applicable_variants) + get_applicable_variants_from_group_response(&experiments, &context, &buckets) } pub fn get_applicable_buckets_from_group( @@ -202,7 +211,7 @@ pub fn get_satisfied_experiments( mut experiments: Experiments, context: &Map, filter_prefixes: Option>, -) -> Result { +) -> Experiments { if let Some(prefix_list) = filter_prefixes.filter(|p| !p.is_empty()) { let prefix_list: HashSet = HashSet::from_iter(prefix_list); experiments = FfiExperiment::filter_keys_by_prefix(experiments, &prefix_list); @@ -212,14 +221,14 @@ pub fn get_satisfied_experiments( experiments = FfiExperiment::get_satisfied(experiments, context); } - Ok(experiments) + experiments } pub fn filter_experiments_by_context( mut experiments: Experiments, context: &Map, filter_prefixes: Option>, -) -> Result { +) -> Experiments { if let Some(prefix_list) = filter_prefixes.filter(|p| !p.is_empty()) { let prefix_list: HashSet = HashSet::from_iter(prefix_list); experiments = FfiExperiment::filter_keys_by_prefix(experiments, &prefix_list); @@ -229,5 +238,5 @@ pub fn filter_experiments_by_context( experiments = FfiExperiment::filter_by_eval(experiments, context); } - Ok(experiments) + experiments } diff --git a/crates/superposition_core/src/ffi.rs b/crates/superposition_core/src/ffi.rs index 82c1d494f..7e15661cb 100644 --- a/crates/superposition_core/src/ffi.rs +++ b/crates/superposition_core/src/ffi.rs @@ -66,8 +66,7 @@ fn ffi_eval_logic( &_q, &identifier, filter_prefixes.clone(), - ) - .map_err(OperationError::Unexpected)?; + ); _q.insert("variantIds".to_string(), variants.into()); } @@ -153,8 +152,7 @@ fn ffi_get_applicable_variants( &_query_data, &identifier, prefix, - ) - .map_err(OperationError::Unexpected)?; + ); Ok(r) } @@ -310,8 +308,7 @@ impl ProviderCache { &_q, targeting_key.as_deref().unwrap_or(""), filter_prefixes.clone(), - ) - .map_err(OperationError::Unexpected)?; + ); _q.insert("variantIds".to_string(), variants.into()); } diff --git a/crates/superposition_core/src/ffi_legacy.rs b/crates/superposition_core/src/ffi_legacy.rs index f89c87741..3b1c3c9ab 100644 --- a/crates/superposition_core/src/ffi_legacy.rs +++ b/crates/superposition_core/src/ffi_legacy.rs @@ -147,22 +147,16 @@ pub unsafe extern "C" fn core_get_resolved_config( if let Some(e_args) = experimentation { let identifier = e_args.targeting_key; - match get_applicable_variants( + let variants = get_applicable_variants( &dimensions, e_args.experiments, &e_args.experiment_groups, &query_data, &identifier, filter_prefixes.clone(), - ) { - Ok(variants) => { - query_data.insert("variantIds".to_string(), variants.into()); - } - Err(e) => { - copy_string(ebuf, format!("Failed to get applicable variants: {}", e)); - return ptr::null_mut(); - } - } + ); + + query_data.insert("variantIds".to_string(), variants.into()); } // Call pure config resolution logic @@ -297,22 +291,16 @@ pub unsafe extern "C" fn core_get_resolved_config_with_reasoning( if let Some(e_args) = experimentation { let identifier = e_args.targeting_key; - match get_applicable_variants( + let variants = get_applicable_variants( &dimensions, e_args.experiments, &e_args.experiment_groups, &query_data, &identifier, filter_prefixes.clone(), - ) { - Ok(variants) => { - query_data.insert("variantIds".to_string(), variants.into()); - } - Err(e) => { - copy_string(ebuf, format!("Failed to get applicable variants: {}", e)); - return ptr::null_mut(); - } - } + ); + + query_data.insert("variantIds".to_string(), variants.into()); } // Call config resolution with reasoning @@ -431,23 +419,18 @@ pub unsafe extern "C" fn core_get_applicable_variants( }; // Call the experimentation logic - match get_applicable_variants( + let result = get_applicable_variants( &dimensions, experiments, &experiment_groups, &query_data, &identifier, filter_prefixes, - ) { - Ok(result) => match serde_json::to_string(&result) { - Ok(json_str) => string_to_c_str(json_str), - Err(e) => { - copy_string(ebuf, format!("Failed to serialize result: {}", e)); - ptr::null_mut() - } - }, + ); + match serde_json::to_string(&result) { + Ok(json_str) => string_to_c_str(json_str), Err(e) => { - copy_string(ebuf, e); + copy_string(ebuf, format!("Failed to serialize result: {}", e)); ptr::null_mut() } } diff --git a/crates/superposition_provider/Cargo.toml b/crates/superposition_provider/Cargo.toml index 88883efcd..a15a7e369 100644 --- a/crates/superposition_provider/Cargo.toml +++ b/crates/superposition_provider/Cargo.toml @@ -13,7 +13,9 @@ description = "Open feature provider for Superposition." async-trait = "0.1" aws-smithy-types = { version = "1.3.0" } chrono = { workspace = true } +derive_more = { workspace = true } log = { workspace = true } +notify = "8" open-feature = "0.2.5" reqwest = { workspace = true } serde = { workspace = true } @@ -26,6 +28,8 @@ tokio = { workspace = true } tokio-util = "0.7" uuid = { workspace = true } +[dev-dependencies] +env_logger = "0.11" [lints] workspace = true diff --git a/crates/superposition_provider/examples/config.toml b/crates/superposition_provider/examples/config.toml new file mode 100644 index 000000000..2ea4a65cc --- /dev/null +++ b/crates/superposition_provider/examples/config.toml @@ -0,0 +1,24 @@ +[default-configs] +timeout = { value = 30, schema = { type = "integer" } } +currency = { value = "Rupee", schema = { type = "string", enum = [ + "Rupee", + "Dollar", + "Euro", +] } } +price = { value = 10000, schema = { type = "integer", minimum = 0 } } + +[dimensions] +os = { position = 1, schema = { type = "string" } } +city = { position = 2, schema = { type = "string" } } + +[[overrides]] +_context_ = { os = "linux" } +timeout = 45 + +[[overrides]] +_context_ = { city = "Boston" } +currency = "Dollar" + +[[overrides]] +_context_ = { city = "Berlin" } +currency = "Euro" diff --git a/crates/superposition_provider/examples/local_file_example.rs b/crates/superposition_provider/examples/local_file_example.rs new file mode 100644 index 000000000..a5133bacf --- /dev/null +++ b/crates/superposition_provider/examples/local_file_example.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use open_feature::EvaluationContext; +use superposition_provider::{ + data_source::file::FileDataSource, local_provider::LocalResolutionProvider, + traits::AllFeatureProvider, OnDemandStrategy, RefreshStrategy, +}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let file_source = FileDataSource::new(manifest_dir.join("examples/config.toml")); + + let provider = LocalResolutionProvider::new( + Box::new(file_source), + None, + RefreshStrategy::OnDemand(OnDemandStrategy { + ttl: 60, + ..Default::default() + }), + ); + provider.init(EvaluationContext::default()).await.unwrap(); + + let context = EvaluationContext::default() + .with_custom_field("os", "linux") + .with_custom_field("city", "Boston"); + + let config = provider.resolve_all_features(context).await.unwrap(); + println!("Config: {:?}", config); + + provider.close_provider().await.unwrap(); +} diff --git a/crates/superposition_provider/examples/local_file_watch_example.rs b/crates/superposition_provider/examples/local_file_watch_example.rs new file mode 100644 index 000000000..985dfefeb --- /dev/null +++ b/crates/superposition_provider/examples/local_file_watch_example.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use open_feature::EvaluationContext; +use superposition_provider::{ + data_source::file::FileDataSource, local_provider::LocalResolutionProvider, + traits::AllFeatureProvider, RefreshStrategy, WatchStrategy, +}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = manifest_dir.join("examples/config.toml"); + + println!("Watching config file: {:?}", config_path); + println!("Edit the file in another terminal to see changes.\n"); + + let file_source = FileDataSource::new(config_path); + + let provider = LocalResolutionProvider::new( + Box::new(file_source), + None, + RefreshStrategy::Watch(WatchStrategy::default()), + ); + provider.init(EvaluationContext::default()).await.unwrap(); + + let context = EvaluationContext::default() + .with_custom_field("os", "linux") + .with_custom_field("city", "Boston"); + + // Poll in a loop to show updated values after file changes + loop { + let config = provider + .resolve_all_features(context.clone()) + .await + .unwrap(); + println!("Config: {:?}", config); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } +} diff --git a/crates/superposition_provider/examples/local_http_example.rs b/crates/superposition_provider/examples/local_http_example.rs new file mode 100644 index 000000000..685514698 --- /dev/null +++ b/crates/superposition_provider/examples/local_http_example.rs @@ -0,0 +1,44 @@ +use open_feature::EvaluationContext; +use superposition_provider::{ + data_source::http::HttpDataSource, + local_provider::LocalResolutionProvider, + traits::{AllFeatureProvider, FeatureExperimentMeta}, + PollingStrategy, RefreshStrategy, SuperpositionOptions, +}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let http_source = HttpDataSource::new(SuperpositionOptions::new( + "http://localhost:8080".to_string(), + "token".to_string(), + "localorg".to_string(), + "dev".to_string(), + )); + + let provider = LocalResolutionProvider::new( + Box::new(http_source), + None, + RefreshStrategy::Polling(PollingStrategy { + interval: 30, + timeout: Some(10), + }), + ); + provider.init(EvaluationContext::default()).await.unwrap(); + + let context = EvaluationContext::default() + .with_targeting_key("user-1234") + .with_custom_field("dimension", "d2"); + + let all_config = provider + .resolve_all_features(context.clone()) + .await + .unwrap(); + println!("All config: {:?}", all_config); + + let variants = provider.get_applicable_variants(context).await.unwrap(); + println!("Variants: {:?}", variants); + + provider.close_provider().await.unwrap(); +} diff --git a/crates/superposition_provider/examples/local_with_fallback_example.rs b/crates/superposition_provider/examples/local_with_fallback_example.rs new file mode 100644 index 000000000..d63a1d085 --- /dev/null +++ b/crates/superposition_provider/examples/local_with_fallback_example.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use open_feature::{EvaluationContext, OpenFeature}; +use superposition_provider::{ + data_source::file::FileDataSource, data_source::http::HttpDataSource, + local_provider::LocalResolutionProvider, PollingStrategy, RefreshStrategy, + SuperpositionOptions, +}; +use tokio::time::{sleep, Duration}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let http_source = HttpDataSource::new(SuperpositionOptions::new( + "http://localhost:8080".to_string(), + "token".to_string(), + "localorg".to_string(), + "dev".to_string(), + )); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let file_source = FileDataSource::new(manifest_dir.join("examples/config.toml")); + + let provider = LocalResolutionProvider::new( + Box::new(http_source), + Some(Box::new(file_source)), + RefreshStrategy::Polling(PollingStrategy { + interval: 10, + timeout: Some(10), + }), + ); + + // Register with OpenFeature and create a client + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(provider).await; + let client = api.create_client(); + + // Allow time for the provider to initialize via OpenFeature + sleep(Duration::from_secs(2)).await; + + println!("=== Superposition Fallback + Polling Example ==="); + println!("Primary: HTTP (localhost:8080), Fallback: config.toml"); + println!("Polling every 10s. Printing config every 5s (Ctrl-C to stop).\n"); + + let context = EvaluationContext::default() + .with_targeting_key("user-456") + .with_custom_field("os", "linux") + .with_custom_field("city", "Berlin"); + + loop { + let ts = chrono::Utc::now().format("%H:%M:%S"); + + match client + .get_string_value("currency", Some(&context), None) + .await + { + Ok(value) => print!("[{}] currency = {}", ts, value), + Err(e) => print!("[{}] currency error: {:?}", ts, e), + } + + match client.get_int_value("timeout", Some(&context), None).await { + Ok(value) => println!(" | timeout = {}", value), + Err(e) => println!(" | timeout error: {:?}", e), + } + + sleep(Duration::from_secs(5)).await; + } +} diff --git a/crates/superposition_provider/examples/polling_example.rs b/crates/superposition_provider/examples/polling_example.rs new file mode 100644 index 000000000..e51123bc8 --- /dev/null +++ b/crates/superposition_provider/examples/polling_example.rs @@ -0,0 +1,102 @@ +/// Demonstrates the Polling refresh strategy with LocalResolutionProvider +/// using the OpenFeature client interface. +/// +/// This example connects to a Superposition server via HTTP, polls for config +/// changes every 10 seconds, and prints a config value in a loop using the +/// standard OpenFeature client API. Change the config on the server and watch +/// the printed value update automatically. +/// +/// Usage: +/// RUST_LOG=info cargo run --example polling_example +/// +/// Environment variables (all optional, with defaults shown): +/// SUPERPOSITION_ENDPOINT http://localhost:8080 +/// SUPERPOSITION_TOKEN token +/// SUPERPOSITION_ORG_ID localorg +/// SUPERPOSITION_WORKSPACE dev +/// POLL_INTERVAL 10 (seconds between server polls) +/// PRINT_INTERVAL 5 (seconds between printing the value) +/// CONFIG_KEY max_connections (the config key to watch) +use std::env; + +use open_feature::{EvaluationContext, OpenFeature}; +use superposition_provider::{ + data_source::http::HttpDataSource, local_provider::LocalResolutionProvider, + PollingStrategy, RefreshStrategy, SuperpositionOptions, +}; +use tokio::time::{sleep, Duration}; + +fn env_or(key: &str, default: &str) -> String { + env::var(key).unwrap_or_else(|_| default.to_string()) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let endpoint = env_or("SUPERPOSITION_ENDPOINT", "http://localhost:8080"); + let token = env_or("SUPERPOSITION_TOKEN", "token"); + let org_id = env_or("SUPERPOSITION_ORG_ID", "localorg"); + let workspace = env_or("SUPERPOSITION_WORKSPACE", "dev"); + let poll_interval: u64 = env_or("POLL_INTERVAL", "10").parse().unwrap_or(10); + let print_interval: u64 = env_or("PRINT_INTERVAL", "5").parse().unwrap_or(5); + let config_key = env_or("CONFIG_KEY", "max_connections"); + + println!("=== Superposition Polling Example ==="); + println!("Endpoint: {}", endpoint); + println!("Org / Workspace: {} / {}", org_id, workspace); + println!("Poll interval: {}s", poll_interval); + println!("Print interval: {}s", print_interval); + println!("Watching key: {}", config_key); + println!(); + + let http_source = HttpDataSource::new(SuperpositionOptions::new( + endpoint, token, org_id, workspace, + )); + + let provider = LocalResolutionProvider::new( + Box::new(http_source), + None, + RefreshStrategy::Polling(PollingStrategy { + interval: poll_interval, + timeout: Some(10), + }), + ); + + // Register with OpenFeature and create a client + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(provider).await; + let client = api.create_client(); + + // Allow time for the provider to initialize via OpenFeature + sleep(Duration::from_secs(2)).await; + + println!( + "Provider ready. Printing config every {}s (Ctrl-C to stop).\n", + print_interval + ); + + let context = EvaluationContext::default(); + + loop { + match client + .get_int_value(&config_key, Some(&context), None) + .await + { + Ok(value) => println!( + "[{}] {} = {}", + chrono::Utc::now().format("%H:%M:%S"), + config_key, + value + ), + Err(e) => eprintln!( + "[{}] Error resolving {}: {:?}", + chrono::Utc::now().format("%H:%M:%S"), + config_key, + e + ), + } + + sleep(Duration::from_secs(print_interval)).await; + } +} diff --git a/crates/superposition_provider/src/client.rs b/crates/superposition_provider/src/client.rs index f4f1273c1..560de9e63 100644 --- a/crates/superposition_provider/src/client.rs +++ b/crates/superposition_provider/src/client.rs @@ -95,6 +95,12 @@ impl CacConfig { on_demand_strategy.timeout.unwrap_or(30) ); } + RefreshStrategy::Watch(_) => { + info!("Using Watch refresh strategy"); + } + RefreshStrategy::Manual => { + info!("Using Manual refresh strategy"); + } } Ok(()) @@ -209,7 +215,7 @@ impl CacConfig { })?; // Use ConversionUtils to convert to proper Config type - let config = ConversionUtils::convert_get_config_response(&response)?; + let config = ConversionUtils::convert_get_config_response(response)?; info!("Successfully fetched and converted config with {} contexts, {} overrides, {} default configs", config.contexts.len(), config.overrides.len(), config.default_configs.len()); @@ -351,6 +357,12 @@ impl ExperimentationConfig { on_demand_strategy.ttl ); } + RefreshStrategy::Watch(_) => { + info!("Using Watch refresh strategy for experiments"); + } + RefreshStrategy::Manual => { + info!("Using Manual refresh strategy for experiments"); + } } Ok(()) @@ -506,7 +518,7 @@ impl ExperimentationConfig { )) })?; - let experiments = ConversionUtils::convert_experiments_response(&response)?; + let experiments = ConversionUtils::convert_experiments_response(response.data)?; info!( "Successfully fetched and converted {} experiments", @@ -547,7 +559,7 @@ impl ExperimentationConfig { })?; let experiment_groups = - ConversionUtils::convert_experiment_groups_response(&response)?; + ConversionUtils::convert_experiment_groups_response(response.data)?; info!( "Successfully fetched and converted {} experiment groups", @@ -596,20 +608,14 @@ impl ExperimentationConfig { ) { (Some(experiments), Some(experiment_groups)) => { // Use get_applicable_variants from superposition_core - get_applicable_variants( + Ok(get_applicable_variants( dimensions_info, experiments.clone(), experiment_groups, contexts, &identifier.unwrap_or_default(), None, - ) - .map_err(|e| { - SuperpositionError::ConfigError(format!( - "Failed to get applicable variants: {}", - e - )) - }) + )) } _ => Err(SuperpositionError::ConfigError( "No cached experiments or experiment groups available".into(), diff --git a/crates/superposition_provider/src/conversions.rs b/crates/superposition_provider/src/conversions.rs new file mode 100644 index 000000000..0d644e9e0 --- /dev/null +++ b/crates/superposition_provider/src/conversions.rs @@ -0,0 +1,218 @@ +use std::collections::HashMap; + +use aws_smithy_types::Document; +use open_feature::{EvaluationContext, EvaluationContextFieldValue}; +use serde_json::{Map, Value}; + +use crate::{types::Result, SuperpositionError}; + +pub fn value_to_document(value: Value) -> Document { + match value { + Value::Null => Document::Null, + Value::Bool(b) => Document::Bool(b), + Value::Number(n) => { + if let Some(u) = n.as_u64() { + Document::Number(aws_smithy_types::Number::PosInt(u)) + } else if let Some(i) = n.as_i64() { + Document::Number(aws_smithy_types::Number::NegInt(i)) + } else if let Some(f) = n.as_f64() { + Document::Number(aws_smithy_types::Number::Float(f)) + } else { + Document::Null + } + } + Value::String(s) => Document::String(s), + Value::Array(arr) => { + Document::Array(arr.into_iter().map(value_to_document).collect()) + } + Value::Object(obj) => { + let map = obj + .into_iter() + .map(|(k, v)| (k, value_to_document(v))) + .collect(); + Document::Object(map) + } + } +} + +pub fn map_to_hashmap(map: Map) -> HashMap { + map.into_iter() + .map(|(k, v)| (k, value_to_document(v))) + .collect() +} + +pub fn hashmap_to_map(hashmap: HashMap) -> Map { + hashmap + .into_iter() + .map(|(k, v)| (k, document_to_value(v))) + .collect() +} + +/// Recursively convert AWS Smithy Document to serde_json::Value by properly matching variants +pub fn document_to_value(doc: Document) -> Value { + match doc { + Document::Object(obj) => { + let hashmap = obj + .into_iter() + .map(|(k, v)| (k, document_to_value(v))) + .collect(); + Value::Object(hashmap) + } + Document::Array(arr) => { + Value::Array(arr.into_iter().map(document_to_value).collect()) + } + Document::Number(num) => { + use aws_smithy_types::Number; + match num { + Number::PosInt(val) => Value::Number(serde_json::Number::from(val)), + Number::NegInt(val) => Value::Number(serde_json::Number::from(val)), + Number::Float(val) => Value::Number( + serde_json::Number::from_f64(val).unwrap_or_else(|| { + log::warn!( + "Failed to convert float {} to JSON number, using 0 instead", + val + ); + serde_json::Number::from(0) + }), + ), + } + } + Document::String(s) => Value::String(s), + Document::Bool(b) => Value::Bool(b), + Document::Null => Value::Null, + } +} + +pub fn evaluation_context_to_value(value: EvaluationContextFieldValue) -> Value { + match value { + EvaluationContextFieldValue::Bool(b) => Value::Bool(b), + EvaluationContextFieldValue::Int(i) => Value::Number(serde_json::Number::from(i)), + EvaluationContextFieldValue::Float(f) => { + Value::Number(serde_json::Number::from_f64(f).unwrap_or_else(|| { + log::warn!( + "Failed to convert float {} to JSON number, using 0 instead", + f + ); + serde_json::Number::from(0) + })) + } + EvaluationContextFieldValue::String(s) => Value::String(s), + EvaluationContextFieldValue::DateTime(dt) => Value::String(dt.to_string()), + EvaluationContextFieldValue::Struct(s) => { + // Convert struct to serde_json::Value + let struct_map = s + .downcast_ref::>() + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), evaluation_context_to_value(v.clone()))) + .collect() + }) + .unwrap_or_else(|| { + log::warn!("Failed to downcast struct value to expected HashMap format, got {:?}. Returning empty object.", s.type_id()); + Map::new() + }); + Value::Object(struct_map) + } + } +} + +/// Convert an EvaluationContext into a (Map, Option) tuple +/// containing the custom fields as serde values and the targeting key. +/// This is used by both local and remote providers. +pub fn evaluation_context_to_query( + ctx: EvaluationContext, +) -> (Map, Option) { + let context = ctx + .custom_fields + .into_iter() + .map(|(k, v)| (k, evaluation_context_to_value(v))) + .collect(); + + (context, ctx.targeting_key) +} + +/// Convert evaluation context to dimension data format expected by superposition_types +pub fn evaluation_context_to_map(context: EvaluationContext) -> Map { + let mut dimension_data = Map::new(); + + // Add targeting key if present + if let Some(targeting_key) = context.targeting_key { + dimension_data.insert("targeting_key".to_string(), Value::String(targeting_key)); + } + + // Add all other fields from the context + for (key, value) in context.custom_fields { + dimension_data.insert(key, evaluation_context_to_value(value)); + } + + log::debug!( + "Converted evaluation context to dimension data with {} keys", + dimension_data.len() + ); + dimension_data +} + +/// Convert serde_json Value to OpenFeature StructValue +pub fn value_to_struct(value: Value) -> Result { + match value { + Value::Object(map) => { + let mut fields = HashMap::new(); + for (k, v) in map { + let open_feature_value = value_to_openfeature_value(v)?; + fields.insert(k, open_feature_value); + } + // StructValue is just a struct with a fields HashMap, not a complex conversion + Ok(open_feature::StructValue { fields }) + } + Value::Array(list) => { + let mut fields = HashMap::new(); + for (index, item) in list.into_iter().enumerate() { + let open_feature_value = value_to_openfeature_value(item)?; + fields.insert(index.to_string(), open_feature_value); + } + Ok(open_feature::StructValue { fields }) + } + _ => Err(SuperpositionError::ConfigError(format!( + "Cannot convert {:?} to StructValue - flag must be an object/array", + value + ))), + } +} + +/// Convert serde_json Value to OpenFeature Value +pub fn value_to_openfeature_value(value: Value) -> Result { + match value { + Value::Bool(b) => Ok(open_feature::Value::Bool(b)), + Value::String(s) => Ok(open_feature::Value::String(s)), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(open_feature::Value::Int(i)) + } else if let Some(f) = n.as_f64() { + Ok(open_feature::Value::Float(f)) + } else { + Err(SuperpositionError::ConfigError(format!( + "Cannot convert number {} to OpenFeature value", + n + ))) + } + } + Value::Array(arr) => Ok(open_feature::Value::Array( + arr.into_iter() + .map(value_to_openfeature_value) + .collect::>>()?, + )), + Value::Object(map) => { + let fields = map + .into_iter() + .map(|(k, v)| Ok((k, value_to_openfeature_value(v)?))) + .collect::>>()?; + + // Create StructValue directly with fields HashMap + let struct_value = open_feature::StructValue { fields }; + Ok(open_feature::Value::Struct(struct_value)) + } + Value::Null => Err(SuperpositionError::ConfigError( + "Cannot convert null to OpenFeature value".to_string(), + )), + } +} diff --git a/crates/superposition_provider/src/data_source.rs b/crates/superposition_provider/src/data_source.rs new file mode 100644 index 000000000..382d6f6a5 --- /dev/null +++ b/crates/superposition_provider/src/data_source.rs @@ -0,0 +1,150 @@ +pub mod file; +pub mod http; + +use std::fmt::Display; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value}; +use superposition_core::experiment::ExperimentConfig; +use superposition_types::Config; + +use crate::types::Result; + +pub enum FetchResponse { + NotModified, + Data(T), +} + +impl FetchResponse { + pub fn is_not_modified(&self) -> bool { + matches!(self, FetchResponse::NotModified) + } + + pub fn data(&self) -> Option<&T> { + match self { + FetchResponse::Data(data) => Some(data), + FetchResponse::NotModified => None, + } + } + + pub fn into_data(self) -> Option { + match self { + FetchResponse::Data(data) => Some(data), + FetchResponse::NotModified => None, + } + } + + pub fn map_data U>(self, f: F) -> FetchResponse { + match self { + FetchResponse::Data(data) => FetchResponse::Data(f(data)), + FetchResponse::NotModified => FetchResponse::NotModified, + } + } +} + +impl Display for FetchResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FetchResponse::NotModified => write!(f, "NotModified"), + FetchResponse::Data(data) => write!(f, "Data({})", data), + } + } +} + +/// Holds a resolved configuration along with the time it was fetched. +#[derive(Debug, Clone)] +pub struct ConfigData { + pub config: Config, + pub fetched_at: DateTime, +} + +impl Display for ConfigData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ConfigData(fetched_at: {}, config.contexts: {}, config.overrides: {}, config.default_configs: {}, config.dimensions: {})", + self.fetched_at, + self.config.contexts.len(), + self.config.overrides.len(), + self.config.default_configs.len(), + self.config.dimensions.len() + ) + } +} + +/// Holds active experiments and experiment groups along with the time they were fetched. +#[derive(Debug, Clone)] +pub struct ExperimentData { + pub data: ExperimentConfig, + pub fetched_at: DateTime, +} + +impl Display for ExperimentData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ExperimentData(data: {} experiments, {} experiment groups, fetched_at: {})", + self.data.experiments.len(), + self.data.experiment_groups.len(), + self.fetched_at + ) + } +} + +/// Trait for fetching configuration and experiment data from a Superposition backend. +/// +/// Implementors provide the transport mechanism (e.g. HTTP, file-based) while consumers +/// interact with this unified interface. +#[async_trait] +pub trait SuperpositionDataSource: Send + Sync { + /// Fetch the full resolved configuration. + async fn fetch_config( + &self, + if_modified_since: Option>, + ) -> Result>; + + /// Fetch a resolved configuration filtered by the given context and key prefixes. + async fn fetch_filtered_config( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result>; + + /// Fetch all active experiments. + async fn fetch_active_experiments( + &self, + if_modified_since: Option>, + ) -> Result>; + + /// Fetch active experiments whose conditions are candidates for the given context + /// and key prefixes. + async fn fetch_candidate_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result>; + + /// Fetch active experiments that match the given context and key prefixes. + async fn fetch_matching_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result>; + + /// Whether this data source supports experiments. + fn supports_experiments(&self) -> bool; + + /// Set up a file watcher and return a stream of change notifications. + /// + /// Returns `Ok(None)` if this data source does not support watching. + fn watch(&self) -> Result> { + Ok(None) + } + + /// Clean up any resources held by this data source. + async fn close(&self) -> Result<()>; +} diff --git a/crates/superposition_provider/src/data_source/file.rs b/crates/superposition_provider/src/data_source/file.rs new file mode 100644 index 000000000..5ad760b41 --- /dev/null +++ b/crates/superposition_provider/src/data_source/file.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use notify::{Event, RecommendedWatcher, Watcher}; +use serde_json::{Map, Value}; +use superposition_core::{ConfigFormat, TomlFormat}; +use tokio::sync::broadcast; + +use crate::data_source::FetchResponse; +use crate::types::{Result, SuperpositionError, WatchStream}; + +use super::{ConfigData, ExperimentData, SuperpositionDataSource}; + +struct WatcherInner { + _watcher: RecommendedWatcher, + broadcast_tx: broadcast::Sender<()>, +} + +pub struct FileDataSource { + file_path: PathBuf, + watcher: Mutex>, +} + +impl FileDataSource { + pub fn new(file_path: PathBuf) -> Self { + Self { + file_path, + watcher: Mutex::new(None), + } + } +} + +#[async_trait] +impl SuperpositionDataSource for FileDataSource { + async fn fetch_config( + &self, + if_modified_since: Option>, + ) -> Result> { + if if_modified_since.is_some() { + log::debug!("FileDataSource: ignoring if_modified_since, always reading fresh from file"); + } + let now = Utc::now(); + let content = tokio::fs::read_to_string(&self.file_path) + .await + .map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to read config file {:?}: {}", + self.file_path, e + )) + })?; + + let config = TomlFormat::parse_config(&content).map_err(|e| { + SuperpositionError::ConfigError(format!("Failed to parse TOML config: {}", e)) + })?; + + Ok(FetchResponse::Data(ConfigData { + config, + fetched_at: now, + })) + } + + async fn fetch_filtered_config( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + let resp = self + .fetch_config(if_modified_since) + .await? + .map_data(|mut data| { + data.config = data.config.filter( + context.as_ref(), + prefix_filter.map(|p| p.into_iter().collect()).as_ref(), + ); + data + }); + + Ok(resp) + } + + async fn fetch_active_experiments( + &self, + _if_modified_since: Option>, + ) -> Result> { + Err(SuperpositionError::ConfigError( + "Experiments not supported by FileDataSource".into(), + )) + } + + async fn fetch_candidate_active_experiments( + &self, + _context: Option>, + _prefix_filter: Option>, + _if_modified_since: Option>, + ) -> Result> { + Err(SuperpositionError::ConfigError( + "Experiments not supported by FileDataSource".into(), + )) + } + + async fn fetch_matching_active_experiments( + &self, + _context: Option>, + _prefix_filter: Option>, + _if_modified_since: Option>, + ) -> Result> { + Err(SuperpositionError::ConfigError( + "Experiments not supported by FileDataSource".into(), + )) + } + + fn supports_experiments(&self) -> bool { + false + } + + fn watch(&self) -> Result> { + // Acquire both locks upfront to prevent concurrent watcher creation + let mut watcher_guard = self.watcher.lock().map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to lock watcher mutex: {}", + e + )) + })?; + + // If already watching, return a new subscriber to the existing broadcast + if let Some(inner) = watcher_guard.as_ref() { + return Ok(Some(WatchStream { + receiver: inner.broadcast_tx.subscribe(), + })); + } + + // Both checks confirmed None — safe to create under the lock + let (tx, _rx) = broadcast::channel(16); + let tx_clone = tx.clone(); + + let mut watcher = notify::recommended_watcher( + move |res: std::result::Result| match res { + Ok(_event) => { + let _ = tx_clone.send(()); + } + Err(e) => { + log::error!("FileDataSource: watch error: {}", e); + } + }, + ) + .map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to create file watcher: {}", + e + )) + })?; + + watcher + .watch(&self.file_path, notify::RecursiveMode::NonRecursive) + .map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to watch file {:?}: {}", + self.file_path, e + )) + })?; + + let subscriber = tx.subscribe(); + *watcher_guard = Some(WatcherInner { + _watcher: watcher, + broadcast_tx: tx, + }); + + Ok(Some(WatchStream { + receiver: subscriber, + })) + } + + async fn close(&self) -> Result<()> { + let mut guard = self.watcher.lock().map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to lock watcher mutex: {}", + e + )) + })?; + *guard = None; + + Ok(()) + } +} diff --git a/crates/superposition_provider/src/data_source/http.rs b/crates/superposition_provider/src/data_source/http.rs new file mode 100644 index 000000000..227f13ff4 --- /dev/null +++ b/crates/superposition_provider/src/data_source/http.rs @@ -0,0 +1,226 @@ +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use serde_json::{Map, Value}; +use superposition_sdk::error::SdkError; +use superposition_sdk::types::DimensionMatchStrategy; +use superposition_sdk::{Client, Config as SdkConfig}; + +use crate::conversions; +use crate::types::{Result, SuperpositionError, SuperpositionOptions}; +use crate::utils::ConversionUtils; + +use super::{ConfigData, ExperimentData, FetchResponse, SuperpositionDataSource}; + +pub struct HttpDataSource { + options: SuperpositionOptions, + client: Client, +} + +fn create_client(options: &SuperpositionOptions) -> Client { + let sdk_config = SdkConfig::builder() + .endpoint_url(&options.endpoint) + .bearer_token(options.token.clone().into()) + .behavior_version_latest() + .build(); + + Client::from_conf(sdk_config) +} + +impl HttpDataSource { + pub fn new(options: SuperpositionOptions) -> Self { + Self { + client: create_client(&options), + options, + } + } + + async fn fetch_experiments_with_filters( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + filter: Option, + ) -> Result> { + let mut experiment_builder = self + .client + .get_experiment_config() + .workspace_id(&self.options.workspace_id) + .org_id(&self.options.org_id); + + if let Some(modified_since) = if_modified_since + .and_then(|t| t.timestamp_nanos_opt()) + .and_then(|t| aws_smithy_types::DateTime::from_nanos(t.into()).ok()) + { + experiment_builder = experiment_builder.if_modified_since(modified_since); + } + + if let Some(context) = context { + if !context.is_empty() { + let context = conversions::map_to_hashmap(context); + experiment_builder = experiment_builder.set_context(Some(context)); + } + } + + if let Some(prefixes) = prefix_filter { + if !prefixes.is_empty() { + experiment_builder = experiment_builder.set_prefix(Some(prefixes)); + } + } + + if let Some(filter) = filter { + experiment_builder = experiment_builder.dimension_match_strategy(filter); + } + + log::info!("Fetching experiments from Superposition service using SDK"); + let experiments_result = experiment_builder.send().await; + + let experiments_response = match experiments_result { + Ok(res) => { + let modified_at = + Utc.timestamp_nanos(res.last_modified.as_nanos() as i64); + ConversionUtils::convert_experiment_config_response(res) + .map(|d| ExperimentData { + data: d, + fetched_at: modified_at, + }) + .map(FetchResponse::Data) + } + Err(SdkError::ResponseError(r)) if r.raw().status().as_u16() == 304 => { + Ok(FetchResponse::NotModified) + } + Err(e) => Err(SuperpositionError::NetworkError(format!( + "Failed to list experiments: {}", + e + ))), + }?; + + log::info!("Successfully fetched {}", experiments_response); + + Ok(experiments_response) + } + + async fn fetch_config_with_filters( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + log::info!("Fetching config from Superposition service using SDK"); + let mut builder = self + .client + .get_config() + .workspace_id(&self.options.workspace_id) + .org_id(&self.options.org_id); + + if let Some(modified_since) = if_modified_since + .and_then(|t| t.timestamp_nanos_opt()) + .and_then(|t| aws_smithy_types::DateTime::from_nanos(t.into()).ok()) + { + builder = builder.if_modified_since(modified_since); + } + + if let Some(context) = context { + if !context.is_empty() { + let context = conversions::map_to_hashmap(context); + builder = builder.set_context(Some(context)); + } + } + + if let Some(prefixes) = prefix_filter { + if !prefixes.is_empty() { + builder = builder.set_prefix(Some(prefixes)); + } + } + + let config_result = builder.send().await; + + let resp = match config_result { + Ok(res) => { + let modified_at = + Utc.timestamp_nanos(res.last_modified.as_nanos() as i64); + ConversionUtils::convert_get_config_response(res) + .map(|d| ConfigData { + config: d, + fetched_at: modified_at, + }) + .map(FetchResponse::Data) + } + Err(SdkError::ResponseError(r)) if r.raw().status().as_u16() == 304 => { + Ok(FetchResponse::NotModified) + } + Err(e) => Err(SuperpositionError::NetworkError(format!( + "Failed to fetch config: {}", + e + ))), + }; + + resp + } +} + +#[async_trait] +impl SuperpositionDataSource for HttpDataSource { + async fn fetch_config( + &self, + if_modified_since: Option>, + ) -> Result> { + self.fetch_config_with_filters(None, None, if_modified_since) + .await + } + + async fn fetch_filtered_config( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + self.fetch_config_with_filters(context, prefix_filter, if_modified_since) + .await + } + + async fn fetch_active_experiments( + &self, + if_modified_since: Option>, + ) -> Result> { + self.fetch_experiments_with_filters(None, None, if_modified_since, None) + .await + } + + async fn fetch_candidate_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + self.fetch_experiments_with_filters( + context, + prefix_filter, + if_modified_since, + Some(DimensionMatchStrategy::Exact), + ) + .await + } + + async fn fetch_matching_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + self.fetch_experiments_with_filters( + context, + prefix_filter, + if_modified_since, + Some(DimensionMatchStrategy::Subset), + ) + .await + } + + fn supports_experiments(&self) -> bool { + true + } + + async fn close(&self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/superposition_provider/src/lib.rs b/crates/superposition_provider/src/lib.rs index 059e47cf1..7a27ea5da 100644 --- a/crates/superposition_provider/src/lib.rs +++ b/crates/superposition_provider/src/lib.rs @@ -1,10 +1,19 @@ pub mod client; +pub mod conversions; +pub mod data_source; +pub mod local_provider; pub mod provider; +pub mod remote_provider; +pub mod traits; pub mod types; pub mod utils; pub use client::*; +pub use data_source::{ConfigData, ExperimentData, SuperpositionDataSource}; +pub use local_provider::LocalResolutionProvider; pub use provider::*; +pub use remote_provider::SuperpositionAPIProvider; +pub use traits::*; pub use types::*; pub use open_feature::{ diff --git a/crates/superposition_provider/src/local_provider.rs b/crates/superposition_provider/src/local_provider.rs new file mode 100644 index 000000000..c869ffe11 --- /dev/null +++ b/crates/superposition_provider/src/local_provider.rs @@ -0,0 +1,732 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use derive_more::{Deref, DerefMut}; +use open_feature::provider::{ + FeatureProvider, ProviderMetadata, ProviderStatus, ResolutionDetails, +}; +use open_feature::{EvaluationContext, EvaluationResult, StructValue}; +use serde_json::{Map, Value}; +use superposition_core::experiment::{filter_experiments_by_context, FfiExperimentGroup}; +use superposition_core::{ + eval_config, get_applicable_variants, get_satisfied_experiments, MergeStrategy, +}; +use superposition_types::experimental::Experimental; +use superposition_types::DimensionInfo; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +use crate::data_source::{ + ConfigData, ExperimentData, FetchResponse, SuperpositionDataSource, +}; +use crate::traits::{AllFeatureProvider, FeatureExperimentMeta}; +use crate::{conversions, types::*}; + +pub struct LocalResolutionProviderInner { + primary: Arc, + fallback: Option>, + refresh_strategy: RefreshStrategy, + cached_config: RwLock>, + cached_experiments: RwLock>, + background_task: RwLock>>, + metadata: ProviderMetadata, + status: RwLock, + global_context: RwLock, +} + +#[derive(Deref, DerefMut, Clone)] +pub struct LocalResolutionProvider(Arc); + +impl LocalResolutionProvider { + pub fn new( + primary: Box, + fallback: Option>, + refresh_strategy: RefreshStrategy, + ) -> Self { + Self(Arc::new(LocalResolutionProviderInner { + primary: Arc::from(primary), + fallback: fallback.map(Arc::from), + refresh_strategy, + cached_config: RwLock::new(None), + cached_experiments: RwLock::new(None), + background_task: RwLock::new(None), + metadata: ProviderMetadata { + name: "LocalResolutionProvider".to_string(), + }, + status: RwLock::new(ProviderStatus::NotReady), + global_context: RwLock::new(EvaluationContext::default()), + })) + } + + pub async fn init(&self, context: EvaluationContext) -> Result<()> { + // Fetch initial config from primary, fall back if needed + let config_data = match self.primary.fetch_config(None).await { + Ok(data) => { + log::info!("LocalResolutionProvider: fetched config from primary source"); + data + } + Err(e) => { + log::warn!( + "LocalResolutionProvider: primary config fetch failed: {}", + e + ); + if let Some(fallback) = &self.fallback { + fallback.fetch_config(None).await.map_err(|fb_err| { + log::error!( + "LocalResolutionProvider: fallback config fetch also failed: {}", + fb_err + ); + SuperpositionError::ConfigError(format!( + "Both primary and fallback config fetch failed. Primary: {}. Fallback: {}", + e, fb_err + )) + })? + } else { + return Err(SuperpositionError::ConfigError(format!( + "Primary config fetch failed and no fallback configured: {}", + e + ))); + } + } + }; + + { + let mut cached = self.cached_config.write().await; + *cached = config_data.into_data(); + } + + // Fetch experiments best-effort: try primary, else fallback + let exp_data = if self.primary.supports_experiments() { + match self.primary.fetch_active_experiments(None).await { + Ok(exp_resp) => exp_resp.into_data(), + Err(e) => { + log::warn!( + "LocalResolutionProvider: primary experiment fetch failed: {}", + e + ); + if let Some(fallback) = &self.fallback { + if fallback.supports_experiments() { + match fallback.fetch_active_experiments(None).await { + Ok(exp_resp) => exp_resp.into_data(), + Err(fb_err) => { + log::warn!( + "LocalResolutionProvider: fallback experiment fetch also failed: {}", + fb_err + ); + return Err(SuperpositionError::ConfigError(format!( + "Both primary and fallback experiment fetch failed. Primary: {}. Fallback: {}", + e, fb_err + ))); + } + } + } else { + log::warn!( + "LocalResolutionProvider: fallback does not support experiments" + ); + None + } + } else { + return Err(SuperpositionError::ConfigError(format!( + "Primary experiment fetch failed and no fallback configured: {}", + e + ))); + } + } + } + } else { + None + }; + + if let Some(data) = exp_data { + let mut cached = self.cached_experiments.write().await; + *cached = Some(data); + } + + // Start refresh strategy + match &self.refresh_strategy { + RefreshStrategy::Polling(polling_strategy) => { + log::info!( + "LocalResolutionProvider: starting polling with interval={}s", + polling_strategy.interval + ); + let task = self.start_polling(polling_strategy.interval).await; + let mut background_task = self.background_task.write().await; + *background_task = Some(task); + } + RefreshStrategy::OnDemand(on_demand_strategy) => { + log::info!( + "LocalResolutionProvider: using OnDemand strategy with ttl={}s", + on_demand_strategy.ttl + ); + } + RefreshStrategy::Watch(watch_strategy) => { + let debounce_ms = watch_strategy.debounce_ms.unwrap_or(500); + match self.primary.watch() { + Ok(Some(stream)) => { + log::info!( + "LocalResolutionProvider: starting watch with debounce={}ms", + debounce_ms + ); + let task = self.start_watching(stream, debounce_ms).await; + let mut background_task = self.background_task.write().await; + *background_task = Some(task); + } + Ok(None) => { + return Err(SuperpositionError::ConfigError( + "Watch strategy selected but data source does not support watching".into(), + )); + } + Err(e) => { + return Err(SuperpositionError::ConfigError(format!( + "Failed to start watch: {}", + e + ))); + } + } + } + RefreshStrategy::Manual => { + log::info!("LocalResolutionProvider: using Manual refresh strategy"); + } + } + + { + let mut global_context = self.global_context.write().await; + *global_context = context; + } + + { + let mut status = self.status.write().await; + *status = ProviderStatus::Ready; + } + + Ok(()) + } + + pub async fn refresh(&self) -> Result<()> { + self.do_refresh().await + } + + pub async fn close_provider(&self) -> Result<()> { + // Abort background task + { + let mut background_task = self.background_task.write().await; + if let Some(task) = background_task.take() { + task.abort(); + } + } + + // Close data sources + if let Err(e) = self.primary.close().await { + log::warn!( + "LocalResolutionProvider: error closing primary source: {}", + e + ); + } + if let Some(fallback) = &self.fallback { + if let Err(e) = fallback.close().await { + log::warn!( + "LocalResolutionProvider: error closing fallback source: {}", + e + ); + } + } + + // Clear caches + { + let mut cached = self.cached_config.write().await; + *cached = None; + } + { + let mut cached = self.cached_experiments.write().await; + *cached = None; + } + + { + let mut global_context = self.global_context.write().await; + *global_context = EvaluationContext::default(); + } + + // Set status to NotReady + { + let mut status = self.status.write().await; + *status = ProviderStatus::NotReady; + } + + Ok(()) + } + + async fn do_refresh(&self) -> Result<()> { + // Fetch config from primary; keep last known good on failure + let last_fetched_at = { + self.cached_config + .read() + .await + .as_ref() + .map(|data| data.fetched_at) + }; + + let config_result = self.primary.fetch_config(last_fetched_at).await; + let mut resp = match config_result { + Ok(FetchResponse::Data(data)) => { + let mut cached = self.cached_config.write().await; + *cached = Some(data); + log::debug!("LocalResolutionProvider: config refreshed from primary"); + Ok(()) + } + Ok(FetchResponse::NotModified) => { + log::debug!("LocalResolutionProvider: config not modified"); + Ok(()) + } + Err(e) => { + log::warn!( + "LocalResolutionProvider: config refresh failed, keeping last known good: {}", + e + ); + Err(e) + } + }; + + if self.primary.supports_experiments() { + let exp_last_fetched_at = { + self.cached_experiments + .read() + .await + .as_ref() + .map(|d| d.fetched_at) + }; + match self + .primary + .fetch_active_experiments(exp_last_fetched_at) + .await + { + Ok(exp_resp) => { + let mut cached = self.cached_experiments.write().await; + if let Some(data) = exp_resp.into_data() { + *cached = Some(data); + } + log::debug!( + "LocalResolutionProvider: experiments refreshed from primary" + ); + } + Err(e) => { + log::warn!( + "LocalResolutionProvider: experiment refresh failed, keeping last known good: {}", + e + ); + if resp.is_ok() { + resp = Err(e); + } + } + } + } + + resp + } + + async fn start_polling(&self, interval: u64) -> JoinHandle<()> { + let provider = self.clone(); + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(interval)).await; + let _ = provider.do_refresh().await; + } + }) + } + + async fn start_watching( + &self, + mut watch_stream: crate::types::WatchStream, + debounce_ms: u64, + ) -> JoinHandle<()> { + let provider = self.clone(); + + tokio::spawn(async move { + loop { + match watch_stream.receiver.recv().await { + Ok(()) => { + // Debounce: wait, then drain any queued events + sleep(Duration::from_millis(debounce_ms)).await; + while watch_stream.receiver.try_recv().is_ok() {} + let _ = provider.do_refresh().await; + } + Err(e) => { + log::error!( + "LocalResolutionProvider: watch channel error: {}", + e + ); + } + } + } + }) + } + + async fn ensure_fresh_data(&self) -> Result<()> { + if let RefreshStrategy::OnDemand(on_demand) = &self.refresh_strategy { + let ttl = on_demand.ttl; + let use_stale_on_error = on_demand.use_stale_on_error.unwrap_or_default(); + + let is_elapsed = |cached_at: DateTime| { + (chrono::Utc::now() - cached_at).num_seconds() > ttl as i64 + }; + + let should_refresh_config = { + let cached = self.cached_config.read().await; + cached + .as_ref() + .map(|data| is_elapsed(data.fetched_at)) + .unwrap_or(true) + }; + + let should_refresh_experiments = { + let cached = self.cached_experiments.read().await; + cached + .as_ref() + .map(|data| is_elapsed(data.fetched_at)) + .unwrap_or(true) + }; + + if should_refresh_config || should_refresh_experiments { + log::debug!("LocalResolutionProvider: TTL expired, refreshing on-demand"); + if let Err(e) = self.do_refresh().await { + if !use_stale_on_error { + return Err(e); + } + log::warn!( + "LocalResolutionProvider: on-demand refresh failed, using stale data: {}", + e + ); + } + } + } + Ok(()) + } + + async fn get_dimensions_info(&self) -> HashMap { + let cached = self.cached_config.read().await; + match cached.as_ref() { + Some(data) => data.config.dimensions.clone(), + None => HashMap::new(), + } + } + + async fn eval_with_context( + &self, + mut context: EvaluationContext, + prefix_filter: Option<&[String]>, + ) -> Result> { + self.ensure_fresh_data().await?; + + let global_context = self.global_context.read().await; + context.merge_missing(&global_context); + + let (mut query_data, targeting_key) = + conversions::evaluation_context_to_query(context); + + let dimensions_info = self.get_dimensions_info().await; + + // If experiments are cached, get applicable variants and inject variantIds + { + let cached_exp = self.cached_experiments.read().await; + if let Some(exp_data) = cached_exp.as_ref() { + let variant_ids = get_applicable_variants( + &dimensions_info, + exp_data.data.experiments.clone(), + &exp_data.data.experiment_groups, + &query_data, + &targeting_key.clone().unwrap_or_default(), + prefix_filter.map(|p| p.to_vec()), + ); + + query_data.insert( + "variantIds".to_string(), + Value::Array(variant_ids.into_iter().map(Value::String).collect()), + ); + } + } + + // Evaluate config using cached data + let cached = self.cached_config.read().await; + match cached.as_ref() { + Some(config_data) => eval_config( + (*config_data.config.default_configs).clone(), + &config_data.config.contexts, + &config_data.config.overrides, + &config_data.config.dimensions, + &query_data, + MergeStrategy::MERGE, + prefix_filter.map(|p| p.to_vec()), + ) + .map_err(|e| { + SuperpositionError::ConfigError(format!( + "Failed to evaluate config: {}", + e + )) + }), + None => Err(SuperpositionError::ConfigError( + "No cached config available".into(), + )), + } + } +} + +#[async_trait] +impl AllFeatureProvider for LocalResolutionProvider { + async fn resolve_all_features( + &self, + context: EvaluationContext, + ) -> Result> { + self.eval_with_context(context, None).await + } + + async fn resolve_all_features_with_filter( + &self, + context: EvaluationContext, + prefix_filter: Option<&[String]>, + ) -> Result> { + self.eval_with_context(context, prefix_filter).await + } +} + +#[async_trait] +impl FeatureExperimentMeta for LocalResolutionProvider { + async fn get_applicable_variants( + &self, + mut context: EvaluationContext, + ) -> Result> { + self.ensure_fresh_data().await?; + + let global_context = self.global_context.read().await; + context.merge_missing(&global_context); + + let (query_data, targeting_key) = + conversions::evaluation_context_to_query(context); + let dimensions_info = self.get_dimensions_info().await; + + let cached_exp = self.cached_experiments.read().await; + let resp = match cached_exp.as_ref() { + Some(exp_data) => get_applicable_variants( + &dimensions_info, + exp_data.data.experiments.clone(), + &exp_data.data.experiment_groups, + &query_data, + &targeting_key.unwrap_or_default(), + None, + ), + None => vec![], + }; + Ok(resp) + } +} + +#[async_trait] +impl FeatureProvider for LocalResolutionProvider { + async fn initialize(&mut self, context: &EvaluationContext) { + log::info!("Initializing LocalResolutionProvider..."); + { + let mut status = self.status.write().await; + *status = ProviderStatus::NotReady; + } + if (self.init(context.clone()).await).is_err() { + let mut status = self.status.write().await; + *status = ProviderStatus::Error; + return; + } + + log::info!("LocalResolutionProvider initialized successfully"); + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_bool(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_string_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_string(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_int_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_int(flag_key, evaluation_context.clone()).await + } + + async fn resolve_float_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_float(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_struct_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_struct(flag_key, evaluation_context.clone()) + .await + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + fn status(&self) -> ProviderStatus { + match self.status.try_read() { + // need to do this as ProviderStatus neither implements Copy nor Clone + Ok(status) => match *status { + ProviderStatus::Ready => ProviderStatus::Ready, + ProviderStatus::Error => ProviderStatus::Error, + ProviderStatus::NotReady => ProviderStatus::NotReady, + ProviderStatus::STALE => ProviderStatus::STALE, + }, + Err(_) => ProviderStatus::NotReady, + } + } +} + +#[async_trait] +impl SuperpositionDataSource for LocalResolutionProvider { + async fn fetch_config( + &self, + if_modified_since: Option>, + ) -> Result> { + if if_modified_since.is_some() { + log::debug!("LocalResolutionProvider: ignoring if_modified_since for config, always returning cached data"); + } + let cached = self.cached_config.read().await; + match cached.as_ref() { + Some(data) => Ok(FetchResponse::Data(data.clone())), + None => Err(SuperpositionError::ConfigError( + "No cached config available".into(), + )), + } + } + + async fn fetch_filtered_config( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + let resp = self + .fetch_config(if_modified_since) + .await? + .map_data(|mut c| { + let prefix = prefix_filter.map(HashSet::from_iter); + c.config = c.config.filter(context.as_ref(), prefix.as_ref()); + + c + }); + + Ok(resp) + } + + async fn fetch_active_experiments( + &self, + if_modified_since: Option>, + ) -> Result> { + if !self.supports_experiments() { + return Err(SuperpositionError::ConfigError( + "Experiments not supported by this provider".into(), + )); + } + if if_modified_since.is_some() { + log::debug!("LocalResolutionProvider: ignoring if_modified_since for experiments, always returning cached data"); + } + let cached = self.cached_experiments.read().await; + match cached.clone() { + Some(data) => Ok(FetchResponse::Data(data)), + None => Err(SuperpositionError::ConfigError( + "No cached experiments available".into(), + )), + } + } + + async fn fetch_candidate_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + if !self.supports_experiments() { + return Err(SuperpositionError::ConfigError( + "Experiments not supported by this provider".into(), + )); + } + + let resp = self + .fetch_active_experiments(if_modified_since) + .await? + .map_data(|mut exp_data| { + let context = context.unwrap_or_default(); + exp_data.data.experiments = get_satisfied_experiments( + exp_data.data.experiments, + &context, + prefix_filter, + ); + exp_data.data.experiment_groups = FfiExperimentGroup::get_satisfied( + exp_data.data.experiment_groups, + &context, + ); + exp_data + }); + + Ok(resp) + } + + async fn fetch_matching_active_experiments( + &self, + context: Option>, + prefix_filter: Option>, + if_modified_since: Option>, + ) -> Result> { + if !self.supports_experiments() { + return Err(SuperpositionError::ConfigError( + "Experiments not supported by this provider".into(), + )); + } + + let resp = self + .fetch_active_experiments(if_modified_since) + .await? + .map_data(|mut exp_data| { + let context = context.unwrap_or_default(); + exp_data.data.experiments = filter_experiments_by_context( + exp_data.data.experiments, + &context, + prefix_filter, + ); + exp_data.data.experiment_groups = FfiExperimentGroup::filter_by_eval( + exp_data.data.experiment_groups, + &context, + ); + exp_data + }); + + Ok(resp) + } + + fn supports_experiments(&self) -> bool { + self.primary.supports_experiments() + } + + async fn close(&self) -> Result<()> { + self.close_provider().await + } +} diff --git a/crates/superposition_provider/src/provider.rs b/crates/superposition_provider/src/provider.rs index 7fe4be3c0..0133b26ca 100644 --- a/crates/superposition_provider/src/provider.rs +++ b/crates/superposition_provider/src/provider.rs @@ -15,9 +15,11 @@ use serde_json::{Map, Value}; use superposition_types::{Config, DimensionInfo}; use tokio::sync::RwLock; -use crate::client::{CacConfig, ExperimentationConfig}; use crate::types::*; -use crate::utils::ConversionUtils; +use crate::{ + client::{CacConfig, ExperimentationConfig}, + conversions, +}; #[derive(Debug, Clone)] pub struct SuperpositionProvider { @@ -65,24 +67,6 @@ impl SuperpositionProvider { } } - fn get_context_from_evaluation_context( - &self, - evaluation_context: &EvaluationContext, - ) -> (serde_json::Map, Option) { - let context = evaluation_context - .custom_fields - .iter() - .map(|(k, v)| { - ( - k.clone(), - ConversionUtils::convert_evaluation_context_value_to_serde_value(v), - ) - }) - .collect(); - - (context, evaluation_context.targeting_key.clone()) - } - async fn get_dimensions_info(&self) -> HashMap { match &self.cac_config { Some(cac_config) => cac_config @@ -138,7 +122,7 @@ impl SuperpositionProvider { ) -> Result> { // Get cached config from CAC let (mut context, targeting_key) = - self.get_context_from_evaluation_context(evaluation_context); + conversions::evaluation_context_to_query(evaluation_context.clone()); let dimensions_info = self.get_dimensions_info().await; let variant_ids = if let Some(exp_config) = &self.exp_config { @@ -330,10 +314,10 @@ impl FeatureProvider for SuperpositionProvider { evaluation_context: &EvaluationContext, ) -> EvaluationResult> { match self.eval_config(evaluation_context).await { - Ok(config) => { - if let Some(value) = config.get(flag_key) { + Ok(mut config) => { + if let Some(value) = config.remove(flag_key) { // Use the conversion utility we added earlier - match ConversionUtils::serde_value_to_struct_value(value) { + match conversions::value_to_struct(value) { Ok(struct_value) => { return Ok(ResolutionDetails::new(struct_value)); } diff --git a/crates/superposition_provider/src/remote_provider.rs b/crates/superposition_provider/src/remote_provider.rs new file mode 100644 index 000000000..9a5e05b79 --- /dev/null +++ b/crates/superposition_provider/src/remote_provider.rs @@ -0,0 +1,376 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use aws_smithy_types::Document; +use open_feature::{ + provider::FeatureProvider, + provider::{ProviderMetadata, ProviderStatus, ResolutionDetails}, + EvaluationContext, EvaluationResult, StructValue, +}; +use serde_json::{Map, Value}; +use superposition_sdk::{Client, Config as SdkConfig}; +use tokio::sync::RwLock; + +use crate::traits::{AllFeatureProvider, FeatureExperimentMeta}; +use crate::{conversions, types::*}; + +// --------------------------------------------------------------------------- +// ResponseCache internals +// --------------------------------------------------------------------------- + +struct CacheEntry { + value: Map, + created_at: Instant, +} + +struct ResponseCache { + entries: HashMap, + max_entries: usize, + ttl: Duration, +} + +impl ResponseCache { + fn new(max_entries: usize, ttl: Duration) -> Self { + Self { + entries: HashMap::new(), + max_entries, + ttl, + } + } + + fn get(&self, key: &str) -> Option<&Map> { + self.entries.get(key).and_then(|entry| { + if entry.created_at.elapsed() < self.ttl { + Some(&entry.value) + } else { + None + } + }) + } + + fn put(&mut self, key: String, value: Map) { + // If at capacity, evict expired entries first + if self.entries.len() >= self.max_entries { + let now = Instant::now(); + self.entries + .retain(|_, entry| now.duration_since(entry.created_at) < self.ttl); + } + + // If still at capacity, remove the oldest entry + if self.entries.len() >= self.max_entries { + if let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.created_at) + .map(|(k, _)| k.clone()) + { + self.entries.remove(&oldest_key); + } + } + + self.entries.insert( + key, + CacheEntry { + value, + created_at: Instant::now(), + }, + ); + } + + fn cache_key(context: &EvaluationContext) -> String { + let mut parts: Vec = Vec::new(); + + // Include targeting_key + if let Some(ref tk) = context.targeting_key { + parts.push(format!("tk={}", tk)); + } + + // Include sorted custom_fields for deterministic keys + let mut field_keys: Vec<_> = context.custom_fields.keys().cloned().collect(); + field_keys.sort(); + for k in field_keys { + if let Some(v) = context.custom_fields.get(&k) { + let serde_val = conversions::evaluation_context_to_value(v.clone()); + parts.push(format!("{}={}", k, serde_val)); + } + } + + parts.join("|") + } +} + +// --------------------------------------------------------------------------- +// SuperpositionAPIProvider +// --------------------------------------------------------------------------- + +pub struct SuperpositionAPIProvider { + options: SuperpositionOptions, + cache: Option>>, + global_context: RwLock, + metadata: ProviderMetadata, + status: RwLock, + client: Client, +} + +fn create_client(options: &SuperpositionOptions) -> Client { + let sdk_config = SdkConfig::builder() + .endpoint_url(&options.endpoint) + .bearer_token(options.token.clone().into()) + .behavior_version_latest() + .build(); + + Client::from_conf(sdk_config) +} + +impl SuperpositionAPIProvider { + /// Create a new provider without response caching. + pub fn new(options: SuperpositionOptions) -> Self { + Self { + client: create_client(&options), + options, + cache: None, + global_context: RwLock::new(EvaluationContext::default()), + metadata: ProviderMetadata { + name: "SuperpositionAPIProvider".to_string(), + }, + status: RwLock::new(ProviderStatus::NotReady), + } + } + + /// Create a new provider with response caching. + pub fn with_cache( + options: SuperpositionOptions, + cache_options: CacheOptions, + ) -> Self { + let max_entries = cache_options.size.unwrap_or(1000); + let ttl = Duration::from_secs(cache_options.ttl.unwrap_or(300)); + let cache = ResponseCache::new(max_entries, ttl); + + Self { + client: create_client(&options), + options, + cache: Some(Arc::new(RwLock::new(cache))), + global_context: RwLock::new(EvaluationContext::default()), + metadata: ProviderMetadata { + name: "SuperpositionAPIProvider".to_string(), + }, + status: RwLock::new(ProviderStatus::NotReady), + } + } + + async fn resolve_remote( + &self, + mut context: EvaluationContext, + prefix_filter: Option<&[String]>, + ) -> Result> { + let cache_key = ResponseCache::cache_key(&context); + // 1. Check cache for the full (unfiltered) result + if let Some(ref cache_arc) = self.cache { + let cache = cache_arc.read().await; + if let Some(cached_value) = cache.get(&cache_key) { + log::debug!("SuperpositionAPIProvider: cache hit for key"); + let result = if let Some(prefixes) = prefix_filter { + filter_by_prefix(cached_value, prefixes) + } else { + cached_value.clone() + }; + return Ok(result); + } + } + + // 2. Create SDK client + let client = &self.client; + + let global_context = self.global_context.read().await; + context.merge_missing(&global_context); + + // 3. Extract context and targeting_key + let (query_data, _targeting_key) = + conversions::evaluation_context_to_query(context); + + // 4. Build and send the get_resolved_config request + // Always fetch WITHOUT prefix filter so we can cache the full result + let mut builder = client + .get_resolved_config() + .workspace_id(&self.options.workspace_id) + .org_id(&self.options.org_id); + + // Set context dimensions from evaluation context + let sdk_context: HashMap = query_data + .into_iter() + .map(|(k, v)| (k, conversions::value_to_document(v))) + .collect(); + builder = builder.set_context(Some(sdk_context)); + + // NOTE: We intentionally do NOT set prefix filter on the SDK request + // so we always get the full config and can cache it. Prefix filtering + // is applied locally after caching. + + let response = builder.send().await.map_err(|e| { + SuperpositionError::NetworkError(format!( + "Failed to get resolved config: {}", + e + )) + })?; + + // 5. Convert response Document to Map + let config_value = conversions::document_to_value(response.config); + + let full_result = match config_value { + Value::Object(map) => map, + other => { + log::warn!( + "SuperpositionAPIProvider: resolved config is not an object, wrapping: {:?}", + other + ); + let mut map = Map::new(); + map.insert("_value".to_string(), other); + map + } + }; + + // Cache the full (unfiltered) result + if let Some(ref cache_arc) = self.cache { + let mut cache = cache_arc.write().await; + cache.put(cache_key, full_result.clone()); + } + + // Apply prefix filtering locally + let result = if let Some(prefixes) = prefix_filter { + filter_by_prefix(&full_result, prefixes) + } else { + full_result + }; + + Ok(result) + } +} + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Filter a config map to only include keys that start with one of the given prefixes. +fn filter_by_prefix( + config: &Map, + prefixes: &[String], +) -> Map { + if prefixes.is_empty() { + return config.clone(); + } + config + .iter() + .filter(|(key, _)| prefixes.iter().any(|prefix| key.starts_with(prefix))) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +// --------------------------------------------------------------------------- +// Trait implementations +// --------------------------------------------------------------------------- + +#[async_trait] +impl AllFeatureProvider for SuperpositionAPIProvider { + async fn resolve_all_features( + &self, + context: EvaluationContext, + ) -> Result> { + self.resolve_remote(context, None).await + } + + async fn resolve_all_features_with_filter( + &self, + context: EvaluationContext, + prefix_filter: Option<&[String]>, + ) -> Result> { + self.resolve_remote(context, prefix_filter).await + } +} + +// TODO: Pending +#[async_trait] +impl FeatureExperimentMeta for SuperpositionAPIProvider { + async fn get_applicable_variants( + &self, + _context: EvaluationContext, + ) -> Result> { + // Remote resolution handles experiments server-side + Ok(vec![]) + } +} + +#[async_trait] +impl FeatureProvider for SuperpositionAPIProvider { + // TODO: use context and set as global context for the provider + async fn initialize(&mut self, _context: &EvaluationContext) { + log::info!("Initializing SuperpositionAPIProvider..."); + { + let mut status = self.status.write().await; + *status = ProviderStatus::Ready; + } + log::info!("SuperpositionAPIProvider initialized successfully"); + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_bool(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_string_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_string(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_int_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_int(flag_key, evaluation_context.clone()).await + } + + async fn resolve_float_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_float(flag_key, evaluation_context.clone()) + .await + } + + async fn resolve_struct_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_struct(flag_key, evaluation_context.clone()) + .await + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + fn status(&self) -> ProviderStatus { + match self.status.try_read() { + // need to do this as ProviderStatus neither implements Copy nor Clone + Ok(status) => match *status { + ProviderStatus::Ready => ProviderStatus::Ready, + ProviderStatus::Error => ProviderStatus::Error, + ProviderStatus::NotReady => ProviderStatus::NotReady, + ProviderStatus::STALE => ProviderStatus::STALE, + }, + Err(_) => ProviderStatus::NotReady, + } + } +} diff --git a/crates/superposition_provider/src/traits.rs b/crates/superposition_provider/src/traits.rs new file mode 100644 index 000000000..d96f07135 --- /dev/null +++ b/crates/superposition_provider/src/traits.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use open_feature::{ + provider::ResolutionDetails, EvaluationContext, EvaluationError, EvaluationErrorCode, + EvaluationResult, StructValue, +}; +use serde_json::{Map, Value}; + +use crate::{conversions, types::Result}; + +/// Trait for experiment variant resolution. +/// +/// Implementors provide the ability to determine which experiment variants +/// are applicable for a given evaluation context. +#[async_trait] +pub trait FeatureExperimentMeta: Send + Sync { + /// Get the list of applicable experiment variant IDs for the given context. + async fn get_applicable_variants( + &self, + context: EvaluationContext, + ) -> Result>; +} + +/// Trait for bulk configuration resolution. +/// +/// Implementors provide the ability to resolve all feature flags at once, +/// optionally filtered by key prefixes. +#[async_trait] +pub trait AllFeatureProvider: Send + Sync { + /// Resolve all features for the given evaluation context. + async fn resolve_all_features( + &self, + context: EvaluationContext, + ) -> Result>; + + /// Resolve all features for the given evaluation context, optionally + /// filtered to only include keys matching the provided prefixes. + async fn resolve_all_features_with_filter( + &self, + context: EvaluationContext, + prefix_filter: Option<&[String]>, + ) -> Result>; + + async fn resolve_typed( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + type_name: &str, + extractor: impl Fn(Value) -> Option + Send + Sync, + ) -> EvaluationResult> { + match self.resolve_all_features(evaluation_context).await { + Ok(mut config) => { + match config.remove(flag_key) { + Some(value) => extractor(value) + .map(ResolutionDetails::new) + .ok_or_else(|| EvaluationError { + code: EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Flag '{flag_key}' is not a {type_name}", + )), + }), + None => Err(EvaluationError { + code: EvaluationErrorCode::FlagNotFound, + message: Some(format!("Flag '{}' not found", flag_key)), + }), + } + } + Err(e) => { + log::error!("Error evaluating {} flag {}: {}", type_name, flag_key, e); + Err(EvaluationError { + code: EvaluationErrorCode::General(format!( + "Error evaluating flag '{}': {}", + flag_key, e + )), + message: Some(format!("Error evaluating flag '{}': {}", flag_key, e)), + }) + } + } + } + + async fn resolve_bool( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + ) -> EvaluationResult> { + self.resolve_typed(flag_key, evaluation_context, "boolean", |v| v.as_bool()) + .await + } + + async fn resolve_string( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + ) -> EvaluationResult> { + self.resolve_typed(flag_key, evaluation_context, "string", |v| match v { + Value::String(s) => Some(s), + _ => None, + }) + .await + } + + async fn resolve_int( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + ) -> EvaluationResult> { + self.resolve_typed(flag_key, evaluation_context, "integer", |v| v.as_i64()) + .await + } + + async fn resolve_float( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + ) -> EvaluationResult> { + self.resolve_typed(flag_key, evaluation_context, "float", |v| v.as_f64()) + .await + } + + async fn resolve_struct( + &self, + flag_key: &str, + evaluation_context: EvaluationContext, + ) -> EvaluationResult> { + self.resolve_typed(flag_key, evaluation_context, "struct", |v| { + conversions::value_to_struct(v).ok() + }) + .await + } +} diff --git a/crates/superposition_provider/src/types.rs b/crates/superposition_provider/src/types.rs index e407bcdfa..292e2b8a6 100644 --- a/crates/superposition_provider/src/types.rs +++ b/crates/superposition_provider/src/types.rs @@ -105,10 +105,32 @@ impl Default for OnDemandStrategy { } } +/// Configuration for the watch refresh strategy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchStrategy { + /// Debounce duration in milliseconds (default: 500). + pub debounce_ms: Option, +} + +impl Default for WatchStrategy { + fn default() -> Self { + Self { + debounce_ms: Some(500), + } + } +} + +/// A stream of change notifications from a data source. +pub struct WatchStream { + pub receiver: tokio::sync::broadcast::Receiver<()>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum RefreshStrategy { Polling(PollingStrategy), OnDemand(OnDemandStrategy), + Watch(WatchStrategy), + Manual, } impl Default for RefreshStrategy { diff --git a/crates/superposition_provider/src/utils.rs b/crates/superposition_provider/src/utils.rs index 9f5d400d4..41f7dec19 100644 --- a/crates/superposition_provider/src/utils.rs +++ b/crates/superposition_provider/src/utils.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; -use aws_smithy_types::Document; use log::debug; -use serde_json::{json, Map, Value}; -use superposition_core::experiment::{ExperimentGroups, FfiExperimentGroup}; +use serde_json::{Map, Value}; +use superposition_core::experiment::{ + ExperimentConfig, ExperimentGroups, FfiExperimentGroup, +}; use superposition_core::{Experiments, FfiExperiment}; -use superposition_sdk::operation::list_experiment_groups::ListExperimentGroupsOutput; use superposition_sdk::types::{ ExperimentStatusType as SDKExperimentStatusType, GroupType as SdkGroupType, }; @@ -18,43 +18,39 @@ use superposition_types::{ Overrides, }; -use crate::types::*; +use crate::{conversions, types::*}; pub struct ConversionUtils; impl ConversionUtils { pub fn convert_get_config_response( - response: &superposition_sdk::operation::get_config::GetConfigOutput, + response: superposition_sdk::operation::get_config::GetConfigOutput, ) -> Result { debug!("Converting get_config response to superposition_types::Config"); // Convert default configs - these are already Value types - let default_configs = - Self::convert_condition_document(response.default_configs())?; + let default_configs = conversions::hashmap_to_map(response.default_configs); // Convert overrides - HashMap> - let overrides = { - let mut result_map = HashMap::new(); - for (override_key, inner_map) in response.overrides() { - let override_values = Self::convert_condition_document(inner_map)?; - - // Create Overrides directly from Map - let overrides_obj = Cac::::try_from(override_values) + let overrides = response + .overrides + .into_iter() + .map(|(k, v)| { + let override_values = conversions::hashmap_to_map(v); + let override_obj = Cac::::try_from(override_values) .map_err(|e| SuperpositionError::SerializationError(e.to_string()))?; - - result_map.insert(override_key.clone(), overrides_obj.into_inner()); - } - result_map - }; + Ok((k, override_obj.into_inner())) + }) + .collect::>>()?; // Convert contexts - Vec let contexts = response - .contexts() - .iter() + .contexts + .into_iter() .map(|context_partial| { // Convert condition Document to Map let condition_map = - Self::convert_condition_document(context_partial.condition())?; + conversions::hashmap_to_map(context_partial.condition); // Create Condition directly from Map let condition = @@ -65,48 +61,40 @@ impl ConversionUtils { )) })?; - let override_with_keys = OverrideWithKeys::try_from( - context_partial.override_with_keys().to_vec(), - ) - .map_err(|e| { - SuperpositionError::SerializationError(format!( - "Invalid override_with_keys: {e}", - )) - })?; + let override_with_keys = + OverrideWithKeys::try_from(context_partial.override_with_keys) + .map_err(|e| { + SuperpositionError::SerializationError(format!( + "Invalid override_with_keys: {e}", + )) + })?; Ok(Context { - id: context_partial.id().to_string(), + id: context_partial.id, condition: condition.into_inner(), - priority: context_partial.priority(), - weight: context_partial.weight(), + priority: context_partial.priority, + weight: context_partial.weight, override_with_keys, }) }) .collect::>>()?; let dimensions = response - .dimensions() - .iter() + .dimensions + .into_iter() .map(|(key, dimension_info)| { - let schema = dimension_info - .schema() - .iter() - .map(|(k, v)| Self::document_to_value(v).map(|val| (k.clone(), val))) - .collect::>>()?; + let schema = conversions::hashmap_to_map(dimension_info.schema); let dim_info = DimensionInfo { schema: ExtendedMap::from(schema), - position: dimension_info.position(), + position: dimension_info.position, dimension_type: Self::try_dimension_type( - dimension_info.dimension_type(), + dimension_info.dimension_type, )?, - dependency_graph: DependencyGraph( - dimension_info.dependency_graph().clone(), - ), + dependency_graph: DependencyGraph(dimension_info.dependency_graph), value_compute_function_name: dimension_info - .value_compute_function_name() - .map(String::from), + .value_compute_function_name, }; - Ok((key.clone(), dim_info)) + Ok((key, dim_info)) }) .collect::>>()?; @@ -124,14 +112,14 @@ impl ConversionUtils { } fn try_dimension_type( - dim_type: &superposition_sdk::types::DimensionType, + dim_type: superposition_sdk::types::DimensionType, ) -> Result { match dim_type { superposition_sdk::types::DimensionType::RemoteCohort(cohort_based_on) => { - Ok(DimensionType::RemoteCohort(cohort_based_on.clone())) + Ok(DimensionType::RemoteCohort(cohort_based_on)) } superposition_sdk::types::DimensionType::LocalCohort(cohort_based_on) => { - Ok(DimensionType::LocalCohort(cohort_based_on.clone())) + Ok(DimensionType::LocalCohort(cohort_based_on)) } superposition_sdk::types::DimensionType::Regular => { Ok(DimensionType::Regular {}) @@ -314,34 +302,22 @@ impl ConversionUtils { }) } - fn convert_condition_document( - context: &HashMap, - ) -> Result> { - let mut condition_map = Map::new(); - for (key, doc) in context { - let value = Self::document_to_value(doc)?; - condition_map.insert(key.clone(), value); - } - Ok(condition_map) - } - /// Convert list_experiment SDK response to structured experiment data pub fn convert_experiments_response( - response: &superposition_sdk::operation::list_experiment::ListExperimentOutput, + response: Vec, ) -> Result { debug!("Converting experiments response"); - let exp_list = response.data(); let mut trimmed_exp_list: Experiments = Vec::new(); - for exp in exp_list { + for exp in response { // Convert experiment context (condition) - let condition_map = Self::convert_condition_document(exp.context())?; + let condition_map = conversions::hashmap_to_map(exp.context); // Convert variants let mut variants: Variants = Variants::new(vec![]); - for variant in exp.variants() { - let variant_type = match variant.variant_type() { + for variant in exp.variants { + let variant_type = match variant.variant_type { superposition_sdk::types::VariantType::Control => { VariantType::CONTROL } @@ -356,16 +332,16 @@ impl ConversionUtils { }; // Convert variant overrides - check if overrides exist - let overrides_map = Self::hashmap_to_map(variant.overrides())?; + let overrides_map = conversions::hashmap_to_map(variant.overrides); let override_ = Exp::::try_from(overrides_map) .map_err(|e| SuperpositionError::SerializationError(e.to_string()))?; let variant_value = Variant { - id: variant.id.clone(), + id: variant.id, variant_type, - context_id: variant.context_id.clone(), - override_id: variant.override_id.clone(), + context_id: variant.context_id, + override_id: variant.override_id, overrides: override_, }; variants.push(variant_value); @@ -391,7 +367,7 @@ impl ConversionUtils { } }; let experiment = FfiExperiment { - id: exp.id.clone(), + id: exp.id, context, variants, traffic_percentage: exp.traffic_percentage as u8, @@ -405,16 +381,15 @@ impl ConversionUtils { } pub fn convert_experiment_groups_response( - response: &ListExperimentGroupsOutput, + response: Vec, ) -> Result { debug!("Converting experiment groups response"); - let group_list = response.data(); let mut trimmed_group_list: ExperimentGroups = Vec::new(); - for exp_group in group_list { + for exp_group in response { // Convert experiment context (condition) - let condition_map = Self::convert_condition_document(exp_group.context())?; + let condition_map = conversions::hashmap_to_map(exp_group.context); let context = Exp::::try_from(condition_map) .map_err(|e| { @@ -435,19 +410,19 @@ impl ConversionUtils { }; let experiment_group = FfiExperimentGroup { - id: exp_group.id.clone(), + id: exp_group.id, context, traffic_percentage: exp_group.traffic_percentage as u8, - member_experiment_ids: exp_group.member_experiment_ids().to_vec(), + member_experiment_ids: exp_group.member_experiment_ids, group_type, buckets: Buckets::try_from( exp_group .buckets - .iter() + .into_iter() .map(|b| { - b.as_ref().map(|bucket| Bucket { - variant_id: bucket.variant_id.clone(), - experiment_id: bucket.experiment_id.clone(), + b.map(|bucket| Bucket { + variant_id: bucket.variant_id, + experiment_id: bucket.experiment_id, }) }) .collect::>(), @@ -461,120 +436,19 @@ impl ConversionUtils { Ok(trimmed_group_list) } - /// Convert AWS Smithy Document to serde_json::Value - pub fn document_to_value(doc: &aws_smithy_types::Document) -> Result { - Self::document_to_value_recursive(doc) - } - - pub fn hashmap_to_map( - hashmap: &HashMap, - ) -> Result> { - hashmap - .iter() - .map(|(k, v)| { - let value = Self::document_to_value(v)?; - Ok((k.clone(), value)) - }) - .collect() - } - - /// Recursively convert AWS Smithy Document to serde_json::Value by properly matching variants - fn document_to_value_recursive(doc: &aws_smithy_types::Document) -> Result { - use aws_smithy_types::Document; + pub fn convert_experiment_config_response( + response: superposition_sdk::operation::get_experiment_config::GetExperimentConfigOutput, + ) -> Result { + debug!("Converting experiment config response"); - match doc { - Document::Object(obj) => { - let mut map = Map::new(); - for (key, value) in obj { - let converted_value = Self::document_to_value_recursive(value)?; - map.insert(key.clone(), converted_value); - } - Ok(Value::Object(map)) - } - Document::Array(arr) => { - let mut vec = Vec::new(); - for item in arr { - let converted_item = Self::document_to_value_recursive(item)?; - vec.push(converted_item); - } - Ok(Value::Array(vec)) - } - Document::Number(num) => { - use aws_smithy_types::Number; - match num { - Number::PosInt(val) => { - Ok(Value::Number(serde_json::Number::from(*val))) - } - Number::NegInt(val) => { - Ok(Value::Number(serde_json::Number::from(*val))) - } - Number::Float(val) => Ok(Value::Number( - serde_json::Number::from_f64(*val).ok_or_else(|| { - SuperpositionError::SerializationError( - "Invalid float value".into(), - ) - })?, - )), - } - } - Document::String(s) => Ok(Value::String(s.clone())), - Document::Bool(b) => Ok(Value::Bool(*b)), - Document::Null => Ok(Value::Null), - } - } + let experiments = Self::convert_experiments_response(response.experiments)?; + let experiment_groups = + Self::convert_experiment_groups_response(response.experiment_groups)?; - pub fn convert_evaluation_context_value_to_serde_value( - value: &open_feature::EvaluationContextFieldValue, - ) -> Value { - match value { - open_feature::EvaluationContextFieldValue::Bool(b) => Value::Bool(*b), - open_feature::EvaluationContextFieldValue::Int(i) => { - Value::Number(serde_json::Number::from(*i)) - } - open_feature::EvaluationContextFieldValue::Float(f) => json!(f), - open_feature::EvaluationContextFieldValue::String(s) => { - Value::String(s.clone()) - } - open_feature::EvaluationContextFieldValue::DateTime(dt) => { - Value::String(dt.to_string()) - } - open_feature::EvaluationContextFieldValue::Struct(s) => { - // Convert struct to serde_json::Value - let struct_map: Map = s - .as_ref() - .downcast_ref::>() - .cloned() - .unwrap_or_default(); - Value::Object(struct_map) - } - } - } - /// Convert evaluation context to dimension data format expected by superposition_types - pub fn context_to_dimension_data( - context: &open_feature::EvaluationContext, - ) -> Map { - let mut dimension_data = Map::new(); - - // Add targeting key if present - if let Some(targeting_key) = &context.targeting_key { - dimension_data.insert( - "targeting_key".to_string(), - Value::String(targeting_key.to_string()), - ); - } - - // Add all other fields from the context - for (key, value) in &context.custom_fields { - let serde_value = - Self::convert_evaluation_context_value_to_serde_value(value); - dimension_data.insert(key.clone(), serde_value); - } - - debug!( - "Converted evaluation context to dimension data with {} keys", - dimension_data.len() - ); - dimension_data + Ok(ExperimentConfig { + experiments, + experiment_groups, + }) } /// Convert Config back to the legacy format for compatibility with existing provider logic @@ -621,7 +495,7 @@ impl ConversionUtils { /// Evaluate config using superposition_types logic and return resolved values pub fn evaluate_config( - config: &Config, + config: Config, dimension_data: &Map, prefix_filter: Option<&[String]>, ) -> Result> { @@ -679,151 +553,4 @@ impl ConversionUtils { let final_result: HashMap = result.into_iter().collect(); Ok(final_result) } - - /// Convert serde_json Value to boolean for OpenFeature provider - pub fn serde_value_to_bool(value: &Value) -> Result { - match value { - Value::Bool(b) => Ok(*b), - Value::String(s) => s.parse::().map_err(|_| { - SuperpositionError::ConfigError(format!( - "Cannot convert string '{}' to boolean", - s - )) - }), - _ => Err(SuperpositionError::ConfigError(format!( - "Cannot convert {:?} to boolean", - value - ))), - } - } - - /// Convert serde_json Value to string for OpenFeature provider - pub fn serde_value_to_string(value: &Value) -> Result { - match value { - Value::String(s) => Ok(s.clone()), - Value::Number(n) => Ok(n.to_string()), - Value::Bool(b) => Ok(b.to_string()), - _ => Err(SuperpositionError::ConfigError(format!( - "Cannot convert {:?} to string", - value - ))), - } - } - - /// Convert serde_json Value to integer for OpenFeature provider - pub fn serde_value_to_int(value: &Value) -> Result { - match value { - Value::Number(n) => n.as_i64().ok_or_else(|| { - SuperpositionError::ConfigError(format!( - "Cannot convert number {} to i64", - n - )) - }), - Value::String(s) => s.parse::().map_err(|_| { - SuperpositionError::ConfigError(format!( - "Cannot convert string '{}' to i64", - s - )) - }), - _ => Err(SuperpositionError::ConfigError(format!( - "Cannot convert {:?} to i64", - value - ))), - } - } - - /// Convert serde_json Value to float for OpenFeature provider - pub fn serde_value_to_float(value: &Value) -> Result { - match value { - Value::Number(n) => n.as_f64().ok_or_else(|| { - SuperpositionError::ConfigError(format!( - "Cannot convert number {} to f64", - n - )) - }), - Value::String(s) => s.parse::().map_err(|_| { - SuperpositionError::ConfigError(format!( - "Cannot convert string '{}' to f64", - s - )) - }), - _ => Err(SuperpositionError::ConfigError(format!( - "Cannot convert {:?} to f64", - value - ))), - } - } - - /// Convert serde_json Value to OpenFeature StructValue - pub fn serde_value_to_struct_value( - value: &Value, - ) -> Result { - match value { - Value::Object(map) => { - let mut fields = HashMap::new(); - for (k, v) in map { - let open_feature_value = Self::serde_value_to_openfeature_value(v)?; - fields.insert(k.clone(), open_feature_value); - } - // StructValue is just a struct with a fields HashMap, not a complex conversion - Ok(open_feature::StructValue { fields }) - } - Value::Array(list) => { - let mut fields = HashMap::new(); - for (index, item) in list.iter().enumerate() { - let open_feature_value = - Self::serde_value_to_openfeature_value(item)?; - fields.insert(index.to_string(), open_feature_value); - } - Ok(open_feature::StructValue { fields }) - } - _ => Err(SuperpositionError::ConfigError(format!( - "Cannot convert {:?} to StructValue - flag must be an object/array", - value - ))), - } - } - - /// Convert serde_json Value to OpenFeature Value - pub fn serde_value_to_openfeature_value( - value: &Value, - ) -> Result { - match value { - Value::Bool(b) => Ok(open_feature::Value::Bool(*b)), - Value::String(s) => Ok(open_feature::Value::String(s.clone())), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(open_feature::Value::Int(i)) - } else if let Some(f) = n.as_f64() { - Ok(open_feature::Value::Float(f)) - } else { - Err(SuperpositionError::ConfigError(format!( - "Cannot convert number {} to OpenFeature value", - n - ))) - } - } - Value::Array(arr) => { - let mut list = Vec::new(); - for item in arr { - list.push(Self::serde_value_to_openfeature_value(item)?); - } - // OpenFeature uses Array, not List - Ok(open_feature::Value::Array(list)) - } - Value::Object(map) => { - let mut fields = HashMap::new(); - for (k, v) in map { - let open_feature_value = Self::serde_value_to_openfeature_value(v)?; - fields.insert(k.clone(), open_feature_value); - } - // Create StructValue directly with fields HashMap - let struct_value = open_feature::StructValue { fields }; - Ok(open_feature::Value::Struct(struct_value)) - } - Value::Null => Err(SuperpositionError::ConfigError( - "Cannot convert null to OpenFeature value".to_string(), - )), - } - } } diff --git a/crates/superposition_types/src/config.rs b/crates/superposition_types/src/config.rs index 39b47a11f..796515502 100644 --- a/crates/superposition_types/src/config.rs +++ b/crates/superposition_types/src/config.rs @@ -20,7 +20,7 @@ use crate::database::schema::dimensions; use crate::{ database::models::cac::{DependencyGraph, DimensionType}, logic::evaluate_local_cohorts_skip_unresolved, - overridden::filter_config_keys_by_prefix, + overridden::{filter_config_keys_by_prefix, filter_into_config_keys_by_prefix}, Cac, Contextual, Exp, ExtendedMap, }; @@ -88,6 +88,10 @@ impl Overrides { } Ok(Self(override_map)) } + + pub fn into_inner(self) -> Map { + self.0 + } } impl IntoIterator for Overrides { @@ -289,28 +293,27 @@ pub struct Config { } impl Config { - pub fn filter_by_dimensions(&self, dimension_data: &Map) -> Self { + pub fn filter_by_dimensions(self, dimension_data: &Map) -> Self { let modified_context = evaluate_local_cohorts_skip_unresolved(&self.dimensions, dimension_data); - let filtered_context = - Context::filter_by_eval(self.contexts.clone(), &modified_context); - + let filtered_context = Context::filter_by_eval(self.contexts, &modified_context); + let mut initial_overrides = self.overrides; let filtered_overrides: HashMap = filtered_context .iter() .flat_map(|ele| { let override_with_key = ele.override_with_keys.get_key(); - self.overrides - .get(override_with_key) - .map(|value| (override_with_key.to_string(), value.clone())) + initial_overrides + .remove(override_with_key) + .map(|value| (override_with_key.to_string(), value)) }) .collect(); Self { contexts: filtered_context, overrides: filtered_overrides, - default_configs: self.default_configs.clone(), - dimensions: self.dimensions.clone(), + default_configs: self.default_configs, + dimensions: self.dimensions, } } @@ -318,39 +321,60 @@ impl Config { filter_config_keys_by_prefix(&self.default_configs, prefix_list).into() } - pub fn filter_by_prefix(&self, prefix_list: &HashSet) -> Self { - let mut filtered_overrides: HashMap = HashMap::new(); - + pub fn filter_by_prefix(self, prefix_list: &HashSet) -> Self { let filtered_default_config = self.filter_default_by_prefix(prefix_list); - for (key, overrides) in &self.overrides { - let filtered_overrides_map = - filter_config_keys_by_prefix(overrides, prefix_list); - - let _ = Cac::::try_from(filtered_overrides_map).map( - |filtered_overrides_map| { - filtered_overrides - .insert(key.clone(), filtered_overrides_map.into_inner()) - }, - ); - } + let filtered_overrides = self + .overrides + .into_iter() + .filter_map(|(key, overrides)| { + let filtered_overrides_map = filter_into_config_keys_by_prefix( + overrides.into_inner(), + prefix_list, + ); + Cac::::try_from(filtered_overrides_map).ok().map( + |filtered_overrides_map| (key, filtered_overrides_map.into_inner()), + ) + }) + .collect::>(); let filtered_context: Vec = self .contexts - .iter() + .into_iter() .filter(|context| { filtered_overrides.contains_key(context.override_with_keys.get_key()) }) - .cloned() .collect(); Self { contexts: filtered_context, overrides: filtered_overrides, default_configs: filtered_default_config, - dimensions: self.dimensions.clone(), + dimensions: self.dimensions, } } + + pub fn filter( + self, + dimension_data: Option<&Map>, + prefix_list: Option<&HashSet>, + ) -> Self { + let mut config = self; + + if let Some(prefixes) = prefix_list { + if !prefixes.is_empty() { + config = config.filter_by_prefix(prefixes); + } + } + + if let Some(ctx) = dimension_data { + if !ctx.is_empty() { + config = config.filter_by_dimensions(ctx); + } + } + + config + } } #[derive(Serialize, Deserialize, Clone, Debug, Default, uniffi::Record)] diff --git a/crates/superposition_types/src/config/tests.rs b/crates/superposition_types/src/config/tests.rs index f5f3031e5..51c4d7c76 100644 --- a/crates/superposition_types/src/config/tests.rs +++ b/crates/superposition_types/src/config/tests.rs @@ -91,12 +91,12 @@ fn filter_by_dimensions_with_dimension() { let config = with_dimensions::get_config(); assert_eq!( - config.filter_by_dimensions(&get_dimension_data1()), + config.clone().filter_by_dimensions(&get_dimension_data1()), with_dimensions::get_dimension_filtered_config1() ); assert_eq!( - config.filter_by_dimensions(&get_dimension_data2()), + config.clone().filter_by_dimensions(&get_dimension_data2()), with_dimensions::get_dimension_filtered_config2() ); @@ -111,12 +111,12 @@ fn filter_by_dimensions_without_dimension() { let config = without_dimensions::get_config(); assert_eq!( - config.filter_by_dimensions(&get_dimension_data1()), + config.clone().filter_by_dimensions(&get_dimension_data1()), without_dimensions::get_dimension_filtered_config1() ); assert_eq!( - config.filter_by_dimensions(&get_dimension_data2()), + config.clone().filter_by_dimensions(&get_dimension_data2()), without_dimensions::get_dimension_filtered_config2() ); @@ -185,7 +185,7 @@ fn filter_by_prefix_with_dimension() { let prefix_list = HashSet::from_iter(vec![String::from("test.")]); assert_eq!( - config.filter_by_prefix(&prefix_list), + config.clone().filter_by_prefix(&prefix_list), with_dimensions::get_prefix_filtered_config1() ); @@ -193,19 +193,20 @@ fn filter_by_prefix_with_dimension() { HashSet::from_iter(vec![String::from("test."), String::from("test2.")]); assert_eq!( - config.filter_by_prefix(&prefix_list), + config.clone().filter_by_prefix(&prefix_list), with_dimensions::get_prefix_filtered_config2() ); let prefix_list = HashSet::from_iter(vec![String::from("abcd")]); + let dimensions = config.dimensions.clone(); assert_eq!( config.filter_by_prefix(&prefix_list), Config { contexts: Vec::new(), overrides: HashMap::new(), default_configs: Map::new().into(), - dimensions: config.dimensions.clone(), + dimensions, } ); } @@ -217,7 +218,7 @@ fn filter_by_prefix_without_dimension() { let prefix_list = HashSet::from_iter(vec![String::from("test.")]); assert_eq!( - config.filter_by_prefix(&prefix_list), + config.clone().filter_by_prefix(&prefix_list), without_dimensions::get_prefix_filtered_config1() ); @@ -225,19 +226,20 @@ fn filter_by_prefix_without_dimension() { HashSet::from_iter(vec![String::from("test."), String::from("test2.")]); assert_eq!( - config.filter_by_prefix(&prefix_list), + config.clone().filter_by_prefix(&prefix_list), without_dimensions::get_prefix_filtered_config2() ); let prefix_list = HashSet::from_iter(vec![String::from("abcd")]); + let dimensions = config.dimensions.clone(); assert_eq!( config.filter_by_prefix(&prefix_list), Config { contexts: Vec::new(), overrides: HashMap::new(), default_configs: Map::new().into(), - dimensions: config.dimensions.clone(), + dimensions, } ); } diff --git a/crates/superposition_types/src/overridden.rs b/crates/superposition_types/src/overridden.rs index cf386047c..704e753b1 100644 --- a/crates/superposition_types/src/overridden.rs +++ b/crates/superposition_types/src/overridden.rs @@ -19,6 +19,20 @@ pub(crate) fn filter_config_keys_by_prefix( .collect() } +pub(crate) fn filter_into_config_keys_by_prefix( + overrides: Map, + prefix_list: &HashSet, +) -> Map { + overrides + .into_iter() + .filter(|(key, _)| { + prefix_list + .iter() + .any(|prefix_str| key.starts_with(prefix_str)) + }) + .collect() +} + pub trait Overridden>>: Clone { fn get_overrides(&self) -> Overrides; diff --git a/smithy/patches/python.patch b/smithy/patches/python.patch index 3b84397b0..bb3c453db 100644 --- a/smithy/patches/python.patch +++ b/smithy/patches/python.patch @@ -24,10 +24,10 @@ index 7c295b28..b7a7bd7e 100644 reportPrivateUsage = false diff --git a/clients/python/sdk/superposition_sdk/models.py b/clients/python/sdk/superposition_sdk/models.py -index 49e83ee2..a0b17933 100644 +index cb471c43..abff3d68 100644 --- a/clients/python/sdk/superposition_sdk/models.py +++ b/clients/python/sdk/superposition_sdk/models.py -@@ -678,7 +678,7 @@ ADD_MEMBERS_TO_GROUP = APIOperation( +@@ -681,7 +681,7 @@ ADD_MEMBERS_TO_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -36,7 +36,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -880,7 +880,7 @@ APPLICABLE_VARIANTS = APIOperation( +@@ -883,7 +883,7 @@ APPLICABLE_VARIANTS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -45,7 +45,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -1154,7 +1154,7 @@ LIST_AUDIT_LOGS = APIOperation( +@@ -1157,7 +1157,7 @@ LIST_AUDIT_LOGS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -54,7 +54,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -1913,7 +1913,7 @@ BULK_OPERATION = APIOperation( +@@ -1916,7 +1916,7 @@ BULK_OPERATION = APIOperation( ShapeID("io.superposition#ResourceNotFound"): ResourceNotFound, }), effective_auth_schemes = [ @@ -63,7 +63,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -2202,7 +2202,7 @@ CONCLUDE_EXPERIMENT = APIOperation( +@@ -2205,7 +2205,7 @@ CONCLUDE_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -72,7 +72,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -2719,7 +2719,7 @@ GET_CONFIG = APIOperation( +@@ -2722,7 +2722,7 @@ GET_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -81,7 +81,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -2816,7 +2816,7 @@ GET_CONFIG_JSON = APIOperation( +@@ -2819,7 +2819,7 @@ GET_CONFIG_JSON = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -90,7 +90,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -2913,7 +2913,7 @@ GET_CONFIG_TOML = APIOperation( +@@ -2916,7 +2916,7 @@ GET_CONFIG_TOML = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -99,7 +99,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3050,7 +3050,7 @@ GET_RESOLVED_CONFIG = APIOperation( +@@ -3053,7 +3053,7 @@ GET_RESOLVED_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -108,7 +108,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3187,7 +3187,7 @@ GET_RESOLVED_CONFIG_WITH_IDENTIFIER = APIOperation( +@@ -3190,7 +3190,7 @@ GET_RESOLVED_CONFIG_WITH_IDENTIFIER = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -117,7 +117,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3302,7 +3302,7 @@ GET_VERSION = APIOperation( +@@ -3305,7 +3305,7 @@ GET_VERSION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -126,7 +126,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3482,7 +3482,7 @@ LIST_VERSIONS = APIOperation( +@@ -3485,7 +3485,7 @@ LIST_VERSIONS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -135,7 +135,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3646,7 +3646,7 @@ CREATE_CONTEXT = APIOperation( +@@ -3649,7 +3649,7 @@ CREATE_CONTEXT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -144,7 +144,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3730,7 +3730,7 @@ DELETE_CONTEXT = APIOperation( +@@ -3733,7 +3733,7 @@ DELETE_CONTEXT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -153,7 +153,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -3890,7 +3890,7 @@ GET_CONTEXT = APIOperation( +@@ -3893,7 +3893,7 @@ GET_CONTEXT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -162,7 +162,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4050,7 +4050,7 @@ GET_CONTEXT_FROM_CONDITION = APIOperation( +@@ -4053,7 +4053,7 @@ GET_CONTEXT_FROM_CONDITION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -171,7 +171,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4238,7 +4238,7 @@ LIST_CONTEXTS = APIOperation( +@@ -4241,7 +4241,7 @@ LIST_CONTEXTS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -180,7 +180,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4402,7 +4402,7 @@ MOVE_CONTEXT = APIOperation( +@@ -4405,7 +4405,7 @@ MOVE_CONTEXT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -189,7 +189,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4566,7 +4566,7 @@ UPDATE_OVERRIDE = APIOperation( +@@ -4569,7 +4569,7 @@ UPDATE_OVERRIDE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -198,7 +198,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4653,7 +4653,7 @@ VALIDATE_CONTEXT = APIOperation( +@@ -4656,7 +4656,7 @@ VALIDATE_CONTEXT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -207,7 +207,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -4817,7 +4817,7 @@ WEIGHT_RECOMPUTE = APIOperation( +@@ -4820,7 +4820,7 @@ WEIGHT_RECOMPUTE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -216,7 +216,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -5050,7 +5050,7 @@ CREATE_DEFAULT_CONFIG = APIOperation( +@@ -5053,7 +5053,7 @@ CREATE_DEFAULT_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -225,7 +225,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -5278,7 +5278,7 @@ CREATE_DIMENSION = APIOperation( +@@ -5281,7 +5281,7 @@ CREATE_DIMENSION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -234,7 +234,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -5545,7 +5545,7 @@ CREATE_EXPERIMENT = APIOperation( +@@ -5548,7 +5548,7 @@ CREATE_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -243,7 +243,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -5764,7 +5764,7 @@ CREATE_EXPERIMENT_GROUP = APIOperation( +@@ -5767,7 +5767,7 @@ CREATE_EXPERIMENT_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -252,7 +252,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -5975,7 +5975,7 @@ CREATE_FUNCTION = APIOperation( +@@ -5978,7 +5978,7 @@ CREATE_FUNCTION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -261,7 +261,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -6162,7 +6162,7 @@ CREATE_ORGANISATION = APIOperation( +@@ -6165,7 +6165,7 @@ CREATE_ORGANISATION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -270,7 +270,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -6314,7 +6314,7 @@ CREATE_SECRET = APIOperation( +@@ -6317,7 +6317,7 @@ CREATE_SECRET = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -279,7 +279,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -6475,7 +6475,7 @@ CREATE_TYPE_TEMPLATES = APIOperation( +@@ -6478,7 +6478,7 @@ CREATE_TYPE_TEMPLATES = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -288,7 +288,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -6622,7 +6622,7 @@ CREATE_VARIABLE = APIOperation( +@@ -6625,7 +6625,7 @@ CREATE_VARIABLE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -297,7 +297,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -6892,7 +6892,7 @@ CREATE_WEBHOOK = APIOperation( +@@ -6895,7 +6895,7 @@ CREATE_WEBHOOK = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -306,7 +306,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7142,7 +7142,7 @@ CREATE_WORKSPACE = APIOperation( +@@ -7145,7 +7145,7 @@ CREATE_WORKSPACE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -315,7 +315,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7222,7 +7222,7 @@ DELETE_DEFAULT_CONFIG = APIOperation( +@@ -7225,7 +7225,7 @@ DELETE_DEFAULT_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -324,7 +324,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7376,7 +7376,7 @@ GET_DEFAULT_CONFIG = APIOperation( +@@ -7379,7 +7379,7 @@ GET_DEFAULT_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -333,7 +333,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7613,7 +7613,7 @@ LIST_DEFAULT_CONFIGS = APIOperation( +@@ -7616,7 +7616,7 @@ LIST_DEFAULT_CONFIGS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -342,7 +342,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7820,7 +7820,7 @@ UPDATE_DEFAULT_CONFIG = APIOperation( +@@ -7823,7 +7823,7 @@ UPDATE_DEFAULT_CONFIG = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -351,7 +351,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -7900,7 +7900,7 @@ DELETE_DIMENSION = APIOperation( +@@ -7903,7 +7903,7 @@ DELETE_DIMENSION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -360,7 +360,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8070,7 +8070,7 @@ DELETE_EXPERIMENT_GROUP = APIOperation( +@@ -8073,7 +8073,7 @@ DELETE_EXPERIMENT_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -369,7 +369,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8150,7 +8150,7 @@ DELETE_FUNCTION = APIOperation( +@@ -8153,7 +8153,7 @@ DELETE_FUNCTION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -378,7 +378,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8275,7 +8275,7 @@ DELETE_SECRET = APIOperation( +@@ -8278,7 +8278,7 @@ DELETE_SECRET = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -387,7 +387,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8408,7 +8408,7 @@ DELETE_TYPE_TEMPLATES = APIOperation( +@@ -8411,7 +8411,7 @@ DELETE_TYPE_TEMPLATES = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -396,7 +396,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8534,7 +8534,7 @@ DELETE_VARIABLE = APIOperation( +@@ -8537,7 +8537,7 @@ DELETE_VARIABLE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -405,7 +405,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8614,7 +8614,7 @@ DELETE_WEBHOOK = APIOperation( +@@ -8617,7 +8617,7 @@ DELETE_WEBHOOK = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -414,7 +414,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -8786,7 +8786,7 @@ GET_DIMENSION = APIOperation( +@@ -8789,7 +8789,7 @@ GET_DIMENSION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -423,7 +423,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -9037,7 +9037,7 @@ LIST_DIMENSIONS = APIOperation( +@@ -9040,7 +9040,7 @@ LIST_DIMENSIONS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -432,7 +432,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -9262,7 +9262,7 @@ UPDATE_DIMENSION = APIOperation( +@@ -9265,7 +9265,7 @@ UPDATE_DIMENSION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -441,7 +441,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -9478,7 +9478,7 @@ DISCARD_EXPERIMENT = APIOperation( +@@ -9481,7 +9481,7 @@ DISCARD_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -450,7 +450,16 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -9648,7 +9648,7 @@ GET_EXPERIMENT_GROUP = APIOperation( +@@ -9916,7 +9916,7 @@ GET_EXPERIMENT_CONFIG = APIOperation( + ShapeID("io.superposition#InternalServerError"): InternalServerError, + }), + effective_auth_schemes = [ +- ShapeID("smithy.api#httpBasicAuth") ++ ShapeID("smithy.api#httpBasicAuth"), + ShapeID("smithy.api#httpBearerAuth") + ] + ) +@@ -10086,7 +10086,7 @@ GET_EXPERIMENT_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -459,7 +468,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -10011,7 +10011,7 @@ LIST_EXPERIMENT_GROUPS = APIOperation( +@@ -10316,7 +10316,7 @@ LIST_EXPERIMENT_GROUPS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -468,7 +477,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -10203,7 +10203,7 @@ REMOVE_MEMBERS_FROM_GROUP = APIOperation( +@@ -10508,7 +10508,7 @@ REMOVE_MEMBERS_FROM_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -477,7 +486,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -10405,7 +10405,7 @@ UPDATE_EXPERIMENT_GROUP = APIOperation( +@@ -10710,7 +10710,7 @@ UPDATE_EXPERIMENT_GROUP = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -486,7 +495,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -10790,7 +10790,7 @@ GET_EXPERIMENT = APIOperation( +@@ -10921,7 +10921,7 @@ GET_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -495,7 +504,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -11005,7 +11005,7 @@ LIST_EXPERIMENT = APIOperation( +@@ -11136,7 +11136,7 @@ LIST_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -504,7 +513,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -11221,7 +11221,7 @@ PAUSE_EXPERIMENT = APIOperation( +@@ -11352,7 +11352,7 @@ PAUSE_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -513,7 +522,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -11444,7 +11444,7 @@ RAMP_EXPERIMENT = APIOperation( +@@ -11575,7 +11575,7 @@ RAMP_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -522,7 +531,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -11660,7 +11660,7 @@ RESUME_EXPERIMENT = APIOperation( +@@ -11791,7 +11791,7 @@ RESUME_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -531,7 +540,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -11971,7 +11971,7 @@ UPDATE_OVERRIDES_EXPERIMENT = APIOperation( +@@ -12102,7 +12102,7 @@ UPDATE_OVERRIDES_EXPERIMENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -540,7 +549,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -12138,7 +12138,7 @@ GET_FUNCTION = APIOperation( +@@ -12269,7 +12269,7 @@ GET_FUNCTION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -549,7 +558,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -12406,7 +12406,7 @@ LIST_FUNCTION = APIOperation( +@@ -12537,7 +12537,7 @@ LIST_FUNCTION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -558,7 +567,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -12578,7 +12578,7 @@ PUBLISH = APIOperation( +@@ -12709,7 +12709,7 @@ PUBLISH = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -567,7 +576,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -12900,7 +12900,7 @@ TEST = APIOperation( +@@ -13031,7 +13031,7 @@ TEST = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -576,7 +585,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13093,7 +13093,7 @@ UPDATE_FUNCTION = APIOperation( +@@ -13224,7 +13224,7 @@ UPDATE_FUNCTION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -585,7 +594,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13240,7 +13240,7 @@ GET_ORGANISATION = APIOperation( +@@ -13371,7 +13371,7 @@ GET_ORGANISATION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -594,7 +603,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13365,7 +13365,7 @@ GET_SECRET = APIOperation( +@@ -13496,7 +13496,7 @@ GET_SECRET = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -603,7 +612,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13498,7 +13498,7 @@ GET_TYPE_TEMPLATE = APIOperation( +@@ -13629,7 +13629,7 @@ GET_TYPE_TEMPLATE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -612,7 +621,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13710,7 +13710,7 @@ GET_TYPE_TEMPLATES_LIST = APIOperation( +@@ -13841,7 +13841,7 @@ GET_TYPE_TEMPLATES_LIST = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -621,7 +630,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -13836,7 +13836,7 @@ GET_VARIABLE = APIOperation( +@@ -13967,7 +13967,7 @@ GET_VARIABLE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -630,7 +639,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -14014,7 +14014,7 @@ GET_WEBHOOK = APIOperation( +@@ -14145,7 +14145,7 @@ GET_WEBHOOK = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -639,7 +648,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -14192,7 +14192,7 @@ GET_WEBHOOK_BY_EVENT = APIOperation( +@@ -14323,7 +14323,7 @@ GET_WEBHOOK_BY_EVENT = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -648,7 +657,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -14371,7 +14371,7 @@ GET_WORKSPACE = APIOperation( +@@ -14502,7 +14502,7 @@ GET_WORKSPACE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -657,7 +666,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -14597,7 +14597,7 @@ LIST_ORGANISATION = APIOperation( +@@ -14728,7 +14728,7 @@ LIST_ORGANISATION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -666,7 +675,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -14841,7 +14841,7 @@ LIST_SECRETS = APIOperation( +@@ -14972,7 +14972,7 @@ LIST_SECRETS = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -675,7 +684,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -15087,7 +15087,7 @@ LIST_VARIABLES = APIOperation( +@@ -15218,7 +15218,7 @@ LIST_VARIABLES = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -684,7 +693,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -15344,7 +15344,7 @@ LIST_WEBHOOK = APIOperation( +@@ -15475,7 +15475,7 @@ LIST_WEBHOOK = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -693,7 +702,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -15602,7 +15602,7 @@ LIST_WORKSPACE = APIOperation( +@@ -15733,7 +15733,7 @@ LIST_WORKSPACE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -702,7 +711,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -15679,7 +15679,7 @@ ROTATE_MASTER_ENCRYPTION_KEY = APIOperation( +@@ -15810,7 +15810,7 @@ ROTATE_MASTER_ENCRYPTION_KEY = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -711,7 +720,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -15858,7 +15858,7 @@ MIGRATE_WORKSPACE_SCHEMA = APIOperation( +@@ -15989,7 +15989,7 @@ MIGRATE_WORKSPACE_SCHEMA = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -720,7 +729,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16045,7 +16045,7 @@ UPDATE_ORGANISATION = APIOperation( +@@ -16176,7 +16176,7 @@ UPDATE_ORGANISATION = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -729,7 +738,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16130,7 +16130,7 @@ ROTATE_WORKSPACE_ENCRYPTION_KEY = APIOperation( +@@ -16261,7 +16261,7 @@ ROTATE_WORKSPACE_ENCRYPTION_KEY = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -738,7 +747,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16281,7 +16281,7 @@ UPDATE_SECRET = APIOperation( +@@ -16412,7 +16412,7 @@ UPDATE_SECRET = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -747,7 +756,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16440,7 +16440,7 @@ UPDATE_TYPE_TEMPLATES = APIOperation( +@@ -16571,7 +16571,7 @@ UPDATE_TYPE_TEMPLATES = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -756,7 +765,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16585,7 +16585,7 @@ UPDATE_VARIABLE = APIOperation( +@@ -16716,7 +16716,7 @@ UPDATE_VARIABLE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -765,7 +774,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -16824,7 +16824,7 @@ UPDATE_WEBHOOK = APIOperation( +@@ -16955,7 +16955,7 @@ UPDATE_WEBHOOK = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -774,7 +783,7 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) -@@ -17070,7 +17070,7 @@ UPDATE_WORKSPACE = APIOperation( +@@ -17201,7 +17201,7 @@ UPDATE_WORKSPACE = APIOperation( ShapeID("io.superposition#InternalServerError"): InternalServerError, }), effective_auth_schemes = [ @@ -783,17 +792,3 @@ index 49e83ee2..a0b17933 100644 ShapeID("smithy.api#httpBearerAuth") ] ) - -diff --git a/clients/python/sdk/superposition_sdk/models.py b/clients/python/sdk/superposition_sdk/models.py -index 9ff09776..abff3d68 100644 ---- a/clients/python/sdk/superposition_sdk/models.py -+++ b/clients/python/sdk/superposition_sdk/models.py -@@ -10316,7 +10316,7 @@ LIST_EXPERIMENT_GROUPS = APIOperation( - ShapeID("io.superposition#InternalServerError"): InternalServerError, - }), - effective_auth_schemes = [ -- ShapeID("smithy.api#httpBasicAuth") -+ ShapeID("smithy.api#httpBasicAuth"), - ShapeID("smithy.api#httpBearerAuth") - ] - )