Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ PATH
action_push_native (0.1.0)
activejob (>= 8.0)
activerecord (>= 8.0)
apnotic (~> 1.7)
googleauth (~> 1.14)
httpx (~> 1.6)
jwt (>= 2)
net-http (~> 0.6)
railties (>= 8.0)

Expand Down Expand Up @@ -52,9 +53,6 @@ GEM
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
apnotic (1.7.2)
connection_pool (~> 2)
net-http2 (>= 0.18.3, < 2)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.4.1)
Expand Down Expand Up @@ -92,6 +90,8 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.2.0)
http-2 (1.1.1)
httpx (1.6.0)
http-2 (>= 1.0.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.1)
Expand All @@ -114,8 +114,6 @@ GEM
multi_json (1.17.0)
net-http (0.6.0)
uri
net-http2 (0.19.0)
http-2 (>= 1.0)
nokogiri (1.18.9-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ You can use `with_apple` for Apple and `with_google` for Google:

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

```ruby
notification = ApplicationPushNotification.silent.with_data(id: 1)
notification = ApplicationPushNotification.silent.with_data(id: 1).new
```

This will create a silent notification for both Apple and Google platforms and sets an application
Expand Down Expand Up @@ -271,7 +271,7 @@ by adding extra arguments to the notification constructor:
data = { calendar_id: @calendar.id, identity_id: @identity.id }

notification = CalendarPushNotification
.with_apple(custom_payload: data)
.with_apple(data)
.with_google(data: data)
.new(calendar_id: 123)

Expand Down Expand Up @@ -311,7 +311,7 @@ end
| :sound | The sound to play when the notification is received.
| :high_priority | Whether the notification should be sent with high priority (default: true).
| :google_data | The Google-specific payload for the notification.
| :apple_data | The Apple-specific payload for the notification.
| :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.
| :data | The data payload for the notification, sent to all platforms.
| ** | Any additional attributes passed to the constructor will be merged in the `context` hash.

Expand All @@ -320,7 +320,7 @@ end
| Name | Description
|------------------|------------
| :with_apple | Set the Apple-specific payload for the notification.
| :with_google | Set the Google-specific payload for the notification.
| :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.
| :with_data | Set the data payload for the notification, sent to all platforms.
| :silent | Create a silent notification that does not trigger a visual alert on the device.

Expand Down
3 changes: 2 additions & 1 deletion action_push_native.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ Gem::Specification.new do |spec|
spec.add_dependency "activerecord", rails_version
spec.add_dependency "activejob", rails_version
spec.add_dependency "railties", rails_version
spec.add_dependency "apnotic", "~> 1.7"
spec.add_dependency "httpx", "~> 1.6"
spec.add_dependency "jwt", ">= 2"
spec.add_dependency "googleauth", "~> 1.14"
spec.add_dependency "net-http", "~> 0.6"
end
2 changes: 1 addition & 1 deletion app/jobs/action_push_native/notification_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def exponential_backoff_delay(executions)

with_options retry_options do
retry_on TimeoutError, wait: 1.minute
retry_on ConnectionError, ConnectionPool::TimeoutError, attempts: 20
retry_on ConnectionError, HTTPX::PoolTimeoutError, attempts: 20

# Altough unexpected, these are short-lived errors that can be retried most of the times.
retry_on ForbiddenError, BadRequestError
Expand Down
7 changes: 6 additions & 1 deletion lib/action_push_native.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
require "action_push_native/engine"
require "action_push_native/errors"
require "net/http"
require "apnotic"
require "httpx"
require "googleauth"
require "jwt"

loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
loader.ignore("#{__dir__}/generators")
Expand Down Expand Up @@ -37,4 +38,8 @@ def self.config_for(platform, notification)
platform_config
end
end

def self.deprecator
@deprecator ||= ActiveSupport::Deprecation.new
end
end
2 changes: 1 addition & 1 deletion lib/action_push_native/configured_notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def with_data(data)

def silent
@options = options.merge(high_priority: false)
with_apple(content_available: 1)
with_apple(aps: { "content-available": 1 })
end

def with_apple(apple_data)
Expand Down
154 changes: 75 additions & 79 deletions lib/action_push_native/service/apns.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,118 +3,114 @@
module ActionPushNative
module Service
class Apns
DEFAULT_TIMEOUT = 30.seconds
DEFAULT_POOL_SIZE = 5

def initialize(config)
@config = config
end

# Per-application connection pools
cattr_accessor :connection_pools
# Per-application HTTPX session
cattr_accessor :httpx_sessions

def push(notification)
reset_connection_error

connection_pool.with do |connection|
rescue_and_reraise_network_errors do
apnotic_notification = apnotic_notification_from(notification)
Rails.logger.info("Pushing APNs notification: #{apnotic_notification.apns_id}")

response = connection.push \
apnotic_notification,
timeout: config[:request_timeout] || DEFAULT_TIMEOUT
raise connection_error if connection_error
handle_response_error(response) unless response&.ok?
end
end
notification.apple_data = ApnoticLegacyConverter.convert(notification.apple_data) if notification.apple_data.present?

headers, payload = headers_from(notification), payload_from(notification)
Rails.logger.info("Pushing APNs notification: #{headers[:"apns-id"]}")
response = httpx_session.authenticated.with(headers: headers).post("/3/device/#{notification.token}", json: payload)
handle_error(response) if response.error
end

private
attr_reader :config, :connection_error
attr_reader :config

def reset_connection_error
@connection_error = nil
PRIORITIES = { high: 10, normal: 5 }.freeze
HEADERS = %i[ apns-id apns-push-type apns-priority apns-topic apns-expiration apns-collapse-id ].freeze

def headers_from(notification)
push_type = notification.apple_data&.dig(:aps, :"content-available") == 1 ? "background" : "alert"
custom_apple_headers = notification.apple_data&.slice(*HEADERS) || {}

{
"apns-push-type": push_type,
"apns-id": SecureRandom.uuid,
"apns-priority": notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal],
"apns-topic": config.fetch(:topic)
}.merge(custom_apple_headers).compact
end

