lowdown 0.2.0 → 0.3.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,63 @@
1
+ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
2
+ require "lowdown"
3
+ require "logger"
4
+
5
+ cert_file, environment, device_token = ARGV.first(3)
6
+ unless cert_file && environment && device_token
7
+ puts "Usage: #{$PROGRAM_NAME} path/to/cert.pem [production|development] device-token"
8
+ exit 1
9
+ end
10
+
11
+ # $CELLULOID_DEBUG = true
12
+ # Celluloid.logger.level = Logger::DEBUG
13
+ # Celluloid.logger.level = Logger::INFO
14
+ Celluloid.logger.level = Logger::ERROR
15
+
16
+ logger = Logger.new(STDOUT)
17
+
18
+ client = Lowdown::Client.production(environment == "production",
19
+ certificate: File.read(cert_file),
20
+ pool_size: 3,
21
+ keep_alive: true) # This option is the key to long running connections
22
+ client.connect
23
+
24
+ loop do
25
+ begin
26
+ logger.info "Perform burst"
27
+ # Perform a burst of notifications from multiple concurrent threads to demonstrate thread safety.
28
+ #
29
+ Array.new(3) do
30
+ Thread.new do
31
+ client.group do |group|
32
+ 10.times do
33
+ notification = Lowdown::Notification.new(:token => device_token)
34
+ notification.payload = { :alert => "Hello HTTP/2! ID=#{notification.id}" }
35
+ group.send_notification(notification) do |response|
36
+ if response.success?
37
+ logger.debug "Sent notification with ID: #{notification.id}"
38
+ else
39
+ logger.error "[!] (##{response.id}): #{response}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end.each(&:join)
46
+
47
+ logger.info "Sleep for 5 seconds"
48
+ sleep(5)
49
+
50
+ rescue Interrupt
51
+ logger.info "[!] Interrupt, exiting"
52
+ break
53
+
54
+ rescue Exception => e
55
+ logger.error "[!] Exception occurred, re-trying in 1 second: #{e.inspect}\n\t#{e.backtrace.join("\n\t")}"
56
+ sleep 1
57
+ redo
58
+ end
59
+ end
60
+
61
+ client.disconnect
62
+ puts "Finished!"
63
+
@@ -0,0 +1,37 @@
1
+ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
2
+ require "lowdown"
3
+
4
+ cert_file, environment, device_token = ARGV.first(3)
5
+ unless cert_file && environment && device_token
6
+ puts "Usage: #{$PROGRAM_NAME} path/to/cert.pem [production|development] device-token"
7
+ exit 1
8
+ end
9
+
10
+ production = environment == "production"
11
+
12
+ # $CELLULOID_DEBUG = true
13
+ # Celluloid.logger.level = Logger::DEBUG
14
+ # Celluloid.logger.level = Logger::INFO
15
+ Celluloid.logger.level = Logger::ERROR
16
+
17
+ # Connection time can take a while, just count the time it takes to connect.
18
+ start = nil
19
+
20
+ # The block form of Client#connect flushes and closes the connection at the end of the block.
21
+ Lowdown::Client.production(production, certificate: File.read(cert_file), pool_size: 2).connect do |group|
22
+ start = Time.now
23
+ 600.times do
24
+ notification = Lowdown::Notification.new(:token => device_token)
25
+ notification.payload = { :alert => "Hello HTTP/2! ID=#{notification.id}" }
26
+ group.send_notification(notification) do |response|
27
+ if response.success?
28
+ puts "Sent notification with ID: #{notification.id}"
29
+ else
30
+ puts "[!] (##{response.id}): #{response}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ puts "Finished in #{Time.now - start} seconds"
37
+
@@ -3,27 +3,8 @@ require "lowdown/version"
3
3
 
4
4
  # Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.
5
5
  #
