diff --git a/src/api/config/mod.rs b/src/api/config/mod.rs new file mode 100644 index 0000000..e36d9da --- /dev/null +++ b/src/api/config/mod.rs @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +//! Route handler +//! for the `POST /config` endpoint. + +#[cfg(test)] +mod test; + +use rocket::{ + data::{self, FromData}, + http::Status, + Data, Request, State, +}; +use rocket_contrib::Json; + +use db::config::{Config as ConfigDb, Data as ConfigData}; +use logging::MozlogLogger; +use types::error::{AppError, AppErrorKind, AppResult}; + +/// Payload for `POST /config`. +#[derive(Debug, Deserialize)] +struct Payload { + /// Flag indicating whether this payload clobbers existing config or should be merged with it. + clobber: Option, + + /// The configuration data. + config: ConfigData, +} + +impl FromData for Payload { + type Error = AppError; + + fn from_data(request: &Request, data: Data) -> data::Outcome { + Json::::from_data(request, data) + .map_failure(|(_status, error)| { + ( + Status::BadRequest, + AppErrorKind::InvalidPayload(error.to_string()).into(), + ) + }) + .map(|json| json.into_inner()) + } +} + +#[post("/config", format = "application/json", data = "")] +fn handler( + payload: AppResult, + config_db: State, + // TODO: do we need the logger? + _logger: State, +) -> AppResult { + let mut payload = payload?; + if payload.clobber == Some(true) { + config_db.set("fxa", &payload.config)?; + } else { + config_db.merge("fxa", &mut payload.config)?; + }; + Ok(Json(json!({}))) +} diff --git a/src/api/config/test.rs b/src/api/config/test.rs new file mode 100644 index 0000000..234c177 --- /dev/null +++ b/src/api/config/test.rs @@ -0,0 +1,114 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +use rocket::{ + http::{ContentType, Status}, + local::Client, +}; + +use db::config::Config; +use logging::MozlogLogger; +use settings::Settings; +use types::error::{AppError, AppErrorKind}; + +fn setup() -> Client { + let mut settings = Settings::new().unwrap(); + settings.provider.forcedefault = false; + let config = Config::new(&settings); + let logger = MozlogLogger::new(&settings); + let server = rocket::ignite() + .manage(settings) + .manage(config) + .manage(logger) + .mount("/", routes![super::handler]); + + Client::new(server).unwrap() +} + +#[test] +fn request_with_all_the_things() { + let client = setup(); + + let mut response = client + .post("/config") + .header(ContentType::JSON) + .body( + r#"{ + "clobber": true, + "config": { + "default_provider": "sendgrid", + "limits": { + "enabled": true, + "complaint": [ + { "period": "1 day", "limit": 0 }, + { "period": "1 year", "limit": 1 } + ], + "hard": [ + { "period": "2 days", "limit": 2 }, + { "period": "2 years", "limit": 10 } + ], + "soft": [ + { "period": "3 minutes", "limit": 1 } + ] + }, + "queue": "https://sqs.us-east-1.amazonaws.com/1234567890/blee", + "rules": [ + { "percentage": 50, "precedence": -127, "provider": "sendgrid" }, + { "percentage": 100, "precedence": 0, "provider": "socketlabs", "regex": "^socketlabs@mozilla\\.com$" }, + { "percentage": 100, "precedence": 127, "provider": "ses" } + ], + "sender": { + "name": "Firefox Accounts", + "address": "accounts@firefox.com" + } + } + }"#, + ) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + + let body = response.body().unwrap().into_string().unwrap(); + assert_eq!(body, json!({}).to_string()); +} + +#[test] +fn request_without_optional_fields() { + let client = setup(); + + let mut response = client + .post("/config") + .header(ContentType::JSON) + .body( + r#"{ + "config": {} + }"#, + ) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + + let body = response.body().unwrap().into_string().unwrap(); + assert_eq!(body, json!({}).to_string()); +} + +#[test] +fn empty_request() { + let client = setup(); + + let mut response = client + .post("/config") + .header(ContentType::JSON) + .body("{}") + .dispatch(); + + assert_eq!(response.status(), Status::BadRequest); + + let body = response.body().unwrap().into_string().unwrap(); + let error: AppError = + AppErrorKind::InvalidPayload(String::from("missing field `config` at line 1 column 2")) + .into(); + let expected = serde_json::to_string(&error).unwrap(); + assert_eq!(body, expected); +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 2f2567d..93b6588 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,5 +6,6 @@ //! for modules pertaining to //! the API layer. +pub mod config; pub mod healthcheck; pub mod send; diff --git a/src/db/config/mod.rs b/src/db/config/mod.rs new file mode 100644 index 0000000..a2f44dc --- /dev/null +++ b/src/db/config/mod.rs @@ -0,0 +1,125 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +//! Storage for configuration data. + +use super::core::{Client as DbClient, DataType, Merge}; +use settings::{DeliveryProblemLimits, Sender, Settings, SqsUrl}; +use types::{error::AppResult, provider::Provider, regex::SerializableRegex}; + +#[cfg(test)] +mod test; + +/// Configuration data store. +/// +/// Data is keyed by client id. +#[derive(Debug)] +pub struct Config { + client: DbClient, +} + +impl Config { + /// Instantiate a storage client. + pub fn new(settings: &Settings) -> Self { + Self { + client: DbClient::new(settings), + } + } + + /// Read configuration data. + pub fn get(&self, client_id: &str) -> AppResult> { + self.client.get(client_id, DataType::Configuration) + } + + /// Store configuration data. + /// + /// Any data previously stored for the client id + /// will be replaced. + pub fn set(&self, client_id: &str, config: &Data) -> AppResult<()> { + self.client.set(client_id, config, DataType::Configuration) + } + + /// Merge configuration data with existing. + pub fn merge(&self, client_id: &str, config: &mut Data) -> AppResult<()> { + self.client + .merge(client_id, config, DataType::Configuration) + } +} + +/// Configuration data. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Data { + /// The fallback provider if no rules match and it's not specified in the call to `/send`. + default_provider: Option, + + /// Thresholds for bounce and complaint reports. + limits: Option, + + /// The queue for outgoing bounce, complaint and delivery notifications. + queue: Option, + + /// Rules for selectively diverting subsets of email traffic via different providers. + rules: Option>, + + /// The name and email address to use in the `From` and `Sender` headers. + sender: Option, +} + +impl Merge for Data { + fn merge(&self, with: &Self) -> Self { + Self { + default_provider: self + .default_provider + .as_ref() + .map(|default_provider| default_provider.clone()) + .or_else(|| with.default_provider.clone()), + limits: self + .limits + .as_ref() + .map(|limits| limits.clone()) + .or_else(|| with.limits.clone()), + queue: self + .queue + .as_ref() + .map(|queue| queue.clone()) + .or_else(|| with.queue.clone()), + rules: self + .rules + .as_ref() + .map(|rules| { + with.rules.as_ref().map_or_else( + || rules.clone(), + |with_rules| { + let mut with_rules = with_rules.clone(); + with_rules.extend(rules.clone()); + with_rules + }, + ) + }) + .or_else(|| with.rules.clone()), + sender: self + .sender + .as_ref() + .map(|sender| sender.clone()) + .or_else(|| with.sender.clone()), + } + } +} + +/// Rules for selectively diverting subsets of email traffic via different providers. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ProviderRules { + /// The percentage of prospective addresses to divert via this provider. + percentage: Option, + + /// Relative precedence of this ruleset against its peers. + /// Default is `0` (lower number equals higher precedence). + precedence: Option, + + /// The email provider for this ruleset. + provider: Provider, + + /// Filter for matching email addresses. + regex: Option, +} diff --git a/src/db/config/test.rs b/src/db/config/test.rs new file mode 100644 index 0000000..bd22a2e --- /dev/null +++ b/src/db/config/test.rs @@ -0,0 +1,66 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +use std::time::SystemTime; + +use regex::Regex; + +use super::*; +use db::core::test::TestFixture; +use settings::SenderName; + +#[test] +fn set() { + let settings = Settings::new().unwrap(); + let config = Config::new(&settings); + let key = create_key("set"); + let test = TestFixture::setup(&settings, &key, DataType::Configuration); + let expected = Data { + default_provider: Some(Provider::SocketLabs), + limits: Some(DeliveryProblemLimits { + enabled: true, + complaint: vec![], + hard: vec![], + soft: vec![], + }), + queue: Some(SqsUrl( + "https://sqs.us-east-1.amazonaws.com/1234567890/wibble".to_owned(), + )), + rules: Some(vec![ProviderRules { + percentage: Some(50), + precedence: Some(-127), + provider: Provider::Sendgrid, + regex: Some(SerializableRegex(Regex::new("wibble").unwrap())), + }]), + sender: Some(Sender { + name: SenderName("Firefox Accounts".to_owned()), + address: "accounts@firefox.com".parse().unwrap(), + }), + }; + + if let Err(error) = config.set(&key, &expected) { + assert!(false, format!("{:?}", error)); + } else { + test.assert_data(&expected); + } + + let data = config.get(&key).unwrap(); + assert_eq!(data, Some(expected)); +} + +#[test] +fn merge() { + // TODO +} + +fn create_key(test: &str) -> String { + format!("fxa-email-service.test.config.{}.{}", test, now()) +} + +fn now() -> u64 { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system time error"); + now.as_secs() * 1000 + u64::from(now.subsec_millis()) +} diff --git a/src/db/core/mod.rs b/src/db/core/mod.rs index 60aa29a..d8f4470 100644 --- a/src/db/core/mod.rs +++ b/src/db/core/mod.rs @@ -24,7 +24,7 @@ use serde_json; use sha2::Sha256; use settings::Settings; -use types::error::AppResult; +use types::error::{AppError, AppResult}; /// Database client. /// @@ -98,6 +98,40 @@ impl Client { .map_err(From::from) } + /// Merge data with existing stored data. + pub fn merge(&self, key: &str, data: &D, data_type: DataType) -> AppResult<()> + where + D: DeserializeOwned + Merge + Serialize, + { + let key = self.generate_key(key, data_type)?; + let key_str = key.as_str(); + self.client + .get(key_str) + .map_err(AppError::from) + .and_then(|value: Option| { + value.map_or_else( + || { + self.client + .set( + key_str, + serde_json::to_string(data).map_err(AppError::from)?, + ) + .map_err(From::from) + }, + |value| { + let merge_with = serde_json::from_str(&value).map_err(AppError::from)?; + self.client + .set( + key_str, + serde_json::to_string(&data.merge(&merge_with)) + .map_err(AppError::from)?, + ) + .map_err(From::from) + }, + ) + }) + } + fn generate_key(&self, key: &str, data_type: DataType) -> AppResult { let mut hmac: Hmac = Hmac::new_varkey(self.hmac_key.as_bytes())?; hmac.input(key.as_bytes()); @@ -108,6 +142,7 @@ impl Client { /// Date types included in this store. #[derive(Clone, Copy, Debug)] pub enum DataType { + Configuration, DeliveryProblem, MessageData, } @@ -115,6 +150,7 @@ pub enum DataType { impl AsRef for DataType { fn as_ref(&self) -> &str { match *self { + DataType::Configuration => "cfg", DataType::DeliveryProblem => "del", DataType::MessageData => "msg", } @@ -126,3 +162,7 @@ impl Display for DataType { write!(formatter, "{}", self.as_ref()) } } + +pub trait Merge { + fn merge(&self, with: &W) -> W; +} diff --git a/src/db/core/test.rs b/src/db/core/test.rs index ed45d7c..7bb6258 100644 --- a/src/db/core/test.rs +++ b/src/db/core/test.rs @@ -34,10 +34,6 @@ impl TestFixture { } } - pub fn key(&self) -> &str { - &self.unhashed_key - } - pub fn assert_not_set(&self) { let exists: bool = self.redis_client.exists(&self.internal_key).unwrap(); assert!(!exists); @@ -51,7 +47,7 @@ impl TestFixture { assert!(!exists); } - pub fn assert_data(&self, expected: D) + pub fn assert_data(&self, expected: &D) where D: Debug + DeserializeOwned + PartialEq, { @@ -62,6 +58,6 @@ impl TestFixture { .get(&self.internal_key) .map(|value: String| serde_json::from_str(&value).unwrap()) .unwrap(); - assert_eq!(data, expected); + assert_eq!(data, *expected); } } diff --git a/src/db/delivery_problems/test.rs b/src/db/delivery_problems/test.rs index d6bea45..2f24d26 100644 --- a/src/db/delivery_problems/test.rs +++ b/src/db/delivery_problems/test.rs @@ -486,7 +486,7 @@ fn record_bounce() { test.assert_data( // created_at is probably a millisecond or two different between MySQL and Redis - bounce_records + &bounce_records .into_iter() .rev() .map(From::from) @@ -530,7 +530,7 @@ fn record_complaint() { test.assert_data( // created_at is probably a millisecond or two different between MySQL and Redis - bounce_records + &bounce_records .into_iter() .map(From::from) .collect::>(), diff --git a/src/db/message_data/test.rs b/src/db/message_data/test.rs index d64795b..7745ada 100644 --- a/src/db/message_data/test.rs +++ b/src/db/message_data/test.rs @@ -17,7 +17,7 @@ fn set() { if let Err(error) = message_data.set(&key, "wibble") { assert!(false, format!("{:?}", error)); } else { - test.assert_data(String::from("wibble")); + test.assert_data(&String::from("wibble")); } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 57c045d..64d5e60 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,6 +7,7 @@ //! the database layer. pub mod auth_db; +pub mod config; mod core; pub mod delivery_problems; pub mod message_data; diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 765c917..d001a53 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -34,7 +34,7 @@ use types::{ macro_rules! deserialize_and_validate { ($(#[$docs:meta] ($type:ident, $validator:ident, $expected:expr)),+) => ($( #[$docs] - #[derive(Clone, Debug, Default, Serialize, PartialEq)] + #[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct $type(pub String); impl AsRef for $type { @@ -123,7 +123,7 @@ pub struct AwsKeys { } /// A definition object for a bounce/complaint limit. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct DeliveryProblemLimit { /// The time period /// within which to limit bounces/complaints. @@ -136,7 +136,7 @@ pub struct DeliveryProblemLimit { /// Controls the thresholds and behaviour /// for bounce and complaint reports. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct DeliveryProblemLimits { /// Controls whether to enable delivery problem limits. /// If set to `false`, @@ -192,7 +192,7 @@ pub struct Redis { /// Controls the name and email address /// that are used for the `From` and `Sender` /// email headers. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct Sender { /// The email address. pub address: EmailAddress, diff --git a/src/types/duration/mod.rs b/src/types/duration/mod.rs index 0f58332..764c31b 100644 --- a/src/types/duration/mod.rs +++ b/src/types/duration/mod.rs @@ -7,7 +7,10 @@ use std::convert::{From, TryFrom}; use regex::Regex; -use serde::de::{Deserialize, Deserializer, Error as SerdeError, Unexpected}; +use serde::{ + de::{Deserialize, Deserializer, Error as SerdeError, Unexpected}, + ser::{Serialize, Serializer}, +}; use types::error::{AppError, AppErrorKind, AppResult}; @@ -34,10 +37,12 @@ lazy_static! { /// for compatibility with /// the rest of the FxA ecosystem. /// -/// Can be deserialized from duration strings +/// Can be serialized to +/// and deserialized from +/// duration strings /// of the format `"{number} {period}"`, /// e.g. `"1 hour"` or `"10 minutes"`. -#[derive(Clone, Debug, Default, Serialize, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct Duration(pub u64); impl<'d> Deserialize<'d> for Duration { @@ -56,6 +61,35 @@ impl<'d> Deserialize<'d> for Duration { } } +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut duration = if self.0 % YEAR == 0 { + format!("{} year", self.0 / YEAR) + } else if self.0 % MONTH == 0 { + format!("{} month", self.0 / MONTH) + } else if self.0 % WEEK == 0 { + format!("{} week", self.0 / WEEK) + } else if self.0 % DAY == 0 { + format!("{} day", self.0 / DAY) + } else if self.0 % HOUR == 0 { + format!("{} hour", self.0 / HOUR) + } else if self.0 % MINUTE == 0 { + format!("{} minute", self.0 / MINUTE) + } else { + format!("{} second", self.0 / SECOND) + }; + + if self.0 > 1 { + duration = format!("{}s", duration); + } + + serializer.serialize_str(&duration) + } +} + impl From for u64 { fn from(value: Duration) -> u64 { value.0 diff --git a/src/types/mod.rs b/src/types/mod.rs index 6581bf0..ca10e35 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -78,4 +78,5 @@ pub mod error; pub mod headers; pub mod logging; pub mod provider; +pub mod regex; pub mod validate; diff --git a/src/types/regex/mod.rs b/src/types/regex/mod.rs new file mode 100644 index 0000000..a2744d1 --- /dev/null +++ b/src/types/regex/mod.rs @@ -0,0 +1,46 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +//! Maps duration strings to millisecond values. + +use regex::Regex; +use serde::{ + de::{Deserialize, Deserializer, Error as SerdeError, Unexpected}, + ser::{Serialize, Serializer}, +}; + +#[cfg(test)] +mod test; + +/// A regex wrapper +/// that can be (de)serialized. +#[derive(Clone, Debug)] +pub struct SerializableRegex(pub Regex); + +impl<'d> Deserialize<'d> for SerializableRegex { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'d>, + { + let value: String = Deserialize::deserialize(deserializer)?; + Regex::new(&value) + .map(SerializableRegex) + .map_err(|_| D::Error::invalid_value(Unexpected::Str(&value), &"regular expression")) + } +} + +impl PartialEq for SerializableRegex { + fn eq(&self, rhs: &Self) -> bool { + self.0.as_str() == rhs.0.as_str() + } +} + +impl Serialize for SerializableRegex { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} diff --git a/src/types/regex/test.rs b/src/types/regex/test.rs new file mode 100644 index 0000000..038b054 --- /dev/null +++ b/src/types/regex/test.rs @@ -0,0 +1,15 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. + +use super::*; + +#[test] +fn serialize_deserialize() { + let regex = SerializableRegex(Regex::new("foo").unwrap()); + let serialized = serde_json::to_string(®ex).unwrap(); + assert_eq!(serialized, "\"foo\""); + + let regex: SerializableRegex = serde_json::from_str(&serialized).unwrap(); + assert_eq!(regex.0.as_str(), "foo"); +}