├── .gitignore ├── .rusty-hook.toml ├── .gitmodules ├── .github ├── dependabot.yml └── workflows │ ├── publish-crates.yml │ └── build-with-coverage.yml ├── Makefile ├── resources └── fixtures │ ├── segments.json │ ├── toggles.json │ └── repo.json ├── examples └── demo.rs ├── README.md ├── Cargo.toml ├── benches └── bench.rs ├── src ├── user.rs ├── lib.rs ├── config.rs ├── sync.rs ├── feature_probe.rs └── evaluate.rs ├── tests └── integration_test.rs └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | .idea 4 | .vscode 5 | -------------------------------------------------------------------------------- /.rusty-hook.toml: -------------------------------------------------------------------------------- 1 | [hooks] 2 | pre-push = "cargo fmt -- --check && cargo clippy -- -Dwarnings && make test" 3 | 4 | [logging] 5 | verbose = true 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "resources/fixtures/spec"] 2 | path = resources/fixtures/spec 3 | url = git@github.com:FeatureProbe/server-sdk-specification.git 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build_date = `date +%Y%m%d%H%M` 2 | commit = `git rev-parse HEAD` 3 | version = `git rev-parse --short HEAD` 4 | 5 | .PHONY: release 6 | clean: 7 | cargo clean 8 | release: 9 | cargo build --release --verbose 10 | release-test: 11 | cargo test --release --verbose && \ 12 | cargo test --release --verbose --features async --no-default-features 13 | test: 14 | cargo test --verbose && \ 15 | cargo test --verbose --features internal --no-default-features 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/publish-crates.yml: -------------------------------------------------------------------------------- 1 | name: Publish Crates 2 | 3 | on: 4 | release: 5 | types: [created] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | override: true 20 | - uses: katyo/publish-crates@v1 21 | with: 22 | registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} 23 | -------------------------------------------------------------------------------- /resources/fixtures/segments.json: -------------------------------------------------------------------------------- 1 | { 2 | "segments": { 3 | "some_segment1-fjoaefjaam": { 4 | "key": "some_segment1", 5 | "unique_id": "some_segment1-fjoaefjaam", 6 | "version": 2, 7 | "rules": [ 8 | { 9 | "conditions": [ 10 | { 11 | "type": "string", 12 | "subject": "city", 13 | "predicate": "is in", 14 | "objects": [ 15 | "4" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/build-with-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test & Coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | with: 18 | submodules: recursive 19 | - name: Test 20 | run: make test 21 | - name: Release 22 | run: make release 23 | 24 | coverage: 25 | runs-on: ubuntu-latest 26 | container: 27 | image: xd009642/tarpaulin:develop-nightly 28 | options: --security-opt seccomp=unconfined 29 | steps: 30 | - name: checkout repository 31 | uses: actions/checkout@v2 32 | with: 33 | submodules: recursive 34 | 35 | - name: generate code coverage 36 | run: | 37 | cargo +nightly tarpaulin --verbose --workspace --timeout 120 --out xml 38 | - name: upload to codecov.io 39 | uses: codecov/codecov-action@v2 40 | with: 41 | fail_ci_if_error: true 42 | 43 | -------------------------------------------------------------------------------- /examples/demo.rs: -------------------------------------------------------------------------------- 1 | use feature_probe_server_sdk::{FPConfig, FPError, FPUser, FeatureProbe}; 2 | use std::time::Duration; 3 | use url::Url; 4 | 5 | // Connect to demo docker environment. 6 | // cargo run --example demo 7 | 8 | #[tokio::main] 9 | async fn main() -> Result<(), FPError> { 10 | tracing_subscriber::fmt::init(); 11 | // let remote_url = Url::parse("http://localhost:4009/server").unwrap(); // for local docker 12 | let remote_url = Url::parse("https://featureprobe.io/server").expect("invalid url"); 13 | // Replace Server SDK key in your Project List Page. 14 | let server_sdk_key = "server-7fa2f771259cb7235b96433d70b91e99abcf6ff8".to_owned(); 15 | let refresh_interval = Duration::from_secs(20); 16 | 17 | let config = FPConfig { 18 | remote_url, 19 | server_sdk_key, 20 | refresh_interval, 21 | start_wait: Some(Duration::from_secs(5)), 22 | ..Default::default() 23 | }; 24 | 25 | let fp = FeatureProbe::new(config); 26 | if !fp.initialized() { 27 | println!("FeatureProbe failed to initialize, will return default value"); 28 | } 29 | 30 | let mut user = FPUser::new(); 31 | user = user.with("userId", "00001"); 32 | let toggle_key = "campaign_allow_list"; 33 | let enable = fp.bool_value(toggle_key, &user, false); 34 | println!("Result => : {:?}", enable); 35 | 36 | let detail = fp.bool_detail(toggle_key, &user, false); 37 | println!(" => reason : {:?}", detail.reason); 38 | println!(" => rule index : {:?}", detail.rule_index); 39 | 40 | fp.close(); 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FeatureProbe Server Side SDK for Rust 2 | 3 | [![Top Language](https://img.shields.io/github/languages/top/FeatureProbe/server-sdk-rust)](https://github.com/FeatureProbe/server-sdk-rust/search?l=rust) 4 | [![codecov](https://codecov.io/gh/featureprobe/server-sdk-rust/branch/main/graph/badge.svg?token=TAN3AU4CK2)](https://codecov.io/gh/featureprobe/server-sdk-rust) 5 | [![Github Star](https://img.shields.io/github/stars/FeatureProbe/server-sdk-rust)](https://github.com/FeatureProbe/server-sdk-rust/stargazers) 6 | [![Apache-2.0 license](https://img.shields.io/github/license/FeatureProbe/FeatureProbe)](https://github.com/FeatureProbe/FeatureProbe/blob/main/LICENSE) 7 | 8 | [FeatureProbe](https://featureprobe.com/) is an open source feature management service. This SDK is used to control features in rust programs. This 9 | SDK is designed primarily for use in multi-user systems such as web servers and applications. 10 | 11 | ## Basic Terms 12 | 13 | Reading the short [Introduction](https://docs.featureprobe.io/reference/sdk-introduction) will help to understand the code blow more easily. [中文](https://docs.featureprobe.io/zh-CN/reference/sdk-introduction) 14 | 15 | ## How to use this SDK 16 | See [SDK Doc](https://docs.featureprobe.io/how-to/Server-Side%20SDKs/rust-sdk) for detail. [中文](https://docs.featureprobe.io/zh-CN/how-to/Server-Side%20SDKs/rust-sdk) 17 | 18 | ## Contributing 19 | 20 | We are working on continue evolving FeatureProbe core, making it flexible and easier to use. 21 | Development of FeatureProbe happens in the open on GitHub, and we are grateful to the 22 | community for contributing bugfixes and improvements. 23 | 24 | Please read [CONTRIBUTING](https://github.com/FeatureProbe/featureprobe/blob/master/CONTRIBUTING.md) 25 | for details on our code of conduct, and the process for taking part in improving FeatureProbe. 26 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | name = "feature-probe-server-sdk" 4 | version = "2.3.2" 5 | license = "Apache-2.0" 6 | authors = ["maintain@featureprobe.com"] 7 | description = "FeatureProbe Server Side SDK for Rust" 8 | 9 | [lib] 10 | name = "feature_probe_server_sdk" 11 | path = "src/lib.rs" 12 | 13 | [[bench]] 14 | name = "bench" 15 | harness = false 16 | 17 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 18 | 19 | [features] 20 | default = ["realtime"] 21 | internal = [] 22 | realtime = ["socketio-rs"] 23 | 24 | [dependencies] 25 | anyhow = "1.0" 26 | byteorder = "1" 27 | dashmap = "5.5" 28 | headers = "0.3" 29 | http = "0.2" 30 | lazy_static = "1.4" 31 | minstant = "0.1" 32 | parking_lot = { version = "0.12", features = ["serde"] } 33 | rand = "0.8" 34 | regex = "1.7.3" 35 | semver = "1.0" 36 | serde = { version = "1.0", features = ["derive"] } 37 | serde_json = "1.0" 38 | sha1 = "0.10" 39 | thiserror = "1.0" 40 | tracing = "0.1" 41 | url = "2" 42 | 43 | feature-probe-event = { version = "1.2.0", features = [ 44 | "use_tokio", 45 | ], default-features = false} 46 | 47 | reqwest = { version = "0.11", default-features = false, features = [ 48 | "rustls-tls", 49 | "json", 50 | ] } 51 | tokio = { version = "1", features = ["full"] } 52 | 53 | socketio-rs = { optional = true, version = "0.1.7", default-features = false, features = ["client"] } 54 | futures-util = { version = "0.3", default-features = false, features = [ 55 | "sink", 56 | ] } 57 | 58 | [dev-dependencies] 59 | approx = "0.5" 60 | axum = { version = "0.6", features = ["headers"] } 61 | axum-extra = { version = "0.4", features = ["typed-routing"] } 62 | clap = { version = "4.4.4", features = ["derive"] } 63 | criterion = "0.4" 64 | rusty-hook = "^0.11.2" 65 | tokio = { version = "1", features = ["full"] } 66 | tracing-subscriber = "0.3" 67 | feature-probe-server = { version = "2.0.1", features = ["realtime"] } 68 | 69 | -------------------------------------------------------------------------------- /benches/bench.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | use feature_probe_server_sdk::{load_json, FPUser, FeatureProbe}; 3 | use serde_json::json; 4 | use std::{fs, path::PathBuf}; 5 | 6 | fn bench_bool_toggle(pair: (&FeatureProbe, &FPUser)) { 7 | let fp = pair.0; 8 | let user = pair.1; 9 | 10 | let _d = fp.bool_value("bool_toggle", user, false); 11 | } 12 | 13 | fn bench_bool_toggle_detail(pair: (&FeatureProbe, &FPUser)) { 14 | let fp = pair.0; 15 | let user = pair.1; 16 | 17 | let _d = fp.bool_detail("bool_toggle", user, false); 18 | } 19 | 20 | fn bench_json_toggle(pair: (&FeatureProbe, &FPUser)) { 21 | let fp = pair.0; 22 | let user = pair.1; 23 | 24 | let _d = fp.json_value("multi_condition_toggle", user, json!("")); 25 | } 26 | 27 | fn criterion_benchmark(c: &mut Criterion) { 28 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 29 | path.push("resources/fixtures/repo.json"); 30 | let json_str = fs::read_to_string(path).unwrap(); 31 | let repo = load_json(&json_str).unwrap(); 32 | let user_default = FPUser::new(); 33 | let user_hit = FPUser::new().with("city", "1"); 34 | let fp = FeatureProbe::new_with("secret key".to_string(), repo); 35 | 36 | c.bench_function("bench_bool_toggle_default", |b| { 37 | b.iter(|| bench_bool_toggle(black_box((&fp, &user_default)))) 38 | }); 39 | 40 | c.bench_function("bench_bool_toggle_hit", |b| { 41 | b.iter(|| bench_bool_toggle(black_box((&fp, &user_hit)))) 42 | }); 43 | 44 | c.bench_function("bench_bool_toggle_detail_hit", |b| { 45 | b.iter(|| bench_bool_toggle_detail(black_box((&fp, &user_hit)))) 46 | }); 47 | 48 | c.bench_function("bench_json_toggle_default", |b| { 49 | b.iter(|| bench_json_toggle(black_box((&fp, &user_default)))) 50 | }); 51 | 52 | c.bench_function("bench_json_toggle_hit", |b| { 53 | b.iter(|| bench_json_toggle(black_box((&fp, &user_hit)))) 54 | }); 55 | } 56 | 57 | criterion_group!(benches, criterion_benchmark); 58 | 59 | criterion_main!(benches); 60 | -------------------------------------------------------------------------------- /src/user.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::RwLock; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use std::time::{SystemTime, UNIX_EPOCH}; 5 | 6 | #[derive(Default, Debug, Serialize, Deserialize)] 7 | pub struct FPUser { 8 | key: RwLock>, 9 | attrs: HashMap, 10 | } 11 | 12 | impl FPUser { 13 | pub fn new() -> Self { 14 | let key = RwLock::new(None); 15 | FPUser { 16 | key, 17 | ..Default::default() 18 | } 19 | } 20 | 21 | pub fn stable_rollout(mut self, key: String) -> Self { 22 | self.key = RwLock::new(Some(key)); 23 | self 24 | } 25 | 26 | pub fn with>(mut self, k: T, v: T) -> Self { 27 | self.attrs.insert(k.into(), v.into()); 28 | self 29 | } 30 | 31 | pub fn with_attrs(mut self, attrs: impl Iterator) -> Self { 32 | self.attrs.extend(attrs); 33 | self 34 | } 35 | 36 | pub fn get(&self, k: &str) -> Option<&String> { 37 | self.attrs.get(k) 38 | } 39 | 40 | pub fn get_all(&self) -> &HashMap { 41 | &self.attrs 42 | } 43 | 44 | pub fn key(&self) -> String { 45 | let key = { 46 | let key = self.key.read(); 47 | (*key).clone() 48 | }; 49 | match key { 50 | Some(key) => key, 51 | None => { 52 | let mut guard = self.key.write(); 53 | let key = generate_key(); 54 | *guard = Some(key.clone()); 55 | key 56 | } 57 | } 58 | } 59 | } 60 | 61 | fn generate_key() -> String { 62 | let start = SystemTime::now(); 63 | let since_the_epoch = start 64 | .duration_since(UNIX_EPOCH) 65 | .expect("Time went before epoch"); 66 | format!("{}", since_the_epoch.as_micros()) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | fn test_user_with() { 75 | let u = FPUser::new().with("name", "bob").with("phone", "123"); 76 | assert_eq!(u.get("name"), Some(&"bob".to_owned())); 77 | assert_eq!(u.get("phone"), Some(&"123".to_owned())); 78 | assert_eq!(u.get_all().len(), 2); 79 | // test generated key not change 80 | assert_eq!(u.key(), u.key()); 81 | } 82 | 83 | #[test] 84 | fn test_user_with_attrs() { 85 | let mut attrs: HashMap = Default::default(); 86 | attrs.insert("name".to_owned(), "bob".to_owned()); 87 | attrs.insert("phone".to_owned(), "123".to_owned()); 88 | let u = FPUser::new().with_attrs(attrs.into_iter()); 89 | assert_eq!(u.get_all().len(), 2); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod evaluate; 3 | mod feature_probe; 4 | mod sync; 5 | mod user; 6 | 7 | pub use crate::config::FPConfig; 8 | pub use crate::evaluate::{load_json, EvalDetail, Repository, Segment, Toggle}; 9 | pub use crate::feature_probe::FeatureProbe; 10 | pub use crate::sync::SyncType; 11 | pub use crate::user::FPUser; 12 | use headers::{Error, Header, HeaderName, HeaderValue}; 13 | use http::header::AUTHORIZATION; 14 | use lazy_static::lazy_static; 15 | use serde::{Deserialize, Serialize}; 16 | use std::fmt::Debug; 17 | use thiserror::Error; 18 | pub use url::Url; 19 | 20 | lazy_static! { 21 | pub(crate) static ref USER_AGENT: String = "Rust/".to_owned() + VERSION; 22 | } 23 | 24 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 25 | 26 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] 27 | #[serde(rename_all = "camelCase")] 28 | pub struct FPDetail { 29 | pub value: T, 30 | pub rule_index: Option, 31 | pub variation_index: Option, 32 | pub version: Option, 33 | pub reason: String, 34 | } 35 | 36 | #[non_exhaustive] 37 | #[derive(Debug, Error)] 38 | pub enum FPError { 39 | #[error("invalid json: {0} error: {1}")] 40 | JsonError(String, serde_json::Error), 41 | #[error("invalid url: {0}")] 42 | UrlError(String), 43 | #[error("http error: {0}")] 44 | HttpError(String), 45 | #[error("evaluation error")] 46 | EvalError, 47 | #[error("evaluation error: {0}")] 48 | EvalDetailError(String), 49 | #[error("internal error: {0}")] 50 | InternalError(String), 51 | } 52 | 53 | #[derive(Debug, Error)] 54 | enum PrerequisiteError { 55 | #[error("prerequisite depth overflow")] 56 | DepthOverflow, 57 | #[error("prerequisite not exist: {0}")] 58 | NotExist(String), 59 | } 60 | 61 | #[derive(Debug, Deserialize)] 62 | pub struct SdkAuthorization(pub String); 63 | 64 | impl SdkAuthorization { 65 | pub fn encode(&self) -> HeaderValue { 66 | HeaderValue::from_str(&self.0).expect("valid header value") 67 | } 68 | } 69 | 70 | impl Header for SdkAuthorization { 71 | fn name() -> &'static HeaderName { 72 | &AUTHORIZATION 73 | } 74 | 75 | fn decode<'i, I>(values: &mut I) -> Result 76 | where 77 | Self: Sized, 78 | I: Iterator, 79 | { 80 | match values.next() { 81 | Some(v) => match v.to_str() { 82 | Ok(s) => Ok(SdkAuthorization(s.to_owned())), 83 | Err(_) => Err(Error::invalid()), 84 | }, 85 | None => Err(Error::invalid()), 86 | } 87 | } 88 | 89 | fn encode>(&self, values: &mut E) { 90 | if let Ok(value) = HeaderValue::from_str(&self.0) { 91 | values.extend(std::iter::once(value)) 92 | } 93 | } 94 | } 95 | 96 | pub fn unix_timestamp() -> u128 { 97 | std::time::SystemTime::now() 98 | .duration_since(std::time::UNIX_EPOCH) 99 | .expect("Time went backwards!") 100 | .as_millis() 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use super::*; 106 | 107 | #[test] 108 | #[should_panic] 109 | fn test_encode_panic() { 110 | let v: Vec = vec![21, 20, 19, 18]; // not visible string 111 | let s = String::from_utf8(v).unwrap(); 112 | let auth = SdkAuthorization(s); 113 | let _ = auth.encode(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use reqwest::Client; 4 | use tracing::info; 5 | use url::Url; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct FPConfig { 9 | pub remote_url: Url, 10 | pub toggles_url: Option, 11 | pub events_url: Option, 12 | pub server_sdk_key: String, 13 | pub refresh_interval: Duration, 14 | pub http_client: Option, 15 | pub start_wait: Option, 16 | 17 | #[cfg(feature = "realtime")] 18 | pub realtime_url: Option, 19 | #[cfg(feature = "realtime")] 20 | pub realtime_path: Option, 21 | pub track_events: bool, 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | pub(crate) struct Config { 26 | pub toggles_url: Url, 27 | pub events_url: Url, 28 | pub server_sdk_key: String, 29 | pub refresh_interval: Duration, 30 | pub http_client: Option, 31 | pub start_wait: Option, 32 | pub track_events: bool, 33 | 34 | #[cfg(feature = "realtime")] 35 | pub realtime_url: Url, 36 | #[cfg(feature = "realtime")] 37 | pub realtime_path: String, 38 | pub max_prerequisites_deep: u8, 39 | } 40 | 41 | impl Default for FPConfig { 42 | fn default() -> Self { 43 | Self { 44 | server_sdk_key: "".to_owned(), 45 | remote_url: Url::parse("https://featureprobe.io/server").unwrap(), 46 | toggles_url: None, 47 | events_url: None, 48 | refresh_interval: Duration::from_secs(5), 49 | start_wait: None, 50 | http_client: None, 51 | 52 | #[cfg(feature = "realtime")] 53 | realtime_url: None, 54 | #[cfg(feature = "realtime")] 55 | realtime_path: None, 56 | track_events: true, 57 | } 58 | } 59 | } 60 | 61 | impl Default for Config { 62 | fn default() -> Self { 63 | Self { 64 | server_sdk_key: "".to_owned(), 65 | toggles_url: Url::parse("https://featureprobe.io/server/api/server-sdk/toggles") 66 | .unwrap(), 67 | events_url: Url::parse("https://featureprobe.io/server/api/events").unwrap(), 68 | track_events: true, 69 | refresh_interval: Duration::from_secs(60), 70 | start_wait: None, 71 | http_client: None, 72 | 73 | #[cfg(feature = "realtime")] 74 | realtime_url: Url::parse("https://featureprobe.io/server/realtime").unwrap(), 75 | #[cfg(feature = "realtime")] 76 | realtime_path: "/server/realtime".to_owned(), 77 | max_prerequisites_deep: 20, 78 | } 79 | } 80 | } 81 | 82 | impl FPConfig { 83 | pub(crate) fn build(&self) -> Config { 84 | info!("build_config from {:?}", self); 85 | let remote_url = self.remote_url.to_string(); 86 | let remote_url = match remote_url.ends_with('/') { 87 | true => remote_url, 88 | false => remote_url + "/", 89 | }; 90 | 91 | #[cfg(feature = "realtime")] 92 | let realtime_url = match &self.realtime_url { 93 | None => Url::parse(&(remote_url.clone() + "realtime")).expect("invalid realtime url"), 94 | Some(url) => url.to_owned(), 95 | }; 96 | 97 | #[cfg(feature = "realtime")] 98 | let realtime_path = match &self.realtime_path { 99 | Some(p) => p.to_owned(), 100 | None => realtime_url.path().to_owned(), 101 | }; 102 | 103 | let toggles_url = match &self.toggles_url { 104 | None => Url::parse(&(remote_url.clone() + "api/server-sdk/toggles")) 105 | .expect("invalid toggles url"), 106 | Some(url) => url.to_owned(), 107 | }; 108 | 109 | let events_url = match &self.events_url { 110 | None => Url::parse(&(remote_url + "api/events")).expect("invalid events url"), 111 | Some(url) => url.to_owned(), 112 | }; 113 | 114 | Config { 115 | toggles_url, 116 | events_url, 117 | server_sdk_key: self.server_sdk_key.clone(), 118 | refresh_interval: self.refresh_interval, 119 | start_wait: self.start_wait, 120 | http_client: self.http_client.clone(), 121 | track_events: self.track_events, 122 | #[cfg(feature = "realtime")] 123 | realtime_url, 124 | #[cfg(feature = "realtime")] 125 | realtime_path, 126 | ..Default::default() 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use feature_probe_server::{ 4 | http::{serve_http, FpHttpHandler, LocalFileHttpHandlerForTest}, 5 | realtime::RealtimeSocket, 6 | repo::SdkRepository, 7 | ServerConfig, 8 | }; 9 | use feature_probe_server_sdk::{FPConfig, FPUser, FeatureProbe, SyncType, Url}; 10 | use parking_lot::Mutex; 11 | 12 | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 13 | async fn integration_test() { 14 | // tracing_subscriber::fmt() 15 | // .with_env_filter( 16 | // "feature_probe_server_sdk=trace,integration=trace,socket=trace,engine=trace", 17 | // ) 18 | // .pretty() 19 | // .init(); 20 | 21 | let api_port = 19980; 22 | let server_port = 19990; 23 | let realtime_port = 19999; 24 | let realtime_path = "/".to_owned(); 25 | setup_server(api_port, server_port, realtime_port, realtime_path).await; 26 | 27 | let config = FPConfig { 28 | remote_url: Url::parse(&format!("http://127.0.0.1:{}", server_port)).unwrap(), 29 | server_sdk_key: "server-sdk-key1".to_owned(), 30 | refresh_interval: Duration::from_secs(2), 31 | start_wait: Some(Duration::from_secs(5)), 32 | #[cfg(feature = "realtime")] 33 | realtime_url: Some(Url::parse(&format!("http://127.0.0.1:{}", realtime_port)).unwrap()), 34 | ..Default::default() 35 | }; 36 | 37 | let mut fp = FeatureProbe::new(config); 38 | #[cfg(all(feature = "use_tokio", feature = "realtime"))] 39 | fp.sync_now(SyncType::Polling); 40 | let did_update = { 41 | let did_update = Arc::new(Mutex::new((false, false))); 42 | let did_update_clone = did_update.clone(); 43 | 44 | fp.set_update_callback(Box::new(move |_old, _new, t| { 45 | let mut lock = did_update_clone.lock(); 46 | match t { 47 | SyncType::Realtime => lock.0 = true, 48 | SyncType::Polling => lock.1 = true, 49 | }; 50 | })); 51 | 52 | did_update 53 | }; 54 | 55 | let user = FPUser::new(); 56 | 57 | tokio::time::sleep(Duration::from_millis(500)).await; 58 | 59 | assert!(fp.initialized()); 60 | 61 | let b = fp.bool_detail("bool_toggle", &user, false); 62 | assert!(b.value); 63 | 64 | tokio::time::sleep(Duration::from_millis(3000)).await; 65 | let lock = did_update.lock(); 66 | #[cfg(feature = "realtime")] 67 | assert!(lock.0); 68 | #[cfg(not(feature = "realtime"))] 69 | assert!(lock.1); 70 | } 71 | 72 | async fn setup_server(api_port: u16, server_port: u16, realtime_port: u16, realtime_path: String) { 73 | let mut mock_api = LocalFileHttpHandlerForTest::default(); 74 | mock_api.version_update = true; 75 | // mock fp api 76 | tokio::spawn(serve_http::( 77 | api_port, mock_api, 78 | )); 79 | 80 | let server_sdk_key = "server-sdk-key1".to_owned(); 81 | let client_sdk_key = "client-sdk-key1".to_owned(); 82 | 83 | tokio::time::sleep(Duration::from_secs(1)).await; 84 | 85 | // start fp server 86 | let toggles_url = format!("http://0.0.0.0:{}/api/server-sdk/toggles", api_port) 87 | .parse() 88 | .unwrap(); 89 | let events_url: Url = format!("http://0.0.0.0:{}/api/events", api_port) 90 | .parse() 91 | .unwrap(); 92 | let analysis_url = None; 93 | let refresh_interval = Duration::from_secs(1); 94 | let config = ServerConfig { 95 | toggles_url, 96 | server_port, 97 | realtime_port, 98 | realtime_path, 99 | refresh_interval, 100 | keys_url: None, 101 | analysis_url: None, 102 | events_url: events_url.clone(), 103 | client_sdk_key: Some(client_sdk_key.clone()), 104 | server_sdk_key: Some(server_sdk_key.clone()), 105 | }; 106 | let realtime_socket = RealtimeSocket::serve(config.realtime_port, &config.realtime_path); 107 | let repo = SdkRepository::new(config, realtime_socket); 108 | repo.sync(client_sdk_key, server_sdk_key, 1); 109 | let repo = Arc::new(repo); 110 | let feature_probe_server = FpHttpHandler { 111 | repo: repo.clone(), 112 | events_url, 113 | analysis_url, 114 | events_timeout: Duration::from_secs(1), 115 | http_client: Default::default(), 116 | }; 117 | tokio::spawn(serve_http::( 118 | server_port, 119 | feature_probe_server, 120 | )); 121 | tokio::time::sleep(Duration::from_secs(1)).await; 122 | } 123 | -------------------------------------------------------------------------------- /resources/fixtures/toggles.json: -------------------------------------------------------------------------------- 1 | { 2 | "toggles": { 3 | "toggle_1": { 4 | "key": "toggle_1", 5 | "enabled": true, 6 | "forClient": true, 7 | "version": 1, 8 | "disabledServe": { 9 | "select": 1 10 | }, 11 | "defaultServe": { 12 | "split": { 13 | "distribution": [ 14 | [ 15 | [ 16 | 0, 17 | 3333 18 | ] 19 | ], 20 | [ 21 | [ 22 | 3333, 23 | 6666 24 | ] 25 | ], 26 | [ 27 | [ 28 | 6666, 29 | 10000 30 | ] 31 | ] 32 | ], 33 | "bucketBy": "user_set_key", 34 | "salt": "some_salt" 35 | } 36 | }, 37 | "rules": [ 38 | { 39 | "serve": { 40 | "select": 0 41 | }, 42 | "conditions": [ 43 | { 44 | "type": "string", 45 | "subject": "city", 46 | "predicate": "is one of", 47 | "objects": [ 48 | "1", 49 | "2", 50 | "3" 51 | ] 52 | } 53 | ] 54 | }, 55 | { 56 | "serve": { 57 | "select": 1 58 | }, 59 | "conditions": [ 60 | { 61 | "type": "segment", 62 | "subject": "user", 63 | "predicate": "is in", 64 | "object": ["some_segment1-fjoaefjaam"] 65 | } 66 | ] 67 | } 68 | ], 69 | "variations": [ 70 | { 71 | "variation_0": "c2", 72 | "v": "v1" 73 | }, 74 | { 75 | "variation_1": "v2" 76 | }, 77 | { 78 | "variation_2": "v3" 79 | } 80 | ] 81 | }, 82 | "multi_condition_toggle": { 83 | "key": "multi_condition_toggle", 84 | "enabled": true, 85 | "forClient": true, 86 | "version": 1, 87 | "disabledServe": { 88 | "select": 1 89 | }, 90 | "defaultServe": { 91 | "select": 1 92 | }, 93 | "rules": [ 94 | { 95 | "serve": { 96 | "select": 0 97 | }, 98 | "conditions": [ 99 | { 100 | "type": "string", 101 | "subject": "city", 102 | "predicate": "is one of", 103 | "objects": [ 104 | "1", 105 | "2", 106 | "3" 107 | ] 108 | }, 109 | { 110 | "type": "string", 111 | "subject": "os", 112 | "predicate": "is one of", 113 | "objects": [ 114 | "mac", 115 | "linux" 116 | ] 117 | } 118 | ] 119 | } 120 | ], 121 | "variations": [ 122 | { 123 | "variation_0": "" 124 | }, 125 | { 126 | "disabled_key": "disabled_value" 127 | } 128 | ] 129 | }, 130 | "disabled_toggle": { 131 | "key": "disabled_toggle", 132 | "enabled": false, 133 | "version": 1, 134 | "disabledServe": { 135 | "select": 1 136 | }, 137 | "defaultServe": { 138 | "select": 0 139 | }, 140 | "rules": [], 141 | "variations": [ 142 | {}, 143 | { 144 | "disabled_key": "disabled_value" 145 | } 146 | ] 147 | } 148 | } 149 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 FeatureProbe 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /resources/fixtures/repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "debugUntilTime": 1681289908000, 4 | "segments": { 5 | "some_segment1-fjoaefjaam": { 6 | "key": "some_segment1", 7 | "uniqueId": "some_segment1-fjoaefjaam", 8 | "version": 2, 9 | "rules": [ 10 | { 11 | "conditions": [ 12 | { 13 | "type": "string", 14 | "subject": "city", 15 | "predicate": "is one of", 16 | "objects": [ 17 | "4" 18 | ] 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | }, 25 | "toggles": { 26 | "bool_toggle": { 27 | "key": "bool_toggle", 28 | "enabled": true, 29 | "forClient": true, 30 | "version": 1, 31 | "disabledServe": { 32 | "select": 1 33 | }, 34 | "defaultServe": { 35 | "select": 0 36 | }, 37 | "rules": [ 38 | { 39 | "serve": { 40 | "select": 0 41 | }, 42 | "conditions": [ 43 | { 44 | "type": "string", 45 | "subject": "city", 46 | "predicate": "is one of", 47 | "objects": [ 48 | "1", 49 | "2", 50 | "3" 51 | ] 52 | } 53 | ] 54 | }, 55 | { 56 | "serve": { 57 | "select": 1 58 | }, 59 | "conditions": [ 60 | { 61 | "type": "segment", 62 | "subject": "user", 63 | "predicate": "is in", 64 | "objects": [ 65 | "some_segment1-fjoaefjaam" 66 | ] 67 | } 68 | ] 69 | } 70 | ], 71 | "variations": [ 72 | true, 73 | false 74 | ] 75 | }, 76 | "number_toggle": { 77 | "key": "number_toggle", 78 | "forClient": true, 79 | "enabled": true, 80 | "version": 1, 81 | "disabledServe": { 82 | "select": 1 83 | }, 84 | "defaultServe": { 85 | "select": 0 86 | }, 87 | "rules": [ 88 | { 89 | "serve": { 90 | "select": 0 91 | }, 92 | "conditions": [ 93 | { 94 | "type": "string", 95 | "subject": "city", 96 | "predicate": "is one of", 97 | "objects": [ 98 | "1", 99 | "2", 100 | "3" 101 | ] 102 | } 103 | ] 104 | }, 105 | { 106 | "serve": { 107 | "select": 1 108 | }, 109 | "conditions": [ 110 | { 111 | "type": "segment", 112 | "subject": "user", 113 | "predicate": "is in", 114 | "objects": [ 115 | "some_segment1-fjoaefjaam" 116 | ] 117 | } 118 | ] 119 | } 120 | ], 121 | "variations": [ 122 | 1, 123 | 2 124 | ] 125 | }, 126 | "string_toggle": { 127 | "key": "string_toggle", 128 | "forClient": true, 129 | "enabled": true, 130 | "version": 1, 131 | "disabledServe": { 132 | "select": 1 133 | }, 134 | "defaultServe": { 135 | "select": 0 136 | }, 137 | "rules": [ 138 | { 139 | "serve": { 140 | "select": 0 141 | }, 142 | "conditions": [ 143 | { 144 | "type": "string", 145 | "subject": "city", 146 | "predicate": "is one of", 147 | "objects": [ 148 | "1", 149 | "2", 150 | "3" 151 | ] 152 | } 153 | ] 154 | }, 155 | { 156 | "serve": { 157 | "select": 1 158 | }, 159 | "conditions": [ 160 | { 161 | "type": "segment", 162 | "subject": "user", 163 | "predicate": "is in", 164 | "objects": [ 165 | "some_segment1-fjoaefjaam" 166 | ] 167 | } 168 | ] 169 | } 170 | ], 171 | "variations": [ 172 | "1", 173 | "2" 174 | ] 175 | }, 176 | "json_toggle": { 177 | "key": "json_toggle", 178 | "enabled": true, 179 | "forClient": true, 180 | "version": 1, 181 | "disabledServe": { 182 | "select": 1 183 | }, 184 | "defaultServe": { 185 | "split": { 186 | "distribution": [ 187 | [ 188 | [ 189 | 0, 190 | 3333 191 | ] 192 | ], 193 | [ 194 | [ 195 | 3333, 196 | 6666 197 | ] 198 | ], 199 | [ 200 | [ 201 | 6666, 202 | 10000 203 | ] 204 | ] 205 | ], 206 | "salt": "some_salt" 207 | } 208 | }, 209 | "rules": [ 210 | { 211 | "serve": { 212 | "select": 0 213 | }, 214 | "conditions": [ 215 | { 216 | "type": "string", 217 | "subject": "city", 218 | "predicate": "is one of", 219 | "objects": [ 220 | "1", 221 | "2", 222 | "3" 223 | ] 224 | } 225 | ] 226 | }, 227 | { 228 | "serve": { 229 | "select": 1 230 | }, 231 | "conditions": [ 232 | { 233 | "type": "segment", 234 | "subject": "user", 235 | "predicate": "is in", 236 | "objects": [ 237 | "some_segment1-fjoaefjaam" 238 | ] 239 | } 240 | ] 241 | } 242 | ], 243 | "variations": [ 244 | { 245 | "variation_0": "c2", 246 | "v": "v1" 247 | }, 248 | { 249 | "variation_1": "v2" 250 | }, 251 | { 252 | "variation_2": "v3" 253 | } 254 | ] 255 | }, 256 | "multi_condition_toggle": { 257 | "key": "multi_condition_toggle", 258 | "enabled": true, 259 | "forClient": true, 260 | "version": 1, 261 | "disabledServe": { 262 | "select": 1 263 | }, 264 | "defaultServe": { 265 | "select": 1 266 | }, 267 | "rules": [ 268 | { 269 | "serve": { 270 | "select": 0 271 | }, 272 | "conditions": [ 273 | { 274 | "type": "string", 275 | "subject": "city", 276 | "predicate": "is one of", 277 | "objects": [ 278 | "1", 279 | "2", 280 | "3" 281 | ] 282 | }, 283 | { 284 | "type": "string", 285 | "subject": "os", 286 | "predicate": "is one of", 287 | "objects": [ 288 | "mac", 289 | "linux" 290 | ] 291 | } 292 | ] 293 | } 294 | ], 295 | "variations": [ 296 | { 297 | "variation_0": "" 298 | }, 299 | { 300 | "disabled_key": "disabled_value" 301 | } 302 | ] 303 | }, 304 | "disabled_toggle": { 305 | "key": "disabled_toggle", 306 | "enabled": false, 307 | "forClient": true, 308 | "version": 1, 309 | "disabledServe": { 310 | "select": 1 311 | }, 312 | "defaultServe": { 313 | "select": 0 314 | }, 315 | "rules": [], 316 | "variations": [ 317 | {}, 318 | { 319 | "disabled_key": "disabled_value" 320 | } 321 | ] 322 | }, 323 | "server_toggle": { 324 | "key": "server_toggle", 325 | "enabled": false, 326 | "forClient": false, 327 | "version": 1, 328 | "disabledServe": { 329 | "select": 1 330 | }, 331 | "defaultServe": { 332 | "select": 0 333 | }, 334 | "rules": [], 335 | "variations": [ 336 | {}, 337 | { 338 | "disabled_key": "disabled_value" 339 | } 340 | ] 341 | }, 342 | "not_in_segment": { 343 | "key": "not_in_segment", 344 | "enabled": true, 345 | "forClient": false, 346 | "version": 1, 347 | "disabledServe": { 348 | "select": 0 349 | }, 350 | "defaultServe": { 351 | "select": 0 352 | }, 353 | "rules": [ 354 | { 355 | "serve": { 356 | "select": 1 357 | }, 358 | "conditions": [ 359 | { 360 | "type": "segment", 361 | "subject": "user", 362 | "predicate": "is not in", 363 | "objects": [ 364 | "some_segment1-fjoaefjaam" 365 | ] 366 | } 367 | ] 368 | } 369 | ], 370 | "variations": [ 371 | {}, 372 | { 373 | "not_in": true 374 | } 375 | ] 376 | }, 377 | "prerequisite_toggle": { 378 | "key": "prerequisite_toggle", 379 | "enabled": true, 380 | "forClient": false, 381 | "version": 1, 382 | "disabledServe": { 383 | "select": 0 384 | }, 385 | "defaultServe": { 386 | "select": 1 387 | }, 388 | "rules": [ 389 | { 390 | "serve": { 391 | "select": 2 392 | }, 393 | "conditions": [ 394 | { 395 | "type": "segment", 396 | "subject": "user", 397 | "predicate": "is in", 398 | "objects": [ 399 | "some_segment1-fjoaefjaam" 400 | ] 401 | } 402 | ] 403 | } 404 | ], 405 | "prerequisites": [ 406 | { 407 | "key": "bool_toggle", 408 | "value": false 409 | }, 410 | { 411 | "key": "string_toggle", 412 | "value": "2" 413 | }, 414 | { 415 | "key": "number_toggle", 416 | "value": 2 417 | } 418 | ], 419 | "variations": [ 420 | { 421 | "0": "0" 422 | }, 423 | { 424 | "1": "1" 425 | }, 426 | { 427 | "2": "2" 428 | } 429 | ] 430 | }, 431 | "prerequisite_toggle_not_exist": { 432 | "key": "prerequisite_toggle_not_exist", 433 | "enabled": true, 434 | "forClient": false, 435 | "version": 1, 436 | "disabledServe": { 437 | "select": 0 438 | }, 439 | "defaultServe": { 440 | "select": 1 441 | }, 442 | "rules": [ 443 | { 444 | "serve": { 445 | "select": 2 446 | }, 447 | "conditions": [ 448 | { 449 | "type": "segment", 450 | "subject": "user", 451 | "predicate": "is in", 452 | "objects": [ 453 | "some_segment1-fjoaefjaam" 454 | ] 455 | } 456 | ] 457 | } 458 | ], 459 | "prerequisites": [ 460 | { 461 | "key": "bool_toggle_not_exist", 462 | "value": true 463 | } 464 | ], 465 | "variations": [ 466 | { 467 | "0": "0" 468 | }, 469 | { 470 | "1": "1" 471 | }, 472 | { 473 | "2": "2" 474 | } 475 | ] 476 | }, 477 | "prerequisite_toggle_not_match": { 478 | "key": "prerequisite_toggle_not_match", 479 | "enabled": true, 480 | "forClient": false, 481 | "version": 1, 482 | "disabledServe": { 483 | "select": 0 484 | }, 485 | "defaultServe": { 486 | "select": 1 487 | }, 488 | "rules": [ 489 | { 490 | "serve": { 491 | "select": 2 492 | }, 493 | "conditions": [ 494 | { 495 | "type": "segment", 496 | "subject": "user", 497 | "predicate": "is in", 498 | "objects": [ 499 | "some_segment1-fjoaefjaam" 500 | ] 501 | } 502 | ] 503 | } 504 | ], 505 | "prerequisites": [ 506 | { 507 | "key": "bool_toggle", 508 | "value": true 509 | } 510 | ], 511 | "variations": [ 512 | { 513 | "0": "0" 514 | }, 515 | { 516 | "1": "1" 517 | }, 518 | { 519 | "2": "2" 520 | } 521 | ] 522 | } 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | use crate::FPError; 2 | use crate::Repository; 3 | use headers::HeaderValue; 4 | use parking_lot::{Mutex, RwLock}; 5 | use reqwest::{header::AUTHORIZATION, Client, Method}; 6 | use std::{sync::mpsc::sync_channel, time::Instant}; 7 | use std::{sync::Arc, time::Duration}; 8 | use tracing::trace; 9 | use tracing::{debug, error}; 10 | use url::Url; 11 | 12 | pub type UpdateCallback = Box; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Synchronizer { 16 | inner: Arc, 17 | } 18 | 19 | #[derive(Debug)] 20 | pub enum SyncType { 21 | Realtime, 22 | Polling, 23 | } 24 | 25 | struct Inner { 26 | toggles_url: Url, 27 | refresh_interval: Duration, 28 | auth: HeaderValue, 29 | client: Client, 30 | repo: Arc>, 31 | is_init: Arc>, 32 | update_callback: Arc>>, 33 | } 34 | 35 | impl std::fmt::Debug for Inner { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | f.debug_tuple("SynchronizerInner") 38 | .field(&self.toggles_url) 39 | .field(&self.refresh_interval) 40 | .field(&self.repo) 41 | .field(&self.is_init) 42 | .finish() 43 | } 44 | } 45 | 46 | //TODO: graceful shutdown 47 | impl Synchronizer { 48 | pub fn new( 49 | toggles_url: Url, 50 | refresh_interval: Duration, 51 | auth: HeaderValue, 52 | client: Client, 53 | repo: Arc>, 54 | ) -> Self { 55 | Self { 56 | inner: Arc::new(Inner { 57 | toggles_url, 58 | refresh_interval, 59 | auth, 60 | client, 61 | repo, 62 | is_init: Default::default(), 63 | update_callback: Arc::new(Mutex::new(None)), 64 | }), 65 | } 66 | } 67 | 68 | pub fn initialized(&self) -> bool { 69 | let lock = self.inner.is_init.read(); 70 | *lock 71 | } 72 | 73 | pub fn start_sync(&self, start_wait: Option, should_stop: Arc>) { 74 | let inner = self.inner.clone(); 75 | let (tx, rx) = sync_channel(1); 76 | let start = Instant::now(); 77 | let mut is_send = false; 78 | let interval_duration = inner.refresh_interval; 79 | let is_timeout = Self::init_timeout_fn(start_wait, interval_duration, start); 80 | 81 | tokio::spawn(async move { 82 | let mut interval = tokio::time::interval(inner.refresh_interval); 83 | loop { 84 | let result = inner.sync_now(SyncType::Polling).await; 85 | 86 | if let Some(r) = Self::should_send(result, &is_timeout, is_send) { 87 | is_send = true; 88 | let _ = tx.try_send(r); 89 | } 90 | 91 | if *should_stop.read() { 92 | break; 93 | } 94 | interval.tick().await; 95 | } 96 | }); 97 | 98 | if start_wait.is_some() { 99 | let _ = rx.recv(); 100 | } 101 | } 102 | 103 | pub fn set_update_callback(&mut self, update_callback: UpdateCallback) { 104 | let mut lock = self.inner.update_callback.lock(); 105 | *lock = Some(update_callback); 106 | } 107 | 108 | pub fn version(&self) -> Option { 109 | let repo = self.inner.repo.read(); 110 | repo.version 111 | } 112 | 113 | #[cfg(test)] 114 | pub fn repository(&self) -> Arc> { 115 | self.inner.repo.clone() 116 | } 117 | 118 | #[cfg(test)] 119 | fn notify_update(&self, old_repo: Repository, new_repo: Repository, t: SyncType) { 120 | self.inner.notify_update(old_repo, new_repo, t) 121 | } 122 | 123 | fn init_timeout_fn( 124 | start_wait: Option, 125 | interval: Duration, 126 | start: Instant, 127 | ) -> Option bool + Send>> { 128 | match start_wait { 129 | Some(timeout) => Some(Box::new(move || start.elapsed() + interval > timeout)), 130 | None => None, 131 | } 132 | } 133 | 134 | fn should_send( 135 | result: Result<(), FPError>, 136 | is_timeout: &Option bool + Send>>, 137 | is_send: bool, 138 | ) -> Option> { 139 | if let Some(is_timeout) = is_timeout { 140 | match result { 141 | Ok(_) if !is_send => { 142 | return Some(Ok(())); 143 | } 144 | Err(e) if !is_send && is_timeout() => { 145 | error!("sync error: {}", e); 146 | return Some(Err(e)); 147 | } 148 | Err(e) => error!("sync error: {}", e), 149 | _ => {} 150 | } 151 | } 152 | None 153 | } 154 | 155 | pub fn sync_now(&self, t: SyncType) { 156 | let slf = self.clone(); 157 | tokio::spawn(async move { slf.inner.sync_now(t).await }); 158 | } 159 | } 160 | 161 | impl Inner { 162 | pub async fn sync_now(&self, t: SyncType) -> Result<(), FPError> { 163 | use http::header::USER_AGENT; 164 | 165 | trace!("sync_now {:?} {:?}", self.auth, t); 166 | let mut request = self 167 | .client 168 | .request(Method::GET, self.toggles_url.clone()) 169 | .header(AUTHORIZATION, self.auth.clone()) 170 | .header(USER_AGENT, &*crate::USER_AGENT) 171 | .timeout(self.refresh_interval); 172 | 173 | { 174 | let repo = self.repo.read(); 175 | if let Some(version) = &repo.version { 176 | request = request.query(&[("version", &version.to_string())]); 177 | } 178 | } // drop repo lock 179 | 180 | //TODO: report failure 181 | match request.send().await { 182 | Err(e) => Err(FPError::HttpError(e.to_string())), 183 | Ok(resp) => match resp.text().await { 184 | Err(e) => Err(FPError::HttpError(e.to_string())), 185 | Ok(body) => match serde_json::from_str::(&body) { 186 | Err(e) => Err(FPError::JsonError(body, e)), 187 | Ok(r) => { 188 | // TODO: validate repo 189 | // TODO: diff change, notify subscriber 190 | debug!("sync success {:?}", r); 191 | let mut repo = self.repo.write(); 192 | if r.version > repo.version { 193 | let old = (*repo).clone(); 194 | let new = r.clone(); 195 | *repo = r; 196 | self.notify_update(old, new, t); 197 | } 198 | let mut is_init = self.is_init.write(); 199 | *is_init = true; 200 | Ok(()) 201 | } 202 | }, 203 | }, 204 | } 205 | } 206 | 207 | fn notify_update(&self, old_repo: Repository, new_repo: Repository, t: SyncType) { 208 | let lock = self.update_callback.lock(); 209 | if let Some(cb) = &*lock { 210 | cb(old_repo, new_repo, t) 211 | } 212 | } 213 | } 214 | 215 | #[cfg(test)] 216 | mod tests { 217 | use super::*; 218 | use crate::SdkAuthorization; 219 | use axum::{routing::get, Json, Router, TypedHeader}; 220 | use headers::UserAgent; 221 | use std::{fs, net::SocketAddr, path::PathBuf, sync::mpsc::channel}; 222 | 223 | #[test] 224 | fn test_update_callback() { 225 | let mut syncer = build_synchronizer(9000); 226 | let (tx, rx) = channel(); 227 | 228 | syncer.set_update_callback(Box::new(move |_old, _new, _| tx.send(()).unwrap())); 229 | let old = Repository::default(); 230 | let new = Repository::default(); 231 | syncer.notify_update(old, new, SyncType::Polling); 232 | 233 | assert!(rx.try_recv().is_ok()) 234 | } 235 | 236 | #[tokio::test] 237 | async fn test_init_timeout_fn() { 238 | let now = Instant::now(); 239 | let now = now - Duration::from_millis(10); 240 | 241 | let is_timeout_fn = Synchronizer::init_timeout_fn(None, Duration::from_millis(1), now); 242 | assert!(is_timeout_fn.is_none()); 243 | 244 | let is_timeout_fn = Synchronizer::init_timeout_fn( 245 | Some(Duration::from_millis(20)), 246 | Duration::from_millis(1), 247 | now, 248 | ); 249 | assert!(!is_timeout_fn.unwrap()()); 250 | 251 | let is_timeout_fn = Synchronizer::init_timeout_fn( 252 | Some(Duration::from_millis(5)), 253 | Duration::from_millis(1), 254 | now, 255 | ); 256 | assert!(is_timeout_fn.unwrap()()); 257 | } 258 | 259 | #[test] 260 | fn test_should_send() { 261 | let is_timeout_fn = None; 262 | let r = Synchronizer::should_send(Ok(()), &is_timeout_fn, false); 263 | assert!(r.is_none(), "no need send because not set timeout"); 264 | 265 | let is_timeout_fn: Option bool + Send>> = Some(Box::new(|| false)); 266 | let r = Synchronizer::should_send(Ok(()), &is_timeout_fn, false); 267 | assert!(r.is_some(), "need send because not timeout, and return Ok"); 268 | let r = r.unwrap(); 269 | assert!(r.is_ok()); 270 | 271 | let is_timeout_fn: Option bool + Send>> = Some(Box::new(|| false)); 272 | let r = Synchronizer::should_send(Ok(()), &is_timeout_fn, true); 273 | assert!( 274 | r.is_none(), 275 | "no need send because not timeout, and return error, wait next loop" 276 | ); 277 | 278 | let is_timeout_fn: Option bool + Send>> = Some(Box::new(|| false)); 279 | let is_send = true; 280 | let r = Synchronizer::should_send( 281 | Err(FPError::InternalError("unknown".to_owned())), 282 | &is_timeout_fn, 283 | is_send, 284 | ); 285 | assert!(r.is_none(), "no need send because already send before"); 286 | 287 | let is_timeout_fn: Option bool + Send>> = Some(Box::new(|| true)); 288 | let r = Synchronizer::should_send( 289 | Err(FPError::InternalError("unknown".to_owned())), 290 | &is_timeout_fn, 291 | is_send, 292 | ); 293 | assert!(r.is_none(), "no need send because already send before"); 294 | 295 | let is_send = false; 296 | let is_timeout_fn: Option bool + Send>> = Some(Box::new(|| true)); 297 | let r = Synchronizer::should_send( 298 | Err(FPError::InternalError("unknown".to_owned())), 299 | &is_timeout_fn, 300 | is_send, 301 | ); 302 | assert!(r.is_some(), "need send because already timeout"); 303 | let r = r.unwrap(); 304 | assert!(r.is_err()); 305 | } 306 | 307 | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 308 | async fn test_sync() { 309 | // let _ = tracing_subscriber::fmt().init(); 310 | 311 | let port = 9009; 312 | setup_mock_api(port).await; 313 | let syncer = build_synchronizer(port); 314 | let should_stop = Arc::new(RwLock::new(false)); 315 | syncer.start_sync(Some(Duration::from_secs(5)), should_stop); 316 | 317 | let repo = syncer.repository(); 318 | let repo = repo.read(); 319 | assert!(!repo.toggles.is_empty()); 320 | assert!(syncer.initialized()); 321 | } 322 | 323 | fn build_synchronizer(port: u16) -> Synchronizer { 324 | let toggles_url = 325 | Url::parse(&format!("http://127.0.0.1:{}/api/server-sdk/toggles", port)).unwrap(); 326 | let refresh_interval = Duration::from_secs(10); 327 | let auth = SdkAuthorization("sdk-key".to_owned()).encode(); 328 | Synchronizer { 329 | inner: Arc::new(Inner { 330 | toggles_url, 331 | refresh_interval, 332 | auth, 333 | client: Default::default(), 334 | repo: Default::default(), 335 | is_init: Default::default(), 336 | update_callback: Default::default(), 337 | }), 338 | } 339 | } 340 | 341 | async fn setup_mock_api(port: u16) { 342 | let app = Router::new().route("/api/server-sdk/toggles", get(server_sdk_toggles)); 343 | let addr = SocketAddr::from(([0, 0, 0, 0], port)); 344 | tokio::spawn(async move { 345 | let _ = axum::Server::bind(&addr) 346 | .serve(app.into_make_service()) 347 | .await; 348 | }); 349 | tokio::time::sleep(Duration::from_millis(100)).await; 350 | } 351 | 352 | async fn server_sdk_toggles( 353 | TypedHeader(SdkAuthorization(sdk_key)): TypedHeader, 354 | TypedHeader(user_agent): TypedHeader, 355 | ) -> Json { 356 | assert_eq!(sdk_key, "sdk-key"); 357 | assert!(!user_agent.to_string().is_empty()); 358 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 359 | path.push("resources/fixtures/repo.json"); 360 | let json_str = fs::read_to_string(path).unwrap(); 361 | let repo = serde_json::from_str::(&json_str).unwrap(); 362 | repo.into() 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/feature_probe.rs: -------------------------------------------------------------------------------- 1 | use crate::sync::SyncType; 2 | use crate::{ 3 | config::Config, 4 | evaluate::{EvalDetail, Repository}, 5 | }; 6 | use crate::{sync::Synchronizer, FPConfig}; 7 | use crate::{sync::UpdateCallback, user::FPUser}; 8 | use crate::{FPDetail, SdkAuthorization, Toggle}; 9 | use event::event::AccessEvent; 10 | use event::event::CustomEvent; 11 | use event::event::DebugEvent; 12 | use event::event::Event; 13 | use event::recorder::unix_timestamp; 14 | use event::recorder::EventRecorder; 15 | use feature_probe_event as event; 16 | #[cfg(feature = "realtime")] 17 | use futures_util::FutureExt; 18 | use parking_lot::RwLock; 19 | use serde_json::Value; 20 | #[cfg(feature = "realtime")] 21 | use socketio_rs::Client; 22 | use std::collections::HashMap; 23 | use std::fmt::Debug; 24 | use std::sync::Arc; 25 | use tracing::{trace, warn}; 26 | 27 | #[cfg(feature = "realtime")] 28 | type SocketCallback = std::pin::Pin + Send>>; 29 | 30 | #[derive(Default, Clone)] 31 | pub struct FeatureProbe { 32 | repo: Arc>, 33 | syncer: Option, 34 | event_recorder: Option, 35 | config: Config, 36 | should_stop: Arc>, 37 | #[cfg(feature = "realtime")] 38 | socket: Option, 39 | } 40 | 41 | impl Debug for FeatureProbe { 42 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 43 | f.debug_tuple("FeatureProbe") 44 | .field(&self.repo) 45 | .field(&self.syncer) 46 | .field(&self.config) 47 | .field(&self.should_stop) 48 | .finish() 49 | } 50 | } 51 | 52 | impl FeatureProbe { 53 | pub fn new(config: FPConfig) -> Self { 54 | let config = config.build(); 55 | let mut slf = Self { 56 | config, 57 | ..Default::default() 58 | }; 59 | 60 | slf.start(); 61 | slf 62 | } 63 | 64 | pub fn new_for_test(toggle: &str, value: Value) -> Self { 65 | let mut toggles = HashMap::new(); 66 | toggles.insert(toggle.to_owned(), value); 67 | FeatureProbe::new_for_tests(toggles) 68 | } 69 | 70 | pub fn new_for_tests(toggles: HashMap) -> Self { 71 | let mut repo = Repository::default(); 72 | for (key, val) in toggles { 73 | repo.toggles 74 | .insert(key.clone(), Toggle::new_for_test(key, val)); 75 | } 76 | 77 | Self { 78 | repo: Arc::new(RwLock::new(repo)), 79 | ..Default::default() 80 | } 81 | } 82 | 83 | pub fn bool_value(&self, toggle: &str, user: &FPUser, default: bool) -> bool { 84 | self.generic_eval(toggle, user, default, false, |v| v.as_bool()) 85 | .value 86 | } 87 | 88 | pub fn string_value(&self, toggle: &str, user: &FPUser, default: String) -> String { 89 | self.generic_eval(toggle, user, default, false, |v| { 90 | v.as_str().map(|s| s.to_owned()) 91 | }) 92 | .value 93 | } 94 | 95 | pub fn number_value(&self, toggle: &str, user: &FPUser, default: f64) -> f64 { 96 | self.generic_eval(toggle, user, default, false, |v| v.as_f64()) 97 | .value 98 | } 99 | 100 | pub fn json_value(&self, toggle: &str, user: &FPUser, default: Value) -> Value { 101 | self.generic_eval(toggle, user, default, false, Some).value 102 | } 103 | 104 | pub fn bool_detail(&self, toggle: &str, user: &FPUser, default: bool) -> FPDetail { 105 | self.generic_eval(toggle, user, default, true, |v| v.as_bool()) 106 | } 107 | 108 | pub fn string_detail(&self, toggle: &str, user: &FPUser, default: String) -> FPDetail { 109 | self.generic_eval(toggle, user, default, true, |v| { 110 | v.as_str().map(|x| x.to_owned()) 111 | }) 112 | } 113 | 114 | pub fn number_detail(&self, toggle: &str, user: &FPUser, default: f64) -> FPDetail { 115 | self.generic_eval(toggle, user, default, true, |v| v.as_f64()) 116 | } 117 | 118 | pub fn json_detail(&self, toggle: &str, user: &FPUser, default: Value) -> FPDetail { 119 | self.generic_eval(toggle, user, default, true, Some) 120 | } 121 | 122 | pub fn track(&self, event_name: &str, user: &FPUser, value: Option) { 123 | let recorder = match self.event_recorder.as_ref() { 124 | None => { 125 | warn!("Event Recorder no ready."); 126 | return; 127 | } 128 | Some(recorder) => recorder, 129 | }; 130 | let event = CustomEvent { 131 | kind: "custom".to_string(), 132 | time: unix_timestamp(), 133 | user: user.key(), 134 | name: event_name.to_string(), 135 | value, 136 | }; 137 | recorder.record_event(Event::CustomEvent(event)); 138 | } 139 | 140 | pub fn new_with(server_key: String, repo: Repository) -> Self { 141 | Self { 142 | config: Config { 143 | server_sdk_key: server_key, 144 | ..Default::default() 145 | }, 146 | repo: Arc::new(RwLock::new(repo)), 147 | syncer: None, 148 | event_recorder: None, 149 | should_stop: Arc::new(RwLock::new(false)), 150 | #[cfg(feature = "realtime")] 151 | socket: None, 152 | } 153 | } 154 | 155 | pub fn close(&self) { 156 | trace!("closing featureprobe client"); 157 | if let Some(recorder) = &self.event_recorder { 158 | recorder.flush(); 159 | } 160 | let mut should_stop = self.should_stop.write(); 161 | *should_stop = true; 162 | } 163 | 164 | pub fn initialized(&self) -> bool { 165 | match &self.syncer { 166 | Some(s) => s.initialized(), 167 | None => false, 168 | } 169 | } 170 | 171 | pub fn set_update_callback(&mut self, update_callback: UpdateCallback) { 172 | if let Some(syncer) = &mut self.syncer { 173 | syncer.set_update_callback(update_callback) 174 | } 175 | } 176 | 177 | pub fn version(&self) -> Option { 178 | self.syncer.as_ref().map(|s| s.version()).flatten() 179 | } 180 | 181 | fn generic_eval( 182 | &self, 183 | toggle: &str, 184 | user: &FPUser, 185 | default: T, 186 | is_detail: bool, 187 | transform: fn(Value) -> Option, 188 | ) -> FPDetail { 189 | let (value, reason, detail) = match self.eval(toggle, user, is_detail) { 190 | None => ( 191 | default, 192 | Some(format!("Toggle:[{toggle}] not exist")), 193 | Default::default(), 194 | ), 195 | Some(mut d) => match d.value.take() { 196 | None => (default, None, d), // Serve error. 197 | Some(v) => match transform(v) { 198 | None => (default, Some("Value type mismatch.".to_string()), d), // Transform error. 199 | Some(typed_v) => (typed_v, None, d), 200 | }, 201 | }, 202 | }; 203 | 204 | FPDetail { 205 | value, 206 | reason: reason.unwrap_or(detail.reason), 207 | rule_index: detail.rule_index, 208 | variation_index: detail.variation_index, 209 | version: detail.version, 210 | } 211 | } 212 | 213 | fn eval(&self, toggle: &str, user: &FPUser, is_detail: bool) -> Option> { 214 | let repo = self.repo.read(); 215 | let debug_until_time = repo.debug_until_time; 216 | let detail = repo.toggles.get(toggle).map(|toggle| { 217 | toggle.eval( 218 | user, 219 | &repo.segments, 220 | &repo.toggles, 221 | is_detail, 222 | self.config.max_prerequisites_deep, 223 | debug_until_time, 224 | ) 225 | }); 226 | 227 | if let Some(recorder) = &self.event_recorder { 228 | let track_access_events = repo 229 | .toggles 230 | .get(toggle) 231 | .map(|t| t.track_access_events()) 232 | .unwrap_or(false); 233 | record_event( 234 | recorder.clone(), 235 | track_access_events, 236 | toggle, 237 | user, 238 | detail.clone(), 239 | debug_until_time, 240 | ) 241 | } 242 | 243 | detail.map(|mut d| { 244 | d.debug_until_time = debug_until_time; 245 | d 246 | }) 247 | } 248 | 249 | fn start(&mut self) { 250 | self.sync(); 251 | 252 | #[cfg(feature = "realtime")] 253 | self.connect_socket(); 254 | 255 | if self.config.track_events { 256 | self.flush_events(); 257 | } 258 | } 259 | 260 | fn sync(&mut self) { 261 | trace!("sync url {}", &self.config.toggles_url); 262 | let toggles_url = self.config.toggles_url.clone(); 263 | let refresh_interval = self.config.refresh_interval; 264 | let auth = SdkAuthorization(self.config.server_sdk_key.clone()).encode(); 265 | let repo = self.repo.clone(); 266 | let syncer = Synchronizer::new( 267 | toggles_url, 268 | refresh_interval, 269 | auth, 270 | self.config.http_client.clone().unwrap_or_default(), 271 | repo, 272 | ); 273 | self.syncer = Some(syncer.clone()); 274 | syncer.start_sync(self.config.start_wait, self.should_stop.clone()); 275 | } 276 | 277 | pub fn sync_now(&self, t: SyncType) { 278 | trace!("sync now url {}", &self.config.toggles_url); 279 | let syncer = match &self.syncer { 280 | Some(syncer) => syncer.clone(), 281 | None => return, 282 | }; 283 | syncer.sync_now(t); 284 | } 285 | 286 | #[cfg(feature = "realtime")] 287 | fn connect_socket(&mut self) { 288 | let mut slf = self.clone(); 289 | let slf2 = self.clone(); 290 | let nsp = self.config.realtime_path.clone(); 291 | tokio::spawn(async move { 292 | let url = slf.config.realtime_url; 293 | let server_sdk_key = slf.config.server_sdk_key.clone(); 294 | trace!("connect_socket {}", url); 295 | let client = socketio_rs::ClientBuilder::new(url.clone()) 296 | .namespace(&nsp) 297 | .on(socketio_rs::Event::Connect, move |_, socket, _| { 298 | Self::socket_on_connect(socket, server_sdk_key.clone()) 299 | }) 300 | .on( 301 | "update", 302 | move |payload: Option, _, _| { 303 | Self::socket_on_update(slf2.clone(), payload) 304 | }, 305 | ) 306 | .on("error", |err, _, _| { 307 | async move { tracing::error!("socket on error: {:#?}", err) }.boxed() 308 | }) 309 | .connect() 310 | .await; 311 | match client { 312 | Err(e) => tracing::error!("connect_socket error: {:?}", e), 313 | Ok(client) => slf.socket = Some(client), 314 | }; 315 | }); 316 | } 317 | 318 | #[cfg(feature = "realtime")] 319 | fn socket_on_connect(socket: socketio_rs::Socket, server_sdk_key: String) -> SocketCallback { 320 | let sdk_key = server_sdk_key; 321 | trace!("socket_on_connect: {:?}", sdk_key); 322 | async move { 323 | if let Err(e) = socket 324 | .emit("register", serde_json::json!({ "key": sdk_key })) 325 | .await 326 | { 327 | tracing::error!("register error: {:?}", e); 328 | } 329 | } 330 | .boxed() 331 | } 332 | 333 | #[cfg(feature = "realtime")] 334 | fn socket_on_update(slf: Self, payload: Option) -> SocketCallback { 335 | trace!("socket_on_update: {:?}", payload); 336 | async move { 337 | if let Some(syncer) = &slf.syncer { 338 | syncer.sync_now(SyncType::Realtime); 339 | } else { 340 | warn!("socket receive update event, but no synchronizer"); 341 | } 342 | } 343 | .boxed() 344 | } 345 | 346 | fn flush_events(&mut self) { 347 | trace!("flush_events"); 348 | let events_url = self.config.events_url.clone(); 349 | let flush_interval = self.config.refresh_interval; 350 | let auth = SdkAuthorization(self.config.server_sdk_key.clone()).encode(); 351 | let should_stop = self.should_stop.clone(); 352 | let event_recorder = EventRecorder::new( 353 | events_url, 354 | auth, 355 | (*crate::USER_AGENT).clone(), 356 | flush_interval, 357 | 100, 358 | should_stop, 359 | ); 360 | self.event_recorder = Some(event_recorder); 361 | } 362 | 363 | #[cfg(feature = "internal")] 364 | pub fn repo(&self) -> Arc> { 365 | self.repo.clone() 366 | } 367 | } 368 | 369 | fn record_event( 370 | recorder: EventRecorder, 371 | track_access_events: bool, 372 | toggle: &str, 373 | user: &FPUser, 374 | detail: Option>, 375 | debug_until_time: Option, 376 | ) { 377 | let toggle = toggle.to_owned(); 378 | let user = user.key(); 379 | let user_detail = serde_json::to_value(user.clone()).unwrap_or_default(); 380 | 381 | tokio::spawn(async move { 382 | let ts = unix_timestamp(); 383 | record_access( 384 | &recorder, 385 | &toggle, 386 | user.clone(), 387 | track_access_events, 388 | &detail, 389 | ts, 390 | ); 391 | record_debug( 392 | &recorder, 393 | &toggle, 394 | user, 395 | user_detail, 396 | debug_until_time, 397 | &detail, 398 | ts, 399 | ); 400 | }); 401 | } 402 | 403 | fn record_access( 404 | recorder: &EventRecorder, 405 | toggle: &str, 406 | user: String, 407 | track_access_events: bool, 408 | detail: &Option>, 409 | ts: u128, 410 | ) -> Option<()> { 411 | let detail = detail.as_ref()?; 412 | let value = detail.value.as_ref()?; 413 | let event = AccessEvent { 414 | kind: "access".to_string(), 415 | time: ts, 416 | key: toggle.to_owned(), 417 | user, 418 | value: value.clone(), 419 | variation_index: detail.variation_index?, 420 | version: detail.version, 421 | rule_index: detail.rule_index, 422 | track_access_events, 423 | }; 424 | recorder.record_event(Event::AccessEvent(event)); 425 | None 426 | } 427 | 428 | #[allow(clippy::too_many_arguments)] 429 | fn record_debug( 430 | recorder: &EventRecorder, 431 | toggle: &str, 432 | user: String, 433 | user_detail: Value, 434 | debug_until_time: Option, 435 | detail: &Option>, 436 | ts: u128, 437 | ) -> Option<()> { 438 | let detail = detail.as_ref()?; 439 | let value = detail.value.as_ref()?; 440 | if let Some(debug_until_time) = debug_until_time { 441 | if debug_until_time as u128 >= ts { 442 | let debug = DebugEvent { 443 | kind: "debug".to_string(), 444 | time: ts, 445 | key: toggle.to_owned(), 446 | user, 447 | user_detail, 448 | value: value.clone(), 449 | variation_index: detail.variation_index?, 450 | version: detail.version, 451 | rule_index: detail.rule_index, 452 | reason: Some(detail.reason.to_string()), 453 | }; 454 | recorder.record_event(Event::DebugEvent(debug)); 455 | } 456 | } 457 | None 458 | } 459 | 460 | #[cfg(test)] 461 | mod tests { 462 | use serde_json::json; 463 | 464 | use super::*; 465 | use crate::FPError; 466 | use std::fs; 467 | use std::path::PathBuf; 468 | 469 | #[test] 470 | fn test_feature_probe_bool() { 471 | let json = load_local_json("resources/fixtures/repo.json"); 472 | let fp = FeatureProbe::new_with("secret key".to_string(), json.unwrap()); 473 | let u = FPUser::new().with("name", "bob").with("city", "1"); 474 | 475 | assert!(fp.bool_value("bool_toggle", &u, false)); 476 | assert!(fp.bool_detail("bool_toggle", &u, false).value); 477 | } 478 | 479 | #[test] 480 | fn test_feature_probe_number() { 481 | let json = load_local_json("resources/fixtures/repo.json"); 482 | let fp = FeatureProbe::new_with("secret key".to_string(), json.unwrap()); 483 | let u = FPUser::new().with("name", "bob").with("city", "1"); 484 | 485 | assert_eq!(fp.number_value("number_toggle", &u, 0.0), 1.0); 486 | assert_eq!(fp.number_detail("number_toggle", &u, 0.0).value, 1.0); 487 | } 488 | 489 | #[test] 490 | fn test_feature_probe_string() { 491 | let json = load_local_json("resources/fixtures/repo.json"); 492 | let fp = FeatureProbe::new_with("secret key".to_string(), json.unwrap()); 493 | let u = FPUser::new().with("name", "bob").with("city", "1"); 494 | 495 | assert_eq!( 496 | fp.string_value("string_toggle", &u, "".to_string()), 497 | "1".to_owned() 498 | ); 499 | assert_eq!( 500 | fp.string_detail("string_toggle", &u, "".to_owned()).value, 501 | "1".to_owned() 502 | ); 503 | } 504 | 505 | #[test] 506 | fn test_feature_probe_json() { 507 | let json = load_local_json("resources/fixtures/repo.json"); 508 | let fp = FeatureProbe::new_with("secret key".to_string(), json.unwrap()); 509 | let u = FPUser::new().with("name", "bob").with("city", "1"); 510 | 511 | assert!(fp 512 | .json_value("json_toggle", &u, json!("")) 513 | .get("variation_0") 514 | .is_some()); 515 | assert!(fp 516 | .json_detail("json_toggle", &u, json!("")) 517 | .value 518 | .get("variation_0") 519 | .is_some()); 520 | } 521 | 522 | #[test] 523 | fn test_feature_probe_none_exist_toggle() { 524 | let json = load_local_json("resources/fixtures/repo.json"); 525 | let fp = FeatureProbe::new_with("secret key".to_string(), json.unwrap()); 526 | let u = FPUser::new(); 527 | 528 | assert!(fp.bool_value("none_exist_toggle", &u, true)); 529 | let d = fp.bool_detail("none_exist_toggle", &u, true); 530 | assert!(d.value); 531 | assert_eq!(d.rule_index, None); 532 | } 533 | 534 | #[test] 535 | fn test_for_ut() { 536 | let fp = FeatureProbe::new_for_test("toggle_1", Value::Bool(false)); 537 | let u = FPUser::new(); 538 | assert!(!fp.bool_value("toggle_1", &u, true)); 539 | 540 | let mut toggles: HashMap = HashMap::new(); 541 | toggles.insert("toggle_2".to_owned(), json!(12.5)); 542 | toggles.insert("toggle_3".to_owned(), json!("value")); 543 | let fp = FeatureProbe::new_for_tests(toggles); 544 | assert_eq!(fp.number_value("toggle_2", &u, 20.0), 12.5); 545 | assert_eq!(fp.string_value("toggle_3", &u, "val".to_owned()), "value"); 546 | } 547 | 548 | #[test] 549 | fn test_feature_probe_record_debug() { 550 | let json = load_local_json("resources/fixtures/repo.json"); 551 | let mut repo = json.unwrap(); 552 | repo.debug_until_time = Some(unix_timestamp() as u64 + 60 * 1000); 553 | let fp = FeatureProbe::new_with("secret key".to_string(), repo); 554 | let u = FPUser::new().with("name", "bob").with("city", "1"); 555 | fp.bool_value("bool_toggle", &u, false); 556 | } 557 | 558 | fn load_local_json(file: &str) -> Result { 559 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 560 | path.push(file); 561 | let json_str = fs::read_to_string(path).unwrap(); 562 | let repo = crate::evaluate::load_json(&json_str); 563 | assert!(repo.is_ok(), "err is {:?}", repo); 564 | repo 565 | } 566 | } 567 | 568 | #[cfg(test)] 569 | mod server_sdk_contract_tests { 570 | use crate::{FPDetail, FPError, FPUser, FeatureProbe, Repository}; 571 | use serde::{Deserialize, Serialize}; 572 | use serde_json::Value; 573 | use std::fmt::Debug; 574 | use std::fs; 575 | use std::path::PathBuf; 576 | use std::string::String; 577 | 578 | #[allow(dead_code)] 579 | pub(crate) fn load_tests_json(json_str: &str) -> Result { 580 | serde_json::from_str::(json_str) 581 | .map_err(|e| FPError::JsonError(json_str.to_owned(), e)) 582 | } 583 | 584 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq)] 585 | #[serde(rename_all = "camelCase")] 586 | pub struct Tests { 587 | pub(crate) tests: Vec, 588 | } 589 | 590 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq)] 591 | pub struct Scenario { 592 | pub(crate) scenario: String, 593 | pub(crate) cases: Vec, 594 | pub(crate) fixture: Repository, 595 | } 596 | 597 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)] 598 | #[serde(rename_all = "camelCase")] 599 | pub struct Case { 600 | pub(crate) name: String, 601 | pub(crate) user: User, 602 | pub(crate) function: Function, 603 | pub(crate) expect_result: ExpectResult, 604 | } 605 | 606 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)] 607 | #[serde(rename_all = "camelCase")] 608 | pub struct User { 609 | pub(crate) key: String, 610 | pub(crate) custom_values: Vec, 611 | } 612 | 613 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)] 614 | pub struct KeyValue { 615 | pub(crate) key: String, 616 | pub(crate) value: String, 617 | } 618 | 619 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)] 620 | pub struct Function { 621 | pub(crate) name: String, 622 | pub(crate) toggle: String, 623 | pub(crate) default: Value, 624 | } 625 | 626 | #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)] 627 | #[serde(rename_all = "camelCase")] 628 | pub struct ExpectResult { 629 | pub(crate) value: Value, 630 | pub(crate) reason: Option, 631 | pub(crate) rule_index: Option, 632 | pub(crate) no_rule_index: Option, 633 | pub(crate) version: Option, 634 | } 635 | 636 | #[test] 637 | fn test_contract() { 638 | let root = load_test_json("resources/fixtures/spec/spec/toggle_simple_spec.json"); 639 | assert!(root.is_ok()); 640 | 641 | for scenario in root.unwrap().tests { 642 | println!("scenario: {}", scenario.scenario); 643 | assert!(!scenario.cases.is_empty()); 644 | 645 | let fp = FeatureProbe::new_with("secret key".to_string(), scenario.fixture); 646 | 647 | for case in scenario.cases { 648 | println!(" case: {}", case.name); 649 | 650 | let mut user = FPUser::new().stable_rollout(case.user.key.clone()); 651 | for custom_value in &case.user.custom_values { 652 | user = user.with(custom_value.key.clone(), custom_value.value.clone()); 653 | } 654 | 655 | macro_rules! validate_value { 656 | ( $fun:ident, $default:expr, $expect:expr) => { 657 | let ret = fp.$fun(case.function.toggle.as_str(), &user, $default); 658 | assert_eq!(ret, $expect); 659 | }; 660 | } 661 | 662 | macro_rules! validate_detail { 663 | ( $fun:ident, $default:expr, $expect:expr) => { 664 | let ret = fp.$fun(case.function.toggle.as_str(), &user, $default); 665 | assert_eq!(ret.value, $expect); 666 | assert_detail(&case, ret); 667 | }; 668 | } 669 | 670 | match case.function.name.as_str() { 671 | "bool_value" => { 672 | validate_value!( 673 | bool_value, 674 | case.function.default.as_bool().unwrap(), 675 | case.expect_result.value.as_bool().unwrap() 676 | ); 677 | } 678 | "string_value" => { 679 | validate_value!( 680 | string_value, 681 | case.function.default.as_str().unwrap().to_string(), 682 | case.expect_result.value.as_str().unwrap().to_string() 683 | ); 684 | } 685 | "number_value" => { 686 | validate_value!( 687 | number_value, 688 | case.function.default.as_f64().unwrap(), 689 | case.expect_result.value.as_f64().unwrap() 690 | ); 691 | } 692 | "json_value" => { 693 | validate_value!( 694 | json_value, 695 | case.function.default, 696 | case.expect_result.value 697 | ); 698 | } 699 | "bool_detail" => { 700 | validate_detail!( 701 | bool_detail, 702 | case.function.default.as_bool().unwrap(), 703 | case.expect_result.value 704 | ); 705 | } 706 | "string_detail" => { 707 | validate_detail!( 708 | string_detail, 709 | case.function.default.as_str().unwrap().to_string(), 710 | case.expect_result.value 711 | ); 712 | } 713 | "number_detail" => { 714 | validate_detail!( 715 | number_detail, 716 | case.function.default.as_f64().unwrap(), 717 | case.expect_result.value 718 | ); 719 | } 720 | "json_detail" => { 721 | validate_detail!( 722 | json_detail, 723 | case.function.default.clone(), 724 | case.expect_result.value 725 | ); 726 | } 727 | _ => panic!("function name {} not found.", case.function.name), 728 | } 729 | } 730 | } 731 | } 732 | 733 | fn assert_detail(case: &Case, ret: FPDetail) { 734 | match &case.expect_result.reason { 735 | None => (), 736 | Some(r) => { 737 | assert!( 738 | ret.reason.contains(r.as_str()), 739 | "reason: \"{}\" does not contains \"{}\"", 740 | ret.reason.as_str(), 741 | r.as_str() 742 | ); 743 | } 744 | }; 745 | 746 | if case.expect_result.rule_index.is_some() { 747 | assert_eq!( 748 | case.expect_result.rule_index, ret.rule_index, 749 | "rule index not match" 750 | ); 751 | } 752 | 753 | if case.expect_result.no_rule_index.is_some() { 754 | assert!( 755 | case.expect_result.rule_index.is_none(), 756 | "should not have rule index." 757 | ); 758 | } 759 | 760 | if case.expect_result.version.is_some() { 761 | assert_eq!(case.expect_result.version, ret.version, "version not match"); 762 | } 763 | } 764 | 765 | fn load_test_json(file: &str) -> Result { 766 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 767 | path.push(file); 768 | let mut json_str = fs::read_to_string(path.clone()); 769 | if json_str.is_err() { 770 | use std::process::Command; 771 | Command::new("git") 772 | .args(["submodule", "init"]) 773 | .status() 774 | .expect("init"); 775 | Command::new("git") 776 | .args(["submodule", "update"]) 777 | .status() 778 | .expect("update"); 779 | json_str = fs::read_to_string(path); 780 | } 781 | assert!(json_str.is_ok(), 782 | "contract test resource not found, run `git submodule init && git submodule update` to fetch"); 783 | let tests = load_tests_json(&json_str.unwrap()); 784 | assert!(tests.is_ok(), "err is {:?}", tests); 785 | tests 786 | } 787 | } 788 | -------------------------------------------------------------------------------- /src/evaluate.rs: -------------------------------------------------------------------------------- 1 | use crate::user::FPUser; 2 | use crate::FPError; 3 | use crate::{unix_timestamp, PrerequisiteError}; 4 | use byteorder::{BigEndian, ReadBytesExt}; 5 | use regex::Regex; 6 | use semver::Version; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | use sha1::Digest; 10 | use std::string::String; 11 | use std::{collections::HashMap, str::FromStr}; 12 | use tracing::{info, warn}; 13 | 14 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 15 | #[serde(rename_all = "camelCase")] 16 | pub enum Serve { 17 | Select(usize), 18 | Split(Distribution), 19 | } 20 | 21 | #[derive(Serialize, Deserialize, Debug, Clone)] 22 | pub struct Variation { 23 | pub value: Value, 24 | pub index: usize, 25 | } 26 | 27 | impl Serve { 28 | pub fn select_variation(&self, eval_param: &EvalParams) -> Result { 29 | let variations = eval_param.variations; 30 | let index = match self { 31 | Serve::Select(i) => *i, 32 | Serve::Split(distribution) => distribution.find_index(eval_param)?, 33 | }; 34 | 35 | match variations.get(index) { 36 | None if eval_param.is_detail => Err(FPError::EvalDetailError(format!( 37 | "index {} overflow, variations count is {}", 38 | index, 39 | variations.len() 40 | ))), 41 | None => Err(FPError::EvalError), 42 | Some(v) => Ok(Variation { 43 | value: v.clone(), 44 | index, 45 | }), 46 | } 47 | } 48 | } 49 | 50 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 51 | struct BucketRange((u32, u32)); 52 | 53 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 54 | #[serde(rename_all = "camelCase")] 55 | pub struct Distribution { 56 | distribution: Vec>, 57 | bucket_by: Option, 58 | salt: Option, 59 | } 60 | 61 | impl Distribution { 62 | pub fn find_index(&self, eval_param: &EvalParams) -> Result { 63 | let user = eval_param.user; 64 | 65 | let hash_key = match &self.bucket_by { 66 | None => user.key(), 67 | Some(custom_key) => match user.get(custom_key) { 68 | None if eval_param.is_detail => { 69 | return Err(FPError::EvalDetailError(format!( 70 | "User with key:{:?} does not have attribute named: [{}]", 71 | user.key(), 72 | custom_key 73 | ))); 74 | } 75 | None => return Err(FPError::EvalError), 76 | Some(value) => value.to_owned(), 77 | }, 78 | }; 79 | 80 | let salt = match &self.salt { 81 | Some(s) if !s.is_empty() => s, 82 | _ => eval_param.key, 83 | }; 84 | 85 | let bucket_index = salt_hash(&hash_key, salt, 10000); 86 | 87 | let variation = self.distribution.iter().position(|ranges| { 88 | ranges.iter().any(|pair| { 89 | let (lower, upper) = pair.0; 90 | lower <= bucket_index && bucket_index < upper 91 | }) 92 | }); 93 | 94 | match variation { 95 | None if eval_param.is_detail => Err(FPError::EvalDetailError( 96 | "not find hash_bucket in distribution.".to_string(), 97 | )), 98 | None => Err(FPError::EvalError), 99 | Some(index) => Ok(index), 100 | } 101 | } 102 | } 103 | 104 | fn salt_hash(key: &str, salt: &str, bucket_size: u64) -> u32 { 105 | let size = 4; 106 | let mut hasher = sha1::Sha1::new(); 107 | let data = format!("{key}{salt}"); 108 | hasher.update(data); 109 | let hax_value = hasher.finalize(); 110 | let mut v = Vec::with_capacity(size); 111 | for i in (hax_value.len() - size)..hax_value.len() { 112 | v.push(hax_value[i]); 113 | } 114 | let mut v = v.as_slice(); 115 | let value = v.read_u32::().expect("can not be here"); 116 | value % bucket_size as u32 117 | } 118 | 119 | pub struct EvalParams<'a> { 120 | key: &'a str, 121 | is_detail: bool, 122 | user: &'a FPUser, 123 | variations: &'a [Value], 124 | segment_repo: &'a HashMap, 125 | toggle_repo: &'a HashMap, 126 | debug_until_time: Option, 127 | } 128 | 129 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default, Clone)] 130 | #[serde(rename_all = "camelCase")] 131 | pub struct EvalDetail { 132 | pub value: Option, 133 | pub rule_index: Option, 134 | pub track_access_events: Option, 135 | pub debug_until_time: Option, 136 | pub last_modified: Option, 137 | pub variation_index: Option, 138 | pub version: Option, 139 | pub reason: String, 140 | } 141 | 142 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 143 | #[serde(rename_all = "camelCase")] 144 | pub struct Prerequisites { 145 | pub key: String, 146 | pub value: Value, 147 | } 148 | 149 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 150 | #[serde(rename_all = "camelCase")] 151 | pub struct Toggle { 152 | key: String, 153 | enabled: bool, 154 | track_access_events: Option, 155 | last_modified: Option, 156 | version: u64, 157 | for_client: bool, 158 | disabled_serve: Serve, 159 | default_serve: Serve, 160 | rules: Vec, 161 | variations: Vec, 162 | prerequisites: Option>, 163 | } 164 | 165 | impl Toggle { 166 | pub fn eval( 167 | &self, 168 | user: &FPUser, 169 | segment_repo: &HashMap, 170 | toggle_repo: &HashMap, 171 | is_detail: bool, 172 | deep: u8, 173 | debug_until_time: Option, 174 | ) -> EvalDetail { 175 | let eval_param = EvalParams { 176 | user, 177 | segment_repo, 178 | toggle_repo, 179 | key: &self.key, 180 | is_detail, 181 | variations: &self.variations, 182 | debug_until_time, 183 | }; 184 | 185 | match self.do_eval(&eval_param, deep) { 186 | Ok(eval) => eval, 187 | Err(e) => self.disabled_variation(&eval_param, Some(e.to_string())), 188 | } 189 | } 190 | 191 | fn do_eval( 192 | &self, 193 | eval_param: &EvalParams, 194 | max_depth: u8, 195 | ) -> Result, PrerequisiteError> { 196 | if !self.enabled { 197 | return Ok(self.disabled_variation(eval_param, None)) 198 | } 199 | 200 | if !self.meet_prerequisite(eval_param, max_depth)? { 201 | return Ok(self.disabled_variation(eval_param, Some( 202 | "Prerequisite not match".to_owned()))); 203 | } 204 | 205 | for (i, rule) in self.rules.iter().enumerate() { 206 | match rule.serve_variation(eval_param) { 207 | Ok(v) => { 208 | if v.is_some() { 209 | return Ok(self.serve_variation( 210 | v, 211 | format!("rule {i}"), 212 | Some(i), 213 | eval_param.debug_until_time, 214 | )); 215 | } 216 | } 217 | Err(e) => { 218 | return Ok(self.serve_variation( 219 | None, 220 | format!("{e:?}"), 221 | Some(i), 222 | eval_param.debug_until_time, 223 | )); 224 | } 225 | } 226 | } 227 | 228 | Ok(self.default_variation(eval_param, None)) 229 | } 230 | 231 | fn meet_prerequisite( 232 | &self, 233 | eval_param: &EvalParams, 234 | deep: u8, 235 | ) -> Result { 236 | if deep == 0 { 237 | return Err(PrerequisiteError::DepthOverflow); 238 | } 239 | 240 | if let Some(ref prerequisites) = self.prerequisites { 241 | for pre in prerequisites { 242 | let eval = match eval_param.toggle_repo.get(&pre.key) { 243 | None => { 244 | return Err(PrerequisiteError::NotExist(pre.key.to_string())); 245 | } 246 | Some(t) => t.do_eval( 247 | &EvalParams { 248 | key: &t.key, 249 | variations: &t.variations, 250 | is_detail: eval_param.is_detail, 251 | user: eval_param.user, 252 | segment_repo: eval_param.segment_repo, 253 | toggle_repo: eval_param.toggle_repo, 254 | debug_until_time: eval_param.debug_until_time, 255 | }, 256 | deep - 1, 257 | )?, 258 | }; 259 | 260 | match eval.value { 261 | Some(v) if v == pre.value => continue, 262 | _ => return Ok(false), 263 | } 264 | } 265 | return Ok(true); 266 | } 267 | Ok(true) 268 | } 269 | 270 | fn serve_variation( 271 | &self, 272 | v: Option, 273 | reason: String, 274 | rule_index: Option, 275 | debug_until_time: Option, 276 | ) -> EvalDetail { 277 | EvalDetail { 278 | variation_index: v.as_ref().map(|v| v.index), 279 | value: v.map(|v| v.value), 280 | version: Some(self.version), 281 | track_access_events: self.track_access_events, 282 | debug_until_time, 283 | last_modified: self.last_modified, 284 | rule_index, 285 | reason, 286 | } 287 | } 288 | 289 | fn default_variation( 290 | &self, 291 | eval_param: &EvalParams, 292 | reason: Option, 293 | ) -> EvalDetail { 294 | return self.fixed_variation( 295 | &self.default_serve, 296 | eval_param, 297 | "default.".to_owned(), 298 | reason, 299 | ); 300 | } 301 | 302 | fn disabled_variation( 303 | &self, 304 | eval_param: &EvalParams, 305 | reason: Option, 306 | ) -> EvalDetail { 307 | return self.fixed_variation( 308 | &self.disabled_serve, 309 | eval_param, 310 | "disabled.".to_owned(), 311 | reason, 312 | ); 313 | } 314 | 315 | fn fixed_variation( 316 | &self, 317 | serve: &Serve, 318 | eval_param: &EvalParams, 319 | default_reason: String, 320 | reason: Option, 321 | ) -> EvalDetail { 322 | match serve.select_variation(eval_param) { 323 | Ok(v) => self.serve_variation( 324 | Some(v), 325 | concat_reason(default_reason, reason), 326 | None, 327 | eval_param.debug_until_time, 328 | ), 329 | Err(e) => self.serve_variation( 330 | None, 331 | concat_reason(format!("{e:?}"), reason), 332 | None, 333 | eval_param.debug_until_time, 334 | ), 335 | } 336 | } 337 | 338 | pub fn track_access_events(&self) -> bool { 339 | self.track_access_events.unwrap_or(false) 340 | } 341 | 342 | #[cfg(feature = "internal")] 343 | pub fn is_for_client(&self) -> bool { 344 | self.for_client 345 | } 346 | 347 | #[cfg(feature = "internal")] 348 | pub fn all_segment_ids(&self) -> Vec<&str> { 349 | let mut sids: Vec<&str> = Vec::new(); 350 | for r in &self.rules { 351 | for c in &r.conditions { 352 | if c.r#type == ConditionType::Segment { 353 | sids.push(&c.subject) 354 | } 355 | } 356 | } 357 | sids 358 | } 359 | 360 | pub fn new_for_test(key: String, val: Value) -> Self { 361 | Self { 362 | key, 363 | enabled: true, 364 | track_access_events: None, 365 | last_modified: None, 366 | default_serve: Serve::Select(0), 367 | disabled_serve: Serve::Select(0), 368 | variations: vec![val], 369 | version: 0, 370 | for_client: false, 371 | rules: vec![], 372 | prerequisites: None, 373 | } 374 | } 375 | } 376 | 377 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] 378 | struct SegmentRule { 379 | conditions: Vec, 380 | } 381 | 382 | impl SegmentRule { 383 | pub fn allow(&self, user: &FPUser) -> bool { 384 | for c in &self.conditions { 385 | if c.meet(user, None) { 386 | return true; 387 | } 388 | } 389 | false 390 | } 391 | } 392 | 393 | #[derive(Serialize, Deserialize, Debug)] 394 | struct DefaultRule { 395 | pub serve: Serve, 396 | } 397 | 398 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 399 | struct Rule { 400 | serve: Serve, 401 | conditions: Vec, 402 | } 403 | 404 | impl Rule { 405 | pub fn serve_variation(&self, eval_param: &EvalParams) -> Result, FPError> { 406 | let user = eval_param.user; 407 | let segment_repo = eval_param.segment_repo; 408 | match self 409 | .conditions 410 | .iter() 411 | .all(|c| c.meet(user, Some(segment_repo))) 412 | { 413 | true => Ok(Some(self.serve.select_variation(eval_param)?)), 414 | false => Ok(None), 415 | } 416 | } 417 | } 418 | 419 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 420 | #[serde(rename_all = "camelCase")] 421 | enum ConditionType { 422 | String, 423 | Segment, 424 | Datetime, 425 | Number, 426 | Semver, 427 | #[serde(other)] 428 | Unknown, 429 | } 430 | 431 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 432 | struct Condition { 433 | r#type: ConditionType, 434 | #[serde(default)] 435 | subject: String, 436 | predicate: String, 437 | objects: Vec, 438 | } 439 | 440 | impl Condition { 441 | pub fn meet(&self, user: &FPUser, segment_repo: Option<&HashMap>) -> bool { 442 | match &self.r#type { 443 | ConditionType::String => self.match_string(user, &self.predicate), 444 | ConditionType::Segment => self.match_segment(user, &self.predicate, segment_repo), 445 | ConditionType::Number => self.match_ordering::(user, &self.predicate), 446 | ConditionType::Semver => self.match_ordering::(user, &self.predicate), 447 | ConditionType::Datetime => self.match_timestamp(user, &self.predicate), 448 | _ => false, 449 | } 450 | } 451 | 452 | fn match_segment( 453 | &self, 454 | user: &FPUser, 455 | predicate: &str, 456 | segment_repo: Option<&HashMap>, 457 | ) -> bool { 458 | match segment_repo { 459 | None => false, 460 | Some(repo) => match predicate { 461 | "is in" => self.user_in_segments(user, repo), 462 | "is not in" => !self.user_in_segments(user, repo), 463 | _ => false, 464 | }, 465 | } 466 | } 467 | 468 | fn match_string(&self, user: &FPUser, predicate: &str) -> bool { 469 | if let Some(c) = user.get(&self.subject) { 470 | return match predicate { 471 | "is one of" => self.do_match::(c, |c, o| c.eq(o)), 472 | "ends with" => self.do_match::(c, |c, o| c.ends_with(o)), 473 | "starts with" => self.do_match::(c, |c, o| c.starts_with(o)), 474 | "contains" => self.do_match::(c, |c, o| c.contains(o)), 475 | "matches regex" => { 476 | self.do_match::(c, |c, o| match Regex::new(o) { 477 | Ok(re) => re.is_match(c), 478 | Err(_) => false, // invalid regex should be checked when load config 479 | }) 480 | } 481 | "is not any of" => !self.match_string(user, "is one of"), 482 | "does not end with" => !self.match_string(user, "ends with"), 483 | "does not start with" => !self.match_string(user, "starts with"), 484 | "does not contain" => !self.match_string(user, "contains"), 485 | "does not match regex" => !self.match_string(user, "matches regex"), 486 | _ => { 487 | info!("unknown predicate {}", predicate); 488 | false 489 | } 490 | }; 491 | } 492 | info!("user attr missing: {}", self.subject); 493 | false 494 | } 495 | 496 | fn match_ordering(&self, user: &FPUser, predicate: &str) -> bool { 497 | if let Some(c) = user.get(&self.subject) { 498 | let c: T = match c.parse() { 499 | Ok(v) => v, 500 | Err(_) => return false, 501 | }; 502 | return match predicate { 503 | "=" => self.do_match::(&c, |c, o| c.eq(o)), 504 | "!=" => !self.match_ordering::(user, "="), 505 | ">" => self.do_match::(&c, |c, o| c.gt(o)), 506 | ">=" => self.do_match::(&c, |c, o| c.ge(o)), 507 | "<" => self.do_match::(&c, |c, o| c.lt(o)), 508 | "<=" => self.do_match::(&c, |c, o| c.le(o)), 509 | _ => { 510 | info!("unknown predicate {}", predicate); 511 | false 512 | } 513 | }; 514 | } 515 | info!("user attr missing: {}", self.subject); 516 | false 517 | } 518 | 519 | fn match_timestamp(&self, user: &FPUser, predicate: &str) -> bool { 520 | let c: u128 = match user.get(&self.subject) { 521 | Some(v) => match v.parse() { 522 | Ok(v) => v, 523 | Err(_) => return false, 524 | }, 525 | None => unix_timestamp() / 1000, 526 | }; 527 | match predicate { 528 | "after" => self.do_match::(&c, |c, o| c.ge(o)), 529 | "before" => self.do_match::(&c, |c, o| c.lt(o)), 530 | _ => { 531 | info!("unknown predicate {}", predicate); 532 | false 533 | } 534 | } 535 | } 536 | 537 | fn do_match(&self, t: &T, f: fn(&T, &T) -> bool) -> bool { 538 | self.objects 539 | .iter() 540 | .map(|o| match o.parse::() { 541 | Ok(o) => f(t, &o), 542 | Err(_) => false, 543 | }) 544 | .any(|x| x) 545 | } 546 | 547 | fn user_in_segments(&self, user: &FPUser, repo: &HashMap) -> bool { 548 | for segment_key in &self.objects { 549 | match repo.get(segment_key) { 550 | Some(segment) => { 551 | if segment.contains(user) { 552 | return true; 553 | } 554 | } 555 | None => warn!("segment not found {}", segment_key), 556 | } 557 | } 558 | false 559 | } 560 | } 561 | 562 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] 563 | #[serde(rename_all = "camelCase")] 564 | pub struct Segment { 565 | unique_id: String, 566 | version: u64, 567 | rules: Vec, 568 | } 569 | 570 | impl Segment { 571 | pub fn contains(&self, user: &FPUser) -> bool { 572 | for rule in &self.rules { 573 | if rule.allow(user) { 574 | return true; 575 | } 576 | } 577 | false 578 | } 579 | } 580 | 581 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] 582 | #[serde(rename_all = "camelCase")] 583 | pub struct Repository { 584 | pub segments: HashMap, 585 | pub toggles: HashMap, 586 | pub events: Option, 587 | // TODO: remove option next release 588 | pub version: Option, 589 | pub debug_until_time: Option, 590 | } 591 | 592 | impl Default for Repository { 593 | fn default() -> Self { 594 | Repository { 595 | segments: Default::default(), 596 | toggles: Default::default(), 597 | events: Default::default(), 598 | version: Some(0), 599 | debug_until_time: None, 600 | } 601 | } 602 | } 603 | 604 | fn validate_toggle(_toggle: &Toggle) -> Result<(), FPError> { 605 | //TODO: validate toggle segment unique id exists 606 | //TODO: validate serve index and buckets size less than variations length 607 | //TODO: validate rules list last one if default rule (no condition just serve) 608 | //TODO: validate bucket is full range 609 | Ok(()) 610 | } 611 | 612 | #[allow(dead_code)] 613 | pub fn load_json(json_str: &str) -> Result { 614 | let repo = serde_json::from_str::(json_str) 615 | .map_err(|e| FPError::JsonError(json_str.to_owned(), e)); 616 | if let Ok(repo) = &repo { 617 | for t in repo.toggles.values() { 618 | validate_toggle(t)? 619 | } 620 | } 621 | repo 622 | } 623 | 624 | fn concat_reason(reason1: String, reason2: Option) -> String { 625 | if let Some(reason2) = reason2 { 626 | return format!("{reason1}. {reason2}."); 627 | } 628 | format!("{reason1}.") 629 | } 630 | 631 | #[cfg(test)] 632 | mod tests { 633 | use super::*; 634 | use approx::{self, assert_relative_eq}; 635 | use std::fs; 636 | use std::path::PathBuf; 637 | 638 | const MAX_DEEP: u8 = 20; 639 | 640 | #[test] 641 | fn test_load() { 642 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 643 | path.push("resources/fixtures/repo.json"); 644 | let json_str = fs::read_to_string(path).unwrap(); 645 | let repo = load_json(&json_str); 646 | assert!(repo.is_ok()); 647 | } 648 | 649 | #[test] 650 | fn test_load_invalid_json() { 651 | let json_str = "{invalid_json}"; 652 | let repo = load_json(json_str); 653 | assert!(repo.is_err()); 654 | } 655 | 656 | #[test] 657 | fn test_salt_hash() { 658 | let bucket = salt_hash("key", "salt", 10000); 659 | assert_eq!(2647, bucket); 660 | } 661 | 662 | #[test] 663 | fn test_segment_condition() { 664 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 665 | path.push("resources/fixtures/repo.json"); 666 | let json_str = fs::read_to_string(path).unwrap(); 667 | let repo = load_json(&json_str); 668 | assert!(repo.is_ok()); 669 | let repo = repo.unwrap(); 670 | 671 | let user = FPUser::new().with("city", "4"); 672 | let toggle = repo.toggles.get("json_toggle").unwrap(); 673 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 674 | let r = r.value.unwrap(); 675 | let r = r.as_object().unwrap(); 676 | assert!(r.get("variation_1").is_some()); 677 | } 678 | 679 | #[test] 680 | fn test_not_in_segment_condition() { 681 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 682 | path.push("resources/fixtures/repo.json"); 683 | let json_str = fs::read_to_string(path).unwrap(); 684 | let repo = load_json(&json_str); 685 | assert!(repo.is_ok()); 686 | let repo = repo.unwrap(); 687 | 688 | let user = FPUser::new().with("city", "100"); 689 | let toggle = repo.toggles.get("not_in_segment").unwrap(); 690 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 691 | let r = r.value.unwrap(); 692 | let r = r.as_object().unwrap(); 693 | assert!(r.get("not_in").is_some()); 694 | } 695 | 696 | #[test] 697 | fn test_multi_condition() { 698 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 699 | path.push("resources/fixtures/repo.json"); 700 | let json_str = fs::read_to_string(path).unwrap(); 701 | let repo = load_json(&json_str); 702 | assert!(repo.is_ok()); 703 | let repo = repo.unwrap(); 704 | 705 | let user = FPUser::new().with("city", "1").with("os", "linux"); 706 | let toggle = repo.toggles.get("multi_condition_toggle").unwrap(); 707 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 708 | let r = r.value.unwrap(); 709 | let r = r.as_object().unwrap(); 710 | assert!(r.get("variation_0").is_some()); 711 | 712 | let user = FPUser::new().with("os", "linux"); 713 | let toggle = repo.toggles.get("multi_condition_toggle").unwrap(); 714 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 715 | assert!(r.reason.starts_with("default")); 716 | 717 | let user = FPUser::new().with("city", "1"); 718 | let toggle = repo.toggles.get("multi_condition_toggle").unwrap(); 719 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 720 | assert!(r.reason.starts_with("default")); 721 | } 722 | 723 | #[test] 724 | fn test_distribution_condition() { 725 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 726 | path.push("resources/fixtures/repo.json"); 727 | let json_str = fs::read_to_string(path).unwrap(); 728 | let repo = load_json(&json_str); 729 | assert!(repo.is_ok()); 730 | let repo = repo.unwrap(); 731 | 732 | let total = 10000; 733 | let users = gen_users(total, false); 734 | let toggle = repo.toggles.get("json_toggle").unwrap(); 735 | let mut variation_0 = 0; 736 | let mut variation_1 = 0; 737 | let mut variation_2 = 0; 738 | for user in &users { 739 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 740 | let r = r.value.unwrap(); 741 | let r = r.as_object().unwrap(); 742 | if r.get("variation_0").is_some() { 743 | variation_0 += 1; 744 | } else if r.get("variation_1").is_some() { 745 | variation_1 += 1; 746 | } else if r.get("variation_2").is_some() { 747 | variation_2 += 1; 748 | } 749 | } 750 | 751 | let rate0 = variation_0 as f64 / total as f64; 752 | assert_relative_eq!(0.3333, rate0, max_relative = 0.05); 753 | let rate1 = variation_1 as f64 / total as f64; 754 | assert_relative_eq!(0.3333, rate1, max_relative = 0.05); 755 | let rate2 = variation_2 as f64 / total as f64; 756 | assert_relative_eq!(0.3333, rate2, max_relative = 0.05); 757 | } 758 | 759 | #[test] 760 | fn test_disabled_toggle() { 761 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 762 | path.push("resources/fixtures/repo.json"); 763 | let json_str = fs::read_to_string(path).unwrap(); 764 | let repo = load_json(&json_str); 765 | assert!(repo.is_ok()); 766 | let repo = repo.unwrap(); 767 | 768 | let user = FPUser::new().with("city", "100"); 769 | let toggle = repo.toggles.get("disabled_toggle").unwrap(); 770 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 771 | assert!(r 772 | .value 773 | .unwrap() 774 | .as_object() 775 | .unwrap() 776 | .get("disabled_key") 777 | .is_some()); 778 | } 779 | 780 | #[test] 781 | fn test_prerequisite_toggle() { 782 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 783 | path.push("resources/fixtures/repo.json"); 784 | let json_str = fs::read_to_string(path).unwrap(); 785 | let repo = load_json(&json_str); 786 | assert!(repo.is_ok()); 787 | let repo = repo.unwrap(); 788 | 789 | let user = FPUser::new().with("city", "4"); 790 | 791 | let toggle = repo.toggles.get("prerequisite_toggle").unwrap(); 792 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 793 | 794 | assert!(r.value.unwrap().as_object().unwrap().get("2").is_some()); 795 | } 796 | 797 | #[test] 798 | fn test_prerequisite_not_exist_should_return_disabled_variation() { 799 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 800 | path.push("resources/fixtures/repo.json"); 801 | let json_str = fs::read_to_string(path).unwrap(); 802 | let repo = load_json(&json_str); 803 | assert!(repo.is_ok()); 804 | let repo = repo.unwrap(); 805 | 806 | let user = FPUser::new().with("city", "4"); 807 | 808 | let toggle = repo.toggles.get("prerequisite_toggle_not_exist").unwrap(); 809 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 810 | 811 | assert!(r.value.unwrap().as_object().unwrap().get("0").is_some()); 812 | assert!(r.reason.contains("not exist")); 813 | } 814 | 815 | #[test] 816 | fn test_prerequisite_not_match_should_return_disabled_variation() { 817 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 818 | path.push("resources/fixtures/repo.json"); 819 | let json_str = fs::read_to_string(path).unwrap(); 820 | let repo = load_json(&json_str); 821 | assert!(repo.is_ok()); 822 | let repo = repo.unwrap(); 823 | 824 | let user = FPUser::new().with("city", "4"); 825 | 826 | let toggle = repo.toggles.get("prerequisite_toggle_not_match").unwrap(); 827 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 828 | 829 | assert!(r.value.unwrap().as_object().unwrap().get("0").is_some()); 830 | assert!(r.reason.contains("disabled.")); 831 | } 832 | 833 | #[test] 834 | fn test_prerequisite_depth_overflow_should_return_disabled_variation() { 835 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 836 | path.push("resources/fixtures/repo.json"); 837 | let json_str = fs::read_to_string(path).unwrap(); 838 | let repo = load_json(&json_str); 839 | assert!(repo.is_ok()); 840 | let repo = repo.unwrap(); 841 | 842 | let user = FPUser::new().with("city", "4"); 843 | 844 | let toggle = repo.toggles.get("prerequisite_toggle").unwrap(); 845 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, 1, None); 846 | 847 | assert!(r.value.unwrap().as_object().unwrap().get("0").is_some()); 848 | assert!(r.reason.contains("depth overflow")); 849 | } 850 | 851 | fn gen_users(num: usize, random: bool) -> Vec { 852 | let mut users = Vec::with_capacity(num); 853 | for i in 0..num { 854 | let key: u64 = if random { rand::random() } else { i as u64 }; 855 | let u = FPUser::new() 856 | .with("city", "100") 857 | .stable_rollout(format!("{}", key)); 858 | users.push(u); 859 | } 860 | users 861 | } 862 | } 863 | 864 | #[cfg(test)] 865 | mod distribution_tests { 866 | use super::*; 867 | 868 | #[test] 869 | fn test_distribution_in_exact_bucket() { 870 | let distribution = Distribution { 871 | distribution: vec![ 872 | vec![BucketRange((0, 2647))], 873 | vec![BucketRange((2647, 2648))], 874 | vec![BucketRange((2648, 10000))], 875 | ], 876 | bucket_by: Some("name".to_string()), 877 | salt: Some("salt".to_string()), 878 | }; 879 | 880 | let user_bucket_by_name = FPUser::new().with("name", "key"); 881 | 882 | let params = EvalParams { 883 | key: "not care", 884 | is_detail: true, 885 | user: &user_bucket_by_name, 886 | variations: &[], 887 | segment_repo: &Default::default(), 888 | toggle_repo: &Default::default(), 889 | debug_until_time: None, 890 | }; 891 | let result = distribution.find_index(¶ms); 892 | 893 | assert_eq!(1, result.unwrap_or_default()); 894 | } 895 | 896 | #[test] 897 | fn test_distribution_in_none_bucket() { 898 | let distribution = Distribution { 899 | distribution: vec![ 900 | vec![BucketRange((0, 2647))], 901 | vec![BucketRange((2648, 10000))], 902 | ], 903 | bucket_by: Some("name".to_string()), 904 | salt: Some("salt".to_string()), 905 | }; 906 | 907 | let user_bucket_by_name = FPUser::new().with("name", "key"); 908 | 909 | let params = EvalParams { 910 | key: "not care", 911 | is_detail: true, 912 | user: &user_bucket_by_name, 913 | variations: &[], 914 | segment_repo: &Default::default(), 915 | toggle_repo: &Default::default(), 916 | debug_until_time: None, 917 | }; 918 | let result = distribution.find_index(¶ms); 919 | 920 | assert!(format!("{:?}", result.expect_err("error")).contains("not find hash_bucket")); 921 | 922 | let params_no_detail = EvalParams { 923 | key: "not care", 924 | is_detail: false, 925 | user: &user_bucket_by_name, 926 | variations: &[], 927 | segment_repo: &Default::default(), 928 | toggle_repo: &Default::default(), 929 | debug_until_time: None, 930 | }; 931 | let result = distribution.find_index(¶ms_no_detail); 932 | assert!(result.is_err()); 933 | } 934 | 935 | #[test] 936 | fn test_select_variation_fail() { 937 | let distribution = Distribution { 938 | distribution: vec![ 939 | vec![BucketRange((0, 5000))], 940 | vec![BucketRange((5000, 10000))], 941 | ], 942 | bucket_by: Some("name".to_string()), 943 | salt: Some("salt".to_string()), 944 | }; 945 | let serve = Serve::Split(distribution); 946 | 947 | let user_with_no_name = FPUser::new(); 948 | 949 | let params = EvalParams { 950 | key: "", 951 | is_detail: true, 952 | user: &user_with_no_name, 953 | variations: &[ 954 | Value::String("a".to_string()), 955 | Value::String("b".to_string()), 956 | ], 957 | segment_repo: &Default::default(), 958 | toggle_repo: &Default::default(), 959 | debug_until_time: None, 960 | }; 961 | 962 | let result = serve.select_variation(¶ms).expect_err("e"); 963 | 964 | assert!(format!("{:?}", result).contains("does not have attribute")); 965 | } 966 | } 967 | 968 | #[cfg(test)] 969 | mod condition_tests { 970 | use super::*; 971 | use std::fs; 972 | use std::path::PathBuf; 973 | 974 | const MAX_DEEP: u8 = 20; 975 | 976 | #[test] 977 | fn test_unknown_condition() { 978 | let json_str = r#" 979 | { 980 | "type": "new_type", 981 | "subject": "new_subject", 982 | "predicate": ">", 983 | "objects": [] 984 | } 985 | "#; 986 | 987 | let condition = serde_json::from_str::(json_str); 988 | assert!(condition.is_ok()); 989 | let condition = condition.unwrap(); 990 | assert_eq!(condition.r#type, ConditionType::Unknown); 991 | } 992 | 993 | #[test] 994 | fn test_match_is_one_of() { 995 | let condition = Condition { 996 | r#type: ConditionType::String, 997 | subject: "name".to_string(), 998 | predicate: "is one of".to_string(), 999 | objects: vec![String::from("hello"), String::from("world")], 1000 | }; 1001 | 1002 | let user = FPUser::new().with("name", "world"); 1003 | assert!(condition.match_string(&user, &condition.predicate)); 1004 | } 1005 | 1006 | #[test] 1007 | fn test_not_match_is_one_of() { 1008 | let condition = Condition { 1009 | r#type: ConditionType::String, 1010 | subject: "name".to_string(), 1011 | predicate: "is one of".to_string(), 1012 | objects: vec![String::from("hello"), String::from("world")], 1013 | }; 1014 | 1015 | let user = FPUser::new().with("name", "not_in"); 1016 | 1017 | assert!(!condition.match_string(&user, &condition.predicate)); 1018 | } 1019 | 1020 | #[test] 1021 | fn test_user_miss_key_is_not_one_of() { 1022 | let condition = Condition { 1023 | r#type: ConditionType::String, 1024 | subject: "name".to_string(), 1025 | predicate: "is not one of".to_string(), 1026 | objects: vec![String::from("hello"), String::from("world")], 1027 | }; 1028 | 1029 | let user = FPUser::new(); 1030 | 1031 | assert!(!condition.match_string(&user, &condition.predicate)); 1032 | } 1033 | 1034 | #[test] 1035 | fn test_match_is_not_any_of() { 1036 | let condition = Condition { 1037 | r#type: ConditionType::String, 1038 | subject: "name".to_string(), 1039 | predicate: "is not any of".to_string(), 1040 | objects: vec![String::from("hello"), String::from("world")], 1041 | }; 1042 | 1043 | let user = FPUser::new().with("name", "welcome"); 1044 | assert!(condition.match_string(&user, &condition.predicate)); 1045 | } 1046 | 1047 | #[test] 1048 | fn test_not_match_is_not_any_of() { 1049 | let condition = Condition { 1050 | r#type: ConditionType::String, 1051 | subject: "name".to_string(), 1052 | predicate: "is not any of".to_string(), 1053 | objects: vec![String::from("hello"), String::from("world")], 1054 | }; 1055 | 1056 | let user = FPUser::new().with("name", "not_in"); 1057 | 1058 | assert!(condition.match_string(&user, &condition.predicate)); 1059 | } 1060 | 1061 | #[test] 1062 | fn test_match_ends_with() { 1063 | let condition = Condition { 1064 | r#type: ConditionType::String, 1065 | subject: "name".to_string(), 1066 | predicate: "ends with".to_string(), 1067 | objects: vec![String::from("hello"), String::from("world")], 1068 | }; 1069 | 1070 | let user = FPUser::new().with("name", "bob world"); 1071 | 1072 | assert!(condition.match_string(&user, &condition.predicate)); 1073 | } 1074 | 1075 | #[test] 1076 | fn test_dont_match_ends_with() { 1077 | let condition = Condition { 1078 | r#type: ConditionType::String, 1079 | subject: "name".to_string(), 1080 | predicate: "ends with".to_string(), 1081 | objects: vec![String::from("hello"), String::from("world")], 1082 | }; 1083 | 1084 | let user = FPUser::new().with("name", "bob"); 1085 | 1086 | assert!(!condition.match_string(&user, &condition.predicate)); 1087 | } 1088 | 1089 | #[test] 1090 | fn test_match_does_not_end_with() { 1091 | let condition = Condition { 1092 | r#type: ConditionType::String, 1093 | subject: "name".to_string(), 1094 | predicate: "does not end with".to_string(), 1095 | objects: vec![String::from("hello"), String::from("world")], 1096 | }; 1097 | 1098 | let user = FPUser::new().with("name", "bob"); 1099 | 1100 | assert!(condition.match_string(&user, &condition.predicate)); 1101 | } 1102 | 1103 | #[test] 1104 | fn test_not_match_does_not_end_with() { 1105 | let condition = Condition { 1106 | r#type: ConditionType::String, 1107 | subject: "name".to_string(), 1108 | predicate: "does not end with".to_string(), 1109 | objects: vec![String::from("hello"), String::from("world")], 1110 | }; 1111 | 1112 | let user = FPUser::new().with("name", "bob world"); 1113 | 1114 | assert!(!condition.match_string(&user, &condition.predicate)); 1115 | } 1116 | 1117 | #[test] 1118 | fn test_match_starts_with() { 1119 | let condition = Condition { 1120 | r#type: ConditionType::String, 1121 | subject: "name".to_string(), 1122 | predicate: "starts with".to_string(), 1123 | objects: vec![String::from("hello"), String::from("world")], 1124 | }; 1125 | 1126 | let user = FPUser::new().with("name", "world bob"); 1127 | 1128 | assert!(condition.match_string(&user, &condition.predicate)); 1129 | } 1130 | 1131 | #[test] 1132 | fn test_not_match_starts_with() { 1133 | let condition = Condition { 1134 | r#type: ConditionType::String, 1135 | subject: "name".to_string(), 1136 | predicate: "ends with".to_string(), 1137 | objects: vec![String::from("hello"), String::from("world")], 1138 | }; 1139 | 1140 | let user = FPUser::new().with("name", "bob"); 1141 | 1142 | assert!(!condition.match_string(&user, &condition.predicate)); 1143 | } 1144 | 1145 | #[test] 1146 | fn test_match_does_not_start_with() { 1147 | let condition = Condition { 1148 | r#type: ConditionType::String, 1149 | subject: "name".to_string(), 1150 | predicate: "does not start with".to_string(), 1151 | objects: vec![String::from("hello"), String::from("world")], 1152 | }; 1153 | 1154 | let user = FPUser::new().with("name", "bob"); 1155 | 1156 | assert!(condition.match_string(&user, &condition.predicate)); 1157 | } 1158 | 1159 | #[test] 1160 | fn test_not_match_does_not_start_with() { 1161 | let condition = Condition { 1162 | r#type: ConditionType::String, 1163 | subject: "name".to_string(), 1164 | predicate: "does not start with".to_string(), 1165 | objects: vec![String::from("hello"), String::from("world")], 1166 | }; 1167 | 1168 | let user = FPUser::new().with("name", "world bob"); 1169 | 1170 | assert!(!condition.match_string(&user, &condition.predicate)); 1171 | } 1172 | 1173 | #[test] 1174 | fn test_match_contains() { 1175 | let condition = Condition { 1176 | r#type: ConditionType::String, 1177 | subject: "name".to_string(), 1178 | predicate: "contains".to_string(), 1179 | objects: vec![String::from("hello"), String::from("world")], 1180 | }; 1181 | 1182 | let user = FPUser::new().with("name", "alice world bob"); 1183 | 1184 | assert!(condition.match_string(&user, &condition.predicate)); 1185 | } 1186 | 1187 | #[test] 1188 | fn test_not_match_contains() { 1189 | let condition = Condition { 1190 | r#type: ConditionType::String, 1191 | subject: "name".to_string(), 1192 | predicate: "contains".to_string(), 1193 | objects: vec![String::from("hello"), String::from("world")], 1194 | }; 1195 | 1196 | let user = FPUser::new().with("name", "alice bob"); 1197 | 1198 | assert!(!condition.match_string(&user, &condition.predicate)); 1199 | } 1200 | 1201 | #[test] 1202 | fn test_match_not_contains() { 1203 | let condition = Condition { 1204 | r#type: ConditionType::String, 1205 | subject: "name".to_string(), 1206 | predicate: "does not contain".to_string(), 1207 | objects: vec![String::from("hello"), String::from("world")], 1208 | }; 1209 | 1210 | let user = FPUser::new().with("name", "alice bob"); 1211 | 1212 | assert!(condition.match_string(&user, &condition.predicate)); 1213 | } 1214 | 1215 | #[test] 1216 | fn test_not_match_not_contains() { 1217 | let condition = Condition { 1218 | r#type: ConditionType::String, 1219 | subject: "name".to_string(), 1220 | predicate: "does not contain".to_string(), 1221 | objects: vec![String::from("hello"), String::from("world")], 1222 | }; 1223 | 1224 | let user = FPUser::new().with("name", "alice world bob"); 1225 | 1226 | assert!(!condition.match_string(&user, &condition.predicate)); 1227 | } 1228 | 1229 | #[test] 1230 | fn test_match_regex() { 1231 | let condition = Condition { 1232 | r#type: ConditionType::String, 1233 | subject: "name".to_string(), 1234 | predicate: "matches regex".to_string(), 1235 | objects: vec![String::from("hello"), String::from("world.*")], 1236 | }; 1237 | 1238 | let user = FPUser::new().with("name", "alice world bob"); 1239 | 1240 | assert!(condition.match_string(&user, &condition.predicate)); 1241 | } 1242 | 1243 | #[test] 1244 | fn test_match_regex_first_object() { 1245 | let condition = Condition { 1246 | r#type: ConditionType::String, 1247 | subject: "name".to_string(), 1248 | predicate: "matches regex".to_string(), 1249 | objects: vec![String::from(r"hello\d"), String::from("world.*")], 1250 | }; 1251 | 1252 | let user = FPUser::new().with("name", "alice orld bob hello3"); 1253 | 1254 | assert!(condition.match_string(&user, &condition.predicate)); 1255 | } 1256 | 1257 | #[test] 1258 | fn test_not_match_regex() { 1259 | let condition = Condition { 1260 | r#type: ConditionType::String, 1261 | subject: "name".to_string(), 1262 | predicate: "matches regex".to_string(), 1263 | objects: vec![String::from(r"hello\d"), String::from("world.*")], 1264 | }; 1265 | 1266 | let user = FPUser::new().with("name", "alice orld bob hello"); 1267 | 1268 | assert!(!condition.match_string(&user, &condition.predicate)); 1269 | } 1270 | 1271 | #[test] 1272 | fn test_match_not_match_regex() { 1273 | let condition = Condition { 1274 | r#type: ConditionType::String, 1275 | subject: "name".to_string(), 1276 | predicate: "does not match regex".to_string(), 1277 | objects: vec![String::from(r"hello\d"), String::from("world.*")], 1278 | }; 1279 | 1280 | let user = FPUser::new().with("name", "alice orld bob hello"); 1281 | 1282 | assert!(condition.match_string(&user, &condition.predicate)); 1283 | } 1284 | 1285 | #[test] 1286 | fn test_invalid_regex_condition() { 1287 | let condition = Condition { 1288 | r#type: ConditionType::String, 1289 | subject: "name".to_string(), 1290 | predicate: "matches regex".to_string(), 1291 | objects: vec![String::from("\\\\\\")], 1292 | }; 1293 | 1294 | let user = FPUser::new().with("name", "\\\\\\"); 1295 | 1296 | assert!(!condition.match_string(&user, &condition.predicate)); 1297 | } 1298 | 1299 | #[test] 1300 | fn test_match_equal_string() { 1301 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 1302 | path.push("resources/fixtures/repo.json"); 1303 | let json_str = fs::read_to_string(path).unwrap(); 1304 | let repo = load_json(&json_str); 1305 | assert!(repo.is_ok()); 1306 | let repo = repo.unwrap(); 1307 | 1308 | let user = FPUser::new().with("city", "1"); 1309 | let toggle = repo.toggles.get("json_toggle").unwrap(); 1310 | let r = toggle.eval(&user, &repo.segments, &repo.toggles, false, MAX_DEEP, None); 1311 | let r = r.value.unwrap(); 1312 | let r = r.as_object().unwrap(); 1313 | assert!(r.get("variation_0").is_some()); 1314 | } 1315 | 1316 | #[test] 1317 | fn test_segment_deserialize() { 1318 | let json_str = r#" 1319 | { 1320 | "type":"segment", 1321 | "predicate":"is in", 1322 | "objects":[ "segment1","segment2"] 1323 | } 1324 | "#; 1325 | 1326 | let segment = serde_json::from_str::(json_str) 1327 | .map_err(|e| FPError::JsonError(json_str.to_owned(), e)); 1328 | assert!(segment.is_ok()) 1329 | } 1330 | 1331 | #[test] 1332 | fn test_semver_condition() { 1333 | let mut condition = Condition { 1334 | r#type: ConditionType::Semver, 1335 | subject: "version".to_owned(), 1336 | objects: vec!["1.0.0".to_owned(), "2.0.0".to_owned()], 1337 | predicate: "=".to_owned(), 1338 | }; 1339 | 1340 | let user = FPUser::new().with("version".to_owned(), "1.0.0".to_owned()); 1341 | assert!(condition.meet(&user, None)); 1342 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1343 | assert!(condition.meet(&user, None)); 1344 | let user = FPUser::new().with("version".to_owned(), "3.0.0".to_owned()); 1345 | assert!(!condition.meet(&user, None)); 1346 | 1347 | condition.predicate = "!=".to_owned(); 1348 | let user = FPUser::new().with("version".to_owned(), "1.0.0".to_owned()); 1349 | assert!(!condition.meet(&user, None)); 1350 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1351 | assert!(!condition.meet(&user, None)); 1352 | let user = FPUser::new().with("version".to_owned(), "0.1.0".to_owned()); 1353 | assert!(condition.meet(&user, None)); 1354 | 1355 | condition.predicate = ">".to_owned(); 1356 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1357 | assert!(condition.meet(&user, None)); 1358 | let user = FPUser::new().with("version".to_owned(), "3.0.0".to_owned()); 1359 | assert!(condition.meet(&user, None)); 1360 | let user = FPUser::new().with("version".to_owned(), "0.1.0".to_owned()); 1361 | assert!(!condition.meet(&user, None)); 1362 | 1363 | condition.predicate = ">=".to_owned(); 1364 | let user = FPUser::new().with("version".to_owned(), "1.0.0".to_owned()); 1365 | assert!(condition.meet(&user, None)); 1366 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1367 | assert!(condition.meet(&user, None)); 1368 | let user = FPUser::new().with("version".to_owned(), "3.0.0".to_owned()); 1369 | assert!(condition.meet(&user, None)); 1370 | let user = FPUser::new().with("version".to_owned(), "0.1.0".to_owned()); 1371 | assert!(!condition.meet(&user, None)); 1372 | 1373 | condition.predicate = "<".to_owned(); 1374 | let user = FPUser::new().with("version".to_owned(), "1.0.0".to_owned()); // < 2.0.0 1375 | assert!(condition.meet(&user, None)); 1376 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1377 | assert!(!condition.meet(&user, None)); 1378 | let user = FPUser::new().with("version".to_owned(), "3.0.0".to_owned()); 1379 | assert!(!condition.meet(&user, None)); 1380 | 1381 | condition.predicate = "<=".to_owned(); 1382 | let user = FPUser::new().with("version".to_owned(), "1.0.0".to_owned()); 1383 | assert!(condition.meet(&user, None)); 1384 | let user = FPUser::new().with("version".to_owned(), "2.0.0".to_owned()); 1385 | assert!(condition.meet(&user, None)); 1386 | let user = FPUser::new().with("version".to_owned(), "0.1.0".to_owned()); 1387 | assert!(condition.meet(&user, None)); 1388 | 1389 | let user = FPUser::new().with("version".to_owned(), "a".to_owned()); 1390 | assert!(!condition.meet(&user, None)); 1391 | } 1392 | 1393 | #[test] 1394 | fn test_number_condition() { 1395 | let mut condition = Condition { 1396 | r#type: ConditionType::Number, 1397 | subject: "price".to_owned(), 1398 | objects: vec!["10".to_owned(), "100".to_owned()], 1399 | predicate: "=".to_owned(), 1400 | }; 1401 | 1402 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); 1403 | assert!(condition.meet(&user, None)); 1404 | let user = FPUser::new().with("price".to_owned(), "100".to_owned()); 1405 | assert!(condition.meet(&user, None)); 1406 | let user = FPUser::new().with("price".to_owned(), "0".to_owned()); 1407 | assert!(!condition.meet(&user, None)); 1408 | 1409 | condition.predicate = "!=".to_owned(); 1410 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); 1411 | assert!(!condition.meet(&user, None)); 1412 | let user = FPUser::new().with("price".to_owned(), "100".to_owned()); 1413 | assert!(!condition.meet(&user, None)); 1414 | let user = FPUser::new().with("price".to_owned(), "0".to_owned()); 1415 | assert!(condition.meet(&user, None)); 1416 | 1417 | condition.predicate = ">".to_owned(); 1418 | let user = FPUser::new().with("price".to_owned(), "11".to_owned()); 1419 | assert!(condition.meet(&user, None)); 1420 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); 1421 | assert!(!condition.meet(&user, None)); 1422 | 1423 | condition.predicate = ">=".to_owned(); 1424 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); 1425 | assert!(condition.meet(&user, None)); 1426 | let user = FPUser::new().with("price".to_owned(), "11".to_owned()); 1427 | assert!(condition.meet(&user, None)); 1428 | let user = FPUser::new().with("price".to_owned(), "100".to_owned()); 1429 | assert!(condition.meet(&user, None)); 1430 | let user = FPUser::new().with("price".to_owned(), "0".to_owned()); 1431 | assert!(!condition.meet(&user, None)); 1432 | 1433 | condition.predicate = "<".to_owned(); 1434 | let user = FPUser::new().with("price".to_owned(), "1".to_owned()); 1435 | assert!(condition.meet(&user, None)); 1436 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); // < 100 1437 | assert!(condition.meet(&user, None)); 1438 | let user = FPUser::new().with("price".to_owned(), "100".to_owned()); // < 100 1439 | assert!(!condition.meet(&user, None)); 1440 | 1441 | condition.predicate = "<=".to_owned(); 1442 | let user = FPUser::new().with("price".to_owned(), "1".to_owned()); 1443 | assert!(condition.meet(&user, None)); 1444 | let user = FPUser::new().with("price".to_owned(), "10".to_owned()); // < 100 1445 | assert!(condition.meet(&user, None)); 1446 | let user = FPUser::new().with("price".to_owned(), "100".to_owned()); // < 100 1447 | assert!(condition.meet(&user, None)); 1448 | 1449 | let user = FPUser::new().with("price".to_owned(), "a".to_owned()); 1450 | assert!(!condition.meet(&user, None)); 1451 | } 1452 | 1453 | #[test] 1454 | fn test_datetime_condition() { 1455 | let now_ts = unix_timestamp() / 1000; 1456 | let mut condition = Condition { 1457 | r#type: ConditionType::Datetime, 1458 | subject: "ts".to_owned(), 1459 | objects: vec![format!("{}", now_ts)], 1460 | predicate: "after".to_owned(), 1461 | }; 1462 | 1463 | let user = FPUser::new(); 1464 | assert!(condition.meet(&user, None)); 1465 | let user = FPUser::new().with("ts".to_owned(), format!("{}", now_ts)); 1466 | assert!(condition.meet(&user, None)); 1467 | 1468 | condition.predicate = "before".to_owned(); 1469 | condition.objects = vec![format!("{}", now_ts + 2)]; 1470 | assert!(condition.meet(&user, None)); 1471 | 1472 | let user = FPUser::new().with("ts".to_owned(), "a".to_owned()); 1473 | assert!(!condition.meet(&user, None)); 1474 | } 1475 | } 1476 | --------------------------------------------------------------------------------