6
- # Multiple notifications are multiplexed and responses are yielded onto a different thread for efficiency.
7
- #
8
- # Note that it is thus _your_ responsibility to take the threading issue into account. E.g. if you are planning to
9
- # update a DB model with the status of a notification delivery, be sure to respect the treading rules of your DB client.
10
- #
11
- # The main classes you will interact with are {Lowdown::Client} and {Lowdown::Notification}. For testing purposes there
12
- # are some helpers available in {Lowdown::Mock}.
13
- #
14
- # @example At its simplest, you can send a notification like so:
15
- #
16
- # notification = Lowdown::Notification.new(:token => "device-token", :payload => { :alert => "Hello World!" })
17
- #
18
- # Lowdown::Client.production(true, File.read("path/to/certificate.pem")).connect do |client|
19
- # client.send_notification(notification) do |response|
20
- # if response.success?
21
- # puts "Notification sent"
22
- # else
23
- # puts "Notification failed: #{response}"
24
- # end
25
- # end
26
- # end
6
+ # Refer to the README for usage instructions.
27
7
  #
28
8
  module Lowdown
29
9
  end
10
+
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "openssl"
2
3
 
3
4
  module Lowdown
@@ -36,6 +37,17 @@ module Lowdown
36
37
  new(certificate, key)
37
38
  end
38
39
 
40
+ # A convenience method that initializes a Certificate with the certificate and key from a SSL context object.
41
+ #
42
+ # @param [OpenSSL::SSL::SSLContext] context
43
+ # the context from which to initialize a Certificate.
44
+ #
45
+ # @return (see Certificate#initialize)
46
+ #
47
+ def self.from_ssl_context(context)
48
+ new(context.cert, context.key)
49
+ end
50
+
39
51
  # @param [OpenSSL::X509::Certificate] certificate
40
52
  # the Apple Push Notification certificate.
41
53
  #
@@ -67,6 +79,13 @@ module Lowdown
67
79
  [@key, @certificate].compact.map(&:to_pem).join("\n")
68
80
  end
69
81
 
82
+ # @return [Boolean]
83
+ # whether or not this Certificate is equal in contents to another Certificate.
84
+ #
85
+ def ==(other)
86
+ other.is_a?(Certificate) && other.to_pem == to_pem
87
+ end
88
+
70
89
  # @return [OpenSSL::SSL::SSLContext]
71
90
  # a SSL context, configured with the certificate/key pair, which is used to connect to the APN service.
72
91
  #
@@ -118,7 +137,7 @@ module Lowdown
118
137
  # the App ID / app’s Bundle ID that this certificate is for.
119
138
  #
120
139
  def app_bundle_id
121
- @certificate.subject.to_a.find { |key, *_| key == 'UID' }[1]
140
+ @certificate.subject.to_a.find { |key, *_| key == "UID" }[1]
122
141
  end
123
142
 
124
143
  private
@@ -133,3 +152,4 @@ module Lowdown
133
152
  end
134
153
  end
135
154
  end
155
+
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lowdown/client/request_group"
1
4
  require "lowdown/certificate"
2
5
  require "lowdown/connection"
6
+ require "lowdown/connection/monitor"
3
7
  require "lowdown/notification"
4
8
 
5
9
  require "uri"
@@ -8,6 +12,9 @@ require "json"
8
12
  module Lowdown
9
13
  # The main class to use for interactions with the Apple Push Notification HTTP/2 service.
10
14
  #
15
+ # Important connection configuration options are `pool_size` and `keep_alive`. The former specifies the number of
16
+ # simultaneous connections the client should make and the latter is key for long running processes.
17
+ #
11
18
  class Client
12
19
  # The details to connect to the development (sandbox) environment version of the APN service.
13
20
  #
@@ -17,25 +24,37 @@ module Lowdown
17
24
  #
18
25
  PRODUCTION_URI = URI.parse("https://api.push.apple.com:443")
19
26
 
27
+ # The default timeout for {#group}.
28
+ #
29
+ DEFAULT_GROUP_TIMEOUT = 3600
30
+
20
31
  # @!group Constructor Summary
21
32
 
22
33
  # This is the most convenient constructor for regular use.
23
34
  #
24
- # It then calls {Client.client}.
25
- #
26
35
  # @param [Boolean] production
27
36
  # whether to use the production or the development environment.
28
37
  #
29
- # @param [Certificate, String] certificate_or_data
38
+ # @param [Certificate, String] certificate
30
39
  # a configured Certificate or PEM data to construct a Certificate from.
