diff --git a/Cargo.toml b/Cargo.toml index 4163ee4..90541cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "flagsmith" -version = "0.2.0" -authors = ["Tomasz ZdybaƂ "] -edition = "2018" +version = "1.0.0" +authors = ["Gagan Trivedi "] +edition = "2021" license = "BSD-3-Clause" description = "Flagsmith SDK for Rust" homepage = "https://flagsmith.com/" @@ -16,5 +16,13 @@ keywords = ["Flagsmith", "feature-flag", "remote-config"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "^0.10", features = ["blocking","json"] } +reqwest = { version = "0.11", features = ["json", "blocking"] } url = "2.1" +chrono = { version = "0.4"} +log = "0.4" + +flagsmith-flag-engine = "0.1.1" + +[dev-dependencies] +httpmock = "0.6" +rstest = "0.12.0" \ No newline at end of file diff --git a/example/Cargo.toml b/example/Cargo.toml new file mode 100644 index 0000000..f342028 --- /dev/null +++ b/example/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "example" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[net] +git-fetch-with-cli = true # use the `git` executable for git operations + +[dependencies] +rocket = "0.4.10" +serde = "1.0" +serde_derive = "1.0" +serde_json = "1.0" +rocket_contrib = {version = "0.4.10", features=["tera_templates"]} +flagsmith = {path="../"} + +flagsmith-flag-engine = "0.1.0" + diff --git a/example/Rocket.toml b/example/Rocket.toml new file mode 100644 index 0000000..b841e7b --- /dev/null +++ b/example/Rocket.toml @@ -0,0 +1,3 @@ +[global] +port = 5000 +workers = 1 diff --git a/example/readme.md b/example/readme.md new file mode 100644 index 0000000..567c2a9 --- /dev/null +++ b/example/readme.md @@ -0,0 +1,21 @@ +# Flagsmith Basic Rust Example + +This directory contains a basic Rocket application which utilises Flagsmith. To run the example application, you'll +need to go through the following steps: + +1. Create an account, organisation and project on [Flagsmith](https://flagsmith.com) +2. Create a feature in the project called "secret_button" +3. Give the feature a value using the json editor as follows: + +```json +{"colour": "#ababab"} +``` + +4. Set the environment variable `FLAGSMITH_ENVIRONMENT_KEY` with the environment key of one of the environments +in flagsmith (This can be found on the 'settings' page accessed from the menu on the left under the chosen environment.) +5. Run the app using `cargo run` +6. Browse to http://localhost:5000 + +Now you can play around with the 'secret_button' feature in flagsmith, turn it on to show it and edit the colour in the +json value to edit the colour of the button. You can also identify as a given user and then update the settings for the +secret button feature for that user in the flagsmith interface to see the affect that has too. diff --git a/example/src/main.rs b/example/src/main.rs new file mode 100644 index 0000000..8fff4f7 --- /dev/null +++ b/example/src/main.rs @@ -0,0 +1,95 @@ +#![feature(proc_macro_hygiene, decl_macro)] + +#[macro_use] +extern crate rocket; +#[macro_use] +extern crate serde_derive; +extern crate flagsmith; +extern crate rocket_contrib; +extern crate serde_json; + +use std::env; + +use rocket_contrib::templates::Template; + +use flagsmith::{Flag, Flagsmith, FlagsmithOptions}; +use flagsmith_flag_engine::identities::Trait; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; + +#[derive(Serialize)] +struct TemplateContext { + show_button: bool, + button_colour: String, + identifier: String, +} +fn default_flag_handler(feature_name: &str) -> Flag { + let mut flag: Flag = Default::default(); + if feature_name == "secret_button" { + flag.value.value_type = FlagsmithValueType::String; + flag.value.value = serde_json::json!({"colour": "#b8b8b8"}).to_string(); + } + return flag; +} + +#[get("/?&&")] +fn home( + identifier: Option, + trait_key: Option, + trait_value: Option, +) -> Template { + let options = FlagsmithOptions { + default_flag_handler: Some(default_flag_handler), + enable_local_evaluation: true, + ..Default::default() + }; + + let flagsmith = Flagsmith::new( + env::var("FLAGSMITH_ENVIRONMENT_KEY") + .expect("FLAGSMITH_ENVIRONMENT_KEY not found in environment"), + options, + ); + let flags; + if identifier.is_some() { + let traits = match trait_key { + Some(trait_key) if trait_key != "".to_string() => Some(vec![Trait { + trait_key, + trait_value: FlagsmithValue { + value: trait_value.unwrap_or("".to_string()), + value_type: FlagsmithValueType::None, + }, + }]), + Some(_) => None, + None => None, + }; + flags = flagsmith + .get_identity_flags(identifier.as_ref().unwrap(), traits) + .unwrap(); + } else { + // Get the default flags for the current environment + flags = flagsmith.get_environment_flags().unwrap(); + } + + let show_button = flags.is_feature_enabled("secret_button").unwrap(); + let button_data = flags.get_feature_value_as_string("secret_button").unwrap(); + + let button_json: serde_json::Value = serde_json::from_str(&button_data).unwrap(); + let button_colour = button_json["colour"].as_str().unwrap().to_string(); + + let context = TemplateContext { + show_button, + button_colour, + identifier: identifier.unwrap_or("World".to_string()), + }; + + Template::render("home", &context) +} + +fn rocket() -> rocket::Rocket { + rocket::ignite() + .mount("/", routes![home]) + .attach(Template::fairing()) +} + +fn main() { + rocket().launch(); +} diff --git a/example/templates/home.html.tera b/example/templates/home.html.tera new file mode 100644 index 0000000..669d6a6 --- /dev/null +++ b/example/templates/home.html.tera @@ -0,0 +1,25 @@ + + + + +Flagsmith Example + +

Hello, {{ identifier }}.

+ {% if show_button %} + + {% endif %} + +

+ +
+

Identify as a user

+
+ +

... with an optional user trait

+
+

