routemaster-client 3.1.0 → 3.1.1
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 +4 -4
- data/.ruby-version +1 -1
- data/.travis.yml +3 -3
- data/CHANGELOG.md +19 -2
- data/Gemfile.lock +32 -30
- data/README.md +103 -12
- data/exe/rtm +11 -0
- data/routemaster-client.gemspec +5 -0
- data/routemaster/cli/base.rb +134 -0
- data/routemaster/cli/helper.rb +61 -0
- data/routemaster/cli/pub.rb +23 -0
- data/routemaster/cli/sub.rb +78 -0
- data/routemaster/cli/token.rb +52 -0
- data/routemaster/cli/top_level.rb +48 -0
- data/routemaster/client.rb +49 -22
- data/routemaster/client/backends/sidekiq/worker.rb +11 -1
- data/routemaster/client/configuration.rb +6 -0
- data/routemaster/client/connection.rb +10 -4
- data/routemaster/client/errors.rb +1 -0
- data/routemaster/client/subscription.rb +32 -0
- data/routemaster/client/topic.rb +22 -0
- data/routemaster/client/version.rb +1 -1
- data/spec/cli/pub_spec.rb +21 -0
- data/spec/cli/sub_spec.rb +61 -0
- data/spec/cli/token_spec.rb +50 -0
- data/spec/client/subscription_spec.rb +19 -0
- data/spec/{topic_spec.rb → client/topic_spec.rb} +2 -2
- data/spec/client_spec.rb +134 -56
- data/spec/spec_helper.rb +28 -0
- metadata +39 -8
- data/routemaster/topic.rb +0 -17
@@ -0,0 +1,32 @@
|
|
1
|
+
module Routemaster
|
2
|
+
module Client
|
3
|
+
class Subscription
|
4
|
+
|
5
|
+
attr_reader :subscriber, :callback, :topics, :events
|
6
|
+
|
7
|
+
def initialize(options)
|
8
|
+
@subscriber = options.fetch('subscriber')
|
9
|
+
@callback = options.fetch('callback')
|
10
|
+
@topics = options.fetch('topics')
|
11
|
+
@events = _symbolize_keys options.fetch('events')
|
12
|
+
end
|
13
|
+
|
14
|
+
def attributes
|
15
|
+
{
|
16
|
+
subscriber: @subscriber,
|
17
|
+
callback: @callback,
|
18
|
+
topics: @topics,
|
19
|
+
events: @events
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def _symbolize_keys(h)
|
26
|
+
{}.tap do |res|
|
27
|
+
h.each { |k,v| res[k.to_sym] = v }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Routemaster
|
2
|
+
module Client
|
3
|
+
class Topic
|
4
|
+
|
5
|
+
attr_reader :name, :publisher, :events
|
6
|
+
|
7
|
+
def initialize(options)
|
8
|
+
@name = options.fetch('name')
|
9
|
+
@publisher = options.fetch('publisher')
|
10
|
+
@events = options.fetch('events')
|
11
|
+
end
|
12
|
+
|
13
|
+
def attributes
|
14
|
+
{ name: @name, publisher: @publisher, events: @events }
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# For backwards compatibility (TODO: remove in v4)
|
21
|
+
Topic = Client::Topic
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'routemaster/cli/top_level'
|
3
|
+
require 'webmock/rspec'
|
4
|
+
|
5
|
+
describe Routemaster::CLI::Pub, type: :cli do
|
6
|
+
before { allow_bus_pulse 'bus.dev', 's3cr3t' }
|
7
|
+
|
8
|
+
context 'with too few arguments' do
|
9
|
+
let(:argv) { [] }
|
10
|
+
it { expect { perform }.to raise_error(Routemaster::CLI::Exit) }
|
11
|
+
it { expect { perform rescue nil }.to change { stderr.string }.to a_string_matching(/Usage/) }
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'with correct arguments' do
|
15
|
+
let(:argv) { %w[pub created widgets https://example.com/widgets/1 -b bus.dev -t s3cr3t] }
|
16
|
+
it {
|
17
|
+
expect(client).to receive(:created).with('widgets', 'https://example.com/widgets/1')
|
18
|
+
perform
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'routemaster/cli/top_level'
|
3
|
+
require 'webmock/rspec'
|
4
|
+
|
5
|
+
describe Routemaster::CLI::Sub, type: :cli do
|
6
|
+
before { allow_bus_pulse 'bus.dev', 's3cr3t' }
|
7
|
+
|
8
|
+
describe 'add' do
|
9
|
+
context 'with correct arguments' do
|
10
|
+
let(:argv) { %w[sub add https://my-service.dev cats dogs -b bus.dev -t s3cr3t] }
|
11
|
+
|
12
|
+
it {
|
13
|
+
expect(client).to receive(:subscribe).with(topics: %w[cats dogs], callback: 'https://my-service.dev')
|
14
|
+
perform
|
15
|
+
}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe 'del' do
|
20
|
+
context 'with a list of topics' do
|
21
|
+
let(:argv) { %w[sub del cats dogs -b bus.dev -t s3cr3t] }
|
22
|
+
it {
|
23
|
+
expect(client).to receive(:unsubscribe).with('cats', 'dogs')
|
24
|
+
perform
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'without arguments' do
|
29
|
+
let(:argv) { %w[sub del -b bus.dev -t s3cr3t] }
|
30
|
+
it {
|
31
|
+
expect(client).to receive(:unsubscribe_all).with(no_args)
|
32
|
+
perform
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe 'list' do
|
38
|
+
context 'with correct arguments' do
|
39
|
+
let(:argv) { %w[sub list -b bus.dev -t s3cr3t] }
|
40
|
+
before {
|
41
|
+
allow(client).to receive(:monitor_subscriptions).and_return([
|
42
|
+
Routemaster::Client::Subscription.new(
|
43
|
+
'subscriber' => 'service--f000-b44r-b44r',
|
44
|
+
'callback' => 'https://serviced.dev',
|
45
|
+
'topics' => %w[cats dogs],
|
46
|
+
'events' => {
|
47
|
+
'queued' => 1234
|
48
|
+
})
|
49
|
+
])
|
50
|
+
}
|
51
|
+
|
52
|
+
it {
|
53
|
+
expect(client).to receive(:monitor_subscriptions).with(no_args)
|
54
|
+
perform
|
55
|
+
}
|
56
|
+
it {
|
57
|
+
expect { perform }.to change { stdout.string }.to a_string_matching(/service--f000-b44r-b44r/)
|
58
|
+
}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'routemaster/cli/top_level'
|
3
|
+
require 'webmock/rspec'
|
4
|
+
|
5
|
+
describe Routemaster::CLI::Token, type: :cli do
|
6
|
+
before { allow_bus_pulse 'bus.dev', 's3cr3t' }
|
7
|
+
|
8
|
+
describe 'add' do
|
9
|
+
context 'with correct arguments' do
|
10
|
+
let(:argv) { %w[token add my-service -b bus.dev -t s3cr3t] }
|
11
|
+
|
12
|
+
it {
|
13
|
+
expect(client).to receive(:token_add).with(name: 'my-service', token: nil)
|
14
|
+
perform
|
15
|
+
}
|
16
|
+
|
17
|
+
it {
|
18
|
+
allow(client).to receive(:token_add).and_return('my-service--dead-0000-beef')
|
19
|
+
expect { perform }.to change { stdout.string }.to("my-service--dead-0000-beef\n")
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe 'del' do
|
25
|
+
context 'with correct arguments' do
|
26
|
+
let(:argv) { %w[token del my-service--dead-0000-beef -b bus.dev -t s3cr3t] }
|
27
|
+
it {
|
28
|
+
expect(client).to receive(:token_del).with(token: 'my-service--dead-0000-beef')
|
29
|
+
perform
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe 'list' do
|
35
|
+
context 'with correct arguments' do
|
36
|
+
let(:argv) { %w[token list -b bus.dev -t s3cr3t] }
|
37
|
+
before {
|
38
|
+
allow(client).to receive(:token_list).and_return({ 'service--t0ken' => 'service' })
|
39
|
+
}
|
40
|
+
|
41
|
+
it {
|
42
|
+
expect(client).to receive(:token_list).with(no_args)
|
43
|
+
perform
|
44
|
+
}
|
45
|
+
it {
|
46
|
+
expect { perform }.to change { stdout.string }.to "service--t0ken\tservice\n"
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'routemaster/client/subscription'
|
3
|
+
|
4
|
+
describe Routemaster::Client::Subscription do
|
5
|
+
describe '#initialize' do
|
6
|
+
let(:options) {{
|
7
|
+
'subscriber' => 'alice',
|
8
|
+
'callback' => 'https://example.com/events',
|
9
|
+
'topics' => %w[widgets],
|
10
|
+
'events' => {},
|
11
|
+
}}
|
12
|
+
|
13
|
+
subject { described_class.new(options) }
|
14
|
+
|
15
|
+
it 'passes' do
|
16
|
+
expect { subject }.not_to raise_error
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/spec/client_spec.rb
CHANGED
@@ -2,9 +2,10 @@ require 'spec_helper'
|
|
2
2
|
require 'spec/support/configuration_helper'
|
3
3
|
require 'routemaster/client'
|
4
4
|
require 'routemaster/client/backends/sidekiq'
|
5
|
-
require 'routemaster/topic'
|
5
|
+
require 'routemaster/client/topic'
|
6
6
|
require 'webmock/rspec'
|
7
7
|
require 'sidekiq/testing'
|
8
|
+
require 'securerandom'
|
8
9
|
|
9
10
|
describe Routemaster::Client do
|
10
11
|
|
@@ -69,10 +70,10 @@ describe Routemaster::Client do
|
|
69
70
|
end
|
70
71
|
end
|
71
72
|
|
72
|
-
shared_examples 'an event sender' do
|
73
|
+
shared_examples 'an event sender' do |spec_options|
|
73
74
|
let(:callback) { 'https://app.example.com/widgets/123' }
|
74
75
|
let(:topic) { 'widgets' }
|
75
|
-
let(:perform) { subject.send(
|
76
|
+
let(:perform) { subject.send(method, topic, callback, **flags) }
|
76
77
|
let(:http_status) { nil }
|
77
78
|
|
78
79
|
before do
|
@@ -97,6 +98,25 @@ describe Routemaster::Client do
|
|
97
98
|
perform
|
98
99
|
end
|
99
100
|
|
101
|
+
it 'sends the url and type' do
|
102
|
+
@stub.with do |req|
|
103
|
+
data = JSON.parse(req.body)
|
104
|
+
expect(data['type']).to eq event
|
105
|
+
expect(data['url']).to eq callback
|
106
|
+
end
|
107
|
+
perform
|
108
|
+
end
|
109
|
+
|
110
|
+
if spec_options && spec_options[:set_timestamp]
|
111
|
+
it 'sets a timestamp' do
|
112
|
+
@stub.with do |req|
|
113
|
+
data = JSON.parse(req.body)
|
114
|
+
expect(data['timestamp']).to be_a_kind_of(Integer)
|
115
|
+
end
|
116
|
+
perform
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
100
120
|
it 'fails with a bad callback URL' do
|
101
121
|
callback.replace 'http.foo.bar'
|
102
122
|
expect { perform }.to raise_error(Routemaster::Client::InvalidArgumentError)
|
@@ -121,7 +141,7 @@ describe Routemaster::Client do
|
|
121
141
|
let(:http_status) { 500 }
|
122
142
|
|
123
143
|
it 'raises an exception' do
|
124
|
-
expect { perform }.to raise_error(
|
144
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'event rejected (status: 500)')
|
125
145
|
end
|
126
146
|
end
|
127
147
|
|
@@ -136,7 +156,7 @@ describe Routemaster::Client do
|
|
136
156
|
|
137
157
|
context 'with explicit timestamp' do
|
138
158
|
let(:timestamp) { (Time.now.to_f * 1e3).to_i }
|
139
|
-
let(:perform) { subject.send(
|
159
|
+
let(:perform) { subject.send(method, topic, callback, t: timestamp) }
|
140
160
|
|
141
161
|
before do
|
142
162
|
@stub = stub_request(:post, 'https://@bus.example.com/topics/widgets').
|
@@ -171,7 +191,7 @@ describe Routemaster::Client do
|
|
171
191
|
|
172
192
|
context 'with a data payload' do
|
173
193
|
let(:timestamp) { (Time.now.to_f * 1e3).to_i }
|
174
|
-
let(:perform) { subject.send(
|
194
|
+
let(:perform) { subject.send(method, topic, callback, data: data) }
|
175
195
|
let(:data) {{ 'foo' => 'bar' }}
|
176
196
|
|
177
197
|
before do
|
@@ -202,7 +222,8 @@ describe Routemaster::Client do
|
|
202
222
|
context 'with default flags' do
|
203
223
|
%w[created updated deleted noop].each do |m|
|
204
224
|
describe "##{m}" do
|
205
|
-
let(:
|
225
|
+
let(:method) { m.to_sym }
|
226
|
+
let(:event) { m.sub(/d$/, '') }
|
206
227
|
let(:flags) { {} }
|
207
228
|
it_behaves_like 'an event sender'
|
208
229
|
end
|
@@ -232,46 +253,49 @@ describe Routemaster::Client do
|
|
232
253
|
|
233
254
|
context 'with the sidekiq async back end configured' do
|
234
255
|
reset_sidekiq_config_between_tests!
|
235
|
-
|
256
|
+
|
236
257
|
before do
|
237
258
|
options[:async_backend] = Routemaster::Client::Backends::Sidekiq.configure do |config|
|
238
259
|
config.queue = :realtime
|
239
260
|
config.retry = true
|
240
261
|
end
|
241
262
|
end
|
242
|
-
|
263
|
+
|
243
264
|
around do |example|
|
244
265
|
Sidekiq::Testing.inline! do
|
245
266
|
example.run
|
246
267
|
end
|
247
268
|
end
|
248
|
-
|
269
|
+
|
249
270
|
context 'with default options' do
|
250
271
|
let(:flags) { {} }
|
251
|
-
|
272
|
+
|
252
273
|
%w[created updated deleted noop].each do |m|
|
253
274
|
describe "##{m}" do
|
254
|
-
let(:
|
275
|
+
let(:method) { m }
|
276
|
+
let(:event) { m.sub(/d$/, '') }
|
255
277
|
it_behaves_like 'an event sender'
|
256
278
|
end
|
257
279
|
end
|
258
280
|
end
|
259
|
-
|
281
|
+
|
260
282
|
context 'with :async option' do
|
261
283
|
let(:flags) { { async: true } }
|
262
|
-
|
284
|
+
|
263
285
|
%w[created updated deleted noop].each do |m|
|
264
286
|
describe "##{m}" do
|
265
|
-
let(:
|
266
|
-
|
287
|
+
let(:method) { m }
|
288
|
+
let(:event) { m.sub(/d$/, '') }
|
289
|
+
it_behaves_like 'an event sender', set_timestamp: true
|
267
290
|
end
|
268
291
|
end
|
269
292
|
end
|
270
|
-
|
293
|
+
|
271
294
|
describe 'deprecated *_async methods' do
|
272
295
|
%w[created updated deleted noop].each do |m|
|
273
296
|
describe "##{m}_async" do
|
274
|
-
let(:
|
297
|
+
let(:method) { "#{m}_async" }
|
298
|
+
let(:event) { m.sub(/d$/, '') }
|
275
299
|
let(:flags) { {} }
|
276
300
|
it_behaves_like 'an event sender'
|
277
301
|
end
|
@@ -324,7 +348,7 @@ describe Routemaster::Client do
|
|
324
348
|
|
325
349
|
it 'fails on HTTP error' do
|
326
350
|
@stub.to_return(status: 500)
|
327
|
-
expect { perform }.to raise_error(
|
351
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'subscribe rejected (status: 500)')
|
328
352
|
end
|
329
353
|
|
330
354
|
it 'accepts a uuid' do
|
@@ -356,7 +380,7 @@ describe Routemaster::Client do
|
|
356
380
|
|
357
381
|
it 'fails on HTTP error' do
|
358
382
|
@stub.to_return(status: 500)
|
359
|
-
expect { perform }.to raise_error(
|
383
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'unsubscribe rejected (status: 500)')
|
360
384
|
end
|
361
385
|
end
|
362
386
|
|
@@ -376,7 +400,7 @@ describe Routemaster::Client do
|
|
376
400
|
|
377
401
|
it 'fails on HTTP error' do
|
378
402
|
@stub.to_return(status: 500)
|
379
|
-
expect { perform }.to raise_error(
|
403
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'unsubscribe all rejected (status: 500)')
|
380
404
|
end
|
381
405
|
end
|
382
406
|
|
@@ -403,58 +427,112 @@ describe Routemaster::Client do
|
|
403
427
|
|
404
428
|
it 'fails on HTTP error' do
|
405
429
|
@stub.to_return(status: 500)
|
406
|
-
expect { perform }.to raise_error(
|
430
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'failed to delete topic (status: 500)')
|
407
431
|
end
|
408
432
|
end
|
409
433
|
|
410
434
|
|
411
|
-
|
435
|
+
context 'monitoring methods' do
|
436
|
+
let(:default_headers) {{}}
|
412
437
|
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
events: 12589
|
420
|
-
}
|
421
|
-
]
|
438
|
+
shared_context 'successful connection to bus' do
|
439
|
+
before do
|
440
|
+
@stub = stub_request(:get, url)
|
441
|
+
.with(basic_auth: [options[:uuid], 'x'], headers: default_headers)
|
442
|
+
.to_return(status: 200, body: expected_result.to_json)
|
443
|
+
end
|
422
444
|
end
|
423
445
|
|
424
|
-
|
446
|
+
shared_context 'failing connection to bus' do
|
425
447
|
before do
|
426
|
-
@stub = stub_request(:get,
|
427
|
-
with(basic_auth: [options[:uuid], 'x'])
|
428
|
-
|
429
|
-
r.headers['Content-Type'] == 'application/json'
|
430
|
-
}.to_return {
|
431
|
-
{ status: 200, body: expected_result.to_json }
|
432
|
-
}
|
448
|
+
@stub = stub_request(:get, url)
|
449
|
+
.with(basic_auth: [options[:uuid], 'x'], headers: default_headers)
|
450
|
+
.to_return(status: 500)
|
433
451
|
end
|
452
|
+
end
|
434
453
|
|
435
|
-
|
436
|
-
|
454
|
+
describe '#monitor_topics' do
|
455
|
+
let(:url) { 'https://bus.example.com/topics' }
|
456
|
+
let(:perform) { subject.monitor_topics }
|
457
|
+
let(:expected_result) do
|
458
|
+
[
|
459
|
+
{
|
460
|
+
name: 'widgets',
|
461
|
+
publisher: 'demo',
|
462
|
+
events: 12589
|
463
|
+
}
|
464
|
+
]
|
465
|
+
end
|
466
|
+
|
467
|
+
context 'the connection to the bus is successful' do
|
468
|
+
include_context 'successful connection to bus'
|
469
|
+
|
470
|
+
it 'expects a collection of topics' do
|
471
|
+
expect(perform.map(&:attributes)).to eql(expected_result)
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
context 'the connection to the bus errors' do
|
476
|
+
include_context 'failing connection to bus'
|
477
|
+
|
478
|
+
it 'expects a collection of topics' do
|
479
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError, 'failed to connect to /topics (status: 500)')
|
480
|
+
end
|
437
481
|
end
|
438
482
|
end
|
439
483
|
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
484
|
+
describe '#monitor_subscriptions' do
|
485
|
+
let(:url) { 'https://bus.example.com/subscriptions' }
|
486
|
+
let(:perform) { subject.monitor_subscriptions }
|
487
|
+
let(:expected_result) do
|
488
|
+
[{
|
489
|
+
subscriber: 'bob',
|
490
|
+
callback: 'https://app.example.com/events',
|
491
|
+
topics: ['widgets', 'kitten'],
|
492
|
+
events: { sent: 1, queued: 100, oldest: 10_000 }
|
493
|
+
}]
|
447
494
|
end
|
448
495
|
|
449
|
-
|
450
|
-
|
496
|
+
context 'the connection to the bus is successful' do
|
497
|
+
include_context 'successful connection to bus'
|
498
|
+
|
499
|
+
it 'expects a collection of subscriptions' do
|
500
|
+
expect(perform.map(&:attributes)).to eql(expected_result)
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
context 'the connection to the bus errors' do
|
505
|
+
include_context 'failing connection to bus'
|
506
|
+
|
507
|
+
it 'expects a collection of topics' do
|
508
|
+
expect { perform }.to raise_error(Routemaster::Client::ConnectionError)
|
509
|
+
end
|
451
510
|
end
|
452
511
|
end
|
453
512
|
end
|
454
|
-
|
455
|
-
describe '#
|
456
|
-
|
513
|
+
|
514
|
+
describe '#reset_connection' do
|
515
|
+
context 'can reset class vars to change params' do
|
516
|
+
let(:instance_uuid) { SecureRandom.uuid }
|
517
|
+
let(:options) {{
|
518
|
+
url: 'https://@bus.example.com',
|
519
|
+
uuid: instance_uuid,
|
520
|
+
verify_ssl: false,
|
521
|
+
lazy: true
|
522
|
+
}}
|
523
|
+
|
524
|
+
before do
|
525
|
+
Routemaster::Client::Connection.reset_connection
|
526
|
+
@stub = stub_request(:get, 'https://@bus.example.com/topics').with({basic_auth: [instance_uuid, 'x']})
|
527
|
+
.to_return(status: 200, body: [{ name: "topic.name", publisher: "topic.publisher", events: "topic.get_count" }].to_json)
|
528
|
+
end
|
529
|
+
|
530
|
+
after { Routemaster::Client::Connection.reset_connection }
|
531
|
+
|
532
|
+
it 'connects with new params' do
|
533
|
+
subject.monitor_topics
|
534
|
+
expect(@stub).to have_been_requested
|
535
|
+
end
|
536
|
+
end
|
457
537
|
end
|
458
|
-
|
459
538
|
end
|
460
|
-
|