31
40
  #
41
+ # @param [Fixnum] pool_size
42
+ # the number of connections to make.
43
+ #
44
+ # @param [Boolean] keep_alive
45
+ # when `true` this will make connections, new and restarted, immediately connect to the remote service. Use
46
+ # this if you want to keep connections open indefinitely.
47
+ #
48
+ # @param [Class] connection_class
49
+ # the connection class to instantiate, this can for instan be {Mock::Connection} during testing.
50
+ #
32
51
  # @raise [ArgumentError]
33
52
  # raised if the provided Certificate does not support the requested environment.
34
53
  #
35
54
  # @return (see Client#initialize)
36
55
  #
37
- def self.production(production, certificate_or_data)
38
- certificate = Certificate.certificate(certificate_or_data)
56
+ def self.production(production, certificate:, pool_size: 1, keep_alive: false, connection_class: Connection)
57
+ certificate = Certificate.certificate(certificate)
39
58
  if production
40
59
  unless certificate.production?
41
60
  raise ArgumentError, "The specified certificate is not usable with the production environment."
@@ -45,45 +64,59 @@ module Lowdown
45
64
  raise ArgumentError, "The specified certificate is not usable with the development environment."
46
65
  end
47
66
  end
48
- client(production ? PRODUCTION_URI : DEVELOPMENT_URI, certificate)
67
+ client(uri: production ? PRODUCTION_URI : DEVELOPMENT_URI,
68
+ certificate: certificate,
69
+ pool_size: pool_size,
70
+ keep_alive: keep_alive,
71
+ connection_class: connection_class)
49
72
  end
50
73
 
51
- # Creates a connection that connects to the specified `uri`.
52
- #
53
- # It then calls {Client.client_with_connection}.
74
+ # Creates a connection pool that connects to the specified `uri`.
54
75
  #
55
76
  # @param [URI] uri
56
77
  # the endpoint details of the service to connect to.
57
78
  #
58
- # @param [Certificate, String] certificate_or_data
79
+ # @param [Certificate, String] certificate
59
80
  # a configured Certificate or PEM data to construct a Certificate from.
60
81
  #
82
+ # @param [Fixnum] pool_size
83
+ # the number of connections to make.
84
+ #
85
+ # @param [Boolean] keep_alive
86
+ # when `true` this will make connections, new and restarted, immediately connect to the remote service. Use
87
+ # this if you want to keep connections open indefinitely.
88
+ #
89
+ # @param [Class] connection_class
90
+ # the connection class to instantiate, this can for instan be {Mock::Connection} during testing.
91
+ #
61
92
  # @return (see Client#initialize)
62
93
  #
63
- def self.client(uri, certificate_or_data)
64
- certificate = Certificate.certificate(certificate_or_data)
65
- client_with_connection(Connection.new(uri, certificate.ssl_context), certificate)
94
+ def self.client(uri:, certificate:, pool_size: 1, keep_alive: false, connection_class: Connection)
95
+ certificate = Certificate.certificate(certificate)
96
+ connection_class ||= Connection
97
+ connection_pool = connection_class.pool(size: pool_size, args: [uri, certificate.ssl_context, keep_alive])
98
+ client_with_connection(connection_pool, certificate: certificate)
66
99
  end
67
100
 
68
- # Creates a Client configured with the `app_bundle_id` as its `default_topic`, in case the Certificate represents a
69
- # Universal Certificate.
101
+ # Creates a Client configured with the `app_bundle_id` as its `default_topic`, in case the Certificate represents
102
+ # a Universal Certificate.
70
103
  #
71
- # @param [Connection] connection
72
- # a Connection configured to connect to the remote service.
104
+ # @param [Connection, Celluloid::Supervision::Container::Pool<Connection>] connection
105
+ # a single Connection or a pool of Connection actors configured to connect to the remote service.
73
106
  #
74
107
  # @param [Certificate] certificate
75
108
  # a configured Certificate.
76
109
  #
77
110
  # @return (see Client#initialize)
78
111
  #
