fcm_service/
lib.rs

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