apnoxide 0.1.1

Apple Push Notification Service for Rust built on `reqwest`
Documentation
use crate::serialize::{JsonObjectError, StructWrapper};
use reqwest::header::HeaderMap;
use serde::Serialize;
use serde_json::{Map, Value};
use snafu::{ResultExt, Snafu};
use serde_with::{serde_as, BoolFromInt};

#[derive(Snafu, Debug)]
#[non_exhaustive]
pub enum BuildError {
    ConvertJsonObjectError { source: JsonObjectError },
}

#[derive(Serialize, Debug)]
pub enum Title {
    #[serde(rename = "title")]
    Normal(String),
    #[serde(untagged)]
    Localized {
        #[serde(rename = "title-loc-key")]
        key: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(rename = "title-loc-args")]
        args: Option<Vec<String>>,
    },
}

#[derive(Serialize, Debug)]
pub enum Subtitle {
    #[serde(rename = "subtitle")]
    Normal(String),
    #[serde(untagged)]
    Localized {
        #[serde(rename = "subtitle-loc-key")]
        key: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(rename = "subtitle-loc-args")]
        args: Option<Vec<String>>,
    },
}

#[derive(Serialize, Debug)]
pub enum Body {
    #[serde(rename = "body")]
    Normal(String),
    #[serde(untagged)]
    Localized {
        #[serde(rename = "loc-key")]
        key: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(rename = "loc-args")]
        args: Option<Vec<String>>,
    },
}

#[derive(Serialize, Debug)]
#[serde(untagged)]
pub enum Alert {
    Body(String),
    Full {
        #[serde(flatten)]
        title: Option<Title>,
        #[serde(flatten)]
        subtitle: Option<Subtitle>,
        #[serde(flatten)]
        body: Option<Body>,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(rename = "launch-image")]
        launch_image: Option<String>,
    },
}

#[serde_as]
#[derive(Serialize, Debug)]
#[serde(untagged)]
pub enum Sound {
    Regular(String),
    Critical {
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde_as(as = "Option<BoolFromInt>")]
        critical: Option<bool>,
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        volume: Option<f64>,
    },
}

#[derive(Serialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum InterruptionLevel {
    Passive,
    Active,
    TimeSensitive,
    Critical,
}

#[serde_as]
#[derive(Serialize, Default, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Notification {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alert: Option<Alert>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub badge: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sound: Option<Sound>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde_as(as = "Option<BoolFromInt>")]
    pub content_available: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde_as(as = "Option<BoolFromInt>")]
    pub mutable_content: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_content_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub interruption_level: Option<InterruptionLevel>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub relevance_score: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filter_criteria: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stale_date: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content_state: Option<Map<String, Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub event: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dismissal_date: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attributes_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attributes: Option<Map<String, Value>>,
}

impl Notification {
    pub fn with_content_state<T: Serialize>(mut self, state: T) -> Result<Self, BuildError> {
        self.content_state = Some(
            StructWrapper(state)
                .try_into()
                .context(ConvertJsonObjectSnafu)?,
        );
        Ok(self)
    }

    pub fn with_attributes<T: Serialize>(mut self, attributes: T) -> Result<Self, BuildError> {
        self.attributes = Some(
            StructWrapper(attributes)
                .try_into()
                .context(ConvertJsonObjectSnafu)?,
        );
        Ok(self)
    }
}

#[derive(Serialize, Default, Debug)]
pub struct Payload {
    pub aps: Notification,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(flatten)]
    pub custom: Option<Map<String, Value>>,
}

impl Payload {
    pub fn with_custom<T: Serialize>(mut self, custom: T) -> Result<Self, BuildError> {
        self.custom = Some(
            StructWrapper(custom)
                .try_into()
                .context(ConvertJsonObjectSnafu)?,
        );
        Ok(self)
    }
}

pub struct Endpoint {
    endpoint: String,
    port: u16,
}

impl From<Endpoint> for String {
    fn from(value: Endpoint) -> Self {
            format!("https://{}:{}", value.endpoint, value.port)
    }
}

impl TryFrom<String> for Endpoint {
    type Error = ();

