lowdown 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+