79
- def self.client_with_connection(connection, certificate)
80
- new(connection, certificate.universal? ? certificate.topics.first : nil)
112
+ def self.client_with_connection(connection, certificate:)
113
+ new(connection: connection, default_topic: certificate.universal? ? certificate.topics.first : nil)
81
114
  end
82
115
 
83
116
  # You should normally use any of the other constructors to create a Client object.
84
117
  #
85
- # @param [Connection] connection
86
- # a Connection configured to connect to the remote service.
118
+ # @param [Connection, Celluloid::Supervision::Container::Pool<Connection>] connection
119
+ # a single Connection or a pool of Connection actors configured to connect to the remote service.
87
120
  #
88
121
  # @param [String] default_topic
89
122
  # the ‘topic’ to use if the Certificate is a Universal Certificate and a Notification doesn’t explicitely
@@ -92,14 +125,14 @@ module Lowdown
92
125
  # @return [Client]
93
126
  # a new instance of Client.
94
127
  #
95
- def initialize(connection, default_topic = nil)
128
+ def initialize(connection:, default_topic: nil)
96
129
  @connection, @default_topic = connection, default_topic
97
130
  end
98
131
 
99
132
  # @!group Instance Attribute Summary
100
133
 
101
- # @return [Connection]
102
- # a Connection configured to connect to the remote service.
134
+ # @return [Connection, Celluloid::Supervision::Container::Pool<Connection>]
135
+ # a single Connection or a pool of Connection actors configured to connect to the remote service.
103
136
  #
104
137
  attr_reader :connection
105
138
 
@@ -111,85 +144,148 @@ module Lowdown
111
144
 
112
145
  # @!group Instance Method Summary
113
146
 
114
- # Opens the connection to the service. If a block is given the connection is automatically closed.
147
+ # Opens the connection to the service, yields a request group, and automatically closes the connection by the end of
148
+ # the block.
149
+ #
150
+ # @note Don’t use this if you opted to keep a pool of connections alive.
115
151
  #
116
- # @see Connection#open
152
+ # @see Connection#connect
153
+ # @see Client#group
117
154
  #
118
- # @yield [client]
119
- # yields `self`.
155
+ # @param [Numeric] group_timeout
156
+ # the maximum amount of time to wait for a request group to halt the caller thread. Defaults to 1 hour.
120
157
  #
121
- # @return (see Connection#open)
158
+ # @yieldparam (see Client#group)
122
159
  #
123
- def connect
124
- @connection.open
125
- if block_given?
160
+ # @return [void]
161
+ #
162
+ def connect(group_timeout: nil, &block)
163
+ if @connection.respond_to?(:actors)
164
+ @connection.actors.each { |connection| connection.async.connect }
165
+ else
166
+ @connection.async.connect
167
+ end
168
+ if block
126
169
  begin
127
- yield self
170
+ group(timeout: group_timeout, &block)
128
171
  ensure
129
- close
172
+ disconnect
130
173
  end
131
174
  end
132
175
  end
133
176
 
134
- # Flushes the connection.
177
+ # Closes the connection to the service.
135
178
  #
136
- # @see Connection#flush
179
+ # @see Connection#disconnect
137
180
  #
138
- # @return (see Connection#flush)
181
+ # @return [void]
139
182
  #
140
- def flush
141
- @connection.flush
183
+ def disconnect
184
+ @connection.disconnect
185
+ rescue Celluloid::DeadActorError
186
+ # Rescue this exception instead of calling #alive? as that only works on an actor, not a pool.
142
187
  end
143
188
 
144
- # Closes the connection.
189
+ # Use this to group a batch of requests and halt the caller thread until all of the requests in the group have been
190
+ # performed.
191
+ #
192
+ # It proxies {RequestGroup#send_notification} to {Client#send_notification}, but, unlike the latter, the request
193
+ # callbacks are provided in the form of a block.
194
+ #
195
+ # @note Do **not** share the yielded group across threads.
196
+ #
197
+ # @see RequestGroup#send_notification
198
+ # @see Connection::Monitor
145
199
  #
146
- # @see Connection#close
200
+ # @param [Numeric] timeout
201
+ # the maximum amount of time to wait for a request group to halt the caller thread. Defaults to 1 hour.
147
202
  #
