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 +4 -4
- data/.github/workflows/ci.yml +16 -1
- data/.hound.yml +2 -0
- data/.rubocop.yml +67 -0
- data/Gemfile +8 -5
- data/README.md +43 -0
- data/Rakefile +9 -7
- data/fcm.gemspec +10 -8
- data/lib/fcm.rb +145 -55
- data/spec/fcm_spec.rb +289 -124
- data/spec/spec_helper.rb +7 -5
- metadata +33 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f15e3ef0efb046941ee9c8e615206668feb9e0f087e03be74b25303d40a8ebfe
|
|
4
|
+
data.tar.gz: 492c261d5f4d58a0511d809aa7ad7278fdc18462590459ec8027a7215b146724
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd794a9f1aedf3f82ae9c1e43f299ab8be6a432738383d6db2d8fc1add8b44577e50ee1563d00bd7878d801e2eff46994b9a84d06b18a9b4892d2d6d9146a950
|
|
7
|
+
data.tar.gz: bd237deb478ab400e9e8e82cc6679c6c2560727757c8a74b25a2db51df7473b012f00b8019402f7426c601a31710dcdd28710cab9b1e96de132bdbd41b31e109
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
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
|
|
5
|
-
gem
|
|
6
|
-
gem
|
|
7
|
-
gem
|
|
8
|
-
gem
|
|
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
|
-
|
|
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
|
|
6
|
+
require "ci/reporter/rake/rspec"
|
|
5
7
|
|
|
6
|
-
RSpec::Core::RakeTask.new(:
|
|
7
|
-
t.pattern =
|
|
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 =
|
|
12
|
-
spec.rspec_opts = [
|
|
13
|
+
spec.pattern = "spec/**/*_spec.rb"
|
|
14
|
+
spec.rspec_opts = ["--format documentation"]
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
task :
|
|
17
|
+
task default: :spec
|
data/fcm.gemspec
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
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.
|
|
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 =
|
|
12
|
-
s.description =
|
|
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.
|
|
23
|
-
s.
|
|
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 = {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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|\(|\)|(&&)
|
|
343
|
+
/(topics|in|\s|\(|\)|(&&)|!|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/,
|
|
274
344
|
""
|
|
275
345
|
)
|
|
276
|
-
bad_characters.
|
|
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, "").
|
|
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
|
-
|
|
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
|