autoendpoint/routers/fcm/
client.rs1use crate::routers::common::message_size_check;
2use crate::routers::fcm::error::FcmError;
3use crate::routers::fcm::settings::{FcmServerCredential, FcmSettings};
4use crate::routers::RouterError;
5use reqwest::StatusCode;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9use std::time::Duration;
10use url::Url;
11use yup_oauth2::authenticator::DefaultAuthenticator;
12use yup_oauth2::{ServiceAccountAuthenticator, ServiceAccountKey};
13
14const OAUTH_SCOPES: &[&str] = &["https://www.googleapis.com/auth/firebase.messaging"];
15
16pub struct FcmClient {
19 endpoint: Url,
20 timeout: Duration,
21 max_data: usize,
22 authenticator: Option<DefaultAuthenticator>,
23 http_client: reqwest::Client,
24}
25
26impl FcmClient {
27 pub async fn new(
29 settings: &FcmSettings,
30 server_credential: FcmServerCredential,
31 http: reqwest::Client,
32 ) -> std::io::Result<Self> {
33 let auth = if server_credential.server_access_token.contains('{') {
38 trace!(
39 "Reading credential for {} from string...",
40 &server_credential.project_id
41 );
42 let key_data =
43 serde_json::from_str::<ServiceAccountKey>(&server_credential.server_access_token)?;
44 Some(
45 ServiceAccountAuthenticator::builder(key_data)
46 .build()
47 .await?,
48 )
49 } else {
50 if Path::new(&server_credential.server_access_token).exists() {
52 warn!(
53 "Reading credential for {} from file...",
54 &server_credential.project_id
55 );
56 let content = std::fs::read_to_string(&server_credential.server_access_token)?;
57 let key_data = serde_json::from_str::<ServiceAccountKey>(&content)?;
58 Some(
59 ServiceAccountAuthenticator::builder(key_data)
60 .build()
61 .await?,
62 )
63 } else {
64 trace!("Presuming {} is GCM", &server_credential.project_id);
65 None
66 }
67 };
68 Ok(FcmClient {
69 endpoint: settings
70 .base_url
71 .join(&format!(
72 "v1/projects/{}/messages:send",
73 server_credential.project_id
74 ))
75 .expect("Project ID is not URL-safe"),
76 timeout: Duration::from_secs(settings.timeout as u64),
77 max_data: settings.max_data,
78 authenticator: auth,
79 http_client: http,
80 })
81 }
82
83 pub async fn send(
85 &self,
86 data: HashMap<&'static str, String>,
87 routing_token: String,
88 ttl: u64,
89 ) -> Result<(), RouterError> {
90 let data_json = serde_json::to_string(&data).unwrap();
93 message_size_check(data_json.as_bytes(), self.max_data)?;
94
95 let message = serde_json::json!({
97 "message": {
98 "token": routing_token,
99 "android": {
100 "ttl": format!("{ttl}s"),
101 "data": data
102 }
103 }
104 });
105
106 let server_access_token = self
107 .authenticator
108 .as_ref()
109 .unwrap()
110 .token(OAUTH_SCOPES)
111 .await
112 .map_err(FcmError::OAuthToken)?;
113 let token = server_access_token.token().ok_or(FcmError::NoOAuthToken)?;
114
115 let response = self
117 .http_client
118 .post(self.endpoint.clone())
119 .header("Authorization", format!("Bearer {token}"))
120 .header("Content-Type", "application/json")
121 .json(&message)
122 .timeout(self.timeout)
123 .send()
124 .await
125 .map_err(|e| {
126 if e.is_timeout() {
127 RouterError::RequestTimeout
128 } else {
129 RouterError::Connect(e)
130 }
131 })?;
132
133 let status = response.status();
135 if status.is_client_error() || status.is_server_error() {
136 let raw_data = response
137 .bytes()
138 .await
139 .map_err(FcmError::DeserializeResponse)?;
140 if raw_data.is_empty() {
141 warn!("Empty FCM response [{status}]");
142 return Err(FcmError::EmptyResponse(status).into());
143 }
144 let data: FcmResponse = serde_json::from_slice(&raw_data).map_err(|e| {
145 let s = String::from_utf8(raw_data.to_vec()).unwrap_or_else(|e| e.to_string());
146 warn!("Invalid FCM response [{status}] \"{s}\"");
147 FcmError::InvalidResponse(e, s, status)
148 })?;
149
150 return Err(match (status, data.error) {
152 (StatusCode::UNAUTHORIZED, _) => RouterError::Authentication,
153 (StatusCode::NOT_FOUND, _) => RouterError::NotFound,
154 (_, Some(error)) => {
155 info!("🌉Bridge Error: {:?}, {:?}", error.message, &self.endpoint);
156 FcmError::Upstream {
157 error_code: error.status, message: error.message,
159 }
160 .into()
161 }
162 (_, None) => {
166 warn!(
167 "🌉Unknown Bridge Error: {:?}, <{:?}>, [{:?}]",
168 status.to_string(),
169 &self.endpoint,
170 raw_data,
171 );
172 FcmError::Upstream {
173 error_code: "UNKNOWN".to_string(),
174 message: format!("Unknown reason: {:?}", status.to_string()),
175 }
176 }
177 .into(),
178 });
179 }
180
181 Ok(())
182 }
183}
184
185#[derive(Deserialize)]
186struct FcmResponse {
187 error: Option<FcmErrorResponse>,
188}
189
190#[derive(Deserialize)]
192struct FcmErrorResponse {
193 status: String,
195 message: String,
196}
197
198#[cfg(test)]
199pub mod tests {
200 use crate::routers::fcm::client::FcmClient;
201 use crate::routers::fcm::error::FcmError;
202 use crate::routers::fcm::settings::{FcmServerCredential, FcmSettings};
203 use crate::routers::RouterError;
204 use std::collections::HashMap;
205 use url::Url;
206
207 pub const PROJECT_ID: &str = "yup-test-243420";
208 const ACCESS_TOKEN: &str = "ya29.c.ElouBywiys0LyNaZoLPJcp1Fdi2KjFMxzvYKLXkTdvM-rDfqKlvEq6PiMhGoGHx97t5FAvz3eb_ahdwlBjSStxHtDVQB4ZPRJQ_EOi-iS7PnayahU2S9Jp8S6rk";
209 pub const GCM_PROJECT_ID: &str = "valid_gcm_access_token";
210
211 pub fn make_service_key(server: &mockito::ServerGuard) -> String {
213 serde_json::json!({
215 "type": "service_account",
216 "project_id": PROJECT_ID,
217 "private_key_id": "26de294916614a5ebdf7a065307ed3ea9941902b",
218 "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDemmylrvp1KcOn\n9yTAVVKPpnpYznvBvcAU8Qjwr2fSKylpn7FQI54wCk5VJVom0jHpAmhxDmNiP8yv\nHaqsef+87Oc0n1yZ71/IbeRcHZc2OBB33/LCFqf272kThyJo3qspEqhuAw0e8neg\nLQb4jpm9PsqR8IjOoAtXQSu3j0zkXemMYFy93PWHjVpPEUX16NGfsWH7oxspBHOk\n9JPGJL8VJdbiAoDSDgF0y9RjJY5I52UeHNhMsAkTYs6mIG4kKXt2+T9tAyHw8aho\nwmuytQAfydTflTfTG8abRtliF3nil2taAc5VB07dP1b4dVYy/9r6M8Z0z4XM7aP+\nNdn2TKm3AgMBAAECggEAWi54nqTlXcr2M5l535uRb5Xz0f+Q/pv3ceR2iT+ekXQf\n+mUSShOr9e1u76rKu5iDVNE/a7H3DGopa7ZamzZvp2PYhSacttZV2RbAIZtxU6th\n7JajPAM+t9klGh6wj4jKEcE30B3XVnbHhPJI9TCcUyFZoscuPXt0LLy/z8Uz0v4B\nd5JARwyxDMb53VXwukQ8nNY2jP7WtUig6zwE5lWBPFMbi8GwGkeGZOruAK5sPPwY\nGBAlfofKANI7xKx9UXhRwisB4+/XI1L0Q6xJySv9P+IAhDUI6z6kxR+WkyT/YpG3\nX9gSZJc7qEaxTIuDjtep9GTaoEqiGntjaFBRKoe+VQKBgQDzM1+Ii+REQqrGlUJo\nx7KiVNAIY/zggu866VyziU6h5wjpsoW+2Npv6Dv7nWvsvFodrwe50Y3IzKtquIal\nVd8aa50E72JNImtK/o5Nx6xK0VySjHX6cyKENxHRDnBmNfbALRM+vbD9zMD0lz2q\nmns/RwRGq3/98EqxP+nHgHSr9QKBgQDqUYsFAAfvfT4I75Glc9svRv8IsaemOm07\nW1LCwPnj1MWOhsTxpNF23YmCBupZGZPSBFQobgmHVjQ3AIo6I2ioV6A+G2Xq/JCF\nmzfbvZfqtbbd+nVgF9Jr1Ic5T4thQhAvDHGUN77BpjEqZCQLAnUWJx9x7e2xvuBl\n1A6XDwH/ewKBgQDv4hVyNyIR3nxaYjFd7tQZYHTOQenVffEAd9wzTtVbxuo4sRlR\nNM7JIRXBSvaATQzKSLHjLHqgvJi8LITLIlds1QbNLl4U3UVddJbiy3f7WGTqPFfG\nkLhUF4mgXpCpkMLxrcRU14Bz5vnQiDmQRM4ajS7/kfwue00BZpxuZxst3QKBgQCI\nRI3FhaQXyc0m4zPfdYYVc4NjqfVmfXoC1/REYHey4I1XetbT9Nb/+ow6ew0UbgSC\nUZQjwwJ1m1NYXU8FyovVwsfk9ogJ5YGiwYb1msfbbnv/keVq0c/Ed9+AG9th30qM\nIf93hAfClITpMz2mzXIMRQpLdmQSR4A2l+E4RjkSOwKBgQCB78AyIdIHSkDAnCxz\nupJjhxEhtQ88uoADxRoEga7H/2OFmmPsqfytU4+TWIdal4K+nBCBWRvAX1cU47vH\nJOlSOZI0gRKe0O4bRBQc8GXJn/ubhYSxI02IgkdGrIKpOb5GG10m85ZvqsXw3bKn\nRVHMD0ObF5iORjZUqD0yRitAdg==\n-----END PRIVATE KEY-----\n",
219 "client_email": "yup-test-sa-1@yup-test-243420.iam.gserviceaccount.com",
220 "client_id": "102851967901799660408",
221 "auth_uri": "https://accounts.google.com/o/oauth2/auth",
222 "token_uri": server.url() + "/token",
223 "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
224 "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/yup-test-sa-1%40yup-test-243420.iam.gserviceaccount.com"
225 }).to_string()
226 }
227
228 pub async fn mock_token_endpoint(server: &mut mockito::ServerGuard) -> mockito::Mock {
230 server
231 .mock("POST", "/token")
232 .with_body(
233 serde_json::json!({
234 "access_token": ACCESS_TOKEN,
235 "expires_in": 3600,
236 "token_type": "Bearer"
237 })
238 .to_string(),
239 )
240 .create_async()
241 .await
242 }
243
244 pub fn mock_fcm_endpoint_builder(server: &mut mockito::ServerGuard, id: &str) -> mockito::Mock {
246 server.mock("POST", format!("/v1/projects/{id}/messages:send").as_str())
247 }
248
249 async fn make_client(
251 server: &mockito::ServerGuard,
252 credential: FcmServerCredential,
253 ) -> FcmClient {
254 FcmClient::new(
255 &FcmSettings {
256 base_url: Url::parse(&server.url()).unwrap(),
257 server_credentials: serde_json::json!(credential).to_string(),
258 ..Default::default()
259 },
260 credential,
261 reqwest::Client::new(),
262 )
263 .await
264 .unwrap()
265 }
266
267 #[tokio::test]
270 async fn sends_correct_fcm_request() {
271 let mut server = mockito::Server::new_async().await;
272
273 let client = make_client(
274 &server,
275 FcmServerCredential {
276 project_id: PROJECT_ID.to_owned(),
277 is_gcm: None,
278 server_access_token: make_service_key(&server),
279 },
280 )
281 .await;
282 let _token_mock = mock_token_endpoint(&mut server).await;
283 let fcm_mock = mock_fcm_endpoint_builder(&mut server, PROJECT_ID)
284 .match_header("Authorization", format!("Bearer {ACCESS_TOKEN}").as_str())
285 .match_header("Content-Type", "application/json")
286 .match_body(r#"{"message":{"android":{"data":{"is_test":"true"},"ttl":"42s"},"token":"test-token"}}"#)
287 .create();
288
289 let mut data = HashMap::new();
290 data.insert("is_test", "true".to_string());
291
292 let result = client.send(data, "test-token".to_string(), 42).await;
293 assert!(result.is_ok(), "result = {result:?}");
294 fcm_mock.assert();
295 }
296
297 #[tokio::test]
299 async fn unauthorized() {
300 let mut server = mockito::Server::new_async().await;
301
302 let client = make_client(
303 &server,
304 FcmServerCredential {
305 project_id: PROJECT_ID.to_owned(),
306 is_gcm: None,
307 server_access_token: make_service_key(&server),
308 },
309 )
310 .await;
311 let _token_mock = mock_token_endpoint(&mut server).await;
312 let _fcm_mock = mock_fcm_endpoint_builder(&mut server, PROJECT_ID)
313 .with_status(401)
314 .with_body(r#"{"error":{"status":"UNAUTHENTICATED","message":"test-message"}}"#)
315 .create_async()
316 .await;
317
318 let result = client
319 .send(HashMap::new(), "test-token".to_string(), 42)
320 .await;
321 assert!(result.is_err());
322 assert!(
323 matches!(result.as_ref().unwrap_err(), RouterError::Authentication),
324 "result = {result:?}"
325 );
326 }
327
328 #[tokio::test]
330 async fn not_found() {
331 let mut server = mockito::Server::new_async().await;
332
333 let client = make_client(
334 &server,
335 FcmServerCredential {
336 project_id: PROJECT_ID.to_owned(),
337 is_gcm: None,
338 server_access_token: make_service_key(&server),
339 },
340 )
341 .await;
342 let _token_mock = mock_token_endpoint(&mut server).await;
343 let _fcm_mock = mock_fcm_endpoint_builder(&mut server, PROJECT_ID)
344 .with_status(404)
345 .with_body(r#"{"error":{"status":"NOT_FOUND","message":"test-message"}}"#)
346 .create_async()
347 .await;
348
349 let result = client
350 .send(HashMap::new(), "test-token".to_string(), 42)
351 .await;
352 assert!(result.is_err());
353 assert!(
354 matches!(result.as_ref().unwrap_err(), RouterError::NotFound),
355 "result = {result:?}"
356 );
357 }
358
359 #[tokio::test]
361 async fn other_fcm_error() {
362 let mut server = mockito::Server::new_async().await;
363
364 let client = make_client(
365 &server,
366 FcmServerCredential {
367 project_id: PROJECT_ID.to_owned(),
368 is_gcm: Some(false),
369 server_access_token: make_service_key(&server),
370 },
371 )
372 .await;
373 let _token_mock = mock_token_endpoint(&mut server).await;
374 let _fcm_mock = mock_fcm_endpoint_builder(&mut server, PROJECT_ID)
375 .with_status(400)
376 .with_body(r#"{"error":{"status":"TEST_ERROR","message":"test-message"}}"#)
377 .create_async()
378 .await;
379
380 let result = client
381 .send(HashMap::new(), "test-token".to_string(), 42)
382 .await;
383 assert!(result.is_err());
384 assert!(
385 matches!(
386 result.as_ref().unwrap_err(),
387 RouterError::Fcm(FcmError::Upstream{ error_code, message })
388 if error_code == "TEST_ERROR" && message == "test-message"
389 ),
390 "result = {result:?}"
391 );
392 }
393
394 #[tokio::test]
396 async fn unknown_fcm_error() {
397 let mut server = mockito::Server::new_async().await;
398
399 let client = make_client(
400 &server,
401 FcmServerCredential {
402 project_id: PROJECT_ID.to_owned(),
403 is_gcm: Some(true),
404 server_access_token: make_service_key(&server),
405 },
406 )
407 .await;
408 let _token_mock = mock_token_endpoint(&mut server).await;
409 let _fcm_mock = mock_fcm_endpoint_builder(&mut server, PROJECT_ID)
410 .with_status(400)
411 .with_body("{}")
412 .create_async()
413 .await;
414
415 let result = client
416 .send(HashMap::new(), "test-token".to_string(), 42)
417 .await;
418 assert!(result.is_err());
419 assert!(
420 matches!(
421 result.as_ref().unwrap_err(),
422 RouterError::Fcm(FcmError::Upstream { error_code, message })
423 if error_code == "UNKNOWN" && message.starts_with("Unknown reason")
424 ),
425 "result = {result:?}"
426 );
427 }
428}