diff --git a/services/comm-services-lib/src/database.rs b/services/comm-services-lib/src/database.rs index d954a974e..579acacc9 100644 --- a/services/comm-services-lib/src/database.rs +++ b/services/comm-services-lib/src/database.rs @@ -1,153 +1,248 @@ use aws_sdk_dynamodb::types::AttributeValue; use aws_sdk_dynamodb::Error as DynamoDBError; use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::num::ParseIntError; +use std::str::FromStr; #[derive( Debug, derive_more::Display, derive_more::From, derive_more::Error, )] pub enum Error { #[display(...)] AwsSdk(DynamoDBError), #[display(...)] Attribute(DBItemError), } #[derive(Debug)] pub enum Value { AttributeValue(Option), String(String), } #[derive(Debug, derive_more::Error, derive_more::Constructor)] pub struct DBItemError { attribute_name: &'static str, attribute_value: Value, attribute_error: DBItemAttributeError, } impl Display for DBItemError { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match &self.attribute_error { DBItemAttributeError::Missing => { write!(f, "Attribute {} is missing", self.attribute_name) } DBItemAttributeError::IncorrectType => write!( f, "Value for attribute {} has incorrect type: {:?}", self.attribute_name, self.attribute_value ), error => write!( f, "Error regarding attribute {} with value {:?}: {}", self.attribute_name, self.attribute_value, error ), } } } #[derive(Debug, derive_more::Display, derive_more::Error)] pub enum DBItemAttributeError { #[display(...)] Missing, #[display(...)] IncorrectType, #[display(...)] + TimestampOutOfRange, + #[display(...)] InvalidTimestamp(chrono::ParseError), #[display(...)] InvalidNumberFormat(ParseIntError), } pub fn parse_string_attribute( attribute_name: &'static str, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::S(value)) => Ok(value), Some(_) => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } pub fn parse_bool_attribute( attribute_name: &'static str, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::Bool(value)) => Ok(value), Some(_) => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } +pub fn parse_int_attribute( + attribute_name: &'static str, + attribute_value: Option, +) -> Result +where + T: FromStr, +{ + match &attribute_value { + Some(AttributeValue::N(numeric_str)) => { + parse_integer(attribute_name, &numeric_str) + } + Some(_) => Err(DBItemError::new( + attribute_name, + Value::AttributeValue(attribute_value), + DBItemAttributeError::IncorrectType, + )), + None => Err(DBItemError::new( + attribute_name, + Value::AttributeValue(attribute_value), + DBItemAttributeError::Missing, + )), + } +} + pub fn parse_datetime_attribute( attribute_name: &'static str, attribute_value: Option, ) -> Result, DBItemError> { if let Some(AttributeValue::S(datetime)) = &attribute_value { // parse() accepts a relaxed RFC3339 string datetime.parse().map_err(|e| { DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::InvalidTimestamp(e), ) }) } else { Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )) } } +/// Parses the UTC timestamp in milliseconds from a DynamoDB numeric attribute +pub fn parse_timestamp_attribute( + attribute_name: &'static str, + attribute_value: Option, +) -> Result, DBItemError> { + let timestamp = + parse_int_attribute::(attribute_name, attribute_value.clone())?; + let naive_datetime = chrono::NaiveDateTime::from_timestamp_millis(timestamp) + .ok_or_else(|| { + DBItemError::new( + attribute_name, + Value::AttributeValue(attribute_value), + DBItemAttributeError::TimestampOutOfRange, + ) + })?; + Ok(DateTime::from_utc(naive_datetime, Utc)) +} + pub fn parse_map_attribute( attribute_name: &'static str, attribute_value: Option, ) -> Result, DBItemError> { match attribute_value { Some(AttributeValue::M(map)) => Ok(map), Some(_) => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name, Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } -pub fn parse_number( +pub fn parse_integer( attribute_name: &'static str, attribute_value: &str, -) -> Result { - attribute_value.parse::().map_err(|e| { +) -> Result +where + T: FromStr, +{ + attribute_value.parse::().map_err(|e| { DBItemError::new( attribute_name, Value::String(attribute_value.to_string()), DBItemAttributeError::InvalidNumberFormat(e), ) }) } + +#[cfg(test)] + +mod tests { + use super::*; + + #[test] + fn test_parse_integer() { + assert!(parse_integer::("some_attr", "123").is_ok()); + assert!(parse_integer::("negative", "-123").is_ok()); + + assert!(parse_integer::("float", "3.14").is_err()); + assert!(parse_integer::("NaN", "foo").is_err()); + + assert!(parse_integer::("negative_uint", "-123").is_err()); + assert!(parse_integer::("too_large", "65536").is_err()); + } + + #[test] + fn test_parse_timestamp() { + let timestamp = Utc::now().timestamp_millis(); + let attr = AttributeValue::N(timestamp.to_string()); + + let parsed_timestamp = parse_timestamp_attribute("some_attr", Some(attr)); + + assert!(parsed_timestamp.is_ok()); + assert_eq!(parsed_timestamp.unwrap().timestamp_millis(), timestamp); + } + + #[test] + fn test_parse_invalid_timestamp() { + let attr = AttributeValue::N("foo".to_string()); + let parsed_timestamp = parse_timestamp_attribute("some_attr", Some(attr)); + assert!(parsed_timestamp.is_err()); + } + + #[test] + fn test_parse_timestamp_out_of_range() { + let attr = AttributeValue::N(i64::MAX.to_string()); + let parsed_timestamp = parse_timestamp_attribute("some_attr", Some(attr)); + assert!(parsed_timestamp.is_err()); + assert!(matches!( + parsed_timestamp.unwrap_err().attribute_error, + DBItemAttributeError::TimestampOutOfRange + )); + } +} diff --git a/services/feature-flags/src/database.rs b/services/feature-flags/src/database.rs index 20542bebb..5b73bd3db 100644 --- a/services/feature-flags/src/database.rs +++ b/services/feature-flags/src/database.rs @@ -1,120 +1,120 @@ use crate::constants::{ FEATURE_FLAGS_CONFIG_FIELD, FEATURE_FLAGS_FEATURE_FIELD, FEATURE_FLAGS_NON_STAFF_FIELD, FEATURE_FLAGS_PLATFORM_FIELD, FEATURE_FLAGS_STAFF_FIELD, FEATURE_FLAGS_TABLE_NAME, PLATFORM_ANDROID, PLATFORM_IOS, }; use aws_sdk_dynamodb::types::{AttributeValue, Select}; use comm_services_lib::database::{self, DBItemError, Error}; use std::collections::HashMap; use std::sync::Arc; use tracing::error; #[derive(Debug)] pub struct CodeVersionSpecificFeatureConfig { pub staff: bool, pub non_staff: bool, } fn parse_code_version_specific_feature_config( value: Option, ) -> Result { let mut code_version_config_map = database::parse_map_attribute(FEATURE_FLAGS_CONFIG_FIELD, value)?; let staff = database::parse_bool_attribute( FEATURE_FLAGS_STAFF_FIELD, code_version_config_map.remove(FEATURE_FLAGS_STAFF_FIELD), )?; let non_staff = database::parse_bool_attribute( FEATURE_FLAGS_NON_STAFF_FIELD, code_version_config_map.remove(FEATURE_FLAGS_NON_STAFF_FIELD), )?; Ok(CodeVersionSpecificFeatureConfig { staff, non_staff }) } #[derive(Debug)] pub struct FeatureConfig { pub name: String, pub config: HashMap, } fn parse_feature_config( mut attribute_value: HashMap, ) -> Result { let feature_name = database::parse_string_attribute( FEATURE_FLAGS_FEATURE_FIELD, attribute_value.remove(FEATURE_FLAGS_FEATURE_FIELD), )?; let config_map = database::parse_map_attribute( FEATURE_FLAGS_CONFIG_FIELD, attribute_value.remove(FEATURE_FLAGS_CONFIG_FIELD), )?; let mut config = HashMap::new(); for (code_version_string, code_version_config) in config_map { let code_version: i32 = - database::parse_number("code_version", code_version_string.as_str())?; + database::parse_integer("code_version", code_version_string.as_str())?; let version_config = parse_code_version_specific_feature_config(Some(code_version_config))?; config.insert(code_version, version_config); } Ok(FeatureConfig { name: feature_name, config, }) } pub enum Platform { IOS, ANDROID, } #[derive(Clone)] pub struct DatabaseClient { client: Arc, } impl DatabaseClient { pub fn new(aws_config: &aws_types::SdkConfig) -> Self { DatabaseClient { client: Arc::new(aws_sdk_dynamodb::Client::new(aws_config)), } } pub async fn get_features_configuration( &self, platform: Platform, ) -> Result, Error> { let platform_value = match platform { Platform::IOS => PLATFORM_IOS, Platform::ANDROID => PLATFORM_ANDROID, }; let result = self .client .query() .select(Select::AllAttributes) .table_name(FEATURE_FLAGS_TABLE_NAME) .consistent_read(true) .key_condition_expression("#platform = :platform") .expression_attribute_names("#platform", FEATURE_FLAGS_PLATFORM_FIELD) .expression_attribute_values( ":platform", AttributeValue::S(platform_value.to_string()), ) .send() .await .map_err(|e| { error!("DynamoDB client failed to find feature flags configuration"); Error::AwsSdk(e.into()) })?; if let Some(items) = result.items { let mut config = HashMap::new(); for item in items { let feature_config = parse_feature_config(item)?; config.insert(feature_config.name.clone(), feature_config); } Ok(config) } else { Ok(HashMap::new()) } } }