autoendpoint/routers/fcm/
client.rs

1use 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
16/// Holds application-specific Firebase data and authentication. This client
17/// handles sending notifications to Firebase.
18pub 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    /// Create an `FcmClient` using the provided credential
28    pub async fn new(
29        settings: &FcmSettings,
30        server_credential: FcmServerCredential,
31        http: reqwest::Client,
32    ) -> std::io::Result<Self> {
33        // `map`ping off of `serde_json::from_str` gets hairy and weird, requiring
34        // async blocks and a number of other specialty items. Doing a very stupid
35        // json detection does not. FCM keys are serialized JSON constructs.
36        // These are both set in the settings and come from the `credentials` value.
37        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            // check to see if this is a path to a file, and read in the credentials.
51            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    /// Send the message data to FCM
84    pub async fn send(
85        &self,
86        data: HashMap<&'static str, String>,
87        routing_token: String,
88        ttl: u64,
89    ) -> Result<(), RouterError> {
90        // Check the payload size. FCM only cares about the `data` field when
91        // checking size.
92        let data_json = serde_json::to_string(&data).unwrap();
93        message_size_check(data_json.as_bytes(), self.max_data)?;
94
95        // Build the FCM message
96        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        // Make the request
116        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        // Handle error
134        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            // we only ever send one.
151            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, // Note: this is the FCM error status enum value
158                        message: error.message,
159                    }
160                    .into()
161                }
162                // In this case, we've gotten an error, but FCM hasn't returned a body.
163                // (This may happen in the case where FCM terminates the connection abruptly
164                // or a similar event.) Treat that as an INTERNAL error.
165                (_, 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/// Response message from FCM in the case of an error.
191#[derive(Deserialize)]
192struct FcmErrorResponse {
193    /// The ErrorCode enum as string from https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
194    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    /// Write service data to a temporary file
212    pub fn make_service_key(server: &mockito::ServerGuard) -> String {
213        // Taken from the yup-oauth2 tests
214        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    /// Mock the OAuth token endpoint to provide the access token
229    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    /// Start building a mock for the FCM endpoint
245    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    /// Make a FcmClient from the service auth data
250    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    /// The FCM client uses the access token and parameters to build the
268    /// expected FCM request.
269    #[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    /// Authorization errors are handled
298    #[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    /// 404 errors are handled
329    #[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    /// Unhandled errors (where an error object is returned) are wrapped and returned
360    #[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    /// Unknown errors (where an error object is NOT returned) is handled
395    #[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}