mercurius 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 65ff200559fb5130010b47d7d2be2cc1e524b0de
4
- data.tar.gz: 8a28049240b58317298775619721978b80cb7ce7
3
+ metadata.gz: bba600c5ea9ccfe58f1be1c8a0190f76ed156876
4
+ data.tar.gz: 878a4fbc8539723e11c8b489a6335fa1e0839e88
5
5
  SHA512:
6
- metadata.gz: ce55894987fecb4a496c014681d07dbea9375e805e2a1523b2d2495fe09a8bfb9a13254f6a78fd720ab3d994311903c5a0ace2415a2edf8b21b7b81dfaf10bd1
7
- data.tar.gz: 334217c4278902311a45fed9ae873b912246354f25972d04dab17356fdca3f68fd0d6d0a214f103d7fd399d617253d99858ddc35a3bc19038d0c7edadbb34173
6
+ metadata.gz: a80bcb308c866c6d4b98beb7ea1ec19e7407c306f6ec6fa322dd40388973473324363dbf6d26432ff8aeea0f57fb492086c4b6e87d2ed50be1e8ba0982b09502
7
+ data.tar.gz: f63ae742964e49c26bb46ada8088dc77cdb94539663ecef72ffa63fcb0232a1945caed3ce11eedb63b516db770ff85de4d63ff865fb8a8995fa14d1f4dc2161d
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- ruby-2.1.5
1
+ 2.1.5
@@ -0,0 +1,37 @@
1
+ module APNS
2
+ class Connection
3
+ attr_reader :host, :port, :ssl
4
+
5
+ def initialize(host, port, pem)
6
+ @socket = TCPSocket.new host, port
7
+ @ssl = OpenSSL::SSL::SSLSocket.new @socket, ssl_context_for_pem(pem)
8
+ end
9
+
10
+ def open
11
+ @ssl.connect
12
+ end
13
+
14
+ def close
15
+ @ssl.close
16
+ @socket.close
17
+ end
18
+
19
+ def closed?
20
+ @ssl.closed? && @socket.closed?
21
+ end
22
+
23
+ def write(data)
24
+ @ssl.write data
25
+ end
26
+
27
+ private
28
+
29
+ def ssl_context_for_pem(pem)
30
+ context = OpenSSL::SSL::SSLContext.new
31
+ context.cert = OpenSSL::X509::Certificate.new(pem.data)
32
+ context.key = OpenSSL::PKey::RSA.new(pem.data, pem.password)
33
+ context
34
+ end
35
+
36
+ end
37
+ end
@@ -1,46 +1,46 @@
1
1
  module APNS
2
2
  class Notification
3
- attr_accessor :device_token, :alert, :badge, :sound, :other
4
-
5
- def initialize(device_token, message)
6
- self.device_token = device_token
7
- if message.is_a?(Hash)
8
- self.alert = message[:alert]
9
- self.badge = message[:badge]
10
- self.sound = message[:sound]
11
- self.other = message[:other]
12
- elsif message.is_a?(String)
13
- self.alert = message
14
- else
15
- raise "Notification needs to have either a Hash or String"
16
- end
3
+ include ActiveModel::Model
4
+
5
+ MAX_PAYLOAD_BYTES = 2048
6
+
7
+ attr_accessor :alert, :badge, :sound, :other
8
+ attr_reader :attributes
9
+
10
+ def initialize(attributes = {})
11
+ @attributes = attributes
12
+ super
17
13
  end
18
14
 
19
- def packaged_notification
20
- pt = self.packaged_token
21
- pm = self.packaged_message
22
- [0, 0, 32, pt, 0, pm.bytesize, pm].pack("ccca*cca*")
15
+ def payload
16
+ {
17
+ alert: alert,
18
+ badge: badge,
19
+ sound: sound,
20
+ other: other
21
+ }.compact
23
22
  end
24
23
 
25
- def packaged_token
24
+ def pack(device_token)
25
+ [0, 0, 32, package_device_token(device_token), 0, packaged_message.bytesize, packaged_message].pack("ccca*cca*")
26
+ end
27
+
28
+ def package_device_token(device_token)
26
29
  [device_token.gsub(/[\s|<|>]/,'')].pack('H*')
27
30
  end
28
31
 
29
32
  def packaged_message
30
- aps = {'aps'=> {} }
31
- aps['aps']['alert'] = self.alert if self.alert
32
- aps['aps']['badge'] = self.badge if self.badge
33
- aps['aps']['sound'] = self.sound if self.sound
34
- aps.merge!(self.other) if self.other
35
- aps.to_json.gsub(/\\u([\da-fA-F]{4})/) {|m| [$1].pack("H*").unpack("n*").pack("U*")}
33
+ { aps: payload }.to_json.gsub(/\\u([\da-fA-F]{4})/) do |m|
34
+ [$1].pack("H*").unpack("n*").pack("U*")
35
+ end
36
36
  end
