├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── fcm.gemspec ├── lib └── fcm.rb └── spec ├── fcm_spec.rb └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | ruby: ['2.7', '3.0', '3.1', '3.3'] 17 | 18 | steps: 19 | - uses: actions/checkout@master 20 | 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler: default 26 | bundler-cache: true 27 | 28 | - name: Run tests 29 | run: | 30 | bundle exec rspec 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | 11 | # bundler 12 | .bundle 13 | 14 | # jeweler generated 15 | pkg 16 | 17 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 18 | # 19 | # * Create a file at ~/.gitignore 20 | # * Include files you want ignored 21 | # * Run: git config --global core.excludesfile ~/.gitignore 22 | # 23 | # After doing this, these files will be ignored in all your git projects, 24 | # saving you from having to 'pollute' every project you touch with them 25 | # 26 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 27 | # 28 | # For MacOS: 29 | # 30 | .DS_Store 31 | # 32 | # For TextMate 33 | #*.tmproj 34 | #tmtags 35 | # 36 | # For emacs: 37 | #*~ 38 | #\#* 39 | #.\#* 40 | # 41 | # For vim: 42 | #*.swp 43 | 44 | bin 45 | cache 46 | gems 47 | specifications 48 | Gemfile.lock 49 | .rvmrc 50 | spec/reports 51 | *.gem 52 | .env 53 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem 'rake' 5 | gem 'rspec' 6 | gem 'webmock' 7 | gem 'ci_reporter_rspec' 8 | gem 'googleauth' 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Kashif Rasul and Shoaib Burq 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase Cloud Messaging (FCM) for Android and iOS 2 | 3 | [![Gem Version](https://badge.fury.io/rb/fcm.svg)](http://badge.fury.io/rb/fcm) [![Build Status](https://github.com/decision-labs/fcm/workflows/Tests/badge.svg)](https://github.com/decision-labs/fcm/actions) 4 | 5 | The FCM gem lets your ruby backend send notifications to Android and iOS devices via [ 6 | Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging/). 7 | 8 | ## Installation 9 | 10 | $ gem install fcm 11 | 12 | or in your `Gemfile` just include it: 13 | 14 | ```ruby 15 | gem 'fcm' 16 | ``` 17 | 18 | ## Requirements 19 | 20 | For Android you will need a device running 2.3 (or newer) that also have the Google Play Store app installed, or an emulator running Android 2.3 with Google APIs. iOS devices are also supported. 21 | 22 | A version of supported Ruby, currently: 23 | `ruby >= 2.4` 24 | 25 | ## Getting Started 26 | To use this gem, you need to instantiate a client with your firebase credentials: 27 | 28 | ```ruby 29 | fcm = FCM.new( 30 | GOOGLE_APPLICATION_CREDENTIALS_PATH, 31 | FIREBASE_PROJECT_ID 32 | ) 33 | ``` 34 | 35 | ## About the `GOOGLE_APPLICATION_CREDENTIALS_PATH` 36 | The `GOOGLE_APPLICATION_CREDENTIALS_PATH` is meant to contain your firebase credentials. 37 | 38 | The easiest way to provide them is to pass here an absolute path to a file with your credentials: 39 | 40 | ```ruby 41 | fcm = FCM.new( 42 | '/path/to/credentials.json', 43 | FIREBASE_PROJECT_ID 44 | ) 45 | ``` 46 | 47 | As per their secret nature, you might not want to have them in your repository. In that case, another supported solution is to pass a `StringIO` that contains your credentials: 48 | 49 | ```ruby 50 | fcm = FCM.new( 51 | StringIO.new(ENV.fetch('FIREBASE_CREDENTIALS')), 52 | FIREBASE_PROJECT_ID 53 | ) 54 | 55 | ``` 56 | 57 | ## Usage 58 | 59 | ## HTTP v1 API 60 | 61 | To migrate to HTTP v1 see: https://firebase.google.com/docs/cloud-messaging/migrate-v1 62 | 63 | ```ruby 64 | fcm = FCM.new( 65 | GOOGLE_APPLICATION_CREDENTIALS_PATH, 66 | FIREBASE_PROJECT_ID 67 | ) 68 | message = { 69 | 'token': "000iddqd", # send to a specific device 70 | # 'topic': "yourTopic", 71 | # 'condition': "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", 72 | 'data': { 73 | payload: { 74 | data: { 75 | id: 1 76 | } 77 | }.to_json 78 | }, 79 | 'notification': { 80 | title: notification.title_th, 81 | body: notification.body_th, 82 | }, 83 | 'android': {}, 84 | 'apns': { 85 | payload: { 86 | aps: { 87 | sound: "default", 88 | category: "#{Time.zone.now.to_i}" 89 | } 90 | } 91 | }, 92 | 'fcm_options': { 93 | analytics_label: 'Label' 94 | } 95 | } 96 | 97 | fcm.send_v1(message) # or fcm.send_notification_v1(message) 98 | ``` 99 | 100 | ## Device Group Messaging 101 | 102 | With [device group messaging](https://firebase.google.com/docs/cloud-messaging/notifications), you can send a single message to multiple instance of an app running on devices belonging to a group. Typically, "group" refers a set of different devices that belong to a single user. However, a group could also represent a set of devices where the app instance functions in a highly correlated manner. To use this feature, you will first need an initialised `FCM` class. 103 | 104 | The maximum number of members allowed for a notification key is 20. 105 | https://firebase.google.com/docs/cloud-messaging/android/device-group#managing_device_groups 106 | 107 | ### Generate a Notification Key for device group 108 | 109 | Then you will need a notification key which you can create for a particular `key_name` which needs to be uniquely named per app in case you have multiple apps for the same `project_id`. This ensures that notifications only go to the intended target app. The `create` method will do this and return the token `notification_key`, that represents the device group, in the response: 110 | 111 | `project_id` is the SENDER_ID in your cloud settings. 112 | https://firebase.google.com/docs/cloud-messaging/concept-options#senderid 113 | 114 | ```ruby 115 | params = { key_name: "appUser-Chris", 116 | project_id: "my_project_id", 117 | registration_ids: ["4", "8", "15", "16", "23", "42"] } 118 | response = fcm.create(*params.values) 119 | ``` 120 | 121 | ### Send to Notification device group 122 | 123 | To send messages to device groups, use the HTTP v1 API, 124 | Sending messages to a device group is very similar to sending messages to an individual device, using the same method to authorize send requests. Set the token field to the group notification key 125 | 126 | ```ruby 127 | message = { 128 | 'token': "NOTIFICATION_KEY", # send to a device group 129 | # ...data 130 | } 131 | 132 | fcm.send_v1(message) 133 | ``` 134 | 135 | ### Add/Remove Registration Tokens 136 | 137 | You can also add/remove registration Tokens to/from a particular `notification_key` of some `project_id`. For example: 138 | 139 | ```ruby 140 | params = { key_name: "appUser-Chris", 141 | project_id: "my_project_id", 142 | notification_key:"appUser-Chris-key", 143 | registration_ids:["7", "3"] } 144 | response = fcm.add(*params.values) 145 | 146 | params = { key_name: "appUser-Chris", 147 | project_id: "my_project_id", 148 | notification_key:"appUser-Chris-key", 149 | registration_ids:["8", "15"] } 150 | response = fcm.remove(*params.values) 151 | ``` 152 | 153 | ## Send Messages to Topics 154 | 155 | FCM [topic messaging](https://firebase.google.com/docs/cloud-messaging/topic-messaging) allows your app server to send a message to multiple devices that have opted in to a particular topic. Based on the publish/subscribe model, one app instance can be subscribed to no more than 2000 topics. Sending to a topic is very similar to sending to an individual device or to a user group, in the sense that you can use the `fcm.send_v1` method where the `topic` matches the regular expression `"/topics/[a-zA-Z0-9-_.~%]+"`: 156 | 157 | ```ruby 158 | message = { 159 | 'topic': "yourTopic", # send to a device group 160 | # ...data 161 | } 162 | 163 | fcm.send_v1(message) 164 | ``` 165 | 166 | Or you can use the `fcm.send_to_topic` helper: 167 | 168 | ```ruby 169 | response = fcm.send_to_topic("yourTopic", 170 | notification: { body: "This is a FCM Topic Message!"} ) 171 | ``` 172 | 173 | ## Send Messages to Topics with Conditions 174 | 175 | FCM [topic condition messaging](https://firebase.google.com/docs/cloud-messaging/android/topic-messaging#build_send_requests) to send a message to a combination of topics, specify a condition, which is a boolean expression that specifies the target topics. 176 | 177 | ```ruby 178 | message = { 179 | 'condition': "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", # send to topic condition 180 | # ...data 181 | } 182 | 183 | fcm.send_v1(message) 184 | ``` 185 | 186 | Or you can use the `fcm.send_to_topic_condition` helper: 187 | 188 | ```ruby 189 | response = fcm.send_to_topic_condition( 190 | "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", 191 | notification: { 192 | body: "This is an FCM Topic Message sent to a condition!" 193 | } 194 | ) 195 | ``` 196 | 197 | ### Sending to Multiple Topics 198 | 199 | To send to combinations of multiple topics, require that you set a **condition** key to a boolean condition that specifies the target topics. For example, to send messages to devices that subscribed to _TopicA_ and either _TopicB_ or _TopicC_: 200 | 201 | ``` 202 | 'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics) 203 | ``` 204 | 205 | FCM first evaluates any conditions in parentheses, and then evaluates the expression from left to right. In the above expression, a user subscribed to any single topic does not receive the message. Likewise, a user who does not subscribe to TopicA does not receive the message. These combinations do receive it: 206 | 207 | - TopicA and TopicB 208 | - TopicA and TopicC 209 | 210 | You can include up to five topics in your conditional expression, and parentheses are supported. Supported operators: `&&`, `||`, `!`. Note the usage for !: 211 | 212 | ``` 213 | !('TopicA' in topics) 214 | ``` 215 | 216 | With this expression, any app instances that are not subscribed to TopicA, including app instances that are not subscribed to any topic, receive the message. 217 | 218 | The `send_to_topic_condition` method within this library allows you to specicy a condition of multiple topics to which to send to the data payload. 219 | 220 | ```ruby 221 | response = fcm.send_to_topic_condition( 222 | "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", 223 | notification: { 224 | body: "This is an FCM Topic Message sent to a condition!" 225 | } 226 | ) 227 | ``` 228 | 229 | ## Subscribe the client app to a topic 230 | 231 | Given a registration token and a topic name, you can add the token to the topic using the [Google Instance ID server API](https://developers.google.com/instance-id/reference/server). 232 | 233 | ```ruby 234 | topic = "YourTopic" 235 | registration_token= "12" # a client registration token 236 | response = fcm.topic_subscription(topic, registration_token) 237 | # or unsubscription 238 | response = fcm.topic_unsubscription(topic, registration_token) 239 | ``` 240 | 241 | Or you can manage relationship maps for multiple app instances [Google Instance ID server API. Manage relationship](https://developers.google.com/instance-id/reference/server#manage_relationship_maps_for_multiple_app_instances) 242 | 243 | ```ruby 244 | topic = "YourTopic" 245 | registration_tokens= ["4", "8", "15", "16", "23", "42"] # an array of one or more client registration tokens 246 | response = fcm.batch_topic_subscription(topic, registration_tokens) 247 | # or unsubscription 248 | response = fcm.batch_topic_unsubscription(topic, registration_tokens) 249 | ``` 250 | 251 | ## Get Information about the Instance ID 252 | 253 | Given a registration token, you can retrieve information about the token using the [Google Instance ID server API](https://developers.google.com/instance-id/reference/server). 254 | 255 | ```ruby 256 | registration_token= "12" # a client registration token 257 | response = fcm.get_instance_id_info(registration_token) 258 | ``` 259 | 260 | To get detailed information about the instance ID, you can pass an optional 261 | `options` hash to the `get_instance_id_info` method: 262 | 263 | ```ruby 264 | registration_token= "12" # a client registration token 265 | options = { "details" => true } 266 | response = fcm.get_instance_id_info(registration_token, options) 267 | ``` 268 | 269 | ## Mobile Clients 270 | 271 | You can find a guide to implement an Android Client app to receive notifications here: [Set up a FCM Client App on Android](https://firebase.google.com/docs/cloud-messaging/android/client). 272 | 273 | The guide to set up an iOS app to get notifications is here: [Setting up a FCM Client App on iOS](https://firebase.google.com/docs/cloud-messaging/ios/client). 274 | 275 | ## ChangeLog 276 | 277 | ### 2.0.1 278 | - Add `http_options` to `initialize` method and whitelist `timeout` option 279 | 280 | ### 2.0.0 281 | #### Breaking Changes 282 | - Remove deprecated `API_KEY` 283 | - Remove deprecated `send` method 284 | - Remove deprecated `send_with_notification_key` method 285 | - Remove `subscribe_instance_id_to_topic` method 286 | - Remove `unsubscribe_instance_id_from_topic` method 287 | - Remove `batch_subscribe_instance_ids_to_topic` method 288 | - Remove `batch_unsubscribe_instance_ids_from_topic` method 289 | 290 | #### Supported Features 291 | - Add HTTP v1 API support for `send_to_topic_condition` method 292 | - Add HTTP v1 API support for `send_to_topic` method 293 | 294 | ### 1.0.8 295 | - caches calls to `Google::Auth::ServiceAccountCredentials` #103 296 | - Allow `faraday` versions from 1 up to 2 #101 297 | 298 | ### 1.0.7 299 | 300 | - Fix passing `DEFAULT_TIMEOUT` to `faraday` [#96](https://github.com/decision-labs/fcm/pull/96) 301 | - Fix issue with `get_instance_id_info` option params [#98](https://github.com/decision-labs/fcm/pull/98) 302 | - Accept any IO object for credentials [#95](https://github.com/decision-labs/fcm/pull/94) 303 | 304 | Huge thanks to @excid3 @jsparling @jensljungblad 305 | 306 | ### 1.0.3 307 | 308 | - Fix overly strict faraday dependency 309 | 310 | ### 1.0.2 311 | 312 | - Bug fix: retrieve notification key" params: https://github.com/spacialdb/fcm/commit/b328a75c11d779a06d0ceda83527e26aa0495774 313 | 314 | ### 1.0.0 315 | 316 | - Bumped supported ruby to `>= 2.4` 317 | - Fix deprecation warnings from `faraday` by changing dependency version to `faraday 1.0.0` 318 | 319 | ### 0.0.7 320 | 321 | - replace `httparty` with `faraday` 322 | 323 | ### 0.0.2 324 | 325 | - Fixed group messaging url. 326 | - Added API to `recover_notification_key`. 327 | 328 | 329 | ### 0.0.1 330 | 331 | - Initial version. 332 | 333 | ## MIT License 334 | 335 | - Copyright (c) 2016 Kashif Rasul and Shoaib Burq. See LICENSE.txt for details. 336 | 337 | ## Many thanks to all the contributors 338 | 339 | - [Contributors](https://github.com/spacialdb/fcm/contributors) 340 | 341 | ## Cutting a release 342 | 343 | Update version in `fcm.gemspec` with `VERSION` and update `README.md` `## ChangeLog` section. 344 | 345 | ```bash 346 | # set the version 347 | # VERSION="1.0.7" 348 | gem build fcm.gemspec 349 | git tag -a v${VERSION} -m "Releasing version v${VERSION}" 350 | git push origin --tags 351 | gem push fcm-${VERSION}.gem 352 | ``` 353 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require "bundler/gem_tasks" 3 | require "rake/tasklib" 4 | require 'ci/reporter/rake/rspec' 5 | 6 | RSpec::Core::RakeTask.new(:spec => ["ci:setup:rspec"]) do |t| 7 | t.pattern = 'spec/**/*_spec.rb' 8 | end 9 | 10 | RSpec::Core::RakeTask.new(:spec) do |spec| 11 | spec.pattern = 'spec/**/*_spec.rb' 12 | spec.rspec_opts = ['--format documentation'] 13 | end 14 | 15 | task :default => :spec 16 | -------------------------------------------------------------------------------- /fcm.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "fcm" 6 | s.version = "2.0.1" 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ["Kashif Rasul", "Shoaib Burq"] 9 | s.email = ["kashif@decision-labs.com", "shoaib@decision-labs.com"] 10 | s.homepage = "https://github.com/decision-labs/fcm" 11 | s.summary = %q{Reliably deliver messages and notifications via FCM} 12 | s.description = %q{fcm provides ruby bindings to Firebase Cloud Messaging (FCM) a cross-platform messaging solution that lets you reliably deliver messages and notifications at no cost to Android, iOS or Web browsers.} 13 | s.license = "MIT" 14 | 15 | s.required_ruby_version = ">= 2.4.0" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.add_runtime_dependency("faraday", ">= 1.0.0", "< 3.0") 23 | s.add_runtime_dependency("googleauth", "~> 1") 24 | end 25 | -------------------------------------------------------------------------------- /lib/fcm.rb: -------------------------------------------------------------------------------- 1 | require "faraday" 2 | require "cgi" 3 | require "json" 4 | require "googleauth" 5 | 6 | class FCM 7 | BASE_URI = "https://fcm.googleapis.com" 8 | BASE_URI_V1 = "https://fcm.googleapis.com/v1/projects/" 9 | DEFAULT_TIMEOUT = 30 10 | 11 | GROUP_NOTIFICATION_BASE_URI = "https://android.googleapis.com" 12 | INSTANCE_ID_API = "https://iid.googleapis.com" 13 | TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/ 14 | 15 | def initialize(json_key_path = "", project_name = "", http_options = {}) 16 | @json_key_path = json_key_path 17 | @project_name = project_name 18 | @http_options = http_options 19 | end 20 | 21 | # See https://firebase.google.com/docs/cloud-messaging/send-message 22 | # { 23 | # "token": "4sdsx", 24 | # "notification": { 25 | # "title": "Breaking News", 26 | # "body": "New news story available." 27 | # }, 28 | # "data": { 29 | # "story_id": "story_12345" 30 | # }, 31 | # "android": { 32 | # "notification": { 33 | # "click_action": "TOP_STORY_ACTIVITY", 34 | # "body": "Check out the Top Story" 35 | # } 36 | # }, 37 | # "apns": { 38 | # "payload": { 39 | # "aps": { 40 | # "category" : "NEW_MESSAGE_CATEGORY" 41 | # } 42 | # } 43 | # } 44 | # } 45 | # fcm = FCM.new(json_key_path, project_name) 46 | # fcm.send_v1( 47 | # { "token": "4sdsx",, "to" : "notification": {}.. } 48 | # ) 49 | def send_notification_v1(message) 50 | return if @project_name.empty? 51 | 52 | post_body = { 'message': message } 53 | for_uri(BASE_URI_V1) do |connection| 54 | response = connection.post( 55 | "#{@project_name}/messages:send", post_body.to_json 56 | ) 57 | build_response(response) 58 | end 59 | end 60 | 61 | alias send_v1 send_notification_v1 62 | 63 | def create_notification_key(key_name, project_id, registration_ids = []) 64 | post_body = build_post_body(registration_ids, operation: "create", 65 | notification_key_name: key_name) 66 | 67 | extra_headers = { 68 | "project_id" => project_id, 69 | } 70 | 71 | for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| 72 | response = connection.post("/gcm/notification", post_body.to_json) 73 | build_response(response) 74 | end 75 | end 76 | 77 | alias create create_notification_key 78 | 79 | def add_registration_ids(key_name, project_id, notification_key, registration_ids) 80 | post_body = build_post_body(registration_ids, operation: "add", 81 | notification_key_name: key_name, 82 | notification_key: notification_key) 83 | 84 | extra_headers = { 85 | "project_id" => project_id, 86 | } 87 | 88 | for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| 89 | response = connection.post("/gcm/notification", post_body.to_json) 90 | build_response(response) 91 | end 92 | end 93 | 94 | alias add add_registration_ids 95 | 96 | def remove_registration_ids(key_name, project_id, notification_key, registration_ids) 97 | post_body = build_post_body(registration_ids, operation: "remove", 98 | notification_key_name: key_name, 99 | notification_key: notification_key) 100 | 101 | extra_headers = { 102 | "project_id" => project_id, 103 | } 104 | 105 | for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| 106 | response = connection.post("/gcm/notification", post_body.to_json) 107 | build_response(response) 108 | end 109 | end 110 | 111 | alias remove remove_registration_ids 112 | 113 | def recover_notification_key(key_name, project_id) 114 | params = { notification_key_name: key_name } 115 | 116 | extra_headers = { 117 | "project_id" => project_id, 118 | } 119 | 120 | for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| 121 | response = connection.get("/gcm/notification", params) 122 | build_response(response) 123 | end 124 | end 125 | 126 | def topic_subscription(topic, registration_token) 127 | for_uri(INSTANCE_ID_API) do |connection| 128 | response = connection.post( 129 | "/iid/v1/#{registration_token}/rel/topics/#{topic}" 130 | ) 131 | build_response(response) 132 | end 133 | end 134 | 135 | def topic_unsubscription(topic, registration_token) 136 | batch_topic_unsubscription(topic, [registration_token]) 137 | end 138 | 139 | def batch_topic_subscription(topic, registration_tokens) 140 | manage_topics_relationship(topic, registration_tokens, 'Add') 141 | end 142 | 143 | def batch_topic_unsubscription(topic, registration_tokens) 144 | manage_topics_relationship(topic, registration_tokens, 'Remove') 145 | end 146 | 147 | def manage_topics_relationship(topic, registration_tokens, action) 148 | body = { to: "/topics/#{topic}", registration_tokens: registration_tokens } 149 | 150 | for_uri(INSTANCE_ID_API) do |connection| 151 | response = connection.post("/iid/v1:batch#{action}", body.to_json) 152 | build_response(response) 153 | end 154 | end 155 | 156 | def get_instance_id_info(iid_token, options = {}) 157 | params = options 158 | 159 | for_uri(INSTANCE_ID_API) do |connection| 160 | response = connection.get("/iid/info/#{iid_token}", params) 161 | build_response(response) 162 | end 163 | end 164 | 165 | def send_to_topic(topic, options = {}) 166 | if topic.gsub(TOPIC_REGEX, '').length.zero? 167 | body = { 'message': { 'topic': topic }.merge(options) } 168 | 169 | for_uri(BASE_URI_V1) do |connection| 170 | response = connection.post( 171 | "#{@project_name}/messages:send", body.to_json 172 | ) 173 | build_response(response) 174 | end 175 | end 176 | end 177 | 178 | def send_to_topic_condition(condition, options = {}) 179 | if validate_condition?(condition) 180 | body = { 'message': { 'condition': condition }.merge(options) } 181 | 182 | for_uri(BASE_URI_V1) do |connection| 183 | response = connection.post( 184 | "#{@project_name}/messages:send", body.to_json 185 | ) 186 | build_response(response) 187 | end 188 | end 189 | end 190 | 191 | private 192 | 193 | def for_uri(uri, extra_headers = {}) 194 | connection = ::Faraday.new( 195 | url: uri, 196 | request: { timeout: @http_options.fetch(:timeout, DEFAULT_TIMEOUT) } 197 | ) do |faraday| 198 | faraday.adapter Faraday.default_adapter 199 | faraday.headers["Content-Type"] = "application/json" 200 | faraday.headers["Authorization"] = "Bearer #{jwt_token}" 201 | faraday.headers["access_token_auth"]= "true" 202 | extra_headers.each do |key, value| 203 | faraday.headers[key] = value 204 | end 205 | end 206 | yield connection 207 | end 208 | 209 | def build_post_body(registration_ids, options = {}) 210 | ids = registration_ids.is_a?(String) ? [registration_ids] : registration_ids 211 | { registration_ids: ids }.merge(options) 212 | end 213 | 214 | def build_response(response, registration_ids = []) 215 | body = response.body || {} 216 | response_hash = { body: body, headers: response.headers, status_code: response.status } 217 | case response.status 218 | when 200 219 | response_hash[:response] = "success" 220 | body = JSON.parse(body) unless body.empty? 221 | response_hash[:canonical_ids] = build_canonical_ids(body, registration_ids) unless registration_ids.empty? 222 | response_hash[:not_registered_ids] = build_not_registered_ids(body, registration_ids) unless registration_ids.empty? 223 | when 400 224 | response_hash[:response] = "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, or it contained invalid fields." 225 | when 401 226 | response_hash[:response] = "There was an error authenticating the sender account." 227 | when 503 228 | response_hash[:response] = "Server is temporarily unavailable." 229 | when 500..599 230 | response_hash[:response] = "There was an internal error in the FCM server while trying to process the request." 231 | end 232 | response_hash 233 | end 234 | 235 | def build_canonical_ids(body, registration_ids) 236 | canonical_ids = [] 237 | unless body.empty? 238 | if body["canonical_ids"] > 0 239 | body["results"].each_with_index do |result, index| 240 | canonical_ids << { old: registration_ids[index], new: result["registration_id"] } if has_canonical_id?(result) 241 | end 242 | end 243 | end 244 | canonical_ids 245 | end 246 | 247 | def build_not_registered_ids(body, registration_id) 248 | not_registered_ids = [] 249 | unless body.empty? 250 | if body["failure"] > 0 251 | body["results"].each_with_index do |result, index| 252 | not_registered_ids << registration_id[index] if is_not_registered?(result) 253 | end 254 | end 255 | end 256 | not_registered_ids 257 | end 258 | 259 | def has_canonical_id?(result) 260 | !result["registration_id"].nil? 261 | end 262 | 263 | def is_not_registered?(result) 264 | result["error"] == "NotRegistered" 265 | end 266 | 267 | def validate_condition?(condition) 268 | validate_condition_format?(condition) && validate_condition_topics?(condition) 269 | end 270 | 271 | def validate_condition_format?(condition) 272 | bad_characters = condition.gsub( 273 | /(topics|in|\s|\(|\)|(&&)|[!]|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/, 274 | "" 275 | ) 276 | bad_characters.length == 0 277 | end 278 | 279 | def validate_condition_topics?(condition) 280 | topics = condition.scan(/(?:^|\S|\s)'([^']*?)'(?:$|\S|\s)/).flatten 281 | topics.all? { |topic| topic.gsub(TOPIC_REGEX, "").length == 0 } 282 | end 283 | 284 | def jwt_token 285 | scope = "https://www.googleapis.com/auth/firebase.messaging" 286 | @authorizer ||= Google::Auth::ServiceAccountCredentials.make_creds( 287 | json_key_io: json_key, 288 | scope: scope, 289 | ) 290 | token = @authorizer.fetch_access_token! 291 | token["access_token"] 292 | end 293 | 294 | def json_key 295 | @json_key ||= if @json_key_path.respond_to?(:read) 296 | @json_key_path 297 | else 298 | File.open(@json_key_path) 299 | end 300 | end 301 | end 302 | -------------------------------------------------------------------------------- /spec/fcm_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe FCM do 4 | let(:project_name) { 'test-project' } 5 | let(:json_key_path) { 'path/to/json/key.json' } 6 | let(:client) { FCM.new(json_key_path) } 7 | 8 | let(:mock_token) { "access_token" } 9 | let(:mock_headers) do 10 | { 11 | "Content-Type" => "application/json", 12 | "Authorization" => "Bearer #{mock_token}", 13 | } 14 | end 15 | 16 | before do 17 | allow(client).to receive(:json_key) 18 | 19 | # Mock the Google::Auth::ServiceAccountCredentials 20 | allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds). 21 | and_return(double(fetch_access_token!: { 'access_token' => mock_token })) 22 | end 23 | 24 | it "should initialize" do 25 | expect { client }.not_to raise_error 26 | end 27 | 28 | describe "credentials path" do 29 | it "can be a path to a file" do 30 | fcm = FCM.new("README.md") 31 | expect(fcm.__send__(:json_key).class).to eq(File) 32 | end 33 | 34 | it "can be an IO object" do 35 | fcm = FCM.new(StringIO.new("hey")) 36 | expect(fcm.__send__(:json_key).class).to eq(StringIO) 37 | end 38 | end 39 | 40 | describe "#send_v1 or #send_notification_v1" do 41 | let(:client) { FCM.new(json_key_path, project_name) } 42 | 43 | let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } 44 | let(:status_code) { 200 } 45 | 46 | let(:stub_fcm_send_v1_request) do 47 | stub_request(:post, uri).with( 48 | body: { 'message' => send_v1_params }.to_json, 49 | headers: mock_headers 50 | ).to_return( 51 | # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream 52 | body: "{}", 53 | headers: {}, 54 | status: status_code, 55 | ) 56 | end 57 | 58 | before do 59 | stub_fcm_send_v1_request 60 | end 61 | 62 | shared_examples 'succesfuly send notification' do 63 | it 'should send notification of HTTP V1 using POST to FCM server' do 64 | client.send_v1(send_v1_params).should eq( 65 | response: 'success', body: '{}', headers: {}, status_code: 200 66 | ) 67 | stub_fcm_send_v1_request.should have_been_made.times(1) 68 | end 69 | end 70 | 71 | describe 'send to token' do 72 | let(:token) { '4sdsx' } 73 | let(:send_v1_params) do 74 | { 75 | 'token' => token, 76 | 'notification' => { 77 | 'title' => 'Breaking News', 78 | 'body' => 'New news story available.' 79 | }, 80 | 'data' => { 81 | 'story_id' => 'story_12345' 82 | }, 83 | 'android' => { 84 | 'notification' => { 85 | 'click_action' => 'TOP_STORY_ACTIVITY', 86 | 'body' => 'Check out the Top Story' 87 | } 88 | }, 89 | 'apns' => { 90 | 'payload' => { 91 | 'aps' => { 92 | 'category' => 'NEW_MESSAGE_CATEGORY' 93 | } 94 | } 95 | } 96 | } 97 | end 98 | 99 | include_examples 'succesfuly send notification' 100 | 101 | it 'includes all the response' do 102 | response = client.send_v1(send_v1_params) 103 | expect(response[:status_code]).to eq(status_code) 104 | expect(response[:response]).to eq('success') 105 | expect(response[:body]).to eq('{}') 106 | expect(response[:headers]).to eq({}) 107 | expect(response[:canonical_ids]).to be_nil 108 | expect(response[:not_registered_ids]).to be_nil 109 | end 110 | end 111 | 112 | describe 'send to multiple tokens' do 113 | let(:tokens) { ['4sdsx', '4sdsy'] } 114 | let(:send_v1_params) do 115 | { 116 | 'token' => tokens, 117 | 'notification' => { 118 | 'title' => 'Breaking News', 119 | 'body' => 'New news story available.' 120 | } 121 | } 122 | end 123 | 124 | include_examples 'succesfuly send notification' 125 | end 126 | 127 | describe 'send to topic' do 128 | let(:topic) { 'news' } 129 | let(:send_v1_params) do 130 | { 131 | 'topic' => topic, 132 | 'notification' => { 133 | 'title' => 'Breaking News', 134 | 'body' => 'New news story available.' 135 | } 136 | } 137 | end 138 | 139 | include_examples 'succesfuly send notification' 140 | 141 | context 'when topic is invalid' do 142 | let(:topic) { '/topics/news$' } 143 | 144 | it 'should raise error' do 145 | stub_fcm_send_v1_request.should_not have_been_requested 146 | end 147 | end 148 | end 149 | 150 | describe 'send to condition' do 151 | let(:condition) { "'foo' in topics" } 152 | let(:send_v1_params) do 153 | { 154 | 'condition' => condition, 155 | 'notification' => { 156 | 'title' => 'Breaking News', 157 | 'body' => 'New news story available.' 158 | }, 159 | } 160 | end 161 | 162 | include_examples 'succesfuly send notification' 163 | end 164 | 165 | describe 'send to notification_key' do 166 | let(:notification_key) { 'notification_key' } 167 | let(:send_v1_params) do 168 | { 169 | 'notification_key' => notification_key, 170 | 'notification' => { 171 | 'title' => 'Breaking News', 172 | 'body' => 'New news story available.' 173 | } 174 | } 175 | end 176 | 177 | include_examples 'succesfuly send notification' 178 | end 179 | 180 | context 'when project_name is empty' do 181 | let(:project_name) { '' } 182 | let(:send_v1_params) do 183 | { 184 | 'token' => '4sdsx', 185 | 'notification' => { 186 | 'title' => 'Breaking News', 187 | 'body' => 'New news story available.' 188 | } 189 | } 190 | end 191 | 192 | it 'should not send notification' do 193 | client.send_v1(send_v1_params) 194 | stub_fcm_send_v1_request.should_not have_been_requested 195 | end 196 | end 197 | 198 | describe 'error handling' do 199 | let(:send_v1_params) do 200 | { 201 | 'token' => '4sdsx', 202 | 'notification' => { 203 | 'title' => 'Breaking News', 204 | 'body' => 'New news story available.' 205 | } 206 | } 207 | end 208 | 209 | context 'when status_code is 400' do 210 | let(:status_code) { 400 } 211 | 212 | it 'should raise error' do 213 | response = client.send_v1(send_v1_params) 214 | expect(response[:status_code]).to eq(status_code) 215 | expect(response[:response]).to include('Only applies for JSON requests') 216 | end 217 | end 218 | 219 | context 'when status_code is 401' do 220 | let(:status_code) { 401 } 221 | 222 | it 'should raise error' do 223 | response = client.send_v1(send_v1_params) 224 | expect(response[:status_code]).to eq(status_code) 225 | expect(response[:response]).to include('There was an error authenticating') 226 | end 227 | end 228 | 229 | context 'when status_code is 500' do 230 | let(:status_code) { 500 } 231 | 232 | it 'should raise error' do 233 | response = client.send_v1(send_v1_params) 234 | expect(response[:status_code]).to eq(status_code) 235 | expect(response[:response]).to include('There was an internal error') 236 | end 237 | end 238 | 239 | context 'when status_code is 503' do 240 | let(:status_code) { 503 } 241 | 242 | it 'should raise error' do 243 | response = client.send_v1(send_v1_params) 244 | expect(response[:status_code]).to eq(status_code) 245 | expect(response[:response]).to include('Server is temporarily unavailable') 246 | end 247 | end 248 | end 249 | end 250 | 251 | describe '#send_to_topic' do 252 | let(:client) { FCM.new(json_key_path, project_name) } 253 | 254 | let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } 255 | 256 | let(:topic) { 'news' } 257 | let(:params) do 258 | { 259 | 'topic' => topic 260 | }.merge(options) 261 | end 262 | let(:options) do 263 | { 264 | 'data' => { 265 | 'story_id' => 'story_12345' 266 | } 267 | } 268 | end 269 | 270 | let(:stub_fcm_send_to_topic_request) do 271 | stub_request(:post, uri).with( 272 | body: { 'message' => params }.to_json, 273 | headers: mock_headers 274 | ).to_return( 275 | body: "{}", 276 | headers: {}, 277 | status: 200, 278 | ) 279 | end 280 | 281 | before do 282 | stub_fcm_send_to_topic_request 283 | end 284 | 285 | it 'should send notification to topic using POST to FCM server' do 286 | client.send_to_topic(topic, options).should eq( 287 | response: 'success', body: '{}', headers: {}, status_code: 200 288 | ) 289 | stub_fcm_send_to_topic_request.should have_been_made.times(1) 290 | end 291 | 292 | context 'when topic is invalid' do 293 | let(:topic) { '/topics/news$' } 294 | 295 | it 'should raise error' do 296 | client.send_to_topic(topic, options) 297 | stub_fcm_send_to_topic_request.should_not have_been_requested 298 | end 299 | end 300 | end 301 | 302 | describe "#send_to_topic_condition" do 303 | let(:client) { FCM.new(json_key_path, project_name) } 304 | 305 | let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } 306 | 307 | let(:topic_condition) { "'foo' in topics" } 308 | let(:params) do 309 | { 310 | 'condition' => topic_condition 311 | }.merge(options) 312 | end 313 | let(:options) do 314 | { 315 | 'data' => { 316 | 'story_id' => 'story_12345' 317 | } 318 | } 319 | end 320 | 321 | let(:stub_fcm_send_to_topic_condition_request) do 322 | stub_request(:post, uri).with( 323 | body: { 'message' => params }.to_json, 324 | headers: mock_headers 325 | ).to_return( 326 | body: "{}", 327 | headers: {}, 328 | status: 200, 329 | ) 330 | end 331 | 332 | before do 333 | stub_fcm_send_to_topic_condition_request 334 | end 335 | 336 | it 'should send notification to topic_condition using POST to FCM server' do 337 | client.send_to_topic_condition(topic_condition, options).should eq( 338 | response: 'success', body: '{}', headers: {}, status_code: 200 339 | ) 340 | stub_fcm_send_to_topic_condition_request.should have_been_made.times(1) 341 | end 342 | 343 | context 'when topic_condition is invalid' do 344 | let(:topic_condition) { "'foo' in topics$" } 345 | 346 | it 'should raise error' do 347 | client.send_to_topic_condition(topic_condition, options) 348 | stub_fcm_send_to_topic_condition_request.should_not have_been_requested 349 | end 350 | end 351 | end 352 | 353 | describe "#get_instance_id_info" do 354 | subject(:get_info) { client.get_instance_id_info(registration_token, options) } 355 | 356 | let(:options) { nil } 357 | let(:base_uri) { "#{FCM::INSTANCE_ID_API}/iid/info" } 358 | let(:uri) { "#{base_uri}/#{registration_token}" } 359 | let(:registration_token) { "42" } 360 | 361 | context 'without options' do 362 | it 'calls info endpoint' do 363 | endpoint = stub_request(:get, uri).with(headers: mock_headers) 364 | get_info 365 | expect(endpoint).to have_been_requested 366 | end 367 | end 368 | 369 | context 'with detail option' do 370 | let(:uri) { "#{base_uri}/#{registration_token}?details=true" } 371 | let(:options) { { details: true } } 372 | 373 | it 'calls info endpoint' do 374 | endpoint = stub_request(:get, uri).with(headers: mock_headers) 375 | get_info 376 | expect(endpoint).to have_been_requested 377 | end 378 | end 379 | end 380 | 381 | describe "topic subscriptions" do 382 | let(:topic) { 'news' } 383 | let(:registration_token) { "42" } 384 | let(:registration_token_2) { "43" } 385 | let(:registration_tokens) { [registration_token, registration_token_2] } 386 | 387 | describe "#topic_subscription" do 388 | subject(:subscribe) { client.topic_subscription(topic, registration_token) } 389 | 390 | let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1/#{registration_token}/rel/topics/#{topic}" } 391 | 392 | it 'subscribes to a topic' do 393 | endpoint = stub_request(:post, uri).with(headers: mock_headers) 394 | subscribe 395 | expect(endpoint).to have_been_requested 396 | end 397 | end 398 | 399 | describe "#topic_unsubscription" do 400 | subject(:unsubscribe) { client.topic_unsubscription(topic, registration_token) } 401 | 402 | let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchRemove" } 403 | let(:params) { { to: "/topics/#{topic}", registration_tokens: [registration_token] } } 404 | 405 | it 'unsubscribes from a topic' do 406 | endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) 407 | unsubscribe 408 | expect(endpoint).to have_been_requested 409 | end 410 | end 411 | 412 | describe "#batch_topic_subscription" do 413 | subject(:batch_subscribe) { client.batch_topic_subscription(topic, registration_tokens) } 414 | 415 | let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchAdd" } 416 | let(:params) { { to: "/topics/#{topic}", registration_tokens: registration_tokens } } 417 | 418 | it 'subscribes to a topic' do 419 | endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) 420 | batch_subscribe 421 | expect(endpoint).to have_been_requested 422 | end 423 | end 424 | 425 | describe "#batch_topic_unsubscription" do 426 | subject(:batch_unsubscribe) { client.batch_topic_unsubscription(topic, registration_tokens) } 427 | 428 | let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchRemove" } 429 | let(:params) { { to: "/topics/#{topic}", registration_tokens: registration_tokens } } 430 | 431 | it 'unsubscribes from a topic' do 432 | endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) 433 | batch_unsubscribe 434 | expect(endpoint).to have_been_requested 435 | end 436 | end 437 | end 438 | end 439 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'webmock/rspec' 4 | 5 | require 'fcm' 6 | 7 | RSpec.configure do |config| 8 | config.run_all_when_everything_filtered = true 9 | config.expect_with :rspec do |c| 10 | c.syntax = [:should, :expect] 11 | end 12 | # config.filter_run :focus 13 | end 14 | --------------------------------------------------------------------------------