diff --git a/Cargo.lock b/Cargo.lock index eb3262530..98baca3db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3456,6 +3456,7 @@ dependencies = [ "futures-util", "jsonschema", "log", + "once_cell", "reqwest", "rs-snowflake", "rusoto_core", diff --git a/crates/context_aware_config/src/api/config/handlers.rs b/crates/context_aware_config/src/api/config/handlers.rs index 73b6bc2e4..85e70ecc8 100644 --- a/crates/context_aware_config/src/api/config/handlers.rs +++ b/crates/context_aware_config/src/api/config/handlers.rs @@ -4,13 +4,23 @@ use std::{collections::HashMap, str::FromStr}; use super::helpers::{ filter_config_by_dimensions, filter_config_by_prefix, filter_context, }; - -use super::types::Config; -use crate::db::schema::{ - contexts::dsl as ctxt, default_configs::dsl as def_conf, event_log::dsl as event_log, +use super::types::{Config, Context}; +use crate::api::context::{ + delete_context_api, hash, put, validate_dimensions_and_calculate_priority, PutReq, +}; +use crate::api::dimension::get_all_dimension_schema_map; +use crate::{ + db::schema::{ + contexts::dsl as ctxt, default_configs::dsl as def_conf, + event_log::dsl as event_log, + }, + helpers::json_to_sorted_string, }; use actix_http::header::{HeaderName, HeaderValue}; -use actix_web::{get, web::Query, HttpRequest, HttpResponse, Scope}; +use actix_web::web; +use actix_web::{ + error::ErrorBadRequest, get, put, web::Query, HttpRequest, HttpResponse, Scope, +}; use cac_client::{eval_cac, eval_cac_with_reasoning, MergeStrategy}; use chrono::{DateTime, NaiveDateTime, TimeZone, Timelike, Utc}; use diesel::{ @@ -22,13 +32,17 @@ use serde_json::{json, Map, Value}; use service_utils::service::types::DbConnection; use service_utils::{bad_argument, db_error, unexpected_error}; -use service_utils::result as superposition; +use itertools::Itertools; +use jsonschema::JSONSchema; +use service_utils::helpers::extract_dimensions; +use service_utils::result::{self as superposition, AppError}; +use superposition_types::User; use uuid::Uuid; - pub fn endpoints() -> Scope { Scope::new("") .service(get) .service(get_resolved_config) + .service(reduce_config) .service(get_filtered_config) } @@ -147,6 +161,332 @@ async fn generate_cac( }) } +fn generate_subsets(map: &Map) -> Vec> { + let mut subsets = Vec::new(); + let keys: Vec = map.keys().cloned().collect_vec(); + let all_subsets_keys = generate_subsets_keys(keys); + + for subset_keys in &all_subsets_keys { + if subset_keys.len() >= 0 { + let mut subset_map = Map::new(); + + for key in subset_keys { + if let Some(value) = map.get(key) { + subset_map.insert(key.to_string(), value.clone()); + } + } + + subsets.push(subset_map); + } + } + + subsets +} + +fn generate_subsets_keys(keys: Vec) -> Vec> { + let mut res = vec![[].to_vec()]; + for element in keys { + let len = res.len(); + for ind in 0..len { + let mut sub = res[ind].clone(); + sub.push(element.clone()); + res.push(sub); + } + } + res +} + +fn reduce( + contexts_overrides_values: Vec<(Context, Map, Value, String)>, + default_config_val: &Value, +) -> superposition::Result>> { + let mut dimensions: Vec> = Vec::new(); + for (context, overrides, key_val, override_id) in contexts_overrides_values { + let mut ct_dimensions = extract_dimensions(&context.condition)?; + ct_dimensions.insert("key_val".to_string(), key_val); + let request_payload = json!({ + "override": overrides, + "context": context.condition, + "id": context.id, + "to_be_deleted": overrides.is_empty(), + "override_id": override_id, + }); + ct_dimensions.insert("req_payload".to_string(), request_payload); + dimensions.push(ct_dimensions); + } + + //adding default config value + let mut default_config_map = Map::new(); + default_config_map.insert("key_val".to_string(), default_config_val.to_owned()); + dimensions.push(default_config_map); + + /* + We now have dimensions array, which is a vector of elements representing each context present where each element is a type of Map which contains the following + 1. all the dimensions and value of those dimensions in the context + 2. key_val, which is the value of the override key for which we are trying to reduce + 3. A req_payload which contains the details of the context like, context_id, override_id, the context_condition, new overrides (without containing the key that has to be reduced) + { + dimension1_in_context : value_of_dimension1_in_context, + dimension2_in_context : value_of_dimension2_in_context, + . + . + key_val: value of the override key that we are trying to reduce + req_payload : { + override : new_overrides(without the key that is to be reduced) + context : context_condition + id : context_id + to_be_deleted : if new_overrides is empty then delete this context + } + } + + We have also sorted this dimensions vector in descending order based on the priority of the dimensions in that context + and in this vector the default config will be at the end of the list as it has no dimensions and it's priority is the least + + Now we iterate from start and then pick an element and generate all subsets of that element keys excluding the req_payload and key_val + i.e we only generate different subsets of dimensions of that context along with the value of those dimensions in that context + + Next we check if in the vector we find any other element c2 whose dimensions is part of the subsets of the parent element c1 + if dimensions_subsets_of_c1 contains dimensions_of_c2 + + if the value of the override key is same in both c1 and c2 then we can reduce or remove that key in the override of c1 + so we mark the can_be_reduce to be true, and then update the dimensions vector. + + but if we find any other element c3 whose dimensions is a subset of c1_dimensions but the value is not the same + then that means we can't reduce this key from c1, because in resolve if we remove it from c1 it will pick the value form c3 which is different. + So if we find this element c3 before any other element which is a subset of c1 with the same value, then we can't reduce this key for c1 so we break + and continue with the next element. + Here "before" means the element with higher priority comes first with a subset of c1 but differnt override value for the key + */ + for (c1_index, dimensions_of_c1_with_payload) in dimensions.clone().iter().enumerate() + { + let mut dimensions_of_c1 = dimensions_of_c1_with_payload.clone(); + dimensions_of_c1.remove("req_payload"); + let override_val_of_key_in_c1 = dimensions_of_c1.remove("key_val"); + let dimensions_subsets_of_c1 = generate_subsets(&dimensions_of_c1); + for (c2_index, dimensions_in_c2_with_payload) in dimensions.iter().enumerate() { + let mut dimensions_of_c2 = dimensions_in_c2_with_payload.clone(); + dimensions_of_c2.remove("req_payload"); + let override_val_of_key_in_c2 = dimensions_of_c2.remove("key_val"); + if c2_index != c1_index { + if dimensions_subsets_of_c1.contains(&dimensions_of_c2) { + if override_val_of_key_in_c1 == override_val_of_key_in_c2 { + let mut temp_c1 = dimensions_of_c1_with_payload.to_owned(); + temp_c1.insert("can_be_reduced".to_string(), Value::Bool(true)); + dimensions[c1_index] = temp_c1; + break; + } else if override_val_of_key_in_c2.is_some() { + break; + } + } + } + } + } + Ok(dimensions) +} + +fn get_contextids_from_overrideid( + contexts: Vec, + overrides: Map, + key_val: Value, + override_id: &str, +) -> superposition::Result, Value, String)>> { + let mut res: Vec<(Context, Map, Value, String)> = Vec::new(); + for ct in contexts { + let ct_dimensions = extract_dimensions(&ct.condition)?; + if ct_dimensions.contains_key("variantIds") { + continue; + } + let override_keys = &ct.override_with_keys; + if override_keys.contains(&override_id.to_owned()) { + res.push(( + ct, + overrides.clone(), + key_val.clone(), + override_id.to_string(), + )); + } + } + Ok(res) +} + +fn construct_new_payload(req_payload: &Map) -> web::Json { + let mut res = req_payload.clone(); + res.remove("to_be_deleted"); + res.remove("override_id"); + res.remove("id"); + if let Some(Value::Object(res_context)) = res.get("context") { + if let Some(Value::Object(res_override)) = res.get("override") { + return web::Json(PutReq { + context: res_context.to_owned(), + r#override: res_override.to_owned(), + }); + } + } + web::Json(PutReq { + context: Map::new(), + r#override: Map::new(), + }) +} + +async fn reduce_config_key( + user: User, + conn: &mut PooledConnection>, + mut og_contexts: Vec, + mut og_overrides: Map, + check_key: &str, + dimension_schema_map: &HashMap, + default_config: Map, + is_approve: bool, +) -> superposition::Result { + let default_config_val = + default_config + .get(check_key) + .ok_or(AppError::BadArgument(format!( + "{} not found in default config", + check_key + )))?; + let mut contexts_overrides_values = Vec::new(); + + for (override_id, override_value) in og_overrides.clone() { + match override_value { + Value::Object(mut override_obj) => { + if let Some(value_of_check_key) = override_obj.remove(check_key) { + let context_arr = get_contextids_from_overrideid( + og_contexts.clone(), + override_obj, + value_of_check_key.clone(), + &override_id, + )?; + contexts_overrides_values.extend(context_arr); + } + } + _ => (), + } + } + + let mut priorities = Vec::new(); + + for (index, ctx) in contexts_overrides_values.iter().enumerate() { + let priority = validate_dimensions_and_calculate_priority( + "context", + &(ctx.0).condition, + dimension_schema_map, + )?; + priorities.push((index, priority)) + } + + // Sort the collected results based on priority + priorities.sort_by(|a, b| b.1.cmp(&a.1)); + + // Use the sorted indices to reorder the original vector + let sorted_priority_contexts = priorities + .into_iter() + .map(|(index, _)| contexts_overrides_values[index].clone()) + .collect(); + + let resolved_dimensions = reduce(sorted_priority_contexts, default_config_val)?; + for rd in resolved_dimensions { + match ( + rd.get("can_be_reduced"), + rd.get("req_payload"), + rd.get("req_payload").and_then(|v| v.get("id")), + rd.get("req_payload").and_then(|v| v.get("override_id")), + rd.get("req_payload").and_then(|v| v.get("to_be_deleted")), + rd.get("req_payload").and_then(|v| v.get("override")), + ) { + ( + Some(Value::Bool(true)), + Some(Value::Object(request_payload)), + Some(Value::String(cid)), + Some(Value::String(oid)), + Some(Value::Bool(to_be_deleted)), + Some(override_val), + ) => { + if *to_be_deleted { + if is_approve { + let _ = delete_context_api(cid.clone(), user.clone(), conn).await; + } + og_contexts.retain(|x| x.id != *cid); + } else { + if is_approve { + let _ = delete_context_api(cid.clone(), user.clone(), conn).await; + let put_req = construct_new_payload(request_payload); + let _ = put(put_req, conn, false, &user); + } + + let new_id = hash(override_val); + og_overrides.insert(new_id.clone(), override_val.clone()); + + let mut ctx_index = 0; + let mut delete_old_oid = true; + + for (ind, ctx) in og_contexts.iter().enumerate() { + if ctx.id == *cid { + ctx_index = ind; + } else if ctx.override_with_keys.contains(oid) { + delete_old_oid = false; + } + } + + let mut elem = og_contexts[ctx_index].clone(); + elem.override_with_keys = [new_id]; + og_contexts[ctx_index] = elem; + + if delete_old_oid { + og_overrides.remove(oid); + } + } + } + _ => continue, + } + } + + Ok(Config { + contexts: og_contexts, + overrides: og_overrides, + default_configs: default_config, + }) +} + +#[put("/reduce")] +async fn reduce_config( + req: HttpRequest, + user: User, + db_conn: DbConnection, +) -> superposition::Result { + let DbConnection(mut conn) = db_conn; + let is_approve = req + .headers() + .get("x-approve") + .and_then(|value| value.to_str().ok().and_then(|s| s.parse::().ok())) + .unwrap_or(false); + + let dimensions_schema_map = get_all_dimension_schema_map(&mut conn)?; + let mut config = generate_cac(&mut conn).await?; + let default_config = (config.default_configs).clone(); + for (key, val) in default_config { + let contexts = config.contexts; + let overrides = config.overrides; + let default_config = config.default_configs; + config = reduce_config_key( + user.clone(), + &mut conn, + contexts.clone(), + overrides.clone(), + key.as_str(), + &dimensions_schema_map, + default_config.clone(), + is_approve, + ) + .await?; + if is_approve { + config = generate_cac(&mut conn).await?; + } + } + + Ok(HttpResponse::Ok().json(config)) +} + #[get("")] async fn get( req: HttpRequest, diff --git a/crates/context_aware_config/src/api/context/handlers.rs b/crates/context_aware_config/src/api/context/handlers.rs index 1ff58c27c..2700b56dc 100644 --- a/crates/context_aware_config/src/api/context/handlers.rs +++ b/crates/context_aware_config/src/api/context/handlers.rs @@ -61,7 +61,7 @@ pub fn endpoints() -> Scope { type DBConnection = PooledConnection>; -fn validate_dimensions_and_calculate_priority( +pub fn validate_dimensions_and_calculate_priority( object_key: &str, cond: &Value, dimension_schema_map: &HashMap, @@ -217,7 +217,7 @@ fn create_ctx_from_put_req( }) } -fn hash(val: &Value) -> String { +pub fn hash(val: &Value) -> String { let sorted_str: String = json_to_sorted_string(val); blake3::hash(sorted_str.as_bytes()).to_string() } @@ -272,7 +272,7 @@ fn get_put_resp(ctx: Context) -> PutResp { } } -fn put( +pub fn put( req: Json, conn: &mut PooledConnection>, already_under_txn: bool, @@ -493,18 +493,13 @@ async fn list_contexts( Ok(Json(result)) } -#[delete("/{ctx_id}")] -async fn delete_context( - path: Path, - db_conn: DbConnection, +pub async fn delete_context_api( + ctx_id: String, user: User, + conn: &mut PooledConnection>, ) -> superposition::Result { use contexts::dsl; - let DbConnection(mut conn) = db_conn; - - let ctx_id = path.into_inner(); - let deleted_row = - delete(dsl::contexts.filter(dsl::id.eq(&ctx_id))).execute(&mut conn); + let deleted_row = delete(dsl::contexts.filter(dsl::id.eq(&ctx_id))).execute(conn); match deleted_row { Ok(0) => Err(not_found!("Context Id `{}` doesn't exists", ctx_id)), Ok(_) => { @@ -518,6 +513,16 @@ async fn delete_context( } } +#[delete("/{ctx_id}")] +async fn delete_context( + path: Path, + user: User, + mut db_conn: DbConnection, +) -> superposition::Result { + let ctx_id = path.into_inner(); + delete_context_api(ctx_id, user, &mut db_conn).await +} + #[put("/bulk-operations")] async fn bulk_operations( reqs: Json>, diff --git a/crates/context_aware_config/src/api/context/mod.rs b/crates/context_aware_config/src/api/context/mod.rs index 8c255ad45..6b9ea678d 100644 --- a/crates/context_aware_config/src/api/context/mod.rs +++ b/crates/context_aware_config/src/api/context/mod.rs @@ -1,4 +1,9 @@ mod handlers; pub mod helpers; mod types; +pub use handlers::delete_context_api; pub use handlers::endpoints; +pub use handlers::hash; +pub use handlers::put; +pub use handlers::validate_dimensions_and_calculate_priority; +pub use types::PutReq; diff --git a/crates/experimentation_client/src/lib.rs b/crates/experimentation_client/src/lib.rs index 90741d67f..f8c206a7e 100644 --- a/crates/experimentation_client/src/lib.rs +++ b/crates/experimentation_client/src/lib.rs @@ -101,7 +101,12 @@ impl Client { let filtered_running_experiments = running_experiments .iter() .filter(|(_, exp)| { - jsonlogic::apply(&exp.context, context) == Ok(Value::Bool(true)) + let is_empty = exp + .context + .as_object() + .map_or(false, |context| context.is_empty()); + is_empty + || jsonlogic::apply(&exp.context, context) == Ok(Value::Bool(true)) }) .map(|(_, exp)| exp.clone()) .collect::(); diff --git a/crates/experimentation_platform/src/api/experiments/handlers.rs b/crates/experimentation_platform/src/api/experiments/handlers.rs index 780e2f861..06c033337 100644 --- a/crates/experimentation_platform/src/api/experiments/handlers.rs +++ b/crates/experimentation_platform/src/api/experiments/handlers.rs @@ -5,6 +5,7 @@ use actix_web::{ web::{self, Data, Json, Query}, HttpRequest, HttpResponse, Scope, }; +use anyhow::anyhow; use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use diesel::{ r2d2::{ConnectionManager, PooledConnection}, @@ -12,12 +13,16 @@ use diesel::{ }; use service_utils::{ - bad_argument, response_error, result as superposition, unexpected_error, + bad_argument, + helpers::{construct_request_headers, request}, + response_error, + result::{self as superposition, AppError}, + unexpected_error, }; use superposition_types::{SuperpositionUser, User}; -use reqwest::{Response, StatusCode}; +use reqwest::{Method, Response, StatusCode}; use service_utils::service::types::{AppState, DbConnection, Tenant}; use super::{ @@ -315,13 +320,39 @@ pub async fn conclude( })?; if variant.id == winner_variant_id { - let context_move_req = ContextMoveReq { - context: experiment_context.clone(), - }; + if !experiment_context.is_empty() { + let context_move_req = ContextMoveReq { + context: experiment_context.clone(), + }; + operations.push(ContextAction::MOVE((context_id, context_move_req))); + } else { + for (key, val) in variant.overrides { + let create_req = HashMap::from([("value", val)]); + + let url = format!("{}/default-config/{}", state.cac_host, key); + + let headers = construct_request_headers(&[ + ("x-tenant", tenant.as_str()), + ( + "Authorization", + &format!( + "{} {}", + user.get_auth_type(), + user.get_auth_token() + ), + ), + ]) + .map_err(|err| AppError::UnexpectedError(anyhow!(err)))?; + + let _ = + request::<_, Value>(url, Method::PUT, Some(create_req), headers) + .await + .map_err(|err| AppError::UnexpectedError(anyhow!(err)))?; + } + operations.push(ContextAction::DELETE(context_id)); + } is_valid_winner_variant = true; - - operations.push(ContextAction::MOVE((context_id, context_move_req))); } else { // delete this context operations.push(ContextAction::DELETE(context_id)); diff --git a/crates/experimentation_platform/src/api/experiments/helpers.rs b/crates/experimentation_platform/src/api/experiments/helpers.rs index 87866fe53..ab00236b6 100644 --- a/crates/experimentation_platform/src/api/experiments/helpers.rs +++ b/crates/experimentation_platform/src/api/experiments/helpers.rs @@ -202,36 +202,41 @@ pub fn add_variant_dimension_to_ctx( context_json: &Value, variant: String, ) -> superposition::Result { - let context = context_json.as_object().ok_or(bad_argument!( - "Context not an object. Ensure the context provided obeys the rules of JSON logic" - ))?; - - let mut conditions = match context.get("and") { - Some(conditions_json) => conditions_json - .as_array() - .ok_or(bad_argument!( - "Failed parsing conditions as an array. Ensure the context provided obeys the rules of JSON logic" - ))? - .clone(), - None => vec![context_json.clone()], - }; - let variant_condition = serde_json::json!({ "in" : [ variant, { "var": "variantIds" } ] }); - conditions.push(variant_condition); - let mut updated_ctx = Map::new(); - updated_ctx.insert(String::from("and"), serde_json::Value::Array(conditions)); + let context = context_json.as_object().ok_or(bad_argument!( + "Context not an object. Ensure the context provided obeys the rules of JSON logic" + ))?; - match serde_json::to_value(updated_ctx) { - Ok(value) => Ok(value), - Err(_) => Err(bad_argument!( - "Failed to convert context to a valid JSON object. Check the request sent for correctness" - )), + if context.is_empty() { + Ok(variant_condition) + } else { + let mut conditions = match context.get("and") { + Some(conditions_json) => conditions_json + .as_array() + .ok_or(bad_argument!( + "Failed parsing conditions as an array. Ensure the context provided obeys the rules of JSON logic" + ))? + .clone(), + None => vec![context_json.clone()], + }; + + conditions.push(variant_condition); + + let mut updated_ctx = Map::new(); + updated_ctx.insert(String::from("and"), serde_json::Value::Array(conditions)); + + match serde_json::to_value(updated_ctx) { + Ok(value) => Ok(value), + Err(_) => Err(bad_argument!( + "Failed to convert context to a valid JSON object. Check the request sent for correctness" + )), + } } } diff --git a/crates/frontend/src/components/context_form/utils.rs b/crates/frontend/src/components/context_form/utils.rs index 3b35fcc58..3fbca9bf2 100644 --- a/crates/frontend/src/components/context_form/utils.rs +++ b/crates/frontend/src/components/context_form/utils.rs @@ -58,20 +58,25 @@ pub fn construct_context( conditions: Vec<(String, String, String)>, dimensions: Vec, ) -> Value { - let condition_schemas = conditions - .iter() - .map(|(variable, operator, value)| { - get_condition_schema(variable, operator, value, dimensions.clone()).unwrap() - }) - .collect::>(); - - let context = if condition_schemas.len() == 1 { - condition_schemas[0].clone() + if conditions.is_empty() { + json!({}) } else { - json!({ "and": condition_schemas }) - }; + let condition_schemas = conditions + .iter() + .map(|(variable, operator, value)| { + get_condition_schema(variable, operator, value, dimensions.clone()) + .unwrap() + }) + .collect::>(); + + let context = if condition_schemas.len() == 1 { + condition_schemas[0].clone() + } else { + json!({ "and": condition_schemas }) + }; - context + context + } } pub fn construct_request_payload( diff --git a/crates/service_utils/Cargo.toml b/crates/service_utils/Cargo.toml index 9ffda8e35..3e69159e3 100644 --- a/crates/service_utils/Cargo.toml +++ b/crates/service_utils/Cargo.toml @@ -33,3 +33,4 @@ serde_json = { workspace = true } derive_more = { workspace = true } reqwest = { workspace = true } thiserror = { workspace = true } +once_cell = { workspace = true } diff --git a/crates/service_utils/src/helpers.rs b/crates/service_utils/src/helpers.rs index 2323a5f13..1ee5c3317 100644 --- a/crates/service_utils/src/helpers.rs +++ b/crates/service_utils/src/helpers.rs @@ -1,6 +1,7 @@ use actix_web::{error::ErrorInternalServerError, Error}; use jsonschema::{error::ValidationErrorKind, ValidationError}; use log::info; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::de::{self, IntoDeserializer}; use std::{ env::VarError, @@ -326,3 +327,45 @@ pub fn validation_err_to_str(errors: Vec) -> Vec { } }).collect() } + +use once_cell::sync::Lazy; +static HTTP_CLIENT: Lazy = Lazy::new(|| reqwest::Client::new()); + +pub fn construct_request_headers(entries: &[(&str, &str)]) -> Result { + entries + .into_iter() + .map(|(name, value)| { + let h_name = HeaderName::from_str(name); + let h_value = HeaderValue::from_str(value); + + match (h_name, h_value) { + (Ok(n), Ok(v)) => Some((n, v)), + _ => None, + } + }) + .collect::>>() + .map(HeaderMap::from_iter) + .ok_or(String::from("failed to parse headers")) +} + +pub async fn request<'a, T, R>( + url: String, + method: reqwest::Method, + body: Option, + headers: HeaderMap, +) -> Result +where + T: serde::Serialize, + R: serde::de::DeserializeOwned, +{ + let mut request_builder = HTTP_CLIENT.request(method.clone(), url).headers(headers); + request_builder = match (method, body) { + (reqwest::Method::GET | reqwest::Method::DELETE, _) => request_builder, + (_, Some(data)) => request_builder.json(&data), + _ => request_builder, + }; + + let response = request_builder.send().await?; + + response.json::().await +}