From 6ec7e92d0e622981f91df754ace79d8f75c169f4 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 19 May 2024 23:17:39 +0300 Subject: [PATCH 01/40] Revert "nix settings" This reverts commit ca6c7e747772e103447d62cc6c0e3dbe6ab9a8f4. --- .envrc | 6 ---- .gitignore | 1 - flake.lock | 80 ------------------------------------------------------ flake.nix | 41 ---------------------------- 4 files changed, 128 deletions(-) delete mode 100644 .envrc delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index d66fb71d8..000000000 --- a/.envrc +++ /dev/null @@ -1,6 +0,0 @@ -export TIBERIUS_TEST_CONNECTION_STRING="server=tcp:localhost,1433;user=SA;password=;TrustServerCertificate=true" -export DOCKER_BUILDKIT=1 -if command -v nix-shell &> /dev/null -then - use flake -fi diff --git a/.gitignore b/.gitignore index e5ca28a4d..741ba858a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ Cargo.lock .DS_Store -.direnv .idea/ # local env files diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 5abaeecc1..000000000 --- a/flake.lock +++ /dev/null @@ -1,80 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "locked": { - "lastModified": 1638122382, - "narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "74f7e4319258e287b0f9cb95426c9853b282730b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { - "locked": { - "lastModified": 1637014545, - "narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1642104392, - "narHash": "sha256-m71b7MgMh9FDv4MnI5sg9MiBVW6DhE1zq+d/KlLWSC8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "5aaed40d22f0d9376330b6fa413223435ad6fee5", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1642387353, - "narHash": "sha256-CmpIo2whHN1ESXuKl9lL9CRJVK8YuEfV2JURFqmWNmw=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "c76db6730b6bc150c49c9dcefc2323785516d1dc", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 5a42dbae7..000000000 --- a/flake.nix +++ /dev/null @@ -1,41 +0,0 @@ -{ - description = "A Microsoft SQL Server TDS client"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - flake-utils = { - url = "github:numtide/flake-utils"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = { self, nixpkgs, flake-utils, rust-overlay }: - flake-utils.lib.eachDefaultSystem (system: - let - overlays = [ (import rust-overlay) ]; - pkgs = import nixpkgs { inherit system overlays; }; - in { - nixpkgs.overlays = [ rust-overlay.overlay ]; - devShell = pkgs.mkShell { - nativeBuildInputs = [ pkgs.bashInteractive ]; - buildInputs = with pkgs; [ - gettext - openssl - pkg-config - clangStdenv - llvmPackages.libclang.lib - kerberos - rust-bin.stable.latest.default - ]; - shellHook = with pkgs; '' - export LIBCLANG_PATH="${llvmPackages.libclang.lib}/lib"; - ''; - }; - }); -} From caa4c4c6ca4abd9b2299b8673960c5955a0eb380 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 19 May 2024 23:22:30 +0300 Subject: [PATCH 02/40] Remove unnecessary documentation config --- src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 259f34f97..3a642f4bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -#![doc(html_root_url = "https://panicbit.github.io/fcm-rust/fcm/")] //! fcm //! === //! From 8961acd86e16e290d852119be3f012495378dc02 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 19 May 2024 23:56:03 +0300 Subject: [PATCH 03/40] Remove unnecessary dependencies --- Cargo.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6e465a1fe..9f33d41f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,8 @@ vendored-tls = ["reqwest/native-tls-vendored"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -erased-serde = "0.4.1" reqwest = {version = "0.11.0", features = ["json"], default-features=false} chrono = "0.4" -log = "0.4" gauth = "0.7.0" dotenv = "0.15.0" From 25d0f030f6fb0e61b8421a81e1d377ee9998beec Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 07:50:25 +0300 Subject: [PATCH 04/40] Remove .env support --- .env.example | 2 -- .gitignore | 5 ----- Cargo.toml | 1 - examples/simple_sender.rs | 2 +- src/client/mod.rs | 21 +++++++-------------- 5 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index a4224f84b..000000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# follow the instructions in the [Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to create a service account. after you create a service account, and download the json file then change the value of `GOOGLE_APPLICATION_CREDENTIALS` to the path of the json file you downloaded. -GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/firebase/file.json" diff --git a/.gitignore b/.gitignore index 741ba858a..d32455529 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,3 @@ Cargo.lock .DS_Store .idea/ - -# local env files -# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables -.env -.env*.local diff --git a/Cargo.toml b/Cargo.toml index 9f33d41f1..0d73812fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ serde_json = { version = "1", features = ["preserve_order"] } reqwest = {version = "0.11.0", features = ["json"], default-features=false} chrono = "0.4" gauth = "0.7.0" -dotenv = "0.15.0" [dev-dependencies] argparse = "0.2.1" diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index e5fc85300..b21aecc04 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -20,7 +20,7 @@ async fn main() -> Result<(), Box> { ap.parse_args_or_exit(); } - let client = Client::new(); + let client = Client::new("service-account-key.json".to_string()); let data = json!({ "key": "value", diff --git a/src/client/mod.rs b/src/client/mod.rs index f2418fa87..160860b96 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -10,12 +10,7 @@ use serde::Serialize; /// An async client for sending the notification payload. pub struct Client { http_client: reqwest::Client, -} - -impl Default for Client { - fn default() -> Self { - Self::new() - } + key_path: String, } // will be used to wrap the message in a "message" field @@ -33,22 +28,20 @@ impl MessageWrapper<'_> { impl Client { /// Get a new instance of Client. - pub fn new() -> Client { + pub fn new(key_path: String) -> Client { let http_client = reqwest::ClientBuilder::new() .pool_max_idle_per_host(usize::MAX) .build() .unwrap(); - Client { http_client } + Client { + http_client, + key_path, + } } fn get_service_key_file_name(&self) -> Result { - let key_path = match dotenv::var("GOOGLE_APPLICATION_CREDENTIALS") { - Ok(key_path) => key_path, - Err(err) => return Err(err.to_string()), - }; - - Ok(key_path) + Ok(self.key_path.clone()) } fn read_service_key_file(&self) -> Result { From dacd72ca029f66b8631c9818adacd0d8c066ff51 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 08:09:08 +0300 Subject: [PATCH 05/40] Update example Change argument parsing to use clap and remove pretty_env_logger. --- Cargo.toml | 3 +-- examples/simple_sender.rs | 31 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0d73812fc..e27e9172b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,5 @@ chrono = "0.4" gauth = "0.7.0" [dev-dependencies] -argparse = "0.2.1" tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } -pretty_env_logger = "0.5.0" +clap = { version = "4.5.4", features = ["cargo", "derive"] } diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index b21aecc04..2a67a4856 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -1,26 +1,25 @@ -// cargo run --example simple_sender -- -t +// cargo run --example simple_sender -- --help -use argparse::{ArgumentParser, Store}; +use std::path::PathBuf; + +use clap::Parser; use fcm::{ AndroidConfig, AndroidNotification, ApnsConfig, Client, FcmOptions, Message, Notification, Target, WebpushConfig, }; use serde_json::json; +#[derive(Parser, Debug)] +struct CliArgs { + #[arg(long)] + device_token: String, + #[arg(long, value_name = "FILE")] + service_account_key_path: PathBuf, +} + #[tokio::main] async fn main() -> Result<(), Box> { - pretty_env_logger::init(); - - let mut device_token = String::new(); - - { - let mut ap = ArgumentParser::new(); - ap.set_description("A simple FCM notification sender"); - ap.refer(&mut device_token) - .add_option(&["-t", "--device_token"], Store, "Device token"); - ap.parse_args_or_exit(); - } - - let client = Client::new("service-account-key.json".to_string()); + let args = CliArgs::parse(); + let client = Client::new(args.service_account_key_path.to_str().unwrap().to_string()); let data = json!({ "key": "value", @@ -33,7 +32,7 @@ async fn main() -> Result<(), Box> { body: Some(format!("it's {}", chrono::Utc::now())), ..Default::default() }), - target: Target::Token(device_token), + target: Target::Token(args.device_token), fcm_options: Some(FcmOptions { analytics_label: "analytics_label".to_string(), }), From 921f2a458cda769552d841743b9aa81dd812a1d3 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 16:08:41 +0300 Subject: [PATCH 06/40] Rewrite client --- Cargo.toml | 5 +- examples/simple_sender.rs | 8 +- src/client/mod.rs | 226 +++++++++------------ src/client/response.rs | 410 +++++++++++++++++++------------------- src/lib.rs | 8 +- src/message/mod.rs | 13 ++ 6 files changed, 325 insertions(+), 345 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e27e9172b..43f568920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,10 @@ vendored-tls = ["reqwest/native-tls-vendored"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -reqwest = {version = "0.11.0", features = ["json"], default-features=false} +reqwest = { version = "0.11.0", features = ["json"], default-features=false } chrono = "0.4" -gauth = "0.7.0" +yup-oauth2 = "9.0.0" +thiserror = "1.0.61" [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index 2a67a4856..ea128d390 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use clap::Parser; use fcm::{ - AndroidConfig, AndroidNotification, ApnsConfig, Client, FcmOptions, Message, Notification, Target, WebpushConfig, + AndroidConfig, AndroidNotification, ApnsConfig, FcmClient, FcmOptions, Message, Notification, Target, WebpushConfig, }; use serde_json::json; @@ -19,7 +19,11 @@ struct CliArgs { #[tokio::main] async fn main() -> Result<(), Box> { let args = CliArgs::parse(); - let client = Client::new(args.service_account_key_path.to_str().unwrap().to_string()); + let client = FcmClient::new( + args.service_account_key_path.to_str().unwrap().to_string(), + None::, + None, + ).await.unwrap(); let data = json!({ "key": "value", diff --git a/src/client/mod.rs b/src/client/mod.rs index 160860b96..38facc60f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,165 +1,125 @@ pub(crate) mod response; -use crate::client::response::{ErrorReason, FcmError, FcmResponse, RetryAfter}; -use crate::{Message, MessageInternal}; -use gauth::serv_account::ServiceAccount; -use reqwest::header::RETRY_AFTER; -use reqwest::{Body, StatusCode}; -use serde::Serialize; - -/// An async client for sending the notification payload. -pub struct Client { - http_client: reqwest::Client, - key_path: String, -} +use std::path::Path; +use std::time::Duration; -// will be used to wrap the message in a "message" field -#[derive(Serialize)] -struct MessageWrapper<'a> { - #[serde(rename = "message")] - message: &'a MessageInternal, +use crate::client::response::{FcmResponse, RetryAfter}; +use crate::{Message, MessageWrapper}; +use reqwest::header::RETRY_AFTER; +use yup_oauth2::authenticator::{Authenticator, DefaultHyperClient, HyperClientBuilder}; +use yup_oauth2::hyper::client::HttpConnector; +use yup_oauth2::hyper_rustls::HttpsConnector; +use yup_oauth2::ServiceAccountAuthenticator; + +use self::response::FcmHttpResponseCode; + + +#[derive(thiserror::Error, Debug)] +pub enum FcmClientError { + #[error("Reqwest error: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("Service account key reading failed: {0}")] + ServiceAccountKeyReadingFailed(std::io::Error), + #[error("OAuth error: {0}")] + OauthError(#[from] yup_oauth2::Error), + #[error("Access token is missing")] + AccessTokenIsMissing, + #[error("Authenticator creation failed: {0}")] + AuthenticatorCreatingFailed(std::io::Error), + #[error("Service account key JSON does not contain project ID")] + MissingProjectId, } -impl MessageWrapper<'_> { - fn new(message: &MessageInternal) -> MessageWrapper { - MessageWrapper { message } +impl FcmClientError { + /// If this is `true` then most likely current service key is invalid. + pub fn is_token_missing_even_if_server_requests_completed(&self) -> bool { + matches!( + self, + FcmClientError::AccessTokenIsMissing | + FcmClientError::OauthError(yup_oauth2::Error::AuthError(_)) + ) } } -impl Client { - /// Get a new instance of Client. - pub fn new(key_path: String) -> Client { - let http_client = reqwest::ClientBuilder::new() - .pool_max_idle_per_host(usize::MAX) - .build() - .unwrap(); - - Client { - http_client, - key_path, - } - } - - fn get_service_key_file_name(&self) -> Result { - Ok(self.key_path.clone()) - } - - fn read_service_key_file(&self) -> Result { - let key_path = self.get_service_key_file_name()?; - - let private_key_content = match std::fs::read(key_path) { - Ok(content) => content, - Err(err) => return Err(err.to_string()), - }; - - Ok(String::from_utf8(private_key_content).unwrap()) - } - - fn read_service_key_file_json(&self) -> Result { - let file_content = match self.read_service_key_file() { - Ok(content) => content, - Err(err) => return Err(err), - }; - - let json_content: serde_json::Value = match serde_json::from_str(&file_content) { - Ok(json) => json, - Err(err) => return Err(err.to_string()), - }; - - Ok(json_content) - } +/// An async client for sending the notification payload. +pub struct FcmClient { + http_client: reqwest::Client, + authenticator: Authenticator>, + project_id: String, +} - fn get_project_id(&self) -> Result { - let json_content = match self.read_service_key_file_json() { - Ok(json) => json, - Err(err) => return Err(err), +impl FcmClient { + /// Google recommends at least 10 minute timeout for FCM requests. + /// https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts + pub async fn new( + service_account_key_json_path: impl AsRef, + token_cache_json_path: Option>, + fcm_request_timeout: Option, + ) -> Result { + let builder = reqwest::ClientBuilder::new(); + let builder = if let Some(timeout) = fcm_request_timeout { + builder.timeout(timeout) + } else { + builder }; - - let project_id = match json_content["project_id"].as_str() { - Some(project_id) => project_id, - None => return Err("could not get project_id".to_string()), + let http_client = builder.build()?; + + let key = yup_oauth2::read_service_account_key(service_account_key_json_path.as_ref()) + .await + .map_err(FcmClientError::ServiceAccountKeyReadingFailed)?; + let oauth_client = DefaultHyperClient.build_hyper_client() + .map_err(FcmClientError::OauthError)?; + let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); + let builder = if let Some(path) = token_cache_json_path { + builder.persist_tokens_to_disk(path.as_ref()) + } else { + builder }; + let authenticator = builder.build() + .await + .map_err(FcmClientError::AuthenticatorCreatingFailed)?; - Ok(project_id.to_string()) - } + let project_id = key.project_id + .ok_or(FcmClientError::MissingProjectId)?; - async fn get_auth_token(&self) -> Result { - let tkn = match self.access_token().await { - Ok(tkn) => tkn, - Err(_) => return Err("could not get access token".to_string()), - }; - - Ok(tkn) - } - - async fn access_token(&self) -> Result { - let scopes = vec!["https://www.googleapis.com/auth/firebase.messaging"]; - let key_path = self.get_service_key_file_name()?; - - let mut service_account = ServiceAccount::from_file(&key_path, scopes); - let access_token = match service_account.access_token().await { - Ok(access_token) => access_token, - Err(err) => return Err(err.to_string()), - }; - - let token_no_bearer = access_token.split(char::is_whitespace).collect::>()[1]; - - Ok(token_no_bearer.to_string()) + Ok(FcmClient { + http_client, + authenticator, + project_id, + }) } - pub async fn send(&self, message: Message) -> Result { - let fin = message.finalize(); - let wrapper = MessageWrapper::new(&fin); - let payload = serde_json::to_vec(&wrapper).unwrap(); - - let project_id = match self.get_project_id() { - Ok(project_id) => project_id, - Err(err) => return Err(FcmError::ProjectIdError(err)), - }; - - let auth_token = match self.get_auth_token().await { - Ok(tkn) => tkn, - Err(err) => return Err(FcmError::ProjectIdError(err)), - }; + pub async fn send(&self, message: Message) -> Result { + let scopes = ["https://www.googleapis.com/auth/firebase.messaging"]; + let auth_token = self.authenticator.token(&scopes).await?; + let auth_token = auth_token.token() + .ok_or(FcmClientError::AccessTokenIsMissing)?; // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send - let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", project_id); + let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.project_id); let request = self .http_client .post(&url) - .header("Content-Type", "application/json") .bearer_auth(auth_token) - .body(Body::from(payload)) + .json(&MessageWrapper::new(message.finalize())) .build()?; let response = self.http_client.execute(request).await?; - - let response_status = response.status(); - + let response_status: FcmHttpResponseCode = response.status().as_u16().into(); let retry_after = response .headers() .get(RETRY_AFTER) .and_then(|ra| ra.to_str().ok()) .and_then(|ra| ra.parse::().ok()); - - match response_status { - StatusCode::OK => { - let fcm_response: FcmResponse = response.json().await.unwrap(); - - match fcm_response.error { - Some(ErrorReason::Unavailable) => Err(FcmError::ServerError(retry_after)), - Some(ErrorReason::InternalServerError) => Err(FcmError::ServerError(retry_after)), - _ => Ok(fcm_response), - } - } - StatusCode::UNAUTHORIZED => Err(FcmError::Unauthorized), - StatusCode::BAD_REQUEST => { - let body = response.text().await.unwrap(); - Err(FcmError::InvalidMessage(format!("Bad Request ({body}"))) - } - status if status.is_server_error() => Err(FcmError::ServerError(retry_after)), - _ => Err(FcmError::InvalidMessage("Unknown Error".to_string())), - } + let response_json_object = response.json::>().await + .ok() + .unwrap_or_default(); + + Ok(FcmResponse::new( + response_status, + response_json_object, + retry_after, + )) } } diff --git a/src/client/response.rs b/src/client/response.rs index f7e5bf6a7..f7c72a128 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -1,176 +1,62 @@ -pub use chrono::{DateTime, Duration, FixedOffset}; -use serde::Deserialize; -use std::{error::Error, fmt, str::FromStr}; - -/// A description of what went wrong with the push notification. -/// Referred from [Firebase documentation](https://firebase.google.com/docs/cloud-messaging/http-server-ref#table9) -#[derive(Deserialize, Debug, PartialEq, Copy, Clone)] -pub enum ErrorReason { - /// Check that the request contains a registration token (in the `to` or - /// `registration_ids` field). - MissingRegistration, - - /// Check the format of the registration token you pass to the server. Make - /// sure it matches the registration token the client app receives from - /// registering with Firebase Notifications. Do not truncate or add - /// additional characters. - InvalidRegistration, - - /// An existing registration token may cease to be valid in a number of - /// scenarios, including: - /// - /// * If the client app unregisters with FCM. - /// * If the client app is automatically unregistered, which can happen if - /// the user uninstalls the application. For example, on iOS, if the APNS - /// Feedback Service reported the APNS token as invalid. - /// * If the registration token expires (for example, Google might decide to - /// refresh registration tokens, or the APNS token has expired for iOS - /// devices). - /// * If the client app is updated but the new version is not configured to - /// receive messages. - /// - /// For all these cases, remove this registration token from the app server - /// and stop using it to send messages. - NotRegistered, - - /// Make sure the message was addressed to a registration token whose - /// package name matches the value passed in the request. - InvalidPackageName, - - /// A registration token is tied to a certain group of senders. When a - /// client app registers for FCM, it must specify which senders are allowed - /// to send messages. You should use one of those sender IDs when sending - /// messages to the client app. If you switch to a different sender, the - /// existing registration tokens won't work. - MismatchSenderId, - - /// Check that the provided parameters have the right name and type. - InvalidParameters, - - /// Check that the total size of the payload data included in a message does - /// not exceed FCM limits: 4096 bytes for most messages, or 2048 bytes in - /// the case of messages to topics. This includes both the keys and the - /// values. - MessageTooBig, - - /// Check that the custom payload data does not contain a key (such as - /// `from`, or `gcm`, or any value prefixed by google) that is used - /// internally by FCM. Note that some words (such as `collapse_key`) are - /// also used by FCM but are allowed in the payload, in which case the - /// payload value will be overridden by the FCM value. - InvalidDataKey, - - /// Check that the value used in `time_to_live` is an integer representing a - /// duration in seconds between 0 and 2,419,200 (4 weeks). - InvalidTtl, - - /// In internal use only. Check - /// [FcmError::ServerError](enum.FcmError.html#variant.ServerError). - Unavailable, - - /// In internal use only. Check - /// [FcmError::ServerError](enum.FcmError.html#variant.ServerError). - InternalServerError, - - /// The rate of messages to a particular device is too high. If an iOS app - /// sends messages at a rate exceeding APNs limits, it may receive this - /// error message - /// - /// Reduce the number of messages sent to this device and use exponential - /// backoff to retry sending. - DeviceMessageRateExceeded, - - /// The rate of messages to subscribers to a particular topic is too high. - /// Reduce the number of messages sent for this topic and use exponential - /// backoff to retry sending. - TopicsMessageRateExceeded, - - /// A message targeted to an iOS device could not be sent because the - /// required APNs authentication key was not uploaded or has expired. Check - /// the validity of your development and production credentials. - InvalidApnsCredential, -} - -#[derive(Deserialize, Debug)] -pub struct FcmResponse { - pub message_id: Option, - pub error: Option, - pub multicast_id: Option, - pub success: Option, - pub failure: Option, - pub canonical_ids: Option, - pub results: Option>, +use chrono::{DateTime, FixedOffset}; + +use chrono::Utc; +use std::time::Duration; +use std::{convert::{TryFrom, TryInto}, str::FromStr}; + +/// Check https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode +/// for more information. +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(u16)] +pub enum FcmHttpError { + InvalidArgument = 400, + Unregistered = 404, + SenderIdMismatch = 403, + QuotaExceeded = 429, + Unavailable = 503, + Internal = 500, + ThirdPartyAuthError = 401, } -#[derive(Deserialize, Debug)] -pub struct MessageResult { - pub message_id: Option, - pub registration_id: Option, - pub error: Option, +impl TryFrom for FcmHttpError { + type Error = (); + + fn try_from(value: u16) -> Result { + match value { + 400 => Ok(FcmHttpError::InvalidArgument), + 404 => Ok(FcmHttpError::Unregistered), + 403 => Ok(FcmHttpError::SenderIdMismatch), + 429 => Ok(FcmHttpError::QuotaExceeded), + 503 => Ok(FcmHttpError::Unavailable), + 500 => Ok(FcmHttpError::Internal), + 401 => Ok(FcmHttpError::ThirdPartyAuthError), + _ => Err(()), + } + } } -/// Fatal errors. Referred from [Firebase -/// documentation](https://firebase.google.com/docs/cloud-messaging/http-server-ref#table9) -#[derive(PartialEq, Debug)] -pub enum FcmError { - /// The sender account used to send a message couldn't be authenticated. Possible causes are: - /// - /// Authorization header missing or with invalid syntax in HTTP request. - /// - /// * The Firebase project that the specified server key belongs to is - /// incorrect. - /// * Legacy server keys only—the request originated from a server not - /// whitelisted in the Server key IPs. - /// - /// Check that the token you're sending inside the Authentication header is - /// the correct Server key associated with your project. See Checking the - /// validity of a Server key for details. If you are using a legacy server - /// key, you're recommended to upgrade to a new key that has no IP - /// restrictions. - Unauthorized, - - /// Check that the JSON message is properly formatted and contains valid - /// fields (for instance, making sure the right data type is passed in). - InvalidMessage(String), - - /// The server couldn't process the request. Retry the same request, but you must: - /// - /// * Honor the [RetryAfter](enum.RetryAfter.html) value if included. - /// * Implement exponential back-off in your retry mechanism. (e.g. if you - /// waited one second before the first retry, wait at least two second - /// before the next one, then 4 seconds and so on). If you're sending - /// multiple messages, delay each one independently by an additional random - /// amount to avoid issuing a new request for all messages at the same time. - /// - /// Senders that cause problems risk being blacklisted. - ServerError(Option), - - ProjectIdError(String), - - AuthToken(String), +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FcmHttpResponseCode { + /// HTTP 200 + Ok, + Error(FcmHttpError), + Unknown(u16), } -impl Error for FcmError {} - -impl fmt::Display for FcmError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FcmError::Unauthorized => write!(f, "authorization header missing or with invalid syntax in HTTP request"), - FcmError::InvalidMessage(ref s) => write!(f, "invalid message {}", s), - FcmError::ServerError(_) => write!(f, "the server couldn't process the request"), - FcmError::ProjectIdError(error) => write!(f, "error getting project_id: {error}"), - FcmError::AuthToken(error) => write!(f, "error getting auth token: {error}"), +impl From for FcmHttpResponseCode { + fn from(value: u16) -> Self { + match value { + 200 => FcmHttpResponseCode::Ok, + _ => match value.try_into() { + Ok(code) => FcmHttpResponseCode::Error(code), + Err(()) => FcmHttpResponseCode::Unknown(value), + }, } } } -impl From for FcmError { - fn from(_: reqwest::Error) -> Self { - Self::ServerError(None) - } -} - -#[derive(PartialEq, Debug)] +/// HTTP `Retry-After` header value. +#[derive(Debug, Clone, PartialEq)] pub enum RetryAfter { /// Amount of time to wait until retrying the message is allowed. Delay(Duration), @@ -179,63 +65,175 @@ pub enum RetryAfter { DateTime(DateTime), } +impl RetryAfter { + /// Wait time calculated from current operating system time. + pub fn wait_time(&self) -> Duration { + self.wait_time_with_current_time(Utc::now().fixed_offset()) + } + + fn wait_time_with_current_time(&self, now: DateTime) -> Duration { + match self { + RetryAfter::Delay(duration) => *duration, + RetryAfter::DateTime(date_time) => + if *date_time <= now { + Duration::ZERO + } else { + (*date_time - now) + .to_std() + .unwrap_or(Duration::ZERO) + } + } + } +} + impl FromStr for RetryAfter { - type Err = crate::Error; + type Err = chrono::ParseError; fn from_str(s: &str) -> Result { - s.parse::() - .map(Duration::seconds) + s.parse::() + .map(Duration::from_secs) .map(RetryAfter::Delay) .or_else(|_| DateTime::parse_from_rfc2822(s).map(RetryAfter::DateTime)) - .map_err(|e| crate::Error::InvalidMessage(format!("{}", e))) } } -#[cfg(test)] -mod tests { - use super::*; - use chrono::{DateTime, Duration}; - use serde_json::json; +#[derive(Debug, Clone)] +pub struct FcmResponse { + response_status: FcmHttpResponseCode, + response_json_object: serde_json::Map, + retry_after: Option, +} - #[test] - fn test_some_errors() { - let errors = vec![ - ("MissingRegistration", ErrorReason::MissingRegistration), - ("InvalidRegistration", ErrorReason::InvalidRegistration), - ("NotRegistered", ErrorReason::NotRegistered), - ("InvalidPackageName", ErrorReason::InvalidPackageName), - ("MismatchSenderId", ErrorReason::MismatchSenderId), - ("InvalidParameters", ErrorReason::InvalidParameters), - ("MessageTooBig", ErrorReason::MessageTooBig), - ("InvalidDataKey", ErrorReason::InvalidDataKey), - ("InvalidTtl", ErrorReason::InvalidTtl), - ("Unavailable", ErrorReason::Unavailable), - ("InternalServerError", ErrorReason::InternalServerError), - ("DeviceMessageRateExceeded", ErrorReason::DeviceMessageRateExceeded), - ("TopicsMessageRateExceeded", ErrorReason::TopicsMessageRateExceeded), - ("InvalidApnsCredential", ErrorReason::InvalidApnsCredential), - ]; - - for (error_str, error_enum) in errors.into_iter() { - let response_data = json!({ - "error": error_str, - "results": [ - {"error": error_str} - ] - }); - - let response_string = serde_json::to_string(&response_data).unwrap(); - let fcm_response: FcmResponse = serde_json::from_str(&response_string).unwrap(); - - assert_eq!(Some(error_enum.clone()), fcm_response.results.unwrap()[0].error,); - - assert_eq!(Some(error_enum), fcm_response.error,) +impl FcmResponse { + pub(crate) fn new( + response_status: FcmHttpResponseCode, + response_json_object: serde_json::Map, + retry_after: Option, + ) -> Self { + Self { + response_status, + response_json_object, + retry_after, } } + pub fn recommended_action(&self) -> Option { + RecomendedAction::analyze(self) + } + + pub fn status(&self) -> FcmHttpResponseCode { + self.response_status + } + + pub fn json(&self) -> &serde_json::Map { + &self.response_json_object + } + + pub fn retry_after(&self) -> Option<&RetryAfter> { + self.retry_after.as_ref() + } +} + + +/// Action which server or developer should do based on the [FcmResponse]. +/// +/// Check https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode +/// and https://firebase.google.com/docs/cloud-messaging/scale-fcm#handling-retries +/// for more details. +pub enum RecomendedAction<'a> { + /// Error [FcmHttpError::Unregistered] was received. + /// The app token sent with the message was detected as + /// missing or unregistered and should be removed. + RemoveFcmAppToken, + + /// Error [FcmHttpError::InvalidArgument] was received. Check + /// that the sent message is correct. + FixMessageContent, + + /// Error [FcmHttpError::SenderIdMismatch] was received. Check + /// that that client and server uses the same sender ID. + CheckSenderIdEquality, + + /// Error [FcmHttpError::QuotaExceeded] was received. Reduce + /// overall message sending rate, device message rate or + /// topic message rate and then retry sending the previous + /// message. + /// + /// TODO: Figure out QuotaExceeded format to know what quota was exceeded + ReduceMessageRateAndRetry(RecomendedWaitTime<'a>), + + /// Error [FcmHttpError::Unavailable] or [FcmHttpError::Internal] + /// was received. Wait specific amount of time before retrying the message. + Retry(RecomendedWaitTime<'a>), + + /// Error [FcmHttpError::ThirdPartyAuthError] was received. Check + /// credentials related to iOS and web push notifications. + CheckIosAndWebCredentials, +} + +impl RecomendedAction<'_> { + fn analyze(response: &FcmResponse) -> Option { + match response.status() { + FcmHttpResponseCode::Ok | + FcmHttpResponseCode::Unknown(_) => None, + FcmHttpResponseCode::Error(e) => match e { + FcmHttpError::Unregistered => Some(RecomendedAction::RemoveFcmAppToken), + FcmHttpError::InvalidArgument => Some(RecomendedAction::FixMessageContent), + FcmHttpError::SenderIdMismatch => + Some(RecomendedAction::CheckSenderIdEquality), + FcmHttpError::QuotaExceeded => { + let wait_time = if let Some(ra) = response.retry_after() { + RecomendedWaitTime::SpecificWaitTime(ra) + } else { + RecomendedWaitTime::InitialWaitTime(Duration::from_secs(60)) + }; + + Some(RecomendedAction::ReduceMessageRateAndRetry(wait_time)) + } + FcmHttpError::Unavailable => { + let wait_time = if let Some(ra) = response.retry_after() { + RecomendedWaitTime::SpecificWaitTime(ra) + } else { + RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) + }; + + Some(RecomendedAction::Retry(wait_time)) + } + FcmHttpError::Internal => + Some(RecomendedAction::Retry( + RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) + )), + FcmHttpError::ThirdPartyAuthError => + Some(RecomendedAction::CheckIosAndWebCredentials), + } + } + } +} + +pub enum RecomendedWaitTime<'a> { + /// Initial wait time for exponential back-off. + /// + /// If the next request will be initial retry then wait this + /// amount of time before sending the request. For next retries + /// multiply the wait time by itself (then the wait time + /// grows exponentially). + /// + /// Note also that Google documentation also recommends implementing + /// jittering to exponential back-off. + InitialWaitTime(Duration), + + /// Specific wait time from HTTP header. + SpecificWaitTime(&'a RetryAfter), +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::DateTime; + #[test] fn test_retry_after_from_seconds() { - assert_eq!(RetryAfter::Delay(Duration::seconds(420)), "420".parse().unwrap()); + assert_eq!(RetryAfter::Delay(Duration::from_secs(420)), "420".parse().unwrap()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 3a642f4bc..b5aad9874 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,11 @@ //! async fn main() -> Result<(), Box> { //! use serde_json::json; //! use fcm::{Target, FcmOptions, Notification, Message}; -//! let client = fcm::Client::new(); +//! let client = fcm::FcmClient::new( +//! "service-account-key.json", +//! None::, +//! None, +//! ).await.unwrap(); //! //! let data = json!({ //! "message": "Howdy!" @@ -69,5 +73,5 @@ pub use crate::web::webpush_config::*; pub use crate::web::webpush_fcm_options::*; mod client; -pub use crate::client::response::FcmError as Error; +pub use crate::client::response::*; pub use crate::client::*; diff --git a/src/message/mod.rs b/src/message/mod.rs index e368f5825..465e1a06a 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -101,3 +101,16 @@ impl Message { } } } + +/// Wrap the message in a "message" field +#[derive(Serialize)] +pub(crate) struct MessageWrapper { + #[serde(rename = "message")] + message: MessageInternal, +} + +impl MessageWrapper { + pub fn new(message: MessageInternal) -> MessageWrapper { + MessageWrapper { message } + } +} From 21c4d3ee89ccc007012626ba22d9d34add8f12f6 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 16:17:01 +0300 Subject: [PATCH 07/40] Fix warnings --- src/message/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/message/tests.rs b/src/message/tests.rs index 8cb9fb713..55a8fc38d 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -47,7 +47,7 @@ fn should_add_custom_data_to_the_payload() { let data = json!({ "foo": "bar", "bar": false }); let builder = Message { - target: target, + target, data: Some(data), notification: None, android: None, @@ -179,5 +179,5 @@ fn should_set_notifications() { }; let msg = builder.finalize(); - assert_eq!(msg.notification.is_none(), false); + assert!(msg.notification.is_some()); } From 7a9831621b017d15ec23dd69a2bc99ff5f38c46c Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 16:31:20 +0300 Subject: [PATCH 08/40] Create client using builder pattern --- examples/simple_sender.rs | 9 +++--- src/client/mod.rs | 58 +++++++++++++++++++++++++++++++-------- src/lib.rs | 9 +++--- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index ea128d390..2cfbe1639 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -19,11 +19,10 @@ struct CliArgs { #[tokio::main] async fn main() -> Result<(), Box> { let args = CliArgs::parse(); - let client = FcmClient::new( - args.service_account_key_path.to_str().unwrap().to_string(), - None::, - None, - ).await.unwrap(); + let client = FcmClient::builder(args.service_account_key_path) + .build() + .await + .unwrap(); let data = json!({ "key": "value", diff --git a/src/client/mod.rs b/src/client/mod.rs index 38facc60f..35d57e28d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,6 +1,6 @@ pub(crate) mod response; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use crate::client::response::{FcmResponse, RetryAfter}; @@ -13,7 +13,6 @@ use yup_oauth2::ServiceAccountAuthenticator; use self::response::FcmHttpResponseCode; - #[derive(thiserror::Error, Debug)] pub enum FcmClientError { #[error("Reqwest error: {0}")] @@ -41,6 +40,41 @@ impl FcmClientError { } } +pub struct FcmClientBuilder { + service_account_key_json_path: PathBuf, + token_cache_json_path: Option, + fcm_request_timeout: Option, +} + +impl FcmClientBuilder { + pub fn new(service_account_key_json_path: impl AsRef) -> Self { + Self { + service_account_key_json_path: service_account_key_json_path.as_ref().to_path_buf(), + token_cache_json_path: None, + fcm_request_timeout: None, + } + } + + /// Set path to the token cache JSON file. Default is no token cache JSON file. + pub fn token_cache_json_path(mut self, token_cache_json_path: impl AsRef) -> Self { + self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); + self + } + + /// Set timeout for FCM requests. Default is no timeout. + /// + /// Google recommends at least 10 minute timeout for FCM requests. + /// https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts + pub fn fcm_request_timeout(mut self, fcm_request_timeout: Duration) -> Self { + self.fcm_request_timeout = Some(fcm_request_timeout); + self + } + + pub async fn build(self) -> Result { + FcmClient::new_from_builder(self).await + } +} + /// An async client for sending the notification payload. pub struct FcmClient { http_client: reqwest::Client, @@ -49,29 +83,31 @@ pub struct FcmClient { } impl FcmClient { - /// Google recommends at least 10 minute timeout for FCM requests. - /// https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts - pub async fn new( + pub fn builder( service_account_key_json_path: impl AsRef, - token_cache_json_path: Option>, - fcm_request_timeout: Option, + ) -> FcmClientBuilder { + FcmClientBuilder::new(service_account_key_json_path) + } + + async fn new_from_builder( + fcm_builder: FcmClientBuilder, ) -> Result { let builder = reqwest::ClientBuilder::new(); - let builder = if let Some(timeout) = fcm_request_timeout { + let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { builder.timeout(timeout) } else { builder }; let http_client = builder.build()?; - let key = yup_oauth2::read_service_account_key(service_account_key_json_path.as_ref()) + let key = yup_oauth2::read_service_account_key(fcm_builder.service_account_key_json_path) .await .map_err(FcmClientError::ServiceAccountKeyReadingFailed)?; let oauth_client = DefaultHyperClient.build_hyper_client() .map_err(FcmClientError::OauthError)?; let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); - let builder = if let Some(path) = token_cache_json_path { - builder.persist_tokens_to_disk(path.as_ref()) + let builder = if let Some(path) = fcm_builder.token_cache_json_path { + builder.persist_tokens_to_disk(path) } else { builder }; diff --git a/src/lib.rs b/src/lib.rs index b5aad9874..cb1aa2480 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,11 +13,10 @@ //! async fn main() -> Result<(), Box> { //! use serde_json::json; //! use fcm::{Target, FcmOptions, Notification, Message}; -//! let client = fcm::FcmClient::new( -//! "service-account-key.json", -//! None::, -//! None, -//! ).await.unwrap(); +//! let client = fcm::FcmClient::builder("service-account-key.json") +//! .build() +//! .await +//! .unwrap(); //! //! let data = json!({ //! "message": "Howdy!" From 16e28bee417ab8d7eb045d7dc05cbaec2b277a04 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 17:04:39 +0300 Subject: [PATCH 09/40] Reintroduce .env file support This reverts commit 25d0f030f6fb0e61b8421a81e1d377ee9998beec. --- .env.example | 6 ++++++ .gitignore | 6 ++++++ Cargo.toml | 1 + examples/simple_sender.rs | 15 ++++++++++++--- src/client/mod.rs | 35 +++++++++++++++++++++++------------ src/lib.rs | 2 +- 6 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..3e7bf180e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Follow the instructions in the +# [Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) +# to create a service account. After you create a service account, and download +# the JSON file then change the value of `GOOGLE_APPLICATION_CREDENTIALS` to +# the path of the JSON file you downloaded. +GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/firebase/file.json" diff --git a/.gitignore b/.gitignore index d32455529..938de1d79 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ Cargo.lock .DS_Store .idea/ + +# Local env files +# Do not commit any .env files to git, except for the .env.example file. +# https://create.t3.gg/en/usage/env-variables#using-environment-variables +.env +.env*.local diff --git a/Cargo.toml b/Cargo.toml index 43f568920..f745b7057 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.11.0", features = ["json"], default-features=false } chrono = "0.4" yup-oauth2 = "9.0.0" thiserror = "1.0.61" +dotenv = "0.15.0" [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index 2cfbe1639..c0d3f8992 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -12,15 +12,24 @@ use serde_json::json; struct CliArgs { #[arg(long)] device_token: String, + /// Set path to the service account key JSON file. Default is to use + /// path from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable + /// (which can be also located in `.env` file). #[arg(long, value_name = "FILE")] - service_account_key_path: PathBuf, + service_account_key_path: Option, } #[tokio::main] async fn main() -> Result<(), Box> { let args = CliArgs::parse(); - let client = FcmClient::builder(args.service_account_key_path) - .build() + let builder = FcmClient::builder(); + let builder = if let Some(path) = args.service_account_key_path { + builder.service_account_key_json_path(path) + } else { + builder + }; + + let client = builder.build() .await .unwrap(); diff --git a/src/client/mod.rs b/src/client/mod.rs index 35d57e28d..00b32dec4 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -27,6 +27,8 @@ pub enum FcmClientError { AuthenticatorCreatingFailed(std::io::Error), #[error("Service account key JSON does not contain project ID")] MissingProjectId, + #[error("Dotenv error: {0}")] + DotenvError(#[from] dotenv::Error), } impl FcmClientError { @@ -40,19 +42,24 @@ impl FcmClientError { } } +#[derive(Debug, Default, Clone)] pub struct FcmClientBuilder { - service_account_key_json_path: PathBuf, + service_account_key_json_path: Option, token_cache_json_path: Option, fcm_request_timeout: Option, } impl FcmClientBuilder { - pub fn new(service_account_key_json_path: impl AsRef) -> Self { - Self { - service_account_key_json_path: service_account_key_json_path.as_ref().to_path_buf(), - token_cache_json_path: None, - fcm_request_timeout: None, - } + pub fn new() -> Self { + Self::default() + } + + /// Set path to the service account key JSON file. Default is to use + /// path from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable + /// (which can be also located in `.env` file). + pub fn service_account_key_json_path(mut self, service_account_key_json_path: impl AsRef) -> Self { + self.service_account_key_json_path = Some(service_account_key_json_path.as_ref().to_path_buf()); + self } /// Set path to the token cache JSON file. Default is no token cache JSON file. @@ -83,10 +90,8 @@ pub struct FcmClient { } impl FcmClient { - pub fn builder( - service_account_key_json_path: impl AsRef, - ) -> FcmClientBuilder { - FcmClientBuilder::new(service_account_key_json_path) + pub fn builder() -> FcmClientBuilder { + FcmClientBuilder::new() } async fn new_from_builder( @@ -100,7 +105,13 @@ impl FcmClient { }; let http_client = builder.build()?; - let key = yup_oauth2::read_service_account_key(fcm_builder.service_account_key_json_path) + let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { + path + } else { + dotenv::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() + }; + + let key = yup_oauth2::read_service_account_key(service_account_key_path) .await .map_err(FcmClientError::ServiceAccountKeyReadingFailed)?; let oauth_client = DefaultHyperClient.build_hyper_client() diff --git a/src/lib.rs b/src/lib.rs index cb1aa2480..2cd2b7cc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ //! async fn main() -> Result<(), Box> { //! use serde_json::json; //! use fcm::{Target, FcmOptions, Notification, Message}; -//! let client = fcm::FcmClient::builder("service-account-key.json") +//! let client = fcm::FcmClient::builder() //! .build() //! .await //! .unwrap(); From 9f95b1c9461533313797a26407d358a5a2dc3f81 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 17:27:04 +0300 Subject: [PATCH 10/40] Simplify example --- examples/simple_sender.rs | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index c0d3f8992..c8010ddab 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use clap::Parser; use fcm::{ - AndroidConfig, AndroidNotification, ApnsConfig, FcmClient, FcmOptions, Message, Notification, Target, WebpushConfig, + FcmClient, Message, Notification, Target, }; use serde_json::json; @@ -33,36 +33,23 @@ async fn main() -> Result<(), Box> { .await .unwrap(); - let data = json!({ - "key": "value", - }); - - let builder = Message { - data: Some(data), + let message = Message { + data: Some(json!({ + "key": "value", + })), notification: Some(Notification { - title: Some("I'm high".to_string()), - body: Some(format!("it's {}", chrono::Utc::now())), + title: Some("Title".to_string()), ..Default::default() }), target: Target::Token(args.device_token), - fcm_options: Some(FcmOptions { - analytics_label: "analytics_label".to_string(), - }), - android: Some(AndroidConfig { - priority: Some(fcm::AndroidMessagePriority::High), - notification: Some(AndroidNotification { - title: Some("I'm Android high".to_string()), - body: Some(format!("Hi Android, it's {}", chrono::Utc::now())), - ..Default::default() - }), - ..Default::default() - }), - apns: Some(ApnsConfig { ..Default::default() }), - webpush: Some(WebpushConfig { ..Default::default() }), + fcm_options: None, + android: None, + apns: None, + webpush: None, }; - let response = client.send(builder).await?; - println!("Sent: {:?}", response); + let response = client.send(message).await?; + println!("Response: {:#?}", response); Ok(()) } From 212ae91ad12cb07064712b4deb353ff541e09828 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 18:13:14 +0300 Subject: [PATCH 11/40] Remove duplicate code --- src/android/android_config.rs | 56 ++------ src/android/android_fcm_options.rs | 15 +-- src/android/android_message_priority.rs | 3 +- src/android/android_notification.rs | 171 ++++-------------------- src/android/color.rs | 27 ---- src/android/light_settings.rs | 29 +--- src/android/notification_priority.rs | 3 +- src/android/visibility.rs | 3 +- src/apns/apns_config.rs | 31 +---- src/apns/apns_fcm_options.rs | 21 +-- src/client/mod.rs | 2 +- src/message/fcm_options.rs | 17 +-- src/message/mod.rs | 64 ++------- src/message/target.rs | 2 +- src/message/tests.rs | 24 ++-- src/notification/mod.rs | 34 +---- src/notification/tests.rs | 2 +- src/web/webpush_config.rs | 40 +----- src/web/webpush_fcm_options.rs | 21 +-- 19 files changed, 87 insertions(+), 478 deletions(-) diff --git a/src/android/android_config.rs b/src/android/android_config.rs index c6ef78756..3b90926f7 100644 --- a/src/android/android_config.rs +++ b/src/android/android_config.rs @@ -2,79 +2,45 @@ use serde::Serialize; use serde_json::Value; use super::{ - android_fcm_options::{AndroidFcmOptions, AndroidFcmOptionsInternal}, + android_fcm_options::AndroidFcmOptions, android_message_priority::AndroidMessagePriority, - android_notification::{AndroidNotification, AndroidNotificationInternal}, + android_notification::AndroidNotification, }; -#[derive(Serialize, Debug)] -pub(crate) struct AndroidConfigInternal { - #[serde(skip_serializing_if = "Option::is_none")] - collapse_key: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - priority: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - ttl: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - restricted_package_name: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - data: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - notification: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - fcm_options: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - direct_boot_ok: Option, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig pub struct AndroidConfig { /// An identifier of a group of messages that can be collapsed, so that only the last message gets /// sent when delivery can be resumed. + #[serde(skip_serializing_if = "Option::is_none")] pub collapse_key: Option, /// Message priority. + #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, /// How long (in seconds) the message should be kept in FCM storage if the device is offline. /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + #[serde(skip_serializing_if = "Option::is_none")] pub ttl: Option, /// Package name of the application where the registration token must match in order to receive the message. + #[serde(skip_serializing_if = "Option::is_none")] pub restricted_package_name: Option, /// Arbitrary key/value payload. + #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, /// Notification to send to android devices. + #[serde(skip_serializing_if = "Option::is_none")] pub notification: Option, /// Options for features provided by the FCM SDK for Android. + #[serde(skip_serializing_if = "Option::is_none")] pub fcm_options: Option, /// If set to true, messages will be allowed to be delivered to the app while the device is in direct boot mode. + #[serde(skip_serializing_if = "Option::is_none")] pub direct_boot_ok: Option, } - -impl AndroidConfig { - pub(crate) fn finalize(self) -> AndroidConfigInternal { - AndroidConfigInternal { - collapse_key: self.collapse_key, - priority: self.priority, - ttl: self.ttl, - restricted_package_name: self.restricted_package_name, - data: self.data, - notification: self.notification.map(|n| n.finalize()), - fcm_options: self.fcm_options.map(|f| f.finalize()), - direct_boot_ok: self.direct_boot_ok, - } - } -} diff --git a/src/android/android_fcm_options.rs b/src/android/android_fcm_options.rs index 212d6a0cc..d9dbfec8f 100644 --- a/src/android/android_fcm_options.rs +++ b/src/android/android_fcm_options.rs @@ -1,21 +1,8 @@ use serde::Serialize; -#[derive(Serialize, Debug)] -pub(crate) struct AndroidFcmOptionsInternal { - analytics_label: String, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig pub struct AndroidFcmOptions { /// Label associated with the message's analytics data. pub analytics_label: String, } - -impl AndroidFcmOptions { - pub(crate) fn finalize(self) -> AndroidFcmOptionsInternal { - AndroidFcmOptionsInternal { - analytics_label: self.analytics_label, - } - } -} diff --git a/src/android/android_message_priority.rs b/src/android/android_message_priority.rs index aa26c9c7c..d8fb8e835 100644 --- a/src/android/android_message_priority.rs +++ b/src/android/android_message_priority.rs @@ -1,7 +1,6 @@ use serde::Serialize; -#[allow(dead_code)] -#[derive(Serialize, Debug)] +#[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidmessagepriority pub enum AndroidMessagePriority { diff --git a/src/android/android_notification.rs b/src/android/android_notification.rs index eba41511a..358f00b69 100644 --- a/src/android/android_notification.rs +++ b/src/android/android_notification.rs @@ -1,234 +1,117 @@ use serde::Serialize; use super::{ - light_settings::{LightSettings, LightSettingsInternal}, + light_settings::LightSettings, notification_priority::NotificationPriority, visibility::Visibility, }; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidnotification -pub(crate) struct AndroidNotificationInternal { - /// The notification's title. - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - - /// The notification's body text. - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - - /// The notification's icon. - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - - /// The notification's icon color, expressed in #rrggbb format. - #[serde(skip_serializing_if = "Option::is_none")] - color: Option, - - /// The sound to play when the device receives the notification. - #[serde(skip_serializing_if = "Option::is_none")] - sound: Option, - - /// Identifier used to replace existing notifications in the notification drawer. - #[serde(skip_serializing_if = "Option::is_none")] - tag: Option, - - /// The action associated with a user click on the notification. - #[serde(skip_serializing_if = "Option::is_none")] - click_action: Option, - - /// The key to the body string in the app's string resources to use to localize the body text to the user's - /// current localization. - #[serde(skip_serializing_if = "Option::is_none")] - body_loc_key: Option, - - /// Variable string values to be used in place of the format specifiers in body_loc_key to use to localize the - /// body text to the user's current localization. - #[serde(skip_serializing_if = "Option::is_none")] - body_loc_args: Option>, - - /// The key to the title string in the app's string resources to use to localize the title text to the user's - /// current localization. - #[serde(skip_serializing_if = "Option::is_none")] - title_loc_key: Option, - - /// Variable string values to be used in place of the format specifiers in title_loc_key to use to localize the - /// title text to the user's current localization. - #[serde(skip_serializing_if = "Option::is_none")] - title_loc_args: Option>, - - /// The notification's channel id (new in Android O). - #[serde(skip_serializing_if = "Option::is_none")] - channel_id: Option, - - /// Sets the "ticker" text, which is sent to accessibility services. - #[serde(skip_serializing_if = "Option::is_none")] - ticker: Option, - - /// When set to false or unset, the notification is automatically dismissed when the user clicks it in the panel. - #[serde(skip_serializing_if = "Option::is_none")] - sticky: Option, - - /// Set the time that the event in the notification occurred. Notifications in the panel are sorted by this time. - /// Timestamp format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Timestamp - #[serde(skip_serializing_if = "Option::is_none")] - event_time: Option, - - /// Set whether or not this notification is relevant only to the current device. - #[serde(skip_serializing_if = "Option::is_none")] - local_only: Option, - - /// Set the relative priority for this notification. - #[serde(skip_serializing_if = "Option::is_none")] - notification_priority: Option, - - /// If set to true, use the Android framework's default sound for the notification. - #[serde(skip_serializing_if = "Option::is_none")] - default_sound: Option, - - /// If set to true, use the Android framework's default vibrate pattern for the notification. - #[serde(skip_serializing_if = "Option::is_none")] - default_vibrate_timings: Option, - - /// If set to true, use the Android framework's default LED light settings for the notification. - #[serde(skip_serializing_if = "Option::is_none")] - default_light_settings: Option, - - /// Set the vibration pattern to use - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration - #[serde(skip_serializing_if = "Option::is_none")] - vibrate_timings: Option>, - - /// Set the Notification.visibility of the notification. - #[serde(skip_serializing_if = "Option::is_none")] - visibility: Option, - - /// Sets the number of items this notification represents. - #[serde(skip_serializing_if = "Option::is_none")] - notification_count: Option, - - /// Settings to control the notification's LED blinking rate and color if LED is available on the device. - #[serde(skip_serializing_if = "Option::is_none")] - light_settings: Option, - - /// Contains the URL of an image that is going to be displayed in a notification. - #[serde(skip_serializing_if = "Option::is_none")] - image: Option, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidnotification pub struct AndroidNotification { /// The notification's title. + #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, /// The notification's body text. + #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, /// The notification's icon. + #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, /// The notification's icon color, expressed in #rrggbb format. + #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, /// The sound to play when the device receives the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub sound: Option, /// Identifier used to replace existing notifications in the notification drawer. + #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, /// The action associated with a user click on the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub click_action: Option, /// The key to the body string in the app's string resources to use to localize the body text to the user's /// current localization. + #[serde(skip_serializing_if = "Option::is_none")] pub body_loc_key: Option, /// Variable string values to be used in place of the format specifiers in body_loc_key to use to localize the /// body text to the user's current localization. + #[serde(skip_serializing_if = "Option::is_none")] pub body_loc_args: Option>, /// The key to the title string in the app's string resources to use to localize the title text to the user's /// current localization. + #[serde(skip_serializing_if = "Option::is_none")] pub title_loc_key: Option, /// Variable string values to be used in place of the format specifiers in title_loc_key to use to localize the /// title text to the user's current localization. + #[serde(skip_serializing_if = "Option::is_none")] pub title_loc_args: Option>, /// The notification's channel id (new in Android O). + #[serde(skip_serializing_if = "Option::is_none")] pub channel_id: Option, /// Sets the "ticker" text, which is sent to accessibility services. + #[serde(skip_serializing_if = "Option::is_none")] pub ticker: Option, /// When set to false or unset, the notification is automatically dismissed when the user clicks it in the panel. + #[serde(skip_serializing_if = "Option::is_none")] pub sticky: Option, /// Set the time that the event in the notification occurred. Notifications in the panel are sorted by this time. /// Timestamp format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Timestamp + #[serde(skip_serializing_if = "Option::is_none")] pub event_time: Option, /// Set whether or not this notification is relevant only to the current device. + #[serde(skip_serializing_if = "Option::is_none")] pub local_only: Option, /// Set the relative priority for this notification. + #[serde(skip_serializing_if = "Option::is_none")] pub notification_priority: Option, /// If set to true, use the Android framework's default sound for the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub default_sound: Option, /// If set to true, use the Android framework's default vibrate pattern for the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub default_vibrate_timings: Option, /// If set to true, use the Android framework's default LED light settings for the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub default_light_settings: Option, /// Set the vibration pattern to use /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + #[serde(skip_serializing_if = "Option::is_none")] pub vibrate_timings: Option>, /// Set the Notification.visibility of the notification. + #[serde(skip_serializing_if = "Option::is_none")] pub visibility: Option, /// Sets the number of items this notification represents. + #[serde(skip_serializing_if = "Option::is_none")] pub notification_count: Option, /// Settings to control the notification's LED blinking rate and color if LED is available on the device. + #[serde(skip_serializing_if = "Option::is_none")] pub light_settings: Option, /// Contains the URL of an image that is going to be displayed in a notification. + #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, } - -impl AndroidNotification { - pub(crate) fn finalize(self) -> AndroidNotificationInternal { - AndroidNotificationInternal { - title: self.title, - body: self.body, - icon: self.icon, - color: self.color, - sound: self.sound, - tag: self.tag, - click_action: self.click_action, - body_loc_key: self.body_loc_key, - body_loc_args: self.body_loc_args, - title_loc_key: self.title_loc_key, - title_loc_args: self.title_loc_args, - channel_id: self.channel_id, - ticker: self.ticker, - sticky: self.sticky, - event_time: self.event_time, - local_only: self.local_only, - notification_priority: self.notification_priority, - default_sound: self.default_sound, - default_vibrate_timings: self.default_vibrate_timings, - default_light_settings: self.default_light_settings, - vibrate_timings: self.vibrate_timings, - visibility: self.visibility, - notification_count: self.notification_count, - light_settings: self.light_settings.map(|x| x.finalize()), - image: self.image, - } - } -} diff --git a/src/android/color.rs b/src/android/color.rs index a22c0077c..fa697adb5 100644 --- a/src/android/color.rs +++ b/src/android/color.rs @@ -1,21 +1,5 @@ use serde::Serialize; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#Color -pub(crate) struct ColorInternal { - /// The amount of red in the color as a value in the interval [0, 1]. - red: f32, - - /// The amount of green in the color as a value in the interval [0, 1]. - green: f32, - - /// The amount of blue in the color as a value in the interval [0, 1]. - blue: f32, - - /// The fraction of this color that should be applied to the pixel. - alpha: f32, -} - #[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#Color pub struct Color { @@ -31,14 +15,3 @@ pub struct Color { /// The fraction of this color that should be applied to the pixel. pub alpha: f32, } - -impl Color { - pub(crate) fn finalize(self) -> ColorInternal { - ColorInternal { - red: self.red, - green: self.green, - blue: self.blue, - alpha: self.alpha, - } - } -} diff --git a/src/android/light_settings.rs b/src/android/light_settings.rs index 1a8850932..12ee89d53 100644 --- a/src/android/light_settings.rs +++ b/src/android/light_settings.rs @@ -1,23 +1,8 @@ use serde::Serialize; -use super::color::{Color, ColorInternal}; +use super::color::Color; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#LightSettings -pub(crate) struct LightSettingsInternal { - /// Set color of the LED with google.type.Color. - color: ColorInternal, - - /// Along with light_off_duration, define the blink rate of LED flashes - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration - light_on_duration: String, - - /// Along with light_on_duration, define the blink rate of LED flashes. - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration - light_off_duration: String, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#LightSettings pub struct LightSettings { /// Set color of the LED with google.type.Color. @@ -31,13 +16,3 @@ pub struct LightSettings { /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration pub light_off_duration: String, } - -impl LightSettings { - pub(crate) fn finalize(self) -> LightSettingsInternal { - LightSettingsInternal { - color: self.color.finalize(), - light_on_duration: self.light_on_duration, - light_off_duration: self.light_off_duration, - } - } -} diff --git a/src/android/notification_priority.rs b/src/android/notification_priority.rs index 1d21c6e03..e128a6077 100644 --- a/src/android/notification_priority.rs +++ b/src/android/notification_priority.rs @@ -1,7 +1,6 @@ use serde::Serialize; -#[allow(dead_code)] -#[derive(Serialize, Debug)] +#[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#notificationpriority pub enum NotificationPriority { diff --git a/src/android/visibility.rs b/src/android/visibility.rs index d07d24dda..9d9e335ca 100644 --- a/src/android/visibility.rs +++ b/src/android/visibility.rs @@ -1,7 +1,6 @@ use serde::Serialize; -#[allow(dead_code)] -#[derive(Serialize, Debug)] +#[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#visibility pub enum Visibility { diff --git a/src/apns/apns_config.rs b/src/apns/apns_config.rs index db26718fa..b54f6e8e6 100644 --- a/src/apns/apns_config.rs +++ b/src/apns/apns_config.rs @@ -1,41 +1,20 @@ use serde::Serialize; use serde_json::Value; -use super::apns_fcm_options::{ApnsFcmOptions, ApnsFcmOptionsInternal}; +use super::apns_fcm_options::ApnsFcmOptions; -#[derive(Serialize, Debug)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsconfig -pub(crate) struct ApnsConfigInternal { +pub struct ApnsConfig { /// HTTP request headers defined in Apple Push Notification Service. #[serde(skip_serializing_if = "Option::is_none")] - headers: Option, + pub headers: Option, /// APNs payload as a JSON object, including both aps dictionary and custom payload. #[serde(skip_serializing_if = "Option::is_none")] - payload: Option, + pub payload: Option, /// Options for features provided by the FCM SDK for iOS. #[serde(skip_serializing_if = "Option::is_none")] - fcm_options: Option, -} - -#[derive(Debug, Default)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsconfig -pub struct ApnsConfig { - /// HTTP request headers defined in Apple Push Notification Service. - pub headers: Option, - /// APNs payload as a JSON object, including both aps dictionary and custom payload. - pub payload: Option, - /// Options for features provided by the FCM SDK for iOS. pub fcm_options: Option, } - -impl ApnsConfig { - pub(crate) fn finalize(self) -> ApnsConfigInternal { - ApnsConfigInternal { - headers: self.headers, - payload: self.payload, - fcm_options: self.fcm_options.map(|fcm_options| fcm_options.finalize()), - } - } -} diff --git a/src/apns/apns_fcm_options.rs b/src/apns/apns_fcm_options.rs index 68e41c105..1feaf1b42 100644 --- a/src/apns/apns_fcm_options.rs +++ b/src/apns/apns_fcm_options.rs @@ -1,16 +1,6 @@ use serde::Serialize; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsfcmoptions -pub(crate) struct ApnsFcmOptionsInternal { - /// Label associated with the message's analytics data. - analytics_label: Option, - - /// Contains the URL of an image that is going to be displayed in a notification. - image: Option, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsfcmoptions pub struct ApnsFcmOptions { /// Label associated with the message's analytics data. @@ -19,12 +9,3 @@ pub struct ApnsFcmOptions { /// Contains the URL of an image that is going to be displayed in a notification. pub image: Option, } - -impl ApnsFcmOptions { - pub(crate) fn finalize(self) -> ApnsFcmOptionsInternal { - ApnsFcmOptionsInternal { - analytics_label: self.analytics_label, - image: self.image, - } - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index 00b32dec4..be12ce08b 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -149,7 +149,7 @@ impl FcmClient { .http_client .post(&url) .bearer_auth(auth_token) - .json(&MessageWrapper::new(message.finalize())) + .json(&MessageWrapper::new(message)) .build()?; let response = self.http_client.execute(request).await?; diff --git a/src/message/fcm_options.rs b/src/message/fcm_options.rs index 2a7172f94..8d8e6ef89 100644 --- a/src/message/fcm_options.rs +++ b/src/message/fcm_options.rs @@ -1,23 +1,8 @@ use serde::Serialize; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#fcmoptions -pub(crate) struct FcmOptionsInternal { - /// Label associated with the message's analytics data. - analytics_label: String, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#fcmoptions pub struct FcmOptions { /// Label associated with the message's analytics data. pub analytics_label: String, } - -impl FcmOptions { - pub(crate) fn finalize(self) -> FcmOptionsInternal { - FcmOptionsInternal { - analytics_label: self.analytics_label, - } - } -} diff --git a/src/message/mod.rs b/src/message/mod.rs index 465e1a06a..31fd30621 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -10,16 +10,11 @@ use serde::Serializer; use serde_json::Value; use crate::android::android_config::AndroidConfig; -use crate::android::android_config::AndroidConfigInternal; use crate::apns::apns_config::ApnsConfig; -use crate::apns::apns_config::ApnsConfigInternal; use crate::notification::Notification; -use crate::notification::NotificationInternal; use crate::web::webpush_config::WebpushConfig; -use crate::web::webpush_config::WebpushConfigInternal; use self::fcm_options::FcmOptions; -use self::fcm_options::FcmOptionsInternal; use self::target::Target; fn output_target(target: &Target, s: S) -> Result @@ -35,82 +30,47 @@ where map.end() } -#[derive(Serialize, Debug)] +#[derive(Debug, Serialize)] +/// A `Message` instance is the main object to send to the FCM API. /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#resource:-message -pub(crate) struct MessageInternal { - /// Arbitrary key/value payload, which must be UTF-8 encoded. +pub struct Message { + /// Arbitrary key/value payload, which must be UTF-8 encoded. Values must be strings. #[serde(skip_serializing_if = "Option::is_none")] - data: Option, + pub data: Option, /// Basic notification template to use across all platforms. #[serde(skip_serializing_if = "Option::is_none")] - notification: Option, + pub notification: Option, /// Android specific options for messages sent through FCM connection server. #[serde(skip_serializing_if = "Option::is_none")] - android: Option, + pub android: Option, /// Webpush protocol options. #[serde(skip_serializing_if = "Option::is_none")] - webpush: Option, + pub webpush: Option, /// Apple Push Notification Service specific options. #[serde(skip_serializing_if = "Option::is_none")] - apns: Option, + pub apns: Option, /// Template for FCM SDK feature options to use across all platforms. #[serde(skip_serializing_if = "Option::is_none")] - fcm_options: Option, + pub fcm_options: Option, /// Target to send a message to. #[serde(flatten, serialize_with = "output_target")] - target: Target, -} - -/// A `Message` instance is the main object to send to the FCM API. -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#resource:-message -#[derive(Debug)] -pub struct Message { - /// Arbitrary key/value payload, which must be UTF-8 encoded. Values must be strings. - pub data: Option, - /// Basic notification template to use across all platforms. - pub notification: Option, - /// Target to send a message to. pub target: Target, - /// Android specific options for messages sent through FCM connection server. - pub android: Option, - /// Webpush protocol options. - pub webpush: Option, - /// Apple Push Notification Service specific options. - pub apns: Option, - /// Template for FCM SDK feature options to use across all platforms. - pub fcm_options: Option, -} - -impl Message { - /// Complete the build and get a `MessageInternal` instance - pub(crate) fn finalize(self) -> MessageInternal { - MessageInternal { - data: self.data, - notification: self.notification.map(|n| n.finalize()), - android: self.android.map(|a| a.finalize()), - webpush: self.webpush.map(|w| w.finalize()), - apns: self.apns.map(|a| a.finalize()), - fcm_options: self.fcm_options.map(|f| f.finalize()), - target: self.target, - } - } } /// Wrap the message in a "message" field #[derive(Serialize)] pub(crate) struct MessageWrapper { - #[serde(rename = "message")] - message: MessageInternal, + message: Message, } impl MessageWrapper { - pub fn new(message: MessageInternal) -> MessageWrapper { + pub fn new(message: Message) -> MessageWrapper { MessageWrapper { message } } } diff --git a/src/message/target.rs b/src/message/target.rs index b1af04f3f..d00256305 100644 --- a/src/message/target.rs +++ b/src/message/target.rs @@ -9,7 +9,7 @@ use serde::Serialize; /// Target::Topic("my-topic-name".to_string()); /// Target::Condition("my-condition".to_string()); /// ``` -#[derive(Clone, Serialize, Debug, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Target { Token(String), diff --git a/src/message/tests.rs b/src/message/tests.rs index 55a8fc38d..350928c11 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -12,8 +12,7 @@ fn should_create_new_message() { webpush: None, apns: None, fcm_options: None, - } - .finalize(); + }; assert_eq!(msg.target, target); } @@ -29,8 +28,7 @@ fn should_leave_nones_out_of_the_json() { webpush: None, apns: None, fcm_options: None, - } - .finalize(); + }; let payload = serde_json::to_string(&msg).unwrap(); let expected_payload = json!({ @@ -46,7 +44,7 @@ fn should_add_custom_data_to_the_payload() { let target = Target::Token("token".to_string()); let data = json!({ "foo": "bar", "bar": false }); - let builder = Message { + let msg = Message { target, data: Some(data), notification: None, @@ -56,7 +54,6 @@ fn should_add_custom_data_to_the_payload() { fcm_options: None, }; - let msg = builder.finalize(); let payload = serde_json::to_string(&msg).unwrap(); let expected_payload = json!({ @@ -79,7 +76,7 @@ fn should_be_able_to_render_a_full_token_message_to_json() { body: None, image: None, }; - let builder = Message { + let msg = Message { target: target.clone(), data: None, notification: Some(notification), @@ -89,7 +86,7 @@ fn should_be_able_to_render_a_full_token_message_to_json() { fcm_options: None, }; - let payload = serde_json::to_string(&builder.finalize()).unwrap(); + let payload = serde_json::to_string(&msg).unwrap(); let expected_payload = json!({ "notification": {}, @@ -108,7 +105,7 @@ fn should_be_able_to_render_a_full_topic_message_to_json() { body: None, image: None, }; - let builder = Message { + let msg = Message { target: target.clone(), data: None, notification: Some(notification), @@ -118,7 +115,7 @@ fn should_be_able_to_render_a_full_topic_message_to_json() { fcm_options: None, }; - let payload = serde_json::to_string(&builder.finalize()).unwrap(); + let payload = serde_json::to_string(&msg).unwrap(); let expected_payload = json!({ "notification": {}, @@ -137,7 +134,7 @@ fn should_be_able_to_render_a_full_condition_message_to_json() { body: None, image: None, }; - let builder = Message { + let msg = Message { target: target.clone(), data: None, notification: Some(notification), @@ -147,7 +144,7 @@ fn should_be_able_to_render_a_full_condition_message_to_json() { fcm_options: None, }; - let payload = serde_json::to_string(&builder.finalize()).unwrap(); + let payload = serde_json::to_string(&msg).unwrap(); let expected_payload = json!({ "notification": {}, @@ -168,7 +165,7 @@ fn should_set_notifications() { image: None, }; - let builder = Message { + let msg = Message { target: target.clone(), data: None, notification: Some(nm), @@ -177,7 +174,6 @@ fn should_set_notifications() { apns: None, fcm_options: None, }; - let msg = builder.finalize(); assert!(msg.notification.is_some()); } diff --git a/src/notification/mod.rs b/src/notification/mod.rs index d76d7ed28..e8aa663e6 100644 --- a/src/notification/mod.rs +++ b/src/notification/mod.rs @@ -3,44 +3,18 @@ mod tests; use serde::Serialize; -/// This struct represents a FCM notification. Use the -/// corresponding `Notification` to get an instance. You can then use -/// this notification instance when sending a FCM message. -#[derive(Serialize, Debug, PartialEq)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#notification -pub(crate) struct NotificationInternal { - /// The notification's title. - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - - /// The notification's body text. - #[serde(skip_serializing_if = "Option::is_none")] - body: Option, - - /// Contains the URL of an image that is going to be downloaded on the device and displayed in a notification. - #[serde(skip_serializing_if = "Option::is_none")] - image: Option, -} - -#[derive(Debug, Default)] pub struct Notification { /// The notification's title. + #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, /// The notification's body text. + #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, /// Contains the URL of an image that is going to be downloaded on the device and displayed in a notification. + #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, } - -impl Notification { - /// Complete the build and get a `Notification` instance - pub(crate) fn finalize(self) -> NotificationInternal { - NotificationInternal { - title: self.title, - body: self.body, - image: self.image, - } - } -} diff --git a/src/notification/tests.rs b/src/notification/tests.rs index 3bf593d87..8f7f1d80a 100644 --- a/src/notification/tests.rs +++ b/src/notification/tests.rs @@ -9,7 +9,7 @@ fn should_be_able_to_render_a_full_notification_to_json() { image: Some("https://my.image.com/test.jpg".to_string()), }; - let payload = serde_json::to_string(¬.finalize()).unwrap(); + let payload = serde_json::to_string(¬).unwrap(); let expected_payload = json!({ "title": "foo", diff --git a/src/web/webpush_config.rs b/src/web/webpush_config.rs index 85f1757ff..3e68d2ca1 100644 --- a/src/web/webpush_config.rs +++ b/src/web/webpush_config.rs @@ -1,53 +1,25 @@ use serde::Serialize; use serde_json::Value; -use super::webpush_fcm_options::{WebpushFcmOptions, WebpushFcmOptionsInternal}; +use super::webpush_fcm_options::WebpushFcmOptions; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushconfig -pub(crate) struct WebpushConfigInternal { - /// HTTP headers defined in webpush protocol. - #[serde(skip_serializing_if = "Option::is_none")] - headers: Option, - - /// Arbitrary key/value payload. - #[serde(skip_serializing_if = "Option::is_none")] - data: Option, - - /// Web Notification options as a JSON object. - /// Struct format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Struct - #[serde(skip_serializing_if = "Option::is_none")] - notification: Option, - - /// Options for features provided by the FCM SDK for Web. - #[serde(skip_serializing_if = "Option::is_none")] - fcm_options: Option, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushconfig pub struct WebpushConfig { /// HTTP headers defined in webpush protocol. + #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option, /// Arbitrary key/value payload. + #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, /// Web Notification options as a JSON object. /// Struct format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Struct + #[serde(skip_serializing_if = "Option::is_none")] pub notification: Option, /// Options for features provided by the FCM SDK for Web. + #[serde(skip_serializing_if = "Option::is_none")] pub fcm_options: Option, } - -impl WebpushConfig { - pub(crate) fn finalize(self) -> WebpushConfigInternal { - WebpushConfigInternal { - headers: self.headers, - data: self.data, - notification: self.notification, - fcm_options: self.fcm_options.map(|fcm_options| fcm_options.finalize()), - } - } -} diff --git a/src/web/webpush_fcm_options.rs b/src/web/webpush_fcm_options.rs index 56ceb05ab..09f5b788b 100644 --- a/src/web/webpush_fcm_options.rs +++ b/src/web/webpush_fcm_options.rs @@ -1,16 +1,6 @@ use serde::Serialize; -#[derive(Serialize, Debug)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushfcmoptions -pub(crate) struct WebpushFcmOptionsInternal { - /// The link to open when the user clicks on the notification. - link: String, - - /// Label associated with the message's analytics data. - analytics_label: String, -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] /// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushfcmoptions pub struct WebpushFcmOptions { /// The link to open when the user clicks on the notification. @@ -19,12 +9,3 @@ pub struct WebpushFcmOptions { /// Label associated with the message's analytics data. pub analytics_label: String, } - -impl WebpushFcmOptions { - pub(crate) fn finalize(self) -> WebpushFcmOptionsInternal { - WebpushFcmOptionsInternal { - link: self.link, - analytics_label: self.analytics_label, - } - } -} From a35f84585ef37a7e3beb668f58bfdd33f58024af Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 19:11:33 +0300 Subject: [PATCH 12/40] Return error if Retry-After header is invalid --- src/client/mod.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index be12ce08b..922f7e02a 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -29,6 +29,13 @@ pub enum FcmClientError { MissingProjectId, #[error("Dotenv error: {0}")] DotenvError(#[from] dotenv::Error), + #[error("Retry-After HTTP header value is not valid string")] + RetryAfterHttpHeaderIsNotString, + #[error("Retry-After HTTP header value is not valid, error: {error}, value: {value}")] + RetryAfterHttpHeaderInvalid { + error: chrono::ParseError, + value: String, + }, } impl FcmClientError { @@ -156,9 +163,19 @@ impl FcmClient { let response_status: FcmHttpResponseCode = response.status().as_u16().into(); let retry_after = response .headers() - .get(RETRY_AFTER) - .and_then(|ra| ra.to_str().ok()) - .and_then(|ra| ra.parse::().ok()); + .get(RETRY_AFTER); + let retry_after = if let Some(header_value) = retry_after { + let header_str = header_value.to_str() + .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; + let value = header_str.parse::() + .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { + error, + value: header_str.to_string(), + })?; + Some(value) + } else { + None + }; let response_json_object = response.json::>().await .ok() .unwrap_or_default(); From a6ff6a065425648c96545095ab8850bce449c024 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 20:14:28 +0300 Subject: [PATCH 13/40] Add tests for wait time method --- src/client/response.rs | 52 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/client/response.rs b/src/client/response.rs index f7c72a128..5727fa88f 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -233,17 +233,65 @@ mod tests { #[test] fn test_retry_after_from_seconds() { - assert_eq!(RetryAfter::Delay(Duration::from_secs(420)), "420".parse().unwrap()); + let expected_wait_time = Duration::from_secs(1); + let expected = RetryAfter::Delay(expected_wait_time); + assert_eq!(expected, "1".parse().unwrap()); + assert_eq!(expected_wait_time, expected.wait_time_with_current_time(DateTime::default())); } #[test] fn test_retry_after_from_date() { let date = "Sun, 06 Nov 1994 08:49:37 GMT"; + let date_time = DateTime::parse_from_rfc2822(date).unwrap(); let retry_after = RetryAfter::from_str(date).unwrap(); assert_eq!( - RetryAfter::DateTime(DateTime::parse_from_rfc2822(date).unwrap()), + RetryAfter::DateTime(date_time), retry_after, ); + + assert_eq!( + Duration::ZERO, + retry_after.wait_time_with_current_time(date_time), + ); + } + + #[test] + fn test_retry_after_from_date_and_get_wait_time_using_future_date() { + let date = "Sun, 06 Nov 1994 08:49:37 GMT"; + let retry_after = RetryAfter::from_str(date).unwrap(); + let future_date = "Sun, 06 Nov 1994 08:49:38 GMT"; + let future_date_time = DateTime::parse_from_rfc2822(future_date).unwrap(); + + assert_eq!( + Duration::from_secs(0), + retry_after.wait_time_with_current_time(future_date_time), + ); + } + + #[test] + fn test_retry_after_from_date_and_get_wait_time_using_past_date() { + let date = "Sun, 06 Nov 1994 08:49:37 GMT"; + let retry_after = RetryAfter::from_str(date).unwrap(); + let past_date = "Sun, 06 Nov 1994 08:49:36 GMT"; + let past_date_time = DateTime::parse_from_rfc2822(past_date).unwrap(); + + assert_eq!( + Duration::from_secs(1), + retry_after.wait_time_with_current_time(past_date_time), + ); + } + + #[test] + fn test_retry_after_from_date_and_get_wait_time_using_different_timezone() { + let date = "Sun, 06 Nov 1994 08:49:37 GMT"; + let retry_after = RetryAfter::from_str(date).unwrap(); + let past_date = "Sun, 06 Nov 1994 08:49:37 +0100"; + let past_date_time = DateTime::parse_from_rfc2822(past_date).unwrap(); + + assert_eq!( + Duration::from_secs(60 * 60), + retry_after.wait_time_with_current_time(past_date_time), + ); } } From a1a5fc38c6a664c1bf1f8040b17dd3ca53266624 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Mon, 20 May 2024 22:20:47 +0300 Subject: [PATCH 14/40] Simplify wait time calculation code --- src/client/response.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/client/response.rs b/src/client/response.rs index 5727fa88f..1b3901878 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -72,16 +72,14 @@ impl RetryAfter { } fn wait_time_with_current_time(&self, now: DateTime) -> Duration { - match self { - RetryAfter::Delay(duration) => *duration, + match *self { + RetryAfter::Delay(duration) => duration, RetryAfter::DateTime(date_time) => - if *date_time <= now { - Duration::ZERO - } else { - (*date_time - now) - .to_std() - .unwrap_or(Duration::ZERO) - } + (date_time - now) + .to_std() + // TimeDelta is negative when the date_time is in the + // past. In that case wait time is 0. + .unwrap_or(Duration::ZERO) } } } @@ -93,7 +91,10 @@ impl FromStr for RetryAfter { s.parse::() .map(Duration::from_secs) .map(RetryAfter::Delay) - .or_else(|_| DateTime::parse_from_rfc2822(s).map(RetryAfter::DateTime)) + .or_else( + |_| DateTime::parse_from_rfc2822(s) + .map(RetryAfter::DateTime) + ) } } From de0f397725c4230fafd037e659e7f4230045544f Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 18:53:52 +0300 Subject: [PATCH 15/40] Reintroduce gauth support --- Cargo.toml | 12 +++- src/client/mod.rs | 104 +++++++++++++++++---------------- src/client/oauth_gauth.rs | 70 ++++++++++++++++++++++ src/client/oauth_yup_oauth2.rs | 80 +++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 53 deletions(-) create mode 100644 src/client/oauth_gauth.rs create mode 100644 src/client/oauth_yup_oauth2.rs diff --git a/Cargo.toml b/Cargo.toml index f745b7057..0782e73f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,20 +16,26 @@ keywords = ["fcm", "firebase", "notification"] edition = "2018" [features] -default = ["native-tls"] +default = ["native-tls", "gauth"] + native-tls = ["reqwest/native-tls"] rustls = ["reqwest/rustls-tls"] vendored-tls = ["reqwest/native-tls-vendored"] +gauth = ["dep:gauth"] +yup-oauth2 = ["dep:yup-oauth2"] + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -reqwest = { version = "0.11.0", features = ["json"], default-features=false } +reqwest = { version = "0.11.0", features = ["json"], default-features = false } chrono = "0.4" -yup-oauth2 = "9.0.0" thiserror = "1.0.61" dotenv = "0.15.0" +gauth = { version = "0.7.0", optional = true } +yup-oauth2 = { version = "9.0.0", optional = true } + [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } clap = { version = "4.5.4", features = ["cargo", "derive"] } diff --git a/src/client/mod.rs b/src/client/mod.rs index 922f7e02a..d97f2618a 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,34 +1,38 @@ pub(crate) mod response; +#[cfg(feature = "gauth")] +mod oauth_gauth; + +#[cfg(feature = "yup-oauth2")] +mod oauth_yup_oauth2; + +#[cfg(feature = "gauth")] +use oauth_gauth as oauth_client_impl; + +#[cfg(feature = "yup-oauth2")] +use oauth_yup_oauth2 as oauth_client_impl; + use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::client::response::{FcmResponse, RetryAfter}; -use crate::{Message, MessageWrapper}; use reqwest::header::RETRY_AFTER; -use yup_oauth2::authenticator::{Authenticator, DefaultHyperClient, HyperClientBuilder}; -use yup_oauth2::hyper::client::HttpConnector; -use yup_oauth2::hyper_rustls::HttpsConnector; -use yup_oauth2::ServiceAccountAuthenticator; -use self::response::FcmHttpResponseCode; +use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseCode}; +use crate::{Message, MessageWrapper}; +use oauth_client_impl::OauthClientImpl; + +pub use oauth_client_impl::FcmOauthError; + +const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; #[derive(thiserror::Error, Debug)] pub enum FcmClientError { #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), - #[error("Service account key reading failed: {0}")] - ServiceAccountKeyReadingFailed(std::io::Error), #[error("OAuth error: {0}")] - OauthError(#[from] yup_oauth2::Error), - #[error("Access token is missing")] - AccessTokenIsMissing, - #[error("Authenticator creation failed: {0}")] - AuthenticatorCreatingFailed(std::io::Error), - #[error("Service account key JSON does not contain project ID")] - MissingProjectId, + Oauth(#[from] FcmOauthError), #[error("Dotenv error: {0}")] - DotenvError(#[from] dotenv::Error), + Dotenv(#[from] dotenv::Error), #[error("Retry-After HTTP header value is not valid string")] RetryAfterHttpHeaderIsNotString, #[error("Retry-After HTTP header value is not valid, error: {error}, value: {value}")] @@ -40,15 +44,32 @@ pub enum FcmClientError { impl FcmClientError { /// If this is `true` then most likely current service key is invalid. - pub fn is_token_missing_even_if_server_requests_completed(&self) -> bool { - matches!( - self, - FcmClientError::AccessTokenIsMissing | - FcmClientError::OauthError(yup_oauth2::Error::AuthError(_)) - ) + #[cfg(feature = "yup-oauth2")] + pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { + match self { + FcmClientError::Oauth(error) => + error.is_access_token_missing_even_if_server_requests_completed(), + _ => false, + } } } +trait OauthClient: Sized { + async fn create_with_key_file( + service_account_key_path: PathBuf, + token_cache_json_path: Option, + ) -> Result; + + async fn get_access_token(&self) -> Result; + + fn get_project_id(&self) -> &str; +} + +trait OauthErrorInfo { + /// If this is `true` then most likely current service key is invalid. + fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool; +} + #[derive(Debug, Default, Clone)] pub struct FcmClientBuilder { service_account_key_json_path: Option, @@ -70,6 +91,7 @@ impl FcmClientBuilder { } /// Set path to the token cache JSON file. Default is no token cache JSON file. + #[cfg(feature = "yup-oauth2")] pub fn token_cache_json_path(mut self, token_cache_json_path: impl AsRef) -> Self { self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); self @@ -92,8 +114,7 @@ impl FcmClientBuilder { /// An async client for sending the notification payload. pub struct FcmClient { http_client: reqwest::Client, - authenticator: Authenticator>, - project_id: String, + oauth_client: OauthClientImpl, } impl FcmClient { @@ -118,44 +139,27 @@ impl FcmClient { dotenv::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() }; - let key = yup_oauth2::read_service_account_key(service_account_key_path) - .await - .map_err(FcmClientError::ServiceAccountKeyReadingFailed)?; - let oauth_client = DefaultHyperClient.build_hyper_client() - .map_err(FcmClientError::OauthError)?; - let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); - let builder = if let Some(path) = fcm_builder.token_cache_json_path { - builder.persist_tokens_to_disk(path) - } else { - builder - }; - let authenticator = builder.build() - .await - .map_err(FcmClientError::AuthenticatorCreatingFailed)?; - - let project_id = key.project_id - .ok_or(FcmClientError::MissingProjectId)?; + let oauth_client = OauthClientImpl::create_with_key_file( + service_account_key_path, + fcm_builder.token_cache_json_path, + ).await?; Ok(FcmClient { http_client, - authenticator, - project_id, + oauth_client, }) } pub async fn send(&self, message: Message) -> Result { - let scopes = ["https://www.googleapis.com/auth/firebase.messaging"]; - let auth_token = self.authenticator.token(&scopes).await?; - let auth_token = auth_token.token() - .ok_or(FcmClientError::AccessTokenIsMissing)?; + let access_token = self.oauth_client.get_access_token().await?; // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send - let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.project_id); + let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); let request = self .http_client .post(&url) - .bearer_auth(auth_token) + .bearer_auth(access_token) .json(&MessageWrapper::new(message)) .build()?; diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs new file mode 100644 index 000000000..88e084ce9 --- /dev/null +++ b/src/client/oauth_gauth.rs @@ -0,0 +1,70 @@ +use std::path::{Path, PathBuf}; + +use gauth::serv_account::ServiceAccount; + +use super::{OauthClient, FIREBASE_OAUTH_SCOPE}; + +#[derive(thiserror::Error, Debug)] +pub enum FcmOauthError { + #[error("OAuth error: {0}")] + Oauth(String), + #[error("Service account key path is not UTF-8")] + ServiceAccountKeyPathIsNotUtf8, + #[error("Service account key reading failed: {0}")] + ServiceAccountKeyReadingFailed(std::io::Error), + #[error("Service account key JSON deserialization failed: {0}")] + ServiceAccountKeyDeserializationFailed(serde_json::Error), + #[error("Service account key JSON does not contain project ID")] + ProjectIdIsMissing, +} + +pub struct OauthClientImpl { + project_id: String, + service_account_key_path: String, +} + +impl OauthClient for OauthClientImpl { + async fn create_with_key_file( + service_account_key_path: PathBuf, + _token_cache_json_path: Option, + ) -> Result { + Ok(OauthClientImpl { + project_id: get_project_id(&service_account_key_path)?, + service_account_key_path: service_account_key_path.to_str() + .ok_or(FcmOauthError::ServiceAccountKeyPathIsNotUtf8)? + .to_string(), + }) + } + + async fn get_access_token(&self) -> Result { + let scopes = vec![FIREBASE_OAUTH_SCOPE]; + let mut service_account = ServiceAccount::from_file(&self.service_account_key_path, scopes); + let access_token = service_account.access_token().await + .map_err(|e| e.to_string()) + .map_err(FcmOauthError::Oauth)?; + + let token_no_bearer = access_token.split(char::is_whitespace).collect::>()[1]; + + Ok(token_no_bearer.to_string()) + } + + fn get_project_id(&self) -> &str { + &self.project_id + } +} + +fn read_service_key_file_json(service_account_key_path: impl AsRef) -> Result { + let json_string = std::fs::read_to_string(service_account_key_path) + .map_err(FcmOauthError::ServiceAccountKeyReadingFailed)?; + let json_content: serde_json::Value = serde_json::from_str(&json_string) + .map_err(FcmOauthError::ServiceAccountKeyDeserializationFailed)?; + + Ok(json_content) +} + +fn get_project_id(service_account_key_path: impl AsRef) -> Result { + let json_content = read_service_key_file_json(service_account_key_path)?; + let project_id = json_content["project_id"].as_str() + .ok_or(FcmOauthError::ProjectIdIsMissing)?; + Ok(project_id.to_string()) +} diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs new file mode 100644 index 000000000..747843743 --- /dev/null +++ b/src/client/oauth_yup_oauth2.rs @@ -0,0 +1,80 @@ +use std::path::PathBuf; + +use yup_oauth2::authenticator::{Authenticator, DefaultHyperClient, HyperClientBuilder}; +use yup_oauth2::hyper::client::HttpConnector; +use yup_oauth2::hyper_rustls::HttpsConnector; +use yup_oauth2::ServiceAccountAuthenticator; + +use super::{OauthClient, OauthErrorInfo, FIREBASE_OAUTH_SCOPE}; + +#[derive(thiserror::Error, Debug)] +pub enum FcmOauthError { + #[error("Service account key reading failed: {0}")] + ServiceAccountKeyReadingFailed(std::io::Error), + #[error("OAuth error: {0}")] + Oauth(#[from] yup_oauth2::Error), + #[error("Access token is missing")] + AccessTokenIsMissing, + #[error("Authenticator creation failed: {0}")] + AuthenticatorCreatingFailed(std::io::Error), + #[error("Service account key JSON does not contain project ID")] + ProjectIdIsMissing, +} + +impl OauthErrorInfo for FcmOauthError { + fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { + matches!( + self, + FcmOauthError::AccessTokenIsMissing | + FcmOauthError::Oauth(yup_oauth2::Error::AuthError(_)) + ) + } +} + +pub struct OauthClientImpl { + authenticator: Authenticator>, + project_id: String, +} + +impl OauthClient for OauthClientImpl { + async fn create_with_key_file( + service_account_key_path: PathBuf, + token_cache_json_path: Option, + ) -> Result { + let key = yup_oauth2::read_service_account_key(service_account_key_path) + .await + .map_err(FcmOauthError::ServiceAccountKeyReadingFailed)?; + let oauth_client = DefaultHyperClient.build_hyper_client() + .map_err(FcmOauthError::Oauth)?; + let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); + let builder = if let Some(path) = token_cache_json_path { + builder.persist_tokens_to_disk(path) + } else { + builder + }; + let authenticator = builder.build() + .await + .map_err(FcmOauthError::AuthenticatorCreatingFailed)?; + + let project_id = key.project_id + .ok_or(FcmOauthError::ProjectIdIsMissing)?; + + Ok(OauthClientImpl { + authenticator, + project_id, + }) + } + + async fn get_access_token(&self) -> Result { + let scopes = [FIREBASE_OAUTH_SCOPE]; + let access_token = self.authenticator.token(&scopes).await?; + let access_token = access_token.token() + .ok_or(FcmOauthError::AccessTokenIsMissing)?; + + Ok(access_token.to_string()) + } + + fn get_project_id(&self) -> &str { + &self.project_id + } +} From aaee2110f6afe0313c5cb866edf19f4d9126ea24 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 19:04:37 +0300 Subject: [PATCH 16/40] Convert text URLs in doc comments to links --- src/android/android_config.rs | 4 ++-- src/android/android_fcm_options.rs | 2 +- src/android/android_message_priority.rs | 2 +- src/android/android_notification.rs | 6 +++--- src/android/color.rs | 2 +- src/android/light_settings.rs | 6 +++--- src/android/notification_priority.rs | 2 +- src/android/visibility.rs | 2 +- src/apns/apns_config.rs | 2 +- src/apns/apns_fcm_options.rs | 2 +- src/client/mod.rs | 2 +- src/client/response.rs | 6 +++--- src/message/fcm_options.rs | 2 +- src/message/mod.rs | 2 +- src/notification/mod.rs | 2 +- src/web/webpush_config.rs | 4 ++-- src/web/webpush_fcm_options.rs | 2 +- 17 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/android/android_config.rs b/src/android/android_config.rs index 3b90926f7..81073836b 100644 --- a/src/android/android_config.rs +++ b/src/android/android_config.rs @@ -8,7 +8,7 @@ use super::{ }; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig +/// pub struct AndroidConfig { /// An identifier of a group of messages that can be collapsed, so that only the last message gets /// sent when delivery can be resumed. @@ -20,7 +20,7 @@ pub struct AndroidConfig { pub priority: Option, /// How long (in seconds) the message should be kept in FCM storage if the device is offline. - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + /// Duration format: #[serde(skip_serializing_if = "Option::is_none")] pub ttl: Option, diff --git a/src/android/android_fcm_options.rs b/src/android/android_fcm_options.rs index d9dbfec8f..efe8722a9 100644 --- a/src/android/android_fcm_options.rs +++ b/src/android/android_fcm_options.rs @@ -1,7 +1,7 @@ use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig +/// pub struct AndroidFcmOptions { /// Label associated with the message's analytics data. pub analytics_label: String, diff --git a/src/android/android_message_priority.rs b/src/android/android_message_priority.rs index d8fb8e835..34c8b4f07 100644 --- a/src/android/android_message_priority.rs +++ b/src/android/android_message_priority.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidmessagepriority +/// pub enum AndroidMessagePriority { Normal, High, diff --git a/src/android/android_notification.rs b/src/android/android_notification.rs index 358f00b69..51c923e26 100644 --- a/src/android/android_notification.rs +++ b/src/android/android_notification.rs @@ -7,7 +7,7 @@ use super::{ }; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidnotification +/// pub struct AndroidNotification { /// The notification's title. #[serde(skip_serializing_if = "Option::is_none")] @@ -70,7 +70,7 @@ pub struct AndroidNotification { pub sticky: Option, /// Set the time that the event in the notification occurred. Notifications in the panel are sorted by this time. - /// Timestamp format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Timestamp + /// Timestamp format: #[serde(skip_serializing_if = "Option::is_none")] pub event_time: Option, @@ -95,7 +95,7 @@ pub struct AndroidNotification { pub default_light_settings: Option, /// Set the vibration pattern to use - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + /// Duration format: #[serde(skip_serializing_if = "Option::is_none")] pub vibrate_timings: Option>, diff --git a/src/android/color.rs b/src/android/color.rs index fa697adb5..ed97b72c2 100644 --- a/src/android/color.rs +++ b/src/android/color.rs @@ -1,7 +1,7 @@ use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#Color +/// pub struct Color { /// The amount of red in the color as a value in the interval [0, 1]. pub red: f32, diff --git a/src/android/light_settings.rs b/src/android/light_settings.rs index 12ee89d53..245315c0e 100644 --- a/src/android/light_settings.rs +++ b/src/android/light_settings.rs @@ -3,16 +3,16 @@ use serde::Serialize; use super::color::Color; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#LightSettings +/// pub struct LightSettings { /// Set color of the LED with google.type.Color. pub color: Color, /// Along with light_off_duration, define the blink rate of LED flashes - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + /// Duration format: pub light_on_duration: String, /// Along with light_on_duration, define the blink rate of LED flashes. - /// Duration format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Duration + /// Duration format: pub light_off_duration: String, } diff --git a/src/android/notification_priority.rs b/src/android/notification_priority.rs index e128a6077..24627b057 100644 --- a/src/android/notification_priority.rs +++ b/src/android/notification_priority.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#notificationpriority +/// pub enum NotificationPriority { PriorityUnspecified, PriorityMin, diff --git a/src/android/visibility.rs b/src/android/visibility.rs index 9d9e335ca..8425affe6 100644 --- a/src/android/visibility.rs +++ b/src/android/visibility.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename_all = "UPPERCASE")] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#visibility +/// pub enum Visibility { VisibilityUnspecified, Private, diff --git a/src/apns/apns_config.rs b/src/apns/apns_config.rs index b54f6e8e6..d20dde985 100644 --- a/src/apns/apns_config.rs +++ b/src/apns/apns_config.rs @@ -4,7 +4,7 @@ use serde_json::Value; use super::apns_fcm_options::ApnsFcmOptions; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsconfig +/// pub struct ApnsConfig { /// HTTP request headers defined in Apple Push Notification Service. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/apns/apns_fcm_options.rs b/src/apns/apns_fcm_options.rs index 1feaf1b42..fd0af8544 100644 --- a/src/apns/apns_fcm_options.rs +++ b/src/apns/apns_fcm_options.rs @@ -1,7 +1,7 @@ use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsfcmoptions +/// pub struct ApnsFcmOptions { /// Label associated with the message's analytics data. pub analytics_label: Option, diff --git a/src/client/mod.rs b/src/client/mod.rs index d97f2618a..3ccc5bf27 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -100,7 +100,7 @@ impl FcmClientBuilder { /// Set timeout for FCM requests. Default is no timeout. /// /// Google recommends at least 10 minute timeout for FCM requests. - /// https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts + /// pub fn fcm_request_timeout(mut self, fcm_request_timeout: Duration) -> Self { self.fcm_request_timeout = Some(fcm_request_timeout); self diff --git a/src/client/response.rs b/src/client/response.rs index 1b3901878..a45e87527 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -4,7 +4,7 @@ use chrono::Utc; use std::time::Duration; use std::{convert::{TryFrom, TryInto}, str::FromStr}; -/// Check https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode +/// Check /// for more information. #[derive(Debug, Clone, Copy, PartialEq)] #[repr(u16)] @@ -138,8 +138,8 @@ impl FcmResponse { /// Action which server or developer should do based on the [FcmResponse]. /// -/// Check https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode -/// and https://firebase.google.com/docs/cloud-messaging/scale-fcm#handling-retries +/// Check +/// and /// for more details. pub enum RecomendedAction<'a> { /// Error [FcmHttpError::Unregistered] was received. diff --git a/src/message/fcm_options.rs b/src/message/fcm_options.rs index 8d8e6ef89..c4b9520dd 100644 --- a/src/message/fcm_options.rs +++ b/src/message/fcm_options.rs @@ -1,7 +1,7 @@ use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#fcmoptions +/// pub struct FcmOptions { /// Label associated with the message's analytics data. pub analytics_label: String, diff --git a/src/message/mod.rs b/src/message/mod.rs index 31fd30621..cf5ba35e3 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -32,7 +32,7 @@ where #[derive(Debug, Serialize)] /// A `Message` instance is the main object to send to the FCM API. -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#resource:-message +/// pub struct Message { /// Arbitrary key/value payload, which must be UTF-8 encoded. Values must be strings. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/notification/mod.rs b/src/notification/mod.rs index e8aa663e6..24cbd5e73 100644 --- a/src/notification/mod.rs +++ b/src/notification/mod.rs @@ -4,7 +4,7 @@ mod tests; use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#notification +/// pub struct Notification { /// The notification's title. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/web/webpush_config.rs b/src/web/webpush_config.rs index 3e68d2ca1..08ece27d1 100644 --- a/src/web/webpush_config.rs +++ b/src/web/webpush_config.rs @@ -4,7 +4,7 @@ use serde_json::Value; use super::webpush_fcm_options::WebpushFcmOptions; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushconfig +/// pub struct WebpushConfig { /// HTTP headers defined in webpush protocol. #[serde(skip_serializing_if = "Option::is_none")] @@ -15,7 +15,7 @@ pub struct WebpushConfig { pub data: Option, /// Web Notification options as a JSON object. - /// Struct format: https://developers.google.com/protocol-buffers/docs/reference/google.protobuf?authuser=0#google.protobuf.Struct + /// Struct format: #[serde(skip_serializing_if = "Option::is_none")] pub notification: Option, diff --git a/src/web/webpush_fcm_options.rs b/src/web/webpush_fcm_options.rs index 09f5b788b..cedba6dd7 100644 --- a/src/web/webpush_fcm_options.rs +++ b/src/web/webpush_fcm_options.rs @@ -1,7 +1,7 @@ use serde::Serialize; #[derive(Debug, Default, Serialize)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushfcmoptions +/// pub struct WebpushFcmOptions { /// The link to open when the user clicks on the notification. pub link: String, From b720b7eb710378fa047f610e32282600be0c1980 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 20:16:09 +0300 Subject: [PATCH 17/40] Improve error handling --- src/client/mod.rs | 12 ++++++--- src/client/response.rs | 60 +++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 3ccc5bf27..5799f5148 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -17,7 +17,7 @@ use std::time::Duration; use reqwest::header::RETRY_AFTER; -use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseCode}; +use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseStatus}; use crate::{Message, MessageWrapper}; use oauth_client_impl::OauthClientImpl; @@ -164,7 +164,6 @@ impl FcmClient { .build()?; let response = self.http_client.execute(request).await?; - let response_status: FcmHttpResponseCode = response.status().as_u16().into(); let retry_after = response .headers() .get(RETRY_AFTER); @@ -180,9 +179,16 @@ impl FcmClient { } else { None }; - let response_json_object = response.json::>().await + let http_status_code = response.status().as_u16(); + // Return if I/O error occurs + let response_body = response.bytes().await?; + let response_json_object = serde_json::from_slice::>(&response_body) .ok() .unwrap_or_default(); + let response_status = FcmHttpResponseStatus::new( + http_status_code, + &response_json_object, + ); Ok(FcmResponse::new( response_status, diff --git a/src/client/response.rs b/src/client/response.rs index a45e87527..651746993 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -4,6 +4,8 @@ use chrono::Utc; use std::time::Duration; use std::{convert::{TryFrom, TryInto}, str::FromStr}; +/// FCM errors which have HTTP status code defined. +/// /// Check /// for more information. #[derive(Debug, Clone, Copy, PartialEq)] @@ -36,20 +38,43 @@ impl TryFrom for FcmHttpError { } #[derive(Debug, Clone, Copy, PartialEq)] -pub enum FcmHttpResponseCode { - /// HTTP 200 +pub enum FcmHttpResponseStatus { + /// FCM message was sent successfully. Ok, + /// HTTP error code was detected as FCM error. Error(FcmHttpError), - Unknown(u16), + /// HTTP status code did not match with [FcmHttpError] and + /// `FcmError` JSON was detected. + /// + /// + /// + /// This variant is named as [FcmHttpResponseStatus::UnspecifiedError] + /// because `UNSPECIFIED_ERROR` is the only `ErrorCode` variant that has no + /// corresponding HTTP status code. + /// + /// + UnspecifiedError, + Unknown { + http_status_code: u16, + }, } -impl From for FcmHttpResponseCode { - fn from(value: u16) -> Self { - match value { - 200 => FcmHttpResponseCode::Ok, - _ => match value.try_into() { - Ok(code) => FcmHttpResponseCode::Error(code), - Err(()) => FcmHttpResponseCode::Unknown(value), +impl FcmHttpResponseStatus { + pub fn new( + http_status_code: u16, + response_json: &serde_json::Map, + ) -> Self { + if let Ok(error) = http_status_code.try_into() { + return FcmHttpResponseStatus::Error(error); + } + + let message = response_json.get("name"); + let fcm_error = response_json.get("error_code"); + match (message, fcm_error) { + (Some(_), _) => FcmHttpResponseStatus::Ok, + (_, Some(_)) => FcmHttpResponseStatus::UnspecifiedError, + (None, None) => FcmHttpResponseStatus::Unknown { + http_status_code, }, } } @@ -100,14 +125,14 @@ impl FromStr for RetryAfter { #[derive(Debug, Clone)] pub struct FcmResponse { - response_status: FcmHttpResponseCode, + response_status: FcmHttpResponseStatus, response_json_object: serde_json::Map, retry_after: Option, } impl FcmResponse { pub(crate) fn new( - response_status: FcmHttpResponseCode, + response_status: FcmHttpResponseStatus, response_json_object: serde_json::Map, retry_after: Option, ) -> Self { @@ -122,7 +147,7 @@ impl FcmResponse { RecomendedAction::analyze(self) } - pub fn status(&self) -> FcmHttpResponseCode { + pub fn status(&self) -> FcmHttpResponseStatus { self.response_status } @@ -141,6 +166,7 @@ impl FcmResponse { /// Check /// and /// for more details. +#[derive(Debug, Clone, PartialEq)] pub enum RecomendedAction<'a> { /// Error [FcmHttpError::Unregistered] was received. /// The app token sent with the message was detected as @@ -175,9 +201,10 @@ pub enum RecomendedAction<'a> { impl RecomendedAction<'_> { fn analyze(response: &FcmResponse) -> Option { match response.status() { - FcmHttpResponseCode::Ok | - FcmHttpResponseCode::Unknown(_) => None, - FcmHttpResponseCode::Error(e) => match e { + FcmHttpResponseStatus::Ok | + FcmHttpResponseStatus::UnspecifiedError | + FcmHttpResponseStatus::Unknown { .. } => None, + FcmHttpResponseStatus::Error(e) => match e { FcmHttpError::Unregistered => Some(RecomendedAction::RemoveFcmAppToken), FcmHttpError::InvalidArgument => Some(RecomendedAction::FixMessageContent), FcmHttpError::SenderIdMismatch => @@ -211,6 +238,7 @@ impl RecomendedAction<'_> { } } +#[derive(Debug, Clone, PartialEq)] pub enum RecomendedWaitTime<'a> { /// Initial wait time for exponential back-off. /// From bdf7e511f5495caf518a9b1e3c3cf55aa3a02d06 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 20:29:45 +0300 Subject: [PATCH 18/40] Improve example CLI options Reintroduce '-t' option. --- examples/simple_sender.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index c8010ddab..665e93999 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -10,7 +10,7 @@ use serde_json::json; #[derive(Parser, Debug)] struct CliArgs { - #[arg(long)] + #[arg(short = 't', long)] device_token: String, /// Set path to the service account key JSON file. Default is to use /// path from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable From 3b6b876742eb6c052c632d55fe8d1784f125627c Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 22:04:30 +0300 Subject: [PATCH 19/40] Update instructions and examples --- .env.example | 2 +- README.md | 76 ++++++++++++++++++++++----------------- examples/simple_sender.rs | 2 +- src/lib.rs | 26 +++++++------- 4 files changed, 59 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 3e7bf180e..16c55e8e4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Follow the instructions in the # [Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) -# to create a service account. After you create a service account, and download +# to download service account key JSON file. After downloading # the JSON file then change the value of `GOOGLE_APPLICATION_CREDENTIALS` to # the path of the JSON file you downloaded. GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/firebase/file.json" diff --git a/README.md b/README.md index 7c361866b..2bd7a1ebf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Add the following to your `Cargo.toml` file: fcm = { git = "https://github.com/rj76/fcm-rust.git" } ``` -Then, you need to add the credentials described in the [Credentials](#credentials) to a `.env` file at the root of your project. +Optionally, add the credentials described in the [Credentials](#credentials) +to a `.env` file at the root of your project. ## Usage @@ -38,58 +39,69 @@ use fcm; ### Create a client instance ```rust -let client = fcm::Client::new(); +let client = fcm::FcmClient::builder() + // Comment to use GOOGLE_APPLICATION_CREDENTIALS environment + // variable. The variable can also be defined in .env file. + .service_account_key_json_path("service_account_key.json") + .build() + .await + .unwrap(); ``` ### Construct a message ```rust -let message = fcm::Message { - data: None, +// Replace "device_token" with the actual device token +let device_token = "device_token".to_string(); +let message = Message { + data: Some(json!({ + "message": "Howdy!", + })), notification: Some(Notification { - title: Some("I'm high".to_string()), + title: Some("Hello".to_string()), body: Some(format!("it's {}", chrono::Utc::now())), - ..Default::default() + image: None, }), target: Target::Token(device_token), - fcm_options: Some(FcmOptions { - analytics_label: "analytics_label".to_string(), - }), - android: Some(AndroidConfig { - priority: Some(fcm::AndroidMessagePriority::High), - notification: Some(AndroidNotification { - title: Some("I'm Android high".to_string()), - body: Some(format!("Hi Android, it's {}", chrono::Utc::now())), - ..Default::default() - }), - ..Default::default() - }), - apns: Some(ApnsConfig { ..Default::default() }), - webpush: Some(WebpushConfig { ..Default::default() }), -} + android: None, + webpush: None, + apns: None, + fcm_options: None, +}; ``` ### Send the message ```rust -let response = client.send(message).await?; +let response = client.send(message).await.unwrap(); ``` # Credentials -This library expects the Google credentials JSON location to be -defined as `GOOGLE_APPLICATION_CREDENTIALS` in the `.env` file. -Please follow the instructions in the [Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) to create a service account. - -## Examples - -For a complete usage example, you may check out the [`simple_sender`](examples/simple_sender.rs) example. +If client is not configured with service account key JSON file path +then this library expects the Google credentials JSON location to be +defined in `GOOGLE_APPLICATION_CREDENTIALS` environment variable. +The variable definition can also be located in the `.env` file. -To run the example, first of all clone the [`.env.example`](.env.example) file to `.env` and fill in the required values. +Please follow the instructions in the +[Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) +to create a service account key JSON file. -You can find info about the required credentials in the [Credentials](#credentials) section. +## Examples -Then run the example with `cargo run --example simple_sender -- -t ` +For a complete usage example, you may check out the +[`simple_sender`](examples/simple_sender.rs) example. +The example can be run with +``` +cargo run --example simple_sender -- -t -k +``` +If `GOOGLE_APPLICATION_CREDENTIALS` environment variable is defined in current +environment or in `.env` file, then the example can be run with +``` +cargo run --example simple_sender -- -t +``` +To define the environment variable using `.env` file copy the [`.env.example`](.env.example) +file to `.env` and fill in the required values. diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index 665e93999..36867c0a8 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -15,7 +15,7 @@ struct CliArgs { /// Set path to the service account key JSON file. Default is to use /// path from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable /// (which can be also located in `.env` file). - #[arg(long, value_name = "FILE")] + #[arg(short = 'k', long, value_name = "FILE")] service_account_key_path: Option, } diff --git a/src/lib.rs b/src/lib.rs index 2cd2b7cc9..70418d7cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,43 +3,43 @@ //! //! A client for asynchronous sending of Firebase Cloud Messages, or Push Notifications. //! -//! # Examples: +//! # Examples //! //! To send out a FCM Message with some custom data: //! //! ```no_run -//! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! use serde_json::json; //! use fcm::{Target, FcmOptions, Notification, Message}; //! let client = fcm::FcmClient::builder() +//! // Comment to use GOOGLE_APPLICATION_CREDENTIALS environment +//! // variable. The variable can also be defined in .env file. +//! .service_account_key_json_path("service_account_key.json") //! .build() //! .await //! .unwrap(); //! -//! let data = json!({ -//! "message": "Howdy!" -//! }); -//! -//! let builder = Message { -//! data: Some(data), +//! // Replace "device_token" with the actual device token +//! let device_token = "device_token".to_string(); +//! let message = Message { +//! data: Some(json!({ +//! "message": "Howdy!", +//! })), //! notification: Some(Notification { //! title: Some("Hello".to_string()), //! body: Some(format!("it's {}", chrono::Utc::now())), //! image: None, //! }), -//! target: Target::Token("token".to_string()), +//! target: Target::Token("device_token".to_string()), //! android: None, //! webpush: None, //! apns: None, -//! fcm_options: Some(FcmOptions { -//! analytics_label: "analytics_label".to_string(), -//! }), +//! fcm_options: None, //! }; //! //! let response = client.send(builder).await?; -//! println!("Sent: {:?}", response); +//! println!("Response: {:?}", response); //! //! Ok(()) //! } From 8b249414137869af239458ae0f0157b3ad4a08d8 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 22:34:32 +0300 Subject: [PATCH 20/40] Allow building with --all-features --- src/client/mod.rs | 10 ++++++---- src/client/oauth_gauth.rs | 2 ++ src/client/oauth_yup_oauth2.rs | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 5799f5148..00ca0a314 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -9,7 +9,7 @@ mod oauth_yup_oauth2; #[cfg(feature = "gauth")] use oauth_gauth as oauth_client_impl; -#[cfg(feature = "yup-oauth2")] +#[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] use oauth_yup_oauth2 as oauth_client_impl; use std::path::{Path, PathBuf}; @@ -44,7 +44,7 @@ pub enum FcmClientError { impl FcmClientError { /// If this is `true` then most likely current service key is invalid. - #[cfg(feature = "yup-oauth2")] + #[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { match self { FcmClientError::Oauth(error) => @@ -55,12 +55,14 @@ impl FcmClientError { } trait OauthClient: Sized { + type Error; + async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, - ) -> Result; + ) -> Result; - async fn get_access_token(&self) -> Result; + async fn get_access_token(&self) -> Result; fn get_project_id(&self) -> &str; } diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs index 88e084ce9..607dfe439 100644 --- a/src/client/oauth_gauth.rs +++ b/src/client/oauth_gauth.rs @@ -24,6 +24,8 @@ pub struct OauthClientImpl { } impl OauthClient for OauthClientImpl { + type Error = FcmOauthError; + async fn create_with_key_file( service_account_key_path: PathBuf, _token_cache_json_path: Option, diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 747843743..62dff687f 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -37,6 +37,8 @@ pub struct OauthClientImpl { } impl OauthClient for OauthClientImpl { + type Error = FcmOauthError; + async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, From f1264df1c2ffb2ae241ed892268e8cc2994ee524 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 22:40:04 +0300 Subject: [PATCH 21/40] Fix example compiling --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 70418d7cf..75d89adf6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! use serde_json::json; -//! use fcm::{Target, FcmOptions, Notification, Message}; +//! use fcm::{Target, Notification, Message}; //! let client = fcm::FcmClient::builder() //! // Comment to use GOOGLE_APPLICATION_CREDENTIALS environment //! // variable. The variable can also be defined in .env file. @@ -38,7 +38,7 @@ //! fcm_options: None, //! }; //! -//! let response = client.send(builder).await?; +//! let response = client.send(message).await?; //! println!("Response: {:?}", response); //! //! Ok(()) From 220f4be4c37afd810004b88f1303fcafb9ee48bd Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Tue, 21 May 2024 23:36:37 +0300 Subject: [PATCH 22/40] Allow selecting OAuth client with generics --- src/client/mod.rs | 101 ++++++++++++++++++++------------- src/client/oauth_gauth.rs | 37 +++++++----- src/client/oauth_yup_oauth2.rs | 30 +++++----- src/lib.rs | 6 ++ 4 files changed, 103 insertions(+), 71 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 00ca0a314..d2f013a03 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,16 +1,10 @@ pub(crate) mod response; #[cfg(feature = "gauth")] -mod oauth_gauth; +pub(crate) mod oauth_gauth; #[cfg(feature = "yup-oauth2")] -mod oauth_yup_oauth2; - -#[cfg(feature = "gauth")] -use oauth_gauth as oauth_client_impl; - -#[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] -use oauth_yup_oauth2 as oauth_client_impl; +pub(crate) mod oauth_yup_oauth2; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -19,18 +13,21 @@ use reqwest::header::RETRY_AFTER; use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseStatus}; use crate::{Message, MessageWrapper}; -use oauth_client_impl::OauthClientImpl; -pub use oauth_client_impl::FcmOauthError; +#[cfg(feature = "gauth")] +pub type DefaultOauthClient = oauth_gauth::Gauth; + +#[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] +pub type DefaultOauthClient = oauth_yup_oauth2::YupOauth2; const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; #[derive(thiserror::Error, Debug)] -pub enum FcmClientError { +pub enum FcmClientError::Error> { #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), #[error("OAuth error: {0}")] - Oauth(#[from] FcmOauthError), + Oauth(T), #[error("Dotenv error: {0}")] Dotenv(#[from] dotenv::Error), #[error("Retry-After HTTP header value is not valid string")] @@ -42,9 +39,9 @@ pub enum FcmClientError { }, } -impl FcmClientError { +#[cfg(feature = "yup-oauth2")] +impl FcmClientError { /// If this is `true` then most likely current service key is invalid. - #[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { match self { FcmClientError::Oauth(error) => @@ -54,32 +51,35 @@ impl FcmClientError { } } -trait OauthClient: Sized { - type Error; +pub trait OauthClient: Sized { + type Error: OauthErrorInfo; - async fn create_with_key_file( + fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, - ) -> Result; + ) -> impl std::future::Future> + Send; - async fn get_access_token(&self) -> Result; + fn get_access_token( + &self + ) -> impl std::future::Future> + Send; fn get_project_id(&self) -> &str; } -trait OauthErrorInfo { +pub trait OauthErrorInfo: std::error::Error { /// If this is `true` then most likely current service key is invalid. fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool; } -#[derive(Debug, Default, Clone)] -pub struct FcmClientBuilder { +#[derive(Debug, Clone)] +pub struct FcmClientBuilder { service_account_key_json_path: Option, token_cache_json_path: Option, fcm_request_timeout: Option, + _phantom: std::marker::PhantomData, } -impl FcmClientBuilder { +impl FcmClientBuilder { pub fn new() -> Self { Self::default() } @@ -92,13 +92,6 @@ impl FcmClientBuilder { self } - /// Set path to the token cache JSON file. Default is no token cache JSON file. - #[cfg(feature = "yup-oauth2")] - pub fn token_cache_json_path(mut self, token_cache_json_path: impl AsRef) -> Self { - self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); - self - } - /// Set timeout for FCM requests. Default is no timeout. /// /// Google recommends at least 10 minute timeout for FCM requests. @@ -108,25 +101,47 @@ impl FcmClientBuilder { self } - pub async fn build(self) -> Result { + pub async fn build(self) -> Result, FcmClientError> { FcmClient::new_from_builder(self).await } } +impl Default for FcmClientBuilder { + fn default() -> Self { + Self { + service_account_key_json_path: None, + token_cache_json_path: None, + fcm_request_timeout: None, + _phantom: std::marker::PhantomData, + } + } +} + +#[cfg(feature = "yup-oauth2")] +impl FcmClientBuilder { + /// Set path to the token cache JSON file. Default is no token cache JSON file. + pub fn token_cache_json_path(mut self, token_cache_json_path: impl AsRef) -> Self { + self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); + self + } +} + /// An async client for sending the notification payload. -pub struct FcmClient { +pub struct FcmClient { http_client: reqwest::Client, - oauth_client: OauthClientImpl, + oauth_client: T, } -impl FcmClient { - pub fn builder() -> FcmClientBuilder { +impl FcmClient { + pub fn builder() -> FcmClientBuilder { FcmClientBuilder::new() } +} +impl FcmClient { async fn new_from_builder( - fcm_builder: FcmClientBuilder, - ) -> Result { + fcm_builder: FcmClientBuilder, + ) -> Result> { let builder = reqwest::ClientBuilder::new(); let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { builder.timeout(timeout) @@ -141,10 +156,12 @@ impl FcmClient { dotenv::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() }; - let oauth_client = OauthClientImpl::create_with_key_file( + let oauth_client = T::create_with_key_file( service_account_key_path, fcm_builder.token_cache_json_path, - ).await?; + ) + .await + .map_err(FcmClientError::Oauth)?; Ok(FcmClient { http_client, @@ -152,8 +169,10 @@ impl FcmClient { }) } - pub async fn send(&self, message: Message) -> Result { - let access_token = self.oauth_client.get_access_token().await?; + pub async fn send(&self, message: Message) -> Result> { + let access_token = self.oauth_client.get_access_token() + .await + .map_err(FcmClientError::Oauth)?; // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs index 607dfe439..6d2523287 100644 --- a/src/client/oauth_gauth.rs +++ b/src/client/oauth_gauth.rs @@ -2,10 +2,10 @@ use std::path::{Path, PathBuf}; use gauth::serv_account::ServiceAccount; -use super::{OauthClient, FIREBASE_OAUTH_SCOPE}; +use super::{OauthClient, OauthErrorInfo, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] -pub enum FcmOauthError { +pub enum GauthError { #[error("OAuth error: {0}")] Oauth(String), #[error("Service account key path is not UTF-8")] @@ -18,32 +18,39 @@ pub enum FcmOauthError { ProjectIdIsMissing, } -pub struct OauthClientImpl { +impl OauthErrorInfo for GauthError { + fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { + // Not supported + false + } +} + +pub struct Gauth { project_id: String, service_account_key_path: String, } -impl OauthClient for OauthClientImpl { - type Error = FcmOauthError; +impl OauthClient for Gauth { + type Error = GauthError; async fn create_with_key_file( service_account_key_path: PathBuf, _token_cache_json_path: Option, - ) -> Result { - Ok(OauthClientImpl { + ) -> Result { + Ok(Gauth { project_id: get_project_id(&service_account_key_path)?, service_account_key_path: service_account_key_path.to_str() - .ok_or(FcmOauthError::ServiceAccountKeyPathIsNotUtf8)? + .ok_or(GauthError::ServiceAccountKeyPathIsNotUtf8)? .to_string(), }) } - async fn get_access_token(&self) -> Result { + async fn get_access_token(&self) -> Result { let scopes = vec![FIREBASE_OAUTH_SCOPE]; let mut service_account = ServiceAccount::from_file(&self.service_account_key_path, scopes); let access_token = service_account.access_token().await .map_err(|e| e.to_string()) - .map_err(FcmOauthError::Oauth)?; + .map_err(GauthError::Oauth)?; let token_no_bearer = access_token.split(char::is_whitespace).collect::>()[1]; @@ -55,18 +62,18 @@ impl OauthClient for OauthClientImpl { } } -fn read_service_key_file_json(service_account_key_path: impl AsRef) -> Result { +fn read_service_key_file_json(service_account_key_path: impl AsRef) -> Result { let json_string = std::fs::read_to_string(service_account_key_path) - .map_err(FcmOauthError::ServiceAccountKeyReadingFailed)?; + .map_err(GauthError::ServiceAccountKeyReadingFailed)?; let json_content: serde_json::Value = serde_json::from_str(&json_string) - .map_err(FcmOauthError::ServiceAccountKeyDeserializationFailed)?; + .map_err(GauthError::ServiceAccountKeyDeserializationFailed)?; Ok(json_content) } -fn get_project_id(service_account_key_path: impl AsRef) -> Result { +fn get_project_id(service_account_key_path: impl AsRef) -> Result { let json_content = read_service_key_file_json(service_account_key_path)?; let project_id = json_content["project_id"].as_str() - .ok_or(FcmOauthError::ProjectIdIsMissing)?; + .ok_or(GauthError::ProjectIdIsMissing)?; Ok(project_id.to_string()) } diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 62dff687f..4f2034eec 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -8,7 +8,7 @@ use yup_oauth2::ServiceAccountAuthenticator; use super::{OauthClient, OauthErrorInfo, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] -pub enum FcmOauthError { +pub enum YupOauth2Error { #[error("Service account key reading failed: {0}")] ServiceAccountKeyReadingFailed(std::io::Error), #[error("OAuth error: {0}")] @@ -21,33 +21,33 @@ pub enum FcmOauthError { ProjectIdIsMissing, } -impl OauthErrorInfo for FcmOauthError { +impl OauthErrorInfo for YupOauth2Error { fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { matches!( self, - FcmOauthError::AccessTokenIsMissing | - FcmOauthError::Oauth(yup_oauth2::Error::AuthError(_)) + YupOauth2Error::AccessTokenIsMissing | + YupOauth2Error::Oauth(yup_oauth2::Error::AuthError(_)) ) } } -pub struct OauthClientImpl { +pub struct YupOauth2 { authenticator: Authenticator>, project_id: String, } -impl OauthClient for OauthClientImpl { - type Error = FcmOauthError; +impl OauthClient for YupOauth2 { + type Error = YupOauth2Error; async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, - ) -> Result { + ) -> Result { let key = yup_oauth2::read_service_account_key(service_account_key_path) .await - .map_err(FcmOauthError::ServiceAccountKeyReadingFailed)?; + .map_err(YupOauth2Error::ServiceAccountKeyReadingFailed)?; let oauth_client = DefaultHyperClient.build_hyper_client() - .map_err(FcmOauthError::Oauth)?; + .map_err(YupOauth2Error::Oauth)?; let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); let builder = if let Some(path) = token_cache_json_path { builder.persist_tokens_to_disk(path) @@ -56,22 +56,22 @@ impl OauthClient for OauthClientImpl { }; let authenticator = builder.build() .await - .map_err(FcmOauthError::AuthenticatorCreatingFailed)?; + .map_err(YupOauth2Error::AuthenticatorCreatingFailed)?; let project_id = key.project_id - .ok_or(FcmOauthError::ProjectIdIsMissing)?; + .ok_or(YupOauth2Error::ProjectIdIsMissing)?; - Ok(OauthClientImpl { + Ok(YupOauth2 { authenticator, project_id, }) } - async fn get_access_token(&self) -> Result { + async fn get_access_token(&self) -> Result { let scopes = [FIREBASE_OAUTH_SCOPE]; let access_token = self.authenticator.token(&scopes).await?; let access_token = access_token.token() - .ok_or(FcmOauthError::AccessTokenIsMissing)?; + .ok_or(YupOauth2Error::AccessTokenIsMissing)?; Ok(access_token.to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 75d89adf6..26375f239 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,3 +74,9 @@ pub use crate::web::webpush_fcm_options::*; mod client; pub use crate::client::response::*; pub use crate::client::*; + +#[cfg(feature = "gauth")] +pub use crate::client::oauth_gauth::*; + +#[cfg(feature = "yup-oauth2")] +pub use crate::client::oauth_yup_oauth2::*; From 03edb4083d5b11e7b2c549811d78192492d944a2 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 00:01:33 +0300 Subject: [PATCH 23/40] Reorganize public API --- README.md | 2 ++ examples/simple_sender.rs | 3 ++- src/client/mod.rs | 6 +++--- src/lib.rs | 38 ++++++-------------------------------- src/message/mod.rs | 28 ++++++++++++++++++++-------- src/message/target.rs | 2 +- src/message/tests.rs | 2 +- src/notification/tests.rs | 2 +- 8 files changed, 36 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 2bd7a1ebf..9cf1cccbc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ let client = fcm::FcmClient::builder() ### Construct a message ```rust +use fcm::message::{Message, Notification, Target}; + // Replace "device_token" with the actual device token let device_token = "device_token".to_string(); let message = Message { diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index 36867c0a8..2684ac34d 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -4,7 +4,8 @@ use std::path::PathBuf; use clap::Parser; use fcm::{ - FcmClient, Message, Notification, Target, + FcmClient, + message::{Message, Notification, Target}, }; use serde_json::json; diff --git a/src/client/mod.rs b/src/client/mod.rs index d2f013a03..cc82ea9fc 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,10 +1,10 @@ pub(crate) mod response; #[cfg(feature = "gauth")] -pub(crate) mod oauth_gauth; +pub mod oauth_gauth; #[cfg(feature = "yup-oauth2")] -pub(crate) mod oauth_yup_oauth2; +pub mod oauth_yup_oauth2; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -12,7 +12,7 @@ use std::time::Duration; use reqwest::header::RETRY_AFTER; use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseStatus}; -use crate::{Message, MessageWrapper}; +use crate::message::{Message, MessageWrapper}; #[cfg(feature = "gauth")] pub type DefaultOauthClient = oauth_gauth::Gauth; diff --git a/src/lib.rs b/src/lib.rs index 26375f239..9c26d5366 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! use serde_json::json; -//! use fcm::{Target, Notification, Message}; +//! use fcm::message::{Target, Notification, Message}; //! let client = fcm::FcmClient::builder() //! // Comment to use GOOGLE_APPLICATION_CREDENTIALS environment //! // variable. The variable can also be defined in .env file. @@ -45,38 +45,12 @@ //! } //! ``` -mod message; -pub use crate::message::fcm_options::*; -pub use crate::message::target::*; -pub use crate::message::*; - -mod notification; -pub use crate::notification::*; - -mod android; -pub use crate::android::android_config::*; -pub use crate::android::android_fcm_options::*; -pub use crate::android::android_message_priority::*; -pub use crate::android::android_notification::*; -pub use crate::android::color::*; -pub use crate::android::light_settings::*; -pub use crate::android::notification_priority::*; -pub use crate::android::visibility::*; - -mod apns; -pub use crate::apns::apns_config::*; -pub use crate::apns::apns_fcm_options::*; - -mod web; -pub use crate::web::webpush_config::*; -pub use crate::web::webpush_fcm_options::*; +pub mod message; +pub(crate) mod notification; +pub(crate) mod android; +pub(crate) mod apns; +pub(crate) mod web; mod client; pub use crate::client::response::*; pub use crate::client::*; - -#[cfg(feature = "gauth")] -pub use crate::client::oauth_gauth::*; - -#[cfg(feature = "yup-oauth2")] -pub use crate::client::oauth_yup_oauth2::*; diff --git a/src/message/mod.rs b/src/message/mod.rs index cf5ba35e3..9d6eccfad 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1,5 +1,5 @@ -pub mod fcm_options; -pub mod target; +pub(crate) mod fcm_options; +pub(crate) mod target; #[cfg(test)] mod tests; @@ -9,13 +9,25 @@ use serde::Serialize; use serde::Serializer; use serde_json::Value; -use crate::android::android_config::AndroidConfig; -use crate::apns::apns_config::ApnsConfig; -use crate::notification::Notification; -use crate::web::webpush_config::WebpushConfig; +pub use crate::message::fcm_options::*; +pub use crate::message::target::*; -use self::fcm_options::FcmOptions; -use self::target::Target; +pub use crate::notification::*; + +pub use crate::android::android_config::*; +pub use crate::android::android_fcm_options::*; +pub use crate::android::android_message_priority::*; +pub use crate::android::android_notification::*; +pub use crate::android::color::*; +pub use crate::android::light_settings::*; +pub use crate::android::notification_priority::*; +pub use crate::android::visibility::*; + +pub use crate::apns::apns_config::*; +pub use crate::apns::apns_fcm_options::*; + +pub use crate::web::webpush_config::*; +pub use crate::web::webpush_fcm_options::*; fn output_target(target: &Target, s: S) -> Result where diff --git a/src/message/target.rs b/src/message/target.rs index d00256305..12c851996 100644 --- a/src/message/target.rs +++ b/src/message/target.rs @@ -3,7 +3,7 @@ use serde::Serialize; /// Target to send a message to. /// /// ```rust -/// use fcm::{Target}; +/// use fcm::message::{Target}; /// /// Target::Token("myfcmtoken".to_string()); /// Target::Topic("my-topic-name".to_string()); diff --git a/src/message/tests.rs b/src/message/tests.rs index 350928c11..a79313ded 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -1,4 +1,4 @@ -use crate::{message::Target, notification::Notification, Message}; +use crate::{message::{Target, Message}, notification::Notification}; use serde_json::json; #[test] diff --git a/src/notification/tests.rs b/src/notification/tests.rs index 8f7f1d80a..7f9d0e4a2 100644 --- a/src/notification/tests.rs +++ b/src/notification/tests.rs @@ -1,4 +1,4 @@ -use crate::Notification; +use crate::message::Notification; use serde_json::json; #[test] From a9a950fd91e7cbcf3a4064338f5e6e8540f5a48e Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 00:39:58 +0300 Subject: [PATCH 24/40] Add missing case to access token error detection --- src/client/oauth_yup_oauth2.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 4f2034eec..a4c7fd387 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -26,7 +26,10 @@ impl OauthErrorInfo for YupOauth2Error { matches!( self, YupOauth2Error::AccessTokenIsMissing | - YupOauth2Error::Oauth(yup_oauth2::Error::AuthError(_)) + YupOauth2Error::Oauth( + yup_oauth2::Error::MissingAccessToken | + yup_oauth2::Error::AuthError(_) + ) ) } } From dc6957b277e9453ebff23ddf577339915978ba0f Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 00:52:55 +0300 Subject: [PATCH 25/40] Add new trait for access token error check --- src/client/mod.rs | 17 ++++++++++------- src/client/oauth_gauth.rs | 9 ++------- src/client/oauth_yup_oauth2.rs | 6 ++++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index cc82ea9fc..70abe4048 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -23,7 +23,7 @@ pub type DefaultOauthClient = oauth_yup_oauth2::YupOauth2; const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; #[derive(thiserror::Error, Debug)] -pub enum FcmClientError::Error> { +pub enum FcmClientError::Error> { #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), #[error("OAuth error: {0}")] @@ -39,9 +39,9 @@ pub enum FcmClientError: }, } -#[cfg(feature = "yup-oauth2")] -impl FcmClientError { - /// If this is `true` then most likely current service key is invalid. +impl FcmClientError { + /// If this is `true` then most likely current service account + /// key is invalid. pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { match self { FcmClientError::Oauth(error) => @@ -52,7 +52,7 @@ impl FcmClientError { } pub trait OauthClient: Sized { - type Error: OauthErrorInfo; + type Error: OauthError; fn create_with_key_file( service_account_key_path: PathBuf, @@ -66,8 +66,11 @@ pub trait OauthClient: Sized { fn get_project_id(&self) -> &str; } -pub trait OauthErrorInfo: std::error::Error { - /// If this is `true` then most likely current service key is invalid. +pub trait OauthError: std::error::Error {} + +pub trait OauthErrorAccessTokenStatus: OauthError { + /// If this is `true` then most likely current service account + /// key is invalid. fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool; } diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs index 6d2523287..78d7563bb 100644 --- a/src/client/oauth_gauth.rs +++ b/src/client/oauth_gauth.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use gauth::serv_account::ServiceAccount; -use super::{OauthClient, OauthErrorInfo, FIREBASE_OAUTH_SCOPE}; +use super::{OauthClient, OauthError, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] pub enum GauthError { @@ -18,12 +18,7 @@ pub enum GauthError { ProjectIdIsMissing, } -impl OauthErrorInfo for GauthError { - fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { - // Not supported - false - } -} +impl OauthError for GauthError {} pub struct Gauth { project_id: String, diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index a4c7fd387..0ff8e495a 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -5,7 +5,7 @@ use yup_oauth2::hyper::client::HttpConnector; use yup_oauth2::hyper_rustls::HttpsConnector; use yup_oauth2::ServiceAccountAuthenticator; -use super::{OauthClient, OauthErrorInfo, FIREBASE_OAUTH_SCOPE}; +use super::{OauthClient, OauthError, OauthErrorAccessTokenStatus, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] pub enum YupOauth2Error { @@ -21,7 +21,9 @@ pub enum YupOauth2Error { ProjectIdIsMissing, } -impl OauthErrorInfo for YupOauth2Error { +impl OauthError for YupOauth2Error {} + +impl OauthErrorAccessTokenStatus for YupOauth2Error { fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { matches!( self, From f59543db7461c6337e8960614de852e873380232 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 01:20:08 +0300 Subject: [PATCH 26/40] Replace dotenv with dotenvy --- Cargo.toml | 2 +- src/client/mod.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0782e73f7..14b0d362a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ serde_json = { version = "1", features = ["preserve_order"] } reqwest = { version = "0.11.0", features = ["json"], default-features = false } chrono = "0.4" thiserror = "1.0.61" -dotenv = "0.15.0" +dotenvy = "0.15.7" gauth = { version = "0.7.0", optional = true } yup-oauth2 = { version = "9.0.0", optional = true } diff --git a/src/client/mod.rs b/src/client/mod.rs index 70abe4048..cf2ec6ad8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -28,8 +28,8 @@ pub enum FcmClientError::Err Reqwest(#[from] reqwest::Error), #[error("OAuth error: {0}")] Oauth(T), - #[error("Dotenv error: {0}")] - Dotenv(#[from] dotenv::Error), + #[error("Dotenvy error: {0}")] + Dotenvy(#[from] dotenvy::Error), #[error("Retry-After HTTP header value is not valid string")] RetryAfterHttpHeaderIsNotString, #[error("Retry-After HTTP header value is not valid, error: {error}, value: {value}")] @@ -156,7 +156,7 @@ impl FcmClient { let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { path } else { - dotenv::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() + dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() }; let oauth_client = T::create_with_key_file( From 86ddc017afaf3301efb3f48075b99a036d94cc38 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 16:06:48 +0300 Subject: [PATCH 27/40] Move OAuth client trait methods to internal trait --- src/client/internal_client.rs | 93 +++++++++++++++++++++++ src/client/mod.rs | 134 ++++++++++----------------------- src/client/oauth_gauth.rs | 4 +- src/client/oauth_yup_oauth2.rs | 4 +- 4 files changed, 139 insertions(+), 96 deletions(-) create mode 100644 src/client/internal_client.rs diff --git a/src/client/internal_client.rs b/src/client/internal_client.rs new file mode 100644 index 000000000..349c4302f --- /dev/null +++ b/src/client/internal_client.rs @@ -0,0 +1,93 @@ +use reqwest::header::RETRY_AFTER; + +use crate::{message::{Message, MessageWrapper}, FcmClientBuilder, FcmClientError, FcmHttpResponseStatus, FcmResponse, OauthClient, RetryAfter}; + +use super::OauthClientInternal; + +pub(crate) struct FcmClientInternal { + http_client: reqwest::Client, + oauth_client: T, +} + +impl FcmClientInternal { + pub async fn new_from_builder( + fcm_builder: FcmClientBuilder, + ) -> Result> { + let builder = reqwest::ClientBuilder::new(); + let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { + builder.timeout(timeout) + } else { + builder + }; + let http_client = builder.build()?; + + let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { + path + } else { + dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() + }; + + let oauth_client = T::create_with_key_file( + service_account_key_path, + fcm_builder.token_cache_json_path, + ) + .await + .map_err(FcmClientError::Oauth)?; + + Ok(FcmClientInternal { + http_client, + oauth_client, + }) + } +} + +impl FcmClientInternal { + pub async fn send(&self, message: Message) -> Result> { + let access_token = self.oauth_client.get_access_token() + .await + .map_err(FcmClientError::Oauth)?; + + // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send + let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); + + let request = self + .http_client + .post(&url) + .bearer_auth(access_token) + .json(&MessageWrapper::new(message)) + .build()?; + + let response = self.http_client.execute(request).await?; + let retry_after = response + .headers() + .get(RETRY_AFTER); + let retry_after = if let Some(header_value) = retry_after { + let header_str = header_value.to_str() + .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; + let value = header_str.parse::() + .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { + error, + value: header_str.to_string(), + })?; + Some(value) + } else { + None + }; + let http_status_code = response.status().as_u16(); + // Return if I/O error occurs + let response_body = response.bytes().await?; + let response_json_object = serde_json::from_slice::>(&response_body) + .ok() + .unwrap_or_default(); + let response_status = FcmHttpResponseStatus::new( + http_status_code, + &response_json_object, + ); + + Ok(FcmResponse::new( + response_status, + response_json_object, + retry_after, + )) + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index cf2ec6ad8..449c6fecb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod internal_client; pub(crate) mod response; #[cfg(feature = "gauth")] @@ -9,10 +10,10 @@ pub mod oauth_yup_oauth2; use std::path::{Path, PathBuf}; use std::time::Duration; -use reqwest::header::RETRY_AFTER; +use crate::client::response::FcmResponse; +use crate::message::Message; -use crate::client::response::{FcmResponse, RetryAfter, FcmHttpResponseStatus}; -use crate::message::{Message, MessageWrapper}; +use self::internal_client::FcmClientInternal; #[cfg(feature = "gauth")] pub type DefaultOauthClient = oauth_gauth::Gauth; @@ -51,9 +52,11 @@ impl FcmClientError { } } -pub trait OauthClient: Sized { +pub trait OauthClient { type Error: OauthError; +} +pub(crate) trait OauthClientInternal: OauthClient + Sized { fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, @@ -82,6 +85,17 @@ pub struct FcmClientBuilder { _phantom: std::marker::PhantomData, } +impl Default for FcmClientBuilder { + fn default() -> Self { + Self { + service_account_key_json_path: None, + token_cache_json_path: None, + fcm_request_timeout: None, + _phantom: std::marker::PhantomData, + } + } +} + impl FcmClientBuilder { pub fn new() -> Self { Self::default() @@ -103,20 +117,14 @@ impl FcmClientBuilder { self.fcm_request_timeout = Some(fcm_request_timeout); self } - - pub async fn build(self) -> Result, FcmClientError> { - FcmClient::new_from_builder(self).await - } } -impl Default for FcmClientBuilder { - fn default() -> Self { - Self { - service_account_key_json_path: None, - token_cache_json_path: None, - fcm_request_timeout: None, - _phantom: std::marker::PhantomData, - } +#[cfg(feature = "gauth")] +impl FcmClientBuilder { + pub async fn build(self) -> Result, FcmClientError<::Error>> { + Ok(FcmClient { + internal_client: FcmClientInternal::new_from_builder(self).await?, + }) } } @@ -127,12 +135,17 @@ impl FcmClientBuilder { self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); self } + + pub async fn build(self) -> Result, FcmClientError<::Error>> { + Ok(FcmClient { + internal_client: FcmClientInternal::new_from_builder(self).await?, + }) + } } /// An async client for sending the notification payload. pub struct FcmClient { - http_client: reqwest::Client, - oauth_client: T, + internal_client: FcmClientInternal, } impl FcmClient { @@ -141,83 +154,16 @@ impl FcmClient { } } -impl FcmClient { - async fn new_from_builder( - fcm_builder: FcmClientBuilder, - ) -> Result> { - let builder = reqwest::ClientBuilder::new(); - let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { - builder.timeout(timeout) - } else { - builder - }; - let http_client = builder.build()?; - - let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { - path - } else { - dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() - }; - - let oauth_client = T::create_with_key_file( - service_account_key_path, - fcm_builder.token_cache_json_path, - ) - .await - .map_err(FcmClientError::Oauth)?; - - Ok(FcmClient { - http_client, - oauth_client, - }) +#[cfg(feature = "gauth")] +impl FcmClient { + pub async fn send(&self, message: Message) -> Result::Error>> { + self.internal_client.send(message).await } +} - pub async fn send(&self, message: Message) -> Result> { - let access_token = self.oauth_client.get_access_token() - .await - .map_err(FcmClientError::Oauth)?; - - // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send - let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); - - let request = self - .http_client - .post(&url) - .bearer_auth(access_token) - .json(&MessageWrapper::new(message)) - .build()?; - - let response = self.http_client.execute(request).await?; - let retry_after = response - .headers() - .get(RETRY_AFTER); - let retry_after = if let Some(header_value) = retry_after { - let header_str = header_value.to_str() - .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; - let value = header_str.parse::() - .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { - error, - value: header_str.to_string(), - })?; - Some(value) - } else { - None - }; - let http_status_code = response.status().as_u16(); - // Return if I/O error occurs - let response_body = response.bytes().await?; - let response_json_object = serde_json::from_slice::>(&response_body) - .ok() - .unwrap_or_default(); - let response_status = FcmHttpResponseStatus::new( - http_status_code, - &response_json_object, - ); - - Ok(FcmResponse::new( - response_status, - response_json_object, - retry_after, - )) +#[cfg(feature = "yup-oauth2")] +impl FcmClient { + pub async fn send(&self, message: Message) -> Result::Error>> { + self.internal_client.send(message).await } } diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs index 78d7563bb..ff3e57531 100644 --- a/src/client/oauth_gauth.rs +++ b/src/client/oauth_gauth.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use gauth::serv_account::ServiceAccount; -use super::{OauthClient, OauthError, FIREBASE_OAUTH_SCOPE}; +use super::{OauthClient, OauthClientInternal, OauthError, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] pub enum GauthError { @@ -27,7 +27,9 @@ pub struct Gauth { impl OauthClient for Gauth { type Error = GauthError; +} +impl OauthClientInternal for Gauth { async fn create_with_key_file( service_account_key_path: PathBuf, _token_cache_json_path: Option, diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 0ff8e495a..92cc2f292 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -5,7 +5,7 @@ use yup_oauth2::hyper::client::HttpConnector; use yup_oauth2::hyper_rustls::HttpsConnector; use yup_oauth2::ServiceAccountAuthenticator; -use super::{OauthClient, OauthError, OauthErrorAccessTokenStatus, FIREBASE_OAUTH_SCOPE}; +use super::{OauthClient, OauthClientInternal, OauthError, OauthErrorAccessTokenStatus, FIREBASE_OAUTH_SCOPE}; #[derive(thiserror::Error, Debug)] pub enum YupOauth2Error { @@ -43,7 +43,9 @@ pub struct YupOauth2 { impl OauthClient for YupOauth2 { type Error = YupOauth2Error; +} +impl OauthClientInternal for YupOauth2 { async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, From 10fa8d5db2d071e0c17726b0ffa42b3b1c23c0da Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 23:02:43 +0300 Subject: [PATCH 28/40] Improve FCM response API --- src/client/internal_client.rs | 8 +- src/client/response.rs | 198 +++++++++++++++++----------------- 2 files changed, 102 insertions(+), 104 deletions(-) diff --git a/src/client/internal_client.rs b/src/client/internal_client.rs index 349c4302f..54a26b088 100644 --- a/src/client/internal_client.rs +++ b/src/client/internal_client.rs @@ -1,6 +1,6 @@ use reqwest::header::RETRY_AFTER; -use crate::{message::{Message, MessageWrapper}, FcmClientBuilder, FcmClientError, FcmHttpResponseStatus, FcmResponse, OauthClient, RetryAfter}; +use crate::{message::{Message, MessageWrapper}, FcmClientBuilder, FcmClientError, FcmResponse, OauthClient, RetryAfter}; use super::OauthClientInternal; @@ -79,13 +79,9 @@ impl FcmClientInternal { let response_json_object = serde_json::from_slice::>(&response_body) .ok() .unwrap_or_default(); - let response_status = FcmHttpResponseStatus::new( - http_status_code, - &response_json_object, - ); Ok(FcmResponse::new( - response_status, + http_status_code, response_json_object, retry_after, )) diff --git a/src/client/response.rs b/src/client/response.rs index 651746993..60a3bb6fe 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -4,78 +4,64 @@ use chrono::Utc; use std::time::Duration; use std::{convert::{TryFrom, TryInto}, str::FromStr}; -/// FCM errors which have HTTP status code defined. +/// Error cases which can be detected from [FcmResponse]. /// /// Check /// for more information. #[derive(Debug, Clone, Copy, PartialEq)] -#[repr(u16)] -pub enum FcmHttpError { - InvalidArgument = 400, - Unregistered = 404, - SenderIdMismatch = 403, - QuotaExceeded = 429, - Unavailable = 503, - Internal = 500, - ThirdPartyAuthError = 401, +pub enum FcmResponseError { + /// HTTP 400 + InvalidArgument, + /// HTTP 404 + Unregistered, + /// HTTP 403 + SenderIdMismatch, + /// HTTP 429 + QuotaExceeded, + /// HTTP 503 + Unavailable, + /// HTTP 500 + Internal, + /// HTTP 401 + ThirdPartyAuth, + /// `UNSPECIFIED_ERROR` (no HTTP error code defined) + Unspecified, + /// Response is not successful and API reference does not have + /// matching error. + Unknown, } -impl TryFrom for FcmHttpError { - type Error = (); - - fn try_from(value: u16) -> Result { - match value { - 400 => Ok(FcmHttpError::InvalidArgument), - 404 => Ok(FcmHttpError::Unregistered), - 403 => Ok(FcmHttpError::SenderIdMismatch), - 429 => Ok(FcmHttpError::QuotaExceeded), - 503 => Ok(FcmHttpError::Unavailable), - 500 => Ok(FcmHttpError::Internal), - 401 => Ok(FcmHttpError::ThirdPartyAuthError), - _ => Err(()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum FcmHttpResponseStatus { - /// FCM message was sent successfully. - Ok, - /// HTTP error code was detected as FCM error. - Error(FcmHttpError), - /// HTTP status code did not match with [FcmHttpError] and - /// `FcmError` JSON was detected. - /// - /// - /// - /// This variant is named as [FcmHttpResponseStatus::UnspecifiedError] - /// because `UNSPECIFIED_ERROR` is the only `ErrorCode` variant that has no - /// corresponding HTTP status code. - /// - /// - UnspecifiedError, - Unknown { - http_status_code: u16, - }, -} - -impl FcmHttpResponseStatus { - pub fn new( +impl FcmResponseError { + pub fn detect_from( http_status_code: u16, response_json: &serde_json::Map, - ) -> Self { + ) -> Option { if let Ok(error) = http_status_code.try_into() { - return FcmHttpResponseStatus::Error(error); + Some(error) + } else if let Some("UNSPECIFIED_ERROR") = response_json.get("error_code").and_then(|v| v.as_str()) { + Some(Self::Unspecified) + } else { + match response_json.get("name") { + Some(_) => None, // No error + None => Some(Self::Unknown), + } } + } +} - let message = response_json.get("name"); - let fcm_error = response_json.get("error_code"); - match (message, fcm_error) { - (Some(_), _) => FcmHttpResponseStatus::Ok, - (_, Some(_)) => FcmHttpResponseStatus::UnspecifiedError, - (None, None) => FcmHttpResponseStatus::Unknown { - http_status_code, - }, +impl TryFrom for FcmResponseError { + type Error = (); + + fn try_from(value: u16) -> Result { + match value { + 400 => Ok(Self::InvalidArgument), + 404 => Ok(Self::Unregistered), + 403 => Ok(Self::SenderIdMismatch), + 429 => Ok(Self::QuotaExceeded), + 503 => Ok(Self::Unavailable), + 500 => Ok(Self::Internal), + 401 => Ok(Self::ThirdPartyAuth), + _ => Err(()), } } } @@ -125,30 +111,39 @@ impl FromStr for RetryAfter { #[derive(Debug, Clone)] pub struct FcmResponse { - response_status: FcmHttpResponseStatus, + http_status_code: u16, response_json_object: serde_json::Map, retry_after: Option, } impl FcmResponse { pub(crate) fn new( - response_status: FcmHttpResponseStatus, + http_status_code: u16, response_json_object: serde_json::Map, retry_after: Option, ) -> Self { Self { - response_status, + http_status_code, response_json_object, retry_after, } } - pub fn recommended_action(&self) -> Option { + /// If `None` then [crate::message::Message] is sent successfully. + pub fn recommended_error_handling_action(&self) -> Option { RecomendedAction::analyze(self) } - pub fn status(&self) -> FcmHttpResponseStatus { - self.response_status + /// If `None` then [crate::message::Message] is sent successfully. + pub fn error(&self) -> Option { + FcmResponseError::detect_from( + self.http_status_code, + &self.response_json_object + ) + } + + pub fn http_status_code(&self) -> u16 { + self.http_status_code } pub fn json(&self) -> &serde_json::Map { @@ -160,81 +155,88 @@ impl FcmResponse { } } - -/// Action which server or developer should do based on the [FcmResponse]. +/// Error handling action which server or developer should do based on +/// [FcmResponseError] and possible [RetryAfter]. /// /// Check /// and /// for more details. #[derive(Debug, Clone, PartialEq)] pub enum RecomendedAction<'a> { - /// Error [FcmHttpError::Unregistered] was received. + /// Error [FcmResponseError::Unregistered] was detected. /// The app token sent with the message was detected as /// missing or unregistered and should be removed. RemoveFcmAppToken, - /// Error [FcmHttpError::InvalidArgument] was received. Check + /// Error [FcmResponseError::InvalidArgument] was detected. Check /// that the sent message is correct. FixMessageContent, - /// Error [FcmHttpError::SenderIdMismatch] was received. Check + /// Error [FcmResponseError::SenderIdMismatch] was detected. Check /// that that client and server uses the same sender ID. CheckSenderIdEquality, - /// Error [FcmHttpError::QuotaExceeded] was received. Reduce + /// Error [FcmResponseError::QuotaExceeded] was detected. Reduce /// overall message sending rate, device message rate or - /// topic message rate and then retry sending the previous - /// message. + /// topic message rate. After that check [RecomendedWaitTime] to determine + /// should specific or exponential back-off wait time should be used as + /// a waiting time. After the waiting time is elapsed then resend the + /// previous message. /// /// TODO: Figure out QuotaExceeded format to know what quota was exceeded ReduceMessageRateAndRetry(RecomendedWaitTime<'a>), - /// Error [FcmHttpError::Unavailable] or [FcmHttpError::Internal] - /// was received. Wait specific amount of time before retrying the message. + /// Error [FcmResponseError::Unavailable] or [FcmResponseError::Internal] + /// was detected. Check [RecomendedWaitTime] to determine + /// should specific or exponential back-off wait time should be used as + /// a waiting time. After the waiting time is elapsed then resend the + /// previous message. Retry(RecomendedWaitTime<'a>), - /// Error [FcmHttpError::ThirdPartyAuthError] was received. Check + /// Error [FcmResponseError::ThirdPartyAuth] was detected. Check /// credentials related to iOS and web push notifications. CheckIosAndWebCredentials, + + /// Error [FcmResponseError::Unspecified] or [FcmResponseError::Unknown] + /// was detected. It is not clear what to do to handle this case. + HandleUnknownError, } impl RecomendedAction<'_> { fn analyze(response: &FcmResponse) -> Option { - match response.status() { - FcmHttpResponseStatus::Ok | - FcmHttpResponseStatus::UnspecifiedError | - FcmHttpResponseStatus::Unknown { .. } => None, - FcmHttpResponseStatus::Error(e) => match e { - FcmHttpError::Unregistered => Some(RecomendedAction::RemoveFcmAppToken), - FcmHttpError::InvalidArgument => Some(RecomendedAction::FixMessageContent), - FcmHttpError::SenderIdMismatch => - Some(RecomendedAction::CheckSenderIdEquality), - FcmHttpError::QuotaExceeded => { + let action = match response.error()? { + FcmResponseError::Unspecified | + FcmResponseError::Unknown { .. } => RecomendedAction::HandleUnknownError, + FcmResponseError::Unregistered => RecomendedAction::RemoveFcmAppToken, + FcmResponseError::InvalidArgument => RecomendedAction::FixMessageContent, + FcmResponseError::SenderIdMismatch => + RecomendedAction::CheckSenderIdEquality, + FcmResponseError::QuotaExceeded => { let wait_time = if let Some(ra) = response.retry_after() { RecomendedWaitTime::SpecificWaitTime(ra) } else { RecomendedWaitTime::InitialWaitTime(Duration::from_secs(60)) }; - Some(RecomendedAction::ReduceMessageRateAndRetry(wait_time)) + RecomendedAction::ReduceMessageRateAndRetry(wait_time) } - FcmHttpError::Unavailable => { + FcmResponseError::Unavailable => { let wait_time = if let Some(ra) = response.retry_after() { RecomendedWaitTime::SpecificWaitTime(ra) } else { RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) }; - Some(RecomendedAction::Retry(wait_time)) + RecomendedAction::Retry(wait_time) } - FcmHttpError::Internal => - Some(RecomendedAction::Retry( + FcmResponseError::Internal => + RecomendedAction::Retry( RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) - )), - FcmHttpError::ThirdPartyAuthError => - Some(RecomendedAction::CheckIosAndWebCredentials), - } - } + ), + FcmResponseError::ThirdPartyAuth => + RecomendedAction::CheckIosAndWebCredentials, + }; + Some(action) } } From bb453d29bb3c7b767f069ed98382c83a7fb8274a Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Wed, 22 May 2024 23:50:24 +0300 Subject: [PATCH 29/40] Support creating client with key string --- Cargo.toml | 1 + src/client/internal_client.rs | 29 +++++++++++++++++++---------- src/client/mod.rs | 17 +++++++++++++++++ src/client/oauth_gauth.rs | 7 +++++++ src/client/oauth_yup_oauth2.rs | 12 ++++++++++-- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 14b0d362a..e8ec2f38f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ yup-oauth2 = ["dep:yup-oauth2"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } +tokio = { version = "1", features = ["fs"] } reqwest = { version = "0.11.0", features = ["json"], default-features = false } chrono = "0.4" thiserror = "1.0.61" diff --git a/src/client/internal_client.rs b/src/client/internal_client.rs index 54a26b088..ec60dbfb1 100644 --- a/src/client/internal_client.rs +++ b/src/client/internal_client.rs @@ -21,18 +21,27 @@ impl FcmClientInternal { }; let http_client = builder.build()?; - let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { - path + let oauth_client = if let Some(key_json) = fcm_builder.service_account_key_json_string { + T::create_with_string_key( + key_json, + fcm_builder.token_cache_json_path, + ) + .await + .map_err(FcmClientError::Oauth)? } else { - dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() - }; + let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { + path + } else { + dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() + }; - let oauth_client = T::create_with_key_file( - service_account_key_path, - fcm_builder.token_cache_json_path, - ) - .await - .map_err(FcmClientError::Oauth)?; + T::create_with_key_file( + service_account_key_path, + fcm_builder.token_cache_json_path, + ) + .await + .map_err(FcmClientError::Oauth)? + }; Ok(FcmClientInternal { http_client, diff --git a/src/client/mod.rs b/src/client/mod.rs index 449c6fecb..ef34ab374 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -62,6 +62,11 @@ pub(crate) trait OauthClientInternal: OauthClient + Sized { token_cache_json_path: Option, ) -> impl std::future::Future> + Send; + fn create_with_string_key( + service_account_key_json_string: String, + token_cache_json_path: Option, + ) -> impl std::future::Future> + Send; + fn get_access_token( &self ) -> impl std::future::Future> + Send; @@ -79,6 +84,7 @@ pub trait OauthErrorAccessTokenStatus: OauthError { #[derive(Debug, Clone)] pub struct FcmClientBuilder { + service_account_key_json_string: Option, service_account_key_json_path: Option, token_cache_json_path: Option, fcm_request_timeout: Option, @@ -88,6 +94,7 @@ pub struct FcmClientBuilder { impl Default for FcmClientBuilder { fn default() -> Self { Self { + service_account_key_json_string: None, service_account_key_json_path: None, token_cache_json_path: None, fcm_request_timeout: None, @@ -136,6 +143,16 @@ impl FcmClientBuilder { self } + /// Set service account key JSON. Default is to use + /// path from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable + /// (which can be also located in `.env` file). + /// + /// This overrides `service_account_key_json_path`. + pub fn service_account_key_json_string(mut self, service_account_key_json_string: impl Into) -> Self { + self.service_account_key_json_string = Some(service_account_key_json_string.into()); + self + } + pub async fn build(self) -> Result, FcmClientError<::Error>> { Ok(FcmClient { internal_client: FcmClientInternal::new_from_builder(self).await?, diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs index ff3e57531..59eaf5339 100644 --- a/src/client/oauth_gauth.rs +++ b/src/client/oauth_gauth.rs @@ -42,6 +42,13 @@ impl OauthClientInternal for Gauth { }) } + async fn create_with_string_key( + _service_account_key_json_string: String, + _token_cache_json_path: Option, + ) -> Result { + unimplemented!() + } + async fn get_access_token(&self) -> Result { let scopes = vec![FIREBASE_OAUTH_SCOPE]; let mut service_account = ServiceAccount::from_file(&self.service_account_key_path, scopes); diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 92cc2f292..a96f144ae 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -50,8 +50,16 @@ impl OauthClientInternal for YupOauth2 { service_account_key_path: PathBuf, token_cache_json_path: Option, ) -> Result { - let key = yup_oauth2::read_service_account_key(service_account_key_path) - .await + let file = tokio::fs::read_to_string(&service_account_key_path).await + .map_err(YupOauth2Error::ServiceAccountKeyReadingFailed)?; + Self::create_with_string_key(file, token_cache_json_path).await + } + + async fn create_with_string_key( + service_account_key_json_string: String, + token_cache_json_path: Option, + ) -> Result { + let key = yup_oauth2::parse_service_account_key(service_account_key_json_string) .map_err(YupOauth2Error::ServiceAccountKeyReadingFailed)?; let oauth_client = DefaultHyperClient.build_hyper_client() .map_err(YupOauth2Error::Oauth)?; From 1cdc2115d3f8aded4b37478cf134871f7b5cebbb Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Thu, 23 May 2024 00:58:20 +0300 Subject: [PATCH 30/40] Improve wait time calculation Get system time only if needed. --- src/client/response.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/client/response.rs b/src/client/response.rs index 60a3bb6fe..f29d2ecc0 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -79,14 +79,17 @@ pub enum RetryAfter { impl RetryAfter { /// Wait time calculated from current operating system time. pub fn wait_time(&self) -> Duration { - self.wait_time_with_current_time(Utc::now().fixed_offset()) + self.wait_time_with_time_provider(|| Utc::now().fixed_offset()) } - fn wait_time_with_current_time(&self, now: DateTime) -> Duration { + fn wait_time_with_time_provider( + &self, + get_time: impl FnOnce() -> DateTime, + ) -> Duration { match *self { RetryAfter::Delay(duration) => duration, RetryAfter::DateTime(date_time) => - (date_time - now) + (date_time - get_time()) .to_std() // TimeDelta is negative when the date_time is in the // past. In that case wait time is 0. @@ -267,7 +270,7 @@ mod tests { let expected_wait_time = Duration::from_secs(1); let expected = RetryAfter::Delay(expected_wait_time); assert_eq!(expected, "1".parse().unwrap()); - assert_eq!(expected_wait_time, expected.wait_time_with_current_time(DateTime::default())); + assert_eq!(expected_wait_time, expected.wait_time_with_time_provider(DateTime::default)); } #[test] @@ -283,7 +286,7 @@ mod tests { assert_eq!( Duration::ZERO, - retry_after.wait_time_with_current_time(date_time), + retry_after.wait_time_with_time_provider(|| date_time), ); } @@ -296,7 +299,7 @@ mod tests { assert_eq!( Duration::from_secs(0), - retry_after.wait_time_with_current_time(future_date_time), + retry_after.wait_time_with_time_provider(|| future_date_time), ); } @@ -309,7 +312,7 @@ mod tests { assert_eq!( Duration::from_secs(1), - retry_after.wait_time_with_current_time(past_date_time), + retry_after.wait_time_with_time_provider(|| past_date_time), ); } @@ -322,7 +325,7 @@ mod tests { assert_eq!( Duration::from_secs(60 * 60), - retry_after.wait_time_with_current_time(past_date_time), + retry_after.wait_time_with_time_provider(|| past_date_time), ); } } From d4676239fea150c5083a31297e50e17ebd80169f Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Thu, 23 May 2024 15:53:11 +0300 Subject: [PATCH 31/40] Allow sending with Message reference --- src/client/internal_client.rs | 4 ++-- src/client/mod.rs | 4 ++-- src/message/mod.rs | 14 ++++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/client/internal_client.rs b/src/client/internal_client.rs index ec60dbfb1..fa2191aba 100644 --- a/src/client/internal_client.rs +++ b/src/client/internal_client.rs @@ -51,7 +51,7 @@ impl FcmClientInternal { } impl FcmClientInternal { - pub async fn send(&self, message: Message) -> Result> { + pub async fn send(&self, message: impl AsRef) -> Result> { let access_token = self.oauth_client.get_access_token() .await .map_err(FcmClientError::Oauth)?; @@ -63,7 +63,7 @@ impl FcmClientInternal { .http_client .post(&url) .bearer_auth(access_token) - .json(&MessageWrapper::new(message)) + .json(&MessageWrapper::new(message.as_ref())) .build()?; let response = self.http_client.execute(request).await?; diff --git a/src/client/mod.rs b/src/client/mod.rs index ef34ab374..9ee90f2a6 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -173,14 +173,14 @@ impl FcmClient { #[cfg(feature = "gauth")] impl FcmClient { - pub async fn send(&self, message: Message) -> Result::Error>> { + pub async fn send(&self, message: impl AsRef) -> Result::Error>> { self.internal_client.send(message).await } } #[cfg(feature = "yup-oauth2")] impl FcmClient { - pub async fn send(&self, message: Message) -> Result::Error>> { + pub async fn send(&self, message: impl AsRef) -> Result::Error>> { self.internal_client.send(message).await } } diff --git a/src/message/mod.rs b/src/message/mod.rs index 9d6eccfad..ec4ea1cee 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -75,14 +75,20 @@ pub struct Message { pub target: Target, } +impl AsRef for Message { + fn as_ref(&self) -> &Message { + self + } +} + /// Wrap the message in a "message" field #[derive(Serialize)] -pub(crate) struct MessageWrapper { - message: Message, +pub(crate) struct MessageWrapper<'a> { + message: &'a Message, } -impl MessageWrapper { - pub fn new(message: Message) -> MessageWrapper { +impl MessageWrapper<'_> { + pub fn new(message: &Message) -> MessageWrapper { MessageWrapper { message } } } From 4a04e9835ac0e982eeb1955ff8cec6a75b65f89e Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Thu, 23 May 2024 18:21:56 +0300 Subject: [PATCH 32/40] Fix request timeout documentation --- src/client/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 9ee90f2a6..5598cd625 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -118,7 +118,8 @@ impl FcmClientBuilder { /// Set timeout for FCM requests. Default is no timeout. /// - /// Google recommends at least 10 minute timeout for FCM requests. + /// If this is set the value should be at least 10 seconds as FCM + /// docs have that value as the minimum timeout. /// pub fn fcm_request_timeout(mut self, fcm_request_timeout: Duration) -> Self { self.fcm_request_timeout = Some(fcm_request_timeout); From 07b9ec8327377438fc74168ab44768c256744ab8 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 13:05:23 +0300 Subject: [PATCH 33/40] Lower dependency version requirements --- Cargo.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e8ec2f38f..77b84ad0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,14 +29,14 @@ yup-oauth2 = ["dep:yup-oauth2"] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["fs"] } -reqwest = { version = "0.11.0", features = ["json"], default-features = false } +reqwest = { version = "0.11", features = ["json"], default-features = false } chrono = "0.4" -thiserror = "1.0.61" -dotenvy = "0.15.7" +thiserror = "1" +dotenvy = "0.15" -gauth = { version = "0.7.0", optional = true } -yup-oauth2 = { version = "9.0.0", optional = true } +gauth = { version = "0.7", optional = true } +yup-oauth2 = { version = "9", optional = true } [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } -clap = { version = "4.5.4", features = ["cargo", "derive"] } +clap = { version = "4.5", features = ["cargo", "derive"] } From 52789aeb7aa677439bf9b3c9a6362634b8acdff5 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 13:40:11 +0300 Subject: [PATCH 34/40] Remove gauth --- Cargo.toml | 9 +- src/client/internal_client.rs | 98 ----------------- src/client/mod.rs | 192 +++++++++++++++++---------------- src/client/oauth_gauth.rs | 83 -------------- src/client/oauth_yup_oauth2.rs | 26 ++--- 5 files changed, 111 insertions(+), 297 deletions(-) delete mode 100644 src/client/internal_client.rs delete mode 100644 src/client/oauth_gauth.rs diff --git a/Cargo.toml b/Cargo.toml index 77b84ad0e..670608ac3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,15 +16,12 @@ keywords = ["fcm", "firebase", "notification"] edition = "2018" [features] -default = ["native-tls", "gauth"] +default = ["native-tls"] native-tls = ["reqwest/native-tls"] rustls = ["reqwest/rustls-tls"] vendored-tls = ["reqwest/native-tls-vendored"] -gauth = ["dep:gauth"] -yup-oauth2 = ["dep:yup-oauth2"] - [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } @@ -33,9 +30,7 @@ reqwest = { version = "0.11", features = ["json"], default-features = false } chrono = "0.4" thiserror = "1" dotenvy = "0.15" - -gauth = { version = "0.7", optional = true } -yup-oauth2 = { version = "9", optional = true } +yup-oauth2 = "9" [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } diff --git a/src/client/internal_client.rs b/src/client/internal_client.rs deleted file mode 100644 index fa2191aba..000000000 --- a/src/client/internal_client.rs +++ /dev/null @@ -1,98 +0,0 @@ -use reqwest::header::RETRY_AFTER; - -use crate::{message::{Message, MessageWrapper}, FcmClientBuilder, FcmClientError, FcmResponse, OauthClient, RetryAfter}; - -use super::OauthClientInternal; - -pub(crate) struct FcmClientInternal { - http_client: reqwest::Client, - oauth_client: T, -} - -impl FcmClientInternal { - pub async fn new_from_builder( - fcm_builder: FcmClientBuilder, - ) -> Result> { - let builder = reqwest::ClientBuilder::new(); - let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { - builder.timeout(timeout) - } else { - builder - }; - let http_client = builder.build()?; - - let oauth_client = if let Some(key_json) = fcm_builder.service_account_key_json_string { - T::create_with_string_key( - key_json, - fcm_builder.token_cache_json_path, - ) - .await - .map_err(FcmClientError::Oauth)? - } else { - let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { - path - } else { - dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() - }; - - T::create_with_key_file( - service_account_key_path, - fcm_builder.token_cache_json_path, - ) - .await - .map_err(FcmClientError::Oauth)? - }; - - Ok(FcmClientInternal { - http_client, - oauth_client, - }) - } -} - -impl FcmClientInternal { - pub async fn send(&self, message: impl AsRef) -> Result> { - let access_token = self.oauth_client.get_access_token() - .await - .map_err(FcmClientError::Oauth)?; - - // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send - let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); - - let request = self - .http_client - .post(&url) - .bearer_auth(access_token) - .json(&MessageWrapper::new(message.as_ref())) - .build()?; - - let response = self.http_client.execute(request).await?; - let retry_after = response - .headers() - .get(RETRY_AFTER); - let retry_after = if let Some(header_value) = retry_after { - let header_str = header_value.to_str() - .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; - let value = header_str.parse::() - .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { - error, - value: header_str.to_string(), - })?; - Some(value) - } else { - None - }; - let http_status_code = response.status().as_u16(); - // Return if I/O error occurs - let response_body = response.bytes().await?; - let response_json_object = serde_json::from_slice::>(&response_body) - .ok() - .unwrap_or_default(); - - Ok(FcmResponse::new( - http_status_code, - response_json_object, - retry_after, - )) - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index 5598cd625..f9842fd5c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,34 +1,24 @@ -pub(crate) mod internal_client; pub(crate) mod response; -#[cfg(feature = "gauth")] -pub mod oauth_gauth; - -#[cfg(feature = "yup-oauth2")] pub mod oauth_yup_oauth2; use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::client::response::FcmResponse; -use crate::message::Message; - -use self::internal_client::FcmClientInternal; +use reqwest::header::RETRY_AFTER; -#[cfg(feature = "gauth")] -pub type DefaultOauthClient = oauth_gauth::Gauth; - -#[cfg(all(feature = "yup-oauth2", not(feature = "gauth")))] -pub type DefaultOauthClient = oauth_yup_oauth2::YupOauth2; +use crate::client::response::FcmResponse; +use crate::message::{Message, MessageWrapper}; +use crate::RetryAfter; -const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; +use self::oauth_yup_oauth2::{YupOauth2, YupOauth2Error}; #[derive(thiserror::Error, Debug)] -pub enum FcmClientError::Error> { +pub enum FcmClientError { #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), #[error("OAuth error: {0}")] - Oauth(T), + Oauth(YupOauth2Error), #[error("Dotenvy error: {0}")] Dotenvy(#[from] dotenvy::Error), #[error("Retry-After HTTP header value is not valid string")] @@ -40,7 +30,7 @@ pub enum FcmClientError::Err }, } -impl FcmClientError { +impl FcmClientError { /// If this is `true` then most likely current service account /// key is invalid. pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { @@ -52,58 +42,15 @@ impl FcmClientError { } } -pub trait OauthClient { - type Error: OauthError; -} - -pub(crate) trait OauthClientInternal: OauthClient + Sized { - fn create_with_key_file( - service_account_key_path: PathBuf, - token_cache_json_path: Option, - ) -> impl std::future::Future> + Send; - - fn create_with_string_key( - service_account_key_json_string: String, - token_cache_json_path: Option, - ) -> impl std::future::Future> + Send; - - fn get_access_token( - &self - ) -> impl std::future::Future> + Send; - - fn get_project_id(&self) -> &str; -} - -pub trait OauthError: std::error::Error {} - -pub trait OauthErrorAccessTokenStatus: OauthError { - /// If this is `true` then most likely current service account - /// key is invalid. - fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool; -} - -#[derive(Debug, Clone)] -pub struct FcmClientBuilder { +#[derive(Debug, Default, Clone)] +pub struct FcmClientBuilder { service_account_key_json_string: Option, service_account_key_json_path: Option, token_cache_json_path: Option, fcm_request_timeout: Option, - _phantom: std::marker::PhantomData, } -impl Default for FcmClientBuilder { - fn default() -> Self { - Self { - service_account_key_json_string: None, - service_account_key_json_path: None, - token_cache_json_path: None, - fcm_request_timeout: None, - _phantom: std::marker::PhantomData, - } - } -} - -impl FcmClientBuilder { +impl FcmClientBuilder { pub fn new() -> Self { Self::default() } @@ -125,19 +72,7 @@ impl FcmClientBuilder { self.fcm_request_timeout = Some(fcm_request_timeout); self } -} -#[cfg(feature = "gauth")] -impl FcmClientBuilder { - pub async fn build(self) -> Result, FcmClientError<::Error>> { - Ok(FcmClient { - internal_client: FcmClientInternal::new_from_builder(self).await?, - }) - } -} - -#[cfg(feature = "yup-oauth2")] -impl FcmClientBuilder { /// Set path to the token cache JSON file. Default is no token cache JSON file. pub fn token_cache_json_path(mut self, token_cache_json_path: impl AsRef) -> Self { self.token_cache_json_path = Some(token_cache_json_path.as_ref().to_path_buf()); @@ -154,34 +89,103 @@ impl FcmClientBuilder { self } - pub async fn build(self) -> Result, FcmClientError<::Error>> { - Ok(FcmClient { - internal_client: FcmClientInternal::new_from_builder(self).await?, - }) + pub async fn build(self) -> Result { + FcmClient::new_from_builder(self).await } } /// An async client for sending the notification payload. -pub struct FcmClient { - internal_client: FcmClientInternal, +pub struct FcmClient { + http_client: reqwest::Client, + oauth_client: YupOauth2, } -impl FcmClient { - pub fn builder() -> FcmClientBuilder { +impl FcmClient { + pub fn builder() -> FcmClientBuilder { FcmClientBuilder::new() } -} -#[cfg(feature = "gauth")] -impl FcmClient { - pub async fn send(&self, message: impl AsRef) -> Result::Error>> { - self.internal_client.send(message).await + async fn new_from_builder( + fcm_builder: FcmClientBuilder, + ) -> Result { + let builder = reqwest::ClientBuilder::new(); + let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { + builder.timeout(timeout) + } else { + builder + }; + let http_client = builder.build()?; + + let oauth_client = if let Some(key_json) = fcm_builder.service_account_key_json_string { + YupOauth2::create_with_string_key( + key_json, + fcm_builder.token_cache_json_path, + ) + .await + .map_err(FcmClientError::Oauth)? + } else { + let service_account_key_path = if let Some(path) = fcm_builder.service_account_key_json_path { + path + } else { + dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() + }; + + YupOauth2::create_with_key_file( + service_account_key_path, + fcm_builder.token_cache_json_path, + ) + .await + .map_err(FcmClientError::Oauth)? + }; + + Ok(FcmClient { + http_client, + oauth_client, + }) } -} -#[cfg(feature = "yup-oauth2")] -impl FcmClient { - pub async fn send(&self, message: impl AsRef) -> Result::Error>> { - self.internal_client.send(message).await + pub async fn send(&self, message: impl AsRef) -> Result { + let access_token = self.oauth_client.get_access_token() + .await + .map_err(FcmClientError::Oauth)?; + + // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send + let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); + + let request = self + .http_client + .post(&url) + .bearer_auth(access_token) + .json(&MessageWrapper::new(message.as_ref())) + .build()?; + + let response = self.http_client.execute(request).await?; + let retry_after = response + .headers() + .get(RETRY_AFTER); + let retry_after = if let Some(header_value) = retry_after { + let header_str = header_value.to_str() + .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; + let value = header_str.parse::() + .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { + error, + value: header_str.to_string(), + })?; + Some(value) + } else { + None + }; + let http_status_code = response.status().as_u16(); + // Return if I/O error occurs + let response_body = response.bytes().await?; + let response_json_object = serde_json::from_slice::>(&response_body) + .ok() + .unwrap_or_default(); + + Ok(FcmResponse::new( + http_status_code, + response_json_object, + retry_after, + )) } } diff --git a/src/client/oauth_gauth.rs b/src/client/oauth_gauth.rs deleted file mode 100644 index 59eaf5339..000000000 --- a/src/client/oauth_gauth.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::path::{Path, PathBuf}; - -use gauth::serv_account::ServiceAccount; - -use super::{OauthClient, OauthClientInternal, OauthError, FIREBASE_OAUTH_SCOPE}; - -#[derive(thiserror::Error, Debug)] -pub enum GauthError { - #[error("OAuth error: {0}")] - Oauth(String), - #[error("Service account key path is not UTF-8")] - ServiceAccountKeyPathIsNotUtf8, - #[error("Service account key reading failed: {0}")] - ServiceAccountKeyReadingFailed(std::io::Error), - #[error("Service account key JSON deserialization failed: {0}")] - ServiceAccountKeyDeserializationFailed(serde_json::Error), - #[error("Service account key JSON does not contain project ID")] - ProjectIdIsMissing, -} - -impl OauthError for GauthError {} - -pub struct Gauth { - project_id: String, - service_account_key_path: String, -} - -impl OauthClient for Gauth { - type Error = GauthError; -} - -impl OauthClientInternal for Gauth { - async fn create_with_key_file( - service_account_key_path: PathBuf, - _token_cache_json_path: Option, - ) -> Result { - Ok(Gauth { - project_id: get_project_id(&service_account_key_path)?, - service_account_key_path: service_account_key_path.to_str() - .ok_or(GauthError::ServiceAccountKeyPathIsNotUtf8)? - .to_string(), - }) - } - - async fn create_with_string_key( - _service_account_key_json_string: String, - _token_cache_json_path: Option, - ) -> Result { - unimplemented!() - } - - async fn get_access_token(&self) -> Result { - let scopes = vec![FIREBASE_OAUTH_SCOPE]; - let mut service_account = ServiceAccount::from_file(&self.service_account_key_path, scopes); - let access_token = service_account.access_token().await - .map_err(|e| e.to_string()) - .map_err(GauthError::Oauth)?; - - let token_no_bearer = access_token.split(char::is_whitespace).collect::>()[1]; - - Ok(token_no_bearer.to_string()) - } - - fn get_project_id(&self) -> &str { - &self.project_id - } -} - -fn read_service_key_file_json(service_account_key_path: impl AsRef) -> Result { - let json_string = std::fs::read_to_string(service_account_key_path) - .map_err(GauthError::ServiceAccountKeyReadingFailed)?; - let json_content: serde_json::Value = serde_json::from_str(&json_string) - .map_err(GauthError::ServiceAccountKeyDeserializationFailed)?; - - Ok(json_content) -} - -fn get_project_id(service_account_key_path: impl AsRef) -> Result { - let json_content = read_service_key_file_json(service_account_key_path)?; - let project_id = json_content["project_id"].as_str() - .ok_or(GauthError::ProjectIdIsMissing)?; - Ok(project_id.to_string()) -} diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index a96f144ae..33663329a 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -5,7 +5,7 @@ use yup_oauth2::hyper::client::HttpConnector; use yup_oauth2::hyper_rustls::HttpsConnector; use yup_oauth2::ServiceAccountAuthenticator; -use super::{OauthClient, OauthClientInternal, OauthError, OauthErrorAccessTokenStatus, FIREBASE_OAUTH_SCOPE}; +const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; #[derive(thiserror::Error, Debug)] pub enum YupOauth2Error { @@ -21,10 +21,10 @@ pub enum YupOauth2Error { ProjectIdIsMissing, } -impl OauthError for YupOauth2Error {} - -impl OauthErrorAccessTokenStatus for YupOauth2Error { - fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { +impl YupOauth2Error { + /// If this is `true` then most likely current service account + /// key is invalid. + pub(crate) fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { matches!( self, YupOauth2Error::AccessTokenIsMissing | @@ -36,17 +36,13 @@ impl OauthErrorAccessTokenStatus for YupOauth2Error { } } -pub struct YupOauth2 { +pub(crate) struct YupOauth2 { authenticator: Authenticator>, project_id: String, } -impl OauthClient for YupOauth2 { - type Error = YupOauth2Error; -} - -impl OauthClientInternal for YupOauth2 { - async fn create_with_key_file( +impl YupOauth2 { + pub async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, ) -> Result { @@ -55,7 +51,7 @@ impl OauthClientInternal for YupOauth2 { Self::create_with_string_key(file, token_cache_json_path).await } - async fn create_with_string_key( + pub async fn create_with_string_key( service_account_key_json_string: String, token_cache_json_path: Option, ) -> Result { @@ -82,7 +78,7 @@ impl OauthClientInternal for YupOauth2 { }) } - async fn get_access_token(&self) -> Result { + pub async fn get_access_token(&self) -> Result { let scopes = [FIREBASE_OAUTH_SCOPE]; let access_token = self.authenticator.token(&scopes).await?; let access_token = access_token.token() @@ -91,7 +87,7 @@ impl OauthClientInternal for YupOauth2 { Ok(access_token.to_string()) } - fn get_project_id(&self) -> &str { + pub fn get_project_id(&self) -> &str { &self.project_id } } From 07146e8433c9f2a388c500801f9f9d338a1d640b Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 13:54:00 +0300 Subject: [PATCH 35/40] Improve OAuth related API --- src/client/mod.rs | 14 ++++++++------ src/client/oauth_yup_oauth2.rs | 32 ++++++++++++++++---------------- src/lib.rs | 2 ++ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index f9842fd5c..21d39271e 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,6 +1,6 @@ pub(crate) mod response; -pub mod oauth_yup_oauth2; +mod oauth_yup_oauth2; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -11,14 +11,16 @@ use crate::client::response::FcmResponse; use crate::message::{Message, MessageWrapper}; use crate::RetryAfter; -use self::oauth_yup_oauth2::{YupOauth2, YupOauth2Error}; +use self::oauth_yup_oauth2::OauthClient; + +pub use self::oauth_yup_oauth2::OauthError; #[derive(thiserror::Error, Debug)] pub enum FcmClientError { #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), #[error("OAuth error: {0}")] - Oauth(YupOauth2Error), + Oauth(OauthError), #[error("Dotenvy error: {0}")] Dotenvy(#[from] dotenvy::Error), #[error("Retry-After HTTP header value is not valid string")] @@ -97,7 +99,7 @@ impl FcmClientBuilder { /// An async client for sending the notification payload. pub struct FcmClient { http_client: reqwest::Client, - oauth_client: YupOauth2, + oauth_client: OauthClient, } impl FcmClient { @@ -117,7 +119,7 @@ impl FcmClient { let http_client = builder.build()?; let oauth_client = if let Some(key_json) = fcm_builder.service_account_key_json_string { - YupOauth2::create_with_string_key( + OauthClient::create_with_string_key( key_json, fcm_builder.token_cache_json_path, ) @@ -130,7 +132,7 @@ impl FcmClient { dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() }; - YupOauth2::create_with_key_file( + OauthClient::create_with_key_file( service_account_key_path, fcm_builder.token_cache_json_path, ) diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth_yup_oauth2.rs index 33663329a..9161b0309 100644 --- a/src/client/oauth_yup_oauth2.rs +++ b/src/client/oauth_yup_oauth2.rs @@ -8,7 +8,7 @@ use yup_oauth2::ServiceAccountAuthenticator; const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; #[derive(thiserror::Error, Debug)] -pub enum YupOauth2Error { +pub enum OauthError { #[error("Service account key reading failed: {0}")] ServiceAccountKeyReadingFailed(std::io::Error), #[error("OAuth error: {0}")] @@ -21,14 +21,14 @@ pub enum YupOauth2Error { ProjectIdIsMissing, } -impl YupOauth2Error { +impl OauthError { /// If this is `true` then most likely current service account /// key is invalid. pub(crate) fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { matches!( self, - YupOauth2Error::AccessTokenIsMissing | - YupOauth2Error::Oauth( + OauthError::AccessTokenIsMissing | + OauthError::Oauth( yup_oauth2::Error::MissingAccessToken | yup_oauth2::Error::AuthError(_) ) @@ -36,29 +36,29 @@ impl YupOauth2Error { } } -pub(crate) struct YupOauth2 { +pub(crate) struct OauthClient { authenticator: Authenticator>, project_id: String, } -impl YupOauth2 { +impl OauthClient { pub async fn create_with_key_file( service_account_key_path: PathBuf, token_cache_json_path: Option, - ) -> Result { + ) -> Result { let file = tokio::fs::read_to_string(&service_account_key_path).await - .map_err(YupOauth2Error::ServiceAccountKeyReadingFailed)?; + .map_err(OauthError::ServiceAccountKeyReadingFailed)?; Self::create_with_string_key(file, token_cache_json_path).await } pub async fn create_with_string_key( service_account_key_json_string: String, token_cache_json_path: Option, - ) -> Result { + ) -> Result { let key = yup_oauth2::parse_service_account_key(service_account_key_json_string) - .map_err(YupOauth2Error::ServiceAccountKeyReadingFailed)?; + .map_err(OauthError::ServiceAccountKeyReadingFailed)?; let oauth_client = DefaultHyperClient.build_hyper_client() - .map_err(YupOauth2Error::Oauth)?; + .map_err(OauthError::Oauth)?; let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); let builder = if let Some(path) = token_cache_json_path { builder.persist_tokens_to_disk(path) @@ -67,22 +67,22 @@ impl YupOauth2 { }; let authenticator = builder.build() .await - .map_err(YupOauth2Error::AuthenticatorCreatingFailed)?; + .map_err(OauthError::AuthenticatorCreatingFailed)?; let project_id = key.project_id - .ok_or(YupOauth2Error::ProjectIdIsMissing)?; + .ok_or(OauthError::ProjectIdIsMissing)?; - Ok(YupOauth2 { + Ok(OauthClient { authenticator, project_id, }) } - pub async fn get_access_token(&self) -> Result { + pub async fn get_access_token(&self) -> Result { let scopes = [FIREBASE_OAUTH_SCOPE]; let access_token = self.authenticator.token(&scopes).await?; let access_token = access_token.token() - .ok_or(YupOauth2Error::AccessTokenIsMissing)?; + .ok_or(OauthError::AccessTokenIsMissing)?; Ok(access_token.to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 9c26d5366..be6e5eb4c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ //! } //! ``` +pub use yup_oauth2; + pub mod message; pub(crate) mod notification; pub(crate) mod android; From ee10da1d37c72fea0019ed2149cc3e9fe4b562ae Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 13:55:10 +0300 Subject: [PATCH 36/40] Rename module oauth_yup_oauth2 to oauth --- src/client/mod.rs | 6 +++--- src/client/{oauth_yup_oauth2.rs => oauth.rs} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename src/client/{oauth_yup_oauth2.rs => oauth.rs} (100%) diff --git a/src/client/mod.rs b/src/client/mod.rs index 21d39271e..79669ae77 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,6 +1,6 @@ pub(crate) mod response; -mod oauth_yup_oauth2; +mod oauth; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -11,9 +11,9 @@ use crate::client::response::FcmResponse; use crate::message::{Message, MessageWrapper}; use crate::RetryAfter; -use self::oauth_yup_oauth2::OauthClient; +use self::oauth::OauthClient; -pub use self::oauth_yup_oauth2::OauthError; +pub use self::oauth::OauthError; #[derive(thiserror::Error, Debug)] pub enum FcmClientError { diff --git a/src/client/oauth_yup_oauth2.rs b/src/client/oauth.rs similarity index 100% rename from src/client/oauth_yup_oauth2.rs rename to src/client/oauth.rs From c8d2713f779bfb16dbf741d511423924f5fa711b Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 14:08:11 +0300 Subject: [PATCH 37/40] Change public API to have response module --- src/client/mod.rs | 8 +++++--- src/lib.rs | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 79669ae77..dcfd904ae 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod response; +pub mod response; mod oauth; @@ -9,9 +9,11 @@ use reqwest::header::RETRY_AFTER; use crate::client::response::FcmResponse; use crate::message::{Message, MessageWrapper}; -use crate::RetryAfter; -use self::oauth::OauthClient; +use self::{ + oauth::OauthClient, + response::RetryAfter, +}; pub use self::oauth::OauthError; diff --git a/src/lib.rs b/src/lib.rs index be6e5eb4c..4da93ba03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,5 +54,4 @@ pub(crate) mod apns; pub(crate) mod web; mod client; -pub use crate::client::response::*; pub use crate::client::*; From dc15c89252828d9885d33eee70205de25fff9636 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 15:15:06 +0300 Subject: [PATCH 38/40] Possibly fix unspecified error detection --- src/client/response.rs | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/client/response.rs b/src/client/response.rs index f29d2ecc0..1b83fab57 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -38,15 +38,43 @@ impl FcmResponseError { ) -> Option { if let Ok(error) = http_status_code.try_into() { Some(error) - } else if let Some("UNSPECIFIED_ERROR") = response_json.get("error_code").and_then(|v| v.as_str()) { + } else if Self::get_error(response_json) == Some("UNSPECIFIED_ERROR") { Some(Self::Unspecified) + } else if response_json.get("name").is_none() { + Some(Self::Unknown) } else { - match response_json.get("name") { - Some(_) => None, // No error - None => Some(Self::Unknown), - } + None // No error } } + + fn get_error( + response_json: &serde_json::Map, + ) -> Option<&str> { + Self::get_error_using_api_reference(response_json) + .or_else(|| Self::get_error_using_real_response(response_json)) + } + + /// Currently (2024-05-26) FCM API response JSON does not have + /// this location for INVALID_ARGUMENT error. + fn get_error_using_api_reference( + response_json: &serde_json::Map, + ) -> Option<&str> { + response_json + .get("error_code") + .and_then(|v| v.as_str()) + } + + /// Current (2024-05-26) FCM API response JSON location for + /// INVALID_ARGUMENT error and possibly for the other errors + /// as well. + fn get_error_using_real_response( + response_json: &serde_json::Map, + ) -> Option<&str> { + response_json + .get("error") + .and_then(|v| v.get("status")) + .and_then(|v| v.as_str()) + } } impl TryFrom for FcmResponseError { From 4501d9b34ba4e6791d75aab79f9035ea508b79a9 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 20:25:39 +0300 Subject: [PATCH 39/40] Run rustfmt --- examples/simple_sender.rs | 6 +- src/android/android_config.rs | 3 +- src/android/android_notification.rs | 6 +- src/client/mod.rs | 61 ++++++-------- src/client/oauth.rs | 23 ++---- src/client/response.rs | 118 +++++++++++----------------- src/lib.rs | 4 +- src/message/tests.rs | 5 +- 8 files changed, 90 insertions(+), 136 deletions(-) diff --git a/examples/simple_sender.rs b/examples/simple_sender.rs index 2684ac34d..1b9beee0e 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; use clap::Parser; use fcm::{ - FcmClient, message::{Message, Notification, Target}, + FcmClient, }; use serde_json::json; @@ -30,9 +30,7 @@ async fn main() -> Result<(), Box> { builder }; - let client = builder.build() - .await - .unwrap(); + let client = builder.build().await.unwrap(); let message = Message { data: Some(json!({ diff --git a/src/android/android_config.rs b/src/android/android_config.rs index 81073836b..cd1408918 100644 --- a/src/android/android_config.rs +++ b/src/android/android_config.rs @@ -2,8 +2,7 @@ use serde::Serialize; use serde_json::Value; use super::{ - android_fcm_options::AndroidFcmOptions, - android_message_priority::AndroidMessagePriority, + android_fcm_options::AndroidFcmOptions, android_message_priority::AndroidMessagePriority, android_notification::AndroidNotification, }; diff --git a/src/android/android_notification.rs b/src/android/android_notification.rs index 51c923e26..542c8e372 100644 --- a/src/android/android_notification.rs +++ b/src/android/android_notification.rs @@ -1,10 +1,6 @@ use serde::Serialize; -use super::{ - light_settings::LightSettings, - notification_priority::NotificationPriority, - visibility::Visibility, -}; +use super::{light_settings::LightSettings, notification_priority::NotificationPriority, visibility::Visibility}; #[derive(Debug, Default, Serialize)] /// diff --git a/src/client/mod.rs b/src/client/mod.rs index dcfd904ae..73fd4cd21 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -10,10 +10,7 @@ use reqwest::header::RETRY_AFTER; use crate::client::response::FcmResponse; use crate::message::{Message, MessageWrapper}; -use self::{ - oauth::OauthClient, - response::RetryAfter, -}; +use self::{oauth::OauthClient, response::RetryAfter}; pub use self::oauth::OauthError; @@ -28,10 +25,7 @@ pub enum FcmClientError { #[error("Retry-After HTTP header value is not valid string")] RetryAfterHttpHeaderIsNotString, #[error("Retry-After HTTP header value is not valid, error: {error}, value: {value}")] - RetryAfterHttpHeaderInvalid { - error: chrono::ParseError, - value: String, - }, + RetryAfterHttpHeaderInvalid { error: chrono::ParseError, value: String }, } impl FcmClientError { @@ -39,8 +33,7 @@ impl FcmClientError { /// key is invalid. pub fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { match self { - FcmClientError::Oauth(error) => - error.is_access_token_missing_even_if_server_requests_completed(), + FcmClientError::Oauth(error) => error.is_access_token_missing_even_if_server_requests_completed(), _ => false, } } @@ -109,9 +102,7 @@ impl FcmClient { FcmClientBuilder::new() } - async fn new_from_builder( - fcm_builder: FcmClientBuilder, - ) -> Result { + async fn new_from_builder(fcm_builder: FcmClientBuilder) -> Result { let builder = reqwest::ClientBuilder::new(); let builder = if let Some(timeout) = fcm_builder.fcm_request_timeout { builder.timeout(timeout) @@ -121,10 +112,7 @@ impl FcmClient { let http_client = builder.build()?; let oauth_client = if let Some(key_json) = fcm_builder.service_account_key_json_string { - OauthClient::create_with_string_key( - key_json, - fcm_builder.token_cache_json_path, - ) + OauthClient::create_with_string_key(key_json, fcm_builder.token_cache_json_path) .await .map_err(FcmClientError::Oauth)? } else { @@ -134,10 +122,7 @@ impl FcmClient { dotenvy::var("GOOGLE_APPLICATION_CREDENTIALS")?.into() }; - OauthClient::create_with_key_file( - service_account_key_path, - fcm_builder.token_cache_json_path, - ) + OauthClient::create_with_key_file(service_account_key_path, fcm_builder.token_cache_json_path) .await .map_err(FcmClientError::Oauth)? }; @@ -149,12 +134,17 @@ impl FcmClient { } pub async fn send(&self, message: impl AsRef) -> Result { - let access_token = self.oauth_client.get_access_token() + let access_token = self + .oauth_client + .get_access_token() .await .map_err(FcmClientError::Oauth)?; // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send - let url = format!("https://fcm.googleapis.com/v1/projects/{}/messages:send", self.oauth_client.get_project_id()); + let url = format!( + "https://fcm.googleapis.com/v1/projects/{}/messages:send", + self.oauth_client.get_project_id() + ); let request = self .http_client @@ -164,17 +154,18 @@ impl FcmClient { .build()?; let response = self.http_client.execute(request).await?; - let retry_after = response - .headers() - .get(RETRY_AFTER); + let retry_after = response.headers().get(RETRY_AFTER); let retry_after = if let Some(header_value) = retry_after { - let header_str = header_value.to_str() + let header_str = header_value + .to_str() .map_err(|_| FcmClientError::RetryAfterHttpHeaderIsNotString)?; - let value = header_str.parse::() - .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { - error, - value: header_str.to_string(), - })?; + let value = + header_str + .parse::() + .map_err(|error| FcmClientError::RetryAfterHttpHeaderInvalid { + error, + value: header_str.to_string(), + })?; Some(value) } else { None @@ -186,10 +177,6 @@ impl FcmClient { .ok() .unwrap_or_default(); - Ok(FcmResponse::new( - http_status_code, - response_json_object, - retry_after, - )) + Ok(FcmResponse::new(http_status_code, response_json_object, retry_after)) } } diff --git a/src/client/oauth.rs b/src/client/oauth.rs index 9161b0309..9d3388068 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -27,11 +27,8 @@ impl OauthError { pub(crate) fn is_access_token_missing_even_if_server_requests_completed(&self) -> bool { matches!( self, - OauthError::AccessTokenIsMissing | - OauthError::Oauth( - yup_oauth2::Error::MissingAccessToken | - yup_oauth2::Error::AuthError(_) - ) + OauthError::AccessTokenIsMissing + | OauthError::Oauth(yup_oauth2::Error::MissingAccessToken | yup_oauth2::Error::AuthError(_)) ) } } @@ -46,7 +43,8 @@ impl OauthClient { service_account_key_path: PathBuf, token_cache_json_path: Option, ) -> Result { - let file = tokio::fs::read_to_string(&service_account_key_path).await + let file = tokio::fs::read_to_string(&service_account_key_path) + .await .map_err(OauthError::ServiceAccountKeyReadingFailed)?; Self::create_with_string_key(file, token_cache_json_path).await } @@ -57,20 +55,16 @@ impl OauthClient { ) -> Result { let key = yup_oauth2::parse_service_account_key(service_account_key_json_string) .map_err(OauthError::ServiceAccountKeyReadingFailed)?; - let oauth_client = DefaultHyperClient.build_hyper_client() - .map_err(OauthError::Oauth)?; + let oauth_client = DefaultHyperClient.build_hyper_client().map_err(OauthError::Oauth)?; let builder = ServiceAccountAuthenticator::with_client(key.clone(), oauth_client); let builder = if let Some(path) = token_cache_json_path { builder.persist_tokens_to_disk(path) } else { builder }; - let authenticator = builder.build() - .await - .map_err(OauthError::AuthenticatorCreatingFailed)?; + let authenticator = builder.build().await.map_err(OauthError::AuthenticatorCreatingFailed)?; - let project_id = key.project_id - .ok_or(OauthError::ProjectIdIsMissing)?; + let project_id = key.project_id.ok_or(OauthError::ProjectIdIsMissing)?; Ok(OauthClient { authenticator, @@ -81,8 +75,7 @@ impl OauthClient { pub async fn get_access_token(&self) -> Result { let scopes = [FIREBASE_OAUTH_SCOPE]; let access_token = self.authenticator.token(&scopes).await?; - let access_token = access_token.token() - .ok_or(OauthError::AccessTokenIsMissing)?; + let access_token = access_token.token().ok_or(OauthError::AccessTokenIsMissing)?; Ok(access_token.to_string()) } diff --git a/src/client/response.rs b/src/client/response.rs index 1b83fab57..5efb11125 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -2,7 +2,10 @@ use chrono::{DateTime, FixedOffset}; use chrono::Utc; use std::time::Duration; -use std::{convert::{TryFrom, TryInto}, str::FromStr}; +use std::{ + convert::{TryFrom, TryInto}, + str::FromStr, +}; /// Error cases which can be detected from [FcmResponse]. /// @@ -47,29 +50,21 @@ impl FcmResponseError { } } - fn get_error( - response_json: &serde_json::Map, - ) -> Option<&str> { + fn get_error(response_json: &serde_json::Map) -> Option<&str> { Self::get_error_using_api_reference(response_json) .or_else(|| Self::get_error_using_real_response(response_json)) } /// Currently (2024-05-26) FCM API response JSON does not have /// this location for INVALID_ARGUMENT error. - fn get_error_using_api_reference( - response_json: &serde_json::Map, - ) -> Option<&str> { - response_json - .get("error_code") - .and_then(|v| v.as_str()) + fn get_error_using_api_reference(response_json: &serde_json::Map) -> Option<&str> { + response_json.get("error_code").and_then(|v| v.as_str()) } /// Current (2024-05-26) FCM API response JSON location for /// INVALID_ARGUMENT error and possibly for the other errors /// as well. - fn get_error_using_real_response( - response_json: &serde_json::Map, - ) -> Option<&str> { + fn get_error_using_real_response(response_json: &serde_json::Map) -> Option<&str> { response_json .get("error") .and_then(|v| v.get("status")) @@ -110,18 +105,14 @@ impl RetryAfter { self.wait_time_with_time_provider(|| Utc::now().fixed_offset()) } - fn wait_time_with_time_provider( - &self, - get_time: impl FnOnce() -> DateTime, - ) -> Duration { + fn wait_time_with_time_provider(&self, get_time: impl FnOnce() -> DateTime) -> Duration { match *self { RetryAfter::Delay(duration) => duration, - RetryAfter::DateTime(date_time) => - (date_time - get_time()) - .to_std() - // TimeDelta is negative when the date_time is in the - // past. In that case wait time is 0. - .unwrap_or(Duration::ZERO) + RetryAfter::DateTime(date_time) => (date_time - get_time()) + .to_std() + // TimeDelta is negative when the date_time is in the + // past. In that case wait time is 0. + .unwrap_or(Duration::ZERO), } } } @@ -133,10 +124,7 @@ impl FromStr for RetryAfter { s.parse::() .map(Duration::from_secs) .map(RetryAfter::Delay) - .or_else( - |_| DateTime::parse_from_rfc2822(s) - .map(RetryAfter::DateTime) - ) + .or_else(|_| DateTime::parse_from_rfc2822(s).map(RetryAfter::DateTime)) } } @@ -167,10 +155,7 @@ impl FcmResponse { /// If `None` then [crate::message::Message] is sent successfully. pub fn error(&self) -> Option { - FcmResponseError::detect_from( - self.http_status_code, - &self.response_json_object - ) + FcmResponseError::detect_from(self.http_status_code, &self.response_json_object) } pub fn http_status_code(&self) -> u16 { @@ -236,36 +221,32 @@ pub enum RecomendedAction<'a> { impl RecomendedAction<'_> { fn analyze(response: &FcmResponse) -> Option { let action = match response.error()? { - FcmResponseError::Unspecified | - FcmResponseError::Unknown { .. } => RecomendedAction::HandleUnknownError, - FcmResponseError::Unregistered => RecomendedAction::RemoveFcmAppToken, - FcmResponseError::InvalidArgument => RecomendedAction::FixMessageContent, - FcmResponseError::SenderIdMismatch => - RecomendedAction::CheckSenderIdEquality, - FcmResponseError::QuotaExceeded => { - let wait_time = if let Some(ra) = response.retry_after() { - RecomendedWaitTime::SpecificWaitTime(ra) - } else { - RecomendedWaitTime::InitialWaitTime(Duration::from_secs(60)) - }; - - RecomendedAction::ReduceMessageRateAndRetry(wait_time) - } - FcmResponseError::Unavailable => { - let wait_time = if let Some(ra) = response.retry_after() { - RecomendedWaitTime::SpecificWaitTime(ra) - } else { - RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) - }; - - RecomendedAction::Retry(wait_time) - } - FcmResponseError::Internal => - RecomendedAction::Retry( - RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) - ), - FcmResponseError::ThirdPartyAuth => - RecomendedAction::CheckIosAndWebCredentials, + FcmResponseError::Unspecified | FcmResponseError::Unknown { .. } => RecomendedAction::HandleUnknownError, + FcmResponseError::Unregistered => RecomendedAction::RemoveFcmAppToken, + FcmResponseError::InvalidArgument => RecomendedAction::FixMessageContent, + FcmResponseError::SenderIdMismatch => RecomendedAction::CheckSenderIdEquality, + FcmResponseError::QuotaExceeded => { + let wait_time = if let Some(ra) = response.retry_after() { + RecomendedWaitTime::SpecificWaitTime(ra) + } else { + RecomendedWaitTime::InitialWaitTime(Duration::from_secs(60)) + }; + + RecomendedAction::ReduceMessageRateAndRetry(wait_time) + } + FcmResponseError::Unavailable => { + let wait_time = if let Some(ra) = response.retry_after() { + RecomendedWaitTime::SpecificWaitTime(ra) + } else { + RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10)) + }; + + RecomendedAction::Retry(wait_time) + } + FcmResponseError::Internal => { + RecomendedAction::Retry(RecomendedWaitTime::InitialWaitTime(Duration::from_secs(10))) + } + FcmResponseError::ThirdPartyAuth => RecomendedAction::CheckIosAndWebCredentials, }; Some(action) } @@ -298,7 +279,10 @@ mod tests { let expected_wait_time = Duration::from_secs(1); let expected = RetryAfter::Delay(expected_wait_time); assert_eq!(expected, "1".parse().unwrap()); - assert_eq!(expected_wait_time, expected.wait_time_with_time_provider(DateTime::default)); + assert_eq!( + expected_wait_time, + expected.wait_time_with_time_provider(DateTime::default) + ); } #[test] @@ -307,15 +291,9 @@ mod tests { let date_time = DateTime::parse_from_rfc2822(date).unwrap(); let retry_after = RetryAfter::from_str(date).unwrap(); - assert_eq!( - RetryAfter::DateTime(date_time), - retry_after, - ); + assert_eq!(RetryAfter::DateTime(date_time), retry_after,); - assert_eq!( - Duration::ZERO, - retry_after.wait_time_with_time_provider(|| date_time), - ); + assert_eq!(Duration::ZERO, retry_after.wait_time_with_time_provider(|| date_time),); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 4da93ba03..67225c7d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,10 +47,10 @@ pub use yup_oauth2; -pub mod message; -pub(crate) mod notification; pub(crate) mod android; pub(crate) mod apns; +pub mod message; +pub(crate) mod notification; pub(crate) mod web; mod client; diff --git a/src/message/tests.rs b/src/message/tests.rs index a79313ded..5331c9e85 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -1,4 +1,7 @@ -use crate::{message::{Target, Message}, notification::Notification}; +use crate::{ + message::{Message, Target}, + notification::Notification, +}; use serde_json::json; #[test] From 32471f21cdb96de45aa1c55b711d92d5966cfba5 Mon Sep 17 00:00:00 2001 From: Juuso Tuononen Date: Sun, 26 May 2024 20:41:09 +0300 Subject: [PATCH 40/40] Remove code coverage --- .github/workflows/test.yml | 18 ++---------------- README.md | 1 - 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ec636b6a..d9af7f52c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: runs-on: ${{ matrix.os }} env: - RUSTFLAGS: "-Dwarnings -Cinstrument-coverage -Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" + RUSTFLAGS: "-Dwarnings -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" CARGO_INCREMENTAL: "0" RUSTDOCFLAGS: "-Cpanic=abort" RUSTC_BOOTSTRAP: "1" @@ -61,7 +61,6 @@ jobs: - uses: actions-rs/toolchain@v1 with: toolchain: ${{matrix.rust}} - components: llvm-tools-preview - uses: actions/cache@v2 with: @@ -72,18 +71,5 @@ jobs: - name: Build run: cargo build - - name: Install grcov - run: cargo install grcov - - name: Run tests - run: LLVM_PROFILE_FILE="fcm-rust-%p-%m.profraw" cargo test - - - name: Collect results - run: grcov . -s . --binary-path ./target/debug/ -t lcov --branch --ignore-not-existing -o ./tests.lcov - - - name: Coveralls GitHub Action - uses: coverallsapp/github-action@v2.2.3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - file: ./tests.lcov - + run: cargo test diff --git a/README.md b/README.md index 9cf1cccbc..b2a58c736 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # fcm-rust [![Cargo tests](https://github.com/rj76/fcm-rust/actions/workflows/test.yml/badge.svg)](https://github.com/rj76/fcm-rust/actions/workflows/test.yml) -[![Coverage Status](https://coveralls.io/repos/github/rj76/fcm-rust/badge.svg)](https://coveralls.io/github/rj76/fcm-rust) [//]: # ([![Crates.io Version](https://img.shields.io/crates/v/fcm.svg?style=flat-square)) [//]: # ([![Crates.io Downloads](https://img.shields.io/crates/dv/fcm.svg?style=flat-square))