apn_client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ spec/certificate.pem
6
+ .idea
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format documentation
data/.rvmrc ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # This is an RVM Project .rvmrc file, used to automatically load the ruby
4
+ # development environment upon cd'ing into the directory
5
+
6
+ # First we specify our desired <ruby>[@<gemset>], the @gemset name is optional.
7
+ environment_id="ruby-1.9.2-p180@apn_client"
8
+
9
+ #
10
+ # Uncomment following line if you want options to be set only for given project.
11
+ #
12
+ # PROJECT_JRUBY_OPTS=( --1.9 )
13
+
14
+ #
15
+ # First we attempt to load the desired environment directly from the environment
16
+ # file. This is very fast and efficient compared to running through the entire
17
+ # CLI and selector. If you want feedback on which environment was used then
18
+ # insert the word 'use' after --create as this triggers verbose mode.
19
+ #
20
+ if [[ -d "${rvm_path:-$HOME/.rvm}/environments" \
21
+ && -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
22
+ then
23
+ \. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
24
+
25
+ if [[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]]
26
+ then
27
+ . "${rvm_path:-$HOME/.rvm}/hooks/after_use"
28
+ fi
29
+ else
30
+ # If the environment file has not yet been created, use the RVM CLI to select.
31
+ if ! rvm --create "$environment_id"
32
+ then
33
+ echo "Failed to create RVM environment '${environment_id}'."
34
+ return 1
35
+ fi
36
+ fi
37
+
38
+ #
39
+ # If you use an RVM gemset file to install a list of gems (*.gems), you can have
40
+ # it be automatically loaded. Uncomment the following and adjust the filename if
41
+ # necessary.
42
+ #
43
+ # filename=".gems"
44
+ # if [[ -s "$filename" ]]
45
+ # then
46
+ # rvm gemset import "$filename" | grep -v already | grep -v listed | grep -v complete | sed '/^$/d'
47
+ # fi
48
+
49
+ # If you use bundler, this might be useful to you:
50
+ # if [[ -s Gemfile ]] && ! command -v bundle >/dev/null
51
+ # then
52
+ # printf "The rubygem 'bundler' is not installed. Installing it now.\n"
53
+ # gem install bundler
54
+ # fi
55
+ # if [[ -s Gemfile ]] && command -v bundle
56
+ # then
57
+ # bundle install
58
+ # fi
59
+
60
+ if [[ $- == *i* ]] # check for interactive shells
61
+ then
62
+ echo "Using: $(tput setaf 2)$GEM_HOME$(tput sgr0)" # show the user the ruby and gemset they are using in green
63
+ else
64
+ echo "Using: $GEM_HOME" # don't use colors in interactive shells
65
+ fi
66
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in apn_client.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # APN Client RubyGem
2
+
3
+ ## Introduction
4
+
5
+ This is a RubyGem that allows sending of Apple Push Notifications to iOS devices (i.e. iPhones, iPads) from Ruby. The main features are:
6
+
7
+ * Broadcasting of notifications to a large number of devices in a reliable fashion
8
+ * Dealing with errors (via the enhanced format Apple protocol) when sending notifications
9
+ * Reading from the Apple Feedback Service to avoid sending to devices with uninstalled applications
10
+
11
+ ## Usage
12
+
13
+ ### 1. Configure the Connection
14
+
15
+ ```
16
+
17
+ ApnClient::Delivery.connection_config = {
18
+ :host => 'gateway.push.apple.com', # For sandbox, use: gateway.sandbox.push.apple.com
19
+ :port => 2195,
20
+ :certificate => IO.read("my_apn_certificate.pem"),
21
+ :certificate_passphrase => '',
22
+ }
23
+
24
+ ApnClient::Feedback.connection_config = {
25
+ :host => 'feedback.push.apple.com', # For sandbox, use: feedback.sandbox.push.apple.com
26
+ :port => 2196,
27
+ :certificate => IO.read("my_apn_certificate.pem"),
28
+ :certificate_passphrase => '',
29
+ }
30
+
31
+ ```
32
+
33
+ ### 2. Deliver Your Message
34
+
35
+ ```
36
+ message1 = ApnClient::Message.new(1,
37
+ :device_token => "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099",
38
+ :alert => "New version of the app is out. Get it now in the app store!",
39
+ :badge => 2
40
+ )
41
+ message2 = ApnClient::Message.new(2,
42
+ :device_token => "6a5g4de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d044",
43
+ :alert => "New version of the app is out. Get it now in the app store!",
44
+ :badge => 1
45
+ )
46
+ delivery = ApnClient::Delivery.new([message1, message2],
47
+ :callbacks => {
48
+ :on_write => lambda { |d, m| puts "Wrote message #{m}" },
49
+ :on_exception => lambda { |d, m, e| puts "Exception #{e} raised when delivering message #{m}" },
50
+ :on_failure => lambda { |d, m| puts "Skipping failed message #{m}" },
51
+ :on_error => lambda { |d, message_id, error_code| puts "Received error code #{error_code} from Apple for message #{message_id}" }
52
+ },
53
+ :consecutive_failure_limit => 10, # If more than 10 devices in a row fail, we abort the whole delivery
54
+ :exception_limit => 3 # If a device raises an exception three times in a row we fail/skip the device and move on
55
+ )
56
+ delivery.process!
57
+ puts "Delivered successfully to #{delivery.success_count} out of #{delivery.total_count} devices in #{delivery.elapsed} seconds"
58
+ ```
59
+
60
+ One potential gotcha to watch out for is that the device token for a message is per device and per application. This means
61
+ that different apps on the same device will have different tokens. The Apple documentation uses phone numbers as an analogy
62
+ to explain what a device token is.
63
+
64
+ ### 3. Check for Feedback
65
+
66
+ TODO
67
+
68
+ ## Dependencies
69
+
70
+ The payload of an APN message is a JSON formated hash (containing alert message, badge count, content available etc.) and therefore a JSON library needs to be present. This gem requires a Hash#to_json method to be defined (hashes need to respond
71
+ to to_json and return valid JSON). If you for example have the json gem or the rails gem in your environment then this requirement is fulfilled.
72
+
73
+ The gem is tested on MRI 1.9.2.
74
+
75
+ ## Credits
76
+
77
+ This gem is an extraction of production code at [Mag+](http://www.magplus.com) and both [Dennis Rogenius](https://github.com/denro) and [Lennart Friden](https://github.com/DevL) made important contributions along the way.
78
+
79
+ The APN connection code has its origins in the [APN on Rails](https://github.com/jwang/apn_on_rails) gem.
80
+
81
+ ## License
82
+
83
+ This library is released under the MIT license.
84
+
85
+ ## Resources
86
+
87
+ * [Apple Push Notifications Documentation](http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008194-CH1-SW1)
88
+ * [The APNS RubyGem](https://github.com/jpoz/APNS). Has a small codebase and a nice API. Does not use the enhanced format protocol and lacks error handling.
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "apn_client/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "apn_client"
7
+ s.version = ApnClient::VERSION
8
+ s.authors = ["Peter Marklund"]
9
+ s.email = ["peter@marklunds.com"]
10
+ s.homepage = ""
11
+ s.summary = %q{Library for sending Apple Push Notifications to iOS devices from Ruby}
12
+ s.description = %q{Uses the "enhanced format" Apple protocol and deals with errors and failures when broadcasting to many devices. Includes support for talking to the Apple Push Notification Feedback service for dealing with uninstalled apps.}
13
+
14
+ s.rubyforge_project = "apn_client"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_development_dependency "json"
22
+ s.add_development_dependency "rspec"
23
+ s.add_development_dependency "mocha"
24
+ s.add_development_dependency "yard"
25
+ end
@@ -0,0 +1,64 @@
1
+ require 'socket'
2
+ require 'openssl'
3
+ require 'apn_client/named_args'
4
+
5
+ module ApnClient
6
+ class Connection
7
+ attr_accessor :config, :tcp_socket, :ssl_socket
8
+
9
+ # Opens an SSL socket for talking to the Apple Push Notification service.
10
+ #
11
+ # @param [String] host the hostname to connect to
12
+ # @param [Fixnum] port the port to connect to
13
+ # @param [String] certificate the APN certificate to use
14
+ # @param [String] certificate_passphrase the passphrase of the certificate, can be empty
15
+ # @param [Float] select_timeout the timeout (seconds) used when doing IO.select on the socket (default 0.1)
16
+ def initialize(config = {})
17
+ NamedArgs.assert_valid!(config,
18
+ :required => [:host, :port, :certificate, :certificate_passphrase],
19
+ :optional => [:select_timeout])
20
+ self.config = config
21
+ config[:select_timeout] ||= 0.1
22
+ connect_to_socket
23
+ end
24
+
25
+ def close
26
+ ssl_socket.close
27
+ tcp_socket.close
28
+ self.ssl_socket = nil
29
+ self.tcp_socket = nil
30
+ end
31
+
32
+ def write(arg)
33
+ ssl_socket.write(arg.to_s)
34
+ end
35
+
36
+ def read(*args)
37
+ ssl_socket.read(*args)
38
+ end
39
+
40
+ def select
41
+ IO.select([ssl_socket], nil, nil, config[:select_timeout])
42
+ end
43
+
44
+ def self.open(options = {})
45
+ connection = Connection.new(options)
46
+ yield connection
47
+ ensure
48
+ connection.close if connection
49
+ end
50
+
51
+ private
52
+
53
+ def connect_to_socket
54
+ context = OpenSSL::SSL::SSLContext.new
55
+ context.key = OpenSSL::PKey::RSA.new(config[:certificate], config[:certificate_passphrase])
56
+ context.cert = OpenSSL::X509::Certificate.new(config[:certificate])
57
+
58
+ self.tcp_socket = TCPSocket.new(config[:host], config[:port])
59
+ self.ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
60
+ ssl_socket.sync = true
61
+ ssl_socket.connect
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,147 @@
1
+ require 'apn_client/named_args'
2
+ require 'apn_client/message'
3
+ require 'apn_client/connection'
4
+
5
+ module ApnClient
6
+ class Delivery
7
+ attr_accessor :messages, :callbacks, :consecutive_failure_limit, :exception_limit, :sleep_on_exception,
8
+ :exception_count, :success_count, :failure_count, :consecutive_failure_count,
9
+ :started_at, :finished_at
10
+
11
+ # Creates a new APN delivery
12
+ #
13
+ # @param [#next] messages should be Enumerator type object that responds to #next. If it's an Array #shift will be used instead.
14
+ def initialize(messages, options = {})
15
+ self.messages = messages
16
+ initialize_options(options)
17
+ self.exception_count = 0
18
+ self.success_count = 0
19
+ self.failure_count = 0
20
+ self.consecutive_failure_count = 0
21
+ end
22
+
23
+ def process!
24
+ self.started_at = Time.now
25
+ while current_message && consecutive_failure_count < consecutive_failure_limit
26
+ process_one_message!
27
+ end
28
+ close_connection
29
+ self.finished_at = Time.now
30
+ end
31
+
32
+ def elapsed
33
+ if started_at
34
+ finished_at ? (finished_at - started_at) : (Time.now - started_at)
35
+ else
36
+ 0
37
+ end
38
+ end
39
+
40
+ def total_count
41
+ success_count + failure_count
42
+ end
43
+
44
+ private
45
+
46
+ def initialize_options(options)
47
+ NamedArgs.assert_valid!(options, :optional => [:callbacks, :consecutive_failure_limit, :exception_limit, :sleep_on_exception])
48
+ NamedArgs.assert_valid!(options[:callbacks], :optional => [:on_write, :on_error, :on_nil_select, :on_read_exception, :on_exception, :on_failure])
49
+ self.callbacks = options[:callbacks]
50
+ self.consecutive_failure_limit = options[:consecutive_failure_limit] || 10
51
+ self.exception_limit = options[:exception_limit] || 3
52
+ self.sleep_on_exception = options[:sleep_on_exception] || 1
53
+ end
54
+
55
+ def current_message
56
+ return @current_message if @current_message
57
+ next_message!
58
+ end
59
+
60
+ def next_message!
61
+ @current_message = (messages.respond_to?(:next) ? messages.next : messages.shift)
62
+ rescue StopIteration
63
+ nil
64
+ end
65
+
66
+ def process_one_message!
67
+ begin
68
+ write_message!
69
+ check_message_error!
70
+ rescue Exception => e
71
+ handle_exception!(e)
72
+ check_message_error! unless @checked_message_error
73
+ close_connection
74
+ end
75
+ end
76
+
77
+ def connection
78
+ @connection ||= Connection.new(self.class.connection_config)
79
+ end
80
+
81
+ def close_connection
82
+ @connection.close if @connection
83
+ @connection = nil
84
+ end
85
+
86
+ def write_message!
87
+ @checked_message_error = false
88
+ connection.write(current_message)
89
+ self.exception_count = 0; self.consecutive_failure_count = 0; self.success_count += 1
90
+ invoke_callback(:on_write, current_message)
91
+ next_message!
92
+ end
93
+
94
+ def check_message_error!
95
+ @checked_message_error = true
96
+ failed_message_id, error_code = read_apns_error
97
+ # NOTE: According to the APN documentation the APN service will return an error code prior to
98
+ # disconnecting. If we don't disconnect here we will attempt to write more messages
99
+ # before a broken pipe error is raised and those messages will never be delivered.
100
+ if failed_message_id
101
+ invoke_callback(:on_error, failed_message_id, error_code)
102
+ self.failure_count += 1
103
+ self.success_count -= 1
104
+ close_connection
105
+ end
106
+ end
107
+
108
+ def read_apns_error
109
+ message_id = error_code = nil
110
+ begin
111
+ select_return = nil
112
+ if connection && select_return = connection.select
113
+ response = connection.read(6)
114
+ command, error_code, message_id = response.unpack('cci') if response
115
+ else
116
+ invoke_callback(:on_nil_select)
117
+ end
118
+ rescue Exception => e
119
+ # NOTE: If we don't catch this exception then one socket read exception could break out of the whole delivery loop
120
+ invoke_callback(:on_read_exception, e)
121
+ end
122
+ return message_id, error_code
123
+ end
124
+
125
+ def handle_exception!(e)
126
+ invoke_callback(:on_exception, e)
127
+ self.exception_count += 1
128
+ fail_message! if exception_limit_reached?
129
+ sleep(sleep_on_exception) if sleep_on_exception
130
+ end
131
+
132
+ def exception_limit_reached?
133
+ exception_count == exception_limit
134
+ end
135
+
136
+ # # Give up on the message and move on to the next one
137
+ def fail_message!
138
+ self.failure_count += 1; self.consecutive_failure_count += 1; self.exception_count = 0
139
+ invoke_callback(:on_failure, current_message)
140
+ next_message!
141
+ end
142
+
143
+ def invoke_callback(name, *args)
144
+ callbacks[name].call(self, *args) if callbacks[name]
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,85 @@
1
+ require 'apn_client/named_args'
2
+
3
+ module ApnClient
4
+ class Message
5
+ attr_accessor :message_id, :device_token, :alert, :badge, :sound, :content_available, :custom_properties
6
+
7
+ # Creates an APN message to to be sent over SSL to the APN service.
8
+ #
9
+ # @param [Fixnum] message_id a unique (at least within a delivery) integer identifier for this message
10
+ # @param [String] device_token A 64 byte long hex digest supplied from an app installed on a device to the server
11
+ # @param [String] alert A text message to display to the user. Should be tweet sized (payload may not exceed 256 bytes)
12
+ # @param [Fixnum] badge the number to show on the badge on the app icon - number of new/unread items
13
+ # @param [String] sound filename of a sound file in the app bundle to be played to the user
14
+ # @param [Boolean] content_available set to true if the message should trigger download of new content
15
+ def initialize(message_id, config = {})
16
+ self.message_id = message_id
17
+ self.device_token = device_token
18
+ NamedArgs.assert_valid!(config,
19
+ :optional => [:alert, :badge, :sound, :content_available],
20
+ :required => [:device_token])
21
+ config.keys.each do |key|
22
+ send("#{key}=", config[key])
23
+ end
24
+ check_payload_size!
25
+ end
26
+
27
+ # We use the enhanced format. See the Apple documentation for details.
28
+ def to_s
29
+ [1, message_id, self.class.expires_at, 0, 32, device_token, 0, payload_size, payload].pack('ciiccH*cca*')
30
+ end
31
+
32
+ def self.error_codes
33
+ {
34
+ :no_errors_encountered => 0,
35
+ :processing_error => 1,
36
+ :missing_device_token => 2,
37
+ :missing_topic => 3,
38
+ :missing_payload => 4,
39
+ :invalid_token_size => 5,
40
+ :invalid_topic_size => 6,
41
+ :invalid_payload_size => 7,
42
+ :invalid_token => 8,
43
+ :unknown => 255
44
+ }
45
+ end
46
+
47
+ def self.expires_at
48
+ seconds_per_day = 24*3600
49
+ (Time.now + 30*seconds_per_day).to_i
50
+ end
51
+
52
+ # The payload is a JSON formated hash with alert, sound, badge, content-available,
53
+ # and any custom properties, example:
54
+ # {"aps" => {"badge" => 5, "sound" => "my_sound.aiff", "alert" => "Hello!"}}
55
+ def payload
56
+ payload_hash.to_json
57
+ end
58
+
59
+ def payload_hash
60
+ result = {}
61
+ result['aps'] = {}
62
+ result['aps']['alert'] = alert if alert
63
+ result['aps']['badge'] = badge if badge and badge > 0
64
+ if sound
65
+ result['aps']['sound'] = sound if sound.is_a? String
66
+ result['aps']['sound'] = "1.aiff" if sound.is_a? TrueClass
67
+ end
68
+ result['aps']['content-available'] = 1 if content_available
69
+ result
70
+ end
71
+
72
+ def payload_size
73
+ payload.bytesize
74
+ end
75
+
76
+ private
77
+
78
+ def check_payload_size!
79
+ max_payload_size = 256
80
+ if payload_size > max_payload_size
81
+ raise "Payload is #{payload_size} bytes and it cannot exceed #{max_payload_size} bytes"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ module ApnClient
2
+ class NamedArgs
3
+ def self.assert_valid!(arguments, options)
4
+ arguments ||= {}
5
+ options[:optional] ||= []
6
+ options[:required] ||= []
7
+ assert_allowed!(arguments, options[:optional] + options[:required])
8
+ assert_present!(arguments, options[:required])
9
+ end
10
+
11
+ def self.assert_allowed!(arguments, allowed_keys)
12
+ invalid_keys = arguments.keys.select { |key| !allowed_keys.include?(key) }
13
+ unless invalid_keys.empty?
14
+ raise "Invalid arguments: #{invalid_keys.join(', ')}. Must be one of #{allowed_keys.join(', ')}"
15
+ end
16
+ end
17
+
18
+ def self.assert_present!(arguments, required_keys)
19
+ missing_keys = required_keys.select { |key| !arguments.keys.include?(key) }
20
+ unless missing_keys.empty?
21
+ raise "Missing required arguments: #{missing_keys.join(', ')}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module ApnClient
2
+ VERSION = "0.0.1"
3
+ end
data/lib/apn_client.rb ADDED
@@ -0,0 +1,5 @@
1
+ require "apn_client/version"
2
+
3
+ module ApnClient
4
+ # Your code goes here...
5
+ end
@@ -0,0 +1,118 @@
1
+ require 'spec_helper'
2
+
3
+ require 'apn_client/connection'
4
+
5
+ describe ApnClient::Connection do
6
+ describe "#initialize" do
7
+ it "opens an SSL connection given host, port, certificate, and certificate_passphrase" do
8
+ if certificate_exists?
9
+ connection = ApnClient::Connection.new(valid_config)
10
+ connection.config.should == valid_config_with_defaults
11
+ connection.tcp_socket.is_a?(TCPSocket).should be_true
12
+ connection.ssl_socket.is_a?(OpenSSL::SSL::SSLSocket).should be_true
13
+ end
14
+ end
15
+
16
+ it "raises an exception if a required argument is missing" do
17
+ if certificate_exists?
18
+ TCPSocket.expects(:new).never
19
+ lambda {
20
+ connection = ApnClient::Connection.new(valid_config.reject { |key| key == :host })
21
+ }.should raise_error(/host/)
22
+ end
23
+ end
24
+
25
+ it "can take a select_timeout argument" do
26
+ if certificate_exists?
27
+ config = valid_config.merge(:select_timeout => 0.5)
28
+ connection = ApnClient::Connection.new(config)
29
+ connection.config.should == config
30
+ connection.tcp_socket.is_a?(TCPSocket).should be_true
31
+ connection.ssl_socket.is_a?(OpenSSL::SSL::SSLSocket).should be_true
32
+ end
33
+ end
34
+
35
+ it "does not accept invalid arguments" do
36
+ invalid_config = valid_config.merge({:foobar => 1})
37
+ TCPSocket.expects(:new).never
38
+ lambda {
39
+ ApnClient::Connection.new(invalid_config)
40
+ }.should raise_error(/foobar/)
41
+ end
42
+ end
43
+
44
+ describe "#close" do
45
+ it "closes ssl and tcp sockets and sets them to nil" do
46
+ if certificate_exists?
47
+ connection = ApnClient::Connection.new(valid_config)
48
+ connection.close
49
+ connection.tcp_socket.should be_nil
50
+ connection.ssl_socket.should be_nil
51
+ end
52
+ end
53
+ end
54
+
55
+ describe "#write" do
56
+ it "invokes write on the ssl socket" do
57
+ ApnClient::Connection.any_instance.expects(:connect_to_socket)
58
+ connection = ApnClient::Connection.new(valid_config)
59
+ ssl_socket = mock('ssl_socket')
60
+ ssl_socket.expects(:write).with('foo')
61
+ connection.expects(:ssl_socket).returns(ssl_socket)
62
+ connection.write(:foo)
63
+ end
64
+ end
65
+
66
+ describe "#read" do
67
+ it "invokes read on the ssl socket" do
68
+ ApnClient::Connection.any_instance.expects(:connect_to_socket)
69
+ connection = ApnClient::Connection.new(valid_config)
70
+ ssl_socket = mock('ssl_socket')
71
+ ssl_socket.expects(:read).with("foo", "bar")
72
+ connection.expects(:ssl_socket).returns(ssl_socket)
73
+ connection.read("foo", "bar")
74
+ end
75
+ end
76
+
77
+ describe "#select" do
78
+ it "does an IO.select on the ssl socket with a timeout" do
79
+ ApnClient::Connection.any_instance.expects(:connect_to_socket)
80
+ connection = ApnClient::Connection.new(valid_config.merge(:select_timeout => 0.9))
81
+ ssl_socket = mock('ssl_socket')
82
+ connection.expects(:ssl_socket).returns(ssl_socket)
83
+ IO.expects(:select).with([ssl_socket], nil, nil, 0.9)
84
+ connection.select
85
+ end
86
+ end
87
+
88
+ describe ".open" do
89
+ it "opens a connection, yields it to a block, then closes it" do
90
+ ApnClient::Connection.any_instance.expects(:connect_to_socket)
91
+ ApnClient::Connection.any_instance.expects(:close)
92
+ ApnClient::Connection.open(valid_config) do |connection|
93
+ connection.config.should == valid_config_with_defaults
94
+ end
95
+ end
96
+ end
97
+
98
+ def certificate_path
99
+ File.join(File.dirname(__FILE__), "certificate.pem")
100
+ end
101
+
102
+ def certificate_exists?
103
+ File.exists?(certificate_path)
104
+ end
105
+
106
+ def valid_config
107
+ {
108
+ :host => 'gateway.push.apple.com',
109
+ :port => 2195,
110
+ :certificate => IO.read(certificate_path),
111
+ :certificate_passphrase => ''
112
+ }
113
+ end
114
+
115
+ def valid_config_with_defaults
116
+ valid_config.merge(:select_timeout => 0.1)
117
+ end
118
+ end
@@ -0,0 +1,136 @@
1
+ require 'spec_helper'
2
+
3
+ require 'apn_client/delivery'
4
+
5
+ describe ApnClient::Delivery do
6
+ before(:each) do
7
+ @message1 = ApnClient::Message.new(1,
8
+ :device_token => "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099",
9
+ :alert => "New version of the app is out. Get it now in the app store!",
10
+ :badge => 2
11
+ )
12
+ @message2 = ApnClient::Message.new(2,
13
+ :device_token => "6a5g4de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d044",
14
+ :alert => "New version of the app is out. Get it now in the app store!",
15
+ :badge => 1
16
+ )
17
+ end
18
+
19
+ describe "#initialize" do
20
+ it "initializes counts and other attributes" do
21
+ delivery = create_delivery([@message1, @message2])
22
+ end
23
+ end
24
+
25
+ describe "#process!" do
26
+ it "can deliver to all messages successfully and invoke on_write callback" do
27
+ messages = [@message1, @message2]
28
+ written_messages = []
29
+ nil_selects = 0
30
+ callbacks = {
31
+ :on_write => lambda { |d, m| written_messages << m },
32
+ :on_nil_select => lambda { |d| nil_selects += 1 }
33
+ }
34
+ delivery = create_delivery(messages.dup, :callbacks => callbacks)
35
+
36
+ connection = mock('connection')
37
+ connection.expects(:write).with(@message1)
38
+ connection.expects(:write).with(@message2)
39
+ connection.expects(:select).times(2).returns(nil)
40
+ delivery.stubs(:connection).returns(connection)
41
+
42
+ delivery.process!
43
+
44
+ delivery.failure_count.should == 0
45
+ delivery.success_count.should == 2
46
+ delivery.total_count.should == 2
47
+ written_messages.should == messages
48
+ nil_selects.should == 2
49
+ end
50
+
51
+ it "fails a message if it fails more than 3 times" do
52
+ messages = [@message1, @message2]
53
+ written_messages = []
54
+ exceptions = []
55
+ failures = []
56
+ read_exceptions = []
57
+ callbacks = {
58
+ :on_write => lambda { |d, m| written_messages << m },
59
+ :on_exception => lambda { |d, e| exceptions << e },
60
+ :on_failure => lambda { |d, m| failures << m },
61
+ :on_read_exception => lambda { |d, e| read_exceptions << e }
62
+ }
63
+ delivery = create_delivery(messages.dup, :callbacks => callbacks)
64
+
65
+ connection = mock('connection')
66
+ connection.expects(:write).with(@message1).times(3).raises(RuntimeError)
67
+ connection.expects(:write).with(@message2)
68
+ connection.expects(:select).times(4).raises(RuntimeError)
69
+ delivery.stubs(:connection).returns(connection)
70
+
71
+ delivery.process!
72
+
73
+ delivery.failure_count.should == 1
74
+ delivery.success_count.should == 1
75
+ delivery.total_count.should == 2
76
+ written_messages.should == [@message2]
77
+ exceptions.size.should == 3
78
+ exceptions.first.is_a?(RuntimeError).should be_true
79
+ failures.should == [@message1]
80
+ read_exceptions.size.should == 4
81
+ end
82
+
83
+ it "invokes on_error callback if there are errors read" do
84
+ messages = [@message1, @message2]
85
+ written_messages = []
86
+ exceptions = []
87
+ failures = []
88
+ read_exceptions = []
89
+ errors = []
90
+ callbacks = {
91
+ :on_write => lambda { |d, m| written_messages << m },
92
+ :on_exception => lambda { |d, e| exceptions << e },
93
+ :on_failure => lambda { |d, m| failures << m },
94
+ :on_read_exception => lambda { |d, e| read_exceptions << e },
95
+ :on_error => lambda { |d, message_id, error_code| errors << [message_id, error_code] }
96
+ }
97
+ delivery = create_delivery(messages.dup, :callbacks => callbacks)
98
+
99
+ connection = mock('connection')
100
+ connection.expects(:write).with(@message1)
101
+ connection.expects(:write).with(@message2)
102
+ selects = sequence('selects')
103
+ connection.expects(:select).returns("something").in_sequence(selects)
104
+ connection.expects(:select).returns(nil).in_sequence(selects)
105
+ connection.expects(:read).returns("something")
106
+ delivery.stubs(:connection).returns(connection)
107
+
108
+ delivery.process!
109
+
110
+ delivery.failure_count.should == 1
111
+ delivery.success_count.should == 1
112
+ delivery.total_count.should == 2
113
+ written_messages.should == [@message1, @message2]
114
+ exceptions.size.should == 0
115
+ failures.size.should == 0
116
+ errors.should == [[1752458605, 111]]
117
+ end
118
+ end
119
+
120
+ def create_delivery(messages, options = {})
121
+ delivery = ApnClient::Delivery.new(messages, options)
122
+ delivery.messages.should == messages
123
+ delivery.callbacks.should == options[:callbacks]
124
+ delivery.exception_count.should == 0
125
+ delivery.success_count.should == 0
126
+ delivery.failure_count.should == 0
127
+ delivery.consecutive_failure_count.should == 0
128
+ delivery.started_at.should be_nil
129
+ delivery.finished_at.should be_nil
130
+ delivery.elapsed.should == 0
131
+ delivery.consecutive_failure_limit.should == 10
132
+ delivery.exception_limit.should == 3
133
+ delivery.sleep_on_exception.should == 1
134
+ delivery
135
+ end
136
+ end
@@ -0,0 +1,60 @@
1
+ require 'spec_helper'
2
+
3
+ require 'apn_client/message'
4
+
5
+ describe ApnClient::Message do
6
+ before(:each) do
7
+ @device_token = "7b7b8de5888bb742ba744a2a5c8e52c6481d1deeecc283e830533b7c6bf1d099"
8
+ @alert = "Hello, check out version 9.5 of our awesome app in the app store"
9
+ @badge = 3
10
+ end
11
+
12
+ describe "#initialize" do
13
+ it "cannot be created without a token" do
14
+ lambda {
15
+ ApnClient::Message.new(1)
16
+ }.should raise_error(/device_token/)
17
+ end
18
+
19
+ it "can be created with a token and an alert" do
20
+ message = create_message(1, :device_token => @device_token, :alert => @alert)
21
+ message.payload_hash.should == {'aps' => {'alert' => @alert}}
22
+ end
23
+
24
+ it "can be created with a token and an alert and a badge" do
25
+ message = create_message(1, :device_token => @device_token, :alert => @alert, :badge => @badge)
26
+ message.payload_hash.should == {'aps' => {'alert' => @alert, 'badge' => @badge}}
27
+ end
28
+
29
+ it "can be created with a token and an alert and a badge and content-available" do
30
+ message = create_message(1,
31
+ :device_token => @device_token,
32
+ :alert => @alert,
33
+ :badge => @badge,
34
+ :content_available => true)
35
+ message.payload_hash.should == {'aps' => {'alert' => @alert, 'badge' => @badge, 'content-available' => 1}}
36
+ end
37
+
38
+ it "raises an exception if payload_size exceeds 256 bytes" do
39
+ lambda {
40
+ too_long_alert = "A"*1000
41
+ ApnClient::Message.new(1, :device_token => @device_token, :alert => too_long_alert)
42
+ }.should raise_error(/payload/i)
43
+ end
44
+ end
45
+
46
+ describe "#payload_size" do
47
+ it "returns number of bytes in the payload"
48
+ end
49
+
50
+ def create_message(message_id, config = {})
51
+ message = ApnClient::Message.new(message_id, config)
52
+ message.message_id.should == 1
53
+ [:device_token, :alert, :badge, :sound, :content_available].each do |attribute|
54
+ message.send(attribute).should == config[attribute]
55
+ end
56
+ message.payload_size.should < 256
57
+ message.to_s.should_not be_nil
58
+ message
59
+ end
60
+ end
@@ -0,0 +1,94 @@
1
+ require 'spec_helper'
2
+
3
+ require 'apn_client/named_args'
4
+
5
+ describe ApnClient::NamedArgs do
6
+ describe ".assert_allowed!" do
7
+ it "raises an exception if the argument hash contains a key not in the allowed list" do
8
+ lambda {
9
+ ApnClient::NamedArgs.assert_allowed!({:foo => 1, :bla => 2}, [:foo, :bar])
10
+ }.should raise_error(/foo/)
11
+ end
12
+
13
+ it "does not raise an exception if the arguments hash is empty" do
14
+ ApnClient::NamedArgs.assert_allowed!({}, [:foo, :bar])
15
+ end
16
+
17
+ it "does not raise an exception if all allowed keys are provided" do
18
+ ApnClient::NamedArgs.assert_allowed!({:foo => 1, :bar => 2}, [:foo, :bar])
19
+ end
20
+
21
+ it "does not raise an exception if a subset of allowed keys are provided" do
22
+ ApnClient::NamedArgs.assert_allowed!({:foo => 1}, [:foo, :bar])
23
+ end
24
+ end
25
+
26
+ describe ".assert_present!" do
27
+ it "raises an exception if arguments are empty and required keys are not empty" do
28
+ lambda {
29
+ ApnClient::NamedArgs.assert_present!({}, [:foo])
30
+ }.should raise_error(/foo/)
31
+ end
32
+
33
+ it "does not raise an exception if arguments are empty and required keys are empty" do
34
+ ApnClient::NamedArgs.assert_present!({}, [])
35
+ end
36
+
37
+ it "raises an exception if arguments have some but not all required keys" do
38
+ lambda {
39
+ ApnClient::NamedArgs.assert_present!({:bar => 1}, [:foo, :bar])
40
+ }.should raise_error(/foo/)
41
+ end
42
+
43
+ it "does not raise an excpeption if arguments have all required keys" do
44
+ ApnClient::NamedArgs.assert_present!({:foo => 1, :bar => 2}, [:foo, :bar])
45
+ end
46
+ end
47
+
48
+ describe ".assert_valid!" do
49
+ it "can take only optional keys" do
50
+ arguments = {:foo => 1}
51
+ ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:foo])
52
+ ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [])
53
+ ApnClient::NamedArgs.assert_valid!(arguments, :optional => [:foo])
54
+ end
55
+
56
+ it "can take only required keys" do
57
+ arguments = {:foo => 1}
58
+ ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:foo])
59
+ ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [:foo])
60
+ ApnClient::NamedArgs.assert_valid!(arguments, :required => [:foo])
61
+ end
62
+
63
+ it "can take both optional and required keys" do
64
+ arguments = {:foo => 1}
65
+ ApnClient::NamedArgs.expects(:assert_allowed!).with(arguments, [:bar, :foo])
66
+ ApnClient::NamedArgs.expects(:assert_present!).with(arguments, [:foo])
67
+ ApnClient::NamedArgs.assert_valid!(arguments, :required => [:foo], :optional => [:bar])
68
+ end
69
+
70
+ it "does not raise exception if only required args are provided" do
71
+ ApnClient::NamedArgs.assert_valid!({:foo => 1}, :required => [:foo], :optional => [:bar])
72
+ end
73
+
74
+ it "does not raise exception if required and optional args are provided" do
75
+ ApnClient::NamedArgs.assert_valid!({:foo => 1, :bar => 2}, :required => [:foo], :optional => [:bar])
76
+ end
77
+
78
+ it "raises an exception if a required arg is missing" do
79
+ lambda {
80
+ ApnClient::NamedArgs.assert_valid!({:bar => 2}, :required => [:foo], :optional => [:bar])
81
+ }.should raise_error(/foo/)
82
+ end
83
+
84
+ it "raises an exception if an invalid arg is present" do
85
+ lambda {
86
+ ApnClient::NamedArgs.assert_valid!({:foo => 1, :bar => 2, :bla => 3}, :required => [:foo], :optional => [:bar])
87
+ }.should raise_error(/bla/)
88
+ end
89
+
90
+ it "does not raise an exception if args are nil and all keys are optional" do
91
+ ApnClient::NamedArgs.assert_valid!(nil, :optional => [:bar])
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,5 @@
1
+ require 'json'
2
+
3
+ RSpec.configure do |config|
4
+ config.mock_with :mocha
5
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: apn_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Peter Marklund
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-12-02 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: json
16
+ requirement: &70199261615240 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70199261615240
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ requirement: &70199261614820 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *70199261614820
36
+ - !ruby/object:Gem::Dependency
37
+ name: mocha
38
+ requirement: &70199261614400 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70199261614400
47
+ - !ruby/object:Gem::Dependency
48
+ name: yard
49
+ requirement: &70199261613980 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *70199261613980
58
+ description: Uses the "enhanced format" Apple protocol and deals with errors and failures
59
+ when broadcasting to many devices. Includes support for talking to the Apple Push
60
+ Notification Feedback service for dealing with uninstalled apps.
61
+ email:
62
+ - peter@marklunds.com
63
+ executables: []
64
+ extensions: []
65
+ extra_rdoc_files: []
66
+ files:
67
+ - .gitignore
68
+ - .rspec
69
+ - .rvmrc
70
+ - Gemfile
71
+ - README.md
72
+ - Rakefile
73
+ - apn_client.gemspec
74
+ - lib/apn_client.rb
75
+ - lib/apn_client/connection.rb
76
+ - lib/apn_client/delivery.rb
77
+ - lib/apn_client/message.rb
78
+ - lib/apn_client/named_args.rb
79
+ - lib/apn_client/version.rb
80
+ - spec/connection_spec.rb
81
+ - spec/delivery_spec.rb
82
+ - spec/message_spec.rb
83
+ - spec/named_args_spec.rb
84
+ - spec/spec_helper.rb
85
+ homepage: ''
86
+ licenses: []
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ none: false
93
+ requirements:
94
+ - - ! '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ none: false
99
+ requirements:
100
+ - - ! '>='
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubyforge_project: apn_client
105
+ rubygems_version: 1.8.10
106
+ signing_key:
107
+ specification_version: 3
108
+ summary: Library for sending Apple Push Notifications to iOS devices from Ruby
109
+ test_files:
110
+ - spec/connection_spec.rb
111
+ - spec/delivery_spec.rb
112
+ - spec/message_spec.rb
113
+ - spec/named_args_spec.rb
114
+ - spec/spec_helper.rb
115
+ has_rdoc: