Skip to content
This repository was archived by the owner on Jun 21, 2024. It is now read-only.

Commit 63db2a6

Browse files
authored
feat(flags): Extract flag definitions from redis (#42)
1 parent 8d69910 commit 63db2a6

File tree

3 files changed

+244
-0
lines changed

3 files changed

+244
-0
lines changed

feature-flags/src/flag_definitions.rs

+200
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::sync::Arc;
3+
use tracing::instrument;
4+
5+
use crate::{
6+
api::FlagError,
7+
redis::{Client, CustomRedisError},
8+
};
9+
10+
// TRICKY: This cache data is coming from django-redis. If it ever goes out of sync, we'll bork.
11+
// TODO: Add integration tests across repos to ensure this doesn't happen.
12+
pub const TEAM_FLAGS_CACHE_PREFIX: &str = "posthog:1:team_feature_flags_";
13+
14+
// TODO: Hmm, revisit when dealing with groups, but seems like
15+
// ideal to just treat it as a u8 and do our own validation on top
16+
#[derive(Debug, Deserialize, Serialize)]
17+
pub enum GroupTypeIndex {}
18+
19+
#[derive(Debug, Deserialize, Serialize)]
20+
pub enum OperatorType {
21+
#[serde(rename = "exact")]
22+
Exact,
23+
#[serde(rename = "is_not")]
24+
IsNot,
25+
#[serde(rename = "icontains")]
26+
Icontains,
27+
#[serde(rename = "not_icontains")]
28+
NotIcontains,
29+
#[serde(rename = "regex")]
30+
Regex,
31+
#[serde(rename = "not_regex")]
32+
NotRegex,
33+
#[serde(rename = "gt")]
34+
Gt,
35+
#[serde(rename = "lt")]
36+
Lt,
37+
#[serde(rename = "gte")]
38+
Gte,
39+
#[serde(rename = "lte")]
40+
Lte,
41+
#[serde(rename = "is_set")]
42+
IsSet,
43+
#[serde(rename = "is_not_set")]
44+
IsNotSet,
45+
#[serde(rename = "is_date_exact")]
46+
IsDateExact,
47+
#[serde(rename = "is_date_after")]
48+
IsDateAfter,
49+
#[serde(rename = "is_date_before")]
50+
IsDateBefore,
51+
}
52+
53+
#[derive(Debug, Deserialize, Serialize)]
54+
pub struct PropertyFilter {
55+
pub key: String,
56+
pub value: serde_json::Value,
57+
pub operator: Option<OperatorType>,
58+
#[serde(rename = "type")]
59+
pub prop_type: String,
60+
pub group_type_index: Option<u8>,
61+
}
62+
63+
#[derive(Debug, Deserialize, Serialize)]
64+
pub struct FlagGroupType {
65+
pub properties: Option<Vec<PropertyFilter>>,
66+
pub rollout_percentage: Option<f32>,
67+
pub variant: Option<String>,
68+
}
69+
70+
#[derive(Debug, Deserialize, Serialize)]
71+
pub struct MultivariateFlagVariant {
72+
pub key: String,
73+
pub name: Option<String>,
74+
pub rollout_percentage: f32,
75+
}
76+
77+
#[derive(Debug, Deserialize, Serialize)]
78+
pub struct MultivariateFlagOptions {
79+
pub variants: Vec<MultivariateFlagVariant>,
80+
}
81+
82+
// TODO: test name with https://www.fileformat.info/info/charset/UTF-16/list.htm values, like '𝖕𝖗𝖔𝖕𝖊𝖗𝖙𝖞': `𝓿𝓪𝓵𝓾𝓮`
83+
84+
#[derive(Debug, Deserialize, Serialize)]
85+
pub struct FlagFilters {
86+
pub groups: Vec<FlagGroupType>,
87+
pub multivariate: Option<MultivariateFlagOptions>,
88+
pub aggregation_group_type_index: Option<u8>,
89+
pub payloads: Option<serde_json::Value>,
90+
pub super_groups: Option<Vec<FlagGroupType>>,
91+
}
92+
93+
#[derive(Debug, Deserialize, Serialize)]
94+
pub struct FeatureFlag {
95+
pub id: i64,
96+
pub team_id: i64,
97+
pub name: Option<String>,
98+
pub key: String,
99+
pub filters: FlagFilters,
100+
#[serde(default)]
101+
pub deleted: bool,
102+
#[serde(default)]
103+
pub active: bool,
104+
#[serde(default)]
105+
pub ensure_experience_continuity: bool,
106+
}
107+
108+
#[derive(Debug, Deserialize, Serialize)]
109+
110+
pub struct FeatureFlagList {
111+
pub flags: Vec<FeatureFlag>,
112+
}
113+
114+
impl FeatureFlagList {
115+
/// Returns feature flags given a team_id
116+
117+
#[instrument(skip_all)]
118+
pub async fn from_redis(
119+
client: Arc<dyn Client + Send + Sync>,
120+
team_id: i64,
121+
) -> Result<FeatureFlagList, FlagError> {
122+
// TODO: Instead of failing here, i.e. if not in redis, fallback to pg
123+
let serialized_flags = client
124+
.get(format!("{TEAM_FLAGS_CACHE_PREFIX}{}", team_id))
125+
.await
126+
.map_err(|e| match e {
127+
CustomRedisError::NotFound => FlagError::TokenValidationError,
128+
CustomRedisError::PickleError(_) => {
129+
tracing::error!("failed to fetch data: {}", e);
130+
println!("failed to fetch data: {}", e);
131+
FlagError::DataParsingError
132+
}
133+
_ => {
134+
tracing::error!("Unknown redis error: {}", e);
135+
FlagError::RedisUnavailable
136+
}
137+
})?;
138+
139+
let flags_list: Vec<FeatureFlag> =
140+
serde_json::from_str(&serialized_flags).map_err(|e| {
141+
tracing::error!("failed to parse data to flags list: {}", e);
142+
println!("failed to parse data: {}", e);
143+
144+
FlagError::DataParsingError
145+
})?;
146+
147+
Ok(FeatureFlagList { flags: flags_list })
148+
}
149+
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use rand::Rng;
154+
155+
use super::*;
156+
use crate::test_utils::{
157+
insert_flags_for_team_in_redis, insert_new_team_in_redis, setup_redis_client,
158+
};
159+
160+
#[tokio::test]
161+
async fn test_fetch_flags_from_redis() {
162+
let client = setup_redis_client(None);
163+
164+
let team = insert_new_team_in_redis(client.clone()).await.unwrap();
165+
166+
insert_flags_for_team_in_redis(client.clone(), team.id, None)
167+
.await
168+
.expect("Failed to insert flags");
169+
170+
let flags_from_redis = FeatureFlagList::from_redis(client.clone(), team.id)
171+
.await
172+
.unwrap();
173+
assert_eq!(flags_from_redis.flags.len(), 1);
174+
let flag = flags_from_redis.flags.get(0).unwrap();
175+
assert_eq!(flag.key, "flag1");
176+
assert_eq!(flag.team_id, team.id);
177+
assert_eq!(flag.filters.groups.len(), 1);
178+
assert_eq!(flag.filters.groups[0].properties.as_ref().unwrap().len(), 1);
179+
}
180+
181+
#[tokio::test]
182+
async fn test_fetch_invalid_team_from_redis() {
183+
let client = setup_redis_client(None);
184+
185+
match FeatureFlagList::from_redis(client.clone(), 1234).await {
186+
Err(FlagError::TokenValidationError) => (),
187+
_ => panic!("Expected TokenValidationError"),
188+
};
189+
}
190+
191+
#[tokio::test]
192+
async fn test_cant_connect_to_redis_error_is_not_token_validation_error() {
193+
let client = setup_redis_client(Some("redis://localhost:1111/".to_string()));
194+
195+
match FeatureFlagList::from_redis(client.clone(), 1234).await {
196+
Err(FlagError::RedisUnavailable) => (),
197+
_ => panic!("Expected RedisUnavailable"),
198+
};
199+
}
200+
}

feature-flags/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod api;
22
pub mod config;
3+
pub mod flag_definitions;
34
pub mod redis;
45
pub mod router;
56
pub mod server;

feature-flags/src/test_utils.rs

+43
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anyhow::Error;
2+
use serde_json::json;
23
use std::sync::Arc;
34

45
use crate::{
6+
flag_definitions,
57
redis::{Client, RedisClient},
68
team::{self, Team},
79
};
@@ -40,6 +42,47 @@ pub async fn insert_new_team_in_redis(client: Arc<RedisClient>) -> Result<Team,
4042
Ok(team)
4143
}
4244

45+
pub async fn insert_flags_for_team_in_redis(
46+
client: Arc<RedisClient>,
47+
team_id: i64,
48+
json_value: Option<String>,
49+
) -> Result<(), Error> {
50+
let payload = match json_value {
51+
Some(value) => value,
52+
None => json!([{
53+
"id": 1,
54+
"key": "flag1",
55+
"name": "flag1 description",
56+
"active": true,
57+
"deleted": false,
58+
"team_id": team_id,
59+
"filters": {
60+
"groups": [
61+
{
62+
"properties": [
63+
{
64+
"key": "email",
65+
"value": "[email protected]",
66+
"type": "person",
67+
},
68+
]
69+
},
70+
],
71+
},
72+
}])
73+
.to_string(),
74+
};
75+
76+
client
77+
.set(
78+
format!("{}{}", flag_definitions::TEAM_FLAGS_CACHE_PREFIX, team_id),
79+
payload,
80+
)
81+
.await?;
82+
83+
Ok(())
84+
}
85+
4386
pub fn setup_redis_client(url: Option<String>) -> Arc<RedisClient> {
4487
let redis_url = match url {
4588
Some(value) => value,

0 commit comments

Comments
 (0)