├── .rspec ├── lib ├── grocer │ ├── version.rb │ ├── pusher.rb │ ├── feedback.rb │ ├── failed_delivery_attempt.rb │ ├── passbook_notification.rb │ ├── extensions │ │ └── deep_symbolize_keys.rb │ ├── newsstand_notification.rb │ ├── test │ │ ├── server.csr │ │ ├── server.crt │ │ └── server.key │ ├── mobile_device_management_notification.rb │ ├── feedback_connection.rb │ ├── error_response.rb │ ├── push_connection.rb │ ├── server.rb │ ├── notification_reader.rb │ ├── ssl_connection.rb │ ├── ssl_server.rb │ ├── connection.rb │ └── notification.rb └── grocer.rb ├── Gemfile ├── Rakefile ├── .travis.yml ├── .gitignore ├── spec ├── support │ └── notification_helpers.rb ├── spec_helper.rb ├── grocer │ ├── pusher_spec.rb │ ├── passbook_notification_spec.rb │ ├── newsstand_notification_spec.rb │ ├── failed_delivery_attempt_spec.rb │ ├── server_spec.rb │ ├── error_response_spec.rb │ ├── feedback_spec.rb │ ├── extensions │ │ └── deep_symbolize_keys_spec.rb │ ├── mobile_device_management_notification_spec.rb │ ├── shared_examples_for_notifications.rb │ ├── ssl_server_spec.rb │ ├── feedback_connection_spec.rb │ ├── push_connection_spec.rb │ ├── notification_reader_spec.rb │ ├── ssl_connection_spec.rb │ ├── notification_spec.rb │ └── connection_spec.rb ├── fixtures │ ├── example.key │ ├── example.cer │ └── example.pem └── grocer_spec.rb ├── LICENSE ├── grocer.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -r pry 4 | -------------------------------------------------------------------------------- /lib/grocer/version.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | VERSION = '0.4.1' 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in grocer.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.2 4 | - 1.9.3 5 | - 2.0.0 6 | - jruby-19mode # JRuby 1.7.0 7 | - rbx-19mode 8 | # - ruby-head # seems unstable on travis at this time 9 | -------------------------------------------------------------------------------- /lib/grocer/pusher.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | class Pusher 3 | def initialize(connection) 4 | @connection = connection 5 | end 6 | 7 | def push(notification) 8 | @connection.write(notification.to_bytes) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .DS_Store 4 | .bundle 5 | .config 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | tags 20 | .rbx 21 | -------------------------------------------------------------------------------- /spec/support/notification_helpers.rb: -------------------------------------------------------------------------------- 1 | module NotificationHelpers 2 | def payload_hash(notification) 3 | JSON.parse(payload_bytes(notification), symbolize_names: true) 4 | end 5 | 6 | def payload_bytes(notification) 7 | notification.to_bytes[45..-1] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/grocer/feedback.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/failed_delivery_attempt' 2 | 3 | module Grocer 4 | class Feedback 5 | include Enumerable 6 | 7 | def initialize(connection) 8 | @connection = connection 9 | end 10 | 11 | def each 12 | while buf = @connection.read(FailedDeliveryAttempt::LENGTH) 13 | yield FailedDeliveryAttempt.new(buf) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | require 'mocha/api' 3 | require 'bourne' 4 | require 'support/notification_helpers' 5 | 6 | RSpec.configure do |config| 7 | config.treat_symbols_as_metadata_keys_with_true_values = true 8 | config.run_all_when_everything_filtered = true 9 | config.filter_run :focus 10 | 11 | config.mock_with :mocha 12 | 13 | config.include NotificationHelpers 14 | end 15 | -------------------------------------------------------------------------------- /spec/grocer/pusher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/pusher' 3 | 4 | describe Grocer::Pusher do 5 | let(:connection) { stub_everything } 6 | 7 | subject { described_class.new(connection) } 8 | 9 | describe '#push' do 10 | it 'serializes a notification and sends it via the connection' do 11 | notification = stub(:to_bytes => 'abc123') 12 | subject.push(notification) 13 | 14 | connection.should have_received(:write).with('abc123') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/grocer/failed_delivery_attempt.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | class FailedDeliveryAttempt 3 | LENGTH = 38 4 | 5 | attr_accessor :timestamp, :device_token 6 | 7 | def initialize(binary_tuple) 8 | # N => 4 byte timestamp 9 | # n => 2 byte token_length 10 | # H64 => 32 byte device_token 11 | seconds, _, @device_token = binary_tuple.unpack('NnH64') 12 | raise InvalidFormatError unless seconds && @device_token 13 | @timestamp = Time.at(seconds) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/fixtures/example.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIBOwIBAAJBANlOUQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64g 3 | mRucz+DKmxfYr5kq+XT6GIpEdw0wrBGsKlUCAwEAAQJBAISneW67Wrrl/WQXfWri 4 | /IBeWvvDo9nEK6gwLdCrm3+UZ18Tr9scjdvWsRbHy6fiIYIIZY0KA0mIx1TlPxbD 5 | ANUCIQDuc5KeAJZ5bVDWK/Qca6/kAtx6jKH0Q8A4M4vhEynHwwIhAOlMXJqUyt4p 6 | As+jG3jRz820QQW5CkjQ4rNCvDgnEjwHAiArzn+5F1KNrE+ViS2nqwD9Wqk2um9m 7 | eKvvp0ijaOncEQIgG7dwwQSwXVht9xEfsGjs0Tl7CB0FtcTrSfTBu8IYjn0CIQCh 8 | tJEYZA1szHnTdHA2V8FvQ9ZDC3KW6pBvKDQJkqPIaw== 9 | -----END RSA PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /lib/grocer/passbook_notification.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/notification' 2 | 3 | module Grocer 4 | # Public: A specialized form of a Grocer::Notification which requires neither 5 | # an alert nor badge to be present in the payload. It requires only the 6 | # `device_token`, and allows an optional `expiry` and `identifier` to be set. 7 | # 8 | # Examples 9 | # 10 | # Grocer::PassbookNotification.new(device_token: '...') 11 | class PassbookNotification < Notification 12 | 13 | private 14 | 15 | def validate_payload 16 | true 17 | end 18 | 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/grocer/extensions/deep_symbolize_keys.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | module Extensions 3 | module DeepSymbolizeKeys 4 | 5 | def deep_symbolize_keys 6 | result = {} 7 | each do |key, value| 8 | # Workaround for JRuby defining Fixnum#to_sym even in 1.9 mode 9 | symbolized_key = key.is_a?(Fixnum) ? key : (key.to_sym rescue key) 10 | 11 | result[symbolized_key] = value.is_a?(Hash) ? 12 | (value.extend DeepSymbolizeKeys).deep_symbolize_keys : value 13 | end 14 | result 15 | end 16 | 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/grocer/passbook_notification_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/passbook_notification' 3 | require 'grocer/shared_examples_for_notifications' 4 | 5 | describe Grocer::PassbookNotification do 6 | describe 'binary format' do 7 | let(:payload_options) { Hash.new } 8 | 9 | include_examples 'a notification' 10 | 11 | it 'does not require a payload' do 12 | expect(payload_hash(notification)[:aps]).to be_empty 13 | end 14 | 15 | it 'encodes the payload length' do 16 | payload_length = bytes[43...45].to_i 17 | expect(payload_length).to be_zero 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/grocer/newsstand_notification_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/newsstand_notification' 3 | require 'grocer/shared_examples_for_notifications' 4 | 5 | describe Grocer::NewsstandNotification do 6 | describe 'binary format' do 7 | let(:payload_options) { Hash.new } 8 | let(:payload) { payload_hash(notification) } 9 | 10 | include_examples 'a notification' 11 | 12 | it 'requires a payload' do 13 | expect(payload[:aps]).to_not be_empty 14 | end 15 | 16 | it 'encodes content-available as part of the payload' do 17 | expect(payload[:aps][:'content-available']).to eq(1) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/grocer/newsstand_notification.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/notification' 2 | 3 | module Grocer 4 | # Public: A specialized form of a Grocer::Notification which requires neither 5 | # an alert nor badge to be present in the payload. It requires only the 6 | # `device_token` to be set. 7 | # 8 | # Examples 9 | # 10 | # Grocer::NewsstandNotification.new(device_token: '...') 11 | # #=> { aps: { 'content-available' => 1 } } 12 | class NewsstandNotification < Notification 13 | 14 | def initialize(payload = {}) 15 | super(payload.merge(content_available: true)) 16 | end 17 | 18 | private 19 | 20 | def validate_payload 21 | true 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/grocer/test/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIBsDCCARkCAQAwcDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkdBMRAwDgYDVQQH 3 | EwdBdGxhbnRhMRswGQYDVQQKExJIaWdoZ3Jvb3ZlIFN0dWRpb3MxJTAjBgNVBAMT 4 | HGZha2UuZ3JvY2VyLmFwbnMuZXhhbXBsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQAD 5 | gY0AMIGJAoGBAJcHamWfm0lQiYWDtPX6po3ueC2llRQIL/+f3oKzoqKH3RdbVFE0 6 | dIr6hgHronMSvOAOMXzV4Clg3M83z0Wm8Wl3QhYhEziqfD1+u4Dc25vpdSWO0K7i 7 | fW3j7/WjpU0KXvNzPvvypKwMUlgAvGeMQKXShRIbjpPeQjxK78ybqa/pAgMBAAGg 8 | ADANBgkqhkiG9w0BAQUFAAOBgQBjcLRdwZpWwp7mBrn2Nw8opFtVbB/jlPTSh794 9 | vdRuVrYAUs11q4qg1viNvZ0qoa6J//U8MkuzTI4JwpIKKUCenUZDC+es7KjwjiGE 10 | 93RmiT0fqzdIw/TVaKpbsdqmI5ZeQyKpN/UXImftO5xG0ARmQRxVpKnlsiZPTIuR 11 | BroEgQ== 12 | -----END CERTIFICATE REQUEST----- 13 | -------------------------------------------------------------------------------- /lib/grocer/mobile_device_management_notification.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/notification' 2 | 3 | module Grocer 4 | # Public: A specialized form of a Grocer::Notification which only requires a 5 | # `push_magic` and `device_token` to be present in the payload. 6 | # 7 | # Examples 8 | # 9 | # Grocer::MobileDeviceManagementNotification.new(device_token: '...', push_magic: '...') 10 | class MobileDeviceManagementNotification < Notification 11 | attr_accessor :push_magic 12 | 13 | private 14 | 15 | def payload_hash 16 | { mdm: push_magic } 17 | end 18 | 19 | def validate_payload 20 | fail NoPayloadError unless push_magic 21 | fail InvalidFormatError if alert || badge || custom 22 | fail PayloadTooLargeError if payload_too_large? 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/fixtures/example.cer: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICKzCCAdWgAwIBAgIJAKDDs5zNJQVeMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTIwMzI5MTg1MDEwWhcNMTIwNDI4MTg1MDEwWjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANlO 7 | UQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64gmRucz+DKmxfYr5kq 8 | +XT6GIpEdw0wrBGsKlUCAwEAAaOBpzCBpDAdBgNVHQ4EFgQUVkHor75NOFIKInJN 9 | VUpmqRbWfsgwdQYDVR0jBG4wbIAUVkHor75NOFIKInJNVUpmqRbWfsihSaRHMEUx 10 | CzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRl 11 | cm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCgw7OczSUFXjAMBgNVHRMEBTADAQH/MA0G 12 | CSqGSIb3DQEBBQUAA0EAzPoxFj8j1uUIEGGsViUXkAH9/uoZuCBy9PtHEJcjSkya 13 | TlKHfzEaEsA8pfmHax3gMgYcIbbWuYVR/HTUEnog9Q== 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /lib/grocer/feedback_connection.rb: -------------------------------------------------------------------------------- 1 | require 'delegate' 2 | require 'grocer/extensions/deep_symbolize_keys' 3 | require 'grocer/connection' 4 | 5 | module Grocer 6 | class FeedbackConnection < SimpleDelegator 7 | 8 | PRODUCTION_GATEWAY = 'feedback.push.apple.com' 9 | SANDBOX_GATEWAY = 'feedback.sandbox.push.apple.com' 10 | 11 | def initialize(options) 12 | options = apply_defaults(options) 13 | super(Connection.new(options)) 14 | end 15 | 16 | private 17 | 18 | def defaults 19 | { 20 | gateway: find_default_gateway, 21 | port: 2196 22 | } 23 | end 24 | 25 | def find_default_gateway 26 | Grocer.env == 'production' ? PRODUCTION_GATEWAY : SANDBOX_GATEWAY 27 | end 28 | 29 | def apply_defaults(options) 30 | options.extend Extensions::DeepSymbolizeKeys 31 | defaults.merge(options.deep_symbolize_keys) 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/grocer/test/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICVzCCAcACCQDW8lrgRNBDkDANBgkqhkiG9w0BAQUFADBwMQswCQYDVQQGEwJV 3 | UzELMAkGA1UECBMCR0ExEDAOBgNVBAcTB0F0bGFudGExGzAZBgNVBAoTEkhpZ2hn 4 | cm9vdmUgU3R1ZGlvczElMCMGA1UEAxMcZmFrZS5ncm9jZXIuYXBucy5leGFtcGxl 5 | LmNvbTAeFw0xMjA1MDgxNzAxMDNaFw0yMjA1MDYxNzAxMDNaMHAxCzAJBgNVBAYT 6 | AlVTMQswCQYDVQQIEwJHQTEQMA4GA1UEBxMHQXRsYW50YTEbMBkGA1UEChMSSGln 7 | aGdyb292ZSBTdHVkaW9zMSUwIwYDVQQDExxmYWtlLmdyb2Nlci5hcG5zLmV4YW1w 8 | bGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCXB2pln5tJUImFg7T1 9 | +qaN7ngtpZUUCC//n96Cs6Kih90XW1RRNHSK+oYB66JzErzgDjF81eApYNzPN89F 10 | pvFpd0IWIRM4qnw9fruA3Nub6XUljtCu4n1t4+/1o6VNCl7zcz778qSsDFJYALxn 11 | jECl0oUSG46T3kI8Su/Mm6mv6QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAEn783m/ 12 | BdbRH/PJkGeYPWzUP05hmloo5VT3kbopmRUslqGMVM9vbPpdx7eTGxdPMUfVglK1 13 | 7vqOwFG6dmzH6gRb2d4+TRafjCK1NBm7fPhKQmxc8WgsqQusMW7SbZ6+OoIMxLbY 14 | 4s+EqIePtUoY+XqbCKXVdyUI1Kw99oph2UXN 15 | -----END CERTIFICATE----- 16 | -------------------------------------------------------------------------------- /lib/grocer/test/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQCXB2pln5tJUImFg7T1+qaN7ngtpZUUCC//n96Cs6Kih90XW1RR 3 | NHSK+oYB66JzErzgDjF81eApYNzPN89FpvFpd0IWIRM4qnw9fruA3Nub6XUljtCu 4 | 4n1t4+/1o6VNCl7zcz778qSsDFJYALxnjECl0oUSG46T3kI8Su/Mm6mv6QIDAQAB 5 | AoGBAIfLRSEmhws+fMgtihH5UrQfDLOORCKE0hN3fSvrtHmKy4Hqvj9deMRVSRSE 6 | 98WbvXN/j4N9ElZiH2e5+IXZ+wjFddSmksKCJEj8IV0oPikNYwEg1pFo5vMSHMTA 7 | 4kBcx6tgKH8kjPxGz1w5RkxvwdqkKyFDask2Xxn4DDfYI6ZhAkEAxdPun7Yf3EOz 8 | /OrHAEC1WXaQlrojcBheNSJiON+joiPeXSqbKXDAkHu8pQWawilIm7M/fIGADT9c 9 | BkWqlbCopQJBAMNwj4bSpNKa83URN8YWJYPocvqb8ZqoS7oZaJLklrRIxnzPACKa 10 | GyJlj2z6ADotJTbdMu0hBjjXf/BAX117AvUCQACRRxH2N8kt+Io1MjTx+pMzH98O 11 | 0aM0rrCAVL/NBG8mozCpOqC3zhWcBUKD7Zm4/JhVv0zgIjnngKAT+xVK2HECQCJp 12 | KCwx3GlkdOcwz+QltBdEjzIG0QRNC4BJxvrOGqbFhYUmITz2az6kKRuj7PRRTJMb 13 | YUMVJHZPoywW+XOJHB0CQBtW+13o60wQfluoQZs1DeHRTVSV5sEUUTZuLdp88dxA 14 | XZPsIY2e3fgrwpgohGvCZ8q7Em9PP9UiWCnEDRhjRW8= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /spec/grocer/failed_delivery_attempt_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/failed_delivery_attempt' 3 | 4 | describe Grocer::FailedDeliveryAttempt do 5 | let(:timestamp) { Time.utc(1995, 12, 21) } 6 | let(:device_token) { 'fe15a27d5df3c34778defb1f4f3980265cc52c0c047682223be59fb68500a9a2' } 7 | let(:binary_tuple) { [timestamp.to_i, 32, device_token].pack('NnH64') } 8 | let(:invalid_binary_tuple) { 'totally not the right format' } 9 | 10 | describe 'decoding' do 11 | it 'accepts a binary tuple and sets each attribute' do 12 | failed_delivery_attempt = described_class.new(binary_tuple) 13 | expect(failed_delivery_attempt.timestamp).to eq(timestamp) 14 | expect(failed_delivery_attempt.device_token).to eq(device_token) 15 | end 16 | 17 | it 'raises an exception when there are problems decoding' do 18 | -> { described_class.new(invalid_binary_tuple) }.should 19 | raise_error(Grocer::InvalidFormatError) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/grocer/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'timeout' 3 | require 'stringio' 4 | require 'grocer/server' 5 | require 'grocer/notification' 6 | 7 | describe Grocer::Server do 8 | let(:ssl_server) { stub_everything } 9 | let(:mock_client) { StringIO.new } 10 | subject { described_class.new(ssl_server) } 11 | 12 | before do 13 | ssl_server.stubs(:accept).yields(mock_client) 14 | mock_client.stubs(:close) 15 | end 16 | 17 | after do 18 | subject.close 19 | end 20 | 21 | it "accepts client connections and reads notifications into a queue" do 22 | mock_client.write(Grocer::Notification.new(alert: "Hi!").to_bytes) 23 | mock_client.rewind 24 | 25 | subject.accept 26 | Timeout.timeout(5) { 27 | notification = subject.notifications.pop 28 | expect(notification.alert).to eq("Hi!") 29 | } 30 | end 31 | 32 | it "closes the socket" do 33 | ssl_server.expects(:close).at_least(1) 34 | 35 | subject.close 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/grocer/error_response_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/grocer/error_response' 2 | 3 | describe Grocer::ErrorResponse do 4 | let(:status_code) { 1 } 5 | let(:identifier) { 8342 } 6 | let(:binary_tuple) { [described_class::COMMAND, status_code, identifier].pack('CCN') } 7 | let(:invalid_binary_tuple) { 'totally not the right format' } 8 | 9 | subject(:error_response) { described_class.new(binary_tuple) } 10 | 11 | describe 'decoding' do 12 | it 'accepts a binary tuple and sets each attribute' do 13 | expect(error_response.status_code).to eq(status_code) 14 | expect(error_response.identifier).to eq(identifier) 15 | end 16 | 17 | it 'raises an exception when there are problems decoding' do 18 | -> { described_class.new(invalid_binary_tuple) }.should 19 | raise_error(Grocer::InvalidFormatError) 20 | end 21 | end 22 | 23 | it 'finds the status from the status code' do 24 | expect(error_response.status).to eq('Processing error') 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/grocer/error_response.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | class ErrorResponse 3 | STATUS_CODE_DESCRIPTIONS = { 4 | 0 => 'No errors encountered', 5 | 1 => 'Processing error', 6 | 2 => 'Missing device token', 7 | 3 => 'Missing topic', 8 | 4 => 'Missing payload', 9 | 5 => 'Invalid token size', 10 | 6 => 'Invalid topic size', 11 | 7 => 'Invalid payload size', 12 | 8 => 'Invalid token', 13 | 256 => 'None (unknown)', 14 | } 15 | 16 | COMMAND = 8 17 | 18 | attr_accessor :status_code, :identifier 19 | 20 | def initialize(binary_tuple) 21 | # C => 1 byte command 22 | # C => 1 byte status 23 | # N => 4 byte identifier 24 | command, @status_code, @identifier = binary_tuple.unpack('CCN') 25 | raise InvalidFormatError unless @status_code && @identifier 26 | raise InvalidCommandError unless command == COMMAND 27 | end 28 | 29 | def status 30 | STATUS_CODE_DESCRIPTIONS[status_code] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/grocer/push_connection.rb: -------------------------------------------------------------------------------- 1 | require 'delegate' 2 | require 'grocer/extensions/deep_symbolize_keys' 3 | require 'grocer/connection' 4 | 5 | module Grocer 6 | class PushConnection < SimpleDelegator 7 | 8 | PRODUCTION_GATEWAY = 'gateway.push.apple.com' 9 | LOCAL_GATEWAY = '127.0.0.1' 10 | SANDBOX_GATEWAY = 'gateway.sandbox.push.apple.com' 11 | 12 | def initialize(options) 13 | options = apply_defaults(options) 14 | super(Connection.new(options)) 15 | end 16 | 17 | private 18 | 19 | def defaults 20 | { 21 | gateway: find_default_gateway, 22 | port: 2195 23 | } 24 | end 25 | 26 | def find_default_gateway 27 | case Grocer.env.downcase 28 | when 'production' 29 | PRODUCTION_GATEWAY 30 | when 'test' 31 | LOCAL_GATEWAY 32 | else 33 | SANDBOX_GATEWAY 34 | end 35 | end 36 | 37 | def apply_defaults(options) 38 | options.extend Extensions::DeepSymbolizeKeys 39 | defaults.merge(options.deep_symbolize_keys) 40 | end 41 | 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Steven Harman 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/grocer/server.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'openssl' 3 | require 'grocer/notification_reader' 4 | require 'grocer/ssl_server' 5 | 6 | module Grocer 7 | class Server 8 | attr_reader :notifications 9 | 10 | def initialize(server) 11 | @server = server 12 | 13 | @clients = [] 14 | @notifications = Queue.new 15 | end 16 | 17 | def accept 18 | Thread.new { 19 | begin 20 | @server.accept { |client| 21 | @clients << client 22 | 23 | Thread.new { 24 | begin 25 | NotificationReader.new(client).each(¬ifications.method(:push)) 26 | rescue Errno::EBADF, NoMethodError, OpenSSL::OpenSSLError 27 | # Expected when another thread closes the socket 28 | end 29 | } 30 | } 31 | rescue Errno::EBADF 32 | # Expected when another thread closes the socket 33 | end 34 | } 35 | end 36 | 37 | def close 38 | if @server 39 | @server.close 40 | @server = nil 41 | end 42 | 43 | @clients.each(&:close) 44 | @clients = [] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/grocer.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/error_response' 2 | require 'grocer/feedback' 3 | require 'grocer/feedback_connection' 4 | require 'grocer/mobile_device_management_notification' 5 | require 'grocer/newsstand_notification' 6 | require 'grocer/notification' 7 | require 'grocer/passbook_notification' 8 | require 'grocer/push_connection' 9 | require 'grocer/pusher' 10 | require 'grocer/server' 11 | require 'grocer/version' 12 | 13 | module Grocer 14 | Error = Class.new(::StandardError) 15 | InvalidFormatError = Class.new(Error) 16 | NoGatewayError = Class.new(Error) 17 | NoPayloadError = Class.new(Error) 18 | NoPortError = Class.new(Error) 19 | PayloadTooLargeError = Class.new(Error) 20 | CertificateExpiredError = Module.new 21 | InvalidCommandError = Class.new(Error) 22 | 23 | def self.env 24 | ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development' 25 | end 26 | 27 | def self.feedback(options) 28 | connection = FeedbackConnection.new(options) 29 | Feedback.new(connection) 30 | end 31 | 32 | def self.pusher(options) 33 | connection = PushConnection.new(options) 34 | Pusher.new(connection) 35 | end 36 | 37 | def self.server(options = { }) 38 | ssl_server = SSLServer.new(options) 39 | Server.new(ssl_server) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/grocer/notification_reader.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'grocer/notification' 3 | 4 | module Grocer 5 | class NotificationReader 6 | include Enumerable 7 | 8 | def initialize(io) 9 | @io = io 10 | end 11 | 12 | def each 13 | while notification = read_notification 14 | yield notification 15 | end 16 | end 17 | 18 | private 19 | 20 | def read_notification 21 | @io.read(1) # version (not used for now) 22 | 23 | payload = { } 24 | payload[:identifier] = @io.read(4).unpack("N").first 25 | payload[:expiry] = Time.at(@io.read(4).unpack("N").first) 26 | 27 | @io.read(2) # device token length (always 32, so not used) 28 | payload[:device_token] = @io.read(32).unpack("H*").first 29 | 30 | payload_length = @io.read(2).unpack("n").first 31 | payload_hash = JSON.parse(@io.read(payload_length), symbolize_names: true) 32 | 33 | aps = sanitize_keys(payload_hash.delete(:aps)) 34 | payload.merge!(aps) 35 | 36 | payload[:custom] = payload_hash 37 | 38 | Grocer::Notification.new(payload) 39 | end 40 | 41 | def sanitize_keys(hash) 42 | hash.each_with_object({}) do |(k,v), h| 43 | new_key = k.to_s.gsub(/-/,'_').to_sym 44 | h[new_key] = v 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/grocer/feedback_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/feedback' 3 | 4 | describe Grocer::Feedback do 5 | def stub_feedback 6 | # "Reads" two failed deliveries: one on Jan 1; the other on Jan 2 7 | connection.stubs(:read). 8 | with(38). 9 | returns([jan1.to_i, 32, device_token].pack('NnH64')). 10 | then. 11 | returns([jan2.to_i, 32, device_token].pack('NnH64')). 12 | then. 13 | returns(nil) 14 | end 15 | 16 | let(:connection) { stub_everything } 17 | let(:jan1) { Time.utc(2012, 1, 1) } 18 | let(:jan2) { Time.utc(2012, 1, 2) } 19 | let(:device_token) { 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2' } 20 | 21 | subject { described_class.new(connection) } 22 | 23 | it 'is enumerable' do 24 | expect(subject).to be_kind_of(Enumerable) 25 | end 26 | 27 | it 'reads failed delivery attempt messages from the connection' do 28 | stub_feedback 29 | 30 | delivery_attempts = subject.to_a 31 | 32 | expect(delivery_attempts[0].timestamp).to eq(jan1) 33 | expect(delivery_attempts[0].device_token).to eq(device_token) 34 | 35 | expect(delivery_attempts[1].timestamp).to eq(jan2) 36 | expect(delivery_attempts[1].device_token).to eq(device_token) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/fixtures/example.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIBOwIBAAJBANlOUQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64g 3 | mRucz+DKmxfYr5kq+XT6GIpEdw0wrBGsKlUCAwEAAQJBAISneW67Wrrl/WQXfWri 4 | /IBeWvvDo9nEK6gwLdCrm3+UZ18Tr9scjdvWsRbHy6fiIYIIZY0KA0mIx1TlPxbD 5 | ANUCIQDuc5KeAJZ5bVDWK/Qca6/kAtx6jKH0Q8A4M4vhEynHwwIhAOlMXJqUyt4p 6 | As+jG3jRz820QQW5CkjQ4rNCvDgnEjwHAiArzn+5F1KNrE+ViS2nqwD9Wqk2um9m 7 | eKvvp0ijaOncEQIgG7dwwQSwXVht9xEfsGjs0Tl7CB0FtcTrSfTBu8IYjn0CIQCh 8 | tJEYZA1szHnTdHA2V8FvQ9ZDC3KW6pBvKDQJkqPIaw== 9 | -----END RSA PRIVATE KEY----- 10 | -----BEGIN CERTIFICATE----- 11 | MIICKzCCAdWgAwIBAgIJAKDDs5zNJQVeMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV 12 | BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX 13 | aWRnaXRzIFB0eSBMdGQwHhcNMTIwMzI5MTg1MDEwWhcNMTIwNDI4MTg1MDEwWjBF 14 | MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 15 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANlO 16 | UQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64gmRucz+DKmxfYr5kq 17 | +XT6GIpEdw0wrBGsKlUCAwEAAaOBpzCBpDAdBgNVHQ4EFgQUVkHor75NOFIKInJN 18 | VUpmqRbWfsgwdQYDVR0jBG4wbIAUVkHor75NOFIKInJNVUpmqRbWfsihSaRHMEUx 19 | CzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRl 20 | cm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCgw7OczSUFXjAMBgNVHRMEBTADAQH/MA0G 21 | CSqGSIb3DQEBBQUAA0EAzPoxFj8j1uUIEGGsViUXkAH9/uoZuCBy9PtHEJcjSkya 22 | TlKHfzEaEsA8pfmHax3gMgYcIbbWuYVR/HTUEnog9Q== 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /spec/grocer/extensions/deep_symbolize_keys_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grocer/extensions/deep_symbolize_keys' 2 | 3 | describe Grocer::Extensions::DeepSymbolizeKeys do 4 | let(:nested_strings) { { 'a' => { 'b' => { 'c' => 3 } } } } 5 | let(:nested_symbols) { { :a => { :b => { :c => 3 } } } } 6 | let(:nested_mixed) { { 'a' => { :b => { 'c' => 3 } } } } 7 | let(:nested_fixnums) { { 0 => { 1 => { 2 => 3} } } } 8 | let(:nested_illegal_symbols) { { [] => { [] => 3} } } 9 | before do 10 | nested_symbols.extend described_class 11 | nested_strings.extend described_class 12 | nested_mixed.extend described_class 13 | nested_fixnums.extend described_class 14 | nested_illegal_symbols.extend described_class 15 | end 16 | 17 | it 'does not change nested symbols' do 18 | expect(nested_symbols.deep_symbolize_keys).to eq(nested_symbols) 19 | end 20 | 21 | it 'symbolizes nested strings' do 22 | expect(nested_strings.deep_symbolize_keys).to eq(nested_symbols) 23 | end 24 | 25 | it 'symbolizes a mix of nested strings and symbols' do 26 | expect(nested_mixed.deep_symbolize_keys).to eq(nested_symbols) 27 | end 28 | 29 | it 'preserves fixnum keys' do 30 | expect(nested_fixnums.deep_symbolize_keys).to eq(nested_fixnums) 31 | end 32 | 33 | it 'preserves keys that cannot be symbolized' do 34 | expect(nested_illegal_symbols.deep_symbolize_keys).to eq(nested_illegal_symbols) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/grocer/ssl_connection.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'openssl' 3 | require 'forwardable' 4 | require 'stringio' 5 | 6 | module Grocer 7 | class SSLConnection 8 | extend Forwardable 9 | def_delegators :@ssl, :write, :read 10 | 11 | attr_accessor :certificate, :passphrase, :gateway, :port 12 | 13 | def initialize(options = {}) 14 | options.each do |key, val| 15 | send("#{key}=", val) 16 | end 17 | end 18 | 19 | def connected? 20 | !@ssl.nil? 21 | end 22 | 23 | def connect 24 | context = OpenSSL::SSL::SSLContext.new 25 | 26 | if certificate 27 | 28 | if certificate.respond_to?(:read) 29 | cert_data = certificate.read 30 | certificate.rewind if certificate.respond_to?(:rewind) 31 | else 32 | cert_data = File.read(certificate) 33 | end 34 | 35 | context.key = OpenSSL::PKey::RSA.new(cert_data, passphrase) 36 | context.cert = OpenSSL::X509::Certificate.new(cert_data) 37 | end 38 | 39 | @sock = TCPSocket.new(gateway, port) 40 | @sock.setsockopt Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true 41 | @ssl = OpenSSL::SSL::SSLSocket.new(@sock, context) 42 | @ssl.sync = true 43 | @ssl.connect 44 | end 45 | 46 | def disconnect 47 | @ssl.close if @ssl 48 | @ssl = nil 49 | 50 | @sock.close if @sock 51 | @sock = nil 52 | end 53 | 54 | def reconnect 55 | disconnect 56 | connect 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/grocer/mobile_device_management_notification_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/mobile_device_management_notification' 3 | require 'grocer/shared_examples_for_notifications' 4 | 5 | describe Grocer::MobileDeviceManagementNotification do 6 | let(:payload_from_bytes) { notification.to_bytes[45..-1] } 7 | let(:payload_dictionary_from_bytes) { JSON.parse(payload_from_bytes, symbolize_names: true) } 8 | 9 | describe 'binary format' do 10 | let(:payload_options) { Hash[:push_magic, "00000000-1111-3333-4444-555555555555"] } 11 | 12 | include_examples 'a notification' 13 | 14 | it 'should have a single key in the payload' do 15 | expect(payload_dictionary_from_bytes.length).to eq(1) 16 | end 17 | 18 | it 'does require a mdm payload' do 19 | expect(payload_dictionary_from_bytes[:'mdm']).to_not be_nil 20 | end 21 | end 22 | 23 | describe 'no push magic specified' do 24 | let(:notification) { Grocer::MobileDeviceManagementNotification.new(device_token: "token", alert: "Moo") } 25 | 26 | it 'should raise a payload error' do 27 | -> { notification.to_bytes }.should raise_error(Grocer::NoPayloadError) 28 | end 29 | end 30 | 31 | describe 'aps object is included in the payload' do 32 | let(:notification) { Grocer::MobileDeviceManagementNotification.new(device_token: "token", push_magic: "00000000-1111-3333-4444-555555555555", alert: "test") } 33 | 34 | it 'should raise a format error' do 35 | -> { payload_dictionary_from_bytes }.should raise_error(Grocer::InvalidFormatError) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /grocer.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/grocer/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ['Andy Lindeman', 'Steven Harman', 'Patrick Van Stee'] 6 | gem.email = ['alindeman@gmail.com', 'steveharman@gmail.com', 'patrickvanstee@gmail.com'] 7 | gem.summary = %q{Pushing Apple notifications since 2012.} 8 | gem.description = <<-DESC 9 | Grocer interfaces with the Apple Push 10 | Notification Service to send push 11 | notifications to iOS devices and collect 12 | notification feedback via the Feedback 13 | Service. 14 | 15 | There are other gems out there to do this, 16 | but Grocer plans to be the cleanest, most 17 | extensible, and friendliest. 18 | DESC 19 | gem.homepage = 'https://github.com/grocer/grocer' 20 | gem.license = 'MIT' 21 | 22 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 23 | gem.files = `git ls-files`.split("\n") 24 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | gem.name = "grocer" 26 | gem.require_paths = ["lib"] 27 | gem.version = Grocer::VERSION 28 | 29 | gem.add_development_dependency 'rspec', '~> 2.11' 30 | gem.add_development_dependency 'pry', '~> 0.9.8' 31 | gem.add_development_dependency 'mocha' 32 | gem.add_development_dependency 'bourne' 33 | gem.add_development_dependency 'rake' 34 | end 35 | -------------------------------------------------------------------------------- /spec/grocer/shared_examples_for_notifications.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'a notification' do 2 | let(:notification) { described_class.new(payload_options) } 3 | 4 | subject(:bytes) { notification.to_bytes } 5 | 6 | it 'sets the command byte to 1' do 7 | expect(bytes[0]).to eq("\x01") 8 | end 9 | 10 | it 'defaults the identifer to 0' do 11 | expect(bytes[1...5]).to eq("\x00\x00\x00\x00") 12 | end 13 | 14 | it 'allows the identifier to be set' do 15 | notification.identifier = 1234 16 | expect(bytes[1...5]).to eq([1234].pack('N')) 17 | end 18 | 19 | it 'defaults expiry to zero' do 20 | expect(bytes[5...9]).to eq("\x00\x00\x00\x00") 21 | end 22 | 23 | it 'allows the expiry to be set' do 24 | expiry = notification.expiry = Time.utc(2013, 3, 24, 12, 34, 56) 25 | expect(bytes[5...9]).to eq([expiry.to_i].pack('N')) 26 | end 27 | 28 | it 'encodes the device token length as 32' do 29 | expect(bytes[9...11]).to eq("\x00\x20") 30 | end 31 | 32 | it 'encodes the device token as a 256-bit integer' do 33 | token = notification.device_token = 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2' 34 | expect(bytes[11...43]).to eq(['fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2'].pack('H*')) 35 | end 36 | 37 | it 'as a convenience, flattens the device token to remove spaces' do 38 | token = notification.device_token = 'fe15 a27d 5df3c3 4778defb1f4f3880265cc52c0c047682223be59fb68500a9a2' 39 | expect(bytes[11...43]).to eq(['fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2'].pack('H*')) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/grocer/ssl_server.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'socket' 3 | require 'thread' 4 | require 'grocer/extensions/deep_symbolize_keys' 5 | 6 | module Grocer 7 | class SSLServer 8 | attr_accessor :port 9 | 10 | def initialize(options = {}) 11 | options.extend Extensions::DeepSymbolizeKeys 12 | options = defaults.merge(options.deep_symbolize_keys) 13 | options.each { |k, v| send("#{k}=", v) } 14 | end 15 | 16 | def defaults 17 | { 18 | port: 2195 19 | } 20 | end 21 | 22 | def accept 23 | while socket = ssl_socket.accept 24 | yield socket if block_given? 25 | end 26 | end 27 | 28 | def close 29 | if @ssl_socket && !@ssl_socket.closed? 30 | begin 31 | @ssl_socket.shutdown 32 | rescue Errno::ENOTCONN 33 | # Some platforms raise this if the socket is not connected. Not sure 34 | # how to avoid it. 35 | end 36 | 37 | @ssl_socket.close 38 | end 39 | 40 | @ssl_socket = nil 41 | @socket = nil 42 | end 43 | 44 | private 45 | 46 | def ssl_socket 47 | @ssl_socket ||= OpenSSL::SSL::SSLServer.new(socket, context) 48 | end 49 | 50 | def socket 51 | @socket ||= TCPServer.new('127.0.0.1', port) 52 | end 53 | 54 | def context 55 | @context ||= OpenSSL::SSL::SSLContext.new.tap do |c| 56 | c.cert = OpenSSL::X509::Certificate.new(File.read(crt_path)) 57 | c.key = OpenSSL::PKey::RSA.new(File.read(key_path)) 58 | end 59 | end 60 | 61 | def crt_path 62 | File.join(File.dirname(__FILE__), "test", "server.crt") 63 | end 64 | 65 | def key_path 66 | File.join(File.dirname(__FILE__), "test", "server.key") 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/grocer/connection.rb: -------------------------------------------------------------------------------- 1 | require 'grocer' 2 | require 'grocer/ssl_connection' 3 | 4 | module Grocer 5 | class Connection 6 | attr_reader :certificate, :passphrase, :gateway, :port, :retries 7 | 8 | def initialize(options = {}) 9 | @certificate = options.fetch(:certificate) { nil } 10 | @passphrase = options.fetch(:passphrase) { nil } 11 | @gateway = options.fetch(:gateway) { fail NoGatewayError } 12 | @port = options.fetch(:port) { fail NoPortError } 13 | @retries = options.fetch(:retries) { 3 } 14 | end 15 | 16 | def read(size = nil, buf = nil) 17 | with_connection do 18 | ssl.read(size, buf) 19 | end 20 | end 21 | 22 | def write(content) 23 | with_connection do 24 | ssl.write(content) 25 | end 26 | end 27 | 28 | def connect 29 | ssl.connect unless ssl.connected? 30 | end 31 | 32 | private 33 | 34 | def ssl 35 | @ssl_connection ||= build_connection 36 | end 37 | 38 | def build_connection 39 | Grocer::SSLConnection.new(certificate: certificate, 40 | passphrase: passphrase, 41 | gateway: gateway, 42 | port: port) 43 | end 44 | 45 | def destroy_connection 46 | return unless @ssl_connection 47 | 48 | @ssl_connection.disconnect rescue nil 49 | @ssl_connection = nil 50 | end 51 | 52 | def with_connection 53 | attempts = 1 54 | begin 55 | connect 56 | yield 57 | rescue => e 58 | if e.class == OpenSSL::SSL::SSLError && e.message =~ /certificate expired/i 59 | e.extend(CertificateExpiredError) 60 | raise 61 | end 62 | 63 | raise unless attempts < retries 64 | 65 | destroy_connection 66 | attempts += 1 67 | retry 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/grocer/ssl_server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/ssl_server' 3 | 4 | describe Grocer::SSLServer do 5 | subject { described_class.new(options) } 6 | let(:options) { { port: 12345 } } 7 | let(:mock_server) { stub_everything } 8 | let(:mock_ssl_server) { stub_everything } 9 | let(:mock_client) { stub_everything } 10 | 11 | before do 12 | TCPServer.stubs(:new).returns(mock_server) 13 | OpenSSL::SSL::SSLServer.stubs(:new).returns(mock_ssl_server) 14 | 15 | mock_ssl_server.stubs(:accept).returns(mock_client).then.returns(nil) 16 | end 17 | 18 | it "is constructed with a port option" do 19 | expect(subject.port).to eq(12345) 20 | end 21 | 22 | 23 | describe "#accept" do 24 | it "accepts client connections, yielding the client socket" do 25 | clients = [] 26 | subject.accept { |c| clients << c } 27 | 28 | expect(clients).to eq([mock_client]) 29 | end 30 | end 31 | 32 | describe "#close" do 33 | it "shutdowns the SSL socket" do 34 | mock_ssl_server.expects(:shutdown) 35 | 36 | # Emulate opening the socket 37 | subject.accept 38 | mock_ssl_server.stubs(:closed? => false) 39 | 40 | subject.close 41 | end 42 | 43 | it "ignores Errno::ENOTCONN errors that might be raised when shutting down the socket" do 44 | mock_ssl_server.stubs(:shutdown).raises(Errno::ENOTCONN) 45 | 46 | # Emulate opening the socket 47 | subject.accept 48 | mock_ssl_server.stubs(:closed? => false) 49 | 50 | subject.close 51 | end 52 | 53 | it "closes the SSL socket" do 54 | mock_ssl_server.expects(:close) 55 | 56 | # Emulate opening the socket 57 | subject.accept 58 | mock_ssl_server.stubs(:closed? => false) 59 | 60 | subject.close 61 | end 62 | 63 | it "is a no-op if the server has not been started" do 64 | mock_server.expects(:close).never 65 | mock_ssl_server.expects(:close).never 66 | 67 | subject.close 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Unreleased 4 | 5 | ## 0.4.1 6 | 7 | * Fix `Grocer::NotificationReader`, ensuring it sanitizes incoming `aps` 8 | payload keys before building a new `Grocer::Notification`. 9 | 10 | ## 0.4.0 11 | 12 | * Add support for `content-available` for background fetches (iOS 7) for all 13 | types of Notifications ([Zack Parker](https://github.com/somezack)) 14 | 15 | ## 0.3.4 16 | 17 | * Add `Grocer::MobileDeviceManagementNotification` to send PushMagic tokens. 18 | ([Osis](https://github.com/Osis)) 19 | * Fix `Grocer::NewsstandNotification` payload 20 | ([khelll](https://github.com/khelll)) 21 | 22 | ## 0.3.3 23 | 24 | * Notifications that only include a custom payload are now valid. 25 | ([overlycommonname](https://github.com/overlycommonname)) 26 | 27 | ## 0.3.2 28 | 29 | * Validate the size of the payload before sending a notification 30 | 31 | ## 0.3.1 32 | 33 | * Move repo to the Grocer organization. 34 | * Automatically require `passbook_notification` when `require 'grocer'`. 35 | ([lgleasain](https://github.com/lgleasain)) 36 | 37 | ## 0.3.0 38 | 39 | * Add `Grocer::PassbookNotification` for sending, well... Passbook 40 | notifications. This kind of notification requires no payload. 41 | * Determining current environment is case-insensitive ([Oriol 42 | Gual](https://github.com/oriolgual)) 43 | 44 | ## 0.2.0 45 | 46 | * Don't retry connection when the certificate has expired. ([Kyle 47 | Drake](https://github.com/kyledrake) and [Jesse 48 | Storimer](https://github.com/jstorimer)) 49 | 50 | ## 0.1.1 51 | 52 | * Warn that `jruby-openssl` is needed on JRuby platform. ([Kyle 53 | Drake](https://github.com/kyledrake)) 54 | 55 | ## 0.1.0 56 | 57 | * Supports non-ASCII characters in notifications 58 | * Enables socket keepalive option on APNS client sockets ([Kyle 59 | Drake](https://github.com/kyledrake)) 60 | * Certificate can be any object that responds to #read ([Kyle 61 | Drake](https://github.com/kyledrake)) 62 | 63 | ## 0.0.13 64 | 65 | * Fixes a bug where closing a Grocer.server could result in an 66 | `Errno::ENOTCONN` being raised (seems isolated to OS X). 67 | -------------------------------------------------------------------------------- /spec/grocer/feedback_connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/feedback_connection' 3 | 4 | describe Grocer::FeedbackConnection do 5 | subject { described_class.new(options) } 6 | let(:options) { { certificate: '/path/to/cert.pem' } } 7 | let(:connection) { stub('Connection') } 8 | 9 | it 'delegates reading to the Connection' do 10 | Grocer::Connection.any_instance.expects(:read).with(42, 'lolIO') 11 | subject.read(42, 'lolIO') 12 | end 13 | 14 | it 'delegates writing to the Connection' do 15 | Grocer::Connection.any_instance.expects(:write).with('Note Eye Fly') 16 | subject.write('Note Eye Fly') 17 | end 18 | 19 | it 'can be initialized with a certificate' do 20 | expect(subject.certificate).to eq('/path/to/cert.pem') 21 | end 22 | 23 | it 'can be initialized with a passphrase' do 24 | options[:passphrase] = 'open sesame' 25 | expect(subject.passphrase).to eq('open sesame') 26 | end 27 | 28 | it 'defaults to Apple feedback gateway in production environment' do 29 | Grocer.stubs(:env).returns('production') 30 | expect(subject.gateway).to eq('feedback.push.apple.com') 31 | end 32 | 33 | it 'defaults to the sandboxed Apple feedback gateway in development environment' do 34 | Grocer.stubs(:env).returns('development') 35 | expect(subject.gateway).to eq('feedback.sandbox.push.apple.com') 36 | end 37 | 38 | it 'defaults to the sandboxed Apple feedback gateway in test environment' do 39 | Grocer.stubs(:env).returns('test') 40 | expect(subject.gateway).to eq('feedback.sandbox.push.apple.com') 41 | end 42 | 43 | it 'defaults to the sandboxed Apple feedback gateway for other random values' do 44 | Grocer.stubs(:env).returns('random') 45 | expect(subject.gateway).to eq('feedback.sandbox.push.apple.com') 46 | end 47 | 48 | it 'can be initialized with a gateway' do 49 | options[:gateway] = 'gateway.example.com' 50 | expect(subject.gateway).to eq('gateway.example.com') 51 | end 52 | 53 | it 'defaults to 2196 as the port' do 54 | expect(subject.port).to eq(2196) 55 | end 56 | 57 | it 'can be initialized with a port' do 58 | options[:port] = 443 59 | expect(subject.port).to eq(443) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/grocer/push_connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/push_connection' 3 | 4 | describe Grocer::PushConnection do 5 | subject { described_class.new(options) } 6 | let(:options) { { certificate: '/path/to/cert.pem' } } 7 | let(:connection) { stub('Connection') } 8 | 9 | it 'delegates reading to the Connection' do 10 | Grocer::Connection.any_instance.expects(:read).with(42, 'lolIO') 11 | subject.read(42, 'lolIO') 12 | end 13 | 14 | it 'delegates writing to the Connection' do 15 | Grocer::Connection.any_instance.expects(:write).with('Note Eye Fly') 16 | subject.write('Note Eye Fly') 17 | end 18 | 19 | it 'can be initialized with a certificate' do 20 | expect(subject.certificate).to eq('/path/to/cert.pem') 21 | end 22 | 23 | it 'can be initialized with a passphrase' do 24 | options[:passphrase] = 'open sesame' 25 | expect(subject.passphrase).to eq('open sesame') 26 | end 27 | 28 | it 'defaults to Apple push gateway in production environment' do 29 | Grocer.stubs(:env).returns('production') 30 | expect(subject.gateway).to eq('gateway.push.apple.com') 31 | end 32 | 33 | it 'defaults to the sandboxed Apple push gateway in development environment' do 34 | Grocer.stubs(:env).returns('development') 35 | expect(subject.gateway).to eq('gateway.sandbox.push.apple.com') 36 | end 37 | 38 | it 'defaults to the localhost Apple push gateway in test environment' do 39 | Grocer.stubs(:env).returns('test') 40 | expect(subject.gateway).to eq('127.0.0.1') 41 | end 42 | 43 | it 'uses a case-insensitive environment to determine the push gateway' do 44 | Grocer.stubs(:env).returns('TEST') 45 | expect(subject.gateway).to eq('127.0.0.1') 46 | end 47 | 48 | it 'defaults to the sandboxed Apple push gateway for other random values' do 49 | Grocer.stubs(:env).returns('random') 50 | expect(subject.gateway).to eq('gateway.sandbox.push.apple.com') 51 | end 52 | 53 | it 'can be initialized with a gateway' do 54 | options[:gateway] = 'gateway.example.com' 55 | expect(subject.gateway).to eq('gateway.example.com') 56 | end 57 | 58 | it 'defaults to 2195 as the port' do 59 | expect(subject.port).to eq(2195) 60 | end 61 | 62 | it 'can be initialized with a port' do 63 | options[:port] = 443 64 | expect(subject.port).to eq(443) 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /spec/grocer/notification_reader_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'stringio' 3 | require 'grocer/notification_reader' 4 | 5 | describe Grocer::NotificationReader do 6 | let(:io) { StringIO.new } 7 | subject { described_class.new(io) } 8 | 9 | context "Version 1 messages" do 10 | it "reads identifier" do 11 | io.write(Grocer::Notification.new(identifier: 1234, alert: "Foo").to_bytes) 12 | io.rewind 13 | 14 | notification = subject.first 15 | expect(notification.identifier).to eq(1234) 16 | end 17 | 18 | it "reads expiry" do 19 | io.write(Grocer::Notification.new(expiry: Time.utc(2013, 3, 24), alert: "Foo").to_bytes) 20 | io.rewind 21 | 22 | notification = subject.first 23 | expect(notification.expiry).to eq(Time.utc(2013, 3, 24)) 24 | end 25 | 26 | it "reads device token" do 27 | io.write(Grocer::Notification.new(device_token: 'fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2', alert: "Foo").to_bytes) 28 | io.rewind 29 | 30 | notification = subject.first 31 | expect(notification.device_token).to eq('fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2') 32 | end 33 | 34 | it "reads alert" do 35 | io.write(Grocer::Notification.new(alert: "Foo").to_bytes) 36 | io.rewind 37 | 38 | notification = subject.first 39 | expect(notification.alert).to eq("Foo") 40 | end 41 | 42 | it "reads badge" do 43 | io.write(Grocer::Notification.new(alert: "Foo", badge: 5).to_bytes) 44 | io.rewind 45 | 46 | notification = subject.first 47 | expect(notification.badge).to eq(5) 48 | end 49 | 50 | it "reads sound" do 51 | io.write(Grocer::Notification.new(alert: "Foo", sound: "foo.aiff").to_bytes) 52 | io.rewind 53 | 54 | notification = subject.first 55 | expect(notification.sound).to eq("foo.aiff") 56 | end 57 | 58 | it "reads custom attributes" do 59 | io.write(Grocer::Notification.new(alert: "Foo", custom: { foo: "bar" }).to_bytes) 60 | io.rewind 61 | 62 | notification = subject.first 63 | expect(notification.custom).to eq({ foo: "bar" }) 64 | end 65 | 66 | it "reads content-available" do 67 | io.write(Grocer::Notification.new(alert: "Foo", content_available: true).to_bytes) 68 | io.rewind 69 | 70 | notification = subject.first 71 | expect(notification.content_available).to be_true 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/grocer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer' 3 | 4 | describe Grocer do 5 | subject { described_class } 6 | 7 | describe '.env' do 8 | let(:environment) { nil } 9 | before do 10 | ENV.stubs(:[]).with('RAILS_ENV').returns(environment) 11 | ENV.stubs(:[]).with('RACK_ENV').returns(environment) 12 | end 13 | 14 | it 'defaults to development' do 15 | expect(subject.env).to eq('development') 16 | end 17 | 18 | it 'reads RAILS_ENV from ENV' do 19 | ENV.stubs(:[]).with('RAILS_ENV').returns('staging') 20 | expect(subject.env).to eq('staging') 21 | end 22 | 23 | it 'reads RACK_ENV from ENV' do 24 | ENV.stubs(:[]).with('RACK_ENV').returns('staging') 25 | expect(subject.env).to eq('staging') 26 | end 27 | end 28 | 29 | describe 'API facade' do 30 | let(:connection_options) { stub('connection options') } 31 | 32 | describe '.pusher' do 33 | before do 34 | Grocer::PushConnection.stubs(:new).returns(stub('PushConnection')) 35 | end 36 | 37 | it 'gets a Pusher' do 38 | expect(subject.pusher(connection_options)).to be_a Grocer::Pusher 39 | end 40 | 41 | it 'passes the connection options on to the underlying Connection' do 42 | subject.pusher(connection_options) 43 | Grocer::PushConnection.should have_received(:new).with(connection_options) 44 | end 45 | end 46 | 47 | describe '.feedback' do 48 | before do 49 | Grocer::FeedbackConnection.stubs(:new).returns(stub('FeedbackConnection')) 50 | end 51 | 52 | it 'gets Feedback' do 53 | expect(subject.feedback(connection_options)).to be_a Grocer::Feedback 54 | end 55 | 56 | it 'passes the connection options on to the underlying Connection' do 57 | subject.feedback(connection_options) 58 | Grocer::FeedbackConnection.should have_received(:new).with(connection_options) 59 | end 60 | end 61 | 62 | describe '.server' do 63 | let(:ssl_server) { stub_everything('SSLServer') } 64 | before do 65 | Grocer::SSLServer.stubs(:new).returns(ssl_server) 66 | end 67 | 68 | it 'gets Server' do 69 | expect(subject.server(connection_options)).to be_a Grocer::Server 70 | end 71 | 72 | it 'passes the connection options on to the underlying SSLServer' do 73 | subject.server(connection_options) 74 | Grocer::SSLServer.should have_received(:new).with(connection_options) 75 | end 76 | end 77 | 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/grocer/ssl_connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/ssl_connection' 3 | 4 | describe Grocer::SSLConnection do 5 | def stub_sockets 6 | TCPSocket.stubs(:new).returns(mock_socket) 7 | OpenSSL::SSL::SSLSocket.stubs(:new).returns(mock_ssl) 8 | end 9 | 10 | def stub_certificate 11 | example_data = File.read(File.dirname(__FILE__) + '/../fixtures/example.pem') 12 | File.stubs(:read).with(connection_options[:certificate]).returns(example_data) 13 | end 14 | 15 | let(:mock_socket) { stub_everything } 16 | let(:mock_ssl) { stub_everything } 17 | 18 | let(:connection_options) { 19 | { 20 | certificate: '/path/to/cert.pem', 21 | passphrase: 'abc123', 22 | gateway: 'gateway.push.example.com', 23 | port: 1234 24 | } 25 | } 26 | 27 | describe 'configuration with pre-read certificate' do 28 | before do 29 | stub_certificate 30 | end 31 | 32 | subject { 33 | string_io = File.read(connection_options[:certificate]) 34 | described_class.new(connection_options.merge(certificate: string_io)) 35 | } 36 | 37 | it 'is initialized with a certificate IO' do 38 | expect(subject.certificate).to eq(File.read(connection_options[:certificate])) 39 | end 40 | end 41 | 42 | subject { described_class.new(connection_options) } 43 | 44 | describe 'configuration' do 45 | it 'is initialized with a certificate' do 46 | expect(subject.certificate).to eq(connection_options[:certificate]) 47 | end 48 | 49 | it 'is initialized with a passphrase' do 50 | expect(subject.passphrase).to eq(connection_options[:passphrase]) 51 | end 52 | 53 | it 'is initialized with a gateway' do 54 | expect(subject.gateway).to eq(connection_options[:gateway]) 55 | end 56 | 57 | it 'is initialized with a port' do 58 | expect(subject.port).to eq(connection_options[:port]) 59 | end 60 | end 61 | 62 | describe 'connecting' do 63 | before do 64 | stub_sockets 65 | stub_certificate 66 | end 67 | 68 | it 'sets up an socket connection' do 69 | subject.connect 70 | TCPSocket.should have_received(:new).with(connection_options[:gateway], 71 | connection_options[:port]) 72 | end 73 | 74 | it 'sets up an SSL connection' do 75 | subject.connect 76 | OpenSSL::SSL::SSLSocket.should have_received(:new).with(mock_socket, anything) 77 | end 78 | end 79 | 80 | describe 'writing data' do 81 | before do 82 | stub_sockets 83 | stub_certificate 84 | end 85 | 86 | it 'writes data to the SSL connection' do 87 | subject.connect 88 | subject.write('abc123') 89 | 90 | mock_ssl.should have_received(:write).with('abc123') 91 | end 92 | end 93 | 94 | describe 'reading data' do 95 | before do 96 | stub_sockets 97 | stub_certificate 98 | end 99 | 100 | it 'reads data from the SSL connection' do 101 | subject.connect 102 | subject.read(42) 103 | 104 | mock_ssl.should have_received(:read).with(42) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/grocer/notification_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'spec_helper' 3 | require 'grocer/notification' 4 | require 'grocer/shared_examples_for_notifications' 5 | 6 | describe Grocer::Notification do 7 | describe 'binary format' do 8 | let(:payload_options) { { alert: 'hi', badge: 2, sound: 'siren.aiff' } } 9 | let(:payload) { payload_hash(notification) } 10 | 11 | include_examples 'a notification' 12 | 13 | it 'encodes alert as part of the payload' do 14 | notification.alert = 'Hello World!' 15 | expect(payload[:aps][:alert]).to eq('Hello World!') 16 | end 17 | 18 | it 'encodes badge as part of the payload' do 19 | notification.badge = 42 20 | expect(payload[:aps][:badge]).to eq(42) 21 | end 22 | 23 | it 'encodes sound as part of the payload' do 24 | notification.sound = 'siren.aiff' 25 | expect(payload[:aps][:sound]).to eq('siren.aiff') 26 | end 27 | 28 | it 'encodes custom payload attributes' do 29 | notification.custom = { :foo => 'bar' } 30 | expect(payload[:foo]).to eq('bar') 31 | end 32 | 33 | it 'encodes UTF-8 characters' do 34 | notification.alert = '私' 35 | expect(payload[:aps][:alert].force_encoding("UTF-8")).to eq('私') 36 | end 37 | 38 | it 'encodes the payload length' do 39 | notification.alert = 'Hello World!' 40 | expect(bytes[43...45]).to eq([payload_bytes(notification).bytesize].pack('n')) 41 | end 42 | 43 | it 'encodes the payload length correctly for multibyte UTF-8 strings' do 44 | notification.alert = '私' 45 | expect(bytes[43...45]).to eq([payload_bytes(notification).bytesize].pack('n')) 46 | end 47 | 48 | it 'encodes content-available as part of the payload if a truthy value is passed' do 49 | notification.content_available = :foo 50 | expect(payload[:aps][:'content-available']).to eq(1) 51 | end 52 | 53 | it 'does not encode content-available as part of the payload if a falsy value is passed' do 54 | notification.content_available = false 55 | expect(payload[:aps]).to_not have_key(:'content-available') 56 | end 57 | 58 | it "is valid" do 59 | expect(notification.valid?).to be_true 60 | end 61 | 62 | context 'missing payload' do 63 | let(:payload_options) { Hash.new } 64 | 65 | it 'raises an error when none of alert, badge, or custom are specified' do 66 | -> { notification.to_bytes }.should raise_error(Grocer::NoPayloadError) 67 | end 68 | 69 | it 'is not valid' do 70 | expect(notification.valid?).to be_false 71 | end 72 | 73 | [{alert: 'hi'}, {badge: 1}, {custom: {a: 'b'}}].each do |payload| 74 | context "when #{payload.keys.first} exists, but not any other payload keys" do 75 | let(:payload_options) { payload } 76 | 77 | it 'does not raise an error' do 78 | -> { notification.to_bytes }.should_not raise_error 79 | end 80 | end 81 | end 82 | end 83 | 84 | context 'oversized payload' do 85 | let(:payload_options) { { alert: 'a' * (Grocer::Notification::MAX_PAYLOAD_SIZE + 1) } } 86 | 87 | it 'raises an error when the size of the payload in bytes is too large' do 88 | -> { notification.to_bytes }.should raise_error(Grocer::PayloadTooLargeError) 89 | end 90 | 91 | it 'is not valid' do 92 | expect(notification.valid?).to be_false 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/grocer/notification.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Grocer 4 | # Public: An object used to send notifications to APNS. 5 | class Notification 6 | MAX_PAYLOAD_SIZE = 256 7 | CONTENT_AVAILABLE_INDICATOR = 1 8 | 9 | attr_accessor :identifier, :expiry, :device_token 10 | attr_reader :alert, :badge, :custom, :sound, :content_available 11 | 12 | # Public: Initialize a new Grocer::Notification. You must specify at least an `alert` or `badge`. 13 | # 14 | # payload - The Hash of notification parameters and payload to be sent to APNS.: 15 | # :device_token - The String representing to device token sent to APNS. 16 | # :alert - The String or Hash to be sent as the alert portion of the payload. (optional) 17 | # :badge - The Integer to be sent as the badge portion of the payload. (optional) 18 | # :sound - The String representing the sound portion of the payload. (optional) 19 | # :expiry - The Integer representing UNIX epoch date sent to APNS as the notification expiry. (default: 0) 20 | # :identifier - The arbitrary Integer sent to APNS to uniquely this notification. (default: 0) 21 | # :content_available - The truthy or falsy value indicating the availability of new content for background fetch. (optional) 22 | def initialize(payload = {}) 23 | @identifier = 0 24 | 25 | payload.each do |key, val| 26 | send("#{key}=", val) 27 | end 28 | end 29 | 30 | def to_bytes 31 | validate_payload 32 | 33 | [ 34 | 1, 35 | identifier, 36 | expiry_epoch_time, 37 | device_token_length, 38 | sanitized_device_token, 39 | encoded_payload.bytesize, 40 | encoded_payload 41 | ].pack('CNNnH64nA*') 42 | end 43 | 44 | def alert=(alert) 45 | @alert = alert 46 | @encoded_payload = nil 47 | end 48 | 49 | def badge=(badge) 50 | @badge = badge 51 | @encoded_payload = nil 52 | end 53 | 54 | def custom=(custom) 55 | @custom = custom 56 | @encoded_payload = nil 57 | end 58 | 59 | def sound=(sound) 60 | @sound = sound 61 | @encoded_payload = nil 62 | end 63 | 64 | def content_available=(content_available) 65 | @content_available = CONTENT_AVAILABLE_INDICATOR if content_available 66 | @encoded_payload = nil 67 | end 68 | 69 | def validate_payload 70 | fail NoPayloadError unless alert || badge || custom 71 | fail PayloadTooLargeError if payload_too_large? 72 | true 73 | end 74 | 75 | def valid? 76 | validate_payload rescue false 77 | end 78 | 79 | private 80 | 81 | def encoded_payload 82 | @encoded_payload ||= JSON.dump(payload_hash) 83 | end 84 | 85 | def payload_hash 86 | aps_hash = { } 87 | aps_hash[:alert] = alert if alert 88 | aps_hash[:badge] = badge if badge 89 | aps_hash[:sound] = sound if sound 90 | aps_hash[:'content-available'] = content_available if content_available 91 | 92 | { aps: aps_hash }.merge(custom || { }) 93 | end 94 | 95 | def payload_too_large? 96 | encoded_payload.bytesize > MAX_PAYLOAD_SIZE 97 | end 98 | 99 | def expiry_epoch_time 100 | expiry.to_i 101 | end 102 | 103 | def sanitized_device_token 104 | device_token.tr(' ', '') if device_token 105 | end 106 | 107 | def device_token_length 108 | 32 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/grocer/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'grocer/connection' 3 | 4 | describe Grocer::Connection do 5 | subject { described_class.new(connection_options) } 6 | let(:connection_options) { { certificate: '/path/to/cert.pem', 7 | gateway: 'push.example.com', 8 | port: 443 } } 9 | let(:ssl) { stub_everything('SSLConnection') } 10 | before do 11 | Grocer::SSLConnection.stubs(:new).returns(ssl) 12 | end 13 | 14 | it 'can be initialized with a certificate' do 15 | expect(subject.certificate).to eq('/path/to/cert.pem') 16 | end 17 | 18 | it 'defaults to an empty passphrase' do 19 | expect(subject.passphrase).to be_nil 20 | end 21 | 22 | it 'can be initialized with a passphrase' do 23 | connection_options[:passphrase] = 'new england clam chowder' 24 | expect(subject.passphrase).to eq('new england clam chowder') 25 | end 26 | 27 | it 'defaults to 3 retries' do 28 | expect(subject.retries).to eq(3) 29 | end 30 | 31 | it 'can be initialized with a number of retries' do 32 | connection_options[:retries] = 2 33 | expect(subject.retries).to eq(2) 34 | end 35 | 36 | it 'requires a gateway' do 37 | connection_options.delete(:gateway) 38 | -> { described_class.new(connection_options) }.should raise_error(Grocer::NoGatewayError) 39 | end 40 | 41 | it 'can be initialized with a gateway' do 42 | expect(subject.gateway).to eq('push.example.com') 43 | end 44 | 45 | it 'requires a port' do 46 | connection_options.delete(:port) 47 | -> { described_class.new(connection_options) }.should raise_error(Grocer::NoPortError) 48 | end 49 | 50 | it 'can be initialized with a port' do 51 | expect(subject.port).to eq(443) 52 | end 53 | 54 | it 'can open the connection to the apple push notification service' do 55 | subject.connect 56 | ssl.should have_received(:connect) 57 | end 58 | 59 | it 'raises CertificateExpiredError for OpenSSL::SSL::SSLError with /certificate expired/i message' do 60 | ssl.stubs(:write).raises(OpenSSL::SSL::SSLError.new('certificate expired')) 61 | -> {subject.write('abc123')}.should raise_error(Grocer::CertificateExpiredError) 62 | end 63 | 64 | context 'an open SSLConnection' do 65 | before do 66 | ssl.stubs(:connected?).returns(true) 67 | end 68 | 69 | it '#write delegates to open SSLConnection' do 70 | subject.write('Apples to Oranges') 71 | ssl.should have_received(:write).with('Apples to Oranges') 72 | end 73 | 74 | it '#read delegates to open SSLConnection' do 75 | subject.read(42, 'IO') 76 | ssl.should have_received(:read).with(42, 'IO') 77 | end 78 | end 79 | 80 | context 'a closed SSLConnection' do 81 | before do 82 | ssl.stubs(:connected?).returns(false) 83 | end 84 | 85 | it '#write connects SSLConnection and delegates to it' do 86 | subject.write('Apples to Oranges') 87 | ssl.should have_received(:connect) 88 | ssl.should have_received(:write).with('Apples to Oranges') 89 | end 90 | 91 | it '#read connects SSLConnection delegates to open SSLConnection' do 92 | subject.read(42, 'IO') 93 | ssl.should have_received(:connect) 94 | ssl.should have_received(:read).with(42, 'IO') 95 | end 96 | end 97 | 98 | describe 'retries' do 99 | [SocketError, OpenSSL::SSL::SSLError, Errno::EPIPE].each do |error| 100 | it "retries #read in the case of an #{error}" do 101 | ssl.stubs(:read).raises(error).then.returns(42) 102 | subject.read 103 | end 104 | 105 | it "retries #write in the case of an #{error}" do 106 | ssl.stubs(:write).raises(error).then.returns(42) 107 | subject.write('abc123') 108 | end 109 | 110 | it 'raises the error if none of the retries work' do 111 | connection_options[:retries] = 1 112 | ssl.stubs(:read).raises(error).then.raises(error) 113 | -> { subject.read }.should raise_error(error) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grocer 2 | 3 | [![Build Status](https://api.travis-ci.org/grocer/grocer.png?branch=master)](https://travis-ci.org/grocer/grocer) 4 | [![Dependency Status](https://gemnasium.com/grocer/grocer.png)](https://gemnasium.com/grocer/grocer) 5 | [![Code Climate](https://codeclimate.com/github/grocer/grocer.png)](https://codeclimate.com/github/grocer/grocer) 6 | 7 | **grocer** interfaces with the [Apple Push Notification 8 | Service](http://developer.apple.com/library/mac/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html) 9 | to send push notifications to iOS devices. 10 | 11 | There are other gems out there to do this, but **grocer** plans to be the 12 | cleanest, most extensible, and friendliest. 13 | 14 | ## Requirements 15 | 16 | * Ruby/MRI 1.9.x, JRuby 1.7.x in 1.9 mode, Rubinius in 1.9 mode 17 | 18 | ## Installation 19 | 20 | Add this line to your application's Gemfile: 21 | 22 | ```ruby 23 | gem 'grocer' 24 | ``` 25 | 26 | If you are using JRuby, you will also need to add this to enable full OpenSSL support: 27 | 28 | ```ruby 29 | gem 'jruby-openssl' 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Connecting 35 | 36 | ```ruby 37 | # `certificate` is the only required option; the rest will default to the values 38 | # shown here. 39 | # 40 | # Information on obtaining a `.pem` file for use with `certificate` is shown 41 | # later. 42 | pusher = Grocer.pusher( 43 | certificate: "/path/to/cert.pem", # required 44 | passphrase: "", # optional 45 | gateway: "gateway.push.apple.com", # optional; See note below. 46 | port: 2195, # optional 47 | retries: 3 # optional 48 | ) 49 | ``` 50 | 51 | #### Notes 52 | 53 | * `certificate`: If you don't have the certificate stored in a file, you 54 | can pass any object that responds to `read`. 55 | Example: `certificate: StringIO.new(pem_string)` 56 | * `gateway`: Defaults to different values depending on the `RAILS_ENV` or 57 | `RACK_ENV` environment variables. If set to `production`, defaults to 58 | `gateway.push.apple.com`, if set to `test`, defaults to `localhost` (see 59 | [Acceptance Testing](#acceptance-testing) later), otherwise defaults to 60 | `gateway.sandbox.push.apple.com`. 61 | * `retries`: The number of times **grocer** will retry writing to or reading 62 | from the Apple Push Notification Service before raising any errors to client 63 | code. 64 | 65 | ### Sending Notifications 66 | 67 | ```ruby 68 | # `device_token` and either `alert` or `badge` are required. 69 | # 70 | # Information on obtaining `device_token` is shown later. 71 | notification = Grocer::Notification.new( 72 | device_token: "fe15a27d5df3c34778defb1f4f3880265cc52c0c047682223be59fb68500a9a2", 73 | alert: "Hello from Grocer!", 74 | badge: 42, 75 | sound: "siren.aiff", # optional 76 | expiry: Time.now + 60*60, # optional; 0 is default, meaning the message is not stored 77 | identifier: 1234, # optional 78 | content_available: true # optional; any truthy value will set 'content-available' to 1 79 | ) 80 | 81 | pusher.push(notification) 82 | ``` 83 | 84 | It is desirable to reuse the same connection to send multiple notifications, as 85 | is recommended by Apple. 86 | 87 | ```ruby 88 | pusher = Grocer.pusher(connection_options) 89 | notifications.each do |notification| 90 | pusher.push(notification) 91 | end 92 | ``` 93 | 94 | #### Custom Payloads 95 | 96 | The Apple documentation says "Providers can specify custom payload values 97 | outside the Apple-reserved aps namespace." To specify a custom payload, set 98 | `Grocer::Notification#custom`. 99 | 100 | ```ruby 101 | notification = Grocer::Notification.new( 102 | device_token: "...", 103 | alert: "Hello from Grocer", 104 | custom: { 105 | "acme2": ["bang", "whiz"] 106 | } 107 | ) 108 | 109 | # Generates a JSON payload like: 110 | # {"aps": {"alert": "Hello from Grocer"}, "acme2": ["bang", "whiz"]} 111 | ``` 112 | 113 | #### Passbook Notifications 114 | 115 | A `Grocer::PassbookNotification` is a specialized kind of notification which 116 | does not require any payload. That is, you need not (and *[Apple explicitly says 117 | not to](http://developer.apple.com/library/ios/#Documentation/UserExperience/Conceptual/PassKit_PG/Chapters/Updating.html#//apple_ref/doc/uid/TP40012195-CH5-SW1)*) 118 | send any payload for a Passbook notification. If you do, it will be ignored. 119 | 120 | ```ruby 121 | notification = Grocer::PassbookNotification.new(device_token: "...") 122 | # Generates a JSON payload like: 123 | # {"aps": {}} 124 | ``` 125 | 126 | #### Newsstand Notifications 127 | 128 | Grocer also supports the special Newsstand 'content-available' notification. `Grocer::NewsstandNotification` can be 129 | used for this. Like `Grocer::PassbookNotification`, it is a specialized kind of notification which does not require 130 | any payload. Likewise, anything you add to it will be ignored. 131 | 132 | ```ruby 133 | notification = Grocer::NewsstandNotification.new(device_token: "...") 134 | # Generates a JSON payload like: 135 | # {"aps": {"content-available":1}} 136 | ```` 137 | 138 | ### Feedback 139 | 140 | ```ruby 141 | # `certificate` is the only required option; the rest will default to the values 142 | # shown here. 143 | feedback = Grocer.feedback( 144 | certificate: "/path/to/cert.pem", # required 145 | passphrase: "", # optional 146 | gateway: "feedback.push.apple.com", # optional; See note below. 147 | port: 2196, # optional 148 | retries: 3 # optional 149 | ) 150 | 151 | feedback.each do |attempt| 152 | puts "Device #{attempt.device_token} failed at #{attempt.timestamp}" 153 | end 154 | ``` 155 | 156 | #### Notes 157 | 158 | * `gateway`: Defaults to `feedback.push.apple.com` **only** when running in a 159 | production environment, as determined by either the `RAILS_ENV` or 160 | `RACK_ENV` environment variables. In all other cases, it defaults to the 161 | sandbox gateway, `feedback.sandbox.push.apple.com`. 162 | * `retries`: The number of times **grocer** will retry writing to or reading 163 | from the Apple Push Notification Service before raising any errors to client 164 | code. 165 | 166 | ### Acceptance Testing 167 | 168 | Grocer ships with framework to setup a real looking APNS server. It listens on 169 | a real SSL-capable socket bound to localhost. 170 | 171 | You can setup an APNS client to talk to it, then inspect the notifications the 172 | server received. 173 | 174 | The server simply exposes a blocking queue where notifications are placed when 175 | they are received. It is your responsibility to timeout if a message is not 176 | received in a reasonable amount of time. 177 | 178 | For example, in RSpec: 179 | 180 | ```ruby 181 | require 'timeout' 182 | 183 | describe "apple push notifications" do 184 | before do 185 | @server = Grocer.server(port: 2195) 186 | @server.accept # starts listening in background 187 | end 188 | 189 | after do 190 | @server.close 191 | end 192 | 193 | specify "As a user, I receive notifications on my phone when awesome things happen" do 194 | # ... exercise code that would send APNS notifications ... 195 | 196 | Timeout.timeout(3) { 197 | notification = @server.notifications.pop # blocking 198 | expect(notification.alert).to eq("An awesome thing happened") 199 | } 200 | end 201 | end 202 | ``` 203 | 204 | ## Device Token 205 | 206 | A device token is obtained from within the iOS app. More details are in Apple's 207 | [Registering for Remote 208 | Notifications](http://developer.apple.com/library/mac/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/IPhoneOSClientImp/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW1) 209 | documentation. 210 | 211 | The key code for this purpose is: 212 | 213 | ```objective-c 214 | - (void)applicationDidFinishLaunching:(UIApplication *)app { 215 | // other setup tasks here.... 216 | [[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound)]; 217 | } 218 | 219 | - (void)application:(UIApplication *)app didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)devToken { 220 | NSLog(@"Got device token: %@", [devToken description]); 221 | 222 | [self sendProviderDeviceToken:[devToken bytes]]; // custom method; e.g., send to a web service and store 223 | } 224 | 225 | - (void)application:(UIApplication *)app didFailToRegisterForRemoteNotificationsWithError:(NSError *)err { 226 | NSLog(@"Error in registration. Error: %@", err); 227 | } 228 | ``` 229 | 230 | ## Certificate File 231 | 232 | Login to the [iOS Provisioning Portal (App IDs)](https://developer.apple.com/ios/manage/bundles/index.action). 233 | 234 | Configure the appropriate certificate for push notifications and download the 235 | certificate: 236 | 237 | ![Downloading the Push Notification Certificate](https://img.skitch.com/20120402-gtj3bkqi1kq92kgw2pbr5puk5d.png) 238 | 239 | Open the file in Keychain Access, then expand the certificate to show both the 240 | certificate *and* the private key. Command select so both are highlighted: 241 | 242 | ![Selecting both the certificate and private key](https://img.skitch.com/20120402-e8deartr2uhimaiatgccttkggi.png) 243 | 244 | Control click and select to export the 2 items: 245 | 246 | ![Exporting the certificate and private key](https://img.skitch.com/20120402-mbmgjrybyym846cy58a9kpyxp5.png) 247 | 248 | Save the items as a `.p12` file. Open a terminal window and run the following 249 | command: 250 | 251 | ```bash 252 | openssl pkcs12 -in exported_certificate.p12 -out certificate.pem -nodes -clcerts 253 | ``` 254 | 255 | The `certificate.pem` file that is generated can be used with **grocer**. 256 | 257 | ## Support Channels 258 | 259 | [GitHub Issues](https://github.com/grocer/grocer/issues) and [Pull 260 | Requests](https://github.com/grocer/grocer/pulls) are the primary venues for 261 | communicating issues and discussing possible features. Several of us also 262 | regularly hang out in the `#grocer` channel on Freenode; feel free to pop in 263 | and ask questions there as well. Thanks! :heart: 264 | --------------------------------------------------------------------------------