diff --git a/Cargo.toml b/Cargo.toml index 04689e8..9c25a5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ murmur3 = "0.5.1" rand = "0.8.4" rustversion = "1.0.7" serde_json = "1.0.68" +serde_qs = "0.12.0" serde_plain = "1.0.0" surf = { version = "2.3.1", optional = true } diff --git a/src/api.rs b/src/api.rs index 261a4fa..269f023 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,9 @@ // Copyright 2020 Cognite AS //! +use serde_qs; use std::collections::HashMap; use std::default::Default; +use std::fmt::Display; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -13,9 +15,53 @@ pub struct Features { pub features: Vec, } +#[derive(Debug, PartialEq, Clone)] +pub struct TagFilter { + pub name: String, + pub value: String, +} + +impl Display for TagFilter { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}:{}", self.name, self.value) + } +} + +#[derive(Serialize, Debug, PartialEq, Clone)] +pub struct FeaturesQuery { + pub project: Option, + #[serde(rename = "namePrefix")] + pub name_prefix: Option, + #[serde(rename = "tag")] + pub tags: Option>, +} + +impl FeaturesQuery { + pub fn new( + project: Option, + name_prefix: Option, + tags: &Option>, + ) -> Self { + FeaturesQuery { + project, + name_prefix, + tags: tags + .as_ref() + .map(|tags| tags.iter().map(ToString::to_string).collect()), + } + } +} + impl Features { - pub fn endpoint(api_url: &str) -> String { - format!("{}/client/features", api_url) + pub fn endpoint(api_url: &str, query: Option<&FeaturesQuery>) -> String { + let url = format!("{}/client/features", api_url); + + let url = match query { + Some(query) => format!("{}?{}", url, serde_qs::to_string(query).unwrap()), + None => url, + }; + + url } } @@ -133,7 +179,8 @@ pub struct MetricsBucket { #[cfg(test)] mod tests { - use super::Registration; + use super::{Features, Registration, TagFilter}; + use crate::api::FeaturesQuery; #[test] fn parse_reference_doc() -> Result<(), serde_json::Error> { @@ -209,7 +256,7 @@ mod tests { ] } "#; - let parsed: super::Features = serde_json::from_str(data)?; + let parsed: Features = serde_json::from_str(data)?; assert_eq!(1, parsed.version); Ok(()) } @@ -224,4 +271,30 @@ mod tests { ..Default::default() }; } + + #[test] + fn test_features_endpoint() { + let endpoint = Features::endpoint( + "http://host.example.com:1234/api", + Some(&FeaturesQuery::new( + Some("myproject".into()), + Some("prefix".into()), + &Some(vec![ + TagFilter { + name: "simple".into(), + value: "taga".into(), + }, + TagFilter { + name: "simple".into(), + value: "tagb".into(), + }, + ]), + )), + ); + + assert_eq!( + "http://host.example.com:1234/api/client/features?project=myproject&namePrefix=prefix&tag[0]=simple%3Ataga&tag[1]=simple%3Atagb", + endpoint + ); + } } diff --git a/src/bin/dump-features.rs b/src/bin/dump-features.rs index ed06df3..eaf1b54 100644 --- a/src/bin/dump-features.rs +++ b/src/bin/dump-features.rs @@ -8,10 +8,10 @@ use unleash_api_client::http; fn main() -> Result<(), Box> { task::block_on(async { let config = EnvironmentConfig::from_env()?; - let endpoint = api::Features::endpoint(&config.api_url); + let endpoint = api::Features::endpoint(&config.api_url, None); let client: http::HTTP = http::HTTP::new(config.app_name, config.instance_id, config.secret)?; - let res: api::Features = client.get(&endpoint).recv_json().await?; + let res: api::Features = client.get_json(&endpoint).await?; dbg!(res); Ok(()) }) diff --git a/src/client.rs b/src/client.rs index ae6a279..aeacd2a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -16,7 +16,9 @@ use rand::Rng; use serde::de::DeserializeOwned; use serde::Serialize; -use crate::api::{self, Feature, Features, Metrics, MetricsBucket, Registration}; +use crate::api::{ + self, Feature, Features, FeaturesQuery, Metrics, MetricsBucket, Registration, TagFilter, +}; use crate::context::Context; use crate::http::{HttpClient, HTTP}; use crate::strategy; @@ -57,6 +59,9 @@ pub struct ClientBuilder { disable_metric_submission: bool, enable_str_features: bool, interval: u64, + project_name: Option, + name_prefix: Option, + tags: Option>, strategies: HashMap, } @@ -75,6 +80,9 @@ impl ClientBuilder { Ok(Client { api_url: api_url.into(), app_name: app_name.into(), + project_name: self.project_name, + name_prefix: self.name_prefix, + tags: self.tags, disable_metric_submission: self.disable_metric_submission, enable_str_features: self.enable_str_features, instance_id: instance_id.into(), @@ -86,6 +94,30 @@ impl ClientBuilder { }) } + /// Only fetch feature toggles belonging to the specified project + /// + /// + pub fn with_project_name(mut self, project_name: String) -> Self { + self.project_name = Some(project_name); + self + } + + /// Only fetch feature toggles with the provided name prefix + /// + /// + pub fn with_name_prefix(mut self, name_prefix: String) -> Self { + self.name_prefix = Some(name_prefix); + self + } + + /// Only fetch feature toggles tagged with the list of tags + /// + /// + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = Some(tags); + self + } + pub fn disable_metric_submission(mut self) -> Self { self.disable_metric_submission = true; self @@ -114,11 +146,13 @@ impl Default for ClientBuilder { enable_str_features: false, interval: 15000, strategies: Default::default(), + project_name: None, + name_prefix: None, + tags: None, }; result .strategy("default", Box::new(&strategy::default)) .strategy("applicationHostname", Box::new(&strategy::hostname)) - .strategy("default", Box::new(&strategy::default)) .strategy("gradualRolloutRandom", Box::new(&strategy::random)) .strategy("gradualRolloutSessionId", Box::new(&strategy::session_id)) .strategy("gradualRolloutUserId", Box::new(&strategy::user_id)) @@ -174,6 +208,9 @@ where { api_url: String, app_name: String, + project_name: Option, + name_prefix: Option, + tags: Option>, disable_metric_submission: bool, enable_str_features: bool, instance_id: String, @@ -694,7 +731,14 @@ where /// stop_poll is called(). pub async fn poll_for_updates(&self) { // TODO: add an event / pipe to permit immediate exit. - let endpoint = Features::endpoint(&self.api_url); + let endpoint = Features::endpoint( + &self.api_url, + Some(&FeaturesQuery::new( + self.project_name.clone(), + self.name_prefix.clone(), + &self.tags, + )), + ); let metrics_endpoint = Metrics::endpoint(&self.api_url); self.polling.store(true, Ordering::Relaxed); loop { @@ -823,9 +867,12 @@ mod tests { use serde::{Deserialize, Serialize}; use super::{ClientBuilder, Variant}; - use crate::api::{self, Feature, Features, Strategy}; + use crate::api::{self, Feature, Features, Strategy, TagFilter}; use crate::context::{Context, IPAddress}; - use crate::strategy; + use crate::http::HTTP; + use crate::{strategy, Client}; + use arc_swap::ArcSwapOption; + use std::fmt::{Debug, Formatter}; cfg_if::cfg_if! { if #[cfg(feature = "surf")] { @@ -1319,4 +1366,81 @@ mod tests { assert_eq!(variant2, c.get_variant_str("two", &session1)); assert_eq!(variant1, c.get_variant_str("two", &host1)); } + + #[test] + fn test_builder() { + #[derive(Debug, Deserialize, Serialize, Enum, Clone)] + enum NoFeatures {} + + let api_url = "http://127.0.0.1:1234/"; + let instance_id = "test"; + let app_name = "foo"; + let project_name = "myproject".to_string(); + let name_prefix = "prefix".to_string(); + let tags = vec![ + TagFilter { + name: "simple".into(), + value: "taga".into(), + }, + TagFilter { + name: "simple".into(), + value: "tagb".into(), + }, + ]; + + let client: Client = Client { + api_url: api_url.into(), + disable_metric_submission: false, + enable_str_features: false, + instance_id: instance_id.into(), + interval: 15000, + polling: Default::default(), + http: HTTP::new(app_name.into(), instance_id.into(), None).unwrap(), + strategies: Default::default(), + project_name: Some(project_name.clone()), + name_prefix: Some(name_prefix.clone()), + tags: Some(tags.clone()), + app_name: app_name.into(), + cached_state: ArcSwapOption::from(None), + }; + + let client_from_builder = ClientBuilder::default() + .with_project_name(project_name) + .with_name_prefix(name_prefix) + .with_tags(tags) + .into_client::(api_url, app_name, instance_id, None) + .unwrap(); + + impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.api_url == other.api_url + && self.app_name == other.app_name + && self.project_name == other.project_name + && self.name_prefix == other.name_prefix + && self.tags == other.tags + && self.disable_metric_submission == other.disable_metric_submission + && self.enable_str_features == other.enable_str_features + && self.instance_id == other.instance_id + && self.interval == other.interval + } + } + + impl Debug for Client { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("api_url", &self.api_url) + .field("app_name", &self.app_name) + .field("project_name", &self.project_name) + .field("name_prefix", &self.name_prefix) + .field("tags", &self.tags) + .field("disable_metric_submission", &self.disable_metric_submission) + .field("enable_str_features", &self.enable_str_features) + .field("instance_id", &self.instance_id) + .field("interval", &self.interval) + .finish() + } + } + + assert_eq!(client, client_from_builder); + } }