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#[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
32impl FcmService {
58 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 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)?;
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}