diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..711459e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-wasip1] +runner = "viceroy run -C fastly.unit-test.toml -- " diff --git a/.cargo/nextest.toml b/.cargo/nextest.toml new file mode 100644 index 0000000..9811ee7 --- /dev/null +++ b/.cargo/nextest.toml @@ -0,0 +1,2 @@ +[profile.default] +platform = { target_arch = "wasm32" } diff --git a/Cargo.toml b/Cargo.toml index 4089d55..d084d2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "flagsmith" version = "2.0.0" -authors = ["Gagan Trivedi ", "Kim Gustyr "] +authors = [ + "Gagan Trivedi ", + "Kim Gustyr ", +] edition = "2021" license = "BSD-3-Clause" description = "Flagsmith SDK for Rust" @@ -16,7 +19,6 @@ keywords = ["Flagsmith", "feature-flag", "remote-config"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.11", features = ["json", "blocking"] } url = "2.1" chrono = { version = "0.4" } log = "0.4" @@ -24,6 +26,15 @@ flume = "0.10.14" flagsmith-flag-engine = "0.4.0" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { version = "0.11", features = ["json", "blocking"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.11", features = ["json"] } +fastly = { version = "^0.11.5" } + [dev-dependencies] -httpmock = "0.6" rstest = "0.12.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +httpmock = "0.6" diff --git a/fastly.unit-test.toml b/fastly.unit-test.toml new file mode 100644 index 0000000..2f25588 --- /dev/null +++ b/fastly.unit-test.toml @@ -0,0 +1,15 @@ +# This file describes a Fastly Compute@Edge package. To learn more visit: +# https://developer.fastly.com/reference/fastly-toml/ + +authors = [ + "Gagan Trivedi ", + "Kim Gustyr ", +] +description = "Flagsmith Rust Client." +language = "rust" +manifest_version = 2 +name = "flagsmith-rust-client" +service_id = "" + +[scripts] +build = "cargo build --release --target wasm32-wasip1 --color always" diff --git a/scripts/fastly-unit-test.sh b/scripts/fastly-unit-test.sh new file mode 100755 index 0000000..b0bce7d --- /dev/null +++ b/scripts/fastly-unit-test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +function run-specs() { + cargo nextest run --target wasm32-wasip1 flagsmith::client +} + +if [ "$0" = "${BASH_SOURCE[0]}" ]; then + set -eo pipefail + run-specs "${@:-}" + exit $? +fi diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..486bf6f --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +function run-default-tests() { + echo "Running default tests" + cargo test +} + +function run-fastly-tests() { + echo "Running fastly/wasm tests" + ./scripts/fastly-unit-test.sh +} + +if [ "$0" = "${BASH_SOURCE[0]}" ]; then + set -eo pipefail + + run-default-tests && run-fastly-tests + + exit $? +fi diff --git a/src/flagsmith/analytics.rs b/src/flagsmith/analytics.rs index 6ea4488..e52bd81 100644 --- a/src/flagsmith/analytics.rs +++ b/src/flagsmith/analytics.rs @@ -5,6 +5,8 @@ use serde_json; use std::{collections::HashMap, thread}; use std::sync::{Arc, RwLock}; + +use crate::flagsmith::client::client::{ClientLike, ClientRequestBuilder, Method, SafeClient}; static ANALYTICS_TIMER_IN_MILLI: u64 = 10 * 1000; #[derive(Clone, Debug)] @@ -21,11 +23,8 @@ impl AnalyticsProcessor { timer: Option, ) -> Self { let (tx, rx) = flume::unbounded(); - let client = reqwest::blocking::Client::builder() - .default_headers(headers) - .timeout(timeout) - .build() - .unwrap(); + let client = SafeClient::new(headers.clone(), timeout); + let analytics_endpoint = format!("{}analytics/flags/", api_url); let timer = timer.unwrap_or(ANALYTICS_TIMER_IN_MILLI); @@ -73,22 +72,22 @@ impl AnalyticsProcessor { } } -fn flush( - client: &reqwest::blocking::Client, - analytics_data: &HashMap, - analytics_endpoint: &str, -) { +fn flush(client: &SafeClient, 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(); + let req = client + .inner + .request(Method::POST, analytics_endpoint.to_string()) + .with_body(body); + let resp = req.send(); if resp.is_err() { warn!("Failed to send analytics data"); } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use httpmock::prelude::*; diff --git a/src/flagsmith/client/blocking_client.rs b/src/flagsmith/client/blocking_client.rs new file mode 100644 index 0000000..f411777 --- /dev/null +++ b/src/flagsmith/client/blocking_client.rs @@ -0,0 +1,90 @@ +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::flagsmith::client::client::Method; +use crate::flagsmith::client::client::{ + ClientLike, ClientRequestBuilder, ClientResponse, ResponseStatusCode, +}; + +impl From for reqwest::Method { + fn from(value: Method) -> Self { + match value { + Method::OPTIONS => reqwest::Method::OPTIONS, + Method::GET => reqwest::Method::GET, + Method::POST => reqwest::Method::POST, + Method::PUT => reqwest::Method::PUT, + Method::DELETE => reqwest::Method::DELETE, + Method::HEAD => reqwest::Method::HEAD, + Method::TRACE => reqwest::Method::TRACE, + Method::CONNECT => reqwest::Method::CONNECT, + Method::PATCH => reqwest::Method::PATCH, + } + } +} + +#[derive(Clone)] +pub struct BlockingClient { + reqwest_client: reqwest::blocking::Client, +} + +impl ResponseStatusCode for reqwest::StatusCode { + fn is_success(&self) -> bool { + self.is_success() + } + + fn as_u16(&self) -> u16 { + self.as_u16() + } +} + +impl ClientResponse for reqwest::blocking::Response { + fn status(&self) -> impl ResponseStatusCode { + self.status() + } + + fn text(self) -> Result { + match self.text() { + Ok(res) => Ok(res), + Err(_) => Err(()), + } + } + + fn json(self) -> Result { + match self.json() { + Ok(res) => Ok(res), + Err(_) => Err(()), + } + } +} + +impl ClientRequestBuilder for reqwest::blocking::RequestBuilder { + fn with_body(self, body: String) -> Self { + self.body(body) + } + + fn send(self) -> Result { + match self.send() { + Ok(res) => Ok(res), + Err(_) => Err(()), + } + } +} + +impl ClientLike for BlockingClient { + fn request(&self, method: super::client::Method, url: String) -> impl ClientRequestBuilder { + self.reqwest_client.request(method.into(), url) + } + + fn new(headers: reqwest::header::HeaderMap, timeout: Duration) -> Self { + let inner = reqwest::blocking::Client::builder() + .default_headers(headers) + .timeout(timeout) + .build() + .unwrap(); + + Self { + reqwest_client: inner, + } + } +} diff --git a/src/flagsmith/client/client.rs b/src/flagsmith/client/client.rs new file mode 100644 index 0000000..a39e95a --- /dev/null +++ b/src/flagsmith/client/client.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use reqwest::header::HeaderMap; +use serde::de::DeserializeOwned; + +#[cfg(not(target_arch = "wasm32"))] +use crate::flagsmith::client::blocking_client::BlockingClient; +#[cfg(target_arch = "wasm32")] +use crate::flagsmith::client::fastly_client::FastlyClient; + +pub enum Method { + OPTIONS, + GET, + POST, + PUT, + DELETE, + HEAD, + TRACE, + CONNECT, + PATCH, +} + +pub trait ResponseStatusCode { + fn is_success(&self) -> bool; + + /// Exists for Unit Testing purposes + #[allow(dead_code)] + fn as_u16(&self) -> u16; +} + +pub trait ClientRequestBuilder { + fn with_body(self, body: String) -> Self; + + // TODO return type + fn send(self) -> Result; +} + +pub trait ClientResponse { + fn status(&self) -> impl ResponseStatusCode; + + // TODO return error type + fn text(self) -> Result; + + // TODO return error type + fn json(self) -> Result; +} + +pub trait ClientLike { + fn new(headers: HeaderMap, timeout: Duration) -> Self; + fn request(&self, method: Method, url: String) -> impl ClientRequestBuilder; +} + +#[derive(Clone)] +pub struct SafeClient { + #[cfg(not(target_arch = "wasm32"))] + pub inner: BlockingClient, + + #[cfg(target_arch = "wasm32")] + pub inner: FastlyClient, +} + +impl SafeClient { + #[cfg(not(target_arch = "wasm32"))] + pub fn new(headers: HeaderMap, timeout: Duration) -> Self { + Self { + inner: BlockingClient::new(headers, timeout), + } + } + + #[cfg(target_arch = "wasm32")] + pub fn new(headers: HeaderMap, timeout: Duration) -> Self { + Self { + inner: FastlyClient::new(headers, timeout), + } + } +} diff --git a/src/flagsmith/client/fastly_client.rs b/src/flagsmith/client/fastly_client.rs new file mode 100644 index 0000000..563a71f --- /dev/null +++ b/src/flagsmith/client/fastly_client.rs @@ -0,0 +1,180 @@ +use std::io::Read; + +use reqwest::header::HeaderMap; + +use crate::flagsmith::{ + self, + client::client::{ + ClientLike, ClientRequestBuilder, ClientResponse, Method, ResponseStatusCode, + }, +}; +use fastly::http; + +impl From for http::Method { + fn from(value: Method) -> Self { + match value { + Method::OPTIONS => http::Method::OPTIONS, + Method::GET => http::Method::GET, + Method::POST => http::Method::POST, + Method::PUT => http::Method::PUT, + Method::DELETE => http::Method::DELETE, + Method::HEAD => http::Method::HEAD, + Method::TRACE => http::Method::TRACE, + Method::CONNECT => http::Method::CONNECT, + Method::PATCH => http::Method::PATCH, + } + } +} + +impl super::client::ResponseStatusCode for http::StatusCode { + fn is_success(&self) -> bool { + let raw = self.as_u16(); + + raw >= 200 && raw <= 299 + } + + fn as_u16(&self) -> u16 { + self.as_u16() + } +} + +impl super::client::ClientResponse for http::Response { + fn status(&self) -> impl ResponseStatusCode { + self.get_status() + } + + fn text(mut self) -> Result { + let mut buf = String::new(); + if self.get_body_mut().read_to_string(&mut buf).is_ok() { + Ok(buf) + } else { + Err(()) + } + } + + fn json(mut self) -> Result { + match self.take_body_json::() { + Ok(res) => Ok(res), + Err(_) => Err(()), + } + } +} + +/// Wrapper to help with abstraction of the client interface. +pub struct FastlyRequestBuilder { + backend: String, + pub request: Result, +} + +impl ClientRequestBuilder for FastlyRequestBuilder { + fn with_body(mut self, body: String) -> Self { + if let Ok(ref mut req) = self.request { + req.set_body(body); + } + + self + } + + fn send(self) -> Result { + if let Ok(req) = self.request { + match req.send(self.backend) { + Ok(res) => Ok(res), + Err(_) => Err(()), + } + } else { + Err(()) + } + } +} + +#[derive(Clone)] +pub struct FastlyClient { + default_headers: HeaderMap, +} + +impl ClientLike for FastlyClient { + fn new(headers: HeaderMap, _timeout: std::time::Duration) -> Self { + Self { + default_headers: headers, + } + } + + fn request(&self, method: super::client::Method, url: String) -> impl ClientRequestBuilder { + let mut req = http::Request::new( + >::into(method), + url, + ); + + for (name, value) in &self.default_headers { + if let Ok(header_val) = value.to_str() { + req.append_header(name.to_string(), header_val); + } + } + + FastlyRequestBuilder { + backend: "flagsmith".to_string(), + request: Ok(req), + } + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use fastly::Response; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use super::*; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct TestData { + key: String, + } + + #[test] + fn test_status_code_is_success() { + let status = http::StatusCode::from_u16(199).unwrap(); + assert!(!status.is_success()); + + let status = http::StatusCode::from_u16(300).unwrap(); + assert!(!status.is_success()); + + for i in 200..=299 { + let status = http::StatusCode::from_u16(i).unwrap(); + + assert!(status.is_success(), "{} should be success", i); + } + } + + #[test] + fn test_response_status_returns_status() { + let resp = Response::from_status(418); + + assert_eq!(resp.status().as_u16(), 418); + } + + #[test] + fn test_response_text_returns_body() { + let resp = Response::from_body("This is a test body."); + + let text = resp.text(); + + assert!(text.is_ok()); + assert_eq!(text.unwrap(), "This is a test body."); + } + + #[test] + fn test_response_json_returns_body() { + let resp = Response::from_body(json!({ "key": "value" }).to_string()); + + let result = resp.json::(); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + TestData { + key: "value".to_string() + } + ); + } +} diff --git a/src/flagsmith/client/mod.rs b/src/flagsmith/client/mod.rs new file mode 100644 index 0000000..9db00ac --- /dev/null +++ b/src/flagsmith/client/mod.rs @@ -0,0 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +pub mod blocking_client; +pub mod client; +#[cfg(target_arch = "wasm32")] +pub mod fastly_client; diff --git a/src/flagsmith/mod.rs b/src/flagsmith/mod.rs index e91635d..10b2e5f 100644 --- a/src/flagsmith/mod.rs +++ b/src/flagsmith/mod.rs @@ -1,3 +1,7 @@ +use crate::flagsmith::client::client::{ + ClientLike, ClientRequestBuilder, ClientResponse, Method, ResponseStatusCode, SafeClient, +}; + use self::analytics::AnalyticsProcessor; use self::models::{Flag, Flags}; use super::error; @@ -17,6 +21,7 @@ use std::sync::{Arc, Mutex}; use std::{thread, time::Duration}; mod analytics; +mod client; pub mod models; pub mod offline_handler; @@ -52,7 +57,7 @@ impl Default for FlagsmithOptions { } pub struct Flagsmith { - client: reqwest::blocking::Client, + client: SafeClient, environment_flags_url: String, identities_url: String, environment_url: String, @@ -76,11 +81,7 @@ impl Flagsmith { ); 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 client = SafeClient::new(headers.clone(), timeout); let environment_flags_url = format!("{}flags/", flagsmith_options.api_url); let identities_url = format!("{}identities/", flagsmith_options.api_url); @@ -112,7 +113,10 @@ impl Flagsmith { // Put the environment model behind mutex to // to share it safely between threads - let ds = Arc::new(Mutex::new(DataStore { environment: None, identities_with_overrides_by_identifier: HashMap::new() })); + let ds = Arc::new(Mutex::new(DataStore { + environment: None, + identities_with_overrides_by_identifier: HashMap::new(), + })); let (tx, rx) = mpsc::sync_channel::(1); let flagsmith = Flagsmith { @@ -146,7 +150,7 @@ impl Flagsmith { if flagsmith.options.enable_local_evaluation { // Update environment once... update_environment(&client, &ds, &environment_url).unwrap(); - + // ...and continue updating in the background let ds = Arc::clone(&ds); thread::spawn(move || loop { @@ -232,8 +236,12 @@ impl Flagsmith { } let environment = data.environment.as_ref().unwrap(); let identities_with_overrides_by_identifier = &data.identities_with_overrides_by_identifier; - let identity_model = - self.get_identity_model(&environment, &identities_with_overrides_by_identifier, identifier, traits.clone().unwrap_or(vec![]))?; + let identity_model = self.get_identity_model( + &environment, + &identities_with_overrides_by_identifier, + identifier, + traits.clone().unwrap_or(vec![]), + )?; let segments = get_identity_segments(environment, &identity_model, traits.as_ref()); return Ok(segments); } @@ -277,7 +285,12 @@ impl Flagsmith { identifier: &str, traits: Vec, ) -> Result { - let identity = self.get_identity_model(environment, identities_with_overrides_by_identifier, identifier, traits.clone())?; + let identity = self.get_identity_model( + environment, + identities_with_overrides_by_identifier, + identifier, + traits.clone(), + )?; let feature_states = engine::get_identity_feature_states(environment, &identity, Some(traits.as_ref())); let flags = Flags::from_feature_states( @@ -299,13 +312,16 @@ impl Flagsmith { let mut identity: Identity; if identities_with_overrides_by_identifier.contains_key(identifier) { - identity = identities_with_overrides_by_identifier.get(identifier).unwrap().clone(); + identity = identities_with_overrides_by_identifier + .get(identifier) + .unwrap() + .clone(); } else { identity = Identity::new(identifier.to_string(), environment.api_key.clone()); } identity.identity_traits = traits; - return Ok(identity.to_owned()) + return Ok(identity.to_owned()); } fn get_identity_flags_from_api( &self, @@ -313,7 +329,7 @@ impl Flagsmith { traits: Vec, transient: bool, ) -> Result { - let method = reqwest::Method::POST; + let method = Method::POST; let json = json!({"identifier":identifier, "traits": traits, "transient": transient}); let response = get_json_response( @@ -340,7 +356,7 @@ impl Flagsmith { return Ok(flags); } fn get_environment_flags_from_api(&self) -> Result { - let method = reqwest::Method::GET; + let method = Method::GET; let api_flags = get_json_response( &self.client, method, @@ -367,54 +383,52 @@ impl Flagsmith { } fn get_environment_from_api( - client: &reqwest::blocking::Client, + client: &SafeClient, environment_url: String, ) -> Result { - let method = reqwest::Method::GET; + let method = Method::GET; let json_document = get_json_response(client, method, environment_url, None)?; let environment = build_environment_struct(json_document); return Ok(environment); } fn update_environment( - client: &reqwest::blocking::Client, + client: &SafeClient, datastore: &Arc>, environment_url: &String, ) -> Result<(), error::Error> { let mut data = datastore.lock().unwrap(); - let environment = Some(get_environment_from_api( - &client, - environment_url.clone(), - )?); + let environment = Some(get_environment_from_api(&client, environment_url.clone())?); for identity in &environment.as_ref().unwrap().identity_overrides { - data.identities_with_overrides_by_identifier.insert(identity.identifier.clone(), identity.clone()); + data.identities_with_overrides_by_identifier + .insert(identity.identifier.clone(), identity.clone()); } data.environment = environment; return Ok(()); } fn get_json_response( - client: &reqwest::blocking::Client, - method: reqwest::Method, + client: &SafeClient, + method: Method, url: String, body: Option, ) -> Result { - let mut request = client.request(method, url); + let mut request = client.inner.request(method, url); if body.is_some() { - request = request.body(body.unwrap()); + request = request.with_body(body.unwrap()); }; - let response = request.send()?; + let response = request.send().unwrap(); if response.status().is_success() { - return Ok(response.json()?); + return Ok(response.json().unwrap()); } else { return Err(error::Error::new( error::ErrorKind::FlagsmithAPIError, - response.text()?, + response.text().unwrap(), )); } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use httpmock::prelude::*; @@ -600,7 +614,21 @@ mod tests { // Then let flags = _flagsmith.get_environment_flags(); let identity_flags = _flagsmith.get_identity_flags("overridden-id", None, None); - assert_eq!(flags.unwrap().get_feature_value_as_string("some_feature").unwrap().to_owned(), "some-value"); - assert_eq!(identity_flags.unwrap().get_feature_value_as_string("some_feature").unwrap().to_owned(), "some-overridden-value"); + assert_eq!( + flags + .unwrap() + .get_feature_value_as_string("some_feature") + .unwrap() + .to_owned(), + "some-value" + ); + assert_eq!( + identity_flags + .unwrap() + .get_feature_value_as_string("some_feature") + .unwrap() + .to_owned(), + "some-overridden-value" + ); } } diff --git a/src/flagsmith/models.rs b/src/flagsmith/models.rs index 5d26a2a..bf15801 100644 --- a/src/flagsmith/models.rs +++ b/src/flagsmith/models.rs @@ -196,7 +196,7 @@ impl From for Trait { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; static FEATURE_STATE_JSON_STRING: &str = r#"{ diff --git a/src/flagsmith/offline_handler.rs b/src/flagsmith/offline_handler.rs index 1ce7eb1..acdb19b 100644 --- a/src/flagsmith/offline_handler.rs +++ b/src/flagsmith/offline_handler.rs @@ -30,7 +30,7 @@ impl OfflineHandler for LocalFileHandler { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 4600f2f..524ea95 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -1,3 +1,5 @@ +#![cfg(all(not(target_arch = "wasm32")))] + use httpmock::prelude::*; use rstest::*; use serde_json; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index d466490..58f6103 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,3 +1,5 @@ +#![cfg(all(not(target_arch = "wasm32")))] + use flagsmith::flagsmith::models::SDKTrait; use flagsmith::flagsmith::offline_handler; use flagsmith::{Flagsmith, FlagsmithOptions}; @@ -441,7 +443,6 @@ fn test_get_identity_flags_calls_api_when_no_local_environment_with_transient_id api_mock.assert(); } - #[rstest] fn test_default_flag_is_not_used_when_environment_flags_returned( mock_server: MockServer,