expo-server-sdk 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'connection_pool'
4
+ require 'http'
5
+
6
+ require_relative './chunk'
7
+ require_relative './notification'
8
+ require_relative './receipts'
9
+ require_relative './tickets'
10
+
11
+ module Expo
12
+ module Push # rubocop:disable Style/Documentation
13
+ class Error < StandardError # rubocop:disable Style/Documentation
14
+ def self.explain(error) # rubocop:disable Metrics/MethodLength
15
+ identifier = error.is_a?(String) ? error : error.fetch('details').fetch('error')
16
+
17
+ case identifier
18
+ when 'DeviceNotRegistered'
19
+ 'The device cannot receive push notifications anymore and you' \
20
+ ' should stop sending messages to the corresponding Expo push token.'
21
+ when 'InvalidCredentials'
22
+ 'Your push notification credentials for your standalone app are ' \
23
+ 'invalid (ex: you may have revoked them). Run expo build:ios -c ' \
24
+ 'to regenerate new push notification credentials for iOS. If you ' \
25
+ 'revoke an APN key, all apps that rely on that key will no longer ' \
26
+ 'be able to send or receive push notifications until you upload a ' \
27
+ 'new key to replace it. Uploading a new APN key will not change ' \
28
+ 'your users\' Expo Push Tokens.'
29
+ when 'MessageTooBig'
30
+ 'The total notification payload was too large. On Android and iOS ' \
31
+ 'the total payload must be at most 4096 bytes.'
32
+ when 'MessageRateExceeded'
33
+ 'You are sending messages too frequently to the given device. ' \
34
+ 'Implement exponential backoff and slowly retry sending messages.'
35
+ else
36
+ "There is no embedded explanation for #{identifier}. Sorry!"
37
+ end
38
+ rescue KeyError
39
+ 'There is no identifier given to explain'
40
+ end
41
+ end
42
+
43
+ class ServerError < Error; end
44
+
45
+ class ArgumentError < Error; end
46
+
47
+ class TicketsWithErrors < Error # rubocop:disable Style/Documentation
48
+ attr_reader :data, :errors
49
+
50
+ def initialize(errors:, data:)
51
+ self.data = data
52
+ self.errors = errors
53
+
54
+ if errors.length.zero?
55
+ super 'Expected at least one error, but got none'
56
+ return
57
+ end
58
+
59
+ puts errors
60
+
61
+ super "Expo indicated one or more problems: #{errors.map { |error| error['message'] }}"
62
+ end
63
+
64
+ private
65
+
66
+ attr_writer :data, :errors
67
+ end
68
+
69
+ class TicketsExpectationFailed < Error # rubocop:disable Style/Documentation
70
+ attr_reader :data
71
+
72
+ def initialize(expected_count:, data:)
73
+ self.data = data
74
+
75
+ super format(
76
+ "Expected %<count>s ticket#{if expected_count != 1
77
+ 's'
78
+ end}, actual: %<actual>s. The response data can be inspected.",
79
+ count: expected_count,
80
+ actual: data.is_a?(Array) ? data.length : '<not a list of tickets>'
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ attr_writer :data
87
+ end
88
+
89
+ class ReceiptsWithErrors < Error # rubocop:disable Style/Documentation
90
+ attr_reader :data, :errors
91
+
92
+ def initialize(errors:, data:)
93
+ self.data = data
94
+ self.errors = errors
95
+
96
+ if errors.length.zero?
97
+ super 'Expected at least one error, but got none'
98
+ return
99
+ end
100
+
101
+ super "Expo indicated one or more problems: #{errors.map { |error| error[:message] }}"
102
+ end
103
+
104
+ private
105
+
106
+ attr_writer :data, :errors
107
+ end
108
+
109
+ class PushTokenInvalid < Error # rubocop:disable Style/Documentation
110
+ attr_reader :token
111
+
112
+ def initialize(token:)
113
+ self.token = token
114
+
115
+ super "Expected a valid Expo Push Token, actual: #{token}"
116
+ end
117
+
118
+ private
119
+
120
+ attr_writer :token
121
+ end
122
+
123
+ ##
124
+ # The max number of push notifications to be sent at once. Since we can't automatically upgrade
125
+ # everyone using this library, we should strongly try not to decrease it.
126
+ #
127
+ PUSH_NOTIFICATION_CHUNK_LIMIT = 100
128
+
129
+ ##
130
+ # The max number of push notification receipts to request at once.
131
+ #
132
+ PUSH_NOTIFICATION_RECEIPT_CHUNK_LIMIT = 300
133
+
134
+ ##
135
+ # The default max number of concurrent HTTP requests to send at once and spread out the load,
136
+ # increasing the reliability of notification delivery.
137
+ #
138
+ DEFAULT_CONCURRENT_REQUEST_LIMIT = 6
139
+
140
+ BASE_URL = 'https://exp.host'
141
+ BASE_API_URL = '/--/api/v2'
142
+
143
+ PUSH_API_URL = "#{BASE_API_URL}/push/send"
144
+ RECEIPTS_API_URL = "#{BASE_API_URL}/push/getReceipts"
145
+
146
+ ##
147
+ # Returns `true` if the token is an Expo push token
148
+ #
149
+ def self.expo_push_token?(token)
150
+ return false unless token
151
+
152
+ /\AExpo(?:nent)?PushToken\[[^\]]+\]\z/.match?(token) ||
153
+ /\A[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}\z/i.match?(token)
154
+ end
155
+
156
+ ##
157
+ # This is the Push Client for Expo's Push Service. It is responsible for
158
+ # sending the notifications themselves as well as retrieving the receipts.
159
+ #
160
+ # It will attempt to keep a persistent connection once the first request is
161
+ # made, and allow at most {concurrency} concurrent requests.
162
+ #
163
+ class Client
164
+ def initialize(
165
+ access_token: nil,
166
+ concurrency: DEFAULT_CONCURRENT_REQUEST_LIMIT,
167
+ logger: false,
168
+ instrumentation: false
169
+ )
170
+ self.access_token = access_token
171
+ self.concurrency = concurrency
172
+ self.logger = logger
173
+ self.instrumentation = if instrumentation == true
174
+ { instrumentation: ActiveSupport::Notifications.instrumenter }
175
+ else
176
+ instrumentation
177
+ end
178
+ end
179
+
180
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
181
+ def send(notifications)
182
+ connect unless pool?
183
+
184
+ threads = Chunk.for(notifications).map do |chunk|
185
+ expected_count = chunk.count
186
+
187
+ Thread.new do
188
+ pool.with do |http|
189
+ response = http.post(PUSH_API_URL, json: chunk.as_json)
190
+ parsed_response = response.parse
191
+
192
+ data = parsed_response['data']
193
+ errors = parsed_response['errors']
194
+
195
+ if errors&.length&.positive?
196
+ TicketsWithErrors.new(data: data, errors: errors)
197
+ elsif !data.is_a?(Array) || data.length != expected_count
198
+ TicketsExpectationFailed.new(expected_count: expected_count, data: data)
199
+ else
200
+ data.map { |ticket| Ticket.new(ticket) }
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ Tickets.new(threads.map(&:value))
207
+ end
208
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
209
+
210
+ def send!(notifications)
211
+ send(notifications).tap do |result|
212
+ result.each_error do |error| # rubocop:disable Lint/UnreachableLoop
213
+ raise error
214
+ end
215
+ end
216
+ end
217
+
218
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
219
+ def receipts(receipt_ids)
220
+ connect unless pool?
221
+
222
+ pool.with do |http|
223
+ response = http.post(RECEIPTS_API_URL, json: { ids: Array(receipt_ids) })
224
+ parsed_response = response.parse
225
+
226
+ if !parsed_response || parsed_response.is_a?(Array) || !parsed_response.is_a?(Hash)
227
+ raise ServerError, 'Expected hash with receipt id => receipt, but got some other data structure'
228
+ end
229
+
230
+ errors = parsed_response['errors']
231
+
232
+ if errors&.length&.positive?
233
+ ReceiptsWithErrors.new(data: parsed_response, errors: errors)
234
+ else
235
+ results = parsed_response.map do |receipt_id, data|
236
+ Receipt.new(data: data, receipt_id: receipt_id)
237
+ end
238
+
239
+ Receipts.new(results: results, requested_ids: receipt_ids)
240
+ end
241
+ end
242
+ end
243
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
244
+
245
+ def connect # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
246
+ shutdown
247
+
248
+ self.pool = ConnectionPool.new(size: concurrency, timeout: 5) do
249
+ http = HTTP.headers(
250
+ # All request should return JSON (in this client)
251
+ Accept: 'application/json',
252
+ # All responses are allowed to be gzip-encoded
253
+ 'Accept-Encoding': 'gzip',
254
+ # Set user-agent so that expo can track usage
255
+ 'User-Agent': format('expo-server-sdk-ruby/%<version>s', version: VERSION)
256
+ )
257
+
258
+ http = http.headers('Authorization', "Bearer #{access_token}") if access_token
259
+
260
+ # All requests are allowed to automatically gzip
261
+ http = http.use(:auto_inflate)
262
+ # Turn on logging if there is a logger
263
+ http = http.use(logging: { logger: logger }) if logger
264
+ # Turn on instrumentation
265
+ http = http.use(instrumentation: instrumentation) if instrumentation
266
+
267
+ http.persistent(BASE_URL)
268
+ end
269
+ end
270
+
271
+ def shutdown
272
+ return unless pool?
273
+
274
+ pool.shutdown do |conn|
275
+ conn&.close
276
+ end
277
+ end
278
+
279
+ def notification
280
+ Expo::Push::Notification.new
281
+ end
282
+
283
+ private
284
+
285
+ attr_accessor :access_token, :concurrency, :pool, :logger, :instrumentation
286
+
287
+ def pool?
288
+ !!pool
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expo
4
+ module Push
5
+ ##
6
+ # Data model for PushNotification.
7
+ #
8
+ class Notification # rubocop:disable Metrics/ClassLength
9
+ attr_accessor :recipients
10
+
11
+ def self.to(recipient)
12
+ new.to(recipient)
13
+ end
14
+
15
+ def initialize(_recipient = [])
16
+ self.recipients = []
17
+ self._params = {}
18
+ end
19
+
20
+ ##
21
+ # Set or add recipient or recipients.
22
+ #
23
+ # Must be a valid Expo Push Token, or array-like / enumerator that yield
24
+ # valid Expo Push Tokens, or an PushTokenInvalid error is raised.
25
+ #
26
+ # @see PushTokenInvalid
27
+ # @see #<<
28
+ #
29
+ def to(recipient_or_multiple)
30
+ Array(recipient_or_multiple).each do |recipient|
31
+ self << recipient
32
+ end
33
+
34
+ self
35
+ rescue NoMethodError
36
+ raise ArgumentError, 'to must be a single Expo Push Token, or an array-like/enumerator of Expo Push Tokens'
37
+ end
38
+
39
+ ##
40
+ # Set or overwrite the data.
41
+ #
42
+ # Data must be a Hash, or at least be JSON serializable as hash.
43
+ #
44
+ # A JSON object delivered to your app. It may be up to about 4KiB; the
45
+ # total notification payload sent to Apple and Google must be at most
46
+ # 4KiB or else you will get a "Message Too Big" error.
47
+ #
48
+ def data(value)
49
+ json_data = value.respond_to?(:as_json) ? value.as_json : value.to_h
50
+
51
+ raise ArgumentError, 'data must be hash-like or nil' if !json_data.nil? && !json_data.is_a?(Hash)
52
+
53
+ _params[:data] = json_data
54
+ self
55
+ rescue NoMethodError
56
+ raise ArgumentError, 'data must be hash-like, respond to as_json, or nil'
57
+ end
58
+
59
+ ##
60
+ # Set or overwrite the title.
61
+ #
62
+ # The title to display in the notification. Often displayed above the
63
+ # notification body.
64
+ #
65
+ def title(value)
66
+ _params[:title] = value.nil? ? nil : String(value)
67
+ self
68
+ rescue NoMethodError
69
+ raise ArgumentError, 'title must be nil or string-like'
70
+ end
71
+
72
+ ##
73
+ # Set or overwrite the subtitle.
74
+ #
75
+ # The subtitle to display in the notification below the title.
76
+ #
77
+ # @note iOS only
78
+ #
79
+ def subtitle(value)
80
+ _params[:subtitle] = value.nil? ? nil : String(value)
81
+ self
82
+ rescue NoMethodError
83
+ raise ArgumentError, 'subtitle must be nil or string-like'
84
+ end
85
+
86
+ alias sub_title subtitle
87
+
88
+ ##
89
+ # Set or overwrite the body (content).
90
+ #
91
+ # The message to display in the notification.
92
+ #
93
+ def body(value)
94
+ _params[:body] = value.nil? ? nil : String(value)
95
+ self
96
+ rescue NoMethodError
97
+ raise ArgumentError, 'body must be nil or string-like'
98
+ end
99
+
100
+ alias content body
101
+
102
+ ##
103
+ # Set or overwrite the sound.
104
+ #
105
+ # Play a sound when the recipient receives this notification. Specify
106
+ # "default" to play the device's default notification sound, or nil to
107
+ # play no sound. Custom sounds are not supported.
108
+ #
109
+ # @note iOS only
110
+ #
111
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
112
+ def sound(value)
113
+ if value.nil?
114
+ _params[:sound] = nil
115
+ return self
116
+ end
117
+
118
+ unless value.respond_to?(:to_h)
119
+ _params[:sound] = String(value)
120
+ return self
121
+ end
122
+
123
+ json_value = value.to_h
124
+
125
+ next_value = {
126
+ critical: !json_value.fetch(:critical, nil).nil?,
127
+ name: json_value.fetch(:name, nil),
128
+ volume: json_value.fetch(:volume, nil)
129
+ }
130
+
131
+ next_value[:name] = String(next_value[:name]) unless next_value[:name].nil?
132
+ next_value[:volume] = next_value[:volume].to_i unless next_value[:volume].nil?
133
+
134
+ _params[:sound] = next_value.compact
135
+
136
+ self
137
+ end
138
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
139
+
140
+ ##
141
+ # Set or overwrite the time to live in seconds.
142
+ #
143
+ # The number of seconds for which the message may be kept around for
144
+ # redelivery if it hasn't been delivered yet. Defaults to nil in order to
145
+ # use the respective defaults of each provider:
146
+ #
147
+ # - 0 for iOS/APNs
148
+ # - 2419200 (4 weeks) for Android/FCM
149
+ #
150
+ # @see expiration
151
+ #
152
+ # @note On Android, we make a best effort to deliver messages with zero
153
+ # TTL immediately and do not throttle them. However, setting TTL to a
154
+ # low value (e.g. zero) can prevent normal-priority notifications from
155
+ # ever reaching Android devices that are in doze mode. In order to
156
+ # guarantee that a notification will be delivered, TTL must be long
157
+ # enough for the device to wake from doze mode.
158
+ #
159
+ def ttl(value)
160
+ _params[:ttl] = value.nil? ? nil : value.to_i
161
+ self
162
+ rescue NoMethodError
163
+ raise ArgumentError, 'ttl must be numeric or nil'
164
+ end
165
+
166
+ ##
167
+ # Set or overwrite the time to live based on a unix timestamp.
168
+ #
169
+ # Timestamp since the UNIX epoch specifying when the message expires.
170
+ # Same effect as ttl (ttl takes precedence over expiration).
171
+ #
172
+ # @see ttl
173
+ #
174
+ def expiration(value)
175
+ _params[:expiration] = value.nil? ? nil : value.to_i
176
+ self
177
+ rescue NoMethodError
178
+ raise ArgumentError, 'ttl must be numeric or nil'
179
+ end
180
+
181
+ ##
182
+ # Set or overwrite the priority.
183
+ #
184
+ # The delivery priority of the message. Specify "default" or nil to use
185
+ # the default priority on each platform:
186
+ #
187
+ # - "normal" on Android
188
+ # - "high" on iOS
189
+ #
190
+ # @note On Android, normal-priority messages won't open network
191
+ # connections on sleeping devices and their delivery may be delayed to
192
+ # conserve the battery. High-priority messages are delivered
193
+ # immediately if possible and may wake sleeping devices to open network
194
+ # connections, consuming energy.
195
+ #
196
+ # @note On iOS, normal-priority messages are sent at a time that takes
197
+ # into account power considerations for the device, and may be grouped
198
+ # and delivered in bursts. They are throttled and may not be delivered
199
+ # by Apple. High-priority messages are sent immediately.
200
+ # Normal priority corresponds to APNs priority level 5 and high
201
+ # priority to 10.
202
+ #
203
+ # rubocop:disable Metrics/MethodLength
204
+ def priority(value)
205
+ if value.nil?
206
+ _params[:priority] = nil
207
+ return self
208
+ end
209
+
210
+ priority_string = String(value)
211
+
212
+ unless %w[default normal high].include?(priority_string)
213
+ raise ArgumentError, 'priority must be default, normal, or high'
214
+ end
215
+
216
+ _params[:priority] = priority_string
217
+ self
218
+ rescue NoMethodError
219
+ raise ArgumentError, 'priority must be default, normal, or high'
220
+ end
221
+ # rubocop:enable Metrics/MethodLength
222
+
223
+ ##
224
+ # Set or overwrite the new badge count.
225
+ #
226
+ # Use 0 to clear, use nil to keep as is.
227
+ #
228
+ # @note iOS only
229
+ #
230
+ def badge(value)
231
+ _params[:badge] = value.nil? ? nil : value.to_i
232
+ self
233
+ rescue NoMethodError
234
+ raise ArgumentError, 'badge must be numeric or nil'
235
+ end
236
+
237
+ ##
238
+ # Set or overwrite the channel ID.
239
+ #
240
+ # ID of the Notification Channel through which to display this
241
+ # notification. If an ID is specified but the corresponding channel does
242
+ # not exist on the device (i.e. has not yet been created by your app),
243
+ # the notification will not be displayed to the user.
244
+ #
245
+ # @note If left nil, a "Default" channel will be used, and Expo will
246
+ # create the channel on the device if it does not yet exist. However,
247
+ # use caution, as the "Default" channel is user-facing and you may not
248
+ # be able to fully delete it.
249
+ #
250
+ # @note Android only
251
+ #
252
+ def channel_id(value)
253
+ _params[:channelId] = value.nil? ? nil : String(value)
254
+ self
255
+ rescue NoMethodError
256
+ raise ArgumentError, 'channelId must be string-like or nil to use "Default"'
257
+ end
258
+
259
+ alias channel_identifier channel_id
260
+
261
+ ##
262
+ # Set or overwrite the category ID
263
+ #
264
+ # ID of the notification category that this notification is associated
265
+ # with. Must be on at least SDK 41 or bare workflow.
266
+ #
267
+ # Notification categories allow you to create interactive push
268
+ # notifications, so that a user can respond directly to the incoming
269
+ # notification either via buttons or a text response. A category defines
270
+ # the set of actions a user can take, and then those actions are applied
271
+ # to a notification by specifying the categoryId here.
272
+ #
273
+ # @see https://docs.expo.dev/versions/latest/sdk/notifications/#managing-notification-categories-interactive-notifications
274
+ #
275
+ def category_id(value)
276
+ _params[:categoryId] = value.nil? ? nil : String(value)
277
+ self
278
+ rescue NoMethodError
279
+ raise ArgumentError, 'categoryId must be string-like or nil'
280
+ end
281
+
282
+ ##
283
+ # Set or overwrite the mutability flag.
284
+ #
285
+ # Use nil to use the defaults.
286
+ #
287
+ # Specifies whether this notification can be intercepted by the client
288
+ # app. In Expo Go, this defaults to true, and if you change that to
289
+ # false, you may experience issues. In standalone and bare apps, this
290
+ # defaults to false.
291
+ #
292
+ def mutable_content(value)
293
+ _params[:mutableContent] = value.nil? ? nil : !value.nil?
294
+ self
295
+ end
296
+
297
+ alias mutable mutable_content
298
+
299
+ ##
300
+ # Add a single recipient
301
+ #
302
+ # Must be a valid Expo Push Token, or a PushTokenInvalid error is raised.
303
+ #
304
+ # @see PushTokenInvalid
305
+ # @see #to
306
+ #
307
+ def <<(recipient)
308
+ raise PushTokenInvalid.new(token: recipient) unless Expo::Push.expo_push_token?(recipient)
309
+
310
+ recipients << recipient
311
+
312
+ self
313
+ end
314
+
315
+ alias add_recipient <<
316
+ alias add_recipients to
317
+
318
+ ##
319
+ # Allows overwriting the recipients list which is necessary to prepare
320
+ # the notification when chunking.
321
+ #
322
+ def prepare(targets)
323
+ dup.tap do |prepared|
324
+ prepared.reset_recipients(targets)
325
+ end
326
+ end
327
+
328
+ def count
329
+ recipients.length
330
+ end
331
+
332
+ def as_json
333
+ puts _params
334
+
335
+ { to: recipients }.merge(_params.compact)
336
+ end
337
+
338
+ def reset_recipients(targets)
339
+ self.recipients = []
340
+ add_recipients(targets)
341
+ end
342
+
343
+ private
344
+
345
+ attr_accessor :_params
346
+ end
347
+ end
348
+ end