37
37
 
38
38
  def ==(that)
39
- device_token == that.device_token &&
40
- alert == that.alert &&
41
- badge == that.badge &&
42
- sound == that.sound &&
43
- other == that.other
39
+ attributes == that.attributes
40
+ end
41
+
42
+ def valid?
43
+ packaged_message.bytesize <= MAX_PAYLOAD_BYTES
44
44
  end
45
45
 
46
46
  end
@@ -0,0 +1,22 @@
1
+ module APNS
2
+ class Pem
3
+ include ActiveModel::Model
4
+
5
+ attr_accessor :path, :data, :password
6
+
7
+ def data
8
+ @_data ||= (@data || read_file_at_path || raise(PemNotConfiguredError.new))
9
+ end
10
+
11
+ private
12
+
13
+ def read_file_at_path
14
+ if File.exist? path
15
+ File.read path
16
+ else
17
+ raise PemNotFoundError.new
18
+ end
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ module APNS
2
+ class Service
3
+ include ActiveModel::Model
4
+
5
+ MAX_NUMBER_OF_RETRIES = 3
6
+
7
+ attr_accessor :host, :port, :pem
8
+ attr_reader :connection, :attempts
9
+
10
+ def initialize(*)
11
+ super
12
+ @host ||= APNS.host
13
+ @port ||= APNS.port
14
+ @pem ||= APNS.pem
15
+ @connection = APNS::Connection.new(@host, @port, @pem)
16
+ @attempts = 0
17
+ end
18
+
19
+ def persist(&block)
20
+ @_persistent = true
21
+ yield
22
+ @_persistent = false
23
+ connection.close
24
+ end
25
+
26
+ def deliver(notification, *device_tokens)
27
+ device_tokens = Array(device_tokens).flatten
28
+ with_connection do |connection|
29
+ device_tokens.each do |device_token|
30
+ connection.write notification.pack(device_token)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def persistent?
38
+ @_persistent
39
+ end
40
+
41
+ def with_connection(&block)
42
+ @attempts = 1
43
+
44
+ begin
45
+ connection.open if connection.closed?
46
+ yield connection
47
+ rescue StandardError, Errno::EPIPE => e
48
+ raise TooManyRetriesError.new if too_many_retries?
49
+ connection.close
50
+ @attempts += 1
51
+ retry
52
+ end
53
+
54
+ connection.close unless persistent?
55
+ end
56
+
57
+ def too_many_retries?
58
+ @attempts >= MAX_NUMBER_OF_RETRIES
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,10 @@
1
+ module APNS
2
+ @host = ''
3
+ @port = 2195
4
+ @pem = nil
5
+
6
+ class << self
7
+ attr_accessor :host, :port, :pem
8
+ end
9
+
10
+ end
@@ -0,0 +1,7 @@
1
+ class PemNotConfiguredError < StandardError
2
+
3
+ def initialize
4
+ super 'PEM is not configured properly.'
5
+ end
6
+
7
+ end
@@ -0,0 +1,7 @@
1
+ class PemNotFoundError < StandardError
2
+
3
+ def initialize
4
+ super 'The specified PEM file does not exist.'
5
+ end
6
+
7
+ end
@@ -0,0 +1,7 @@
1
+ class TooManyRetriesError < StandardError
2
+
3
+ def initialize
4
+ super "APNS Service has reached it's maximum number of retries"
5
+ end
6
+
7
+ end
@@ -0,0 +1,24 @@
1
+ module GCM
2
+ class Connection
3
+
4
+ attr_accessor :host, :key
5
+
6
+ def initialize(host, key)
7
+ @host = host
8
+ @key = key
9
+ end
10
+
11
+ def write(json)
12
+ client.post '/gcm/send', json
13
+ end
14
+
15
+ def client
16
+ @_client ||= Faraday.new(host) do |http|
17
+ http.headers['Authorization'] = "key=#{key}"
18
+ http.request :json
19
+ http.adapter :net_http
20
+ end
21
+ end
22
+
23
+ end
24
+ end
@@ -1,54 +1,30 @@
1
1
  module GCM
2
2
  class Notification
3
- attr_accessor :device_tokens, :data, :collapse_key, :time_to_live, :delay_while_idle, :identity
3
+ include ActiveModel::Model
4
4
 
