diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..4e8f30c --- /dev/null +++ b/.hound.yml @@ -0,0 +1,3 @@ +rubocop: + config_file: .rubocop.yml +fail_on_violations: true \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..eac73aa --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,37 @@ +# default rule used by hound +# @see https://github.com/houndci/hound/blob/master/.rubocop.yml +require: rubocop-rspec + +AllCops: + Exclude: + - spec/spec_helper.rb + - vendor/**/* + - fcm.gemspec + - Gemfile + - Rakefile + - lib/fcm.rb + +Metrics/LineLength: + Description: 'Limit lines to 85 characters.' + StyleGuide: '#80-character-limits' + Max: 85 + Enabled: true + +Metrics/MethodLength: + Description: 'Avoid methods longer than 25 lines of code.' + StyleGuide: '#short-methods' + Max: 25 + Enabled: true + +Metrics/BlockLength: + Exclude: + - spec/fcm_spec.rb + +RSpec/MultipleMemoizedHelpers: + Max: 10 + +RSpec/ExampleLength: + Max: 15 + +RSpec/MultipleMemoizedHelpers: + Max: 15 diff --git a/Gemfile b/Gemfile index f0e87c4..737a69e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,19 @@ -source "https://rubygems.org" +source 'https://rubygems.org' gemspec -gem 'rake' -gem 'rspec' -gem 'webmock' -gem 'ci_reporter_rspec' +gem 'faraday' +gem 'faraday-retry' +gem 'faraday-typhoeus' gem 'googleauth' +gem 'rake' + +group :development, :test do + gem 'ci_reporter_rspec' + gem 'pry-byebug' + gem 'rspec' + gem 'rubocop' + gem 'rubocop-rspec', require: false + gem 'simplecov', require: false + gem 'webmock' + gem 'yard' +end diff --git a/Rakefile b/Rakefile index ebcf703..8ead2cb 100644 --- a/Rakefile +++ b/Rakefile @@ -1,9 +1,9 @@ require 'rspec/core/rake_task' -require "bundler/gem_tasks" -require "rake/tasklib" +require 'bundler/gem_tasks' +require 'rake/tasklib' require 'ci/reporter/rake/rspec' -RSpec::Core::RakeTask.new(:spec => ["ci:setup:rspec"]) do |t| +RSpec::Core::RakeTask.new(spec: ['ci:setup:rspec']) do |t| t.pattern = 'spec/**/*_spec.rb' end @@ -12,4 +12,4 @@ RSpec::Core::RakeTask.new(:spec) do |spec| spec.rspec_opts = ['--format documentation'] end -task :default => :spec +task default: :spec diff --git a/fcm.gemspec b/fcm.gemspec index 05a211e..8dee0d6 100644 --- a/fcm.gemspec +++ b/fcm.gemspec @@ -1,24 +1,24 @@ # -*- encoding: utf-8 -*- -$:.push File.expand_path("../lib", __FILE__) +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require 'fcm/version' Gem::Specification.new do |s| - s.name = "fcm" - s.version = "1.0.8" + s.name = 'fcm' + s.version = Fcm::VERSION s.platform = Gem::Platform::RUBY - s.authors = ["Kashif Rasul", "Shoaib Burq"] - s.email = ["kashif@decision-labs.com", "shoaib@decision-labs.com"] - s.homepage = "https://github.com/decision-labs/fcm" + s.authors = ['Kashif Rasul', 'Shoaib Burq'] + s.email = ['kashif@decision-labs.com', 'shoaib@decision-labs.com'] + s.homepage = 'https://github.com/decision-labs/fcm' s.summary = %q{Reliably deliver messages and notifications via FCM} 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.} - s.license = "MIT" - - s.required_ruby_version = ">= 2.4.0" + s.license = 'MIT' - s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } - s.require_paths = ["lib"] + s.required_ruby_version = '>= 2.4.0' - s.add_runtime_dependency("faraday", ">= 1.0.0", "< 3.0") - s.add_runtime_dependency("googleauth", "~> 1") + s.files = `git ls-files`.split('\n') + s.test_files = `git ls-files -- {test,spec,features}/*`.split('\n') + s.executables = `git ls-files -- bin/*`.split('\n').map { |f| File.basename(f) } + s.require_paths = ['lib'] end diff --git a/lib/fcm.rb b/lib/fcm.rb index 08980ce..02e4d26 100644 --- a/lib/fcm.rb +++ b/lib/fcm.rb @@ -1,329 +1,225 @@ -require "faraday" -require "cgi" -require "json" -require "googleauth" +# frozen_string_literal: true + +require 'googleauth' +require 'fcm/client' +require 'fcm/client_v1' class FCM - BASE_URI = "https://fcm.googleapis.com" - BASE_URI_V1 = "https://fcm.googleapis.com/v1/projects/" - DEFAULT_TIMEOUT = 30 + BASE_URI = Fcm::Client::BASE_URI + BASE_URI_V1 = Fcm::ClientV1::BASE_URI_V1 + DEFAULT_TIMEOUT = Fcm::Connection::DEFAULT_TIMEOUT - GROUP_NOTIFICATION_BASE_URI = "https://android.googleapis.com" - INSTANCE_ID_API = "https://iid.googleapis.com" - TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/ + GROUP_NOTIFICATION_BASE_URI = Fcm::Client::GROUP_NOTIFICATION_BASE_URI + INSTANCE_ID_API = Fcm::Client::INSTANCE_ID_API - def initialize(api_key, json_key_path = "", project_name = "", client_options = {}) + def initialize(api_key, json_key_path = '', project_name = '') @api_key = api_key - @client_options = client_options @json_key_path = json_key_path @project_name = project_name end - # See https://firebase.google.com/docs/cloud-messaging/send-message - # { - # "token": "4sdsx", - # "notification": { - # "title": "Breaking News", - # "body": "New news story available." - # }, - # "data": { - # "story_id": "story_12345" - # }, - # "android": { + # @param message [Hash] message hash + # @see https://firebase.google.com/docs/cloud-messaging/send-message + # @example + # + # message = { + # "token": "4sdsx", # "notification": { - # "click_action": "TOP_STORY_ACTIVITY", - # "body": "Check out the Top Story" - # } - # }, - # "apns": { - # "payload": { - # "aps": { - # "category" : "NEW_MESSAGE_CATEGORY" + # "title": "Breaking News", + # "body": "New news story available." + # }, + # "data": { + # "story_id": "story_12345" + # }, + # "android": { + # "notification": { + # "click_action": "TOP_STORY_ACTIVITY", + # "body": "Check out the Top Story" + # } + # }, + # "apns": { + # "payload": { + # "aps": { + # "category" : "NEW_MESSAGE_CATEGORY" + # } # } # } # } - # } - # fcm = FCM.new(api_key, json_key_path, project_name) - # fcm.send_v1( - # { "token": "4sdsx",, "to" : "notification": {}.. } - # ) + # + # fcm = FCM.new(api_key, json_key_path, project_name) + # + # fcm.send_v1(message) + # + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::ClientV1#send_notification_v1} instead def send_notification_v1(message) - return if @project_name.empty? + warn '[DEPRECATION] `FCM#send_notification_v1` will be deprecated.'\ + 'Use`Fcm::ClientV1.new(json_key_path).send_notification_v1` instead.' - post_body = { 'message': message } - extra_headers = { - 'Authorization' => "Bearer #{jwt_token}" - } - for_uri(BASE_URI_V1, extra_headers) do |connection| - response = connection.post( - "#{@project_name}/messages:send", post_body.to_json - ) - build_response(response) - end + Fcm::ClientV1.new(@json_key_path).send_notification_v1( + message, @project_name + ) end - alias send_v1 send_notification_v1 - # See https://developers.google.com/cloud-messaging/http for more details. - # { "notification": { - # "title": "Portugal vs. Denmark", - # "text": "5 to 1" - # }, - # "to" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..." - # } - # fcm = FCM.new("API_KEY") - # fcm.send( - # ["4sdsx", "8sdsd"], # registration_ids - # { "notification": { "title": "Portugal vs. Denmark", "text": "5 to 1" }, "to" : "bk3RNwTe3HdFQ3P1..." } - # ) + # @see https://developers.google.com/cloud-messaging/http + # @example + # message = { "notification": { + # "title": "Portugal vs. Denmark", + # "text": "5 to 1" + # }, + # "to" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..." + # } + # fcm = FCM.new("API_KEY") + # fcm.send( + # ["4sdsx", "8sdsd"], # registration_ids + # message + # ) + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#send_notification_v1} instead def send_notification(registration_ids, options = {}) - post_body = build_post_body(registration_ids, options) - - for_uri(BASE_URI) do |connection| - response = connection.post("/fcm/send", post_body.to_json) - build_response(response, registration_ids) - end + deprecate_warning(:send_notification) + Fcm::Client.new(@api_key).send_notification(registration_ids, options) end alias send send_notification + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#create_notification_key} instead def create_notification_key(key_name, project_id, registration_ids = []) - post_body = build_post_body(registration_ids, operation: "create", - notification_key_name: key_name) - - extra_headers = { - "project_id" => project_id, - } - - for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| - response = connection.post("/gcm/notification", post_body.to_json) - build_response(response) - end + deprecate_warning(:create_notification_key) + Fcm::Client.new(@api_key).create_notification_key( + key_name, project_id, registration_ids + ) end alias create create_notification_key + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#add_registration_ids} instead def add_registration_ids(key_name, project_id, notification_key, registration_ids) - post_body = build_post_body(registration_ids, operation: "add", - notification_key_name: key_name, - notification_key: notification_key) - - extra_headers = { - "project_id" => project_id, - } - - for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| - response = connection.post("/gcm/notification", post_body.to_json) - build_response(response) - end + deprecate_warning(:add_registration_ids) + Fcm::Client.new(@api_key).add_registration_ids( + key_name, project_id, notification_key, registration_ids + ) end alias add add_registration_ids + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#remove_registration_ids} instead def remove_registration_ids(key_name, project_id, notification_key, registration_ids) - post_body = build_post_body(registration_ids, operation: "remove", - notification_key_name: key_name, - notification_key: notification_key) - - extra_headers = { - "project_id" => project_id, - } - - for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| - response = connection.post("/gcm/notification", post_body.to_json) - build_response(response) - end + deprecate_warning(:remove_registration_ids) + Fcm::Client.new(@api_key).remove_registration_ids( + key_name, project_id, notification_key, registration_ids + ) end alias remove remove_registration_ids + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#recover_notification_key} instead def recover_notification_key(key_name, project_id) - params = { notification_key_name: key_name } - - extra_headers = { - "project_id" => project_id, - } - - for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| - response = connection.get("/gcm/notification", params) - build_response(response) - end + deprecate_warning(:recover_notification_key) + Fcm::Client.new(@api_key).recover_notification_key(key_name, project_id) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#send_with_notification_key} instead def send_with_notification_key(notification_key, options = {}) - body = { to: notification_key }.merge(options) - execute_notification(body) + deprecate_warning(:send_with_notification_key) + Fcm::Client.new(@api_key).send_with_notification_key( + notification_key, options + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#topic_subscription} instead def topic_subscription(topic, registration_id) - for_uri(INSTANCE_ID_API) do |connection| - response = connection.post("/iid/v1/#{registration_id}/rel/topics/#{topic}") - build_response(response) - end + deprecate_warning(:topic_subscription) + Fcm::Client.new(@api_key).topic_subscription(topic, registration_id) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#batch_topic_subscription} instead def batch_topic_subscription(topic, registration_ids) - manage_topics_relationship(topic, registration_ids, "Add") + deprecate_warning(:batch_topic_subscription) + Fcm::Client.new(@api_key).batch_topic_subscription( + topic, registration_ids + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#batch_topic_unsubscription} instead def batch_topic_unsubscription(topic, registration_ids) - manage_topics_relationship(topic, registration_ids, "Remove") - end - - def manage_topics_relationship(topic, registration_ids, action) - body = { to: "/topics/#{topic}", registration_tokens: registration_ids } - - for_uri(INSTANCE_ID_API) do |connection| - response = connection.post("/iid/v1:batch#{action}", body.to_json) - build_response(response) - end + deprecate_warning(:batch_topic_unsubscription) + Fcm::Client.new(@api_key).batch_topic_unsubscription( + topic, registration_ids + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#send_to_topic} instead def send_to_topic(topic, options = {}) - if topic.gsub(TOPIC_REGEX, "").length == 0 - send_with_notification_key("/topics/" + topic, options) - end + deprecate_warning(:send_to_topic) + Fcm::Client.new(@api_key).send_to_topic(topic, options) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#get_instance_id_info} instead def get_instance_id_info(iid_token, options = {}) - params = options - - for_uri(INSTANCE_ID_API) do |connection| - response = connection.get("/iid/info/" + iid_token, params) - build_response(response) - end + deprecate_warning(:get_instance_id_info) + Fcm::Client.new(@api_key).get_instance_id_info(iid_token, options) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#subscribe_instance_id_to_topic} instead def subscribe_instance_id_to_topic(iid_token, topic_name) - batch_subscribe_instance_ids_to_topic([iid_token], topic_name) + deprecate_warning(:subscribe_instance_id_to_topic) + Fcm::Client.new(@api_key).subscribe_instance_id_to_topic( + iid_token, topic_name + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#unsubscribe_instance_id_from_topic} instead def unsubscribe_instance_id_from_topic(iid_token, topic_name) - batch_unsubscribe_instance_ids_from_topic([iid_token], topic_name) + deprecate_warning(:unsubscribe_instance_id_from_topic) + Fcm::Client.new(@api_key).unsubscribe_instance_id_from_topic( + iid_token, topic_name + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#batch_subscribe_instance_ids_to_topic} instead def batch_subscribe_instance_ids_to_topic(instance_ids, topic_name) - manage_topics_relationship(topic_name, instance_ids, "Add") + deprecate_warning(:batch_subscribe_instance_ids_to_topic) + Fcm::Client.new(@api_key).batch_subscribe_instance_ids_to_topic( + instance_ids, topic_name + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#batch_unsubscribe_instance_ids_from_topic} instead def batch_unsubscribe_instance_ids_from_topic(instance_ids, topic_name) - manage_topics_relationship(topic_name, instance_ids, "Remove") + deprecate_warning(:batch_unsubscribe_instance_ids_from_topic) + Fcm::Client.new(@api_key).batch_unsubscribe_instance_ids_from_topic( + instance_ids, topic_name + ) end + # @return [Fcm::Response] a custom fcm response hash + # @deprecated Use {Fcm::Client#send_to_topic_condition} instead def send_to_topic_condition(condition, options = {}) - if validate_condition?(condition) - body = { condition: condition }.merge(options) - execute_notification(body) - end + deprecate_warning(:send_to_topic_condition) + Fcm::Client.new(@api_key).send_to_topic_condition(condition, options) end private - def for_uri(uri, extra_headers = {}) - connection = ::Faraday.new( - url: uri, - request: { timeout: DEFAULT_TIMEOUT } - ) do |faraday| - faraday.adapter Faraday.default_adapter - faraday.headers["Content-Type"] = "application/json" - faraday.headers['Authorization'] = "key=#{@api_key}" - extra_headers.each do |key, value| - faraday.headers[key] = value - end - end - yield connection - end - - def build_post_body(registration_ids, options = {}) - ids = registration_ids.is_a?(String) ? [registration_ids] : registration_ids - { registration_ids: ids }.merge(options) - end - - def build_response(response, registration_ids = []) - body = response.body || {} - response_hash = { body: body, headers: response.headers, status_code: response.status } - case response.status - when 200 - response_hash[:response] = "success" - body = JSON.parse(body) unless body.empty? - response_hash[:canonical_ids] = build_canonical_ids(body, registration_ids) unless registration_ids.empty? - response_hash[:not_registered_ids] = build_not_registered_ids(body, registration_ids) unless registration_ids.empty? - when 400 - response_hash[:response] = "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, or it contained invalid fields." - when 401 - response_hash[:response] = "There was an error authenticating the sender account." - when 503 - response_hash[:response] = "Server is temporarily unavailable." - when 500..599 - response_hash[:response] = "There was an internal error in the FCM server while trying to process the request." - end - response_hash - end - - def build_canonical_ids(body, registration_ids) - canonical_ids = [] - unless body.empty? - if body["canonical_ids"] > 0 - body["results"].each_with_index do |result, index| - canonical_ids << { old: registration_ids[index], new: result["registration_id"] } if has_canonical_id?(result) - end - end - end - canonical_ids - end - - def build_not_registered_ids(body, registration_id) - not_registered_ids = [] - unless body.empty? - if body["failure"] > 0 - body["results"].each_with_index do |result, index| - not_registered_ids << registration_id[index] if is_not_registered?(result) - end - end - end - not_registered_ids - end - - def execute_notification(body) - for_uri(BASE_URI) do |connection| - response = connection.post("/fcm/send", body.to_json) - build_response(response) - end - end - - def has_canonical_id?(result) - !result["registration_id"].nil? - end - - def is_not_registered?(result) - result["error"] == "NotRegistered" - end - - def validate_condition?(condition) - validate_condition_format?(condition) && validate_condition_topics?(condition) - end - - def validate_condition_format?(condition) - bad_characters = condition.gsub( - /(topics|in|\s|\(|\)|(&&)|[!]|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/, - "" - ) - bad_characters.length == 0 - end - - def validate_condition_topics?(condition) - topics = condition.scan(/(?:^|\S|\s)'([^']*?)'(?:$|\S|\s)/).flatten - topics.all? { |topic| topic.gsub(TOPIC_REGEX, "").length == 0 } - end - - def jwt_token - scope = "https://www.googleapis.com/auth/firebase.messaging" - @authorizer ||= Google::Auth::ServiceAccountCredentials.make_creds( - json_key_io: json_key, - scope: scope, - ) - token = @authorizer.fetch_access_token! - token["access_token"] + def deprecate_warning(method) + warn "[DEPRECATION] `FCM##{method} will be deprecated." \ + "Please use `Fcm::Client.new(api_key).#{method}` instead." end def json_key diff --git a/lib/fcm/client.rb b/lib/fcm/client.rb new file mode 100644 index 0000000..0dca147 --- /dev/null +++ b/lib/fcm/client.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'fcm/connection' +require 'fcm/response' +require 'fcm/client/notification_delivery' +require 'fcm/client/notification_setting' +require 'fcm/client/instance_topic_management' + +module Fcm + # Fcm Client class for legacy http protocol API connections + # + # @see https://firebase.google.com/docs/cloud-messaging/http-server-ref + class Client + include Fcm::Connection + include Fcm::Response + include Fcm::Client::NotificationDilivery + include Fcm::Client::NotificationSetting + include Fcm::Client::InstanceTopicManagement + + # @see https://firebase.google.com/docs/projects/api-keys + # @param api_key [String] Firebase API key + # @return [Client] client instance + def initialize(api_key) + @api_key = api_key + end + + private + + def authorization_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "key=#{@api_key}" + } + end + end +end diff --git a/lib/fcm/client/instance_topic_management.rb b/lib/fcm/client/instance_topic_management.rb new file mode 100644 index 0000000..7b69269 --- /dev/null +++ b/lib/fcm/client/instance_topic_management.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Fcm + class Client + # A Fcm Client class to handle notification setting methods + module InstanceTopicManagement + INSTANCE_ID_API = 'https://iid.googleapis.com' + + def manage_topics_relationship(topic, registration_ids, action) + body = { to: "/topics/#{topic}", registration_tokens: registration_ids } + end_point = "/iid/v1:batch#{action}" + res = make_request( + :post, INSTANCE_ID_API, end_point, body, authorization_headers + ) + Fcm::Response.build_fcm_response(res, registration_ids) + end + + def topic_subscription(topic, registration_id) + end_point = "/iid/v1/#{registration_id}/rel/topics/#{topic}" + res = make_request( + :post, INSTANCE_ID_API, end_point, nil, authorization_headers + ) + Fcm::Response.build_fcm_response(res) + end + + def get_instance_id_info(iid_token, options = {}) + params = options + end_point = "/iid/info/#{iid_token}" + res = make_request( + :get, INSTANCE_ID_API, end_point, params, authorization_headers + ) + Fcm::Response.build_fcm_response(res) + end + + def batch_topic_subscription(topic, registration_ids) + manage_topics_relationship(topic, registration_ids, 'Add') + end + + def batch_topic_unsubscription(topic, registration_ids) + manage_topics_relationship(topic, registration_ids, 'Remove') + end + + def batch_subscribe_instance_ids_to_topic(instance_ids, topic_name) + manage_topics_relationship(topic_name, instance_ids, 'Add') + end + + def batch_unsubscribe_instance_ids_from_topic(instance_ids, topic_name) + manage_topics_relationship(topic_name, instance_ids, 'Remove') + end + + def subscribe_instance_id_to_topic(iid_token, topic_name) + batch_subscribe_instance_ids_to_topic([iid_token], topic_name) + end + + def unsubscribe_instance_id_from_topic(iid_token, topic_name) + batch_unsubscribe_instance_ids_from_topic([iid_token], topic_name) + end + end + end +end diff --git a/lib/fcm/client/notification_delivery.rb b/lib/fcm/client/notification_delivery.rb new file mode 100644 index 0000000..13bd6b9 --- /dev/null +++ b/lib/fcm/client/notification_delivery.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Fcm + class Client + # Handle notification delivery methods + module NotificationDilivery + BASE_URI = 'https://fcm.googleapis.com' + END_POINT = '/fcm/send' + + def send_notification(registration_ids, options = {}) + post_body = build_post_body(registration_ids, options) + res = make_request( + :post, BASE_URI, END_POINT, post_body, authorization_headers + ) + Fcm::Response.build_fcm_response(res, registration_ids) + end + + def send_with_notification_key(notification_key, options = {}) + body = { to: notification_key }.merge(options) + res = make_request( + :post, BASE_URI, END_POINT, body, authorization_headers + ) + Fcm::Response.build_fcm_response(res) + end + + def send_to_topic_condition(condition, options = {}) + return unless validate_condition?(condition) + + body = { condition: condition }.merge(options) + + res = make_request( + :post, BASE_URI, END_POINT, body, authorization_headers + ) + Fcm::Response.build_fcm_response(res) + end + + def send_to_topic(topic, options = {}) + return unless topic.gsub(/[a-zA-Z0-9\-_.~%]+/, '').length.zero? + + send_with_notification_key("/topics/#{topic}", options) + end + + private + + def validate_condition?(condition) + validate_condition_format?( + condition + ) && validate_condition_topics?( + condition + ) + end + + def validate_condition_format?(condition) + bad_characters = condition.gsub( + /(topics|in|\s|\(|\)|(&&)|!|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/, + '' + ) + bad_characters.length.zero? + end + + def validate_condition_topics?(condition) + topics = condition.scan(/(?:^|\S|\s)'([^']*?)'(?:$|\S|\s)/).flatten + topics.all? { |topic| topic.gsub(/[a-zA-Z0-9\-_.~%]+/, '').length.zero? } + end + end + end +end diff --git a/lib/fcm/client/notification_setting.rb b/lib/fcm/client/notification_setting.rb new file mode 100644 index 0000000..eadd57e --- /dev/null +++ b/lib/fcm/client/notification_setting.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Fcm + class Client + # A Fcm Client class to handle notification setting methods + module NotificationSetting + GROUP_NOTIFICATION_BASE_URI = 'https://android.googleapis.com' + END_POINT = '/gcm/notification' + + def create_notification_key(key_name, project_id, registration_ids = []) + post_body = build_post_body( + registration_ids, + operation: 'create', + notification_key_name: key_name + ) + res = make_request( + :post, + GROUP_NOTIFICATION_BASE_URI, + END_POINT, + post_body, + authorization_headers.merge('project_id' => project_id) + ) + Fcm::Response.build_fcm_response(res) + end + + def add_registration_ids(key_name, project_id, notification_key, register_ids) + post_body = build_post_body( + register_ids, + operation: 'add', + notification_key_name: key_name, + notification_key: notification_key + ) + res = make_request( + :post, + GROUP_NOTIFICATION_BASE_URI, + END_POINT, + post_body, + authorization_headers.merge('project_id' => project_id) + ) + Fcm::Response.build_fcm_response(res) + end + + def remove_registration_ids(key_name, project_id, notif_key, register_ids) + post_body = build_post_body( + register_ids, + operation: 'remove', + notification_key_name: key_name, + notification_key: notif_key + ) + res = make_request( + :post, + GROUP_NOTIFICATION_BASE_URI, + END_POINT, + post_body, + authorization_headers.merge('project_id' => project_id) + ) + Fcm::Response.build_fcm_response(res) + end + + def recover_notification_key(key_name, project_id) + params = { notification_key_name: key_name } + res = make_request( + :get, + GROUP_NOTIFICATION_BASE_URI, + END_POINT, + params, + authorization_headers.merge('project_id' => project_id) + ) + Fcm::Response.build_fcm_response(res) + end + + private + + def build_post_body(registration_ids, options = {}) + ids = if registration_ids.is_a?(String) + [registration_ids] + else + registration_ids + end + { registration_ids: ids }.merge(options) + end + end + end +end diff --git a/lib/fcm/client_v1.rb b/lib/fcm/client_v1.rb new file mode 100644 index 0000000..7f57eae --- /dev/null +++ b/lib/fcm/client_v1.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'fcm/connection' +require 'fcm/response' +require 'fcm/client_v1/notification_delivery' + +module Fcm + # Fcm Client class for http v1 protocol API connections + # + # @see https://firebase.google.com/docs/cloud-messaging/migrate-v1 + class ClientV1 + include Fcm::Connection + include Fcm::Response + include Fcm::ClientV1::NotificationDilivery + + TOKEN_URI = 'https://www.googleapis.com/auth/firebase.messaging' + + # @see https://firebase.google.com/docs/projects/provisioning/configure-oauth#auth + # @param json_key_path [String] file path to service_account_key.json + # @return [ClientV1] client_v1 instance + def initialize(json_key_path) + @json_key_path = json_key_path + end + + private + + def authorization_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{jwt_token}" + } + end + + def jwt_token + @authorizer ||= Google::Auth::ServiceAccountCredentials.make_creds( + json_key_io: json_key, + scope: TOKEN_URI + ) + token = @authorizer.fetch_access_token! + token['access_token'] + end + + def json_key + @json_key ||= if @json_key_path.respond_to?(:read) + @json_key_path + else + File.open(@json_key_path) + end + end + end +end diff --git a/lib/fcm/client_v1/notification_delivery.rb b/lib/fcm/client_v1/notification_delivery.rb new file mode 100644 index 0000000..dbd3687 --- /dev/null +++ b/lib/fcm/client_v1/notification_delivery.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Fcm + class ClientV1 + # Handle notification delivery methods + module NotificationDilivery + BASE_URI_V1 = 'https://fcm.googleapis.com/v1/projects/' + TOKEN_URI = 'https://www.googleapis.com/auth/firebase.messaging' + + def send_notification_v1(message, project_name) + return if project_name.empty? + + post_body = { 'message': message } + end_point = "#{project_name}/messages:send" + + res = make_request( + :post, BASE_URI_V1, end_point, post_body, authorization_headers + ) + Fcm::Response.build_fcm_response(res) + end + end + end +end diff --git a/lib/fcm/connection.rb b/lib/fcm/connection.rb new file mode 100644 index 0000000..568bd26 --- /dev/null +++ b/lib/fcm/connection.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'faraday' +require 'faraday/retry' +require 'faraday/typhoeus' + +module Fcm + # underhood http client using faraday with typhoeus adapter + module Connection + DEFAULT_TIMEOUT = 30 + + def make_request(method, base_uri, end_point, body, headers) + url = "#{base_uri}#{end_point}" + agent.send(method, url, body, headers) + end + + private + + def agent + @agent ||= Faraday.new( + request: { timeout: DEFAULT_TIMEOUT } + ) do |builder| + builder.request :retry, exceptions: [ + Faraday::TimeoutError, Faraday::ConnectionFailed + ] + builder.request :json + builder.response :json + builder.adapter :typhoeus + end + end + end +end diff --git a/lib/fcm/error.rb b/lib/fcm/error.rb new file mode 100644 index 0000000..681178f --- /dev/null +++ b/lib/fcm/error.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Fcm + # Custom Fcm errors + class Error < StandardError + ERROR_RESPONSE_400 = 'Only applies for JSON requests.'\ + ' Indicates that the request could not be parsed as JSON,'\ + ' or it contained invalid fields.' + + ERROR_RESPONSE_401 = 'There was an error authenticating'\ + ' the sender account.' + + ERROR_RESPONSE_503 = 'Server is temporarily unavailable.' + + ERROR_RESPONSE_50X = 'There was an internal error in the'\ + ' FCM server while trying to process the request.' + end + + # Raised on errors in the 400-499 range + class ClientError < Error; end + + # Raised when Faraday returns a 400 HTTP status code + class BadRequest < ClientError; end + + # Raised when Faraday returns a 401 HTTP status code + class Unauthorized < ClientError; end + + # Raised on errors in the 500-599 range + class ServerError < Error; end + + # Raised when Faraday returns a 503 HTTP status code + class ServiceUnavailable < ServerError; end +end diff --git a/lib/fcm/response.rb b/lib/fcm/response.rb new file mode 100644 index 0000000..5f91cee --- /dev/null +++ b/lib/fcm/response.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'fcm/error' + +module Fcm + # Custome Fcm response + module Response + class << self + # Converts the faraday response into a custom fcm response + # + # @param response [Faraday::Response] a faraday response object + # @return [Hash] a custom fcm response hash + def build_fcm_response(response, registration_ids = []) + return success_response(response, registration_ids) if response.success? + + failure_response(response) + end + + private + + def success_response(response, registration_ids) + body = response.body || {} + response_hash = { + body: body, + headers: response.headers, + status_code: response.status, + response: 'success' + } + return response_hash if registration_ids.empty? + + body = JSON.parse(body) unless body.empty? + response_hash[:canonical_ids] = build_canonical_ids( + body, registration_ids + ) + response_hash[:not_registered_ids] = build_not_registered_ids( + body, registration_ids + ) + response_hash + end + + def failure_response(response) + body = response.body || {} + response_hash = { + body: body, + headers: response.headers, + status_code: response.status + } + + response_hash[:response] = case response.status.to_i + when 400 + Fcm::Error::ERROR_RESPONSE_400 + when 401 + Fcm::Error::ERROR_RESPONSE_401 + when 503 + Fcm::Error::ERROR_RESPONSE_503 + when 500..599 + Fcm::Error::ERROR_RESPONSE_50X + end + response_hash + end + + def build_canonical_ids(body, registration_ids) + canonical_ids = [] + return canonical_ids if body.empty? || body['canonical_ids'] <= 0 + + body['results'].each_with_index do |result, index| + return canonical_ids unless canonical_id?(result) + + canonical_ids << { + old: registration_ids[index], new: result['registration_id'] + } + end + canonical_ids + end + + def build_not_registered_ids(body, registration_id) + not_registered_ids = [] + return not_registered_ids if body.empty? + + if body['failure'].positive? + body['results'].each_with_index do |result, index| + not_registered_ids << registration_id[index] if not_registered?(result) + end + end + not_registered_ids + end + + def canonical_id?(result) + !result['registration_id'].nil? + end + + def not_registered?(result) + result['error'] == 'NotRegistered' + end + end + end +end diff --git a/lib/fcm/version.rb b/lib/fcm/version.rb new file mode 100644 index 0000000..2402a80 --- /dev/null +++ b/lib/fcm/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Fcm + VERSION = '2.0.0' +end diff --git a/spec/fcm_spec.rb b/spec/fcm_spec.rb index e976433..2bafefe 100644 --- a/spec/fcm_spec.rb +++ b/spec/fcm_spec.rb @@ -1,354 +1,214 @@ -require "spec_helper" +# frozen_string_literal: true + +require 'spec_helper' describe FCM do + let(:api_key) { 'AIzaSyB-1uEai2WiUapxCs2Q0GZYzPu7Udno5aA' } + let(:fcm) { described_class.new(api_key) } let(:send_url) { "#{FCM::BASE_URI}/fcm/send" } - let(:group_notification_base_uri) { "#{FCM::GROUP_NOTIFICATION_BASE_URI}/gcm/notification" } - let(:api_key) { "AIzaSyB-1uEai2WiUapxCs2Q0GZYzPu7Udno5aA" } - let(:registration_id) { "42" } - let(:registration_ids) { ["42"] } - let(:key_name) { "appUser-Chris" } - let(:project_id) { "123456789" } # https://developers.google.com/cloud-messaging/gcm#senderid - let(:notification_key) { "APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ" } - let(:valid_topic) { "TopicA" } - let(:invalid_topic) { "TopicA$" } - let(:valid_condition) { "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" } - let(:invalid_condition) { "'TopicA' in topics and some other text ('TopicB' in topics || 'TopicC' in topics)" } - let(:invalid_condition_topic) { "'TopicA$' in topics" } - - it "should raise an error if the api key is not provided" do - expect { FCM.new }.to raise_error(ArgumentError) + let(:valid_condition) do + "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" end - - it "should raise error if time_to_live is given" do - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#ttl + let(:valid_request_headers) do + { + 'Content-Type' => 'application/json', + 'Authorization' => "key=#{api_key}" + } end + let(:registration_id) { '42' } - describe "#send_v1" do - let(:project_name) { "project_name" } - let(:send_v1_url) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } - let(:access_token) { "access_token" } - let(:valid_request_v1_headers) do - { - "Content-Type" => "application/json", - "Authorization" => "Bearer #{access_token}", - } - end + it 'raises an error if the api key is not provided' do + expect { described_class.new }.to raise_error(ArgumentError) + end - let(:send_v1_params) do + describe '#send_notification' do + let(:mock_request_attributes) do { - 'token' => '4sdsx', - 'notification' => { - 'title' => 'Breaking News', - 'body' => 'New news story available.' - }, - 'data' => { - 'story_id' => 'story_12345' - }, - 'android' => { - 'notification' => { - 'click_action' => 'TOP_STORY_ACTIVITY', - 'body' => 'Check out the Top Story' - } - }, - 'apns' => { - 'payload' => { - 'aps' => { - 'category' => 'NEW_MESSAGE_CATEGORY' - } - } - } + body: valid_request_body.to_json, + headers: valid_request_headers } end - - let(:valid_request_v1_body) do - { 'message' => send_v1_params } - end - - let(:stub_fcm_send_v1_request) do - stub_request(:post, send_v1_url).with( - body: valid_request_v1_body.to_json, - headers: valid_request_v1_headers + let(:stub_fcm_send_request) do + stub_request(:post, send_url).with( + mock_request_attributes ).to_return( # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", + body: '{}', headers: {}, - status: 200, + status: 200 ) end - let(:authorizer_double) { double("token_fetcher") } - let(:json_key_path) { double("file alike object") } - - before do - expect(json_key_path).to receive(:respond_to?).and_return(true) - expect(Google::Auth::ServiceAccountCredentials).to receive_message_chain(:make_creds).and_return(authorizer_double) - expect(authorizer_double).to receive(:fetch_access_token!).and_return({ "access_token" => access_token }) - stub_fcm_send_v1_request - end - - it 'should send notification of HTTP V1 using POST to FCM server' do - fcm = FCM.new(api_key, json_key_path, project_name) - fcm.send_v1(send_v1_params).should eq( - response: 'success', body: '{}', headers: {}, status_code: 200 - ) - stub_fcm_send_v1_request.should have_been_made.times(1) - end - end - - describe "sending notification" do let(:valid_request_body) do - { registration_ids: registration_ids } - end - let(:valid_request_body_with_string) do - { registration_ids: registration_id } - end - let(:valid_request_headers) do - { - "Content-Type" => "application/json", - "Authorization" => "key=#{api_key}", - } + { registration_ids: [registration_id] } end - let(:stub_fcm_send_request) do - stub_request(:post, send_url).with( - body: valid_request_body.to_json, - headers: valid_request_headers, - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", + let(:successful_fcm_response) do + { + response: 'success', + body: '{}', headers: {}, - status: 200, - ) + status_code: 200, + canonical_ids: [], + not_registered_ids: [] + } end - let(:stub_fcm_send_request_with_string) do - stub_request(:post, send_url).with( - body: valid_request_body_with_string.to_json, - headers: valid_request_headers, - ).to_return( - body: "{}", - headers: {}, - status: 200, - ) - end + before { stub_fcm_send_request } - let(:stub_fcm_send_request_with_basic_auth) do - uri = URI.parse(send_url) - uri.user = "a" - uri.password = "b" - stub_request(:post, uri.to_s).to_return(body: "{}", headers: {}, status: 200) - end + context 'when registration_id provided as array' do + subject(:send_notification) { fcm.send([registration_id]) } - before(:each) do - stub_fcm_send_request - stub_fcm_send_request_with_string - stub_fcm_send_request_with_basic_auth + it 'sends notification successfully' do + expect(send_notification).to eq(successful_fcm_response) + stub_fcm_send_request.should have_been_requested + end end - it "should send notification using POST to FCM server" do - fcm = FCM.new(api_key) - fcm.send(registration_ids).should eq(response: "success", body: "{}", headers: {}, status_code: 200, canonical_ids: [], not_registered_ids: []) - stub_fcm_send_request.should have_been_made.times(1) - end + context 'when registration_id provided as string' do + subject(:send_notification) { fcm.send(registration_id) } - it "should send notification using POST to FCM if id provided as string" do - fcm = FCM.new(api_key) - fcm.send(registration_id).should eq(response: "success", body: "{}", headers: {}, status_code: 200, canonical_ids: [], not_registered_ids: []) - stub_fcm_send_request.should have_been_made.times(1) + it 'sends notification successfully' do + expect(send_notification).to eq(successful_fcm_response) + stub_fcm_send_request.should have_been_requested + end end - context "send notification with data" do - let!(:stub_with_data) do - stub_request(:post, send_url) - .with(body: '{"registration_ids":["42"],"data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) - end - before do - end - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send(registration_ids, data: { score: "5x1", time: "15:10" }) - stub_with_data.should have_been_requested + context 'when send notification with data' do + subject(:send_notification_with_data) do + fcm.send( + [registration_id], data: { score: '5x1', time: '15:10' } + ) end - end - context "sending notification to a topic" do - let!(:stub_with_valid_topic) do + let(:stub_request_with_data) do stub_request(:post, send_url) - .with(body: '{"to":"/topics/TopicA","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + .with( + body: + '{"registration_ids":["42"],"data":{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) end - let!(:stub_with_invalid_topic) do - stub_request(:post, send_url) - .with(body: '{"condition":"/topics/TopicA$","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + + let(:successful_fcm_response) do + { + response: 'success', + body: '', + headers: {}, + status_code: 200, + canonical_ids: [], + not_registered_ids: [] + } end - describe "#send_to_topic" do - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send_to_topic(valid_topic, data: { score: "5x1", time: "15:10" }) - stub_with_valid_topic.should have_been_requested - end + before { stub_request_with_data } - it "should not send to invalid topics" do - fcm = FCM.new(api_key) - fcm.send_to_topic(invalid_topic, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_topic.should_not have_been_requested - end + it 'sends the data in a post request to fcm' do + expect(send_notification_with_data).to eq(successful_fcm_response) + stub_request_with_data.should have_been_requested end end - context "sending notification to a topic condition" do - let!(:stub_with_valid_condition) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA\' in topics && (\'TopicB\' in topics || \'TopicC\' in topics)","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + context 'with failure code 400' do + before do + stub_request(:post, send_url).with( + mock_request_attributes + ).to_return( + # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + body: '{}', + headers: {}, + status: 400 + ) end - let!(:stub_with_invalid_condition) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA\' in topics and some other text (\'TopicB\' in topics || \'TopicC\' in topics)","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + + it 'does not send notification due to 400' do + fcm.send([registration_id]).should eq( + body: '{}', + headers: {}, + response: Fcm::Error::ERROR_RESPONSE_400, + status_code: 400 + ) end - let!(:stub_with_invalid_condition_topic) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA$\' in topics","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + end + + context 'with failure code 401' do + before do + stub_request(:post, send_url).with( + mock_request_attributes + ).to_return( + # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + body: '{}', + headers: {}, + status: 401 + ) end - describe "#send_to_topic_condition" do - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(valid_condition, data: { score: "5x1", time: "15:10" }) - stub_with_valid_condition.should have_been_requested - end + it 'does not send notification due to 401' do + fcm.send([registration_id]).should eq( + body: '{}', + headers: {}, + response: Fcm::Error::ERROR_RESPONSE_401, + status_code: 401 + ) + end + end - it "should not send to invalid conditions" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(invalid_condition, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_condition.should_not have_been_requested - end + context 'with failure code 503' do + before do + stub_request(:post, send_url).with( + mock_request_attributes + ).to_return( + # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + body: '{}', + headers: {}, + status: 503 + ) + end - it "should not send to invalid topics in a condition" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(invalid_condition_topic, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_condition_topic.should_not have_been_requested - end + it 'does not send notification due to 503' do + fcm.send([registration_id]).should eq( + body: '{}', + headers: {}, + response: Fcm::Error::ERROR_RESPONSE_503, + status_code: 503 + ) end end - context "when send_notification responds with failure" do - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + context 'with failure code 5xx' do + before do + stub_request(:post, send_url).with( + mock_request_attributes + ).to_return( + # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + body: '{"body-key" => "Body value"}', + headers: { 'header-key' => 'Header value' }, + status: 599 + ) end - subject { FCM.new(api_key) } - - context "on failure code 400" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 400, - ) - end - it "should not send notification due to 400" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, or it contained invalid fields.", - status_code: 400) - end - end - - context "on failure code 401" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 401, - ) - end - - it "should not send notification due to 401" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "There was an error authenticating the sender account.", - status_code: 401) - end - end - - context "on failure code 503" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 503, - ) - end - - it "should not send notification due to 503" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "Server is temporarily unavailable.", - status_code: 503) - end - end - - context "on failure code 5xx" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: '{"body-key" => "Body value"}', - headers: { "header-key" => "Header value" }, - status: 599, - ) - end - - it "should not send notification due to 599" do - subject.send(registration_ids).should eq(body: '{"body-key" => "Body value"}', - headers: { "header-key" => "Header value" }, - response: "There was an internal error in the FCM server while trying to process the request.", - status_code: 599) - end - end - end - - context "when send_notification responds canonical_ids" do - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + it 'does not send notification due to 599' do + fcm.send([registration_id]).should eq( + body: '{"body-key" => "Body value"}', + headers: { 'Header-Key' => 'Header value' }, + response: Fcm::Error::ERROR_RESPONSE_50X, + status_code: 599 + ) end + end + context 'when send_notification responds canonical_ids' do let(:valid_response_body_with_canonical_ids) do { - failure: 0, canonical_ids: 1, results: [{ registration_id: "43", message_id: "0:1385025861956342%572c22801bb3" }], + failure: 0, canonical_ids: 1, results: [ + { + registration_id: '43', + message_id: '0:1385025861956342%572c22801bb3' + } + ] } end - subject { FCM.new(api_key) } - before do stub_request(:post, send_url).with( mock_request_attributes @@ -356,35 +216,31 @@ # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream body: valid_response_body_with_canonical_ids.to_json, headers: {}, - status: 200, + status: 200 ) end - it "should contain canonical_ids" do - response = subject.send(registration_ids) + it 'response body should contain canonical_ids' do + response = fcm.send([registration_id]) - response.should eq(headers: {}, - canonical_ids: [{ old: "42", new: "43" }], - not_registered_ids: [], - status_code: 200, - response: "success", - body: '{"failure":0,"canonical_ids":1,"results":[{"registration_id":"43","message_id":"0:1385025861956342%572c22801bb3"}]}') + response.should eq( + headers: {}, + canonical_ids: [{ old: '42', new: '43' }], + not_registered_ids: [], + status_code: 200, + response: 'success', + body: + '{"failure":0,"canonical_ids":1,"results":'\ + '[{"registration_id":"43","message_id":'\ + '"0:1385025861956342%572c22801bb3"}]}' + ) end end - context "when send_notification responds with NotRegistered" do - subject { FCM.new(api_key) } - - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } - end - + context 'when send_notification responds with NotRegistered' do let(:valid_response_body_with_not_registered_ids) do { - canonical_ids: 0, failure: 1, results: [{ error: "NotRegistered" }], + canonical_ids: 0, failure: 1, results: [{ error: 'NotRegistered' }] } end @@ -394,60 +250,178 @@ ).to_return( body: valid_response_body_with_not_registered_ids.to_json, headers: {}, - status: 200, + status: 200 ) end - it "should contain not_registered_ids" do - response = subject.send(registration_ids) + it 'contains not_registered_ids' do + response = fcm.send([registration_id]) response.should eq( headers: {}, canonical_ids: [], - not_registered_ids: registration_ids, + not_registered_ids: [registration_id], status_code: 200, - response: "success", - body: '{"canonical_ids":0,"failure":1,"results":[{"error":"NotRegistered"}]}', + response: 'success', + body: '{"canonical_ids":0,"failure":1,'\ + '"results":[{"error":"NotRegistered"}]}' ) end end end - describe "sending group notifications" do - # TODO: refactor to should_behave_like + describe '#send_to_topic' do + let(:successful_fcm_response) do + { + response: 'success', + body: '', + headers: {}, + status_code: 200 + } + end + + context 'when valid topic' do + subject(:send_notification_to_topic) do + fcm.send_to_topic(valid_topic, data: { score: '5x1', time: '15:10' }) + end + + let(:valid_topic) { 'TopicA' } + let!(:stub_with_valid_topic) do + stub_request(:post, send_url).with( + body: '{"to":"/topics/TopicA","data":{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) + end + + it 'sends the data in a post request to fcm' do + expect(send_notification_to_topic).to eq(successful_fcm_response) + stub_with_valid_topic.should have_been_requested + end + end + + context 'when invalid topic' do + let(:invalid_topic) { 'TopicA$' } + + let!(:stub_with_invalid_topic) do + stub_request(:post, send_url).with( + body: '{"condition":"/topics/TopicA$",'\ + '"data":{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) + end + + it 'does not send to invalid topics' do + stub_with_invalid_topic.should_not have_been_requested + end + end + end + + describe '#send_to_topic_condition' do + context 'when sending notification to a topic condition' do + let!(:stub_with_valid_condition) do + stub_request(:post, send_url) + .with( + body: '{"condition":"\'TopicA\' in topics && (\'TopicB\' in topics'\ + ' || \'TopicC\' in topics)","data":{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) + end + + it 'sends the data in a post request to fcm' do + fcm.send_to_topic_condition( + valid_condition, + data: { score: '5x1', time: '15:10' } + ) + stub_with_valid_condition.should have_been_requested + end + end + + context 'when sending notification to an invalid condition' do + let!(:stub_with_invalid_condition) do + stub_request(:post, send_url) + .with( + body: + '{"condition":"\'TopicA\' in topics and some other text'\ + ' (\'TopicB\' in topics || \'TopicC\' in topics)","data":'\ + '{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) + end + + let(:invalid_condition) do + "'TopicA' in topics and some other text'\ + ' ('TopicB' in topics || 'TopicC' in topics)" + end + + it 'does not send to invalid conditions' do + fcm.send_to_topic_condition(invalid_condition, + data: { score: '5x1', time: '15:10' }) + stub_with_invalid_condition.should_not have_been_requested + end + end + + context 'when sending notification to an invalid condition topic' do + let(:invalid_condition_topic) { "'TopicA$' in topics" } + let!(:stub_with_invalid_condition_topic) do + stub_request(:post, send_url) + .with( + body: + '{"condition":"\'TopicA$\' in topics","data"'\ + ':{"score":"5x1","time":"15:10"}}', + headers: valid_request_headers + ).to_return(status: 200, body: '', headers: {}) + end + + it 'does not send to invalid topics in a condition' do + fcm.send_to_topic_condition( + invalid_condition_topic, + data: { score: '5x1', time: '15:10' } + ) + stub_with_invalid_condition_topic.should_not have_been_requested + end + end + end + + describe 'sending group notifications' do + let(:group_notification_base_uri) do + "#{FCM::GROUP_NOTIFICATION_BASE_URI}/gcm/notification" + end + let(:notification_key) { 'APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ' } + let(:key_name) { 'appUser-Chris' } + # https://developers.google.com/cloud-messaging/gcm#senderid + let(:project_id) { '123456789' } + + let(:mock_request_attributes) do + { + body: valid_request_body.to_json, + headers: valid_request_headers + } + end + let(:valid_request_headers) do { - "Authorization" => "key=#{api_key}", - "Content-Type" => "application/json", - "Project-Id" => project_id, + 'Authorization' => "key=#{api_key}", + 'Content-Type' => 'application/json', + 'Project-Id' => project_id } end let(:valid_response_body) do - { notification_key: "APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ" } + { notification_key: 'APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ' } end let(:default_valid_request_body) do { - registration_ids: registration_ids, - operation: "create", - notification_key_name: key_name, + registration_ids: [registration_id], + operation: 'create', + notification_key_name: key_name } end - subject { FCM.new(api_key) } - # ref: https://firebase.google.com/docs/cloud-messaging/notifications#managing-device-groups-on-the-app-server - context "create" do + context 'when #create_notification_key' do let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "create", - }) - end - - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + default_valid_request_body.merge( + operation: 'create' + ) end before do @@ -456,34 +430,27 @@ ).to_return( body: valid_response_body.to_json, headers: {}, - status: 200, + status: 200 ) end - it "should send a post request" do - response = subject.create(key_name, project_id, registration_ids) + it 'sends a post request' do + response = fcm.create(key_name, project_id, [registration_id]) response.should eq( headers: {}, status_code: 200, - response: "success", - body: valid_response_body.to_json, + response: 'success', + body: valid_response_body.to_json ) end - end # create context + end - context "add" do + context 'when #add_notification_key' do let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "add", - notification_key: notification_key, - }) - end - - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + default_valid_request_body.merge( + operation: 'add', + notification_key: notification_key + ) end before do @@ -492,34 +459,31 @@ ).to_return( body: valid_response_body.to_json, headers: {}, - status: 200, + status: 200 ) end - it "should send a post request" do - response = subject.add(key_name, project_id, notification_key, registration_ids) + it 'sends a post request' do + response = fcm.add( + key_name, project_id, + notification_key, + [registration_id] + ) response.should eq( headers: {}, status_code: 200, - response: "success", - body: valid_response_body.to_json, + response: 'success', + body: valid_response_body.to_json ) end - end # add context + end - context "remove" do + context 'when #remove_notification_key' do let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "remove", - notification_key: notification_key, - }) - end - - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + default_valid_request_body.merge( + operation: 'remove', + notification_key: notification_key + ) end before do @@ -528,55 +492,49 @@ ).to_return( body: valid_response_body.to_json, headers: {}, - status: 200, + status: 200 ) end - it "should send a post request" do - response = subject.remove(key_name, project_id, notification_key, registration_ids) + it 'sends a post request' do + response = fcm.remove( + key_name, project_id, + notification_key, + [registration_id] + ) response.should eq( headers: {}, status_code: 200, - response: "success", - body: valid_response_body.to_json, + response: 'success', + body: valid_response_body.to_json ) end - end # remove context - end + end - describe "#recover_notification_key" do - it "sends a 'retrieve notification key' request" do - uri = "#{FCM::GROUP_NOTIFICATION_BASE_URI}/gcm/notification" - endpoint = stub_request(:get, uri).with( - headers: { - "Content-Type" => "application/json", - "Authorization" => "key=TEST_SERVER_KEY", - "project_id" => "TEST_PROJECT_ID", - }, - query: { notification_key_name: "TEST_KEY_NAME" }, - ) - client = FCM.new("TEST_SERVER_KEY") + context 'when #recover_notification_key' do + it "sends a 'retrieve notification key' request" do + endpoint = stub_request(:get, group_notification_base_uri).with( + headers: valid_request_headers, + query: { notification_key_name: key_name } + ) - client.recover_notification_key("TEST_KEY_NAME", "TEST_PROJECT_ID") + fcm.recover_notification_key(key_name, project_id) - expect(endpoint).to have_been_requested + expect(endpoint).to have_been_requested + end end end - describe "subscribing to a topic" do - # TODO - end - - describe 'getting instance info' do + describe '#get_instance_id_info' do subject(:get_info) { client.get_instance_id_info(registration_id, options) } let(:options) { nil } - let(:client) { FCM.new('TEST_SERVER_KEY') } + let(:client) { described_class.new(api_key) } let(:base_uri) { "#{FCM::INSTANCE_ID_API}/iid/info" } let(:uri) { "#{base_uri}/#{registration_id}" } let(:mock_request_attributes) do { headers: { - 'Authorization' => 'key=TEST_SERVER_KEY', + 'Authorization' => "key=#{api_key}", 'Content-Type' => 'application/json' } } end @@ -601,15 +559,88 @@ end end - describe "credentials path" do - it "can be a path to a file" do - fcm = FCM.new("test", "README.md") + describe 'credentials json_key_path' do + it 'can be a path to a file' do + fcm = described_class.new('test', 'README.md') expect(fcm.__send__(:json_key).class).to eq(File) end - it "can be an IO object" do - fcm = FCM.new("test", StringIO.new("hey")) + it 'can be an IO object' do + fcm = described_class.new('test', StringIO.new('hey')) expect(fcm.__send__(:json_key).class).to eq(StringIO) end end + + describe '#send_v1' do + let(:project_name) { 'project_name' } + let(:send_v1_url) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } + let(:access_token) { 'access_token' } + let(:valid_request_v1_headers) do + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{access_token}" + } + end + + let(:send_v1_params) do + { + 'token' => '4sdsx', + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + }, + 'data' => { + 'story_id' => 'story_12345' + }, + 'android' => { + 'notification' => { + 'click_action' => 'TOP_STORY_ACTIVITY', + 'body' => 'Check out the Top Story' + } + }, + 'apns' => { + 'payload' => { + 'aps' => { + 'category' => 'NEW_MESSAGE_CATEGORY' + } + } + } + } + end + + let(:google_authorizer_double) { instance_double('google_token_fetcher') } + let(:json_key_path) { object_double('file_alike_object') } + + let(:stub_fcm_send_v1_request) do + stub_request(:post, send_v1_url).with( + body: { 'message' => send_v1_params }.to_json, + headers: valid_request_v1_headers + ).to_return( + # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + body: '{}', + headers: {}, + status: 200 + ) + end + + before do + stub_fcm_send_v1_request + end + + it 'sends notification of HTTP V1 using POST to FCM server' do + allow(json_key_path).to receive(:respond_to?).and_return(true) + allow(Google::Auth::ServiceAccountCredentials).to receive( + :make_creds + ).and_return(google_authorizer_double) + allow(google_authorizer_double).to receive( + :fetch_access_token! + ).and_return('access_token' => access_token) + + fcm = described_class.new(api_key, json_key_path, project_name) + fcm.send_v1(send_v1_params).should eq( + response: 'success', body: '{}', headers: {}, status_code: 200 + ) + stub_fcm_send_v1_request.should have_been_made.times(1) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0eec70c..c28bb4e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,8 @@ +require 'simplecov' +SimpleCov.start require 'rubygems' require 'bundler/setup' require 'webmock/rspec' - require 'fcm' RSpec.configure do |config|