148
- # @return (see Connection#close)
203
+ # @yieldparam [RequestGroup] group
204
+ # the request group object.
149
205
  #
150
- def close
151
- @connection.close
206
+ # @raise [Exception]
207
+ # if a connection in the pool has died during the execution of this group, the reason for its death will be
208
+ # raised.
209
+ #
210
+ # @return [void]
211
+ #
212
+ def group(timeout: nil)
213
+ group = nil
214
+ monitor do |condition|
215
+ group = RequestGroup.new(self, condition)
216
+ yield group
217
+ if !group.empty? && exception = condition.wait(timeout || DEFAULT_GROUP_TIMEOUT)
218
+ raise exception
219
+ end
220
+ end
221
+ ensure
222
+ group.terminate
223
+ end
224
+
225
+ # Registers a condition object with the connection pool, for the duration of the given block. It either returns an
226
+ # exception that caused a connection to die, or whatever value you signal to it.
227
+ #
228
+ # This is automatically used by {#group}.
229
+ #
230
+ # @yieldparam [Connection::Monitor::Condition] condition
231
+ # the monitor condition object.
232
+ #
233
+ # @return [void]
234
+ #
235
+ def monitor
236
+ condition = Connection::Monitor::Condition.new
237
+ if defined?(Mock::Connection) && @connection.class == Mock::Connection
238
+ yield condition
239
+ else
240
+ begin
241
+ @connection.__register_lowdown_crash_condition__(condition)
242
+ yield condition
243
+ ensure
244
+ @connection.__deregister_lowdown_crash_condition__(condition)
245
+ end
246
+ end
152
247
  end
153
248
 
154
- # Verifies the `notification` is valid and sends it to the remote service.
249
+ # Verifies the `notification` is valid and then sends it to the remote service. Response feedback is provided via
250
+ # a delegate mechanism.
155
251
  #
156
- # @see Connection#post
252
+ # @note In general, you will probably want to use {#group} to be able to use {RequestGroup#send_notification},
253
+ # which takes a traditional blocks-based callback approach.
157
254
  #
158
- # @note (see Connection#post)
255
+ # @see Connection#post
159
256
  #
160
257
  # @param [Notification] notification
161
258
  # the notification object whose data to send to the service.
162
259
  #
163
- # @yield [response, notification]
164
- # called when the request is finished and a response is available.
165
- #
166
- # @yieldparam [Response] response
167
- # the Response that holds the status data that came back from the service.
260
+ # @param [Connection::DelegateProtocol] delegate
261
+ # an object that implements the connection delegate protocol.
168
262
  #
169
- # @yieldparam [Notification] notification
170
- # the originally passed in notification object.
263
+ # @param [Object, nil] context
264
+ # any object that you want to be passed to the delegate once the response is back.
171
265
  #
172
266
  # @raise [ArgumentError]
173
267
  # raised if the Notification is not {Notification#valid?}.
174
268
  #
175
269
  # @return [void]
176
270
  #
177
- def send_notification(notification, &callback)
271
+ def send_notification(notification, delegate:, context: nil)
178
272
  raise ArgumentError, "Invalid notification: #{notification.inspect}" unless notification.valid?
179
273
 
180
274
  topic = notification.topic || @default_topic
181
275
  headers = {}
182
276
  headers["apns-expiration"] = (notification.expiration || 0).to_i
183
- headers["apns-id"] = notification.formatted_id if notification.id
277
+ headers["apns-id"] = notification.formatted_id
184
278
  headers["apns-priority"] = notification.priority if notification.priority
185
279
  headers["apns-topic"] = topic if topic
186
280
 
187
281
  body = notification.formatted_payload.to_json
188
282
 
189
- # No need to keep a strong reference to the notification object, unless the user really wants it.
190
- actual_callback = callback.arity < 2 ? callback : lambda { |response| callback.call(response, notification) }
191
-
192
- @connection.post("/3/device/#{notification.token}", headers, body, &actual_callback)
283
+ @connection.async.post(path: "/3/device/#{notification.token}",
284
+ headers: headers,
285
+ body: body,
286
+ delegate: delegate,
287
+ context: context)
193
288
  end
194
289
  end
195
290
  end
291
+