5
- def initialize(tokens, data, options = {})
6
- self.device_tokens = tokens
7
- self.data = data
5
+ attr_accessor :data, :collapse_key, :time_to_live, :delay_while_idle
8
6
 
9
- @collapse_key = options[:collapse_key]
10
- @time_to_live = options[:time_to_live]
11
- @delay_while_idle = options[:delay_while_idle]
12
- @identity = options[:identity]
13
- end
14
-
15
- def device_tokens=(tokens)
16
- if tokens.is_a?(Array)
17
- @device_tokens = tokens
18
- elsif tokens.is_a?(String)
19
- @device_tokens = [tokens]
20
- else
21
- raise "device_tokens needs to be either an Array or a String"
22
- end
23
- end
7
+ # validate delay_while_idle is true/false
8
+ # validate ttl is integer in seconds
24
9
 
25
- def data=(data)
26
- if data.is_a?(Hash)
27
- @data = data
28
- else
29
- raise "data parameter must be the type of Hash"
30
- end
10
+ def initialize(attributes = {})
11
+ @attributes = attributes
12
+ super data: @attributes.except(:collapse_key, :time_to_live, :delay_while_idle)
31
13
  end
32
14
 
33
- def delay_while_idle=(delay_while_idle)
34
- @delay_while_idle = (delay_while_idle == true || delay_while_idle == :true)
35
- end
15
+ def to_h
16
+ hash = {
17
+ data: data,
18
+ collapse_key: collapse_key,
19
+ time_to_live: time_to_live,
20
+ delay_while_idle: delay_while_idle
21
+ }
36
22
 
37
- def time_to_live=(time_to_live)
38
- if time_to_live.is_a?(Integer)
39
- @time_to_live = time_to_live
40
- else
41
- raise %q{"time_to_live" must be seconds as an integer value, like "100"}
42
- end
23
+ hash.reject { |k, v| v.nil? }
43
24
  end
44
25
 
45
26
  def ==(that)
46
- device_tokens == that.device_tokens &&
47
- data == that.data &&
48
- collapse_key == that.collapse_key &&
49
- time_to_live == that.time_to_live &&
50
- delay_while_idle == that.delay_while_idle &&
51
- identity == that.identity
27
+ attributes == that.attributes
52
28
  end
53
29
 
54
30
  end
@@ -0,0 +1,35 @@
1
+ module GCM
2
+ class Response
3
+ attr_reader :response, :device_tokens
4
+
5
+ MESSAGES = {
6
+ 200 => 'Success',
7
+ 400 => 'The request could not be parsed as JSON or it contained invalid fields',
8
+ 401 => 'There was an error authenticating the sender account',
9
+ 500 => 'There was an internal error in the GCM server',
10
+ 503 => 'GCM server is temporarily unavailable',
11
+ default: 'Unknown error'
12
+ }
13
+
14
+ def initialize(response, device_tokens)
15
+ @response = response
16
+ @device_tokens = device_tokens
17
+ end
18
+
19
+ def status
20
+ @response.status
21
+ end
22
+
23
+ def message
24
+ MESSAGES.fetch(status, MESSAGES[:default])
25
+ end
26
+
27
+ def success?
28
+ @response.success?
29
+ end
30
+
31
+ def failed?
32
+ !success?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ module GCM
2
+ class Result
3
+ attr_reader :responses
4
+
5
+ def initialize(notification)
6
+ @notification = notification
7
+ @responses = []
8
+ end
9
+
10
+ def success?
11
+ failed_responses.empty?
12
+ end
13
+
14
+ def process_response(response, device_tokens)
15
+ self.responses << GCM::Response.new(response, device_tokens)
16
+ end
17
+
18
+ def failed_responses
19
+ @_failed_responses ||= responses.select(&:failed?)
20
+ end
21
+
22
+ def failed_device_tokens
23
+ @_failed_device_tokens ||= failed_responses.flat_map { |response| response.device_tokens }
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ module GCM
2
+ class Service
3
+ include ActiveModel::Model
4
+
5
+ MAX_NUMBER_OF_RETRIES = 3
6
+ MAX_DEVICES_AT_ONCE = 999
7
+
8
+ attr_accessor :host, :key
9
+ attr_reader :connection, :attempts
10
+
11
+ def initialize(*)
12
+ super
13
+ @host ||= GCM.host
14
+ @key ||= GCM.key
15
+ @connection = GCM::Connection.new(@host, @key)
16
+ @attempts = 0
17
+ end
18
+
19
+ def deliver(notification, *device_tokens)
20
+ result = GCM::Result.new(notification)
21
+ device_tokens = Array(device_tokens).flatten
22
+ device_tokens.each_slice(MAX_DEVICES_AT_ONCE) do |tokens|
23
+ payload = notification.to_h.merge registration_ids: tokens
24
+ result.process_response connection.write(payload), tokens
25
+ end
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def too_many_retries?
32
+ @attempts >= MAX_NUMBER_OF_RETRIES
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ module GCM
2
+ @host = 'https://android.googleapis.com/'
3
+ @key = ENV['GCM_KEY']
4
+
5
+ class << self
6
+ attr_accessor :host, :key
7
+ end
8
+
9
+ end
@@ -1,3 +1,3 @@
1
1
  module Mercurius
