|
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.post("https://api.push.apple.com/3/device/#{notification.token}", json: payload, headers: headers) |
| 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