fcm 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49ddf83437f7c8df20298ffa3f334473e0032032e7f8e88d5021185f448f4606
4
- data.tar.gz: fc79d5369ee5b22b854f49889a69de1612c4c7e2a37ec17683b8a428a0326549
3
+ metadata.gz: f15e3ef0efb046941ee9c8e615206668feb9e0f087e03be74b25303d40a8ebfe
4
+ data.tar.gz: 492c261d5f4d58a0511d809aa7ad7278fdc18462590459ec8027a7215b146724
5
5
  SHA512:
6
- metadata.gz: 9b37b2f37b95cc0816547c973872c8c17c3905602fe8162bff2e25e3a52c7997f7dae11c472a399c8d947e9bfdde668b3223f793b912d274f4bcd204ba827325
7
- data.tar.gz: 06f380df29d1bcc8683df25f45c099c8615609081308de79c0966c52ea9f4f887155ee77591b65ad479260d736b5e2b98f682e4616bce19cfc52c937995015ad
6
+ metadata.gz: fd794a9f1aedf3f82ae9c1e43f299ab8be6a432738383d6db2d8fc1add8b44577e50ee1563d00bd7878d801e2eff46994b9a84d06b18a9b4892d2d6d9146a950
7
+ data.tar.gz: bd237deb478ab400e9e8e82cc6679c6c2560727757c8a74b25a2db51df7473b012f00b8019402f7426c601a31710dcdd28710cab9b1e96de132bdbd41b31e109
@@ -13,7 +13,7 @@ jobs:
13
13
  runs-on: ubuntu-latest
14
14
  strategy:
15
15
  matrix:
16
- ruby: ['2.7', '3.0', '3.1', '3.3']
16
+ ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0']
17
17
 
18
18
  steps:
19
19
  - uses: actions/checkout@master
@@ -28,3 +28,18 @@ jobs:
28
28
  - name: Run tests
29
29
  run: |
30
30
  bundle exec rspec
31
+
32
+ lint:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@master
36
+
37
+ - name: Set up Ruby
38
+ uses: ruby/setup-ruby@v1
39
+ with:
40
+ ruby-version: '3.3'
41
+ bundler: default
42
+ bundler-cache: true
43
+
44
+ - name: Run RuboCop
45
+ run: bundle exec rubocop --parallel
data/.hound.yml ADDED
@@ -0,0 +1,2 @@
1
+ rubocop:
2
+ enabled: false
data/.rubocop.yml ADDED
@@ -0,0 +1,67 @@
1
+ plugins:
2
+ - rubocop-rspec
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 2.7
6
+ NewCops: enable
7
+ SuggestExtensions: false
8
+ Exclude:
9
+ - "vendor/**/*"
10
+ - "tmp/**/*"
11
+
12
+ # This is a small gem; some style cops add noise without value.
13
+ Style/Documentation:
14
+ Enabled: false
15
+ Style/StringLiterals:
16
+ EnforcedStyle: double_quotes
17
+ Style/StringLiteralsInInterpolation:
18
+ EnforcedStyle: double_quotes
19
+
20
+ # Renaming `is_not_registered?` / `has_canonical_id?` would change the gem's
21
+ # (admittedly internal) Ruby surface area. Out of scope for a lint pass.
22
+ Naming/PredicatePrefix:
23
+ Enabled: false
24
+
25
+ Layout/LineLength:
26
+ Max: 120
27
+ Exclude:
28
+ - "fcm.gemspec"
29
+
30
+ # Out of scope for a lint pass: bumping required_ruby_version is a semver-relevant
31
+ # change for gem consumers and should be considered separately.
32
+ Gemspec/RequiredRubyVersion:
33
+ Enabled: false
34
+
35
+ # Enabling MFA is a publishing-policy decision for the gem maintainer, not
36
+ # something a lint pass should silently introduce.
37
+ Gemspec/RequireMFA:
38
+ Enabled: false
39
+
40
+ # Code metrics are coarse signals that fight a lot of legacy code.
41
+ Metrics/AbcSize:
42
+ Enabled: false
43
+ Metrics/ClassLength:
44
+ Enabled: false
45
+ Metrics/CyclomaticComplexity:
46
+ Enabled: false
47
+ Metrics/MethodLength:
48
+ Enabled: false
49
+ Metrics/PerceivedComplexity:
50
+ Enabled: false
51
+ Metrics/BlockLength:
52
+ Exclude:
53
+ - "spec/**/*"
54
+ - "fcm.gemspec"
55
+
56
+ # Specs are intentionally long, descriptive, and use `.should` syntax in
57
+ # places — none of these add value here.
58
+ RSpec/ExampleLength:
59
+ Enabled: false
60
+ RSpec/MultipleExpectations:
61
+ Enabled: false
62
+ RSpec/MultipleMemoizedHelpers:
63
+ Enabled: false
64
+ RSpec/NestedGroups:
65
+ Enabled: false
66
+ RSpec/NoExpectationExample:
67
+ Enabled: false
data/Gemfile CHANGED
@@ -1,8 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source "https://rubygems.org"
2
4
  gemspec
