Skip to content

Commit d2ab84b

Browse files
committed
Switch away from Apnotic
Removes the `apnotic`→`net-http2` depencency in favor of a custom implementation using the `httpx` client. **Backward compatibility:** The `apple_data` payload now expects a format which matches exactly the [APNs API](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification#Payload-key-reference). Previously, the [Apnotic](https://github.com/ostinelli/apnotic) format was used instead. The key differences are: - The field keys now use dash `-` as a separator instead of an underscore `_`. - The header fields can be overridden using their exact name, e.g. `apns-priority` instead of a custom name set by Apnotic (`priority`). *before:* ```ruby notification = ApplicationPushNotification .with_apple(category: "observable", thread_id: "greeting", priority: "1") .new(title: "Hello world!") ``` *after:* ```ruby notification = ApplicationPushNotification .with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1") .new(title: "Hello world!") ``` There is a temporary backward-compatibility layer which takes care of the translation from the Apnotic format, but it will be removed in the next release. See #25 (comment)
1 parent 501b9ae commit d2ab84b

File tree

15 files changed

+326
-155
lines changed

15 files changed

+326
-155
lines changed

Gemfile.lock

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ PATH
44
action_push_native (0.1.0)
55
activejob (>= 8.0)
66
activerecord (>= 8.0)
7-
apnotic (~> 1.7)
87
googleauth (~> 1.14)
8+
httpx (~> 1.6)
9+
jwt (>= 2)
910
net-http (~> 0.6)
1011
railties (>= 8.0)
1112

@@ -52,9 +53,6 @@ GEM
5253
uri (>= 0.13.1)
5354
addressable (2.8.7)
5455
public_suffix (>= 2.0.2, < 7.0)
55-
apnotic (1.7.2)
56-
connection_pool (~> 2)
57-
net-http2 (>= 0.18.3, < 2)
5856
ast (2.4.3)
5957
base64 (0.3.0)
6058
benchmark (0.4.1)
@@ -92,6 +90,8 @@ GEM
9290
signet (>= 0.16, < 2.a)
9391
hashdiff (1.2.0)
9492
http-2 (1.1.1)
93+
httpx (1.6.0)
94+
http-2 (>= 1.0.0)
9595
i18n (1.14.7)
9696
concurrent-ruby (~> 1.0)
9797
io-console (0.8.1)
@@ -114,8 +114,6 @@ GEM
114114
multi_json (1.17.0)
115115
net-http (0.6.0)
116116
uri
117-
net-http2 (0.19.0)
118-
http-2 (>= 1.0)
119117
nokogiri (1.18.9-aarch64-linux-gnu)
120118
racc (~> 1.4)
121119
nokogiri (1.18.9-aarch64-linux-musl)

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ You can use `with_apple` for Apple and `with_google` for Google:
212212

213213
```ruby
214214
notification = ApplicationPushNotification
215-
.with_apple(category: "observable")
215+
.with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1")
216216
.with_google(data: { badge: 1 })
217217
.new(title: "Hello world!")
218218
```
@@ -234,7 +234,7 @@ and `body`.
234234
You can create a silent notification via the `silent` method:
235235

236236
```ruby
237-
notification = ApplicationPushNotification.silent.with_data(id: 1)
237+
notification = ApplicationPushNotification.silent.with_data(id: 1).new
238238
```
239239

240240
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:
271271
data = { calendar_id: @calendar.id, identity_id: @identity.id }
272272
273273
notification = CalendarPushNotification
274-
.with_apple(custom_payload: data)
274+
.with_apple(data)
275275
.with_google(data: data)
276276
.new(calendar_id: 123)
277277
@@ -311,7 +311,7 @@ end
311311
| :sound | The sound to play when the notification is received.
312312
| :high_priority | Whether the notification should be sent with high priority (default: true).
313313
| :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.
315315
| :data | The data payload for the notification, sent to all platforms.
316316
| ** | Any additional attributes passed to the constructor will be merged in the `context` hash.
317317

@@ -320,7 +320,7 @@ end
320320
| Name | Description
321321
|------------------|------------
322322
| :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.
324324
| :with_data | Set the data payload for the notification, sent to all platforms.
325325
| :silent | Create a silent notification that does not trigger a visual alert on the device.
326326

action_push_native.gemspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Gem::Specification.new do |spec|
3535
spec.add_dependency "activerecord", rails_version
3636
spec.add_dependency "activejob", rails_version
3737
spec.add_dependency "railties", rails_version
38-
spec.add_dependency "apnotic", "~> 1.7"
38+
spec.add_dependency "httpx", "~> 1.6"
39+
spec.add_dependency "jwt", ">= 2"
3940
spec.add_dependency "googleauth", "~> 1.14"
4041
spec.add_dependency "net-http", "~> 0.6"
4142
end

app/jobs/action_push_native/notification_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def exponential_backoff_delay(executions)
4040

4141
with_options retry_options do
4242
retry_on TimeoutError, wait: 1.minute
43-
retry_on ConnectionError, ConnectionPool::TimeoutError, attempts: 20
43+
retry_on ConnectionError, HTTPX::PoolTimeoutError, attempts: 20
4444

4545
# Altough unexpected, these are short-lived errors that can be retried most of the times.
4646
retry_on ForbiddenError, BadRequestError

lib/action_push_native.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
require "action_push_native/engine"
55
require "action_push_native/errors"
66
require "net/http"
7-
require "apnotic"
7+
require "httpx"
88
require "googleauth"
9+
require "jwt"
910

1011
loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
1112
loader.ignore("#{__dir__}/generators")
@@ -37,4 +38,8 @@ def self.config_for(platform, notification)
3738
platform_config
3839
end
3940
end
41+
42+
def self.deprecator
43+
@deprecator ||= ActiveSupport::Deprecation.new
44+
end
4045
end

lib/action_push_native/configured_notification.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def with_data(data)
1616

1717
def silent
1818
@options = options.merge(high_priority: false)
19-
with_apple(content_available: 1)
19+
with_apple(aps: { "content-available": 1 })
2020
end
2121

2222
def with_apple(apple_data)

lib/action_push_native/service/apns.rb

Lines changed: 75 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,118 +3,114 @@
33
module ActionPushNative
44
module Service
55
class Apns
6-
DEFAULT_TIMEOUT = 30.seconds
7-
DEFAULT_POOL_SIZE = 5
8-
96
def initialize(config)
107
@config = config
118
end
129

13-
# Per-application connection pools
14-
cattr_accessor :connection_pools
10+
# Per-application HTTPX session
11+
cattr_accessor :httpx_sessions
1512

1613
def push(notification)
17-
reset_connection_error
18-
19-
connection_pool.with do |connection|
20-
rescue_and_reraise_network_errors do
21-
apnotic_notification = apnotic_notification_from(notification)
22-
Rails.logger.info("Pushing APNs notification: #{apnotic_notification.apns_id}")
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
14+
notification.apple_data = ApnoticLegacyConverter.convert(notification.apple_data) if notification.apple_data.present?
15+
16+
headers, payload = headers_from(notification), payload_from(notification)
17+
Rails.logger.info("Pushing APNs notification: #{headers[:"apns-id"]}")
18+
response = httpx_session.authenticated.with(headers: headers).post("/3/device/#{notification.token}", json: payload)
19+
handle_error(response) if response.error
3120
end
3221

3322
private
34-
attr_reader :config, :connection_error
23+
attr_reader :config
3524

36-
def reset_connection_error
37-
@connection_error = nil
25+
PRIORITIES = { high: 10, normal: 5 }.freeze
26+
HEADERS = %i[ apns-id apns-push-type apns-priority apns-topic apns-expiration apns-collapse-id ].freeze
27+
28+
def headers_from(notification)
29+
push_type = notification.apple_data&.dig(:aps, :"content-available") == 1 ? "background" : "alert"
30+
custom_apple_headers = notification.apple_data&.slice(*HEADERS) || {}
31+
32+
{
33+
"apns-push-type": push_type,
34+
"apns-id": SecureRandom.uuid,
35+
"apns-priority": notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal],
36+
"apns-topic": config.fetch(:topic)
37+
}.merge(custom_apple_headers).compact
3838
end
3939

