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.
@@ -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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'push_kit/apns/constants'
4
+
5
+ RSpec.describe PushKit::APNS do
6
+ it 'has a semantic version' do
7
+ expect(described_class::VERSION).to match(/[0-9]+\.[0-9]+\.[0-9]+/)
8
+ end
9
+ 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