|
3 | 3 | module ActionPushNative
|
4 | 4 | module Service
|
5 | 5 | class Apns
|
6 |
| - DEFAULT_TIMEOUT = 30.seconds |
7 |
| - DEFAULT_POOL_SIZE = 5 |
8 |
| - |
9 | 6 | def initialize(config)
|
10 | 7 | @config = config
|
11 | 8 | end
|
12 | 9 |
|
13 |
| - # Per-application connection pools |
14 |
| - cattr_accessor :connection_pools |
| 10 | + # Per-application HTTPX session |
| 11 | + cattr_accessor :httpx_sessions |
15 | 12 |
|
16 | 13 | 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 |
31 | 20 | end
|
32 | 21 |
|
33 | 22 | private
|
34 |
| - attr_reader :config, :connection_error |
| 23 | + attr_reader :config |
35 | 24 |
|
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 |
38 | 38 | end
|
39 | 39 |
|
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 |
43 | 58 | end
|
44 | 59 |
|
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) |
57 | 63 | end
|
58 | 64 |
|
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) |
72 | 70 | end
|
73 | 71 | end
|
74 | 72 |
|
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 |
88 | 86 | end
|
89 | 87 | end
|
90 | 88 | end
|
91 | 89 |
|
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? |
95 | 93 |
|
96 |
| - Rails.logger.error("APNs response error #{code}: #{reason}") if reason |
| 94 | + Rails.logger.error("APNs response error #{status}: #{reason}") if reason |
97 | 95 |
|
98 |
| - case [ code, reason ] |
99 |
| - in [ nil, _ ] |
100 |
| - raise ActionPushNative::TimeoutError |
101 |
| - in [ "400", "BadDeviceToken" ] |
| 96 | + case [ status, reason ] |
| 97 | + in [ 400, "BadDeviceToken" ] |
102 | 98 | raise ActionPushNative::TokenError, reason
|
103 |
| - in [ "400", "DeviceTokenNotForTopic" ] |
| 99 | + in [ 400, "DeviceTokenNotForTopic" ] |
104 | 100 | raise ActionPushNative::BadDeviceTopicError, reason
|
105 |
| - in [ "400", _ ] |
| 101 | + in [ 400, _ ] |
106 | 102 | raise ActionPushNative::BadRequestError, reason
|
107 |
| - in [ "403", _ ] |
| 103 | + in [ 403, _ ] |
108 | 104 | raise ActionPushNative::ForbiddenError, reason
|
109 |
| - in [ "404", _ ] |
| 105 | + in [ 404, _ ] |
110 | 106 | raise ActionPushNative::NotFoundError, reason
|
111 |
| - in [ "410", _ ] |
| 107 | + in [ 410, _ ] |
112 | 108 | raise ActionPushNative::TokenError, reason
|
113 |
| - in [ "413", _ ] |
| 109 | + in [ 413, _ ] |
114 | 110 | raise ActionPushNative::PayloadTooLargeError, reason
|
115 |
| - in [ "429", _ ] |
| 111 | + in [ 429, _ ] |
116 | 112 | raise ActionPushNative::TooManyRequestsError, reason
|
117 |
| - in [ "503", _ ] |
| 113 | + in [ 503, _ ] |
118 | 114 | raise ActionPushNative::ServiceUnavailableError, reason
|
119 | 115 | else
|
120 | 116 | raise ActionPushNative::InternalServerError, reason
|
|
0 commit comments