def connection_pool
self.class.connection_pools ||= {}
self.class.connection_pools[config] ||= build_connection_pool
def payload_from(notification)
payload = \
{
aps: {
alert: { title: notification.title, body: notification.body },
badge: notification.badge,
"thread-id": notification.thread_id,
sound: notification.sound
}
}

payload = payload.merge notification.data if notification.data.present?
custom_apple_payload = notification.apple_data&.except(*HEADERS) || {}
payload = payload.deep_merge custom_apple_payload

payload.dig(:aps, :alert)&.compact!
payload[:aps].compact_blank!
payload.compact
end

def build_connection_pool
build_method = config[:connect_to_development_server] ? "development" : "new"
Apnotic::ConnectionPool.public_send(build_method, {
auth_method: :token,
cert_path: StringIO.new(config.fetch(:encryption_key)),
key_id: config.fetch(:key_id),
team_id: config.fetch(:team_id)
}, size: config[:connection_pool_size] || DEFAULT_POOL_SIZE) do |connection|
# Prevents the main thread from crashing collecting the connection error from the off-thread
# and raising it afterwards.
connection.on(:error) { |error| @connection_error = error }
end
def httpx_session
self.class.httpx_sessions ||= {}
self.class.httpx_sessions[config] ||= HttpxSession.new(config)
end

def rescue_and_reraise_network_errors
begin
yield
rescue Errno::ETIMEDOUT => e
raise ActionPushNative::TimeoutError, e.message
rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
raise ActionPushNative::ConnectionError, e.message
rescue OpenSSL::SSL::SSLError => e
if e.message.include?("SSL_connect")
raise ActionPushNative::ConnectionError, e.message
else
raise
end
def handle_error(response)
if response.is_a?(HTTPX::ErrorResponse)
handle_network_error(response.error)
else
handle_apns_error(response)
end
end

PRIORITIES = { high: 10, normal: 5 }.freeze

def apnotic_notification_from(notification)
Apnotic::Notification.new(notification.token).tap do |n|
n.topic = config.fetch(:topic)
n.alert = { title: notification.title, body: notification.body }.compact
n.badge = notification.badge
n.thread_id = notification.thread_id
n.sound = notification.sound
n.priority = notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal]
n.custom_payload = notification.data
notification.apple_data&.each do |key, value|
n.public_send("#{key.to_s.underscore}=", value)
def handle_network_error(error)
case error
when Errno::ETIMEDOUT, HTTPX::TimeoutError
raise ActionPushNative::TimeoutError, error.message
when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError,
HTTPX::TLSError, HTTPX::Connection::HTTP2::Error
raise ActionPushNative::ConnectionError, error.message
when OpenSSL::SSL::SSLError
if error.message.include?("SSL_connect")
raise ActionPushNative::ConnectionError, error.message
else
raise
end
end
end

