mercurius 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 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