fcm_service/
lib.rs

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