expo-push 1.0.2

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.
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ cache: bundler
3
+ rvm:
4
+ - 2.6.0
5
+ - 2.5.0
6
+ - 2.4.0
7
+ - 2.3.0
8
+ before_install:
9
+ - gem update --system
10
+ - gem install bundler
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in exponent-server-sdk.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2016 Jesse Ruder
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Exponent Server SDK Ruby
2
+
3
+ [![Build Status](https://travis-ci.org/expo/expo-server-sdk-ruby.svg?branch=master)](https://travis-ci.org/expo/expo-server-sdk-ruby)
4
+ [![Gem Version](https://badge.fury.io/rb/exponent-server-sdk.svg)](https://badge.fury.io/rb/exponent-server-sdk)
5
+
6
+ Use to send push notifications to Exponent Experiences from a Ruby server.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'expo-push'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ ```shell
19
+ $ bundle
20
+ ```
21
+
22
+ Or install it yourself as:
23
+
24
+ ```shell
25
+ $ gem install expo-push
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Client
31
+
32
+ The push client is the preferred way. This hits the latest version of the api.
33
+
34
+ Optional arguments: `gzip: true`
35
+
36
+ ```ruby
37
+ client = Exponent::Push::Client.new
38
+ # client = Exponent::Push::Client.new(gzip: true) # for compressed, faster requests
39
+
40
+ messages = [{
41
+ to: "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
42
+ sound: "default",
43
+ body: "Hello world!"
44
+ }, {
45
+ to: "ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]",
46
+ badge: 1,
47
+ body: "You've got mail"
48
+ }]
49
+
50
+ # @Deprecated
51
+ # client.publish(messages)
52
+
53
+ # MAX 100 messages at a time
54
+ handler = client.send_messages(messages)
55
+
56
+ # Array of all errors returned from the API
57
+ # puts handler.errors
58
+
59
+ # you probably want to delay calling this because the service might take a few moments to send
60
+ # I would recommend reading the expo documentation regarding delivery delays
61
+ client.verify_deliveries(handler.receipt_ids)
62
+
63
+ ```
64
+
65
+ See the getting started example. If you clone this repo, you can also use it to test locally by entering your ExponentPushToken. Otherwise it serves as a good copy pasta example to get you going.
66
+
67
+ The complete format of the messages can be found [here.](https://docs.expo.io/push-notifications/sending-notifications/#message-request-format)
68
+
69
+ ## Contributing
70
+
71
+ If you have problems with the code in this repository, please file issues & bug reports. We encourage you
72
+ to submit a pull request with a solution or a failing test to reproduce your issue. Thanks!
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ def load_libs(rake_task)
5
+ rake_task.libs << 'test'
6
+ rake_task.libs << 'lib'
7
+ end
8
+
9
+ Rake::TestTask.new(:test) do |rake_task|
10
+ load_libs rake_task
11
+ rake_task.test_files = FileList['test/**/*-test.rb']
12
+ end
13
+
14
+ Rake::TestTask.new(:getting_started) do |rake_task|
15
+ load_libs rake_task
16
+ rake_task.test_files = FileList['examples/getting_started.rb']
17
+ end
18
+
19
+ task default: :test
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'expo-push'
4
+
5
+ class Test
6
+ def initialize
7
+ # @client = Exponent::Push::Client.new
8
+
9
+ # OR use GZIP to be AWESOME
10
+ @client = Exponent::Push::Client.new(gzip: true)
11
+ end
12
+
13
+ def too_many_messages
14
+ (0..101).map { create_message }
15
+ end
16
+
17
+ def create_message
18
+ {
19
+ # REPLACE WITH YOUR EXPONENT PUSH TOKEN LIKE:
20
+ # to: 'ExponentPushToken[g5sIEbOm2yFdzn5VdSSy9n]',
21
+ to: "ExponentPushToken[#{(0...22).map { ('a'..'z').to_a[rand(26)] }.join}]",
22
+ sound: 'default',
23
+ title: 'Hello World',
24
+ subtitle: 'This is a Push Notification',
25
+ body: 'Here\'s a little message for you...',
26
+ data: {
27
+ user_id: 1,
28
+ points: 23_434
29
+ },
30
+ ttl: 10,
31
+ expiration: 1_886_207_332,
32
+ priority: 'default',
33
+ badge: 0,
34
+ channelId: 'game'
35
+ }
36
+ end
37
+
38
+ def test
39
+ # messages = too_many_messages
40
+ messages = [create_message]
41
+
42
+ response_handler = @client.send_messages(messages)
43
+ puts response_handler.response.response_body
44
+ end
45
+ end
46
+
47
+ Test.new.test
data/expo-push.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'expo-push/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'expo-push'
7
+ spec.version = Exponent::VERSION
8
+ spec.authors = ['Jesse Ruder', 'Pablo Gomez', 'Mike Taylor']
9
+ spec.email = ['jesse@sixfivezero.net', 'pablonahuelgomez@gmail.com', 'mike.taylor@growmaple.com']
10
+ spec.summary = %q{Expo Push}
11
+ spec.description = %q{A ruby gem to communicate with the Expo Push service.}
12
+ spec.homepage = 'https://github.com/growmaple/expo-push'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_dependency 'typhoeus', '~> 1.4'
21
+
22
+ spec.add_development_dependency 'bundler'
23
+ spec.add_development_dependency 'minitest'
24
+ spec.add_development_dependency 'rake'
25
+ spec.add_development_dependency 'rubocop'
26
+ end
@@ -0,0 +1,2 @@
1
+ class TooManyMessagesError < StandardError
2
+ end
@@ -0,0 +1,3 @@
1
+ module Exponent
2
+ VERSION = '1.0.2'.freeze
3
+ end
data/lib/expo-push.rb ADDED
@@ -0,0 +1,269 @@
1
+ require 'expo-push/version'
2
+ require 'expo-push/too_many_messages_error'
3
+ require 'typhoeus'
4
+ require 'json'
5
+
6
+ # Basic Usage:
7
+ #
8
+ # Create new client
9
+ # client = Exponent::Push::Client.new(**args)
10
+ #
11
+ # Send UPTO ~~100~~ messages per call,
12
+ # https://docs.expo.io/versions/latest/guides/push-notifications/#message-format
13
+ # response_handler = client.send_messages([list of formatted messages])
14
+ #
15
+ # Check the response to see if any errors were re
16
+ # response_handler.errors?
17
+ #
18
+ # To process each error, iterate over the errors array
19
+ # which contains each Error class instance
20
+ # response_handler.errors
21
+ #
22
+ # There is an array of invalid ExponentPushTokens that were found in the initial /send call
23
+ # response_handler.invalid_push_tokens['ExponentPushToken[1212121212121212]']
24
+ #
25
+ # You can use the handler to get receipt_ids
26
+ # response_handler.receipt_ids
27
+ #
28
+ # You can pass an array of receipt_ids to verify_deliveries method and
29
+ # it will populate a new ResponseHandler with any errors
30
+ # receipt_response = client.verify_deliveries(receipt_ids)
31
+
32
+ module Exponent
33
+ def self.is_exponent_push_token?(token)
34
+ token.start_with?('ExponentPushToken')
35
+ end
36
+
37
+ module Push
38
+ class Client
39
+ def initialize(**args)
40
+ @http_client = args[:http_client] || Typhoeus
41
+ @error_builder = ErrorBuilder.new
42
+ # future versions will deprecate this
43
+ @response_handler = args[:response_handler] || ResponseHandler.new
44
+ @gzip = args[:gzip] == true
45
+ end
46
+
47
+ # returns response handler that provides access to errors? and other response inspection methods
48
+ def send_messages(messages, **args)
49
+ # https://docs.expo.io/versions/latest/guides/push-notifications/#message-format
50
+ raise TooManyMessagesError, 'Only 100 message objects at a time allowed.' if messages.length > 100
51
+
52
+ response = push_notifications(messages)
53
+
54
+ # each call to send_messages will return a new instance of ResponseHandler
55
+ handler = args[:response_handler] || ResponseHandler.new
56
+ handler.process_response(response)
57
+ handler
58
+ end
59
+
60
+ def verify_deliveries(receipt_ids, **args)
61
+ response = get_receipts(receipt_ids)
62
+ handler = args[:response_handler] || ResponseHandler.new
63
+ handler.process_response(response)
64
+ handler
65
+ end
66
+
67
+ private
68
+
69
+ def push_notifications(messages)
70
+ @http_client.post(
71
+ push_url,
72
+ body: messages.to_json,
73
+ headers: headers,
74
+ accept_encoding: @gzip
75
+ )
76
+ end
77
+
78
+ def push_url
79
+ 'https://exp.host/--/api/v2/push/send'
80
+ end
81
+
82
+ def get_receipts(receipt_ids)
83
+ @http_client.post(
84
+ receipts_url,
85
+ body: { ids: receipt_ids }.to_json,
86
+ headers: headers,
87
+ accept_encoding: @gzip
88
+ )
89
+ end
90
+
91
+ def receipts_url
92
+ 'https://exp.host/--/api/v2/push/getReceipts'
93
+ end
94
+
95
+ def headers
96
+ headers = {
97
+ 'Content-Type' => 'application/json',
98
+ 'Accept' => 'application/json'
99
+ }
100
+ headers
101
+ end
102
+ end
103
+
104
+ class ResponseHandler
105
+ attr_reader :response, :invalid_push_tokens, :receipt_ids, :errors, :raw_errors
106
+
107
+ def initialize(error_builder = ErrorBuilder.new)
108
+ @error_builder = error_builder
109
+ @response = nil
110
+ @receipt_ids = []
111
+ @invalid_push_tokens = []
112
+ @errors = []
113
+ @raw_errors = []
114
+ end
115
+
116
+ def process_response(response)
117
+ @response = response
118
+
119
+ case response.code.to_s
120
+ when '504'
121
+ # NOTE: Raise specific error so app knows to retry the request.
122
+ raise Exponent::Push::GatewayTimeoutError.new(
123
+ "Request timed out: #{response.code} #{response.response_body}"
124
+ )
125
+ when '502'
126
+ # NOTE: Raise specific error so app knows to retry the request.
127
+ raise Exponent::Push::BadGatewayError.new(
128
+ "Request timed out: #{response.code} #{response.response_body}"
129
+ )
130
+ when '400'
131
+ # NOTE: The app will want to handle expo server error response differently.
132
+ # Responding with an object that exposes what the expo server errored with.
133
+ @raw_errors = body.fetch('errors', [])
134
+ when /(^4|^5)/
135
+ # NOTE: Catch-all in case we do not get a 400 or 504 error. This will raise unknown
136
+ # error with information on what the issue was.
137
+ raise @error_builder.parse_response(response)
138
+ else
139
+ sort_results
140
+ end
141
+ end
142
+
143
+ def errors?
144
+ @errors.any?
145
+ end
146
+
147
+ private
148
+
149
+ def sort_results
150
+ data = body&.fetch('data', nil) || nil
151
+
152
+ # something is definitely wrong
153
+ return if data.nil?
154
+
155
+ # Array indicates a response from the /send endpoint
156
+ # Hash indicates a response from the /getReceipts endpoint
157
+ if data.is_a? Array
158
+ data.each do |push_ticket|
159
+ receipt_id = push_ticket.fetch('id', nil)
160
+ if push_ticket.fetch('status', nil) == 'ok'
161
+ @receipt_ids.push(receipt_id) unless receipt_id.nil?
162
+ else
163
+ process_error(push_ticket)
164
+ end
165
+ end
166
+ else
167
+ process_receipts(data)
168
+ end
169
+ end
170
+
171
+ def process_receipts(receipts)
172
+ receipts.each do |receipt_id, receipt|
173
+ @receipt_ids.push(receipt_id) unless receipt_id.nil?
174
+ process_error(receipt) unless receipt.fetch('status') == 'ok'
175
+ end
176
+ end
177
+
178
+ def process_error(push_ticket)
179
+ message = push_ticket.fetch('message')
180
+ invalid = message.match(/ExponentPushToken\[(...*)\]/)
181
+ unregistered = message.match(/\"(...*)\"/)
182
+ error_class = @error_builder.parse_push_ticket(push_ticket)
183
+
184
+ @invalid_push_tokens.push(invalid[0]) unless invalid.nil?
185
+ @invalid_push_tokens.push(unregistered[1]) unless unregistered.nil?
186
+
187
+ @errors.push(error_class) unless @errors.include?(error_class)
188
+ end
189
+
190
+ def body
191
+ # memoization FTW!
192
+ @body ||= JSON.parse(@response.body)
193
+ rescue SyntaxError
194
+ # Sometimes the server returns an empty string.
195
+ # It must be escaped before we can process it.
196
+ @body = JSON.parse(@response.body.to_json)
197
+ rescue StandardError
198
+ # Prevent nil errors in old version of ruby when using fetch
199
+ @body = {}
200
+ end
201
+ end
202
+
203
+ class ErrorBuilder
204
+ def parse_response(response)
205
+ with_error_handling(response) do
206
+ error = response.fetch('errors')
207
+ error_name = error.fetch('code')
208
+ message = error.fetch('message')
209
+
210
+ get_error_class(error_name).new(message)
211
+ end
212
+ end
213
+
214
+ def parse_push_ticket(push_ticket)
215
+ with_error_handling(push_ticket) do
216
+ message = push_ticket.fetch('message')
217
+ get_error_class(push_ticket.fetch('details').fetch('error')).new(message)
218
+ end
219
+ end
220
+
221
+ %i[erroneous successful].each do |selector|
222
+ define_method(:"build_from_#{selector}") do |response|
223
+ with_error_handling(response) do
224
+ send "from_#{selector}_response", response
225
+ end
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ def with_error_handling(response)
232
+ yield(response)
233
+ rescue KeyError, NoMethodError => e
234
+ unknown_error_format(response, e)
235
+ end
236
+
237
+ def validate_error_name(condition)
238
+ condition ? yield : Exponent::Push::UnknownError
239
+ end
240
+
241
+ def get_error_class(error_name)
242
+ validate_error_name(Exponent::Push.error_names.include?(error_name)) do
243
+ Exponent::Push.const_get("#{error_name}Error")
244
+ end
245
+ end
246
+
247
+ def unknown_error_format(response, e)
248
+ error_message = e.message
249
+ error_response_code = response.response_code
250
+ error_response_body = response.response_body
251
+ Exponent::Push::UnknownError.new(
252
+ "Unknown error format: #{e.message} #{error_response_code} #{error_response_body}"
253
+ )
254
+ end
255
+ end
256
+
257
+ Error = Class.new(StandardError)
258
+
259
+ def self.error_names
260
+ %w[DeviceNotRegistered MessageTooBig
261
+ MessageRateExceeded InvalidCredentials GatewayTimeout
262
+ BadGateway Unknown]
263
+ end
264
+
265
+ error_names.each do |error_name|
266
+ const_set "#{error_name}Error", Class.new(Error)
267
+ end
268
+ end
269
+ end