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 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: