fcm_service/
lib.rs

1use gcp_auth::CustomServiceAccount;
2use gcp_auth::TokenProvider;
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::error::Error;
7use std::fs;
8use std::io;
9use std::path::PathBuf;
10mod domain;
11pub use domain::AndroidConfig;
12pub use domain::ApnsConfig;
13pub use domain::FcmMessage;
14pub use domain::FcmNotification;
15pub use domain::FcmOptions;
16pub use domain::Target;
17pub use domain::WebpushConfig;
18
19/// Wrapper struct for FCM payload, required by the FCM v1 API.
20#[derive(Clone, Debug, Serialize, Deserialize, Default)]
21pub struct FcmPayload {
22    pub message: FcmMessage,
23}
24
25pub struct FcmService {
26    pub credential_file: String,
27}
28
29impl FcmService {
30    pub fn new(credential_file: impl Into<String>) -> Self {
31        Self {
32            credential_file: credential_file.into(),
33        }
34    }
35}
36
37/// Service for sending Firebase Cloud Messaging (FCM) notifications using the v1 API.
38///
39/// This service uses a Google Cloud service account credential file to authenticate
40/// and send notifications to FCM.
41///
42/// # Examples
43/// ```rust,no_run
44/// use fcm_service::{FcmService, FcmMessage, FcmNotification, Target};
45///
46/// #[tokio::main]
47/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
48///     let service = FcmService::new("path/to/service-account.json");
49///
50///     let mut message = FcmMessage::new();
51///     let mut notification = FcmNotification::new();
52///     notification.set_title("Hello".to_string());
53///     notification.set_body("World".to_string());
54///     notification.set_image(None);
55///     message.set_notification(Some(notification));
56///     message.set_target(Target::Token("device-token".to_string()));
57///
58///     service.send_notification(message).await?;
59///     Ok(())
60/// }
61/// ```
62impl FcmService {
63    /// Extracts the project ID from the service account credential file.
64    fn get_project_id(&self) -> Result<String, Box<dyn Error>> {
65        let content = fs::read_to_string(&self.credential_file)?;
66        let json: Value = serde_json::from_str(&content)?;
67
68        json.get("project_id")
69            .and_then(|v| v.as_str())
70            .map(std::string::ToString::to_string)
71            .ok_or_else(|| {
72                io::Error::new(io::ErrorKind::InvalidData, "project_id not found").into()
73            })
74    }
75    /// Sends an FCM notification asynchronously.
76    ///
77    /// # Errors
78    /// Returns an error if:
79    /// - The credential file cannot be read or parsed
80    /// - Authentication with GCP fails
81    /// - The HTTP request to FCM fails
82    /// - The FCM API returns an unsuccessful status
83    pub async fn send_notification(&self, message: FcmMessage) -> Result<(), Box<dyn Error>> {
84        let project_id = self.get_project_id()?;
85        let client = Client::new();
86        let credentials_path = PathBuf::from(&self.credential_file);
87        // let service_account = CustomServiceAccount::from_file(credentials_path)?;
88        let service_account = CustomServiceAccount::from_file(credentials_path)?;
89        let scopes = &["https://www.googleapis.com/auth/firebase.messaging"];
90        let token = service_account.token(scopes).await?;
91        let url = format!("https://fcm.googleapis.com/v1/projects/{project_id}/messages:send");
92
93        let payload = FcmPayload { message };
94
95        let response = client
96            .post(&url)
97            .bearer_auth(token.as_str())
98            .json(&payload)
99            .send()
100            .await?;
101
102        if response.status().is_success() {
103            response.text().await?;
104
105            Ok(())
106        } else {
107            let error_text = response.text().await?;
108            Err(format!("Failed to send notification: {error_text:#?}").into())
109        }
110    }
111}
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs::File;
116    use std::io::Write;
117    use tempfile;
118
119    fn setup_dummy_credentials(temp_dir: &tempfile::TempDir) -> String {
120        let credential_path = temp_dir.path().join("service-account.json");
121        let mut file = File::create(&credential_path).unwrap();
122        writeln!(
123            file,
124            r#"{{"project_id": "test-project", "client_email": "test@example.com"}}"#
125        )
126        .unwrap();
127        credential_path.to_str().unwrap().to_string()
128    }
129
130    #[test]
131    fn test_new_service() {
132        let service = FcmService::new("dummy.json");
133        assert_eq!(service.credential_file, "dummy.json");
134    }
135
136    #[test]
137    fn test_get_project_id_success() {
138        let temp_dir = tempfile::tempdir().unwrap();
139        let credential_file = setup_dummy_credentials(&temp_dir);
140        let service = FcmService::new(credential_file);
141        let project_id = service.get_project_id().unwrap();
142        assert_eq!(project_id, "test-project");
143    }
144
145    #[test]
146    fn test_get_project_id_missing_file() {
147        let service = FcmService::new("nonexistent.json");
148        let result = service.get_project_id();
149        assert!(result.is_err());
150        assert!(matches!(
151            result.unwrap_err().downcast_ref::<io::Error>(),
152            Some(err) if err.kind() == io::ErrorKind::NotFound
153        ));
154    }
155
156    #[test]
157    fn test_get_project_id_invalid_json() {
158        let temp_dir = tempfile::tempdir().unwrap();
159        let credential_path = temp_dir.path().join("service-account.json");
160        let mut file = File::create(&credential_path).unwrap();
161        writeln!(file, "invalid json").unwrap();
162        let service = FcmService::new(credential_path.to_str().unwrap());
163        let result = service.get_project_id();
164        assert!(result.is_err());
165    }
166}