lowdown 0.0.4 → 0.0.5
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.
- checksums.yaml +4 -4
- data/.gitignore +0 -1
- data/.travis.yml +6 -2
- data/.yardopts +3 -0
- data/Gemfile +4 -0
- data/README.md +7 -16
- data/Rakefile +29 -8
- data/lib/lowdown.rb +21 -0
- data/lib/lowdown/certificate.rb +77 -14
- data/lib/lowdown/client.rb +112 -4
- data/lib/lowdown/connection.rb +61 -5
- data/lib/lowdown/mock.rb +89 -4
- data/lib/lowdown/notification.rb +55 -2
- data/lib/lowdown/response.rb +57 -4
- data/lib/lowdown/threading.rb +43 -1
- data/lib/lowdown/version.rb +3 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5ce849fce8cd7a90dc303f9f761d88b497c7d80a
|
4
|
+
data.tar.gz: 4c041b547386c1f591ac0a30326666f91f926b09
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: abab9a4f67830d08411466d48c2ac48bcf6a9d5d4c91239096dcac04e418047ef2965ae7800311aa1e0a75554fc2e02f3b92269301fb0838e9c7d06c91b743e0
|
7
|
+
data.tar.gz: 5c8715d560ab305e3eca365f94f5f2458183dc3972b10608a1f39e7457b6aa9bf2f121b0ee8dd6b2ee2c408c52682470bfcc31a333817f2fcd0a5a9c8e9f89b5
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
data/.yardopts
ADDED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,10 +1,14 @@
|
|
1
|
+

