push_kit-apns 1.0.0.pre.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +46 -0
- data/.rspec +2 -0
- data/.rubocop.yml +21 -0
- data/Gemfile +5 -0
- data/Guardfile +17 -0
- data/README.md +118 -0
- data/Rakefile +8 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/lib/push_kit/apns.rb +65 -0
- data/lib/push_kit/apns/constants.rb +9 -0
- data/lib/push_kit/apns/http_client.rb +277 -0
- data/lib/push_kit/apns/notification.rb +257 -0
- data/lib/push_kit/apns/notification/localization.rb +61 -0
- data/lib/push_kit/apns/push_client.rb +207 -0
- data/lib/push_kit/apns/token_generator.rb +97 -0
- data/push_kit.gemspec +33 -0
- data/spec/push_kit/apns/constants_spec.rb +9 -0
- data/spec/push_kit/apns/notification/localization_spec.rb +89 -0
- data/spec/push_kit/apns/notification_spec.rb +264 -0
- data/spec/spec_helper.rb +85 -0
- data/spec/support/have_accessor.rb +19 -0
- metadata +189 -0
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PushKit
|
4
|
+
module APNS
|
5
|
+
# The TokenGenerator class provides an API for creating JSON Web Tokens used to authenticate requests to the APNS
|
6
|
+
# system.
|
7
|
+
#
|
8
|
+
class TokenGenerator
|
9
|
+
# @return [String] The team identifier.
|
10
|
+
#
|
11
|
+
attr_reader :team_id
|
12
|
+
|
13
|
+
# @return [String] The key identifier.
|
14
|
+
#
|
15
|
+
attr_reader :key_id
|
16
|
+
|
17
|
+
# Create a new AutoToken instance.
|
18
|
+
#
|
19
|
+
# @param team_id [String] The team identifier.
|
20
|
+
# @param key_id [String] The key identifier.
|
21
|
+
# @param key [OpenSSL::PKey::EC] The private key.
|
22
|
+
# @return [PushKit::APNS::TokenGenerator] A TokenGenerator instance.
|
23
|
+
#
|
24
|
+
def initialize(key: nil, key_id: nil, team_id: nil)
|
25
|
+
raise ArgumentError, 'The :key attribute must be an OpenSSL::PKey::EC.' unless key.is_a?(OpenSSL::PKey::EC)
|
26
|
+
raise ArgumentError, 'The :key attribute must contain a private key.' unless key.private_key?
|
27
|
+
raise ArgumentError, 'The :key_id attribute must be a String.' unless key_id.is_a?(String)
|
28
|
+
raise ArgumentError, 'The :key_id attribute does not appear to be valid.' unless key_id.length == 10
|
29
|
+
raise ArgumentError, 'The :team_id attribute must be a String.' unless team_id.is_a?(String)
|
30
|
+
|
31
|
+
@key = key
|
32
|
+
@key_id = key_id
|
33
|
+
@team_id = team_id
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [String] The token to use for authentication.
|
37
|
+
#
|
38
|
+
def token
|
39
|
+
return @token unless generate_token?
|
40
|
+
|
41
|
+
mutex.synchronize do
|
42
|
+
next @token unless generate_token?
|
43
|
+
|
44
|
+
@generated_at = time
|
45
|
+
@token = generate_token
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Hash] The authentication headers.
|
50
|
+
#
|
51
|
+
def headers
|
52
|
+
{
|
53
|
+
'authorization' => 'Bearer ' + token
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# @return [OpenSSL::PKey::EC] The private key.
|
60
|
+
#
|
61
|
+
attr_reader :key
|
62
|
+
|
63
|
+
# @return [Time] The time the current token was generated.
|
64
|
+
#
|
65
|
+
attr_reader :generated_at
|
66
|
+
|
67
|
+
# @return [Mutex] The mutex used to generate a new token.
|
68
|
+
#
|
69
|
+
def mutex
|
70
|
+
@mutex ||= Mutex.new
|
71
|
+
end
|
72
|
+
|
73
|
+
# @return [Boolean] Does a token need to be generated?
|
74
|
+
#
|
75
|
+
def generate_token?
|
76
|
+
@token.nil? || generated_at.nil? || generated_at < (time - 3000)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Generate a JSON Web Token based on the current time for authentication.
|
80
|
+
#
|
81
|
+
# @return [String] The newly generated token.
|
82
|
+
#
|
83
|
+
def generate_token
|
84
|
+
headers = { 'kid' => key_id }
|
85
|
+
claims = { 'iat' => time, 'iss' => team_id }
|
86
|
+
|
87
|
+
JWT.encode(claims, key, 'ES256', headers)
|
88
|
+
end
|
89
|
+
|
90
|
+
# @return [Integer] The current UTC time in seconds.
|
91
|
+
#
|
92
|
+
def time
|
93
|
+
Time.now.utc.to_i
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
data/push_kit.gemspec
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
|
6
|
+
require 'push_kit/apns/constants'
|
7
|
+
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = 'push_kit-apns'
|
10
|
+
spec.version = PushKit::APNS::VERSION
|
11
|
+
spec.authors = ['Nialto Services']
|
12
|
+
spec.email = ['support@nialtoservices.co.uk']
|
13
|
+
|
14
|
+
spec.summary = 'Send APNS push notifications with ease'
|
15
|
+
spec.homepage = 'https://github.com/nialtoservices/push_kit-apns'
|
16
|
+
spec.license = 'MIT'
|
17
|
+
|
18
|
+
spec.files = `git ls-files -z`.split("\x0")
|
19
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
20
|
+
spec.require_paths = ['lib']
|
21
|
+
|
22
|
+
spec.metadata['yard.run'] = 'yri'
|
23
|
+
|
24
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.1', '>= 1.1.5'
|
25
|
+
spec.add_dependency 'http-2', '~> 0.10.1'
|
26
|
+
spec.add_dependency 'jwt', '~> 2.2'
|
27
|
+
|
28
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
29
|
+
spec.add_development_dependency 'guard-rspec', '~> 4.7'
|
30
|
+
spec.add_development_dependency 'rake', '~> 12.3'
|
31
|
+
spec.add_development_dependency 'rspec', '~> 3.8'
|
32
|
+
spec.add_development_dependency 'yard', '~> 0.9.20'
|
33
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'push_kit/apns/notification/localization'
|
4
|
+
|
5
|
+
RSpec.describe PushKit::APNS::Notification::Localization, :unit do
|
6
|
+
def localization(params = {})
|
7
|
+
default_params = {
|
8
|
+
key: 'HELLO_WORLD',
|
9
|
+
arguments: nil
|
10
|
+
}
|
11
|
+
|
12
|
+
described_class.new(**default_params.merge(params))
|
13
|
+
end
|
14
|
+
|
15
|
+
subject { localization }
|
16
|
+
|
17
|
+
it { is_expected.to have_accessor(:key) }
|
18
|
+
it { is_expected.to have_accessor(:arguments) }
|
19
|
+
|
20
|
+
describe '#initialize' do
|
21
|
+
let(:key) { 'A_KEY' }
|
22
|
+
let(:arguments) { ['An Argument'] }
|
23
|
+
|
24
|
+
subject { localization(key: key, arguments: arguments) }
|
25
|
+
|
26
|
+
it 'sets #key' do
|
27
|
+
expect(subject.key).to eq(key)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'sets #arguments' do
|
31
|
+
expect(subject.arguments).to eq(arguments)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#payload' do
|
36
|
+
context 'when localizing a supported attribute' do
|
37
|
+
let(:payload) { subject.payload(:title) }
|
38
|
+
|
39
|
+
it 'includes #key as the prefixed loc-key attribute' do
|
40
|
+
subject.key = 'TITLE_KEY'
|
41
|
+
expect(payload).to include('title-loc-key' => 'TITLE_KEY')
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'includes #arguments as the prefixed loc-args attribute' do
|
45
|
+
subject.arguments = ['An Argument']
|
46
|
+
expect(payload).to include('title-loc-args' => ['An Argument'])
|
47
|
+
end
|
48
|
+
|
49
|
+
context 'without #arguments' do
|
50
|
+
before { subject.arguments = nil }
|
51
|
+
|
52
|
+
it 'excludes the prefixed loc-args attribute' do
|
53
|
+
expect(payload).not_to have_key('title-loc-args')
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'when localizing an unsupported attribute' do
|
59
|
+
let(:attribute) { :some_unknown_attribute }
|
60
|
+
let(:payload) { subject.payload(attribute) }
|
61
|
+
|
62
|
+
it 'returns nil' do
|
63
|
+
expect(payload).to be_nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe '#prefix' do
|
69
|
+
def prefix(*args)
|
70
|
+
subject.instance_eval { prefix(*args) }
|
71
|
+
end
|
72
|
+
|
73
|
+
it "returns 'title-' for :title" do
|
74
|
+
expect(prefix(:title)).to eq('title-')
|
75
|
+
end
|
76
|
+
|
77
|
+
it "returns 'subtitle-' for :subtitle" do
|
78
|
+
expect(prefix(:subtitle)).to eq('subtitle-')
|
79
|
+
end
|
80
|
+
|
81
|
+
it "returns '' for :body" do
|
82
|
+
expect(prefix(:body)).to eq('')
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'returns nil for an unknown attribute' do
|
86
|
+
expect(prefix(:some_unknown_attribute)).to be_nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
require 'push_kit/apns/notification'
|
5
|
+
require 'push_kit/apns/notification/localization'
|
6
|
+
|
7
|
+
RSpec.describe PushKit::APNS::Notification, :unit do
|
8
|
+
describe '::PRIORITIES' do
|
9
|
+
subject { described_class::PRIORITIES }
|
10
|
+
|
11
|
+
it { is_expected.to have_key(:eco) }
|
12
|
+
it { is_expected.to have_key(:immediate) }
|
13
|
+
end
|
14
|
+
|
15
|
+
it { is_expected.to have_accessor(:title) }
|
16
|
+
it { is_expected.to have_accessor(:subtitle) }
|
17
|
+
it { is_expected.to have_accessor(:body) }
|
18
|
+
it { is_expected.to have_accessor(:badge) }
|
19
|
+
it { is_expected.to have_accessor(:sound) }
|
20
|
+
it { is_expected.to have_accessor(:action_key) }
|
21
|
+
it { is_expected.to have_accessor(:category) }
|
22
|
+
it { is_expected.to have_accessor(:launch_image) }
|
23
|
+
it { is_expected.to have_accessor(:metadata) }
|
24
|
+
it { is_expected.to have_accessor(:content_available) }
|
25
|
+
it { is_expected.to have_accessor(:mutable_content) }
|
26
|
+
it { is_expected.to have_accessor(:uuid) }
|
27
|
+
it { is_expected.to have_accessor(:collapse_uuid) }
|
28
|
+
it { is_expected.to have_accessor(:priority) }
|
29
|
+
it { is_expected.to have_accessor(:expiration) }
|
30
|
+
it { is_expected.to have_accessor(:device_token) }
|
31
|
+
|
32
|
+
describe '#initialize' do
|
33
|
+
it 'sets #content_available to false' do
|
34
|
+
expect(subject.content_available).to be false
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'sets #mutable_content to false' do
|
38
|
+
expect(subject.mutable_content).to be false
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#apns_priority' do
|
43
|
+
let(:value) { 10 }
|
44
|
+
|
45
|
+
context 'when #priority is a valid Symbol' do
|
46
|
+
before { subject.priority = :immediate }
|
47
|
+
|
48
|
+
it 'returns the numerical value' do
|
49
|
+
expect(subject.apns_priority).to eq(value)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context 'when #priority is not a valid Symbol' do
|
54
|
+
before { subject.priority = :some_unacceptable_symbol }
|
55
|
+
|
56
|
+
it 'returns nil' do
|
57
|
+
expect(subject.apns_priority).to be nil
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
context 'when #priority is not a Symbol' do
|
62
|
+
before { subject.priority = value }
|
63
|
+
|
64
|
+
it 'returns the exact value of #priority' do
|
65
|
+
expect(subject.apns_priority).to eq(value)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe '#apns_expiration' do
|
71
|
+
let(:value) { 10 }
|
72
|
+
|
73
|
+
context 'when #expiration is a Time' do
|
74
|
+
before { subject.expiration = Time.iso8601('1970-01-01T00:00:10Z') }
|
75
|
+
|
76
|
+
it 'returns the Time represented as a UNIX timestamp' do
|
77
|
+
expect(subject.apns_expiration).to eq(value)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'when #expiration is not a Time' do
|
82
|
+
before { subject.expiration = value }
|
83
|
+
|
84
|
+
it 'returns the exact value of #expiration' do
|
85
|
+
expect(subject.apns_expiration).to eq(value)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe '#for_tokens' do
|
91
|
+
let(:tokens) do
|
92
|
+
%w[
|
93
|
+
1111111111111111111111111111111111111111111111111111111111111111
|
94
|
+
2222222222222222222222222222222222222222222222222222222222222222
|
95
|
+
3333333333333333333333333333333333333333333333333333333333333333
|
96
|
+
]
|
97
|
+
end
|
98
|
+
|
99
|
+
it 'returns a notification for each of provided device tokens' do
|
100
|
+
notifications = subject.for_tokens(*tokens)
|
101
|
+
expect(notifications).to all(be_a described_class)
|
102
|
+
expect(notifications).to contain_exactly(
|
103
|
+
have_attributes(device_token: '1111111111111111111111111111111111111111111111111111111111111111'),
|
104
|
+
have_attributes(device_token: '2222222222222222222222222222222222222222222222222222222222222222'),
|
105
|
+
have_attributes(device_token: '3333333333333333333333333333333333333333333333333333333333333333')
|
106
|
+
)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
describe '#headers' do
|
111
|
+
it 'uses #apns_priority' do
|
112
|
+
expect(subject).to receive(:apns_priority).at_least(:once)
|
113
|
+
subject.headers
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'uses #apns_expiration' do
|
117
|
+
expect(subject).to receive(:apns_expiration).at_least(:once)
|
118
|
+
subject.headers
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'includes #uuid' do
|
122
|
+
subject.uuid = 'ABCD1234-ABCD-1234-5678-ABCDEF123456'
|
123
|
+
expect(subject.headers).to include('apns-id' => 'ABCD1234-ABCD-1234-5678-ABCDEF123456')
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'includes #collapse_uuid' do
|
127
|
+
subject.collapse_uuid = 'ABCD1234-ABCD-1234-5678-ABCDEF123456'
|
128
|
+
expect(subject.headers).to include('apns-collapse-id' => 'ABCD1234-ABCD-1234-5678-ABCDEF123456')
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'includes #priority' do
|
132
|
+
subject.priority = 10
|
133
|
+
expect(subject.headers).to include('apns-priority' => 10)
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'includes #expiration' do
|
137
|
+
subject.expiration = 10
|
138
|
+
expect(subject.headers).to include('apns-expiration' => 10)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
describe '#payload' do
|
143
|
+
let(:payload) { subject.payload }
|
144
|
+
|
145
|
+
it 'includes #metadata' do
|
146
|
+
subject.metadata = { 'key' => 'value' }
|
147
|
+
expect(payload).to include('key' => 'value')
|
148
|
+
end
|
149
|
+
|
150
|
+
it 'includes #payload_aps' do
|
151
|
+
expect(subject).to receive(:payload_aps).and_return('key' => 'value')
|
152
|
+
expect(payload).to include('aps' => { 'key' => 'value' })
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
describe '#payload_aps' do
|
157
|
+
let(:payload_aps) do
|
158
|
+
subject.instance_eval { payload_aps }
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'includes #payload_alert' do
|
162
|
+
expect(subject).to receive(:payload_alert).and_return('key' => 'value')
|
163
|
+
expect(payload_aps).to include('alert' => { 'key' => 'value' })
|
164
|
+
end
|
165
|
+
|
166
|
+
it 'includes #badge' do
|
167
|
+
subject.badge = 10
|
168
|
+
expect(payload_aps).to include('badge' => 10)
|
169
|
+
end
|
170
|
+
|
171
|
+
it 'includes #sound' do
|
172
|
+
subject.sound = 'default'
|
173
|
+
expect(payload_aps).to include('sound' => 'default')
|
174
|
+
end
|
175
|
+
|
176
|
+
it 'converts #sound into a String given a Symbol' do
|
177
|
+
subject.sound = :default
|
178
|
+
expect(payload_aps).to include('sound' => 'default')
|
179
|
+
end
|
180
|
+
|
181
|
+
it 'includes #category' do
|
182
|
+
subject.category = 'GENERAL'
|
183
|
+
expect(payload_aps).to include('category' => 'GENERAL')
|
184
|
+
end
|
185
|
+
|
186
|
+
it 'includes #content_available when truthy' do
|
187
|
+
subject.content_available = true
|
188
|
+
expect(payload_aps).to include('content-available' => '1')
|
189
|
+
end
|
190
|
+
|
191
|
+
it 'excludes #content_available when falsey' do
|
192
|
+
subject.content_available = false
|
193
|
+
expect(payload_aps).not_to have_key('content-available')
|
194
|
+
end
|
195
|
+
|
196
|
+
it 'includes #mutable_content when truthy' do
|
197
|
+
subject.mutable_content = true
|
198
|
+
expect(payload_aps).to include('mutable-content' => '1')
|
199
|
+
end
|
200
|
+
|
201
|
+
it 'excludes #mutable_content when falsey' do
|
202
|
+
subject.mutable_content = false
|
203
|
+
expect(payload_aps).not_to have_key('mutable-content')
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
describe '#payload_alert' do
|
208
|
+
let(:payload_alert) do
|
209
|
+
subject.instance_eval { payload_alert }
|
210
|
+
end
|
211
|
+
|
212
|
+
it 'includes #title' do
|
213
|
+
subject.title = 'A title'
|
214
|
+
expect(payload_alert).to include('title' => 'A title')
|
215
|
+
end
|
216
|
+
|
217
|
+
it 'includes #subtitle' do
|
218
|
+
subject.subtitle = 'A subtitle'
|
219
|
+
expect(payload_alert).to include('subtitle' => 'A subtitle')
|
220
|
+
end
|
221
|
+
|
222
|
+
it 'includes #body' do
|
223
|
+
subject.body = 'A body'
|
224
|
+
expect(payload_alert).to include('body' => 'A body')
|
225
|
+
end
|
226
|
+
|
227
|
+
it 'includes #action_key' do
|
228
|
+
subject.action_key = 'ACTION_TITLE'
|
229
|
+
expect(payload_alert).to include('action-loc-key' => 'ACTION_TITLE')
|
230
|
+
end
|
231
|
+
|
232
|
+
it 'includes #launch_image' do
|
233
|
+
subject.launch_image = 'launch.png'
|
234
|
+
expect(payload_alert).to include('launch-image' => 'launch.png')
|
235
|
+
end
|
236
|
+
|
237
|
+
context 'when localizing attributes' do
|
238
|
+
let(:localization) { instance_double(PushKit::APNS::Notification::Localization) }
|
239
|
+
|
240
|
+
before do
|
241
|
+
allow(localization).to receive(:is_a?).and_return(false)
|
242
|
+
allow(localization).to receive(:is_a?).with(PushKit::APNS::Notification::Localization).and_return(true)
|
243
|
+
end
|
244
|
+
|
245
|
+
it 'includes the localization payload for #title' do
|
246
|
+
subject.title = localization
|
247
|
+
expect(localization).to receive(:payload).with(:title).and_return(localization: :some_value)
|
248
|
+
expect(payload_alert).to include(localization: :some_value)
|
249
|
+
end
|
250
|
+
|
251
|
+
it 'includes the localization payload for #subtitle' do
|
252
|
+
subject.subtitle = localization
|
253
|
+
expect(localization).to receive(:payload).with(:subtitle).and_return(localization: :some_value)
|
254
|
+
expect(payload_alert).to include(localization: :some_value)
|
255
|
+
end
|
256
|
+
|
257
|
+
it 'includes the localization payload for #body' do
|
258
|
+
subject.body = localization
|
259
|
+
expect(localization).to receive(:payload).with(:body).and_return(localization: :some_value)
|
260
|
+
expect(payload_alert).to include(localization: :some_value)
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|