    fn try_from(value: String) -> Result<Self, Self::Error> {
        let value = value.split(":").collect::<Vec<_>>();
        if value.len() != 2 {
            return Err(());
        };
        Ok(Self {
            endpoint: value[0].to_string(),
            port: value[1].parse().map_err(|_| ())?,
        })
    }
}

impl Endpoint {
    pub fn development() -> Self {
        Self {
            endpoint: "api.sandbox.push.apple.com".to_string(),
            port: 443,
        }
    }

    pub fn development_alter() -> Self {
        Self {
            endpoint: "api.sandbox.push.apple.com".to_string(),
            port: 2197,
        }
    }

    pub fn production() -> Self {
        Self {
            endpoint: "api.push.apple.com".to_string(),
            port: 443,
        }
    }

    pub fn production_alter() -> Self {
        Self {
            endpoint: "api.push.apple.com".to_string(),
            port: 2197,
        }
    }
}

impl Default for Endpoint {
    fn default() -> Self {
        Self::production()
    }
}

#[derive(Default)]
pub struct PushOption<'a> {
    pub push_type: Option<&'a str>,
    pub id: Option<&'a str>,
    pub expiration: Option<u128>,
    pub priority: Option<u8>,
    pub topic: &'a str,
    pub collapse_id: Option<&'a str>,
}

impl TryFrom<PushOption<'_>> for HeaderMap {
    type Error = ();

    fn try_from(value: PushOption) -> Result<Self, Self::Error> {
        let mut headers = Self::new();
        if let Some(push_type) = value.push_type {
            headers.insert("apns-push-type", push_type.parse().map_err(|_| ())?);
        }
        if let Some(id) = value.id {
            headers.insert("apns-id", id.parse().map_err(|_| ())?);
        }
        if let Some(expiration) = value.expiration {
            headers.insert(
                "apns-expiration",
                expiration.to_string().parse().map_err(|_| ())?,
            );
        }
        if let Some(priority) = value.priority {
            headers.insert(
                "apns-priority",
                priority.to_string().parse().map_err(|_| ())?,
            );
        }
        if let Some(collapse_id) = value.collapse_id {
            headers.insert("apns-collapse-id", collapse_id.parse().map_err(|_| ())?);
        }
        headers.insert("apns-topic", value.topic.parse().map_err(|_| ())?);
        Ok(headers)
    }
}

#[cfg(test)]
mod tests {
    use serde::Serialize;
    use crate::{Alert, InterruptionLevel, Notification, Payload, Sound, Subtitle, Title};

    #[test]
    fn test_empty() {
        let aps = Notification::default();
        let json = serde_json::to_string(&aps).unwrap();
        assert_eq!("{}", json);
    }

    #[test]
    fn test_filled() {
        #[derive(Serialize)]
        struct Attr {
            attr: String,
        }

        let attr = Attr {
            attr: "foo".to_string(),
        };

        let aps = Notification {
            alert: Some(Alert::Full {
                title: Some(Title::Normal("Title".to_string())),
                subtitle: Some(Subtitle::Localized {
                    key: "SUBTITLE_KEY".to_string(),
                    args: None,
                }),
                body: None,
                launch_image: None,
            }),
            sound: Some(Sound::Critical {
                critical: Some(true),
                name: None,
                volume: None,
            }),
            mutable_content: Some(true),
            interruption_level: Some(InterruptionLevel::TimeSensitive),
            ..Notification::default()
        }
        .with_attributes(attr)
        .unwrap();
        let json = serde_json::to_string(&aps).unwrap();
        assert_eq!(
            "{\"alert\":{\"title\":\"Title\",\"subtitle-loc-key\":\"SUBTITLE_KEY\"},\"sound\":{\"critical\":1},\"mutable-content\":1,\"interruption-level\":\"time-sensitive\",\"attributes\":{\"attr\":\"foo\"}}",
            json
        )
    }

    #[test]
    fn test_custom_payload() {
        #[derive(Serialize)]
        struct Custom {
            payload: String,
        }

        let custom = Custom {
            payload: String::from("payload"),
        };
        let notification = Payload {
            ..Payload::default()
        }
        .with_custom(custom)
        .unwrap();
        let json = serde_json::to_string(&notification).unwrap();
        assert_eq!("{\"aps\":{},\"payload\":\"payload\"}", json)
    }
}