action_push_native 0.1.0 → 0.2.0
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/README.md +14 -14
- data/app/jobs/action_push_native/notification_job.rb +1 -1
- data/lib/action_push_native/configured_notification.rb +1 -1
- data/lib/action_push_native/service/apns/apnotic_legacy_converter.rb +38 -0
- data/lib/action_push_native/service/apns/httpx_session.rb +26 -0
- data/lib/action_push_native/service/apns/token_provider.rb +33 -0
- data/lib/action_push_native/service/apns.rb +64 -83
- data/lib/action_push_native/service/fcm/httpx_session.rb +27 -0
- data/lib/action_push_native/service/fcm/token_provider.rb +33 -0
- data/lib/action_push_native/service/fcm.rb +25 -46
- data/lib/action_push_native/service/network_error_handling.rb +20 -0
- data/lib/action_push_native/version.rb +1 -1
- data/lib/action_push_native.rb +6 -2
- data/lib/generators/action_push_native/install/templates/config/push.yml.tt +11 -5
- metadata +19 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 597ea29826dc73cb9a6215038924dae218c5b415e754657ea01fa543420eb9ee
|
4
|
+
data.tar.gz: 1a058af0f6d383e2fe3b7ce0832c27f35d6f2e86026adce2871a4394c98644b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1d667e9975da93ba1a4e998103af3b5c0258c763d50931e5658c2c76f9d59db85ef43711a87ca8f86d82cb531f7e0cc907d1fed25a22ad4eaa33777f642c73c6
|
7
|
+
data.tar.gz: 6dcb6f3230a3df117044353b2d04b8ce9130e886bde3ab96ef1b93371eb906346d073efaa3185594722d52d32280b6ad4b06e2d476cab23838dcfb7edff9ed65
|
data/README.md
CHANGED
@@ -75,8 +75,8 @@ shared:
|
|
75
75
|
apple:
|
76
76
|
# Token auth params
|
77
77
|
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
|
78
|
-
key_id:
|
79
|
-
encryption_key:
|
78
|
+
key_id: <%= Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %>
|
79
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :apns, :encryption_key)&.dump %>
|
80
80
|
|
81
81
|
team_id: your_apple_team_id
|
82
82
|
# Your identifier found on https://developer.apple.com/account/resources/identifiers/list
|
@@ -85,7 +85,7 @@ shared:
|
|
85
85
|
google:
|
86
86
|
# Your Firebase project service account credentials
|
87
87
|
# See https://firebase.google.com/docs/cloud-messaging/auth-server
|
88
|
-
encryption_key:
|
88
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %>
|
89
89
|
|
90
90
|
# Firebase project_id
|
91
91
|
project_id: your_project_id
|
@@ -130,16 +130,16 @@ shared:
|
|
130
130
|
calendar:
|
131
131
|
# Token auth params
|
132
132
|
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
|
133
|
-
key_id:
|
134
|
-
encryption_key:
|
133
|
+
key_id: <%= Rails.application.credentials.dig(:action_push_native, :apns, :calendar, :key_id) %>
|
134
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :apns, :calendar, :encryption_key)&.dump %>
|
135
135
|
# Your identifier found on https://developer.apple.com/account/resources/identifiers/list
|
136
136
|
topic: calendar.bundle.identifier
|
137
137
|
|
138
138
|
email:
|
139
139
|
# Token auth params
|
140
140
|
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
|
141
|
-
key_id:
|
142
|
-
encryption_key:
|
141
|
+
key_id: <%= Rails.application.credentials.dig(:action_push_native, :apns, :email, :key_id) %>
|
142
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :apns, :email, :encryption_key)&.dump %>
|
143
143
|
# Your identifier found on https://developer.apple.com/account/resources/identifiers/list
|
144
144
|
topic: email.bundle.identifier
|
145
145
|
|
@@ -147,7 +147,7 @@ shared:
|
|
147
147
|
calendar:
|
148
148
|
# Your Firebase project service account credentials
|
149
149
|
# See https://firebase.google.com/docs/cloud-messaging/auth-server
|
150
|
-
encryption_key:
|
150
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :calendar, :encryption_key)&.dump %>
|
151
151
|
|
152
152
|
# Firebase project_id
|
153
153
|
project_id: calendar_project_id
|
@@ -155,7 +155,7 @@ shared:
|
|
155
155
|
email:
|
156
156
|
# Your Firebase project service account credentials
|
157
157
|
# See https://firebase.google.com/docs/cloud-messaging/auth-server
|
158
|
-
encryption_key:
|
158
|
+
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :email, :encryption_key)&.dump %>
|
159
159
|
|
160
160
|
# Firebase project_id
|
161
161
|
project_id: email_project_id
|
@@ -212,7 +212,7 @@ You can use `with_apple` for Apple and `with_google` for Google:
|
|
212
212
|
|
213
213
|
```ruby
|
214
214
|
notification = ApplicationPushNotification
|
215
|
-
.with_apple(category: "observable")
|
215
|
+
.with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1")
|
216
216
|
.with_google(data: { badge: 1 })
|
217
217
|
.new(title: "Hello world!")
|
218
218
|
```
|
@@ -234,7 +234,7 @@ and `body`.
|
|
234
234
|
You can create a silent notification via the `silent` method:
|
235
235
|
|
236
236
|
```ruby
|
237
|
-
notification = ApplicationPushNotification.silent.with_data(id: 1)
|
237
|
+
notification = ApplicationPushNotification.silent.with_data(id: 1).new
|
238
238
|
```
|
239
239
|
|
240
240
|
This will create a silent notification for both Apple and Google platforms and sets an application
|
@@ -271,7 +271,7 @@ by adding extra arguments to the notification constructor:
|
|
271
271
|
data = { calendar_id: @calendar.id, identity_id: @identity.id }
|
272
272
|
|
273
273
|
notification = CalendarPushNotification
|
274
|
-
.with_apple(
|
274
|
+
.with_apple(data)
|
275
275
|
.with_google(data: data)
|
276
276
|
.new(calendar_id: 123)
|
277
277
|
|
@@ -311,7 +311,7 @@ end
|
|
311
311
|
| :sound | The sound to play when the notification is received.
|
312
312
|
| :high_priority | Whether the notification should be sent with high priority (default: true).
|
313
313
|
| :google_data | The Google-specific payload for the notification.
|
314
|
-
| :apple_data | The Apple-specific payload for the notification.
|
314
|
+
| :apple_data | The Apple-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc.
|
315
315
|
| :data | The data payload for the notification, sent to all platforms.
|
316
316
|
| ** | Any additional attributes passed to the constructor will be merged in the `context` hash.
|
317
317
|
|
@@ -320,7 +320,7 @@ end
|
|
320
320
|
| Name | Description
|
321
321
|
|------------------|------------
|
322
322
|
| :with_apple | Set the Apple-specific payload for the notification.
|
323
|
-
| :with_google | Set the Google-specific payload for the notification.
|
323
|
+
| :with_google | Set the Google-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc.
|
324
324
|
| :with_data | Set the data payload for the notification, sent to all platforms.
|
325
325
|
| :silent | Create a silent notification that does not trigger a visual alert on the device.
|
326
326
|
|
@@ -40,7 +40,7 @@ module ActionPushNative
|
|
40
40
|
|
41
41
|
with_options retry_options do
|
42
42
|
retry_on TimeoutError, wait: 1.minute
|
43
|
-
retry_on ConnectionError,
|
43
|
+
retry_on ConnectionError, HTTPX::PoolTimeoutError, attempts: 20
|
44
44
|
|
45
45
|
# Altough unexpected, these are short-lived errors that can be retried most of the times.
|
46
46
|
retry_on ForbiddenError, BadRequestError
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Converts the legacy `apple_data` format from the Apnotic gem
|
4
|
+
# to the new format expected by the APNs API.
|
5
|
+
#
|
6
|
+
# Temporary compatibility layer: It will be removed in the next release.
|
7
|
+
class ActionPushNative::Service::Apns::ApnoticLegacyConverter
|
8
|
+
APS_FIELDS = %i[
|
9
|
+
alert badge sound content_available category url_args mutable_content thread_id
|
10
|
+
target_content_id interruption_level relevance_score
|
11
|
+
stale_date content_state timestamp event dismissal_date
|
12
|
+
].freeze
|
13
|
+
APNS_HEADERS = %i[ expiration priority topic push_type ]
|
14
|
+
|
15
|
+
def self.convert(apple_data)
|
16
|
+
apple_data.each_with_object({}) do |(key, value), converted|
|
17
|
+
if key.in?(APS_FIELDS)
|
18
|
+
converted[:aps] ||= {}
|
19
|
+
converted_key = key.to_s.dasherize.to_sym
|
20
|
+
converted[:aps][converted_key] = value
|
21
|
+
ActionPushNative.deprecator.warn("Passing the `#{key}` field directly is deprecated. Please use `.with_apple(aps: { \"#{converted_key}\": ... })` instead.")
|
22
|
+
elsif key.in?(APNS_HEADERS)
|
23
|
+
converted_key = "apns-#{key.to_s.dasherize}".to_sym
|
24
|
+
converted[converted_key] = value
|
25
|
+
ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.")
|
26
|
+
elsif key == :apns_collapse_id
|
27
|
+
converted_key = key.to_s.dasherize.to_sym
|
28
|
+
converted[converted_key] = value
|
29
|
+
ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.")
|
30
|
+
elsif key == :custom_payload
|
31
|
+
converted.merge!(value)
|
32
|
+
ActionPushNative.deprecator.warn("Passing `custom_payload` is deprecated. Please use `.with_apple(#{value})` instead.")
|
33
|
+
else
|
34
|
+
converted[key] = value
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActionPushNative::Service::Apns::HttpxSession
|
4
|
+
DEFAULT_POOL_SIZE = 5
|
5
|
+
DEFAULT_REQUEST_TIMEOUT = 30.seconds
|
6
|
+
DEVELOPMENT_SERVER_URL = "https://api.sandbox.push.apple.com:443"
|
7
|
+
PRODUCTION_SERVER_URL = "https://api.push.apple.com:443"
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
@session = \
|
11
|
+
HTTPX.
|
12
|
+
plugin(:persistent, close_on_fork: true).
|
13
|
+
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
|
14
|
+
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
|
15
|
+
with(origin: config[:connect_to_development_server] ? DEVELOPMENT_SERVER_URL : PRODUCTION_SERVER_URL)
|
16
|
+
@token_provider = ActionPushNative::Service::Apns::TokenProvider.new(config)
|
17
|
+
end
|
18
|
+
|
19
|
+
def post(*uri, **options)
|
20
|
+
options[:headers][:authorization] = "Bearer #{token_provider.fresh_access_token}"
|
21
|
+
session.post(*uri, **options)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
attr_reader :token_provider, :session
|
26
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActionPushNative::Service::Apns::TokenProvider
|
4
|
+
EXPIRED = -1
|
5
|
+
|
6
|
+
def initialize(config)
|
7
|
+
@config = config
|
8
|
+
@expires_at = EXPIRED
|
9
|
+
end
|
10
|
+
|
11
|
+
def fresh_access_token
|
12
|
+
regenerate_if_expired
|
13
|
+
token
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
attr_reader :config, :token, :expires_at
|
18
|
+
|
19
|
+
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns#Refresh-your-token-regularly
|
20
|
+
def regenerate_if_expired
|
21
|
+
if Time.now.utc >= expires_at
|
22
|
+
@expires_at = 30.minutes.from_now.utc
|
23
|
+
@token = generate
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def generate
|
28
|
+
payload = { iss: config.fetch(:team_id), iat: Time.now.utc.to_i }
|
29
|
+
header = { kid: config.fetch(:key_id) }
|
30
|
+
private_key = OpenSSL::PKey::EC.new(config.fetch(:encryption_key))
|
31
|
+
JWT.encode(payload, private_key, "ES256", header)
|
32
|
+
end
|
33
|
+
end
|
@@ -3,118 +3,99 @@
|
|
3
3
|
module ActionPushNative
|
4
4
|
module Service
|
5
5
|
class Apns
|
6
|
-
|
7
|
-
|
6
|
+
include NetworkErrorHandling
|
7
|
+
|
8
|
+
# Per-application HTTPX session
|
9
|
+
cattr_accessor :httpx_sessions
|
8
10
|
|
9
11
|
def initialize(config)
|
10
12
|
@config = config
|
11
13
|
end
|
12
14
|
|
13
|
-
# Per-application connection pools
|
14
|
-
cattr_accessor :connection_pools
|
15
|
-
|
16
15
|
def push(notification)
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
response = connection.push \
|
25
|
-
apnotic_notification,
|
26
|
-
timeout: config[:request_timeout] || DEFAULT_TIMEOUT
|
27
|
-
raise connection_error if connection_error
|
28
|
-
handle_response_error(response) unless response&.ok?
|
29
|
-
end
|
30
|
-
end
|
16
|
+
notification.apple_data = ApnoticLegacyConverter.convert(notification.apple_data) if notification.apple_data.present?
|
17
|
+
|
18
|
+
headers, payload = headers_from(notification), payload_from(notification)
|
19
|
+
Rails.logger.info("Pushing APNs notification: #{headers[:"apns-id"]}")
|
20
|
+
response = httpx_session.post("3/device/#{notification.token}", json: payload, headers: headers)
|
21
|
+
handle_error(response) if response.error
|
31
22
|
end
|
32
23
|
|
33
24
|
private
|
34
|
-
attr_reader :config
|
35
|
-
|
36
|
-
def reset_connection_error
|
37
|
-
@connection_error = nil
|
38
|
-
end
|
25
|
+
attr_reader :config
|
39
26
|
|
40
|
-
|
41
|
-
|
42
|
-
|
27
|
+
PRIORITIES = { high: 10, normal: 5 }.freeze
|
28
|
+
HEADERS = %i[ apns-id apns-push-type apns-priority apns-topic apns-expiration apns-collapse-id ].freeze
|
29
|
+
|
30
|
+
def headers_from(notification)
|
31
|
+
push_type = notification.apple_data&.dig(:aps, :"content-available") == 1 ? "background" : "alert"
|
32
|
+
custom_apple_headers = notification.apple_data&.slice(*HEADERS) || {}
|
33
|
+
|
34
|
+
{
|
35
|
+
"apns-push-type": push_type,
|
36
|
+
"apns-id": SecureRandom.uuid,
|
37
|
+
"apns-priority": notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal],
|
38
|
+
"apns-topic": config.fetch(:topic)
|
39
|
+
}.merge(custom_apple_headers).compact
|
43
40
|
end
|
44
41
|
|
45
|
-
def
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
42
|
+
def payload_from(notification)
|
43
|
+
payload = \
|
44
|
+
{
|
45
|
+
aps: {
|
46
|
+
alert: { title: notification.title, body: notification.body },
|
47
|
+
badge: notification.badge,
|
48
|
+
"thread-id": notification.thread_id,
|
49
|
+
sound: notification.sound
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
payload = payload.merge notification.data if notification.data.present?
|
54
|
+
custom_apple_payload = notification.apple_data&.except(*HEADERS) || {}
|
55
|
+
payload = payload.deep_merge custom_apple_payload
|
56
|
+
|
57
|
+
payload.dig(:aps, :alert)&.compact!
|
58
|
+
payload[:aps]&.compact_blank!
|
59
|
+
payload.compact
|
57
60
|
end
|
58
61
|
|
59
|
-
def
|
60
|
-
|
61
|
-
|
62
|
-
rescue Errno::ETIMEDOUT => e
|
63
|
-
raise ActionPushNative::TimeoutError, e.message
|
64
|
-
rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
65
|
-
raise ActionPushNative::ConnectionError, e.message
|
66
|
-
rescue OpenSSL::SSL::SSLError => e
|
67
|
-
if e.message.include?("SSL_connect")
|
68
|
-
raise ActionPushNative::ConnectionError, e.message
|
69
|
-
else
|
70
|
-
raise
|
71
|
-
end
|
72
|
-
end
|
62
|
+
def httpx_session
|
63
|
+
self.class.httpx_sessions ||= {}
|
64
|
+
self.class.httpx_sessions[config] ||= HttpxSession.new(config)
|
73
65
|
end
|
74
66
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
n.alert = { title: notification.title, body: notification.body }.compact
|
81
|
-
n.badge = notification.badge
|
82
|
-
n.thread_id = notification.thread_id
|
83
|
-
n.sound = notification.sound
|
84
|
-
n.priority = notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal]
|
85
|
-
n.custom_payload = notification.data
|
86
|
-
notification.apple_data&.each do |key, value|
|
87
|
-
n.public_send("#{key.to_s.underscore}=", value)
|
88
|
-
end
|
67
|
+
def handle_error(response)
|
68
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
69
|
+
handle_network_error(response.error)
|
70
|
+
else
|
71
|
+
handle_apns_error(response)
|
89
72
|
end
|
90
73
|
end
|
91
74
|
|
92
|
-
def
|
93
|
-
|
94
|
-
reason = response.body["reason"]
|
75
|
+
def handle_apns_error(response)
|
76
|
+
status = response.status
|
77
|
+
reason = JSON.parse(response.body.to_s)["reason"] unless response.body.empty?
|
95
78
|
|
96
|
-
Rails.logger.error("APNs response error #{
|
79
|
+
Rails.logger.error("APNs response error #{status}: #{reason}") if reason
|
97
80
|
|
98
|
-
case [
|
99
|
-
in [
|
100
|
-
raise ActionPushNative::TimeoutError
|
101
|
-
in [ "400", "BadDeviceToken" ]
|
81
|
+
case [ status, reason ]
|
82
|
+
in [ 400, "BadDeviceToken" ]
|
102
83
|
raise ActionPushNative::TokenError, reason
|
103
|
-
in [
|
84
|
+
in [ 400, "DeviceTokenNotForTopic" ]
|
104
85
|
raise ActionPushNative::BadDeviceTopicError, reason
|
105
|
-
in [
|
86
|
+
in [ 400, _ ]
|
106
87
|
raise ActionPushNative::BadRequestError, reason
|
107
|
-
in [
|
88
|
+
in [ 403, _ ]
|
108
89
|
raise ActionPushNative::ForbiddenError, reason
|
109
|
-
in [
|
90
|
+
in [ 404, _ ]
|
110
91
|
raise ActionPushNative::NotFoundError, reason
|
111
|
-
in [
|
92
|
+
in [ 410, _ ]
|
112
93
|
raise ActionPushNative::TokenError, reason
|
113
|
-
in [
|
94
|
+
in [ 413, _ ]
|
114
95
|
raise ActionPushNative::PayloadTooLargeError, reason
|
115
|
-
in [
|
96
|
+
in [ 429, _ ]
|
116
97
|
raise ActionPushNative::TooManyRequestsError, reason
|
117
|
-
in [
|
98
|
+
in [ 503, _ ]
|
118
99
|
raise ActionPushNative::ServiceUnavailableError, reason
|
119
100
|
else
|
120
101
|
raise ActionPushNative::InternalServerError, reason
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActionPushNative::Service::Fcm::HttpxSession
|
4
|
+
# FCM suggests at least a 10s timeout for requests, we set 15 to add some buffer.
|
5
|
+
# https://firebase.google.com/docs/cloud-messaging/scale-fcm#timeouts
|
6
|
+
DEFAULT_REQUEST_TIMEOUT = 15.seconds
|
7
|
+
DEFAULT_POOL_SIZE = 5
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
@session = \
|
11
|
+
HTTPX.
|
12
|
+
plugin(:persistent, close_on_fork: true).
|
13
|
+
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
|
14
|
+
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
|
15
|
+
with(origin: "https://fcm.googleapis.com")
|
16
|
+
@token_provider = ActionPushNative::Service::Fcm::TokenProvider.new(config)
|
17
|
+
end
|
18
|
+
|
19
|
+
def post(*uri, **options)
|
20
|
+
options[:headers] ||= {}
|
21
|
+
options[:headers][:authorization] = "Bearer #{token_provider.fresh_access_token}"
|
22
|
+
session.post(*uri, **options)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
attr_reader :token_provider, :session
|
27
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActionPushNative::Service::Fcm::TokenProvider
|
4
|
+
EXPIRED = -1
|
5
|
+
|
6
|
+
def initialize(config)
|
7
|
+
@config = config
|
8
|
+
@expires_at = EXPIRED
|
9
|
+
end
|
10
|
+
|
11
|
+
def fresh_access_token
|
12
|
+
regenerate_if_expired
|
13
|
+
token
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
attr_reader :config, :token, :expires_at
|
18
|
+
|
19
|
+
def regenerate_if_expired
|
20
|
+
regenerate if Time.now.utc >= expires_at
|
21
|
+
end
|
22
|
+
|
23
|
+
REFRESH_BUFFER = 1.minutes
|
24
|
+
|
25
|
+
def regenerate
|
26
|
+
authorizer = Google::Auth::ServiceAccountCredentials.make_creds \
|
27
|
+
json_key_io: StringIO.new(config.fetch(:encryption_key)),
|
28
|
+
scope: "https://www.googleapis.com/auth/firebase.messaging"
|
29
|
+
oauth2 = authorizer.fetch_access_token!
|
30
|
+
@token = oauth2["access_token"]
|
31
|
+
@expires_at = oauth2["expires_in"].seconds.from_now.utc - REFRESH_BUFFER
|
32
|
+
end
|
33
|
+
end
|
@@ -3,22 +3,28 @@
|
|
3
3
|
module ActionPushNative
|
4
4
|
module Service
|
5
5
|
class Fcm
|
6
|
-
|
7
|
-
|
8
|
-
|
6
|
+
include NetworkErrorHandling
|
7
|
+
|
8
|
+
# Per-application HTTPX session
|
9
|
+
cattr_accessor :httpx_sessions
|
9
10
|
|
10
11
|
def initialize(config)
|
11
12
|
@config = config
|
12
13
|
end
|
13
14
|
|
14
15
|
def push(notification)
|
15
|
-
response =
|
16
|
-
handle_error(response)
|
16
|
+
response = httpx_session.post("v1/projects/#{config.fetch(:project_id)}/messages:send", json: payload_from(notification))
|
17
|
+
handle_error(response) if response.error
|
17
18
|
end
|
18
19
|
|
19
20
|
private
|
20
21
|
attr_reader :config
|
21
22
|
|
23
|
+
def httpx_session
|
24
|
+
self.class.httpx_sessions ||= {}
|
25
|
+
self.class.httpx_sessions[config] ||= HttpxSession.new(config)
|
26
|
+
end
|
27
|
+
|
22
28
|
def payload_from(notification)
|
23
29
|
deep_compact({
|
24
30
|
message: {
|
@@ -56,64 +62,37 @@ module ActionPushNative
|
|
56
62
|
hash.compact.transform_values(&:to_s)
|
57
63
|
end
|
58
64
|
|
59
|
-
def
|
60
|
-
|
61
|
-
|
62
|
-
request["Authorization"] = "Bearer #{access_token}"
|
63
|
-
request["Content-Type"] = "application/json"
|
64
|
-
request.body = payload.to_json
|
65
|
-
|
66
|
-
rescue_and_reraise_network_errors do
|
67
|
-
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: config[:request_timeout] || DEFAULT_TIMEOUT) do |http|
|
68
|
-
http.request(request)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
72
|
-
|
73
|
-
def rescue_and_reraise_network_errors
|
74
|
-
yield
|
75
|
-
rescue Net::ReadTimeout, Net::OpenTimeout => e
|
76
|
-
raise ActionPushNative::TimeoutError, e.message
|
77
|
-
rescue Errno::ECONNRESET, SocketError => e
|
78
|
-
raise ActionPushNative::ConnectionError, e.message
|
79
|
-
rescue OpenSSL::SSL::SSLError => e
|
80
|
-
if e.message.include?("SSL_connect")
|
81
|
-
raise ActionPushNative::ConnectionError, e.message
|
65
|
+
def handle_error(response)
|
66
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
67
|
+
handle_network_error(response.error)
|
82
68
|
else
|
83
|
-
|
69
|
+
handle_fcm_error(response)
|
84
70
|
end
|
85
71
|
end
|
86
72
|
|
87
|
-
def
|
88
|
-
|
89
|
-
json_key_io: StringIO.new(config.fetch(:encryption_key)),
|
90
|
-
scope: "https://www.googleapis.com/auth/firebase.messaging"
|
91
|
-
authorizer.fetch_access_token!["access_token"]
|
92
|
-
end
|
93
|
-
|
94
|
-
def handle_error(response)
|
95
|
-
code = response.code
|
73
|
+
def handle_fcm_error(response)
|
74
|
+
status = response.status
|
96
75
|
reason = \
|
97
76
|
begin
|
98
|
-
JSON.parse(response.body).dig("error", "message")
|
77
|
+
JSON.parse(response.body.to_s).dig("error", "message")
|
99
78
|
rescue JSON::ParserError
|
100
|
-
response.body
|
79
|
+
response.body.to_s
|
101
80
|
end
|
102
81
|
|
103
|
-
Rails.logger.error("FCM response error #{
|
82
|
+
Rails.logger.error("FCM response error #{status}: #{reason}")
|
104
83
|
|
105
84
|
case
|
106
85
|
when reason =~ /message is too big/i
|
107
86
|
raise ActionPushNative::PayloadTooLargeError, reason
|
108
|
-
when
|
87
|
+
when status == 400
|
109
88
|
raise ActionPushNative::BadRequestError, reason
|
110
|
-
when
|
89
|
+
when status == 404
|
111
90
|
raise ActionPushNative::TokenError, reason
|
112
|
-
when
|
91
|
+
when status.in?([ 401, 403 ])
|
113
92
|
raise ActionPushNative::ForbiddenError, reason
|
114
|
-
when
|
93
|
+
when status == 429
|
115
94
|
raise ActionPushNative::TooManyRequestsError, reason
|
116
|
-
when
|
95
|
+
when status == 503
|
117
96
|
raise ActionPushNative::ServiceUnavailableError, reason
|
118
97
|
else
|
119
98
|
raise ActionPushNative::InternalServerError, reason
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ActionPushNative::Service::NetworkErrorHandling
|
2
|
+
private
|
3
|
+
|
4
|
+
def handle_network_error(error)
|
5
|
+
case error
|
6
|
+
when Errno::ETIMEDOUT, HTTPX::TimeoutError
|
7
|
+
raise ActionPushNative::TimeoutError, error.message
|
8
|
+
when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
|
9
|
+
SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError,
|
10
|
+
HTTPX::TLSError, HTTPX::Connection::HTTP2::Error
|
11
|
+
raise ActionPushNative::ConnectionError, error.message
|
12
|
+
when OpenSSL::SSL::SSLError
|
13
|
+
if error.message.include?("SSL_connect")
|
14
|
+
raise ActionPushNative::ConnectionError, error.message
|
15
|
+
else
|
16
|
+
raise
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/action_push_native.rb
CHANGED
@@ -3,9 +3,9 @@
|
|
3
3
|
require "zeitwerk"
|
4
4
|
require "action_push_native/engine"
|
5
5
|
require "action_push_native/errors"
|
6
|
-
require "
|
7
|
-
require "apnotic"
|
6
|
+
require "httpx"
|
8
7
|
require "googleauth"
|
8
|
+
require "jwt"
|
9
9
|
|
10
10
|
loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
11
11
|
loader.ignore("#{__dir__}/generators")
|
@@ -37,4 +37,8 @@ module ActionPushNative
|
|
37
37
|
platform_config
|
38
38
|
end
|
39
39
|
end
|
40
|
+
|
41
|
+
def self.deprecator
|
42
|
+
@deprecator ||= ActiveSupport::Deprecation.new
|
43
|
+
end
|
40
44
|
end
|
@@ -1,16 +1,17 @@
|
|
1
1
|
shared:
|
2
|
+
# Use bin/rails credentials:edit to set the apns secrets (as action_push_native:apns:key_id|encryption_key)
|
2
3
|
apple:
|
3
4
|
# Token auth params
|
4
5
|
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
|
5
|
-
key_id:
|
6
|
-
encryption_key:
|
6
|
+
key_id: <%%= Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %>
|
7
|
+
encryption_key: <%%= Rails.application.credentials.dig(:action_push_native, :apns, :encryption_key)&.dump %>
|
7
8
|
|
8
9
|
team_id: your_apple_team_id
|
9
10
|
# Your identifier found on https://developer.apple.com/account/resources/identifiers/list
|
10
11
|
topic: your.bundle.identifier
|
11
12
|
|
12
13
|
# Set this to the number of threads used to process notifications (default: 5).
|
13
|
-
# When the pool size is too small a
|
14
|
+
# When the pool size is too small a HTTPX::PoolTimeoutError error will be raised.
|
14
15
|
# connection_pool_size: 5
|
15
16
|
|
16
17
|
# Change the request timeout (default: 30).
|
@@ -20,15 +21,20 @@ shared:
|
|
20
21
|
# Please note that anything built directly from Xcode and loaded on your phone will have
|
21
22
|
# the app generate DEVELOPMENT tokens, while everything else (TestFlight, Apple Store, ...)
|
22
23
|
# will be considered as PRODUCTION environment.
|
23
|
-
# connect_to_development_server:
|
24
|
+
# connect_to_development_server: <%%# Rails.env.development? %>
|
24
25
|
|
26
|
+
# Use bin/rails credentials:edit to set the fcm secrets (as action_push_native:fcm:encryption_key)
|
25
27
|
google:
|
26
28
|
# Your Firebase project service account credentials
|
27
29
|
# See https://firebase.google.com/docs/cloud-messaging/auth-server
|
28
|
-
encryption_key:
|
30
|
+
encryption_key: <%%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %>
|
29
31
|
|
30
32
|
# Firebase project_id
|
31
33
|
project_id: your_project_id
|
32
34
|
|
35
|
+
# Set this to the number of threads used to process notifications (default: 5).
|
36
|
+
# When the pool size is too small a HTTPX::PoolTimeoutError error will be raised.
|
37
|
+
# connection_pool_size: 5
|
38
|
+
|
33
39
|
# Change the request timeout (default: 15).
|
34
40
|
# request_timeout: 30
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: action_push_native
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jacopo Beschi
|
@@ -52,47 +52,47 @@ dependencies:
|
|
52
52
|
- !ruby/object:Gem::Version
|
53
53
|
version: '8.0'
|
54
54
|
- !ruby/object:Gem::Dependency
|
55
|
-
name:
|
55
|
+
name: httpx
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
57
57
|
requirements:
|
58
58
|
- - "~>"
|
59
59
|
- !ruby/object:Gem::Version
|
60
|
-
version: '1.
|
60
|
+
version: '1.6'
|
61
61
|
type: :runtime
|
62
62
|
prerelease: false
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
64
64
|
requirements:
|
65
65
|
- - "~>"
|
66
66
|
- !ruby/object:Gem::Version
|
67
|
-
version: '1.
|
67
|
+
version: '1.6'
|
68
68
|
- !ruby/object:Gem::Dependency
|
69
|
-
name:
|
69
|
+
name: jwt
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
71
71
|
requirements:
|
72
|
-
- - "
|
72
|
+
- - ">="
|
73
73
|
- !ruby/object:Gem::Version
|
74
|
-
version: '
|
74
|
+
version: '2'
|
75
75
|
type: :runtime
|
76
76
|
prerelease: false
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
78
78
|
requirements:
|
79
|
-
- - "
|
79
|
+
- - ">="
|
80
80
|
- !ruby/object:Gem::Version
|
81
|
-
version: '
|
81
|
+
version: '2'
|
82
82
|
- !ruby/object:Gem::Dependency
|
83
|
-
name:
|
83
|
+
name: googleauth
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
85
85
|
requirements:
|
86
86
|
- - "~>"
|
87
87
|
- !ruby/object:Gem::Version
|
88
|
-
version: '
|
88
|
+
version: '1.14'
|
89
89
|
type: :runtime
|
90
90
|
prerelease: false
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
92
92
|
requirements:
|
93
93
|
- - "~>"
|
94
94
|
- !ruby/object:Gem::Version
|
95
|
-
version: '
|
95
|
+
version: '1.14'
|
96
96
|
description: Send push notifications to mobile apps
|
97
97
|
email:
|
98
98
|
- jacopo@37signals.com
|
@@ -112,7 +112,13 @@ files:
|
|
112
112
|
- lib/action_push_native/errors.rb
|
113
113
|
- lib/action_push_native/notification.rb
|
114
114
|
- lib/action_push_native/service/apns.rb
|
115
|
+
- lib/action_push_native/service/apns/apnotic_legacy_converter.rb
|
116
|
+
- lib/action_push_native/service/apns/httpx_session.rb
|
117
|
+
- lib/action_push_native/service/apns/token_provider.rb
|
115
118
|
- lib/action_push_native/service/fcm.rb
|
119
|
+
- lib/action_push_native/service/fcm/httpx_session.rb
|
120
|
+
- lib/action_push_native/service/fcm/token_provider.rb
|
121
|
+
- lib/action_push_native/service/network_error_handling.rb
|
116
122
|
- lib/action_push_native/version.rb
|
117
123
|
- lib/generators/action_push_native/install/install_generator.rb
|
118
124
|
- lib/generators/action_push_native/install/templates/app/jobs/application_push_notification_job.rb.tt
|
@@ -139,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
139
145
|
- !ruby/object:Gem::Version
|
140
146
|
version: '0'
|
141
147
|
requirements: []
|
142
|
-
rubygems_version: 3.6.
|
148
|
+
rubygems_version: 3.6.9
|
143
149
|
specification_version: 4
|
144
150
|
summary: Send push notifications to mobile apps
|
145
151
|
test_files: []
|