diff --git a/.gitignore b/.gitignore index 924afc1..5412b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ Gemfile.lock # rspec failure tracking .rspec_status + +# ruby version +.ruby-version \ No newline at end of file diff --git a/firebase-admin-sdk.gemspec b/firebase-admin-sdk.gemspec index d1e2632..cb21f0a 100644 --- a/firebase-admin-sdk.gemspec +++ b/firebase-admin-sdk.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "googleauth", "> 0.16", "< 2.0" spec.add_runtime_dependency "faraday", "> 1", "< 3" spec.add_runtime_dependency "jwt", ">= 1.5", "< 3.0" + spec.add_runtime_dependency "google-apis-fcm_v1", ">= 0.19.0", "< 1.0" spec.add_development_dependency "rake" spec.add_development_dependency "rspec" @@ -31,5 +32,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "fakefs" spec.add_development_dependency "climate_control" spec.add_development_dependency "standard" - spec.add_development_dependency "activesupport" + spec.add_development_dependency "activesupport", "~> 7.0.8" end diff --git a/lib/firebase-admin-sdk.rb b/lib/firebase-admin-sdk.rb index eb5d864..281c622 100644 --- a/lib/firebase-admin-sdk.rb +++ b/lib/firebase-admin-sdk.rb @@ -34,4 +34,6 @@ require_relative "firebase/admin/messaging/multicast_message" require_relative "firebase/admin/messaging/error_info" require_relative "firebase/admin/messaging/topic_management_response" +require_relative "firebase/admin/messaging/send_response" +require_relative "firebase/admin/messaging/batch_response" require_relative "firebase/admin/messaging/client" diff --git a/lib/firebase/admin/messaging/aps.rb b/lib/firebase/admin/messaging/aps.rb index 03410c4..a6b26e1 100644 --- a/lib/firebase/admin/messaging/aps.rb +++ b/lib/firebase/admin/messaging/aps.rb @@ -3,7 +3,7 @@ module Admin module Messaging # Aps dictionary to be included in an APNS payload. class APS - # @return [APSAlert, String, nil] + # @return [Firebase::Admin::Messaging::APSAlert, String, nil] # Alert to be included in the message. attr_accessor :alert @@ -12,7 +12,7 @@ class APS # remain unchanged. attr_accessor :badge - # @return [String, CriticalSound, nil] + # @return [Firebase::Admin::Messaging::CriticalSound, String, nil] # Sound to be played with the message. attr_accessor :sound @@ -39,12 +39,12 @@ class APS # Initializes an {APS}. # - # @param [APSAlert, String, nil] alert + # @param [Firebase::Admin::Messaging::APSAlert, String, nil] alert # Alert to be included in the message (optional). # @param [Integer, nil] badge # Badge to be displayed with the message (optional). # Set to 0 to remove the badge. When not specified, the badge will remain unchanged. - # @param [String, CriticalSound, nil] sound + # @param [Firebase::Admin::Messaging::CriticalSound, String, nil] sound # Sound to be played with the message (optional). # @param [Boolean, nil] content_available # Specifies whether to configure a background update notification (optional). diff --git a/lib/firebase/admin/messaging/batch_response.rb b/lib/firebase/admin/messaging/batch_response.rb new file mode 100644 index 0000000..4183f7c --- /dev/null +++ b/lib/firebase/admin/messaging/batch_response.rb @@ -0,0 +1,29 @@ +module Firebase + module Admin + module Messaging + # The response received from a batch request + class BatchResponse + # The list of responses (possibly empty). + # @return [Array] + attr_reader :responses + + # The number of successful messages. + # @return [Integer] + attr_reader :success_count + + # The number of failed messages. + # @return [Integer] + attr_reader :failure_count + + # A response received from a batch request. + # + # @param [Array] responses + def initialize(responses:) + @responses = responses + @success_count = responses.count(:success?) + @failure_count = responses.count - @success_count + end + end + end + end +end diff --git a/lib/firebase/admin/messaging/client.rb b/lib/firebase/admin/messaging/client.rb index 61beada..afc34fa 100644 --- a/lib/firebase/admin/messaging/client.rb +++ b/lib/firebase/admin/messaging/client.rb @@ -1,3 +1,5 @@ +require "google-apis-fcm_v1" + module Firebase module Admin module Messaging @@ -5,8 +7,11 @@ module Messaging class Client def initialize(app) @project_id = app.project_id - @http_client = Firebase::Admin::Internal::HTTPClient.new(credentials: app.credentials) + @project_path = "projects/#{app.project_id}" @message_encoder = MessageEncoder.new + @http_client = Firebase::Admin::Internal::HTTPClient.new(credentials: app.credentials) + @service = Google::Apis::FcmV1::FirebaseCloudMessagingService.new + @service.authorization = app.credentials end # Sends a message via Firebase Cloud Messaging (FCM). @@ -19,13 +24,10 @@ def initialize(app) # # @return [String] A message id that uniquely identifies the message. def send_one(message, dry_run: false) - body = { - validate_only: dry_run, - message: @message_encoder.encode(message) - } - res = @http_client.post(send_url, body, FCM_HEADERS) - res.body["name"] - rescue Faraday::Error => e + body = encode_message(message, dry_run: dry_run) + res = @service.send_message(@project_path, body, options: {skip_serialization: true}) + res.name + rescue Google::Apis::Error => e raise parse_fcm_error(e) end @@ -39,7 +41,21 @@ def send_one(message, dry_run: false) # # @return [BatchResponse] A batch response. def send_all(messages, dry_run: false) - raise NotImplementedError + raise "messages must be an Array" unless messages.is_a?(Array) + raise "messages must not contain more than 500 elements" unless messages.length < 500 + + responses = [] + @service.batch do |service| + options = {skip_serialization: true} + messages.each do |message| + body = encode_message(message, dry_run: dry_run) + service.send_message(@project_path, body, options: options) do |res, err| + wrapped_err = parse_fcm_error(err) unless err.nil? + responses << SendResponse.new(message_id: res&.name, error: wrapped_err) + end + end + end + BatchResponse.new(responses: responses) end # Sends the given multicast message to all tokens via Firebase Cloud Messaging (FCM). @@ -124,9 +140,9 @@ def make_topic_mgmt_request(tokens, topic, operation) TopicManagementResponse.new(res) end - # @param [Faraday::Error] err + # @param [Google::Apis::Error] err def parse_fcm_error(err) - msg, info = parse_platform_error(err.response_status, err.response_body) + msg, info = parse_platform_error(err.status_code, err.body) return err if info.empty? details = info["details"] || [] @@ -153,10 +169,19 @@ def parse_platform_error(status_code, body) [msg, details] end + # Encodes a send message request. + def encode_message(message, dry_run:) + body = { + message: @message_encoder.encode(message), + validateOnly: dry_run + } + JSON.generate(body) + end + FCM_HOST = "https://fcm.googleapis.com" FCM_HEADERS = {"X-GOOG-API-FORMAT-VERSION": "2"} IID_HOST = "https://iid.googleapis.com" - IID_HEADERS = {"access_token_auth" => "true"} + IID_HEADERS = {access_token_auth: "true"} FCM_ERROR_TYPES = { "APNS_AUTH_ERROR" => ThirdPartyAuthError, diff --git a/lib/firebase/admin/messaging/message_encoder.rb b/lib/firebase/admin/messaging/message_encoder.rb index 7756b51..746b170 100644 --- a/lib/firebase/admin/messaging/message_encoder.rb +++ b/lib/firebase/admin/messaging/message_encoder.rb @@ -1,3 +1,5 @@ +require "json" + module Firebase module Admin module Messaging @@ -6,7 +8,7 @@ class MessageEncoder # # @param [Message] message # The message to encode. - # @return [Hash] + # @return [String] A json encoded string. def encode(message) raise ArgumentError, "message must be a Message" unless message.is_a?(Message) result = { @@ -17,7 +19,7 @@ def encode(message) notification: encode_notification(message.notification), token: check_string("Message.token", message.token, non_empty: true), topic: check_string("Message.topic", message.topic, non_empty: true), - fcm_options: encode_fcm_options(message.fcm_options) + fcmOptions: encode_fcm_options(message.fcm_options) } result[:topic] = sanitize_topic_name(result[:topic]) result = remove_nil_values(result) @@ -47,13 +49,13 @@ def encode_android(v) return nil unless v raise ArgumentError, "Message.android must be an AndroidConfig." unless v.is_a?(AndroidConfig) result = { - collapse_key: check_string("AndroidConfig.collapse_key", v.collapse_key), + collapseKey: check_string("AndroidConfig.collapse_key", v.collapse_key), data: check_string_hash("AndroidConfig.data", v.data), notification: encode_android_notification(v.notification), priority: check_string("AndroidConfig.priority", v.priority, non_empty: true), - restricted_package_name: check_string("AndroidConfig.restricted_package_name", v.restricted_package_name), + restrictedPackageName: check_string("AndroidConfig.restricted_package_name", v.restricted_package_name), ttl: encode_duration("AndroidConfig.ttl", v.ttl), - fcm_options: encode_android_fcm_options(v.fcm_options) + fcmOptions: encode_android_fcm_options(v.fcm_options) } result = remove_nil_values(result) if result.key?(:priority) && !%w[normal high].include?(result[:priority]) @@ -71,49 +73,49 @@ def encode_android_notification(v) result = { body: check_string("AndroidNotification.body", v.body), - body_loc_key: check_string("AndroidNotification.body_loc_key", v.body_loc_key), - body_loc_args: check_string_array("AndroidNotification.body_loc_args", v.body_loc_args), - click_action: check_string("AndroidNotification.click_action", v.click_action), + bodyLocKey: check_string("AndroidNotification.body_loc_key", v.body_loc_key), + bodyLocArgs: check_string_array("AndroidNotification.body_loc_args", v.body_loc_args), + clickAction: check_string("AndroidNotification.click_action", v.click_action), color: check_color("AndroidNotification.color", v.color, allow_alpha: true, required: false), icon: check_string("AndroidNotification.icon", v.icon), sound: check_string("AndroidNotification.sound", v.sound), tag: check_string("AndroidNotification.tag", v.tag), title: check_string("AndroidNotification.title", v.title), - title_loc_key: check_string("AndroidNotification.title_loc_key", v.title_loc_key), - title_loc_args: check_string_array("AndroidNotification.title_loc_args", v.title_loc_args), - channel_id: check_string("AndroidNotification.channel_id", v.channel_id), + titleLocKey: check_string("AndroidNotification.title_loc_key", v.title_loc_key), + titleLocArgs: check_string_array("AndroidNotification.title_loc_args", v.title_loc_args), + channelId: check_string("AndroidNotification.channel_id", v.channel_id), image: check_string("AndroidNotification.image", v.image), ticker: check_string("AndroidNotification.ticker", v.ticker), sticky: v.sticky, - event_time: check_time("AndroidNotification.event_time", v.event_time), - local_only: v.local_only, - notification_priority: check_string("AndroidNotification.priority", v.priority, non_empty: true), - vibrate_timings: check_numeric_array("AndroidNotification.vibrate_timings", v.vibrate_timings), - default_vibrate_timings: v.default_vibrate_timings, - default_sound: v.default_sound, - default_light_settings: v.default_light_settings, - light_settings: encode_light_settings(v.light_settings), + eventTime: check_time("AndroidNotification.event_time", v.event_time), + localOnly: v.local_only, + notificationPriority: check_string("AndroidNotification.priority", v.priority, non_empty: true), + vibrateTimings: check_numeric_array("AndroidNotification.vibrate_timings", v.vibrate_timings), + defaultVibrateTimings: v.default_vibrate_timings, + defaultSound: v.default_sound, + defaultLightSettings: v.default_light_settings, + lightSettings: encode_light_settings(v.light_settings), visibility: check_string("AndroidNotification.visibility", v.visibility, non_empty: true), - notification_count: check_numeric("AndroidNotification.notification_count", v.notification_count) + notificationCount: check_numeric("AndroidNotification.notification_count", v.notification_count) } result = remove_nil_values(result) - if result.key?(:body_loc_args) && !result.key?(:body_loc_key) + if result.key?(:bodyLocArgs) && !result.key?(:bodyLocKey) raise ArgumentError, "AndroidNotification.body_loc_key is required when specifying body_loc_args" - elsif result.key?(:title_loc_args) && !result.key?(:title_loc_key) + elsif result.key?(:titleLocArgs) && !result.key?(:titleLocKey) raise ArgumentError, "AndroidNotification.title_loc_key is required when specifying title_loc_args" end - if (event_time = result[:event_time]) - event_time = event_time.dup.utc unless event_time.utc? - result[:event_time] = event_time.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") + if (event_time = result[:eventTime]) + event_time = event_time.getutc unless event_time.utc? + result[:eventTime] = event_time.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") end - if (priority = result[:notification_priority]) + if (priority = result[:notificationPriority]) unless %w[min low default high max].include?(priority) raise ArgumentError, "AndroidNotification.priority must be 'default', 'min', 'low', 'high' or 'max'." end - result[:notification_priority] = "PRIORITY_#{priority.upcase}" + result[:notificationPriority] = "PRIORITY_#{priority.upcase}" end if (visibility = result[:visibility]) @@ -123,11 +125,11 @@ def encode_android_notification(v) result[:visibility] = visibility.upcase end - if (vibrate_timings = result[:vibrate_timings]) + if (vibrate_timings = result[:vibrateTimings]) vibrate_timing_strings = vibrate_timings.map do |t| encode_duration("AndroidNotification.vibrate_timings", t) end - result[:vibrate_timings] = vibrate_timing_strings + result[:vibrateTimings] = vibrate_timing_strings end result @@ -140,7 +142,7 @@ def encode_android_fcm_options(v) raise ArgumentError, "AndroidConfig.fcm_options must be an AndroidFCMOptions" end result = { - analytics_label: check_analytics_label("AndroidFCMOptions.analytics_label", v.analytics_label) + analyticsLabel: check_analytics_label("AndroidFCMOptions.analytics_label", v.analytics_label) } remove_nil_values(result) end @@ -159,14 +161,14 @@ def encode_light_settings(v) raise ArgumentError, "AndroidNotification.light_settings must be a LightSettings." unless v.is_a?(LightSettings) result = { color: encode_color("LightSettings.color", v.color, allow_alpha: true), - light_on_duration: encode_duration("LightSettings.light_on_duration", v.light_on_duration), - light_off_duration: encode_duration("LightSettings.light_off_duration", v.light_off_duration) + lightOnDuration: encode_duration("LightSettings.light_on_duration", v.light_on_duration), + lightOffDuration: encode_duration("LightSettings.light_off_duration", v.light_off_duration) } result = remove_nil_values(result) - unless result.key?(:light_on_duration) + unless result.key?(:lightOnDuration) raise ArgumentError, "LightSettings.light_on_duration is required" end - unless result.key?(:light_off_duration) + unless result.key?(:lightOffDuration) raise ArgumentError, "LightSettings.light_off_duration is required" end result @@ -190,7 +192,7 @@ def encode_apns(apns) result = { headers: check_string_hash("APNSConfig.headers", apns.headers), payload: encode_apns_payload(apns.payload), - fcm_options: encode_apns_fcm_options(apns.fcm_options) + fcmOptions: encode_apns_fcm_options(apns.fcm_options) } remove_nil_values(result) end @@ -213,7 +215,7 @@ def encode_apns_fcm_options(options) return nil unless options raise ArgumentError, "APNSConfig.fcm_options must be an APNSFCMOptions" unless options.is_a?(APNSFCMOptions) result = { - analytics_label: check_analytics_label("APNSFCMOptions.analytics_label", options.analytics_label), + analyticsLabel: check_analytics_label("APNSFCMOptions.analytics_label", options.analytics_label), image: check_string("APNSFCMOptions.image", options.image) } remove_nil_values(result) @@ -331,7 +333,7 @@ def encode_fcm_options(options) return nil unless options raise ArgumentError, "Message.fcm_options must be a FCMOptions." unless options.is_a?(FCMOptions) result = { - analytics_label: check_analytics_label("Message.fcm_options", options.analytics_label) + analyticsLabel: check_analytics_label("Message.fcm_options", options.analytics_label) } remove_nil_values(result) end diff --git a/lib/firebase/admin/messaging/send_response.rb b/lib/firebase/admin/messaging/send_response.rb new file mode 100644 index 0000000..c0658a4 --- /dev/null +++ b/lib/firebase/admin/messaging/send_response.rb @@ -0,0 +1,31 @@ +module Firebase + module Admin + module Messaging + # The response received from an individual batched request. + class SendResponse + # A message id string that uniquely identifies the message. + # @return [String] + attr_reader :message_id + + # The error if one occurred while sending the message. + # @return [Error] + attr_reader :error + + # A boolean indicating if the request was successful. + # @return [Boolean] + def success? + !!@message_id + end + + # Initializes the object. + # + # @param [String, nil] message_id the id of the sent message + # @param [Error, nil] error the error that occurred + def initialize(message_id:, error:) + @message_id = message_id + @error = error + end + end + end + end +end diff --git a/lib/firebase/admin/messaging/utils.rb b/lib/firebase/admin/messaging/utils.rb index 7a8e832..1affd20 100644 --- a/lib/firebase/admin/messaging/utils.rb +++ b/lib/firebase/admin/messaging/utils.rb @@ -61,11 +61,10 @@ def check_color(label, value, allow_alpha: false, required: false) return nil unless value || required raise ArgumentError, "#{label} is required" unless value raise ArgumentError, "#{label} must be a string" unless value.is_a?(String) - unless /\A#[0-9a-fA-F]{6}\Z/.match?(value) || (/\A#[0-9a-fA-F]{8}\Z/.match?(value) && allow_alpha) - raise ArgumentError, "#{label} must be in the form #RRGGBB" unless allow_alpha - raise ArgumentError, "#{label} must be in the form #RRGGBB or #RRGGBBAA" - end - value + return value if /\A#[0-9a-fA-F]{6}\Z/.match?(value) + return value if /\A#[0-9a-fA-F]{8}\Z/.match?(value) && allow_alpha + raise ArgumentError, "#{label} must be in the form #RRGGBB" unless allow_alpha + raise ArgumentError, "#{label} must be in the form #RRGGBB or #RRGGBBAA" end def to_seconds_string(seconds) diff --git a/lib/firebase/admin/version.rb b/lib/firebase/admin/version.rb index 6295b6b..9217121 100644 --- a/lib/firebase/admin/version.rb +++ b/lib/firebase/admin/version.rb @@ -2,6 +2,6 @@ module Firebase module Admin - VERSION = "0.2.0" + VERSION = "0.3.0" end end diff --git a/spec/unit/firebase/admin/messaging/message_encoder_spec.rb b/spec/unit/firebase/admin/messaging/message_encoder_spec.rb index c83d3bb..d70e620 100644 --- a/spec/unit/firebase/admin/messaging/message_encoder_spec.rb +++ b/spec/unit/firebase/admin/messaging/message_encoder_spec.rb @@ -78,24 +78,24 @@ sound: "sound", tag: "tag", image: "image", - click_action: "click_action", - body_loc_key: "body_loc_key", - body_loc_args: ["body_loc_args"], - title_loc_key: "title_loc_key", - title_loc_args: ["title_loc_args"], - channel_id: "channel_id", + clickAction: "click_action", + bodyLocKey: "body_loc_key", + bodyLocArgs: ["body_loc_args"], + titleLocKey: "title_loc_key", + titleLocArgs: ["title_loc_args"], + channelId: "channel_id", ticker: "ticker", sticky: true, - event_time: current_time.dup.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"), - local_only: true, - notification_priority: "PRIORITY_MIN", - vibrate_timings: %w[0.250000000s 0.250000000s], - default_vibrate_timings: true, - default_sound: true, - light_settings: encoded_light_settings, - default_light_settings: true, + eventTime: current_time.dup.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"), + localOnly: true, + notificationPriority: "PRIORITY_MIN", + vibrateTimings: %w[0.250000000s 0.250000000s], + defaultVibrateTimings: true, + defaultSound: true, + lightSettings: encoded_light_settings, + defaultLightSettings: true, visibility: "SECRET", - notification_count: 5 + notificationCount: 5 } } @@ -115,8 +115,8 @@ blue: 0x22 / 255.0, alpha: 0xFF / 255.0 }, - light_on_duration: "0.100000000s", - light_off_duration: "0.100000000s" + lightOnDuration: "0.100000000s", + lightOffDuration: "0.100000000s" } } @@ -126,7 +126,7 @@ let(:encoded_android_fcm_options) { { - analytics_label: "android_analytics_label" + analyticsLabel: "android_analytics_label" } } @@ -144,13 +144,13 @@ let(:encoded_android) { { - collapse_key: "collapse_key", + collapseKey: "collapse_key", priority: "high", ttl: "1.500000000s", - restricted_package_name: "test_package_name", + restrictedPackageName: "test_package_name", data: {foo: "bar"}, notification: encoded_android_notification, - fcm_options: encoded_android_fcm_options + fcmOptions: encoded_android_fcm_options } } @@ -170,7 +170,7 @@ { headers: {"apns-priority": "10"}, payload: encoded_apns_payload, - fcm_options: encoded_apns_fcm_options + fcmOptions: encoded_apns_fcm_options } } @@ -279,7 +279,7 @@ let(:encoded_apns_fcm_options) { { - analytics_label: "analytics-label", + analyticsLabel: "analytics-label", image: "image" } }