3
5
 
4
- gem 'rake'
5
- gem 'rspec'
6
- gem 'webmock'
7
- gem 'ci_reporter_rspec'
8
- gem 'googleauth'
6
+ gem "ci_reporter_rspec"
7
+ gem "rake"
8
+ gem "rspec"
9
+ gem "rubocop", require: false
10
+ gem "rubocop-rspec", require: false
11
+ gem "webmock"
data/README.md CHANGED
@@ -54,6 +54,39 @@ fcm = FCM.new(
54
54
 
55
55
  ```
56
56
 
57
+ ## Request timeouts
58
+
59
+ Both the read timeout and the TCP connect timeout are configurable via
60
+ `http_options`. Both default to `DEFAULT_TIMEOUT` (30s):
61
+
62
+ ```ruby
63
+ fcm = FCM.new(
64
+ GOOGLE_APPLICATION_CREDENTIALS_PATH,
65
+ FIREBASE_PROJECT_ID,
66
+ timeout: 10, # response read timeout
67
+ open_timeout: 5 # TCP connect timeout — fail fast on stuck handshakes
68
+ )
69
+ ```
70
+
71
+ ## HTTP keep-alive
72
+
73
+ By default each request opens a fresh TCP/TLS connection to FCM. For high-volume
74
+ senders this dominates per-request latency. Pass `keep_alive_connections: true`
75
+ to reuse a thread-local connection (per host) backed by `net-http-persistent`:
76
+
77
+ ```ruby
78
+ fcm = FCM.new(
79
+ GOOGLE_APPLICATION_CREDENTIALS_PATH,
80
+ FIREBASE_PROJECT_ID,
81
+ keep_alive_connections: true,
82
+ keep_alive_idle_timeout_seconds: 30, # optional, default 30
83
+ keep_alive_pool_size: 1 # optional, default 1
84
+ )
85
+ ```
86
+
87
+ `Net::HTTP` is not thread-safe, so connections are cached per `(thread, uri)`.
88
+ Connections are dropped on error so half-closed sockets are not reused.
89
+
57
90
  ## Usage
58
91
 
59
92
  ## HTTP v1 API
@@ -274,6 +307,16 @@ The guide to set up an iOS app to get notifications is here: [Setting up a FCM C
274
307
 
275
308
  ## ChangeLog
276
309
 
310
+ ### 2.0.3
311
+ - Add opt-in HTTP keep-alive via `keep_alive_connections` [#145](https://github.com/decision-labs/fcm/pull/145)
312
+ - Add configurable `open_timeout` via `http_options` [#146](https://github.com/decision-labs/fcm/pull/146)
313
+ - Cover modern Ruby versions in the CI test matrix [#147](https://github.com/decision-labs/fcm/pull/147)
314
+ - Replace Hound with RuboCop [#148](https://github.com/decision-labs/fcm/pull/148)
315
+ - Drop unused `cgi` require and duplicate Gemfile `googleauth` [#149](https://github.com/decision-labs/fcm/pull/149)
316
+
317
+ ### 2.0.2
318
+ - Ensure JSON key path is an actual file or IO object before opening [#134](https://github.com/spacialdb/fcm/pull/134)
319
+
277
320
  ### 2.0.1
278
321
  - Add `http_options` to `initialize` method and whitelist `timeout` option
279
322
 
data/Rakefile CHANGED
@@ -1,15 +1,17 @@
1
- require 'rspec/core/rake_task'
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/rake_task"
2
4
  require "bundler/gem_tasks"
3
5
  require "rake/tasklib"
4
- require 'ci/reporter/rake/rspec'
6
+ require "ci/reporter/rake/rspec"
5
7
 
6
- RSpec::Core::RakeTask.new(:spec => ["ci:setup:rspec"]) do |t|
7
- t.pattern = 'spec/**/*_spec.rb'
8
+ RSpec::Core::RakeTask.new(spec: ["ci:setup:rspec"]) do |t|
9
+ t.pattern = "spec/**/*_spec.rb"
8
10
  end
9
11
 
10
12
  RSpec::Core::RakeTask.new(:spec) do |spec|
11
- spec.pattern = 'spec/**/*_spec.rb'
12
- spec.rspec_opts = ['--format documentation']
13
+ spec.pattern = "spec/**/*_spec.rb"
14
+ spec.rspec_opts = ["--format documentation"]
13
15
  end
14
16
 
15
- task :default => :spec
17
+ task default: :spec
data/fcm.gemspec CHANGED
@@ -1,24 +1,26 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path("lib", __dir__)
3
4
 
4
5
  Gem::Specification.new do |s|
5
6
  s.name = "fcm"
6
- s.version = "2.0.1"
7
+ s.version = "2.0.3"
7
8
  s.platform = Gem::Platform::RUBY
8
9
  s.authors = ["Kashif Rasul", "Shoaib Burq"]
9
10
  s.email = ["kashif@decision-labs.com", "shoaib@decision-labs.com"]
10
11
  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.}
12
+ s.summary = "Reliably deliver messages and notifications via FCM"
13
+ s.description = "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
14
  s.license = "MIT"
14
15
 
15
16
  s.required_ruby_version = ">= 2.4.0"
16
17
 
17
18
  s.files = `git ls-files`.split("\n")
18
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
19
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
20
20
  s.require_paths = ["lib"]
21
21
 
22
- s.add_runtime_dependency("faraday", ">= 1.0.0", "< 3.0")
23
- s.add_runtime_dependency("googleauth", "~> 1")
22
+ s.add_dependency("faraday", ">= 1.0.0", "< 3.0")
23
+ s.add_dependency("faraday-net_http_persistent", "~> 2.0")
24
+ s.add_dependency("googleauth", "~> 1")
25
+ s.add_dependency("net-http-persistent", "~> 4.0")
24
26
  end
data/lib/fcm.rb CHANGED
@@ -1,21 +1,36 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "faraday"
2
- require "cgi"
3
4
  require "json"
4
5
  require "googleauth"
5
6
 
6
7
  class FCM
8
+ class InvalidCredentialError < StandardError; end
9
+
7
10
  BASE_URI = "https://fcm.googleapis.com"
8
11
  BASE_URI_V1 = "https://fcm.googleapis.com/v1/projects/"
9
12
  DEFAULT_TIMEOUT = 30
13
+ DEFAULT_KEEP_ALIVE_IDLE_TIMEOUT_SECONDS = 30
14
+ DEFAULT_KEEP_ALIVE_POOL_SIZE = 1
10
15
 
11
16
  GROUP_NOTIFICATION_BASE_URI = "https://android.googleapis.com"
12
17
  INSTANCE_ID_API = "https://iid.googleapis.com"
13
- TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/
18
+ TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/.freeze
14
19
 
15
20
  def initialize(json_key_path = "", project_name = "", http_options = {})
16
21
  @json_key_path = json_key_path
17
22
  @project_name = project_name
18
23
  @http_options = http_options
24
+ @keep_alive_connections = http_options.fetch(:keep_alive_connections, false)
25
+ @keep_alive_idle_timeout_seconds =
26
+ http_options.fetch(:keep_alive_idle_timeout_seconds, DEFAULT_KEEP_ALIVE_IDLE_TIMEOUT_SECONDS)
27
+ @keep_alive_pool_size = http_options.fetch(:keep_alive_pool_size, DEFAULT_KEEP_ALIVE_POOL_SIZE)
28
+
29
+ # Per-instance key for the thread-local connection cache so multiple FCM
30
+ # clients in the same process do not share sockets.
31
+ @thread_connections_key = :"_fcm_connections_#{object_id}"
32
+
33
+ require "faraday/net_http_persistent" if @keep_alive_connections
19
34
  end
20
35
 
21
36
  # See https://firebase.google.com/docs/cloud-messaging/send-message
@@ -49,7 +64,7 @@ class FCM
49
64
  def send_notification_v1(message)
50
65
  return if @project_name.empty?
51
66
 
52
- post_body = { 'message': message }
67
+ post_body = { message: message }
53
68
  for_uri(BASE_URI_V1) do |connection|
54
69
  response = connection.post(
55
70
  "#{@project_name}/messages:send", post_body.to_json
@@ -65,7 +80,7 @@ class FCM
65
80
  notification_key_name: key_name)
66
81
 
67
82
  extra_headers = {
68
- "project_id" => project_id,
83
+ "project_id" => project_id
69
84
  }
70
85
 
71
86
  for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection|
@@ -82,7 +97,7 @@ class FCM
82
97
  notification_key: notification_key)
83
98
 
84
99
  extra_headers = {
85
- "project_id" => project_id,
100
+ "project_id" => project_id
86
101
  }
87
102
 
88
103
  for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection|
@@ -99,7 +114,7 @@ class FCM
99
114
  notification_key: notification_key)
100
115
 
101
116
  extra_headers = {
102
- "project_id" => project_id,
117
+ "project_id" => project_id
103
118
  }
104
119
 
105
120
  for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection|
@@ -114,7 +129,7 @@ class FCM
114
129
  params = { notification_key_name: key_name }
115
130
 
116
131
  extra_headers = {
117
- "project_id" => project_id,
132
+ "project_id" => project_id
118
133
  }
119
134
 
120
135
  for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection|
@@ -137,11 +152,11 @@ class FCM
137
152
  end
138
153
 
139
154
  def batch_topic_subscription(topic, registration_tokens)
140
- manage_topics_relationship(topic, registration_tokens, 'Add')
155
+ manage_topics_relationship(topic, registration_tokens, "Add")
141
156
  end
142
157
 
143
158
  def batch_topic_unsubscription(topic, registration_tokens)
144
- manage_topics_relationship(topic, registration_tokens, 'Remove')
159
+ manage_topics_relationship(topic, registration_tokens, "Remove")
145
160
  end
146
161
 
147
162
  def manage_topics_relationship(topic, registration_tokens, action)
@@ -163,47 +178,101 @@ class FCM
163
178
  end
164
179
 
165
180
  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
181
+ return unless topic.gsub(TOPIC_REGEX, "").empty?
182
+
183
+ body = { message: { topic: topic }.merge(options) }
184
+
185
+ for_uri(BASE_URI_V1) do |connection|
186
+ response = connection.post(
187
+ "#{@project_name}/messages:send", body.to_json
188
+ )
189
+ build_response(response)
175
190
  end
176
191
  end
177
192
 
178
193
  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
194
+ return unless validate_condition?(condition)
195
+
196
+ body = { message: { condition: condition }.merge(options) }
197
+
198
+ for_uri(BASE_URI_V1) do |connection|
199
+ response = connection.post(
200
+ "#{@project_name}/messages:send", body.to_json
201
+ )
202
+ build_response(response)
188
203
  end
189
204
  end
190
205
 
191
206
  private
192
207
 
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|
208
+ def for_uri(uri, extra_headers = {}, &block)
209
+ if @keep_alive_connections
210
+ with_persistent_connection(uri, extra_headers, &block)
211
+ else
212
+ yield build_one_shot_connection(uri, extra_headers)
213
+ end
214
+ end
215
+
216
+ def build_one_shot_connection(uri, extra_headers)
217
+ ::Faraday.new(url: uri, request: request_options) do |faraday|
198
218
  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
219
+ apply_default_headers(faraday, extra_headers)
205
220
  end
221
+ end
222
+
223
+ # Reuses a thread-local Faraday connection (one per uri) backed by
224
+ # net-http-persistent so the TCP/TLS handshake and HTTP/2 stream are
225
+ # amortised across requests. Bearer tokens and per-call headers are
226
+ # re-applied each yield because JWTs expire and extra_headers vary.
227
+ # On error, the cached connection is dropped: the underlying socket may
228
+ # be half-closed and reusing it would just fail again.
229
+ def with_persistent_connection(uri, extra_headers)
230
+ connection = persistent_connection_for(uri)
231
+ apply_default_headers(connection, extra_headers)
206
232
  yield connection
233
+ rescue StandardError
234
+ discard_persistent_connection(uri)
235
+ raise
236
+ end
237
+
238
+ def apply_default_headers(connection, extra_headers)
239
+ connection.headers["Content-Type"] = "application/json"
240
+ connection.headers["Authorization"] = "Bearer #{jwt_token}"
241
+ connection.headers["access_token_auth"] = "true"
242
+ extra_headers.each { |key, value| connection.headers[key] = value }
243
+ end
244
+
245
+ # Net::HTTP is not thread-safe, so connections are cached per (thread, uri)
246
+ # rather than shared across threads.
247
+ def persistent_connection_for(uri)
248
+ thread_connections[uri] ||= build_persistent_connection(uri)
249
+ end
250
+
251
+ def discard_persistent_connection(uri)
252
+ connection = thread_connections.delete(uri)
253
+ connection.close if connection.respond_to?(:close)
254
+ end
255
+
256
+ def thread_connections
257
+ Thread.current[@thread_connections_key] ||= {}
258
+ end
259
+
260
+ def request_options
261
+ {
262
+ timeout: @http_options.fetch(:timeout, DEFAULT_TIMEOUT),
263
+ open_timeout: @http_options.fetch(:open_timeout, DEFAULT_TIMEOUT)
264
+ }
265
+ end
266
+
267
+ def build_persistent_connection(uri)
268
+ ::Faraday.new(url: uri, request: request_options) do |faraday|
269
+ # pool_size defaults to 1: we already cache one Faraday connection per
270
+ # (thread, uri), and Net::HTTP is not thread-safe — so a single socket
271
+ # per pool is the safe default. Override only with a specific reason.
272
+ faraday.adapter :net_http_persistent, pool_size: @keep_alive_pool_size do |http|
273
+ http.idle_timeout = @keep_alive_idle_timeout_seconds
274
+ end
275
+ end
207
276
  end
208
277
 
209
278
  def build_post_body(registration_ids, options = {})
@@ -219,9 +288,14 @@ class FCM
219
288
  response_hash[:response] = "success"
220
289
  body = JSON.parse(body) unless body.empty?
221
290
  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?
291
+ unless registration_ids.empty?
292
+ response_hash[:not_registered_ids] =
293
+ build_not_registered_ids(body, registration_ids)
294
+ end
223
295
  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."
296
+ response_hash[:response] =
297
+ "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, " \
298
+ "or it contained invalid fields."
225
299
  when 401
226
300
  response_hash[:response] = "There was an error authenticating the sender account."
227
301
  when 503
@@ -234,11 +308,9 @@ class FCM
234
308
 
235
309
  def build_canonical_ids(body, registration_ids)
236
310
  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
311
+ if !body.empty? && body["canonical_ids"].positive?
312
+ body["results"].each_with_index do |result, index|
313
+ canonical_ids << { old: registration_ids[index], new: result["registration_id"] } if has_canonical_id?(result)
242
314
  end
243
315
  end
244
316
  canonical_ids
@@ -246,11 +318,9 @@ class FCM
246
318
 
247
319
  def build_not_registered_ids(body, registration_id)
248
320
  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
321
+ if !body.empty? && body["failure"].positive?
322
+ body["results"].each_with_index do |result, index|
323
+ not_registered_ids << registration_id[index] if is_not_registered?(result)
254
324
  end
255
325
  end
256
326
  not_registered_ids
@@ -270,32 +340,52 @@ class FCM
270
340
 
271
341
  def validate_condition_format?(condition)
272
342
  bad_characters = condition.gsub(
273
- /(topics|in|\s|\(|\)|(&&)|[!]|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/,
343
+ /(topics|in|\s|\(|\)|(&&)|!|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/,
274
344
  ""
275
345
  )
276
- bad_characters.length == 0
346
+ bad_characters.empty?
277
347
  end
278
348
 
279
349
  def validate_condition_topics?(condition)
280
350
  topics = condition.scan(/(?:^|\S|\s)'([^']*?)'(?:$|\S|\s)/).flatten
281
- topics.all? { |topic| topic.gsub(TOPIC_REGEX, "").length == 0 }
351
+ topics.all? { |topic| topic.gsub(TOPIC_REGEX, "").empty? }
282
352
  end
283
353
 
284
354
  def jwt_token
285
355
  scope = "https://www.googleapis.com/auth/firebase.messaging"
286
356
  @authorizer ||= Google::Auth::ServiceAccountCredentials.make_creds(
287
357
  json_key_io: json_key,
288
- scope: scope,
358
+ scope: scope
289
359
  )
290
360
  token = @authorizer.fetch_access_token!
291
361
  token["access_token"]
292
362
  end
293
363
 
364
+ def raise_credentials_error(param)
365
+ error_msg = "credentials must be an IO-like " \
366
+ "object or path. You passed"
367
+
368
+ param_klass = param.nil? ? "nil" : "a #{param.class.name}"
369
+ error_msg += " #{param_klass}."
370
+ raise InvalidCredentialError, error_msg
371
+ end
372
+
373
+ def valid_json_key_path?(path)
374
+ valid_io_object = path.respond_to?(:open)
375
+ return true if valid_io_object && File.file?(path)
376
+
377
+ max_path_len = 1024
378
+ valid_path = path.is_a?(String) && path.length <= max_path_len
379
+ valid_path && File.file?(path)
380
+ end
381
+
294
382
  def json_key
295
383
  @json_key ||= if @json_key_path.respond_to?(:read)
296
384
  @json_key_path
297
- else
385
+ elsif valid_json_key_path?(@json_key_path)
298
386
  File.open(@json_key_path)
387
+ else
388
+ raise_credentials_error(@json_key_path)
299
389
  end
300
390
  end
301
391
  end