diff --git a/.env.example b/.env.example index a4224f84b..16c55e8e4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +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. +# Follow the instructions in the +# [Firebase Documentation](https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually) +# 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/.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/.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/.gitignore b/.gitignore index e5ca28a4d..938de1d79 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,10 @@ Cargo.lock .DS_Store -.direnv .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 +# 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 6e465a1fe..670608ac3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ edition = "2018" [features] default = ["native-tls"] + native-tls = ["reqwest/native-tls"] rustls = ["reqwest/rustls-tls"] vendored-tls = ["reqwest/native-tls-vendored"] @@ -24,14 +25,13 @@ 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} +tokio = { version = "1", features = ["fs"] } +reqwest = { version = "0.11", features = ["json"], default-features = false } chrono = "0.4" -log = "0.4" -gauth = "0.7.0" -dotenv = "0.15.0" +thiserror = "1" +dotenvy = "0.15" +yup-oauth2 = "9" [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", features = ["cargo", "derive"] } diff --git a/README.md b/README.md index 7c361866b..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)) @@ -23,7 +22,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 +38,71 @@ 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, +use fcm::message::{Message, Notification, Target}; + +// 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 e5fc85300..1b9beee0e 100644 --- a/examples/simple_sender.rs +++ b/examples/simple_sender.rs @@ -1,57 +1,54 @@ -// 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, + message::{Message, Notification, Target}, + FcmClient, }; use serde_json::json; +#[derive(Parser, Debug)] +struct CliArgs { + #[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 + /// (which can be also located in `.env` file). + #[arg(short = 'k', long, value_name = "FILE")] + service_account_key_path: Option, +} + #[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(); + let args = CliArgs::parse(); + 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 data = json!({ - "key": "value", - }); + let client = builder.build().await.unwrap(); - 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())), - ..Default::default() - }), - 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() - }), + title: Some("Title".to_string()), ..Default::default() }), - apns: Some(ApnsConfig { ..Default::default() }), - webpush: Some(WebpushConfig { ..Default::default() }), + target: Target::Token(args.device_token), + 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(()) } 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"; - ''; - }; - }); -} diff --git a/src/android/android_config.rs b/src/android/android_config.rs index c6ef78756..cd1408918 100644 --- a/src/android/android_config.rs +++ b/src/android/android_config.rs @@ -2,79 +2,44 @@ use serde::Serialize; use serde_json::Value; use super::{ - android_fcm_options::{AndroidFcmOptions, AndroidFcmOptionsInternal}, - android_message_priority::AndroidMessagePriority, - android_notification::{AndroidNotification, AndroidNotificationInternal}, + android_fcm_options::AndroidFcmOptions, android_message_priority::AndroidMessagePriority, + 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig +#[derive(Debug, Default, Serialize)] +/// 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 + /// Duration format: + #[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..efe8722a9 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidconfig +#[derive(Debug, Default, Serialize)] +/// 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..34c8b4f07 100644 --- a/src/android/android_message_priority.rs +++ b/src/android/android_message_priority.rs @@ -1,9 +1,8 @@ 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 { Normal, High, diff --git a/src/android/android_notification.rs b/src/android/android_notification.rs index eba41511a..542c8e372 100644 --- a/src/android/android_notification.rs +++ b/src/android/android_notification.rs @@ -1,234 +1,113 @@ use serde::Serialize; -use super::{ - light_settings::{LightSettings, LightSettingsInternal}, - 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, +use super::{light_settings::LightSettings, notification_priority::NotificationPriority, visibility::Visibility}; - /// 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#androidnotification +#[derive(Debug, Default, Serialize)] +/// 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 + /// Timestamp format: + #[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 + /// Duration format: + #[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..ed97b72c2 100644 --- a/src/android/color.rs +++ b/src/android/color.rs @@ -1,23 +1,7 @@ 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 { /// The amount of red in the color as a value in the interval [0, 1]. pub red: f32, @@ -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..245315c0e 100644 --- a/src/android/light_settings.rs +++ b/src/android/light_settings.rs @@ -1,43 +1,18 @@ 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#LightSettings +#[derive(Debug, Default, Serialize)] +/// 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, } - -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..24627b057 100644 --- a/src/android/notification_priority.rs +++ b/src/android/notification_priority.rs @@ -1,9 +1,8 @@ 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 { PriorityUnspecified, PriorityMin, diff --git a/src/android/visibility.rs b/src/android/visibility.rs index d07d24dda..8425affe6 100644 --- a/src/android/visibility.rs +++ b/src/android/visibility.rs @@ -1,9 +1,8 @@ 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 { VisibilityUnspecified, Private, diff --git a/src/apns/apns_config.rs b/src/apns/apns_config.rs index db26718fa..d20dde985 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsconfig -pub(crate) struct ApnsConfigInternal { +#[derive(Debug, Default, Serialize)] +/// +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..fd0af8544 100644 --- a/src/apns/apns_fcm_options.rs +++ b/src/apns/apns_fcm_options.rs @@ -1,17 +1,7 @@ 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#apnsfcmoptions +#[derive(Debug, Default, Serialize)] +/// pub struct ApnsFcmOptions { /// Label associated with the message's analytics data. pub analytics_label: Option, @@ -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 f2418fa87..73fd4cd21 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,172 +1,182 @@ -pub(crate) mod response; +pub mod response; + +mod oauth; + +use std::path::{Path, PathBuf}; +use std::time::Duration; -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, +use crate::client::response::FcmResponse; +use crate::message::{Message, MessageWrapper}; + +use self::{oauth::OauthClient, response::RetryAfter}; + +pub use self::oauth::OauthError; + +#[derive(thiserror::Error, Debug)] +pub enum FcmClientError { + #[error("Reqwest error: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("OAuth error: {0}")] + Oauth(OauthError), + #[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}")] + RetryAfterHttpHeaderInvalid { error: chrono::ParseError, value: String }, } -impl Default for Client { - fn default() -> Self { - Self::new() +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) => error.is_access_token_missing_even_if_server_requests_completed(), + _ => false, + } } } -// will be used to wrap the message in a "message" field -#[derive(Serialize)] -struct MessageWrapper<'a> { - #[serde(rename = "message")] - message: &'a MessageInternal, +#[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, } -impl MessageWrapper<'_> { - fn new(message: &MessageInternal) -> MessageWrapper { - MessageWrapper { message } +impl FcmClientBuilder { + pub fn new() -> Self { + Self::default() } -} - -impl Client { - /// Get a new instance of Client. - pub fn new() -> Client { - let http_client = reqwest::ClientBuilder::new() - .pool_max_idle_per_host(usize::MAX) - .build() - .unwrap(); - Client { http_client } + /// 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 } - 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) + /// Set timeout for FCM requests. Default is no timeout. + /// + /// 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); + self } - 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()) + /// 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 } - 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) + /// 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 } - fn get_project_id(&self) -> Result { - let json_content = match self.read_service_key_file_json() { - Ok(json) => json, - Err(err) => return Err(err), - }; - - 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()), - }; - - Ok(project_id.to_string()) + pub async fn build(self) -> Result { + FcmClient::new_from_builder(self).await } +} - 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()), - }; +/// An async client for sending the notification payload. +pub struct FcmClient { + http_client: reqwest::Client, + oauth_client: OauthClient, +} - Ok(tkn) +impl FcmClient { + pub fn builder() -> FcmClientBuilder { + FcmClientBuilder::new() } - 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()), + 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 { + OauthClient::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() + }; + + OauthClient::create_with_key_file(service_account_key_path, fcm_builder.token_cache_json_path) + .await + .map_err(FcmClientError::Oauth)? }; - let token_no_bearer = access_token.split(char::is_whitespace).collect::>()[1]; - - Ok(token_no_bearer.to_string()) + Ok(FcmClient { + http_client, + oauth_client, + }) } - 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: 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", 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) - .header("Content-Type", "application/json") - .bearer_auth(auth_token) - .body(Body::from(payload)) + .bearer_auth(access_token) + .json(&MessageWrapper::new(message.as_ref())) .build()?; let response = self.http_client.execute(request).await?; - - let response_status = response.status(); - - 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 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.rs b/src/client/oauth.rs new file mode 100644 index 000000000..9d3388068 --- /dev/null +++ b/src/client/oauth.rs @@ -0,0 +1,86 @@ +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; + +const FIREBASE_OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; + +#[derive(thiserror::Error, Debug)] +pub enum OauthError { + #[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 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, + OauthError::AccessTokenIsMissing + | OauthError::Oauth(yup_oauth2::Error::MissingAccessToken | yup_oauth2::Error::AuthError(_)) + ) + } +} + +pub(crate) struct OauthClient { + authenticator: Authenticator>, + project_id: String, +} + +impl OauthClient { + pub async fn create_with_key_file( + service_account_key_path: PathBuf, + token_cache_json_path: Option, + ) -> Result { + 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 + } + + pub 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(OauthError::ServiceAccountKeyReadingFailed)?; + 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 project_id = key.project_id.ok_or(OauthError::ProjectIdIsMissing)?; + + Ok(OauthClient { + authenticator, + project_id, + }) + } + + 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)?; + + Ok(access_token.to_string()) + } + + pub fn get_project_id(&self) -> &str { + &self.project_id + } +} diff --git a/src/client/response.rs b/src/client/response.rs index f7e5bf6a7..5efb11125 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -1,176 +1,96 @@ -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). +use chrono::{DateTime, FixedOffset}; + +use chrono::Utc; +use std::time::Duration; +use std::{ + convert::{TryFrom, TryInto}, + str::FromStr, +}; + +/// Error cases which can be detected from [FcmResponse]. +/// +/// Check +/// for more information. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FcmResponseError { + /// HTTP 400 + InvalidArgument, + /// HTTP 404 + Unregistered, + /// HTTP 403 + SenderIdMismatch, + /// HTTP 429 + QuotaExceeded, + /// HTTP 503 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, + /// 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, } -#[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>, -} - -#[derive(Deserialize, Debug)] -pub struct MessageResult { - pub message_id: Option, - pub registration_id: Option, - pub error: Option, -} - -/// 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), +impl FcmResponseError { + pub fn detect_from( + http_status_code: u16, + response_json: &serde_json::Map, + ) -> Option { + if let Ok(error) = http_status_code.try_into() { + Some(error) + } 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 { + None // No error + } + } - AuthToken(String), -} + 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)) + } -impl Error for FcmError {} + /// 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()) + } -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}"), - } + /// 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 From for FcmError { - fn from(_: reqwest::Error) -> Self { - Self::ServerError(None) +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(()), + } } } -#[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,73 +99,239 @@ 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_time_provider(|| Utc::now().fixed_offset()) + } + + 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), + } + } +} + 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))) } } +#[derive(Debug, Clone)] +pub struct FcmResponse { + http_status_code: u16, + response_json_object: serde_json::Map, + retry_after: Option, +} + +impl FcmResponse { + pub(crate) fn new( + http_status_code: u16, + response_json_object: serde_json::Map, + retry_after: Option, + ) -> Self { + Self { + http_status_code, + response_json_object, + retry_after, + } + } + + /// If `None` then [crate::message::Message] is sent successfully. + pub fn recommended_error_handling_action(&self) -> Option { + RecomendedAction::analyze(self) + } + + /// 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 { + &self.response_json_object + } + + pub fn retry_after(&self) -> Option<&RetryAfter> { + self.retry_after.as_ref() + } +} + +/// 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 [FcmResponseError::Unregistered] was detected. + /// The app token sent with the message was detected as + /// missing or unregistered and should be removed. + RemoveFcmAppToken, + + /// Error [FcmResponseError::InvalidArgument] was detected. Check + /// that the sent message is correct. + FixMessageContent, + + /// Error [FcmResponseError::SenderIdMismatch] was detected. Check + /// that that client and server uses the same sender ID. + CheckSenderIdEquality, + + /// Error [FcmResponseError::QuotaExceeded] was detected. Reduce + /// overall message sending rate, device message rate or + /// 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 [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 [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 { + 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, + }; + Some(action) + } +} + +#[derive(Debug, Clone, PartialEq)] +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, Duration}; - use serde_json::json; + use chrono::DateTime; #[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,) - } + fn test_retry_after_from_seconds() { + 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) + ); } #[test] - fn test_retry_after_from_seconds() { - assert_eq!(RetryAfter::Delay(Duration::seconds(420)), "420".parse().unwrap()); + 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(date_time), retry_after,); + + assert_eq!(Duration::ZERO, retry_after.wait_time_with_time_provider(|| date_time),); } #[test] - fn test_retry_after_from_date() { + 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!( - RetryAfter::DateTime(DateTime::parse_from_rfc2822(date).unwrap()), - retry_after, + Duration::from_secs(0), + retry_after.wait_time_with_time_provider(|| 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_time_provider(|| 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_time_provider(|| past_date_time), ); } } diff --git a/src/lib.rs b/src/lib.rs index 259f34f97..67225c7d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,74 +1,57 @@ -#![doc(html_root_url = "https://panicbit.github.io/fcm-rust/fcm/")] //! fcm //! === //! //! 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::Client::new(); -//! -//! let data = json!({ -//! "message": "Howdy!" -//! }); -//! -//! let builder = Message { -//! data: Some(data), +//! 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. +//! .service_account_key_json_path("service_account_key.json") +//! .build() +//! .await +//! .unwrap(); +//! +//! // 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); +//! let response = client.send(message).await?; +//! println!("Response: {:?}", response); //! //! Ok(()) //! } //! ``` -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::*; +pub use yup_oauth2; -mod web; -pub use crate::web::webpush_config::*; -pub use crate::web::webpush_fcm_options::*; +pub(crate) mod android; +pub(crate) mod apns; +pub mod message; +pub(crate) mod notification; +pub(crate) mod web; mod client; -pub use crate::client::response::FcmError as Error; pub use crate::client::*; diff --git a/src/message/fcm_options.rs b/src/message/fcm_options.rs index 2a7172f94..c4b9520dd 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#fcmoptions +#[derive(Debug, Default, Serialize)] +/// 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 e368f5825..ec4ea1cee 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,18 +9,25 @@ use serde::Serialize; 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; +pub use crate::message::fcm_options::*; +pub use crate::message::target::*; -use self::fcm_options::FcmOptions; -use self::fcm_options::FcmOptionsInternal; -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 @@ -35,69 +42,53 @@ where map.end() } -#[derive(Serialize, Debug)] -/// 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. +#[derive(Debug, Serialize)] +/// A `Message` instance is the main object to send to the FCM API. +/// +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, + pub 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 AsRef for Message { + fn as_ref(&self) -> &Message { + self + } +} + +/// Wrap the message in a "message" field +#[derive(Serialize)] +pub(crate) struct MessageWrapper<'a> { + message: &'a Message, } -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, - } +impl MessageWrapper<'_> { + pub fn new(message: &Message) -> MessageWrapper { + MessageWrapper { message } } } diff --git a/src/message/target.rs b/src/message/target.rs index b1af04f3f..12c851996 100644 --- a/src/message/target.rs +++ b/src/message/target.rs @@ -3,13 +3,13 @@ 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()); /// 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 8cb9fb713..5331c9e85 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -1,4 +1,7 @@ -use crate::{message::Target, notification::Notification, Message}; +use crate::{ + message::{Message, Target}, + notification::Notification, +}; use serde_json::json; #[test] @@ -12,8 +15,7 @@ fn should_create_new_message() { webpush: None, apns: None, fcm_options: None, - } - .finalize(); + }; assert_eq!(msg.target, target); } @@ -29,8 +31,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,8 +47,8 @@ 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 { - target: target, + let msg = Message { + target, data: Some(data), notification: None, android: None, @@ -56,7 +57,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 +79,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 +89,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 +108,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 +118,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 +137,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 +147,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 +168,7 @@ fn should_set_notifications() { image: None, }; - let builder = Message { + let msg = Message { target: target.clone(), data: None, notification: Some(nm), @@ -177,7 +177,6 @@ fn should_set_notifications() { apns: None, fcm_options: None, }; - let msg = builder.finalize(); - assert_eq!(msg.notification.is_none(), false); + assert!(msg.notification.is_some()); } diff --git a/src/notification/mod.rs b/src/notification/mod.rs index d76d7ed28..24cbd5e73 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)] -/// 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)] +#[derive(Debug, Default, Serialize)] +/// 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..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] @@ -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..08ece27d1 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushconfig +#[derive(Debug, Default, Serialize)] +/// 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 + /// Struct format: + #[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..cedba6dd7 100644 --- a/src/web/webpush_fcm_options.rs +++ b/src/web/webpush_fcm_options.rs @@ -1,17 +1,7 @@ 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)] -/// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=0#webpushfcmoptions +#[derive(Debug, Default, Serialize)] +/// pub struct WebpushFcmOptions { /// The link to open when the user clicks on the notification. pub link: String, @@ -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, - } - } -}