2
- VERSION = '0.0.1'
2
+ VERSION = '0.0.2'
3
3
  end
data/lib/mercurius.rb CHANGED
@@ -1,3 +1,25 @@
1
- require "mercurius/version"
2
- require "mercurius/apple"
3
- require "mercurius/android"
1
+ require 'active_model'
2
+ require 'active_support/core_ext/hash/compact'
3
+ require 'active_support/core_ext/hash/except'
4
+ require 'faraday'
5
+ require 'faraday_middleware'
6
+ require 'json'
7
+
8
+ require 'mercurius/version'
9
+
10
+ require 'mercurius/errors/pem_not_configured_error'
11
+ require 'mercurius/errors/pem_not_found_error'
12
+ require 'mercurius/errors/too_many_retries_error'
13
+
14
+ require 'mercurius/apns'
15
+ require 'mercurius/apns/pem'
16
+ require 'mercurius/apns/connection'
17
+ require 'mercurius/apns/notification'
18
+ require 'mercurius/apns/service'
19
+
20
+ require 'mercurius/gcm'
21
+ require 'mercurius/gcm/connection'
22
+ require 'mercurius/gcm/notification'
23
+ require 'mercurius/gcm/service'
24
+ require 'mercurius/gcm/response'
25
+ require 'mercurius/gcm/result'
data/mercurius.gemspec CHANGED
@@ -22,9 +22,13 @@ Gem::Specification.new do |s|
22
22
 
23
23
  s.require_paths = ["lib"]
24
24
 
25
- s.add_dependency 'httparty'
26
25
  s.add_dependency 'json'
26
+ s.add_dependency 'faraday'
27
+ s.add_dependency 'faraday_middleware'
28
+ s.add_dependency 'activemodel', '>= 4.0.0'
29
+ s.add_dependency 'activesupport', '>= 4.1.0'
27
30
 
28
31
  s.add_development_dependency 'rake'
29
32
  s.add_development_dependency 'rspec'
33
+ s.add_development_dependency 'webmock'
30
34
  end
