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