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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1fa2be0e57306efb793fb262856aa2a1bea16c03
4
- data.tar.gz: 151af85c92ff4c4553244f78c70721bf41cc31a9
3
+ metadata.gz: 5ce849fce8cd7a90dc303f9f761d88b497c7d80a
4
+ data.tar.gz: 4c041b547386c1f591ac0a30326666f91f926b09
5
5
  SHA512:
6
- metadata.gz: c6aab49b75690c5aac492d6d1ffd137bb5b548b2377e7f391e2bb7095b03661a80ba920388e89e24f1f2f24826946b81274e94d12cdf49bf9d737e80db539289
7
- data.tar.gz: e0ccfd14e17cbe9b7e96b2401ea493396eab4a0acd72c472b4383dd95c3b20e0aab469ebdba38d663648dd09a26d2cd235ff95cd7d472f026a5e5cd91170ae0e
6
+ metadata.gz: abab9a4f67830d08411466d48c2ac48bcf6a9d5d4c91239096dcac04e418047ef2965ae7800311aa1e0a75554fc2e02f3b92269301fb0838e9c7d06c91b743e0
7
+ data.tar.gz: 5c8715d560ab305e3eca365f94f5f2458183dc3972b10608a1f39e7457b6aa9bf2f121b0ee8dd6b2ee2c408c52682470bfcc31a333817f2fcd0a5a9c8e9f89b5
data/.gitignore CHANGED
@@ -1,5 +1,4 @@
1
1
  /.bundle/
2
- /.yardoc
3
2
  /Gemfile.lock
4
3
  /_yardoc/
5
4
  /coverage/
data/.travis.yml CHANGED
@@ -1,4 +1,8 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.0.0
4
- before_install: gem install bundler -v 1.11.2
3
+ - 2.1.0
4
+ - 2.1
5
+ - 2.2
6
+ - 2.3.0
7
+ before_install: gem install bundler
8
+ install: bundle install --without doc
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ --no-private
2
+ --markup markdown
3
+ --main README.md
data/Gemfile CHANGED
@@ -3,3 +3,7 @@ source 'https://rubygems.org'
3
3
  #gem 'http-2', :git => 'https://github.com/alloy/http-2.git', :branch => "apns"
4
4
 
5
5
  gemspec
6
+
7
+ group :doc do
8
+ gem 'yard'
9
+ end
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 and there is no documentation yet. This will all follow over the next few weeks._
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 in code at its simplest:
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
- require "bundler/gem_tasks"
2
- require "rake/testtask"
3
-
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList['test/**/*_test.rb']
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
- task :default => :test
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
@@ -1,36 +1,75 @@
1
1
  require "openssl"
2
2
 
3
3
  module Lowdown
4
- def self.Certificate(certificate_or_data)
5
- if certificate_or_data.is_a?(Certificate)
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
- # http://images.apple.com/certificateauthority/pdf/Apple_WWDR_CPS_v1.13.pdf
14
- DEVELOPMENT_ENV_EXTENSION = "1.2.840.113635.100.6.3.1".freeze
15
- PRODUCTION_ENV_EXTENSION = "1.2.840.113635.100.6.3.2".freeze
16
- UNIVERSAL_CERTIFICATE_EXTENSION = "1.2.840.113635.100.6.3.6".freeze
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
- attr_reader :key, :certificate
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
@@ -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 = Lowdown.Certificate(certificate_or_data)
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 = Lowdown.Certificate(certificate_or_data)
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
- attr_reader :connection, :default_topic
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
 
@@ -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
- attr_reader :uri, :ssl_context
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
- # Terminates the worker thread and closes the socket. Finally it peforms one more check for pending jobs dispatched
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
- # Real API
41
- attr_reader :uri, :ssl_context
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
@@ -1,19 +1,64 @@
1
1
  module Lowdown
2
- # For payload documentation see: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/TheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH107-SW1
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
- attr_accessor :token, :id, :expiration, :priority, :topic, :payload
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
@@ -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
- # https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH101-SW3
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
- def unformatted_id(length = nil)
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
- length ? id[32-length,length] : id.gsub(/\A0*/, '')
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
- # Only available when using an invalid token.
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
@@ -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 #drain!. Unlike
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
@@ -1,3 +1,5 @@
1
1
  module Lowdown
2
- VERSION = "0.0.4"
2
+ # The currect version of the Lowdown library.
3
+ #
4
+ VERSION = "0.0.5"
3
5
  end
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
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-05 00:00:00.000000000 Z
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: