push_kit-apns 1.0.0.pre.beta1

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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81a37cc16b4ed2fa5d99091530b2352cfce87b8329b7573c2a57cd66c8f951c6
4
+ data.tar.gz: d7d6b80348f17dec42e2ba44cc1c2b8e9348a4cd5979b63e4194d0aa728f7d5a
5
+ SHA512:
6
+ metadata.gz: c3bf44f4cd9d0935ed1ed3ea76f5ae5d7407ec9723946f8086e262a5184f8e92f62ba69268585c706c66bf21e5a3fab6529858e9af2c57f03139a859a0772747
7
+ data.tar.gz: 39b9bf33159a6a53669515700f169d9865c8442aa8d05d625c34032bfc9b46b9c14571cc2fd3bd190992c5eade594dbd029fc9c7f638aa75c0429c5fd039a37e
@@ -0,0 +1,46 @@
1
+ # Packages #
2
+ ############
3
+ *.7z
4
+ *.dmg
5
+ *.gz
6
+ *.iso
7
+ *.jar
8
+ *.rar
9
+ *.tar
10
+ *.zip
11
+
12
+ # Logs #
13
+ ########
14
+ *.log
15
+
16
+ # Databases #
17
+ #############
18
+ *.sql
19
+ *.sqlite
20
+
21
+ # OS Files #
22
+ ############
23
+ .DS_Store
24
+ .Trashes
25
+ ehthumbs.db
26
+ Icon?
27
+ Thumbs.db
28
+
29
+ # Vagrant #
30
+ ###########
31
+ .vagrant
32
+
33
+ # Ruby Files #
34
+ ##############
35
+ /.bundle/
36
+ /.yardoc
37
+ /Gemfile.lock
38
+ /_yardoc/
39
+ /coverage/
40
+ /doc/
41
+ /pkg/
42
+ /spec/reports/
43
+ /spec/examples.txt
44
+ /tmp/
45
+ /vendor/bundle/
46
+ *.gem
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,21 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'bin/**/*'
4
+
5
+ Metrics/AbcSize:
6
+ Max: 30
7
+
8
+ Metrics/LineLength:
9
+ Max: 120
10
+
11
+ Metrics/BlockLength:
12
+ Enabled: false
13
+
14
+ Metrics/ClassLength:
15
+ Enabled: false
16
+
17
+ Metrics/MethodLength:
18
+ Enabled: false
19
+
20
+ Style/Documentation:
21
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ guard :rspec, cmd: 'bundle exec rspec' do
4
+ require 'guard/rspec/dsl'
5
+
6
+ dsl = Guard::RSpec::Dsl.new(self)
7
+
8
+ # RSpec files
9
+ rspec = dsl.rspec
10
+ watch(rspec.spec_helper) { rspec.spec_dir }
11
+ watch(rspec.spec_support) { rspec.spec_dir }
12
+ watch(rspec.spec_files)
13
+
14
+ # Ruby files
15
+ ruby = dsl.ruby
16
+ dsl.watch_spec_files_for(ruby.lib_files)
17
+ end
@@ -0,0 +1,118 @@
1
+ # PushKit
2
+
3
+ PushKit makes it easy to create and deliver push notifications.
4
+
5
+ ## PushKit::APNS
6
+
7
+ PushKit::APNS provides an easy-to-use API for creating and then delivering notifications via the Apple Push Notification service.
8
+ It uses the new HTTP/2 API and tokens so no more yearly certificate renewals!
9
+
10
+ ## Installation
11
+
12
+ You can install **PushKit::APNS** using the following command:
13
+
14
+ $ gem install push_kit-apns
15
+
16
+ ### Usage
17
+
18
+ You'll need to grab a few things in order to send push notifications:
19
+
20
+ - A key from the Apple Developer portal, one which has the push notification option enabled.
21
+ - The ID of the key you're going to use.
22
+ - The ID of the team that generated the key.
23
+ - The bundle identifier of your application, which is used as the APNS topic.
24
+
25
+ Once you've obtained all of the above, you can create a new client like this:
26
+
27
+ ```ruby
28
+ client = PushKit::APNS.client(
29
+ key: PushKit::APNS.load_key('/path/to/APNsAuthKey_XXXXXXXXXX.p8'),
30
+ key_id: 'XXXXXXXXXX',
31
+ team_id: 'XXXXXXXXXX',
32
+ topic: 'com.example.app'
33
+ )
34
+ ```
35
+
36
+ Ideally, you should create the client when your app boots and keep it until your app is rebooted.
37
+ This allows us to maintain a connection to the Apple Push Notification service and quickly deliver notifications without the overhead of establishing a connection.
38
+
39
+ See [Communicating with APNs - Best Practices for Managing Connections](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW8) for more info on this.
40
+
41
+ ### Additional Options
42
+
43
+ There are a few additional options you can specify when creating a client.
44
+
45
+ ##### :host
46
+
47
+ You can provide the full hostname (or IP address) as a String. For example: 'api.push.apple.com'
48
+
49
+ Because this option's value is rarely set to anything other than the standard hostnames, we've added
50
+ a couple of convenience Symbols. These are *:development* and *:production* which sets the hostname
51
+ to the appropriate hostname for that environment.
52
+
53
+ The default value of this option is *:development*.
54
+
55
+ ##### :port
56
+
57
+ You can also provide the port number as an Integer. For example: 443
58
+ This also has a couple of convenience Symbols, *:default* and *:alternative*.
59
+
60
+ The default value of this option is *:default*.
61
+
62
+ ---
63
+
64
+ Creating a notification is easy with PushKit and there are quite a few different options you can set.
65
+ For a full list, checkout the documentation for `PushKit::APNS::Notification`.
66
+
67
+ For now, let's just create a simple notification:
68
+
69
+ ```ruby
70
+ notification = PushKit::APNS::Notification.new
71
+ notification.title = 'Hello, World!'
72
+ notification.body = 'How are you today?'
73
+ notification.sound = :default
74
+ notification.device_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
75
+ ```
76
+
77
+ Then using our `PushKit::APNS` instance we created earlier, deliver the notification:
78
+
79
+ ```ruby
80
+ apns.deliver(notification)
81
+ ```
82
+
83
+ EXPLAIN_ASYNC_NEEDS_WAIT
84
+
85
+ ---
86
+
87
+ The `PushKit::APNS::Notification` instance has a *:device_token* attribute which needs to be set to the token of the device you're sending the notification to.
88
+
89
+ It's common to want to send the same notification to a bunch of devices. When you want to do this, leave the initial notification's *:device_token* attribute blank and instead use the helper method *:for_tokens*:
90
+
91
+ ```ruby
92
+ notification = PushKit::APNS::Notification.new
93
+ notification.title = 'Hello, World!'
94
+ notification.body = 'How are you today?'
95
+ notification.sound = :default
96
+
97
+ tokens = %w[
98
+ 1111111111111111111111111111111111111111111111111111111111111111
99
+ 2222222222222222222222222222222222222222222222222222222222222222
100
+ 3333333333333333333333333333333333333333333333333333333333333333
101
+ ]
102
+
103
+ notifications = notification.for_tokens(*tokens)
104
+ ```
105
+
106
+ When you're ready, you can just deliver the array of notifications:
107
+
108
+ ```ruby
109
+ client.deliver(notifications) do |notification, result|
110
+ # ...
111
+ end
112
+ ```
113
+
114
+ ## Development
115
+
116
+ After checking out the repo, run `bundle exec rake spec` to run the tests.
117
+
118
+ To install this gem onto your machine, run `bundle exec rake install`.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'push_kit/apns'
5
+ require 'irb'
6
+
7
+ IRB.start
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'http/2'
5
+ require 'json'
6
+ require 'jwt'
7
+ require 'openssl'
8
+ require 'securerandom'
9
+ require 'uri'
10
+
11
+ require 'push_kit/apns/constants'
12
+ require 'push_kit/apns/notification/localization'
13
+ require 'push_kit/apns/notification'
14
+ require 'push_kit/apns/http_client'
15
+ require 'push_kit/apns/token_generator'
16
+ require 'push_kit/apns/push_client'
17
+
18
+ module PushKit
19
+ # PushKit::APNS provides an easy-to-use API for creating and then delivering notifications via the
20
+ # Apple Push Notification service.
21
+ #
22
+ module APNS
23
+ # Read the key from the file at the specified path.
24
+ #
25
+ # @param path [String] Path to a key file.
26
+ # @return [OpenSSL::PKey::EC] The loaded key.
27
+ #
28
+ def self.load_key(path)
29
+ raise ArgumentError, "The key file does not exist: #{path}" unless File.file?(path)
30
+
31
+ OpenSSL::PKey::EC.new(File.read(path))
32
+ end
33
+
34
+ # Create a new PushClient instance.
35
+ #
36
+ # @param options [Hash] The APNS options:
37
+ # host [String|Symbol] The host (can also be :production or :development).
38
+ # port [Integer|Symbol] The port number (can also be :default or :alternative).
39
+ # topic [String] The APNS topic (matches the app's bundle identifier).
40
+ # key [OpenSSL::PKey::EC] The elliptic curve key to use for authentication.
41
+ # key_id [String] The identifier for the elliptic curve key.
42
+ # team_id [String] The identifier for the Apple Developer team.
43
+ # topic [String] The topic for your application (usually the bundle identifier).
44
+ #
45
+ def self.client(options = {})
46
+ options = {
47
+ host: :development,
48
+ port: :default
49
+ }.merge(options)
50
+
51
+ token_generator = TokenGenerator.new(
52
+ key: options[:key],
53
+ key_id: options[:key_id],
54
+ team_id: options[:team_id]
55
+ )
56
+
57
+ PushClient.new(
58
+ host: options[:host],
59
+ port: options[:port],
60
+ topic: options[:topic],
61
+ token_generator: token_generator
62
+ )
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushKit
4
+ module APNS
5
+ # @return [String] The gem's semantic version number.
6
+ #
7
+ VERSION = '1.0.0-beta1'
8
+ end
9
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushKit
4
+ module APNS
5
+ # The HTTPClient class provides a minimal HTTP/2 client.
6
+ #
7
+ class HTTPClient
8
+ # @return [String] The ALPN protocol required by HTTP/2.
9
+ #
10
+ ALPN_PROTOCOL = 'h2'
11
+
12
+ # @return [URI] The URI which indicates the scheme, host and port of the remote server.
13
+ #
14
+ attr_reader :uri
15
+
16
+ # Creates a new HTTPClient.
17
+ #
18
+ # @param uri [String|URI] A URI which indicates the scheme, host and port of the remote server.
19
+ #
20
+ def initialize(uri)
21
+ case uri
22
+ when String then @uri = URI(uri)
23
+ when URI then @uri = uri
24
+ else raise 'Expected the :uri attribute to be a String or a URI.'
25
+ end
26
+ end
27
+
28
+ # Perform a HTTP request.
29
+ #
30
+ # @param method [String|Symbol] The request method (:get, :post, etc).
31
+ # @param path [String] The request path.
32
+ # @param headers [Hash] The request headers.
33
+ # @param body [String] The request body.
34
+ # @param block [Proc] A block to call once the request has completed.
35
+ #
36
+ # rubocop:disable Metrics/CyclomaticComplexity
37
+ # rubocop:disable Metrics/PerceivedComplexity
38
+ #
39
+ def request(method: nil, path: nil, headers: nil, body: nil, &block)
40
+ raise 'The :method should be a String or a Symbol' unless method.is_a?(String) || method.is_a?(Symbol)
41
+ raise 'The :path should be a String' unless path.is_a?(String)
42
+ raise 'The :headers should be a Hash' unless headers.nil? || headers.is_a?(Hash)
43
+ raise 'The :body should be a String' unless body.nil? || body.is_a?(String)
44
+ raise 'The completion block must be provided.' unless block.is_a?(Proc)
45
+
46
+ requests.push(method: method, path: path, headers: headers, body: body, completion: block)
47
+
48
+ perform_requests
49
+
50
+ nil
51
+ end
52
+ # rubocop:enable Metrics/CyclomaticComplexity
53
+ # rubocop:enable Metrics/PerceivedComplexity
54
+
55
+ private
56
+
57
+ # @return [Mutex] The semaphore used to ensure only one #perform_requests method call executes at a time.
58
+ #
59
+ def perform_requests_mutex
60
+ @perform_requests_mutex ||= Mutex.new
61
+ end
62
+
63
+ # @return [Queue] The queue containing requests that need to be processed.
64
+ #
65
+ def requests
66
+ @requests ||= Queue.new
67
+ end
68
+
69
+ # @return [OpenSSL::SSL::SSLSocket|TCPSocket] An SSL or TCP socket.
70
+ #
71
+ def socket
72
+ ssl_socket || tcp_socket
73
+ end
74
+
75
+ # Reset the client and sockets if the connection is broken.
76
+ #
77
+ # rubocop:disable Metrics/CyclomaticComplexity
78
+ # rubocop:disable Metrics/PerceivedComplexity
79
+ #
80
+ def reset_if_required!
81
+ socket = @ssl_socket || @tcp_socket
82
+
83
+ return unless socket.nil? || socket.closed? || @client.nil? || @client.closed?
84
+
85
+ @ssl_socket.close unless @ssl_socket.nil? || @ssl_socket.closed?
86
+ @tcp_socket.close unless @tcp_socket.nil? || @tcp_socket.closed?
87
+
88
+ @ssl_socket = nil
89
+ @tcp_socket = nil
90
+ @client = nil
91
+ end
92
+ # rubocop:enable Metrics/CyclomaticComplexity
93
+ # rubocop:enable Metrics/PerceivedComplexity
94
+
95
+ # @return [HTTP2::Client] An HTTP/2 client.
96
+ #
97
+ def client
98
+ reset_if_required!
99
+
100
+ return @client unless @client.nil? || @client.closed?
101
+
102
+ @client = HTTP2::Client.new
103
+
104
+ @client.on(:frame) do |bytes|
105
+ next if socket.closed?
106
+
107
+ socket.print(bytes)
108
+ socket.flush
109
+ end
110
+
111
+ @client.send_connection_preface
112
+
113
+ # The @client instance variable must exist before calling this method!
114
+ spawn_input_thread
115
+
116
+ @client
117
+ end
118
+
119
+ # @return [TCPSocket] The TCP socket.
120
+ #
121
+ def tcp_socket
122
+ return @tcp_socket unless @tcp_socket.nil? || @tcp_socket.closed?
123
+
124
+ @tcp_socket = TCPSocket.new(uri.host, uri.port)
125
+ end
126
+
127
+ # @return [OpenSSL::SSL::SSLSocket] An SSL socket.
128
+ #
129
+ def ssl_socket
130
+ return @ssl_socket unless @ssl_socket.nil? || @ssl_socket.closed?
131
+
132
+ # Only create the SSLSocket if the URI requires a secure connection.
133
+ return nil unless uri.scheme == 'https'
134
+
135
+ socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
136
+ socket.sync_close = true
137
+ socket.hostname = uri.host
138
+ socket.connect
139
+
140
+ if socket.alpn_protocol != ALPN_PROTOCOL
141
+ raise "Expected ALPN protocol '#{ALPN_PROTOCOL}' but received '#{socket.alpn_protocol}'."
142
+ end
143
+
144
+ @ssl_socket = socket
145
+ end
146
+
147
+ # @return [OpenSSL::SSL::SSLContext] An SSL context.
148
+ #
149
+ def ssl_context
150
+ context = OpenSSL::SSL::SSLContext.new
151
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
152
+ context.alpn_protocols = [ALPN_PROTOCOL]
153
+ context.alpn_select_cb = lambda do |protocols|
154
+ ALPN_PROTOCOL if protocols.include?(ALPN_PROTOCOL)
155
+ end
156
+
157
+ context
158
+ end
159
+
160
+ # Loop over the requests in the queue, performing them until we've emptied the queue or we've hit
161
+ # the concurrent stream limit.
162
+ #
163
+ def perform_requests
164
+ return unless perform_requests_mutex.try_lock
165
+
166
+ loop do
167
+ begin
168
+ request = requests.pop(true)
169
+ perform_request(request)
170
+ rescue HTTP2::Error::StreamLimitExceeded
171
+ requests.push(request)
172
+ break
173
+ rescue ThreadError
174
+ break
175
+ end
176
+ end
177
+
178
+ perform_requests_mutex.unlock
179
+ end
180
+
181
+ # @param request [Hash] The request.
182
+ #
183
+ def perform_request(request)
184
+ stream = client.new_stream
185
+
186
+ if (block = request[:completion])
187
+ handle_response(stream, &block)
188
+ end
189
+
190
+ headers = headers(request)
191
+ body = request[:body]
192
+
193
+ if body.is_a?(String)
194
+ stream.headers(headers, end_stream: false)
195
+ stream.data(body, end_stream: true)
196
+ else
197
+ stream.headers(headers, end_stream: true)
198
+ end
199
+ end
200
+
201
+ # @param stream [HTTP2::Stream] The unique stream for the request.
202
+ # @param block [Proc] A block to call once the request has completed.
203
+ #
204
+ def handle_response(stream, &block)
205
+ return unless block.is_a?(Proc)
206
+
207
+ response_headers = {}
208
+ response_body = String.new
209
+
210
+ stream.on(:headers) do |headers|
211
+ response_headers.merge!(Hash[headers])
212
+ end
213
+
214
+ stream.on(:data) do |data|
215
+ response_body.concat(data)
216
+ end
217
+
218
+ stream.on(:close) do
219
+ perform_requests
220
+
221
+ status = response_headers[':status']
222
+ status = status.to_i if status.is_a?(String)
223
+ status = nil unless status.is_a?(Integer)
224
+
225
+ block.call(status, response_headers, response_body)
226
+ end
227
+ end
228
+
229
+ # @param request [Hash] The request.
230
+ # @return [Hash] The request headers.
231
+ #
232
+ def headers(request)
233
+ return nil unless request.is_a?(Hash)
234
+ return nil unless (method = request[:method])
235
+ return nil unless (path = request[:path])
236
+
237
+ headers = {
238
+ ':scheme' => uri.scheme,
239
+ ':authority' => [uri.host, uri.port].join(':'),
240
+ ':method' => method.to_s.upcase,
241
+ ':path' => path.to_s
242
+ }
243
+
244
+ body = request[:body]
245
+ headers['content-length'] = body.length.to_s if body.is_a?(String)
246
+
247
+ headers.merge!(request[:headers]) if request[:headers].is_a?(Hash)
248
+
249
+ headers
250
+ end
251
+
252
+ # Spawn a thread to wrap the #input_loop method.
253
+ #
254
+ def spawn_input_thread
255
+ return if @input_thread&.alive?
256
+
257
+ @input_thread = Thread.new { input_loop }
258
+ end
259
+
260
+ # Start a continuous loop, reading bytes from the socket and passing them to the HTTP/2 client.
261
+ #
262
+ def input_loop
263
+ loop do
264
+ break if @client.nil? || @client.closed? || socket.closed? || socket.eof?
265
+
266
+ begin
267
+ @client << socket.read_nonblock(1024)
268
+ rescue StandardError
269
+ socket.close
270
+
271
+ raise
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end