40-
def connection_pool
41-
self.class.connection_pools ||= {}
42-
self.class.connection_pools[config] ||= build_connection_pool
40+
def payload_from(notification)
41+
payload = \
42+
{
43+
aps: {
44+
alert: { title: notification.title, body: notification.body },
45+
badge: notification.badge,
46+
"thread-id": notification.thread_id,
47+
sound: notification.sound
48+
}
49+
}
50+
51+
payload = payload.merge notification.data if notification.data.present?
52+
custom_apple_payload = notification.apple_data&.except(*HEADERS) || {}
53+
payload = payload.deep_merge custom_apple_payload
54+
55+
payload.dig(:aps, :alert)&.compact!
56+
payload[:aps].compact_blank!
57+
payload.compact
4358
end
4459

45-
def build_connection_pool
46-
build_method = config[:connect_to_development_server] ? "development" : "new"
47-
Apnotic::ConnectionPool.public_send(build_method, {
48-
auth_method: :token,
49-
cert_path: StringIO.new(config.fetch(:encryption_key)),
50-
key_id: config.fetch(:key_id),
51-
team_id: config.fetch(:team_id)
52-
}, size: config[:connection_pool_size] || DEFAULT_POOL_SIZE) do |connection|
53-
# Prevents the main thread from crashing collecting the connection error from the off-thread
54-
# and raising it afterwards.
55-
connection.on(:error) { |error| @connection_error = error }
56-
end
60+
def httpx_session
61+
self.class.httpx_sessions ||= {}
62+
self.class.httpx_sessions[config] ||= HttpxSession.new(config)
5763
end
5864