@@ -0,0 +1,70 @@
1
+ describe APNS::Service do
2
+ let(:service) { APNS::Service.new }
3
+
4
+ before do
5
+ # setup APNS
6
+ bundle = File.read File.expand_path(File.join(File.dirname(__FILE__), '..', 'support', 'apns.pem'))
7
+ APNS.host = 'gateway.sandbox.push.apple.com'
8
+ APNS.port = 2195
9
+ APNS.pem = APNS::Pem.new(data: bundle, password: 'test123')
10
+ end
11
+
12
+ it 'should default to the APNS module configs' do
13
+ expect(service.host).to eq 'gateway.sandbox.push.apple.com'
14
+ expect(service.port).to eq 2195
15
+ expect(service.pem.password).to eq 'test123'
16
+ end
17
+
18
+ describe '#send' do
19
+ let(:ssl) { FakeSocket.new }
20
+ let(:socket) { FakeSocket.new }
21
+
22
+ before do
23
+ expect(OpenSSL::SSL::SSLSocket).to receive(:new) { ssl }
24
+ expect(TCPSocket).to receive(:new) { socket }
25
+ end
26
+
27
+ it 'sends a single message' do
28
+ message = APNS::Notification.new(alert: 'Hey')
29
+ service.deliver message, 'token123'
30
+ expect(ssl.wrote[0]).to include ({ alert: 'Hey' }).to_json
31
+ end
32
+
33
+ it 'sends to multiple tokens via splat' do
34
+ message = APNS::Notification.new(alert: 'Hey')
35
+ service.deliver message, 'token123', 'token456'
36
+ expect(ssl.wrote.size).to eq 2
37
+ end
38
+
39
+ it 'sends to multiple token via array' do
40
+ message = APNS::Notification.new(alert: 'Hey1')
41
+ service.deliver message, ['token123', 'token456']
42
+ expect(ssl.wrote.size).to eq 2
43
+ end
44
+
45
+ describe 'persist' do
46
+ it 'with persist, it keeps the SSL connection open until all messages are sent' do
47
+ expect(service.connection).to receive(:close).once
48
+ service.persist do
49
+ service.deliver APNS::Notification.new(alert: 'Hey1'), 'token123'
50
+ service.deliver APNS::Notification.new(alert: 'Hey2'), 'token123'
51
+ end
52
+ end
53
+
54
+ it 'without persist, closes the connection on each message' do
55
+ expect(service.connection).to receive(:close).twice
56
+ service.deliver APNS::Notification.new(alert: 'Hey1'), 'token123'
57
+ service.deliver APNS::Notification.new(alert: 'Hey2'), 'token123'
58
+ end
59
+ end
60
+
61
+ describe 'retries' do
62
+ it 'tries 3 times before giving up' do
63
+ allow(service.connection).to receive(:open) { raise StandardError }
64
+ expect { service.deliver APNS::Notification.new(alert: 'Hey1'), 'token123' }.to raise_exception(TooManyRetriesError)
65
+ expect(service.attempts).to eq 3
66
+ end
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,72 @@
1
+ describe GCM::Service do
2
+ let(:service) { GCM::Service.new }
3
+ let(:message) { GCM::Notification.new(alert: 'Hey') }
4
+
5
+ it 'should default to the GCM module configs' do
6
+ expect(service.host).to eq 'https://android.googleapis.com/'
7
+ expect(service.key).to be_nil
8
+ end
9
+
10
+ describe '#send' do
11
+ before { stub_request :post, %r[android.googleapis.com/gcm/send] }
12
+
13
+ it 'sends a single message' do
14
+ service.deliver message, 'token123'
15
+ expect(WebMock).to have_requested(:post, %r[android.googleapis.com/gcm/send]).
16
+ with(body: { data: { alert: 'Hey' }, registration_ids: ['token123'] })
17
+ end
18
+
19
+ it 'sends to multiple tokens via splat' do
20
+ service.deliver message, 'token123', 'token456'
21
+ expect(WebMock).to have_requested(:post, %r[android.googleapis.com/gcm/send]).
22
+ with(body: { data: { alert: 'Hey' }, registration_ids: ['token123', 'token456'] })
23
+ end
24
+
25
+ it 'sends to multiple tokens via array' do
26
+ service.deliver message, ['token123', 'token456']
27
+ expect(WebMock).to have_requested(:post, %r[android.googleapis.com/gcm/send]).
28
+ with(body: { data: { alert: 'Hey' }, registration_ids: ['token123', 'token456'] })
29
+ end
30
+
31
+ it 'only sends 999 tokens at a time' do
32
+ tokens = (1..1000).to_a.map { |i| "token#{i}" }
33
+ service.deliver message, tokens
34
+ expect(WebMock).to have_requested(:post, %r[android.googleapis.com/gcm/send]).
35
+ with(body: { data: { alert: 'Hey' }, registration_ids: tokens.take(999) })
36
+ expect(WebMock).to have_requested(:post, %r[android.googleapis.com/gcm/send]).
37
+ with(body: { data: { alert: 'Hey' }, registration_ids: [tokens.last] })
38
+ end
39
+ end
40
+
41
+ describe 'response' do
42
+ context 'success' do
43
+ before { stub_request :post, %r[android.googleapis.com/gcm/send] }
44
+
45
+ it 'processes a 200 response' do
46
+ result = service.deliver message, 'token123'
47
+ expect(result.responses[0].status).to eq 200
48
+ expect(result.responses[0].message).to eq GCM::Response::MESSAGES[200]
49
+ expect(result.failed_device_tokens).to eq []
50
+ end
51
+ end
52
+
53
+ context 'failure' do
54
+ before { stub_request(:post, %r[android.googleapis.com/gcm/send]).to_return(status: 400) }
55
+
56
+ it 'processes a 400 response' do
57
+ result = service.deliver message, 'token123'
58
+ expect(result.responses[0].status).to eq 400
59
+ expect(result.responses[0].message).to eq GCM::Response::MESSAGES[400]
60
+ expect(result.failed_device_tokens).to eq ['token123']
61
+ end
62
+
63
+ it 'adds all failed messages to the failed device tokens array' do
64
+ tokens = (1..1000).to_a.map { |i| "token#{i}" }
65
+ result = service.deliver message, tokens
66
+ expect(result.failed_device_tokens).to include 'token1'
67
+ expect(result.failed_device_tokens).to include 'token1000'
68
+ end
69
+ end
70
+
71
+ end
72
+ end