expo-server-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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