|
2
|
+
|
1
3
|
# Lowdown
|
2
4
|
|
5
|
+
[](https://travis-ci.org/alloy/lowdown)
|
6
|
+
|
3
7
|
Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.
|
4
8
|
|
5
9
|
Multiple notifications are multiplexed for efficiency.
|
6
10
|
|
7
|
-
NOTE: _It is not yet battle-tested
|
11
|
+
NOTE: _It is not yet battle-tested. This will all follow over the next few weeks._
|
8
12
|
|
9
13
|
## Installation
|
10
14
|
|
@@ -24,21 +28,8 @@ Or install it yourself as:
|
|
24
28
|
|
25
29
|
## Usage
|
26
30
|
|
27
|
-
You can use the `lowdown` bin that comes with this gem or
|
28
|
-
|
29
|
-
```ruby
|
30
|
-
notification = Lowdown::Notification.new(:token => "device-token", :payload => { :alert => "Hello World!" })
|
31
|
-
|
32
|
-
Lowdown::Client.production(true, File.read("path/to/certificate.pem")).connect do |client|
|
33
|
-
client.send_notification(notification) do |response|
|
34
|
-
if response.success?
|
35
|
-
puts "Notification sent"
|
36
|
-
else
|
37
|
-
puts "Notification failed: #{response}"
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
```
|
31
|
+
You can use the `lowdown` bin that comes with this gem or for code usage see
|
32
|
+
[the documentation](http://www.rubydoc.info/gems/lowdown).
|
42
33
|
|
43
34
|
## Contributing
|
44
35
|
|
data/Rakefile
CHANGED
@@ -1,10 +1,31 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
1
|
+
desc "Install all dependencies"
|
2
|
+
task :bootstrap do
|
3
|
+
if system('which bundle')
|
4
|
+
sh "bundle install"
|
5
|
+
#sh "git submodule update --init"
|
6
|
+
else
|
7
|
+
$stderr.puts "\033[0;31m[!] Please install the bundler gem manually: $ [sudo] gem install bundler\e[0m"
|
8
|
+
exit 1
|
9
|
+
end
|
8
10
|
end
|
9
11
|
|
10
|
-
|
12
|
+
begin
|
13
|
+
require 'bundler/gem_tasks'
|
14
|
+
|
15
|
+
desc "Generate documentation"
|
16
|
+
task :doc do
|
17
|
+
sh "yard doc"
|
18
|
+
end
|
19
|
+
|
20
|
+
require "rake/testtask"
|
21
|
+
Rake::TestTask.new(:test) do |t|
|
22
|
+
t.libs << "test"
|
23
|
+
t.libs << "lib"
|
24
|
+
t.test_files = FileList['test/**/*_test.rb']
|
25
|
+
end
|
26
|
+
|
27
|
+
task :default => :test
|
28
|
+
|
29
|
+
rescue LoadError
|
30
|
+
$stderr.puts "\033[0;33m[!] Disabling rake tasks because the environment couldn’t be loaded. Be sure to run `rake bootstrap` first.\e[0m"
|
31
|
+
end
|
data/lib/lowdown.rb
CHANGED
@@ -1,5 +1,26 @@
|
|
1
1
|
require "lowdown/client"
|
2
2
|
require "lowdown/version"
|
3
3
|
|
4
|
+
# Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.
|
5
|
+
#
|
6
|
+
# Multiple notifications are multiplexed for efficiency.
|
7
|
+
#
|
8
|
+
# The main classes you will interact with are {Lowdown::Client} and {Lowdown::Notification}. For testing purposes there
|
9
|
+
# are some helpers available in {Lowdown::Mock}.
|
10
|
+
#
|
11
|
+
# @example At its simplest, you can send a notification like so:
|
12
|
+
#
|
13
|
+
# notification = Lowdown::Notification.new(:token => "device-token", :payload => { :alert => "Hello World!" })
|
14
|
+
#
|
15
|
+
# Lowdown::Client.production(true, File.read("path/to/certificate.pem")).connect do |client|
|
16
|
+
# client.send_notification(notification) do |response|
|
17
|
+
# if response.success?
|
18
|
+
# puts "Notification sent"
|
19
|
+
# else
|
20
|
+
# puts "Notification failed: #{response}"
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
#
|
4
25
|
module Lowdown
|
5
26
|
end
|
data/lib/lowdown/certificate.rb
CHANGED
@@ -1,36 +1,75 @@
|
|
1
1
|
require "openssl"
|
2
2
|
|
3
3
|
module Lowdown
|
4
|
-
|
5
|
-
|
6
|
-
certificate_or_data
|
7
|
-
else
|
8
|
-
Certificate.from_pem_data(certificate_or_data)
|
9
|
-
end
|
10
|
-
end
|
11
|
-
|
4
|
+
# This class is a wrapper around a certificate/key pair that returns values used by Lowdown.
|
5
|
+
#
|
12
6
|
class Certificate
|
13
|
-
#
|
14
|
-
|
15
|
-
|
16
|
-
|
7
|
+
# @!group Constructor Summary
|
8
|
+
|
9
|
+
# @param [Certificate, String] certificate_or_data
|
10
|
+
# a configured Certificate or PEM data to construct a Certificate from.
|
11
|
+
#
|
12
|
+
# @return [Certificate]
|
13
|
+
# either the originally passed in Certificate or a new Certificate.
|
14
|
+
#
|
15
|
+
def self.certificate(certificate_or_data)
|
16
|
+
if certificate_or_data.is_a?(Certificate)
|
17
|
+
certificate_or_data
|
18
|
+
else
|
19
|
+
from_pem_data(certificate_or_data)
|
20
|
+
end
|
21
|
+
end
|
17
22
|
|
23
|
+
# A convenience method that initializes a Certificate from PEM data.
|
24
|
+
#
|
25
|
+
# @param [String] data
|
26
|
+
# the PEM encoded certificate/key pair data.
|
27
|
+
#
|
28
|
+
# @param [String] passphrase
|
29
|
+
# a passphrase required to decrypt the PEM data.
|
30
|
+
#
|
31
|
+
# @return (see Certificate#initialize)
|
32
|
+
#
|
18
33
|
def self.from_pem_data(data, passphrase = nil)
|
19
34
|
key = OpenSSL::PKey::RSA.new(data, passphrase)
|
20
35
|
certificate = OpenSSL::X509::Certificate.new(data)
|
21
36
|
new(certificate, key)
|
22
37
|
end
|
23
38
|
|
24
|
-
|
25
|
-
|
39
|
+
# @param [OpenSSL::X509::Certificate] certificate
|
40
|
+
# the Apple Push Notification certificate.
|
41
|
+
#
|
42
|
+
# @param [OpenSSL::PKey::RSA] key
|
43
|
+
# the private key that belongs to the certificate.
|
44
|
+
#
|
26
45
|
def initialize(certificate, key = nil)
|
27
46
|
@key, @certificate = key, certificate
|
28
47
|
end
|
29
48
|
|
49
|
+
# @!group Instance Attribute Summary
|
50
|
+
|
51
|
+
# @return [OpenSSL::X509::Certificate]
|
52
|
+
# the Apple Push Notification certificate.
|
53
|
+
#
|
54
|
+
attr_reader :certificate
|
55
|
+
|
56
|
+
# @return [OpenSSL::PKey::RSA, nil]
|
57
|
+
# the private key that belongs to the certificate.
|
58
|
+
#
|
59
|
+
attr_reader :key
|
60
|
+
|
61
|
+
# @!group Instance Method Summary
|
62
|
+
|
63
|
+
# @return [String]
|
64
|
+
# the certificate/key pair encoded as PEM data. Only used for testing.
|
65
|
+
#
|
30
66
|
def to_pem
|
31
67
|
[@key, @certificate].compact.map(&:to_pem).join("\n")
|
32
68
|
end
|
33
69
|
|
70
|
+
# @return [OpenSSL::SSL::SSLContext]
|
71
|
+
# a SSL context, configured with the certificate/key pair, which is used to connect to the APN service.
|
72
|
+
#
|
34
73
|
def ssl_context
|
35
74
|
@ssl_context ||= OpenSSL::SSL::SSLContext.new.tap do |context|
|
36
75
|
context.key = @key
|
@@ -38,18 +77,34 @@ module Lowdown
|
|
38
77
|
end
|
39
78
|
end
|
40
79
|
|
80
|
+
# @return [Boolean]
|
81
|
+
# whether or not the certificate is a Universal Certificate.
|
82
|
+
#
|
83
|
+
# @see https://developer.apple.com/library/ios/documentation/IDEs/Conceptual/AppDistributionGuide/AddingCapabilities/AddingCapabilities.html#//apple_ref/doc/uid/TP40012582-CH26-SW11
|
84
|
+
#
|
41
85
|
def universal?
|
42
86
|
!extension(UNIVERSAL_CERTIFICATE_EXTENSION).nil?
|
43
87
|
end
|
44
88
|
|
89
|
+
# @return [Boolean]
|
90
|
+
# whether or not the certificate supports the development (sandbox) environment (for development builds).
|
91
|
+
#
|
45
92
|
def development?
|
46
93
|
!extension(DEVELOPMENT_ENV_EXTENSION).nil?
|
47
94
|
end
|
48
95
|
|
96
|
+
# @return [Boolean]
|
97
|
+
# whether or not the certificate supports the production environment (for Testflight & App Store builds).
|
98
|
+
#
|
49
99
|
def production?
|
50
100
|
!extension(PRODUCTION_ENV_EXTENSION).nil?
|
51
101
|
end
|
52
102
|
|
103
|
+
# @return [Array<String>]
|
104
|
+
# a list of ‘topics’ that the certificate supports.
|
105
|
+
#
|
106
|
+
# @see https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html
|
107
|
+
#
|
53
108
|
def topics
|
54
109
|
if universal?
|
55
110
|
components = extension(UNIVERSAL_CERTIFICATE_EXTENSION).value.split(/0?\.{2,}/)
|
@@ -59,12 +114,20 @@ module Lowdown
|
|
59
114
|
end
|
60
115
|
end
|
61
116
|
|
117
|
+
# @return [String]
|
118
|
+
# the App ID / app’s Bundle ID that this certificate is for.
|
119
|
+
#
|
62
120
|
def app_bundle_id
|
63
121
|
@certificate.subject.to_a.find { |key, *_| key == 'UID' }[1]
|
64
122
|
end
|
65
123
|
|
66
124
|
private
|
67
125
|
|
126
|
+
# http://images.apple.com/certificateauthority/pdf/Apple_WWDR_CPS_v1.13.pdf
|
127
|
+
DEVELOPMENT_ENV_EXTENSION = "1.2.840.113635.100.6.3.1".freeze
|
128
|
+
PRODUCTION_ENV_EXTENSION = "1.2.840.113635.100.6.3.2".freeze
|
129
|
+
UNIVERSAL_CERTIFICATE_EXTENSION = "1.2.840.113635.100.6.3.6".freeze
|
130
|
+
|
68
131
|
def extension(oid)
|
69
132
|
@certificate.extensions.find { |ext| ext.oid == oid }
|
70
133
|
end
|
data/lib/lowdown/client.rb
CHANGED
@@ -6,12 +6,36 @@ require "uri"
|
|
6
6
|
require "json"
|
7
7
|
|
8
8
|
module Lowdown
|
9
|
+
# The main class to use for interactions with the Apple Push Notification HTTP/2 service.
|
10
|
+
#
|
9
11
|
class Client
|
12
|
+
# The details to connect to the development (sandbox) environment version of the APN service.
|
13
|
+
#
|
10
14
|
DEVELOPMENT_URI = URI.parse("https://api.development.push.apple.com:443")
|
15
|
+
|
16
|
+
# The details to connect to the production environment version of the APN service.
|
17
|
+
#
|
11
18
|
PRODUCTION_URI = URI.parse("https://api.push.apple.com:443")
|
12
19
|
|
20
|
+
# @!group Constructor Summary
|
21
|
+
|
22
|
+
# This is the most convenient constructor for regular use.
|
23
|
+
#
|
24
|
+
# It then calls {Client.client}.
|
25
|
+
#
|
26
|
+
# @param [Boolean] production
|
27
|
+
# whether to use the production or the development environment.
|
28
|
+
#
|
29
|
+
# @param [Certificate, String] certificate_or_data
|
30
|
+
# a configured Certificate or PEM data to construct a Certificate from.
|
31
|
+
#
|
32
|
+
# @raise [ArgumentError]
|
33
|
+
# raised if the provided Certificate does not support the requested environment.
|
34
|
+
#
|
35
|
+
# @return (see Client#initialize)
|
36
|
+
#
|
13
37
|
def self.production(production, certificate_or_data)
|
14
|
-
certificate =
|
38
|
+
certificate = Certificate.certificate(certificate_or_data)
|
15
39
|
if production
|
16
40
|
unless certificate.production?
|
17
41
|
raise ArgumentError, "The specified certificate is not usable with the production environment."
|
@@ -24,21 +48,78 @@ module Lowdown
|
|
24
48
|
client(production ? PRODUCTION_URI : DEVELOPMENT_URI, certificate)
|
25
49
|
end
|
26
50
|
|
51
|
+
# Creates a connection that connects to the specified `uri`.
|
52
|
+
#
|
53
|
+
# It then calls {Client.client_with_connection}.
|
54
|
+
#
|
55
|
+
# @param [URI] uri
|
56
|
+
# the endpoint details of the service to connect to.
|
57
|
+
#
|
58
|
+
# @param [Certificate, String] certificate_or_data
|
59
|
+
# a configured Certificate or PEM data to construct a Certificate from.
|
60
|
+
#
|
61
|
+
# @return (see Client#initialize)
|
62
|
+
#
|
27
63
|
def self.client(uri, certificate_or_data)
|
28
|
-
certificate =
|
64
|
+
certificate = Certificate.certificate(certificate_or_data)
|
29
65
|
client_with_connection(Connection.new(uri, certificate.ssl_context), certificate)
|
30
66
|
end
|
31
67
|
|
68
|
+
# Creates a Client configured with the `app_bundle_id` as its `default_topic`, in case the Certificate represents a
|
69
|
+
# Universal Certificate.
|
70
|
+
#
|
71
|
+
# @param [Connection] connection
|
72
|
+
# a Connection configured to connect to the remote service.
|
73
|
+
#
|
74
|
+
# @param [Certificate] certificate
|
75
|
+
# a configured Certificate.
|
76
|
+
#
|
77
|
+
# @return (see Client#initialize)
|
78
|
+
#
|
32
79
|
def self.client_with_connection(connection, certificate)
|
33
80
|
new(connection, certificate.universal? ? certificate.topics.first : nil)
|
34
81
|
end
|
35
82
|
|
36
|
-
|
37
|
-
|
83
|
+
# You should normally use any of the other constructors to create a Client object.
|
84
|
+
#
|
85
|
+
# @param [Connection] connection
|
86
|
+
# a Connection configured to connect to the remote service.
|
87
|
+
#
|
88
|
+
# @param [String] default_topic
|
89
|
+
# the ‘topic’ to use if the Certificate is a Universal Certificate and a Notification doesn’t explicitely
|
90
|
+
# provide one.
|
91
|
+
#
|
92
|
+
# @return [Client]
|
93
|
+
# a new instance of Client.
|
94
|
+
#
|
38
95
|
def initialize(connection, default_topic = nil)
|
39
96
|
@connection, @default_topic = connection, default_topic
|
40
97
|
end
|
41
98
|
|
99
|
+
# @!group Instance Attribute Summary
|
100
|
+
|
101
|
+
# @return [Connection]
|
102
|
+
# a Connection configured to connect to the remote service.
|
103
|
+
#
|
104
|
+
attr_reader :connection
|
105
|
+
|
106
|
+
# @return [String, nil]
|
107
|
+
# the ‘topic’ to use if the Certificate is a Universal Certificate and a Notification doesn’t explicitely
|
108
|
+
# provide one.
|
109
|
+
#
|
110
|
+
attr_reader :default_topic
|
111
|
+
|
112
|
+
# @!group Instance Method Summary
|
113
|
+
|
114
|
+
# Opens the connection to the service. If a block is given the connection is automatically closed.
|
115
|
+
#
|
116
|
+
# @see Connection#open
|
117
|
+
#
|
118
|
+
# @yield [client]
|
119
|
+
# yields `self`.
|
120
|
+
#
|
121
|
+
# @return (see Connection#open)
|
122
|
+
#
|
42
123
|
def connect
|
43
124
|
@connection.open
|
44
125
|
if block_given?
|
@@ -50,14 +131,41 @@ module Lowdown
|
|
50
131
|
end
|
51
132
|
end
|
52
133
|
|
134
|
+
# Flushes the connection.
|
135
|
+
#
|
136
|
+
# @see Connection#flush
|
137
|
+
#
|
138
|
+
# @return (see Connection#flush)
|
139
|
+
#
|
53
140
|
def flush
|
54
141
|
@connection.flush
|
55
142
|
end
|
56
143
|
|
144
|
+
# Closes the connection.
|
145
|
+
#
|
146
|
+
# @see Connection#close
|
147
|
+
#
|
148
|
+
# @return (see Connection#close)
|
149
|
+
#
|
57
150
|
def close
|
58
151
|
@connection.close
|
59
152
|
end
|
60
153
|
|
154
|
+
# Verifies the `notification` is valid and sends it to the remote service.
|
155
|
+
#
|
156
|
+
# @see Connection#post
|
157
|
+
#
|
158
|
+
# @param [Notification] notification
|
159
|
+
# the notification object whose data to send to the service.
|
160
|
+
#
|
161
|
+
# @yield (see Connection#post)
|
162
|
+
# @yieldparam (see Connection#post)
|
163
|
+
#
|
164
|
+
# @raise [ArgumentError]
|
165
|
+
# raised if the Notification is not {Notification#valid?}.
|
166
|
+
#
|
167
|
+
# @return [void]
|
168
|
+
#
|
61
169
|
def send_notification(notification, &callback)
|
62
170
|
raise ArgumentError, "Invalid notification: #{notification.inspect}" unless notification.valid?
|
63
171
|
|
data/lib/lowdown/connection.rb
CHANGED
@@ -6,9 +6,13 @@ require "openssl"
|
|
6
6
|
require "uri"
|
7
7
|
require "socket"
|
8
8
|
|
9
|
-
# Monkey-patch http-2 gem until this PR is merged: https://github.com/igrigorik/http-2/pull/44
|
10
9
|
if HTTP2::VERSION == "0.8.0"
|
10
|
+
# @!visibility private
|
11
|
+
#
|
12
|
+
# Monkey-patch http-2 gem until this PR is merged: https://github.com/igrigorik/http-2/pull/44
|
11
13
|
class HTTP2::Client
|
14
|
+
# This monkey-patch ensures that we send the HTTP/2 connection preface before anything else.
|
15
|
+
#
|
12
16
|
def connection_management(frame)
|
13
17
|
if @state == :waiting_connection_preface
|
14
18
|
send_connection_preface
|
@@ -21,13 +25,35 @@ if HTTP2::VERSION == "0.8.0"
|
|
21
25
|
end
|
22
26
|
|
23
27
|
module Lowdown
|
28
|
+
# The class responsible for managing the connection to the Apple Push Notification service.
|
29
|
+
#
|
30
|
+
# It manages both the SSL connection and processing of the HTTP/2 data sent back and forth over that connection.
|
31
|
+
#
|
24
32
|
class Connection
|
25
|
-
|
26
|
-
|
33
|
+
# @param [URI, String] uri
|
34
|
+
# the details to connect to the APN service.
|
35
|
+
#
|
36
|
+
# @param [OpenSSL::SSL::SSLContext] ssl_context
|
37
|
+
# a SSL context, configured with the certificate/key pair, which is used to connect to the APN service.
|
38
|
+
#
|
27
39
|
def initialize(uri, ssl_context)
|
28
40
|
@uri, @ssl_context = URI(uri), ssl_context
|
29
41
|
end
|
30
42
|
|
43
|
+
# @return [URI]
|
44
|
+
# the details to connect to the APN service.
|
45
|
+
#
|
46
|
+
attr_reader :uri
|
47
|
+
|
48
|
+
# @return [OpenSSL::SSL::SSLContext]
|
49
|
+
# a SSL context, configured with the certificate/key pair, which is used to connect to the APN service.
|
50
|
+
#
|
51
|
+
attr_reader :ssl_context
|
52
|
+
|
53
|
+
# Creates a new SSL connection to the service, a HTTP/2 client, and starts off a worker thread.
|
54
|
+
#
|
55
|
+
# @return [void]
|
56
|
+
#
|
31
57
|
def open
|
32
58
|
@socket = TCPSocket.new(@uri.host, @uri.port)
|
33
59
|
|
@@ -49,12 +75,19 @@ module Lowdown
|
|
49
75
|
@worker_thread = start_worker_thread!
|
50
76
|
end
|
51
77
|
|
78
|
+
# @return [Boolean]
|
79
|
+
# whether or not the Connection is open.
|
80
|
+
#
|
81
|
+
# @todo Possibly add a HTTP/2 `PING` in the future.
|
82
|
+
#
|
52
83
|
def open?
|
53
84
|
!@ssl.nil? && !@ssl.closed?
|
54
85
|
end
|
55
86
|
|
56
|
-
#
|
57
|
-
# onto the main thread.
|
87
|
+
# Flushes the connection, terminates the worker thread, and closes the socket. Finally it peforms one more check for
|
88
|
+
# pending jobs dispatched onto the main thread.
|
89
|
+
#
|
90
|
+
# @return [void]
|
58
91
|
#
|
59
92
|
def close
|
60
93
|
flush
|
@@ -70,6 +103,10 @@ module Lowdown
|
|
70
103
|
@socket = @ssl = @http = @main_queue = @work_queue = @requests = @exceptions = @worker_thread = nil
|
71
104
|
end
|
72
105
|
|
106
|
+
# Halts the calling thread until all dispatched requests have been performed.
|
107
|
+
#
|
108
|
+
# @return [void]
|
109
|
+
#
|
73
110
|
def flush
|
74
111
|
until @work_queue.empty? && @requests.zero?
|
75
112
|
@main_queue.drain!
|
@@ -77,6 +114,25 @@ module Lowdown
|
|
77
114
|
end
|
78
115
|
end
|
79
116
|
|
117
|
+
# Sends the provided data as a `POST` request to the service.
|
118
|
+
#
|
119
|
+
# @param [String] path
|
120
|
+
# the request path, which should be `/3/device/<device-token>`.
|
121
|
+
#
|
122
|
+
# @param [Hash] headers
|
123
|
+
# the additional headers for the request. By default it sends `:method`, `:path`, and `content-length`.
|
124
|
+
#
|
125
|
+
# @param [String] body
|
126
|
+
# the (JSON) encoded payload data to send to the service.
|
127
|
+
#
|
128
|
+
# @yield [response]
|
129
|
+
# Called when the request is finished and a response is available.
|
130
|
+
#
|
131
|
+
# @yieldparam [Response] response
|
132
|
+
# The Response that holds the status data that came back from the service.
|
133
|
+
#
|
134
|
+
# @return [void]
|
135
|
+
#
|
80
136
|
def post(path, headers, body, &callback)
|
81
137
|
request('POST', path, headers, body, &callback)
|
82
138
|
end
|
data/lib/lowdown/mock.rb
CHANGED
@@ -3,7 +3,19 @@ require "lowdown/client"
|
|
3
3
|
require "lowdown/response"
|
4
4
|
|
5
5
|
module Lowdown
|
6
|
+
# Provides a collection of test helpers.
|
7
|
+
#
|
8
|
+
# This file is not loaded by default.
|
9
|
+
#
|
6
10
|
module Mock
|
11
|
+
# Generates a self-signed Universal Certificate.
|
12
|
+
#
|
13
|
+
# @param [String] app_bundle_id
|
14
|
+
# the App ID / app Bundle ID to encode into the certificate.
|
15
|
+
#
|
16
|
+
# @return [Array<OpenSSL::X509::Certificate, OpenSSL::PKey::RSA>]
|
17
|
+
# the self-signed certificate and private key.
|
18
|
+
#
|
7
19
|
def self.ssl_certificate_and_key(app_bundle_id)
|
8
20
|
key = OpenSSL::PKey::RSA.new(1024)
|
9
21
|
name = OpenSSL::X509::Name.parse("/UID=#{app_bundle_id}/CN=Stubbed APNS Certificate: #{app_bundle_id}")
|
@@ -21,31 +33,68 @@ module Lowdown
|
|
21
33
|
[cert, key]
|
22
34
|
end
|
23
35
|
|
36
|
+
# Generates a Certificate configured with a self-signed Universal Certificate.
|
37
|
+
#
|
38
|
+
# @param (see Mock.ssl_certificate_and_key)
|
39
|
+
#
|
40
|
+
# @return [Certificate]
|
41
|
+
# a Certificate configured with a self-signed certificate/key pair.
|
42
|
+
#
|
24
43
|
def self.certificate(app_bundle_id)
|
25
44
|
Certificate.new(*ssl_certificate_and_key(app_bundle_id))
|
26
45
|
end
|
27
46
|
|
47
|
+
# Generates a Client with a mock {Connection} and a self-signed Universal Certificate.
|
48
|
+
#
|
49
|
+
# @param [URI, String] uri
|
50
|
+
# the details to connect to the APN service.
|
51
|
+
#
|
52
|
+
# @param [String] app_bundle_id
|
53
|
+
# the App ID / app Bundle ID to encode into the certificate.
|
54
|
+
#
|
55
|
+
# @return [Client]
|
56
|
+
# a Client configured with the `uri` and a self-signed certificate that has the `app_bundle_id` encoded.
|
57
|
+
#
|
28
58
|
def self.client(uri: nil, app_bundle_id: "com.example.MockApp")
|
29
59
|
certificate = certificate(app_bundle_id)
|
30
60
|
connection = Connection.new(uri: uri, ssl_context: certificate.ssl_context)
|
31
61
|
Client.client_with_connection(connection, certificate)
|
32
62
|
end
|
33
63
|
|
64
|
+
# A mock object that can be used instead of a real Connection object.
|
65
|
+
#
|
34
66
|
class Connection
|
67
|
+
# Represents a recorded request.
|
68
|
+
#
|
35
69
|
Request = Struct.new(:path, :headers, :body, :response)
|
36
70
|
|
37
|
-
# Mock API
|
38
|
-
attr_reader :requests, :responses
|
71
|
+
# @!group Mock API: Instance Attribute Summary
|
39
72
|
|
40
|
-
#
|
41
|
-
|
73
|
+
# @return [Array<Request>]
|
74
|
+
# a list of requests that have been made in order.
|
75
|
+
#
|
76
|
+
attr_reader :requests
|
42
77
|
|
78
|
+
# @return [Array<Response>]
|
79
|
+
# a list of stubbed responses to return in order.
|
80
|
+
#
|
81
|
+
attr_reader :responses
|
82
|
+
|
83
|
+
# @!group Mock API: Instance Method Summary
|
84
|
+
|
85
|
+
# @param (see Lowdown::Connection#initialize)
|
86
|
+
#
|
43
87
|
def initialize(uri: nil, ssl_context: nil)
|
44
88
|
@uri, @ssl_context = uri, ssl_context
|
45
89
|
@responses = []
|
46
90
|
@requests = []
|
47
91
|
end
|
48
92
|
|
93
|
+
# @param (see Response#unformatted_id)
|
94
|
+
#
|
95
|
+
# @return [Array<Notification>]
|
96
|
+
# returns the recorded requests as Notification objects.
|
97
|
+
#
|
49
98
|
def requests_as_notifications(unformatted_id_length = nil)
|
50
99
|
@requests.map do |request|
|
51
100
|
headers = request.headers
|
@@ -61,20 +110,56 @@ module Lowdown
|
|
61
110
|
end
|
62
111
|
end
|
63
112
|
|
113
|
+
# @!group Real API: Instance Attribute Summary
|
114
|
+
|
115
|
+
# @return (see Lowdown::Connection#uri)
|
116
|
+
#
|
117
|
+
attr_reader :uri
|
118
|
+
|
119
|
+
# @return (see Lowdown::Connection#ssl_context)
|
120
|
+
#
|
121
|
+
attr_reader :ssl_context
|
122
|
+
|
123
|
+
# @!group Real API: Instance Method Summary
|
124
|
+
|
125
|
+
# Yields stubbed {#responses} or if none are available defaults to success responses.
|
126
|
+
#
|
127
|
+
# @param (see Lowdown::Connection#post)
|
128
|
+
# @yield (see Lowdown::Connection#post)
|
129
|
+
# @yieldparam (see Lowdown::Connection#post)
|
130
|
+
# @return (see Lowdown::Connection#post)
|
131
|
+
#
|
64
132
|
def post(path, headers, body)
|
65
133
|
response = @responses.shift || Response.new(":status" => "200", "apns-id" => (headers["apns-id"] || generate_id))
|
66
134
|
@requests << Request.new(path, headers, body, response)
|
67
135
|
yield response
|
68
136
|
end
|
69
137
|
|
138
|
+
# Changes {#open?} to return `true`.
|
139
|
+
#
|
140
|
+
# @return [void]
|
141
|
+
#
|
70
142
|
def open
|
71
143
|
@open = true
|
72
144
|
end
|
73
145
|
|
146
|
+
# Changes {#open?} to return `false`.
|
147
|
+
#
|
148
|
+
# @return [void]
|
149
|
+
#
|
74
150
|
def close
|
75
151
|
@open = false
|
76
152
|
end
|
77
153
|
|
154
|
+
# no-op
|
155
|
+
#
|
156
|
+
# @return [void]
|
157
|
+
#
|
158
|
+
def flush
|
159
|
+
end
|
160
|
+
|
161
|
+
# @return (see Lowdown::Connection#open?)
|
162
|
+
#
|
78
163
|
def open?
|
79
164
|
!!@open
|
80
165
|
end
|
data/lib/lowdown/notification.rb
CHANGED
@@ -1,19 +1,64 @@
|
|
1
1
|
module Lowdown
|
2
|
-
#
|
2
|
+
# A Notification holds the data and metadata about a Remote Notification.
|
3
|
+
#
|
4
|
+
# @see https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH101-SW15
|
5
|
+
# @see https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/TheNotificationPayload.html
|
3
6
|
#
|
4
7
|
class Notification
|
8
|
+
# @!visibility private
|
5
9
|
APS_KEYS = %w{ alert badge sound content-available category }.freeze
|
6
10
|
|
7
|
-
|
11
|
+
# @return [String]
|
12
|
+
# a device token.
|
13
|
+
#
|
14
|
+
attr_accessor :token
|
15
|
+
|
16
|
+
# @return [Object, nil]
|
17
|
+
# a object that uniquely identifies this notification and is coercable to a String.
|
18
|
+
#
|
19
|
+
attr_accessor :id
|
20
|
+
|
21
|
+
# @return [Time, nil]
|
22
|
+
# the time until which to retry delivery of a notification. By default it is only tried once.
|
23
|
+
#
|
24
|
+
attr_accessor :expiration
|
25
|
+
|
26
|
+
# @return [Integer, nil]
|
27
|
+
# the priority at which to deliver this notification, which may be `10` or `5` if power consumption should
|
28
|
+
# be taken into consideration. Defaults to `10.
|
29
|
+
#
|
30
|
+
attr_accessor :priority
|
31
|
+
|
32
|
+
# @return [String, nil]
|
33
|
+
# the ‘topic’ for this notification.
|
34
|
+
#
|
35
|
+
attr_accessor :topic
|
36
|
+
|
37
|
+
# @return [Hash]
|
38
|
+
# the data payload for this notification.
|
39
|
+
#
|
40
|
+
attr_accessor :payload
|
8
41
|
|
42
|
+
# @param [Hash] params
|
43
|
+
# a dictionary of keys described in the Instance Attribute Summary.
|
44
|
+
#
|
9
45
|
def initialize(params)
|
10
46
|
params.each { |key, value| send("#{key}=", value) }
|
11
47
|
end
|
12
48
|
|
49
|
+
# @return [Boolean]
|
50
|
+
# whether this notification holds enough data and metadata to be sent to the APN service.
|
51
|
+
#
|
13
52
|
def valid?
|
14
53
|
!!(@token && @payload)
|
15
54
|
end
|
16
55
|
|
56
|
+
# Formats the {#id} in the format required by the APN service, which is in groups of 8-4-4-12. It is padded with
|
57
|
+
# leading zeroes.
|
58
|
+
#
|
59
|
+
# @return [String]
|
60
|
+
# the formatted ID.
|
61
|
+
#
|
17
62
|
def formatted_id
|
18
63
|
if @id
|
19
64
|
padded = @id.to_s.rjust(32, "0")
|
@@ -21,6 +66,14 @@ module Lowdown
|
|
21
66
|
end
|
22
67
|
end
|
23
68
|
|
69
|
+
# Unless the payload contains an `aps` entry, the payload is assumed to be a mix of APN defined attributes and
|
70
|
+
# custom attributes and re-organized according to the specifications.
|
71
|
+
#
|
72
|
+
# @see https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/TheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH107-SW1
|
73
|
+
#
|
74
|
+
# @return [Hash]
|
75
|
+
# the payload organized according to the APN specification.
|
76
|
+
#
|
24
77
|
def formatted_payload
|
25
78
|
if @payload.has_key?("aps")
|
26
79
|
@payload
|
data/lib/lowdown/response.rb
CHANGED
@@ -1,6 +1,18 @@
|
|
1
1
|
module Lowdown
|
2
|
+
# An object that represents a response from the Apple Push Notification service for a single notification delivery.
|
3
|
+
#
|
4
|
+
# @see https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html
|
5
|
+
#
|
6
|
+
#
|
7
|
+
# @attr [Hash] headers
|
8
|
+
# The HTTP response headers from the service.
|
9
|
+
#
|
10
|
+
# @attr [String] raw_body
|
11
|
+
# The JSON encoded response body from the service.
|
12
|
+
#
|
2
13
|
class Response < Struct.new(:headers, :raw_body)
|
3
|
-
#
|
14
|
+
# The possible HTTP status codes and their associated messages.
|
15
|
+
#
|
4
16
|
STATUS_CODES = {
|
5
17
|
200 => "Success",
|
6
18
|
400 => "Bad request",
|
@@ -13,44 +25,82 @@ module Lowdown
|
|
13
25
|
503 => "The server is shutting down and unavailable"
|
14
26
|
}
|
15
27
|
|
28
|
+
# @return [String]
|
29
|
+
# either the {Notification#id} or, if none was provided, an ID generated by the service.
|
30
|
+
#
|
16
31
|
def id
|
17
32
|
headers["apns-id"]
|
18
33
|
end
|
19
34
|
|
20
|
-
|
35
|
+
# Tries to convert the ID back to the Notification {Notification#id} by removing leading zeroes.
|
36
|
+
#
|
37
|
+
# @param [Integer] unformatted_id_length
|
38
|
+
# the expected length of an ID, which ensures that **required** leading zeroes are not removed.
|
39
|
+
#
|
40
|
+
# @return [String]
|
41
|
+
# the ID that was assigned to the Notification.
|
42
|
+
#
|
43
|
+
def unformatted_id(unformatted_id_length = nil)
|
21
44
|
id = self.id.tr('-', '')
|
22
|
-
|
45
|
+
unformatted_id_length ? id[32-unformatted_id_length,unformatted_id_length] : id.gsub(/\A0*/, '')
|
23
46
|
end
|
24
47
|
|
48
|
+
# @return [Integer]
|
49
|
+
# the HTTP status returned by the service.
|
50
|
+
#
|
51
|
+
# @see Response::STATUS_CODES
|
52
|
+
#
|
25
53
|
def status
|
26
54
|
headers[":status"].to_i
|
27
55
|
end
|
28
56
|
|
57
|
+
# @return [String]
|
58
|
+
# the message belonging to the {#status} returned by the service.
|
59
|
+
#
|
60
|
+
# @see Response::STATUS_CODES
|
61
|
+
#
|
29
62
|
def message
|
30
63
|
STATUS_CODES[status]
|
31
64
|
end
|
32
65
|
|
66
|
+
# @return [Boolean]
|
67
|
+
# whether or not the notification has been delivered.
|
68
|
+
#
|
33
69
|
def success?
|
34
70
|
status == 200
|
35
71
|
end
|
36
72
|
|
73
|
+
# @return [Hash, nil]
|
74
|
+
# the response payload from the service, which is empty in the case of a successful delivery.
|
75
|
+
#
|
37
76
|
def body
|
38
77
|
JSON.parse(raw_body) if raw_body
|
39
78
|
end
|
40
79
|
|
80
|
+
# @return [String, nil]
|
81
|
+
# the reason for a failed delivery.
|
82
|
+
#
|
41
83
|
def failure_reason
|
42
84
|
body["reason"] unless success?
|
43
85
|
end
|
44
86
|
|
87
|
+
# @return [Boolean]
|
88
|
+
# whether or not the delivery has failed due to a token no longer being valid.
|
89
|
+
#
|
45
90
|
def invalid_token?
|
46
91
|
status == 410
|
47
92
|
end
|
48
93
|
|
49
|
-
#
|
94
|
+
# @return [Time, nil]
|
95
|
+
# in case of an invalid token, the time at which the service last checked it.
|
96
|
+
#
|
50
97
|
def validity_last_checked_at
|
51
98
|
Time.at(body["timestamp"].to_i) if invalid_token?
|
52
99
|
end
|
53
100
|
|
101
|
+
# @return [String]
|
102
|
+
# a formatted description of the response.
|
103
|
+
#
|
54
104
|
def to_s
|
55
105
|
s = "#{status} (#{message})"
|
56
106
|
s << ": #{failure_reason}" unless success?
|
@@ -58,6 +108,9 @@ module Lowdown
|
|
58
108
|
s
|
59
109
|
end
|
60
110
|
|
111
|
+
# @return [String]
|
112
|
+
# a formatted description of the response used for debugging.
|
113
|
+
#
|
61
114
|
def inspect
|
62
115
|
"#<Lowdown::Connection::Response #{to_s}>"
|
63
116
|
end
|
data/lib/lowdown/threading.rb
CHANGED
@@ -1,26 +1,43 @@
|
|
1
1
|
require "thread"
|
2
2
|
|
3
3
|
module Lowdown
|
4
|
+
# A collection of internal threading related helpers.
|
5
|
+
#
|
4
6
|
module Threading
|
7
|
+
# A queue of blocks that are to be dispatched onto another thread.
|
8
|
+
#
|
5
9
|
class DispatchQueue
|
6
10
|
def initialize
|
7
11
|
@queue = Queue.new
|
8
12
|
end
|
9
13
|
|
14
|
+
# Adds a block to the queue.
|
15
|
+
#
|
16
|
+
# @return [void]
|
17
|
+
#
|
10
18
|
def dispatch(&block)
|
11
19
|
@queue << block
|
12
20
|
end
|
13
21
|
|
22
|
+
# @return [Boolean]
|
23
|
+
# whether or not the queue is empty.
|
24
|
+
#
|
14
25
|
def empty?
|
15
26
|
@queue.empty?
|
16
27
|
end
|
17
28
|
|
18
|
-
# Performs the number of dispatched blocks that were on the queue at the moment of calling
|
29
|
+
# Performs the number of dispatched blocks that were on the queue at the moment of calling `#drain!`. Unlike
|
19
30
|
# performing blocks _until the queue is empty_, this ensures that it doesn’t block the calling thread too long if
|
20
31
|
# another thread is dispatching more work at the same time.
|
21
32
|
#
|
22
33
|
# By default this will let any exceptions bubble up on the main thread or catch and return them on other threads.
|
23
34
|
#
|
35
|
+
# @param [Boolean] rescue_exceptions
|
36
|
+
# whether or not to rescue exceptions.
|
37
|
+
#
|
38
|
+
# @return [Exception, nil]
|
39
|
+
# in case of rescueing exceptions, this returns the exception raised during execution of a block.
|
40
|
+
#
|
24
41
|
def drain!(rescue_exceptions = (Thread.current != Thread.main))
|
25
42
|
@queue.size.times { @queue.pop.call }
|
26
43
|
nil
|
@@ -30,31 +47,56 @@ module Lowdown
|
|
30
47
|
end
|
31
48
|
end
|
32
49
|
|
50
|
+
# A simple thread-safe counter.
|
51
|
+
#
|
33
52
|
class Counter
|
53
|
+
# @param [Integer] value
|
54
|
+
# the initial count.
|
55
|
+
#
|
34
56
|
def initialize(value = 0)
|
35
57
|
@value = value
|
36
58
|
@mutex = Mutex.new
|
37
59
|
end
|
38
60
|
|
61
|
+
# @return [Integer]
|
62
|
+
# the current count.
|
63
|
+
#
|
39
64
|
def value
|
40
65
|
value = nil
|
41
66
|
@mutex.synchronize { value = @value }
|
42
67
|
value
|
43
68
|
end
|
44
69
|
|
70
|
+
# @return [Boolean]
|
71
|
+
# whether or not the current count is zero.
|
72
|
+
#
|
45
73
|
def zero?
|
46
74
|
value.zero?
|
47
75
|
end
|
48
76
|
|
77
|
+
# @param [Integer] value
|
78
|
+
# the new count.
|
79
|
+
#
|
80
|
+
# @return [Integer]
|
81
|
+
# the input value.
|
82
|
+
#
|
49
83
|
def value=(value)
|
50
84
|
@mutex.synchronize { @value = value }
|
51
85
|
value
|
52
86
|
end
|
53
87
|
|
88
|
+
# Increments the current count.
|
89
|
+
#
|
90
|
+
# @return [void]
|
91
|
+
#
|
54
92
|
def increment!
|
55
93
|
@mutex.synchronize { @value += 1 }
|
56
94
|
end
|
57
95
|
|
96
|
+
# Decrements the current count.
|
97
|
+
#
|
98
|
+
# @return [void]
|
99
|
+
#
|
58
100
|
def decrement!
|
59
101
|
@mutex.synchronize { @value -= 1 }
|
60
102
|
end
|
data/lib/lowdown/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lowdown
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Eloy Durán
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-01-
|
11
|
+
date: 2016-01-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: http-2
|
@@ -77,11 +77,13 @@ files:
|
|
77
77
|
- ".gitignore"
|
78
78
|
- ".ruby-version"
|
79
79
|
- ".travis.yml"
|
80
|
+
- ".yardopts"
|
80
81
|
- Gemfile
|
81
82
|
- LICENSE.txt
|
82
83
|
- README.md
|
83
84
|
- Rakefile
|
84
85
|
- bin/lowdown
|
86
|
+
- doc/lowdown.png
|
85
87
|
- lib/lowdown.rb
|
86
88
|
- lib/lowdown/certificate.rb
|
87
89
|
- lib/lowdown/client.rb
|
@@ -117,3 +119,4 @@ signing_key:
|
|
117
119
|
specification_version: 4
|
118
120
|
summary: A Ruby client for the HTTP/2 version of the Apple Push Notification Service.
|
119
121
|
test_files: []
|
122
|
+
has_rdoc:
|