lowdown 0.0.4 → 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![](https://raw.githubusercontent.com/alloy/lowdown/master/doc/lowdown.png)
|
2
|
+
|
1
3
|
# Lowdown
|
2
4
|
|
5
|
+
[![Build Status](https://travis-ci.org/alloy/lowdown.svg?branch=master)](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:
|