+ + +
+ + diff --git a/src/error.rs b/src/error.rs index 7e30852..bbf6131 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,51 +4,47 @@ use std::fmt; /// Wraps several types of errors. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - desc: String, + pub kind: ErrorKind, + pub msg: String, } /// Defines error kind. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum ErrorKind { - ParseError, - RequestError, - AppError, + FlagsmithClientError, + FlagsmithAPIError, +} +impl Error{ + pub fn new(kind: ErrorKind, msg: String) -> Error{ + Error{ + kind, + msg + } + } } - impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self.kind { - ErrorKind::ParseError => write!(f, "URL parsing error: {}", &self.desc), - ErrorKind::RequestError => write!(f, "REST API request error: {}", &self.desc), - ErrorKind::AppError => write!(f, "Application error: {}", &self.desc), + ErrorKind::FlagsmithClientError => write!(f, "Flagsmith API error: {}", &self.msg), + ErrorKind::FlagsmithAPIError => write!(f, "Flagsmith client error: {}", &self.msg), } } } impl From for Error { fn from(e: url::ParseError) -> Self { - Error { - kind: ErrorKind::ParseError, - desc: e.to_string(), - } + Error::new(ErrorKind::FlagsmithClientError, e.to_string()) } } impl From for Error { fn from(e: reqwest::Error) -> Self { - Error { - kind: ErrorKind::RequestError, - desc: e.to_string(), - } + Error::new(ErrorKind::FlagsmithAPIError, e.to_string()) } } -impl From for Error { - fn from(s: String) -> Self { - Error { - kind: ErrorKind::AppError, - desc: s, - } +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::new(ErrorKind::FlagsmithAPIError, e.to_string()) } } diff --git a/src/flagsmith/analytics.rs b/src/flagsmith/analytics.rs new file mode 100644 index 0000000..3d0e441 --- /dev/null +++ b/src/flagsmith/analytics.rs @@ -0,0 +1,159 @@ +use log::{debug, warn}; +use reqwest::header::HeaderMap; +use serde_json; +use std::sync::mpsc; +use std::sync::mpsc::{Sender, TryRecvError}; +use std::{collections::HashMap, thread}; + +use std::sync::{Arc, RwLock}; +static ANALYTICS_TIMER_IN_MILLI: u64 = 10 * 1000; + +#[derive(Clone, Debug)] +pub struct AnalyticsProcessor { + pub tx: Sender, + _analytics_data: Arc>>, +} + +impl AnalyticsProcessor { + pub fn new( + api_url: String, + headers: HeaderMap, + timeout: std::time::Duration, + timer: Option, + ) -> Self { + let (tx, rx) = mpsc::channel::(); + let client = reqwest::blocking::Client::builder() + .default_headers(headers) + .timeout(timeout) + .build() + .unwrap(); + let analytics_endpoint = format!("{}analytics/flags/", api_url); + let timer = timer.unwrap_or(ANALYTICS_TIMER_IN_MILLI); + + let analytics_data_arc: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + + let analytics_data_locked = Arc::clone(&analytics_data_arc); + thread::Builder::new() + .name("Analytics Processor".to_string()) + .spawn(move || { + let mut last_flushed = chrono::Utc::now(); + loop { + let data = rx.try_recv(); + let mut analytics_data = analytics_data_locked.write().unwrap(); + match data { + // Update the analytics data with feature_id received + Ok(feature_id) => { + analytics_data + .entry(feature_id) + .and_modify(|e| *e += 1) + .or_insert(1); + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => { + debug!("Shutting down analytics thread "); + break; + } + }; + if (chrono::Utc::now() - last_flushed).num_milliseconds() > timer as i64 { + flush(&client, &analytics_data, &analytics_endpoint); + analytics_data.clear(); + last_flushed = chrono::Utc::now(); + } + } + }) + .expect("Failed to start analytics thread"); + + return AnalyticsProcessor { + tx, + _analytics_data: Arc::clone(&analytics_data_arc), + }; + } + pub fn track_feature(&self, feature_id: u32) { + self.tx.send(feature_id).unwrap(); + } +} + +fn flush( + client: &reqwest::blocking::Client, + analytics_data: &HashMap, + analytics_endpoint: &str, +) { + if analytics_data.len() == 0 { + return; + } + let body = serde_json::to_string(&analytics_data).unwrap(); + let resp = client.post(analytics_endpoint).body(body).send(); + if resp.is_err() { + warn!("Failed to send analytics data"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + use reqwest::header; + + #[test] + fn track_feature_updates_analytics_data() { + // Given + let feature_1 = 1; + let processor = AnalyticsProcessor::new( + "http://localhost".to_string(), + header::HeaderMap::new(), + std::time::Duration::from_secs(10), + Some(10000), + ); + // Now, let's make tracking calls + processor.track_feature(feature_1); + processor.track_feature(feature_1); + // Wait a little for it to receive the message + thread::sleep(std::time::Duration::from_millis(50)); + let analytics_data = processor._analytics_data.read().unwrap(); + // Then, verify that analytics_data was updated correctly + assert_eq!(analytics_data[&feature_1], 2); + } + + #[test] + fn test_analytics_processor() { + // Given + let feature_1 = 1; + let feature_2 = 2; + let server = MockServer::start(); + let first_invocation_mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/analytics/flags/") + .header("X-Environment-Key", "ser.UiYoRr6zUjiFBUXaRwo7b5") + .json_body(serde_json::json!({feature_1.to_string():10, feature_2.to_string():10})); + then.status(200).header("content-type", "application/json"); + }); + let mut headers = header::HeaderMap::new(); + headers.insert( + "X-Environment-Key", + header::HeaderValue::from_str("ser.UiYoRr6zUjiFBUXaRwo7b5").unwrap(), + ); + let url = server.url("/api/v1/"); + + let processor = AnalyticsProcessor::new( + url.to_string(), + headers, + std::time::Duration::from_secs(10), + Some(10), + ); + // Now, let's update the analytics data + let mut analytics_data = processor._analytics_data.write().unwrap(); + analytics_data.insert(1, 10); + analytics_data.insert(2, 10); + // drop the analytics data to release the lock + drop(analytics_data); + // Next, let's sleep a little to let the processor flush the data + thread::sleep(std::time::Duration::from_millis(50)); + + // Finally, let's assert that the mock was called + first_invocation_mock.assert(); + // and, analytics data is now empty + let analytics_data = processor._analytics_data.read().unwrap(); + assert_eq!(true, analytics_data.is_empty()) + } +} diff --git a/src/flagsmith/mod.rs b/src/flagsmith/mod.rs new file mode 100644 index 0000000..ffe4a7e --- /dev/null +++ b/src/flagsmith/mod.rs @@ -0,0 +1,430 @@ +use flagsmith_flag_engine::engine; +use flagsmith_flag_engine::environments::builders::build_environment_struct; +use flagsmith_flag_engine::environments::Environment; +use flagsmith_flag_engine::identities::{Identity, Trait}; +use flagsmith_flag_engine::segments::evaluator::get_identity_segments; +use flagsmith_flag_engine::segments::Segment; +use log::debug; +use reqwest::header::{self, HeaderMap}; +use serde_json::json; +use std::sync::{Arc, Mutex}; +use std::{thread, time::Duration}; +mod analytics; +pub mod models; +use self::analytics::AnalyticsProcessor; +use self::models::{Flag, Flags}; +use super::error; +use std::sync::mpsc::{self, Sender, TryRecvError}; + +const DEFAULT_API_URL: &str = "https://edge.flagsmith.com/api/v1/"; + +pub struct FlagsmithOptions { + pub api_url: String, + pub custom_headers: HeaderMap, + pub request_timeout_seconds: u64, + pub enable_local_evaluation: bool, + pub environment_refresh_interval_mills: u64, + pub enable_analytics: bool, + pub default_flag_handler: Option Flag>, +} + +impl Default for FlagsmithOptions { + fn default() -> Self { + FlagsmithOptions { + api_url: DEFAULT_API_URL.to_string(), + custom_headers: header::HeaderMap::new(), + request_timeout_seconds: 10, + enable_local_evaluation: false, + enable_analytics: false, + environment_refresh_interval_mills: 60 * 1000, + default_flag_handler: None, + } + } +} + +pub struct Flagsmith { + client: reqwest::blocking::Client, + environment_flags_url: String, + identities_url: String, + environment_url: String, + options: FlagsmithOptions, + datastore: Arc>, + analytics_processor: Option, + _polling_thead_tx: Sender, // used for shutting down polling manager +} + +struct DataStore { + environment: Option, +} + +impl Flagsmith { + pub fn new(environment_key: String, flagsmith_options: FlagsmithOptions) -> Self { + let mut headers = flagsmith_options.custom_headers.clone(); + headers.insert( + "X-Environment-Key", + header::HeaderValue::from_str(&environment_key).unwrap(), + ); + headers.insert("Content-Type", "application/json".parse().unwrap()); + let timeout = Duration::from_secs(flagsmith_options.request_timeout_seconds); + let client = reqwest::blocking::Client::builder() + .default_headers(headers.clone()) + .timeout(timeout) + .build() + .unwrap(); + + let environment_flags_url = format!("{}flags/", flagsmith_options.api_url); + let identities_url = format!("{}identities/", flagsmith_options.api_url); + let environment_url = format!("{}environment-document/", flagsmith_options.api_url); + // Initialize analytics processor + let analytics_processor = match flagsmith_options.enable_analytics { + true => Some(AnalyticsProcessor::new( + flagsmith_options.api_url.clone(), + headers, + timeout, + None, + )), + false => None, + }; + // Put the environment model behind mutex to + // to share it safely between threads + let ds = Arc::new(Mutex::new(DataStore { environment: None })); + let (tx, rx) = mpsc::channel::(); + let flagsmith = Flagsmith { + client: client.clone(), + environment_flags_url, + environment_url: environment_url.clone(), + identities_url, + options: flagsmith_options, + datastore: Arc::clone(&ds), + analytics_processor, + _polling_thead_tx: tx, + }; + + // Create a thread to update environment document + // If enabled + let environment_refresh_interval_mills = + flagsmith.options.environment_refresh_interval_mills; + if flagsmith.options.enable_local_evaluation { + let ds = Arc::clone(&ds); + thread::spawn(move || loop { + match rx.try_recv() { + Ok(_) | Err(TryRecvError::Disconnected) => { + debug!("shutting down polling manager"); + break; + } + Err(TryRecvError::Empty) => {} + } + + let environment = Some( + get_environment_from_api(&client, environment_url.clone()) + .expect("updating environment document failed"), + ); + let mut data = ds.lock().unwrap(); + data.environment = environment; + thread::sleep(Duration::from_millis(environment_refresh_interval_mills)); + }); + } + return flagsmith; + } + //Returns `Flags` struct holding all the flags for the current environment. + pub fn get_environment_flags(&self) -> Result { + let data = self.datastore.lock().unwrap(); + if data.environment.is_some() { + let environment = data.environment.as_ref().unwrap(); + return Ok(self.get_environment_flags_from_document(environment)); + } + return self.default_handler_if_err(self.get_environment_flags_from_api()); + } + + // Returns all the flags for the current environment for a given identity. Will also + // upsert all traits to the Flagsmith API for future evaluations. Providing a + // trait with a value of None will remove the trait from the identity if it exists. + // # Example + // ``` + // use flagsmith_flag_engine::identities::Trait; + // use flagsmith::{Flagsmith, FlagsmithOptions}; + // use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; + // const ENVIRONMENT_KEY: &str = "YOUR_ENVIRONMENT_KEY"; + // fn main(){ + // let flagsmith_options = FlagsmithOptions::default(); + // let traits = vec![Trait{trait_key:"random_key".to_string(), trait_value: FlagsmithValue{value:"10.1".to_string(), value_type:FlagsmithValueType::Float}}, + // Trait{trait_key:"another_random_key".to_string(), trait_value: FlagsmithValue{value:"false".to_string(), value_type:FlagsmithValueType::Bool}}, + // Trait{trait_key:"another_random_key".to_string(), trait_value: FlagsmithValue{value:"".to_string(), value_type:FlagsmithValueType::None}} + // ]; + // let mut flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // let flags = flagsmith.get_identity_flags("user_identifier".to_string(), traits); + // } + //``` + pub fn get_identity_flags( + &self, + identifier: &str, + traits: Option>, + ) -> Result { + let data = self.datastore.lock().unwrap(); + let traits = traits.unwrap_or(vec![]); + if data.environment.is_some() { + let environment = data.environment.as_ref().unwrap(); + return self.get_identity_flags_from_document(environment, identifier, traits); + } + return self.default_handler_if_err(self.get_identity_flags_from_api(identifier, traits)); + } + // Returns a list of segments that the given identity is part of + pub fn get_identity_segments( + &self, + identifier: &str, + traits: Option>, + ) -> Result, error::Error> { + let data = self.datastore.lock().unwrap(); + if data.environment.is_none() { + return Err(error::Error::new( + error::ErrorKind::FlagsmithClientError, + "Local evaluation required to obtain identity segments.".to_string(), + )); + } + let environment = data.environment.as_ref().unwrap(); + let identity_model = + self.build_identity_model(environment, identifier, traits.clone().unwrap_or(vec![]))?; + let segments = get_identity_segments(environment, &identity_model, traits.as_ref()); + return Ok(segments); + } + + fn default_handler_if_err( + &self, + result: Result, + ) -> Result { + match result { + Ok(result) => Ok(result), + Err(e) => { + if self.options.default_flag_handler.is_some() { + return Ok(Flags::from_api_flags( + &vec![], + self.analytics_processor.clone(), + self.options.default_flag_handler, + ) + .unwrap()); + } else { + Err(e) + } + } + } + } + fn get_environment_flags_from_document(&self, environment: &Environment) -> models::Flags { + return models::Flags::from_feature_states( + &environment.feature_states, + self.analytics_processor.clone(), + self.options.default_flag_handler, + None, + ); + } + pub fn update_environment(&mut self) -> Result<(), error::Error> { + let mut data = self.datastore.lock().unwrap(); + data.environment = Some(get_environment_from_api( + &self.client, + self.environment_url.clone(), + )?); + return Ok(()); + } + + fn get_identity_flags_from_document( + &self, + environment: &Environment, + identifier: &str, + traits: Vec, + ) -> Result { + let identity = self.build_identity_model(environment, identifier, traits.clone())?; + let feature_states = + engine::get_identity_feature_states(environment, &identity, Some(traits.as_ref())); + let flags = Flags::from_feature_states( + &feature_states, + self.analytics_processor.clone(), + self.options.default_flag_handler, + Some(&identity.composite_key()), + ); + return Ok(flags); + } + + fn build_identity_model( + &self, + environment: &Environment, + identifier: &str, + traits: Vec, + ) -> Result { + let mut identity = Identity::new(identifier.to_string(), environment.api_key.clone()); + identity.identity_traits = traits; + Ok(identity) + } + fn get_identity_flags_from_api( + &self, + identifier: &str, + traits: Vec, + ) -> Result { + let method = reqwest::Method::POST; + + let json = json!({"identifier":identifier, "traits": traits}); + let response = get_json_response( + &self.client, + method, + self.identities_url.clone(), + Some(json.to_string()), + )?; + // Cast to array of values + let api_flags = response["flags"].as_array().ok_or(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + "Unable to get valid response from Flagsmith API.".to_string(), + ))?; + + let flags = Flags::from_api_flags( + api_flags, + self.analytics_processor.clone(), + self.options.default_flag_handler, + ) + .ok_or(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + "Unable to get valid response from Flagsmith API.".to_string(), + ))?; + return Ok(flags); + } + fn get_environment_flags_from_api(&self) -> Result { + let method = reqwest::Method::GET; + let api_flags = get_json_response( + &self.client, + method, + self.environment_flags_url.clone(), + None, + )?; + // Cast to array of values + let api_flags = api_flags.as_array().ok_or(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + "Unable to get valid response from Flagsmith API.".to_string(), + ))?; + + let flags = Flags::from_api_flags( + api_flags, + self.analytics_processor.clone(), + self.options.default_flag_handler, + ) + .ok_or(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + "Unable to get valid response from Flagsmith API.".to_string(), + ))?; + return Ok(flags); + } +} + +fn get_environment_from_api( + client: &reqwest::blocking::Client, + environment_url: String, +) -> Result { + let method = reqwest::Method::GET; + let json_document = get_json_response(client, method, environment_url, None)?; + let environment = build_environment_struct(json_document); + return Ok(environment); +} + +fn get_json_response( + client: &reqwest::blocking::Client, + method: reqwest::Method, + url: String, + body: Option, +) -> Result { + let mut request = client.request(method, url); + if body.is_some() { + request = request.body(body.unwrap()); + }; + let response = request.send()?; + if response.status().is_success() { + return Ok(response.json()?); + } else { + return Err(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + response.text()?, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + static ENVIRONMENT_JSON: &str = r#"{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [] + }"#; + + #[test] + fn polling_thread_updates_environment_on_start() { + // Given + let environment_key = "ser.test_environment_key"; + let response_body: serde_json::Value = serde_json::from_str(ENVIRONMENT_JSON).unwrap(); + + let mock_server = MockServer::start(); + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/environment-document/") + .header("X-Environment-Key", environment_key); + then.status(200).json_body(response_body); + }); + + let url = mock_server.url("/api/v1/"); + + let flagsmith_options = FlagsmithOptions { + api_url: url, + enable_local_evaluation: true, + ..Default::default() + }; + // When + let _flagsmith = Flagsmith::new(environment_key.to_string(), flagsmith_options); + // let's wait for the thread to make the request + thread::sleep(std::time::Duration::from_millis(50)); + // Then + api_mock.assert(); + } + + #[test] + fn polling_thread_updates_environment_on_each_refresh() { + // Given + let environment_key = "ser.test_environment_key"; + let response_body: serde_json::Value = serde_json::from_str(ENVIRONMENT_JSON).unwrap(); + + let mock_server = MockServer::start(); + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/environment-document/") + .header("X-Environment-Key", environment_key); + then.status(200).json_body(response_body); + }); + + let url = mock_server.url("/api/v1/"); + + let flagsmith_options = FlagsmithOptions { + api_url: url, + environment_refresh_interval_mills: 100, + enable_local_evaluation: true, + ..Default::default() + }; + // When + let _flagsmith = Flagsmith::new(environment_key.to_string(), flagsmith_options); + thread::sleep(std::time::Duration::from_millis(250)); + // Then + // 3 api calls to update environment should be made, one when the thread starts and 2 + // for each subsequent refresh + api_mock.assert_hits(3); + } +} diff --git a/src/flagsmith/models.rs b/src/flagsmith/models.rs new file mode 100644 index 0000000..b7f0334 --- /dev/null +++ b/src/flagsmith/models.rs @@ -0,0 +1,339 @@ +use crate::flagsmith::analytics::AnalyticsProcessor; +use core::f64; +use flagsmith_flag_engine::features::FeatureState; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; +use std::collections::HashMap; + +use crate::error; + +#[derive(Clone, Debug, Default)] +pub struct Flag { + pub enabled: bool, + pub value: FlagsmithValue, + pub is_default: bool, + pub feature_id: u32, + pub feature_name: String, +} + +impl Flag { + pub fn from_feature_state(feature_state: FeatureState, identity_id: Option<&str>) -> Flag { + return Flag { + enabled: feature_state.enabled, + value: feature_state.get_value(identity_id), + is_default: false, + feature_name: feature_state.feature.name, + feature_id: feature_state.feature.id, + }; + } + + pub fn from_api_flag(flag_json: &serde_json::Value) -> Option { + let value: FlagsmithValue = + serde_json::from_value(flag_json["feature_state_value"].clone()).ok()?; + + let flag = Flag { + enabled: flag_json["enabled"].as_bool()?, + is_default: false, + feature_name: flag_json["feature"]["name"].as_str()?.to_string(), + feature_id: flag_json["feature"]["id"].as_u64()?.try_into().ok()?, + value, + }; + Some(flag) + } + pub fn value_as_string(&self) -> Option { + match self.value.value_type { + FlagsmithValueType::String => Some(self.value.value.clone()), + _ => None, + } + } + pub fn value_as_bool(&self) -> Option { + match self.value.value_type { + FlagsmithValueType::Bool => match self.value.value.as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }, + _ => None, + } + } + pub fn value_as_f64(&self) -> Option { + match self.value.value_type { + FlagsmithValueType::Float => Some(self.value.value.parse::().ok()?), + _ => None, + } + } + pub fn value_as_i64(&self) -> Option { + match self.value.value_type { + FlagsmithValueType::Integer => Some(self.value.value.parse::().ok()?), + _ => None, + } + } +} + +#[derive(Clone)] +pub struct Flags { + flags: HashMap, + analytics_processor: Option, + default_flag_handler: Option Flag>, +} + +impl Flags { + pub fn from_feature_states( + feature_states: &Vec, + analytics_processor: Option, + default_flag_handler: Option Flag>, + identity_id: Option<&str>, + ) -> Flags { + let mut flags: HashMap = HashMap::new(); + for feature_state in feature_states { + flags.insert( + feature_state.feature.name.clone(), + Flag::from_feature_state(feature_state.to_owned(), identity_id), + ); + } + return Flags { + flags, + analytics_processor, + default_flag_handler, + }; + } + pub fn from_api_flags( + api_flags: &Vec, + analytics_processor: Option, + default_flag_handler: Option Flag>, + ) -> Option { + let mut flags: HashMap = HashMap::new(); + for flag_json in api_flags { + let flag = Flag::from_api_flag(flag_json)?; + flags.insert(flag.feature_name.clone(), flag); + } + return Some(Flags { + flags, + analytics_processor, + default_flag_handler, + }); + } + + // Returns a vector of all `Flag` structs + pub fn all_flags(&self) -> Vec { + return self.flags.clone().into_values().collect(); + } + + // Check whether a given feature is enabled. + // Returns error:Error if the feature is not found + pub fn is_feature_enabled(&self, feature_name: &str) -> Result { + Ok(self.get_flag(feature_name)?.enabled) + } + + // Returns the string value of a given feature + // Or error if the feature is not found + pub fn get_feature_value_as_string(&self, feature_name: &str) -> Result { + let flag = self.get_flag(feature_name)?; + return Ok(flag.value.value); + } + + // Returns a specific `Flag` given the feature name + pub fn get_flag(&self, feature_name: &str) -> Result { + match self.flags.get(&feature_name.to_string()) { + Some(flag) => { + if self.analytics_processor.is_some() && !flag.is_default { + let _ = self + .analytics_processor + .as_ref() + .unwrap() + .tx + .send(flag.feature_id); + }; + return Ok(flag.clone()); + } + None => match self.default_flag_handler { + Some(handler) => Ok(handler(feature_name)), + None => Err(error::Error::new( + error::ErrorKind::FlagsmithAPIError, + "API returned invalid response".to_string(), + )), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + static FEATURE_STATE_JSON_STRING: &str = r#"{ + "multivariate_feature_state_values": [ + { + "id": 3404, + "multivariate_feature_option": { + "value": "baz" + }, + "percentage_allocation": 30 + } + ], + "feature_state_value": 1, + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }"#; + + #[test] + fn can_create_flag_from_feature_state() { + // Given + let feature_state: FeatureState = + serde_json::from_str(FEATURE_STATE_JSON_STRING.clone()).unwrap(); + // When + let flag = Flag::from_feature_state(feature_state.clone(), None); + // Then + assert_eq!(flag.feature_name, feature_state.feature.name); + assert_eq!(flag.is_default, false); + assert_eq!(flag.enabled, feature_state.enabled); + assert_eq!(flag.value, feature_state.get_value(None)); + assert_eq!(flag.feature_id, feature_state.feature.id); + } + + #[test] + fn can_create_flag_from_from_api_flag() { + // Give + let feature_state_json: serde_json::Value = + serde_json::from_str(FEATURE_STATE_JSON_STRING).unwrap(); + let expected_value: FlagsmithValue = + serde_json::from_value(feature_state_json["feature_state_value"].clone()).unwrap(); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!( + flag.feature_name, + feature_state_json["feature"]["name"].as_str().unwrap() + ); + assert_eq!( + flag.feature_id, + feature_state_json["feature"]["id"].as_u64().unwrap() as u32 + ); + assert_eq!(flag.is_default, false); + assert_eq!( + flag.enabled, + feature_state_json["enabled"].as_bool().unwrap() + ); + assert_eq!(flag.value, expected_value); + } + + #[test] + fn value_as_string() { + // Give + let feature_state_json = serde_json::json!({ + "multivariate_feature_state_values": [], + "feature_state_value": "test_value", + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!(flag.value_as_string().unwrap(), "test_value"); + } + + #[test] + fn value_as_bool() { + // Give + let feature_state_json = serde_json::json!({ + "multivariate_feature_state_values": [], + "feature_state_value": true, + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!(flag.value_as_bool().unwrap(), true); + } + + #[test] + fn value_as_i64() { + // Give + let feature_state_json = serde_json::json!({ + "multivariate_feature_state_values": [], + "feature_state_value": 10, + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!(flag.value_as_i64().unwrap(), 10); + } + + #[test] + fn value_as_f64() { + // Give + let feature_state_json = serde_json::json!({ + "multivariate_feature_state_values": [], + "feature_state_value": 10.1, + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!(flag.value_as_f64().unwrap(), 10.1); + } + #[test] + fn value_as_type_returns_none_if_value_is_of_a_different_type() { + // Give + let feature_state_json = serde_json::json!({ + "multivariate_feature_state_values": [], + "feature_state_value": 10.1, + "django_id": 1, + "feature": { + "name": "feature1", + "type": null, + "id": 1 + }, + "segment_id": null, + "enabled": false + }); + + // When + let flag = Flag::from_api_flag(&feature_state_json).unwrap(); + + // Then + assert_eq!(flag.value_as_i64().is_none(), true); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2d0349e..75bd5e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,263 +1,4 @@ -//! flagsmith create provides client for flagsmith.com API. -//! -//! # Example -//! -//! ```rust -//! # const API_KEY: &str = "MgfUaRCvvZMznuQyqjnQKt"; -//! use flagsmith::{Client,Value}; -//! -//! let client = Client::new(API_KEY); -//! if client.feature_enabled("test_feature")? { -//! if let Some(Value::Int(i)) = client.get_value("integer_feature")? { -//! println!("integer value: {}", i); -//! # assert!(i == 200); -//! } -//! // ... -//! } -//! # Ok::<(), flagsmith::error::Error>(()) -//! ``` - +pub mod flagsmith; pub mod error; -use serde::{Deserialize, Serialize}; - -/// Default address of Flagsmith API. -pub const DEFAULT_BASE_URI: &str = "https://api.flagsmith.com/api/v1/"; - -/// Contains core information about feature. -#[derive(Serialize, Deserialize)] -pub struct Feature { - pub name: String, - #[serde(rename = "type")] - pub kind: Option, - pub description: Option, -} - -/// Represents remote config value. -/// -/// Currently there are three possible types of values: booleans, integers and strings. -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum Value { - Bool(bool), - Int(i64), - String(String), -} - -/// Contains information about Feature and it's value. -#[derive(Serialize, Deserialize)] -pub struct Flag { - pub feature: Feature, - #[serde(rename = "feature_state_value")] - pub state_value: Option, - pub enabled: bool, -} - -/// Holds identity information. -#[derive(Serialize, Deserialize)] -pub struct User { - pub identifier: String, -} - -/// Holds information about User's trait. -#[derive(Serialize, Deserialize)] -pub struct Trait { - pub identity: Option, - #[serde(rename = "trait_key")] - pub key: String, - #[serde(rename = "trait_value")] - pub value: String, -} - -/// Provides various methods to interact with Flagsmith API. -/// -/// Static method new can be used to create instance configured with default API address. -/// To use custom API address, use struct constructor. -/// -/// # Example -/// -/// ```rust -/// let client = flagsmith::Client { -/// api_key: String::from("secret key"), -/// base_uri: String::from("https://features.on.my.own.server/api/v1/"), -/// }; -/// # match client.get_features() { -/// # Err(e) => println!("{}", e), -/// # Ok(f) => assert!(false), -/// # } -/// ``` -pub struct Client { - pub api_key: String, - pub base_uri: String, -} - -/// Internal structure used for deserialization. -#[derive(Serialize, Deserialize)] -struct TraitResponse { - traits: Vec, -} - -impl Client { - /// Returns Client instance configured to use default API address and given API key. - pub fn new(api_key: &str) -> Client { - return Client { - api_key: String::from(api_key), - base_uri: String::from(DEFAULT_BASE_URI), - }; - } - - /// Returns all features available in given environment. - pub fn get_features(&self) -> Result, error::Error> { - let resp = self - .build_request(vec!["flags/"])? - .send()? - .json::>()?; - Ok(resp) - } - - /// Returns all features as defined for given user. - pub fn get_user_features(&self, user: &User) -> Result, error::Error> { - let resp = self - .build_request(vec!["flags/", &user.identifier])? - .send()? - .json::>()?; - Ok(resp) - } - - /// Returns information whether given feature is defined. - pub fn has_feature(&self, name: &str) -> Result { - let flag = self.get_flag(self.get_features()?, name); - match flag { - Some(_) => Ok(true), - None => Ok(false), - } - } - - /// Returns information whether given feature is defined for given user. - pub fn has_user_feature(&self, user: &User, name: &str) -> Result { - let flag = self.get_flag(self.get_user_features(user)?, name); - match flag { - Some(_) => Ok(true), - None => Ok(false), - } - } - - /// Returns information whether given feature flag is enabled. - pub fn feature_enabled(&self, name: &str) -> Result { - let flag = self.get_flag(self.get_features()?, name); - match flag { - Some(f) => Ok(f.enabled), - None => Err(error::Error::from(format!("unknown feature {}", name))), - } - } - - /// Returns information whether given feature flag is enabled for given user. - pub fn user_feature_enabled(&self, user: &User, name: &str) -> Result { - let flag = self.get_flag(self.get_user_features(user)?, name); - match flag { - Some(f) => Ok(f.enabled), - None => Err(error::Error::from(format!("unknown feature {}", name))), - } - } - - /// Returns value of given feature (remote config). - /// - /// Returned value can have one of following types: boolean, integer, string. - pub fn get_value(&self, name: &str) -> Result, error::Error> { - let flag = self.get_flag(self.get_features()?, name); - match flag { - Some(f) => Ok(f.state_value), - None => Err(error::Error::from(format!("unknown feature {}", name))), - } - } - - /// Returns value of given feature (remote config) as defined for given user. - /// - /// Returned value can have one of following types: boolean, integer, string. - pub fn get_user_value(&self, user: &User, name: &str) -> Result, error::Error> { - let flag = self.get_flag(self.get_user_features(user)?, name); - match flag { - Some(f) => Ok(f.state_value), - None => Err(error::Error::from(format!("unknown feature {}", name))), - } - } - - /// Returns trait defined for given user. - pub fn get_trait(&self, user: &User, key: &str) -> Result { - let mut traits = self.get_traits(user, vec![key])?; - match traits.len() { - 1 => Ok(traits.remove(0)), - _ => Err(error::Error::from(format!( - "unknown trait {} for user {}", - key, &user.identifier - ))), - } - } - - /// Returns all traits defined for given user. - /// - /// If keys are provided, get_traits returns only corresponding traits, - /// otherwise all traits for given user are returned. - pub fn get_traits(&self, user: &User, keys: Vec<&str>) -> Result, error::Error> { - let resp = self - .build_request(vec!["identities/"])? - .query(&[("identifier", &user.identifier)]) - .send()? - .json::()?; - - let mut traits = resp.traits; - if keys.is_empty() { - return Ok(traits); - } - - traits.retain(|t| { - let tk: &String = &t.key; - keys.iter().any(|k| tk == k) - }); - - Ok(traits) - } - - /// Updates trait value for given user, returns updated trait. - pub fn update_trait(&self, user: &User, to_update: &Trait) -> Result { - let update = Trait { - identity: Some(User { - identifier: user.identifier.clone(), - }), - key: to_update.key.clone(), - value: to_update.value.clone(), - }; - let url = reqwest::Url::parse(&self.base_uri)?.join("traits/")?; - let client = reqwest::blocking::Client::new(); - let resp = client - .post(url) - .header("X-Environment-Key", &self.api_key) - .json(&update) - .send()? - .json::()?; - - Ok(resp) - } - - /// Builds get request, using API URL and API key. - fn build_request( - &self, - parts: Vec<&str>, - ) -> Result { - let mut url = reqwest::Url::parse(&self.base_uri)?; - for p in parts { - url = url.join(p)?; - } - let client = reqwest::blocking::Client::new(); - Ok(client.get(url).header("X-Environment-Key", &self.api_key)) - } - - /// Returns flag by name. - fn get_flag(&self, features: Vec, name: &str) -> Option { - for f in features { - if f.feature.name == name { - return Some(f); - } - } - None - } -} +pub use crate::flagsmith::{Flagsmith, FlagsmithOptions}; +pub use crate::flagsmith::models::Flag; diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs new file mode 100644 index 0000000..4600f2f --- /dev/null +++ b/tests/fixtures/mod.rs @@ -0,0 +1,176 @@ +use httpmock::prelude::*; +use rstest::*; +use serde_json; + +use flagsmith::{Flagsmith, FlagsmithOptions}; +pub static FEATURE_1_NAME: &str = "feature_1"; +pub static FEATURE_1_ID: u32 = 1; +pub static FEATURE_1_STR_VALUE: &str = "some_value"; +pub static DEFAULT_FLAG_HANDLER_FLAG_VALUE: &str = "default_flag_handler_flag_value"; + +pub const ENVIRONMENT_KEY: &str = "ser.test_environment_key"; + +#[fixture] +pub fn environment_json() -> serde_json::Value { + serde_json::json!({ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [ + { + "id": 1, + "name": "Test Segment", + "feature_states":[], + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "property_": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": FEATURE_1_STR_VALUE, + "id": 1, + "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", + "feature": { + "name": FEATURE_1_NAME, + "type": "STANDARD", + "id": FEATURE_1_ID + }, + "segment_id": null, + "enabled": true + } + ] + }) +} + +#[fixture] +pub fn flags_json() -> serde_json::Value { + serde_json::json!( + [ + { + "id": 1, + "feature": { + "id": FEATURE_1_ID, + "name": FEATURE_1_NAME, + "created_date": "2019-08-27T14:53:45.698555Z", + "initial_value": null, + "description": null, + "default_enabled": false, + "type": "STANDARD", + "project": 1 + }, + "feature_state_value": FEATURE_1_STR_VALUE, + "enabled": true, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] + ) +} + +#[fixture] +pub fn identities_json() -> serde_json::Value { + serde_json::json!( + { + "traits": [ + { + "id": 1, + "trait_key": "some_trait", + "trait_value": "some_value" + } + ], + "flags": [ + { + "id": 1, + "feature": { + "id": FEATURE_1_ID, + "name": FEATURE_1_NAME, + "created_date": "2019-08-27T14:53:45.698555Z", + "initial_value": null, + "description": null, + "default_enabled": false, + "type": "STANDARD", + "project": 1 + }, + "feature_state_value": FEATURE_1_STR_VALUE, + "enabled": true, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] + } + ) +} + +#[fixture] +pub fn default_flag_handler() -> fn(&str) -> flagsmith::Flag { + fn handler(_feature_name: &str) -> flagsmith::Flag { + let mut default_flag = flagsmith::Flag::default(); + default_flag.enabled = true; + default_flag.is_default = true; + default_flag.value.value_type = flagsmith_flag_engine::types::FlagsmithValueType::String; + default_flag.value.value = DEFAULT_FLAG_HANDLER_FLAG_VALUE.to_string(); + return default_flag; + } + return handler; +} + +#[fixture] +pub fn mock_server() -> MockServer { + MockServer::start() +} + +#[fixture] +pub fn local_eval_flagsmith( + environment_json: serde_json::Value, + mock_server: MockServer, +) -> Flagsmith { + // Given + let _api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/environment-document/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(environment_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + enable_local_evaluation: true, + ..Default::default() + }; + let mut flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + flagsmith.update_environment().unwrap(); + return flagsmith; +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 4dd2b34..e911d58 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,147 +1,529 @@ -use flagsmith::{Client, User, Value}; - -const API_KEY: &str = "MgfUaRCvvZMznuQyqjnQKt"; -const TEST_FEATURE_NAME: &str = "test_feature"; -const TEST_FEATURE_VALUE: &str = "sample feature value"; -const TEST_USER_FEATURE_VALUE: &str = "user feature value"; -const TEST_FLAG_NAME: &str = "test_flag"; -const TEST_FLAG_VALUE: bool = true; -const TEST_TRAIT_NAME: &str = "test_trait"; -const TEST_TRAIT_VALUE: &str = "sample trait value"; -const TEST_TRAIT_NEW_VALUE: &str = "new value"; -const INVALID_NAME: &str = "invalid_name_for_tests"; -const TEST_DISABLED_FLAG: &str = "disabled_flag"; - -fn test_user() -> User { - User { - identifier: String::from("test_user"), - } +use flagsmith::{Flagsmith, FlagsmithOptions}; +use flagsmith_flag_engine::identities::Trait; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; + +use httpmock::prelude::*; +use rstest::*; + +mod fixtures; + +use fixtures::default_flag_handler; +use fixtures::environment_json; +use fixtures::flags_json; +use fixtures::identities_json; +use fixtures::local_eval_flagsmith; +use fixtures::mock_server; +use fixtures::ENVIRONMENT_KEY; + +#[rstest] +fn test_get_environment_flags_uses_local_environment_when_available( + mock_server: MockServer, + environment_json: serde_json::Value, +) { + // Given + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/environment-document/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(environment_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let mut flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + + flagsmith.update_environment().unwrap(); + + // Then + let all_flags = flagsmith.get_environment_flags().unwrap().all_flags(); + assert_eq!(all_flags.len(), 1); + assert_eq!(all_flags[0].feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(all_flags[0].feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + all_flags[0].value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + api_mock.assert(); } -fn different_user() -> User { - User { - identifier: String::from("different_user"), - } + +#[rstest] +fn test_get_environment_flags_calls_api_when_no_local_environment( + mock_server: MockServer, + flags_json: serde_json::Value, +) { + // Given + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(flags_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let all_flags = flagsmith.get_environment_flags().unwrap().all_flags(); + + // Then + assert_eq!(all_flags.len(), 1); + assert_eq!(all_flags[0].feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(all_flags[0].feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + all_flags[0].value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + api_mock.assert(); } +#[rstest] +fn test_get_identity_flags_uses_local_environment_when_available( + mock_server: MockServer, + environment_json: serde_json::Value, +) { + // Given + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/environment-document/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(environment_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let mut flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); -#[test] -fn test_get_features() { - let features = Client::new(API_KEY).get_features().unwrap(); - assert_eq!(features.len(), 5); - for f in features.iter() { - assert!(f.feature.name != ""); - } + // When + + flagsmith.update_environment().unwrap(); + + // Then + let all_flags = flagsmith + .get_identity_flags("test_identity", None) + .unwrap() + .all_flags(); + assert_eq!(all_flags.len(), 1); + assert_eq!(all_flags[0].feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(all_flags[0].feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + all_flags[0].value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + api_mock.assert(); } -#[test] -fn test_get_user_features() { - let features = Client::new(API_KEY) - .get_user_features(&test_user()) - .unwrap(); - for f in features.iter() { - assert!(f.feature.name != ""); - } +#[rstest] +fn test_get_identity_flags_calls_api_when_no_local_environment_no_traits( + mock_server: MockServer, + identities_json: serde_json::Value, +) { + // Given + let identifier = "test_identity"; + let api_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/api/v1/identities/") + .header("X-Environment-Key", ENVIRONMENT_KEY) + .json_body(serde_json::json!({ + "identifier": identifier, + "traits": [] + })); + then.status(200).json_body(identities_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + + let all_flags = flagsmith + .get_identity_flags(identifier, None) + .unwrap() + .all_flags(); + + // Then + assert_eq!(all_flags.len(), 1); + assert_eq!(all_flags[0].feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(all_flags[0].feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + all_flags[0].value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + + api_mock.assert(); +} + +#[rstest] +fn test_get_identity_flags_calls_api_when_no_local_environment_with_traits( + mock_server: MockServer, + identities_json: serde_json::Value, +) { + // Given + let identifier = "test_identity"; + let trait_key = "trait_key1"; + let trait_value = "trai_value1"; + + let api_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/api/v1/identities/") + .header("X-Environment-Key", ENVIRONMENT_KEY) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "identifier": identifier, + "traits": [{"trait_key":trait_key, "trait_value": trait_value}] + })); + then.status(200).json_body(identities_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let traits = vec![Trait { + trait_key: trait_key.to_string(), + trait_value: FlagsmithValue { + value: trait_value.to_string(), + value_type: FlagsmithValueType::String, + }, + }]; + let all_flags = flagsmith + .get_identity_flags(identifier, Some(traits)) + .unwrap() + .all_flags(); + + // Then + assert_eq!(all_flags.len(), 1); + assert_eq!(all_flags[0].feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(all_flags[0].feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + all_flags[0].value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + + api_mock.assert(); } -#[test] -fn test_has_value() { - let client = Client::new(API_KEY); - let ok = client.has_feature(TEST_FEATURE_NAME).unwrap(); - assert!(ok); +#[rstest] +fn test_default_flag_is_not_used_when_environment_flags_returned( + mock_server: MockServer, + flags_json: serde_json::Value, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(flags_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); - let ok = client.has_feature(INVALID_NAME).unwrap(); - assert!(ok == false); + // When + let flags = flagsmith.get_environment_flags().unwrap(); + let flag = flags.get_flag(fixtures::FEATURE_1_NAME).unwrap(); + // Then + assert_eq!(flag.feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(flag.is_default, false); + assert_eq!(flag.feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + assert!(flag.value_as_string().unwrap() != fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE); + api_mock.assert(); } -#[test] -fn test_has_user_value() { - let client = Client::new(API_KEY); - let ok = client.has_user_feature(&test_user(), TEST_FEATURE_NAME).unwrap(); - assert!(ok); +#[rstest] +fn test_default_flag_is_used_when_no_matching_environment_flag_returned( + mock_server: MockServer, + flags_json: serde_json::Value, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(flags_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); - let ok = client.has_user_feature(&test_user(), INVALID_NAME).unwrap(); - assert!(ok == false); + // When + let flags = flagsmith.get_environment_flags().unwrap(); + let flag = flags.get_flag("feature_that_does_not_exists").unwrap(); + // Then + assert_eq!(flag.is_default, true); + assert!(flag.value_as_string().unwrap() != fixtures::FEATURE_1_STR_VALUE); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE + ); + api_mock.assert(); } -#[test] -fn test_feature_enabled() { - let client = Client::new(API_KEY); - let enabled = client.feature_enabled(TEST_FLAG_NAME).unwrap(); - assert!(enabled); +#[rstest] +fn test_default_flag_is_not_used_when_identity_flags_returned( + mock_server: MockServer, + identities_json: serde_json::Value, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + // Given + let identifier = "test_identity"; + let api_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/api/v1/identities/") + .header("X-Environment-Key", ENVIRONMENT_KEY) + .json_body(serde_json::json!({ + "identifier": identifier, + "traits": [] + })); + then.status(200).json_body(identities_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let flags = flagsmith.get_identity_flags(identifier, None).unwrap(); + let flag = flags.get_flag(fixtures::FEATURE_1_NAME).unwrap(); + // Then + assert_eq!(flag.feature_name, fixtures::FEATURE_1_NAME); + assert_eq!(flag.is_default, false); + assert_eq!(flag.feature_id, fixtures::FEATURE_1_ID); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::FEATURE_1_STR_VALUE + ); + assert!(flag.value_as_string().unwrap() != fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE); + api_mock.assert(); } -#[test] -fn test_feature_disabled() { - let client = Client::new(API_KEY); - let enabled = client.feature_enabled(TEST_DISABLED_FLAG).unwrap(); - assert!(!enabled); +#[rstest] +fn test_default_flag_is_used_when_no_matching_identity_flags_returned( + mock_server: MockServer, + identities_json: serde_json::Value, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + // Given + let identifier = "test_identity"; + let api_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/api/v1/identities/") + .header("X-Environment-Key", ENVIRONMENT_KEY) + .json_body(serde_json::json!({ + "identifier": identifier, + "traits": [] + })); + then.status(200).json_body(identities_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let flags = flagsmith.get_identity_flags(identifier, None).unwrap(); + let flag = flags.get_flag("feature_that_does_not_exists").unwrap(); + // Then + assert_eq!(flag.is_default, true); + assert!(flag.value_as_string().unwrap() != fixtures::FEATURE_1_STR_VALUE); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE + ); + api_mock.assert(); } -#[test] -fn test_get_value() { - let client = Client::new(API_KEY); - let val = client.get_value(TEST_FEATURE_NAME).unwrap().unwrap(); - match val { - Value::String(v) => assert!(v == TEST_FEATURE_VALUE), - _ => assert!(false), - } - - let val = client.get_value("integer_feature").unwrap().unwrap(); - match val { - Value::Int(v) => assert!(v == 200), - _ => assert!(false), - } - - let val = client.get_value("boolean_feature").unwrap().unwrap(); - match val { - Value::Bool(v) => assert!(v == TEST_FLAG_VALUE), - _ => assert!(false), - } +#[rstest] +fn test_default_flags_are_used_if_api_error_and_default_flag_handler_given_for_environment( + mock_server: MockServer, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + // Give + let api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body({}); // returning empty body will return api error + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let flags = flagsmith.get_environment_flags().unwrap(); + let flag = flags.get_flag(fixtures::FEATURE_1_NAME).unwrap(); + // Then + assert_eq!(flag.is_default, true); + assert!(flag.value_as_string().unwrap() != fixtures::FEATURE_1_STR_VALUE); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE + ); + api_mock.assert(); } -#[test] -fn test_get_user_value() { - let val = Client::new(API_KEY) - .get_user_value(&test_user(), TEST_FEATURE_NAME) - .unwrap() - .unwrap(); - match val { - Value::String(v) => assert!(v == TEST_USER_FEATURE_VALUE), - _ => assert!(false), - } +#[rstest] +fn test_default_flags_are_used_if_api_error_and_default_flag_handler_given_for_identity( + mock_server: MockServer, + default_flag_handler: fn(&str) -> flagsmith::Flag, +) { + // Given + let identifier = "test_identity"; + let api_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/api/v1/identities/") + .header("X-Environment-Key", ENVIRONMENT_KEY) + .json_body(serde_json::json!({ + "identifier": identifier, + "traits": [] + })); + then.status(200).json_body({}); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + default_flag_handler: Some(default_flag_handler), + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let flags = flagsmith.get_identity_flags(identifier, None).unwrap(); + let flag = flags.get_flag("feature_that_does_not_exists").unwrap(); + // Then + assert_eq!(flag.is_default, true); + assert!(flag.value_as_string().unwrap() != fixtures::FEATURE_1_STR_VALUE); + assert_eq!( + flag.value_as_string().unwrap(), + fixtures::DEFAULT_FLAG_HANDLER_FLAG_VALUE + ); + api_mock.assert(); } -#[test] -fn test_get_traits() { - let traits = Client::new(API_KEY) - .get_traits(&test_user(), vec![]) - .unwrap(); - assert!(traits.len() == 2) +#[rstest] +fn test_flagsmith_api_error_is_returned_if_something_goes_wrong_with_the_request( + mock_server: MockServer, +) { + // Give + let _api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(502).json_body({}); // returning 502 + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + + // When + let err = flagsmith.get_environment_flags().err().unwrap(); + assert_eq!(err.kind, flagsmith::error::ErrorKind::FlagsmithAPIError); } -#[test] -fn test_get_trait() { - let t = Client::new(API_KEY) - .get_trait(&test_user(), TEST_TRAIT_NAME) +#[rstest] +fn test_flagsmith_client_error_is_returned_if_get_flag_is_called_with_a_flag_that_does_not_exists_without_default_handler( + mock_server: MockServer, + flags_json: serde_json::Value, +) { + // Given + let _api_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v1/flags/") + .header("X-Environment-Key", ENVIRONMENT_KEY); + then.status(200).json_body(flags_json); + }); + let url = mock_server.url("/api/v1/"); + let flagsmith_options = FlagsmithOptions { + api_url: url, + ..Default::default() + }; + let flagsmith = Flagsmith::new(ENVIRONMENT_KEY.to_string(), flagsmith_options); + // When + let err = flagsmith + .get_environment_flags() + .unwrap() + .get_flag("flag_that_does_not_exists") + .err() .unwrap(); - assert!(t.value == TEST_TRAIT_VALUE) + + // Then + assert_eq!(err.kind, flagsmith::error::ErrorKind::FlagsmithAPIError); } -#[test] -fn test_update_trait() { - let client = Client::new(API_KEY); - let mut old_trait = client - .get_trait(&different_user(), TEST_TRAIT_NAME) +#[rstest] +fn test_get_identity_segments_no_traits(local_eval_flagsmith: Flagsmith) { + // Given + let identifier = "some_identifier"; + + // When + let segments = local_eval_flagsmith + .get_identity_segments(identifier, None) .unwrap(); - old_trait.value = String::from(TEST_TRAIT_NEW_VALUE); - let updated = client.update_trait(&different_user(), &old_trait).unwrap(); - assert!(TEST_TRAIT_NEW_VALUE == updated.value); + //Then + assert_eq!(segments.len(), 0) +} + +#[rstest] +fn test_get_identity_segments_with_valid_trait(local_eval_flagsmith: Flagsmith) { + // Given + let identifier = "some_identifier"; + + // lifted from fixtures::environment_json + let trait_key = "foo"; + let trait_value = "bar"; - let t = client - .get_trait(&different_user(), TEST_TRAIT_NAME) + let traits = vec![Trait { + trait_key: trait_key.to_string(), + trait_value: FlagsmithValue { + value: trait_value.to_string(), + value_type: FlagsmithValueType::String, + }, + }]; + // When + let segments = local_eval_flagsmith + .get_identity_segments(identifier, Some(traits)) .unwrap(); - assert!(TEST_TRAIT_NEW_VALUE == t.value); - old_trait.value = String::from("old value"); - client.update_trait(&different_user(), &old_trait).unwrap(); + //Then + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].name, "Test Segment"); }