push_kit-apns 1.0.0.pre.beta1

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.
@@ -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