diff --git a/services/comm-services-lib/src/database.rs b/services/comm-services-lib/src/database.rs --- a/services/comm-services-lib/src/database.rs +++ b/services/comm-services-lib/src/database.rs @@ -115,6 +115,38 @@ } } +/// 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, diff --git a/services/reports/src/database/item.rs b/services/reports/src/database/item.rs --- a/services/reports/src/database/item.rs +++ b/services/reports/src/database/item.rs @@ -1,5 +1,11 @@ -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; @@ -7,6 +13,142 @@ 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