def handle_response_error(response)
code = response&.status
reason = response.body["reason"] if response
def handle_apns_error(response)
status = response.status
reason = JSON.parse(response.body.to_s)["reason"] unless response.body.empty?

Rails.logger.error("APNs response error #{code}: #{reason}") if reason
Rails.logger.error("APNs response error #{status}: #{reason}") if reason

case [ code, reason ]
in [ nil, _ ]
raise ActionPushNative::TimeoutError
in [ "400", "BadDeviceToken" ]
case [ status, reason ]
in [ 400, "BadDeviceToken" ]
raise ActionPushNative::TokenError, reason
in [ "400", "DeviceTokenNotForTopic" ]
in [ 400, "DeviceTokenNotForTopic" ]
raise ActionPushNative::BadDeviceTopicError, reason
in [ "400", _ ]
in [ 400, _ ]
raise ActionPushNative::BadRequestError, reason
in [ "403", _ ]
in [ 403, _ ]
raise ActionPushNative::ForbiddenError, reason
in [ "404", _ ]
in [ 404, _ ]
raise ActionPushNative::NotFoundError, reason
in [ "410", _ ]
in [ 410, _ ]
raise ActionPushNative::TokenError, reason
in [ "413", _ ]
in [ 413, _ ]
raise ActionPushNative::PayloadTooLargeError, reason
in [ "429", _ ]
in [ 429, _ ]
raise ActionPushNative::TooManyRequestsError, reason
in [ "503", _ ]
in [ 503, _ ]
raise ActionPushNative::ServiceUnavailableError, reason
else
raise ActionPushNative::InternalServerError, reason
Expand Down
38 changes: 38 additions & 0 deletions lib/action_push_native/service/apns/apnotic_legacy_converter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

# Converts the legacy `apple_data` format from the Apnotic gem
# to the new format expected by the APNs API.
#
# Temporary compatibility layer: It will be removed in the next release.
class ActionPushNative::Service::Apns::ApnoticLegacyConverter
APS_FIELDS = %i[
alert badge sound content_available category url_args mutable_content thread_id
target_content_id interruption_level relevance_score
stale_date content_state timestamp event dismissal_date
].freeze
APNS_HEADERS = %i[ expiration priority topic push_type ]

def self.convert(apple_data)
apple_data.each_with_object({}) do |(key, value), converted|
if key.in?(APS_FIELDS)
converted[:aps] ||= {}
converted_key = key.to_s.dasherize.to_sym
converted[:aps][converted_key] = value
ActionPushNative.deprecator.warn("Passing the `#{key}` field directly is deprecated. Please use `.with_apple(aps: { \"#{converted_key}\": ... })` instead.")
elsif key.in?(APNS_HEADERS)
converted_key = "apns-#{key.to_s.dasherize}".to_sym
converted[converted_key] = value
ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.")
elsif key == :apns_collapse_id
converted_key = key.to_s.dasherize.to_sym
converted[converted_key] = value
ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.")
elsif key == :custom_payload
converted.merge!(value)
ActionPushNative.deprecator.warn("Passing `custom_payload` is deprecated. Please use `.with_apple(#{value})` instead.")
else
converted[key] = value
end
end
end
end
26 changes: 26 additions & 0 deletions lib/action_push_native/service/apns/httpx_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

class ActionPushNative::Service::Apns::HttpxSession
DEFAULT_POOL_SIZE = 5
DEFAULT_REQUEST_TIMEOUT = 30.seconds
DEVELOPMENT_SERVER_URL = "https://api.sandbox.push.apple.com:443"
PRODUCTION_SERVER_URL = "https://api.push.apple.com:443"

def initialize(config)
@session = \
HTTPX.
plugin(:auth).
plugin(:persistent, close_on_fork: true).
with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }).
with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }).
with(origin: config[:connect_to_development_server] ? DEVELOPMENT_SERVER_URL : PRODUCTION_SERVER_URL)
@token_provider = ActionPushNative::Service::Apns::TokenProvider.new(config)
end

def authenticated
@session.bearer_auth(token_provider.fresh_access_token)
end

private
attr_reader :token_provider
end
Loading