diff --git a/services/comm-services-lib/src/database.rs b/services/comm-services-lib/src/database.rs index 1040a3cae..19ca7dadc 100644 --- a/services/comm-services-lib/src/database.rs +++ b/services/comm-services-lib/src/database.rs @@ -1,367 +1,399 @@ use aws_sdk_dynamodb::types::AttributeValue; use aws_sdk_dynamodb::Error as DynamoDBError; use chrono::{DateTime, Utc}; use std::fmt::{Display, Formatter}; use std::num::ParseIntError; use std::str::FromStr; // # Useful type aliases // Rust exports `pub type` only into the so-called "type namespace", but in // order to use them e.g. with the `TryFromAttribute` trait, they also need // to be exported into the "value namespace" which is what `pub use` does. // // To overcome that, a dummy module is created and aliases are re-exported // with `pub use` construct mod aliases { use aws_sdk_dynamodb::types::AttributeValue; use std::collections::HashMap; pub type AttributeMap = HashMap; } pub use self::aliases::AttributeMap; // # Error handling #[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: String, 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), } /// Conversion trait for [`AttributeValue`] /// /// Types implementing this trait are able to do the following: /// ```ignore /// use comm_services_lib::database::{TryFromAttribute, AttributeTryInto}; /// /// let foo = SomeType::try_from_attr("MyAttribute", Some(attribute))?; /// /// // if `AttributeTryInto` is imported, also: /// let bar = Some(attribute).attr_try_into("MyAttribute")?; /// ``` pub trait TryFromAttribute: Sized { fn try_from_attr( attribute_name: impl Into, attribute: Option, ) -> Result; } /// Do NOT implement this trait directly. Implement [`TryFromAttribute`] instead pub trait AttributeTryInto { fn attr_try_into( self, attribute_name: impl Into, ) -> Result; } // Automatic attr_try_into() for all attribute values // that have TryFromAttribute implemented impl AttributeTryInto for Option { fn attr_try_into( self, attribute_name: impl Into, ) -> Result { T::try_from_attr(attribute_name, self) } } +/// Helper trait for extracting attributes from a collection +pub trait AttributeExtractor { + /// Gets an attribute from the map and tries to convert it to the given type + /// This method does not consume the raw attribute - it gets cloned + /// See [`AttributeExtractor::take_attr`] for a non-cloning method + fn get_attr( + &self, + attribute_name: &str, + ) -> Result; + /// Takes an attribute from the map and tries to convert it to the given type + /// This method consumes the raw attribute - it gets removed from the map + /// See [`AttributeExtractor::get_attr`] for a non-mutating method + fn take_attr( + &mut self, + attribute_name: &str, + ) -> Result; +} +impl AttributeExtractor for AttributeMap { + fn get_attr( + &self, + attribute_name: &str, + ) -> Result { + T::try_from_attr(attribute_name, self.get(attribute_name).cloned()) + } + fn take_attr( + &mut self, + attribute_name: &str, + ) -> Result { + T::try_from_attr(attribute_name, self.remove(attribute_name)) + } +} + impl TryFromAttribute for String { fn try_from_attr( attribute_name: impl Into, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::S(value)) => Ok(value), Some(_) => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } } impl TryFromAttribute for bool { fn try_from_attr( attribute_name: impl Into, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::Bool(value)) => Ok(value), Some(_) => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } } impl TryFromAttribute for DateTime { fn try_from_attr( attribute_name: impl Into, attribute: Option, ) -> Result { match &attribute { Some(AttributeValue::S(datetime)) => datetime.parse().map_err(|e| { DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute), DBItemAttributeError::InvalidTimestamp(e), ) }), Some(_) => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute), DBItemAttributeError::Missing, )), } } } impl TryFromAttribute for AttributeMap { fn try_from_attr( attribute_name: impl Into, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::M(map)) => Ok(map), Some(_) => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } } impl TryFromAttribute for Vec { fn try_from_attr( attribute_name: impl Into, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::B(data)) => Ok(data.into_inner()), Some(_) => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } } #[deprecated = "Use `String::try_from_attr()` instead"] pub fn parse_string_attribute( attribute_name: impl Into, attribute_value: Option, ) -> Result { String::try_from_attr(attribute_name, attribute_value) } #[deprecated = "Use `bool::try_from_attr()` instead"] pub fn parse_bool_attribute( attribute_name: impl Into, attribute_value: Option, ) -> Result { bool::try_from_attr(attribute_name, attribute_value) } #[deprecated = "Use `DateTime::::try_from_attr()` instead"] pub fn parse_datetime_attribute( attribute_name: impl Into, attribute_value: Option, ) -> Result, DBItemError> { DateTime::::try_from_attr(attribute_name, attribute_value) } #[deprecated = "Use `AttributeMap::try_from_attr()` instead"] pub fn parse_map_attribute( attribute_name: impl Into, attribute_value: Option, ) -> Result { attribute_value.attr_try_into(attribute_name) } pub fn parse_int_attribute( attribute_name: impl Into, 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.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( attribute_name.into(), Value::AttributeValue(attribute_value), DBItemAttributeError::Missing, )), } } /// Parses the UTC timestamp in milliseconds from a DynamoDB numeric attribute pub fn parse_timestamp_attribute( attribute_name: impl Into, attribute_value: Option, ) -> Result, DBItemError> { let attribute_name: String = attribute_name.into(); let timestamp = parse_int_attribute::( attribute_name.clone(), 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_integer( attribute_name: impl Into, attribute_value: &str, ) -> Result where T: FromStr, { attribute_value.parse::().map_err(|e| { DBItemError::new( attribute_name.into(), Value::String(attribute_value.into()), 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/reports/src/database/item.rs b/services/reports/src/database/item.rs index 4566a7dd3..86bbb1021 100644 --- a/services/reports/src/database/item.rs +++ b/services/reports/src/database/item.rs @@ -1,116 +1,258 @@ -use aws_sdk_dynamodb::types::AttributeValue; -use comm_services_lib::database::{self, DBItemError, TryFromAttribute}; +use aws_sdk_dynamodb::{primitives::Blob, types::AttributeValue}; +use chrono::{DateTime, Utc}; +use comm_services_lib::{ + blob::types::BlobInfo, + database::{ + self, AttributeExtractor, AttributeMap, DBItemError, TryFromAttribute, + }, +}; use num_traits::FromPrimitive; use tracing::debug; use super::constants::*; use crate::report_types::*; +/// Represents a report item row in DynamoDB +/// This is serializable to display a list of reports +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportItem { + pub id: ReportID, + #[serde(rename = "userID")] + pub user_id: String, + pub report_type: ReportType, + pub platform: ReportPlatform, + pub creation_time: DateTime, + #[serde(skip_serializing)] + pub content: ReportContent, + #[serde(skip_serializing)] + pub encryption_key: Option, +} + +impl ReportItem { + pub fn into_attrs(self) -> AttributeMap { + let creation_time = self + .creation_time + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + + let mut attrs = AttributeMap::from([ + (ATTR_REPORT_ID.to_string(), self.id.into()), + (ATTR_USER_ID.to_string(), AttributeValue::S(self.user_id)), + (ATTR_REPORT_TYPE.to_string(), self.report_type.into()), + (ATTR_PLATFORM.to_string(), self.platform.into()), + ( + ATTR_CREATION_TIME.to_string(), + AttributeValue::S(creation_time), + ), + ]); + + let (content_attr_name, content_attr) = self.content.into_attr_pair(); + attrs.insert(content_attr_name, content_attr); + + if let Some(key) = self.encryption_key { + attrs.insert(ATTR_ENCRYPTION_KEY.to_string(), AttributeValue::S(key)); + } + attrs + } + + /// Creates a report item from a report input payload + pub fn from_input( + payload: ReportInput, + user_id: Option, + ) -> Result { + let ReportInput { + platform_details, + report_type, + time, + mut report_content, + } = payload; + + let platform = platform_details.platform.clone(); + + // Add "platformDetails" back to report content + let platform_details_value = serde_json::to_value(platform_details)?; + report_content + .insert("platformDetails".to_string(), platform_details_value); + + // serialize report JSON to bytes + let content_bytes = serde_json::to_vec(&report_content)?; + let content = ReportContent::Database(content_bytes); + + Ok(ReportItem { + id: ReportID::default(), + user_id: user_id.unwrap_or("[null]".to_string()), + platform, + report_type, + creation_time: time.unwrap_or_else(Utc::now), + encryption_key: None, + content, + }) + } +} + +impl TryFrom for ReportItem { + type Error = DBItemError; + + fn try_from(mut row: AttributeMap) -> Result { + let id = row.remove(ATTR_REPORT_ID).try_into()?; + let user_id = row.take_attr(ATTR_USER_ID)?; + let report_type = row.take_attr(ATTR_REPORT_TYPE)?; + let platform = row.take_attr(ATTR_PLATFORM)?; + let creation_time = row.take_attr(ATTR_CREATION_TIME)?; + + let content = ReportContent::parse_from_attrs(&mut row)?; + let encryption_key = row + .remove(ATTR_ENCRYPTION_KEY) + .map(|attr| String::try_from_attr(ATTR_ENCRYPTION_KEY, Some(attr))) + .transpose()?; + + Ok(ReportItem { + id, + user_id, + report_type, + platform, + content, + encryption_key, + creation_time, + }) + } +} + +/// Represents the content of a report item stored in DynamoDB +#[derive(Clone, Debug)] +pub enum ReportContent { + Blob(BlobInfo), + Database(Vec), +} + +impl ReportContent { + /// Returns a tuple of attribute name and value for this content + fn into_attr_pair(self) -> (String, AttributeValue) { + match self { + Self::Blob(blob_info) => (ATTR_BLOB_INFO.to_string(), blob_info.into()), + Self::Database(data) => ( + ATTR_REPORT_CONTENT.to_string(), + AttributeValue::B(Blob::new(data)), + ), + } + } + fn parse_from_attrs(attrs: &mut AttributeMap) -> Result { + if let Some(blob_info_attr) = attrs.remove(ATTR_BLOB_INFO) { + let blob_info = + BlobInfo::try_from_attr(ATTR_BLOB_INFO, Some(blob_info_attr))?; + return Ok(ReportContent::Blob(blob_info)); + } + + let content_data = attrs.take_attr(ATTR_REPORT_CONTENT)?; + Ok(ReportContent::Database(content_data)) + } +} + // DB conversions for report types // ReportID impl From for AttributeValue { fn from(value: ReportID) -> Self { AttributeValue::S(value.into()) } } impl From<&ReportID> for AttributeValue { fn from(value: &ReportID) -> Self { AttributeValue::S(value.clone().into()) } } impl TryFrom> for ReportID { type Error = database::DBItemError; fn try_from(value: Option) -> Result { let raw = String::try_from_attr(ATTR_REPORT_ID, value)?; Ok(ReportID::from(raw)) } } // ReportType impl From for AttributeValue { fn from(value: ReportType) -> Self { let num = value as u8; AttributeValue::N(num.to_string()) } } impl TryFromAttribute for ReportType { fn try_from_attr( attribute_name: impl Into, attribute: Option, ) -> Result { let attr_name = attribute_name.into(); let num: u8 = database::parse_int_attribute(&attr_name, attribute)?; ::from_u8(num).ok_or_else(|| { database::DBItemError::new( attr_name, database::Value::String(num.to_string()), database::DBItemAttributeError::IncorrectType, ) }) } } // ReportPlatform impl From for AttributeValue { fn from(value: ReportPlatform) -> Self { let raw = value.to_string().to_lowercase(); AttributeValue::S(raw) } } impl TryFromAttribute for ReportPlatform { fn try_from_attr( attribute_name: impl Into, attribute: Option, ) -> Result { let attr_name = attribute_name.into(); let raw = String::try_from_attr(&attr_name, attribute)?; // serde_json understands only quoted strings let quoted = format!("\"{raw}\""); serde_json::from_str("ed).map_err(|err| { debug!("Failed to deserialize ReportPlatform: {}", err); DBItemError::new( attr_name, database::Value::String(raw), database::DBItemAttributeError::IncorrectType, ) }) } } #[cfg(test)] mod tests { use comm_services_lib::database::AttributeTryInto; use super::*; #[test] fn test_platform_conversions() -> anyhow::Result<()> { let platform = ReportPlatform::MacOS; let attribute: AttributeValue = platform.into(); assert_eq!(attribute, AttributeValue::S("macos".to_string())); let converted_back: ReportPlatform = Some(attribute).attr_try_into("foo")?; assert!(matches!(converted_back, ReportPlatform::MacOS)); Ok(()) } #[test] fn test_type_conversions() -> anyhow::Result<()> { let report_type = ReportType::MediaMission; let numeric_type = (report_type as u8).to_string(); let attr: AttributeValue = report_type.into(); assert_eq!(attr, AttributeValue::N(numeric_type.to_string())); let converted_back: ReportType = Some(attr).attr_try_into("foo")?; assert!(matches!(converted_back, ReportType::MediaMission)); Ok(()) } }