59-
def rescue_and_reraise_network_errors
60-
begin
61-
yield
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
65+
def handle_error(response)
66+
if response.is_a?(HTTPX::ErrorResponse)
67+
handle_network_error(response.error)
68+
else
69+
handle_apns_error(response)
7270
end
7371
end
7472

75-
PRIORITIES = { high: 10, normal: 5 }.freeze
76-
77-
def apnotic_notification_from(notification)
78-
Apnotic::Notification.new(notification.token).tap do |n|
79-
n.topic = config.fetch(:topic)
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)
73+
def handle_network_error(error)
74+
case error
75+
when Errno::ETIMEDOUT, HTTPX::TimeoutError
76+
raise ActionPushNative::TimeoutError, error.message
77+
when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
78+
SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError,
79+
HTTPX::TLSError, HTTPX::Connection::HTTP2::Error
80+
raise ActionPushNative::ConnectionError, error.message
81+
when OpenSSL::SSL::SSLError
82+
if error.message.include?("SSL_connect")
83+
raise ActionPushNative::ConnectionError, error.message
84+
else
85+
raise
8886
end
8987
end
9088
end
9189

92-
def handle_response_error(response)
93-
code = response&.status
94-
reason = response.body["reason"] if response
90+
def handle_apns_error(response)
91+
status = response.status
92+
reason = JSON.parse(response.body.to_s)["reason"] unless response.body.empty?
9593

96-
Rails.logger.error("APNs response error #{code}: #{reason}") if reason
94+
Rails.logger.error("APNs response error #{status}: #{reason}") if reason
9795

98-
case [ code, reason ]
99-
in [ nil, _ ]
100-
raise ActionPushNative::TimeoutError
101-
in [ "400", "BadDeviceToken" ]
96+
case [ status, reason ]
97+
in [ 400, "BadDeviceToken" ]
10298
raise ActionPushNative::TokenError, reason
103-
in [ "400", "DeviceTokenNotForTopic" ]
99+
in [ 400, "DeviceTokenNotForTopic" ]
104100
raise ActionPushNative::BadDeviceTopicError, reason
105-
in [ "400", _ ]
101+
in [ 400, _ ]
106102
raise ActionPushNative::BadRequestError, reason
107-
in [ "403", _ ]
103+
in [ 403, _ ]
108104
raise ActionPushNative::ForbiddenError, reason
109-
in [ "404", _ ]
105+
in [ 404, _ ]
110106
raise ActionPushNative::NotFoundError, reason
111-
in [ "410", _ ]
107+
in [ 410, _ ]
112108
raise ActionPushNative::TokenError, reason
113-
in [ "413", _ ]
109+
in [ 413, _ ]
114110
raise ActionPushNative::PayloadTooLargeError, reason
115-
in [ "429", _ ]
111+
in [ 429, _ ]
116112
raise ActionPushNative::TooManyRequestsError, reason
117-
in [ "503", _ ]
113+
in [ 503, _ ]
118114
raise ActionPushNative::ServiceUnavailableError, reason
119115
else
120116
raise ActionPushNative::InternalServerError, reason
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(:auth).
13+
plugin(:persistent, close_on_fork: true).
14+
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
15+
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
16+
with(origin: config[:connect_to_development_server] ? DEVELOPMENT_SERVER_URL : PRODUCTION_SERVER_URL)
17+
@token_provider = ActionPushNative::Service::Apns::TokenProvider.new(config)
18+
end
19+
20+
def authenticated
21+
@session.bearer_auth(token_provider.fresh_access_token)
22+
end
23+
24+
private
25+
attr_reader :token_provider
26+
end

0 commit comments

Comments
 (0)