active_record_streams 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +35 -0
- data/.gitignore +12 -0
- data/.rspec +5 -0
- data/.rubocop.yml +10 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +96 -0
- data/MIT-LICENSE +20 -0
- data/README.md +168 -0
- data/Rakefile +8 -0
- data/active_record_streams.gemspec +55 -0
- data/lib/active_record_streams.rb +26 -0
- data/lib/active_record_streams/config.rb +15 -0
- data/lib/active_record_streams/config_spec.rb +32 -0
- data/lib/active_record_streams/credentials.rb +31 -0
- data/lib/active_record_streams/credentials_spec.rb +79 -0
- data/lib/active_record_streams/extensions/active_record/base.rb +41 -0
- data/lib/active_record_streams/extensions/active_record/base_spec.rb +45 -0
- data/lib/active_record_streams/extensions/active_record/persistence.rb +17 -0
- data/lib/active_record_streams/extensions/active_record/persistence_spec.rb +36 -0
- data/lib/active_record_streams/extensions/active_record/relation.rb +60 -0
- data/lib/active_record_streams/extensions/active_record/relation_spec.rb +41 -0
- data/lib/active_record_streams/message.rb +21 -0
- data/lib/active_record_streams/message_spec.rb +29 -0
- data/lib/active_record_streams/publishers/http_stream.rb +58 -0
- data/lib/active_record_streams/publishers/http_stream_spec.rb +73 -0
- data/lib/active_record_streams/publishers/kinesis_client.rb +26 -0
- data/lib/active_record_streams/publishers/kinesis_client_spec.rb +49 -0
- data/lib/active_record_streams/publishers/kinesis_stream.rb +64 -0
- data/lib/active_record_streams/publishers/kinesis_stream_spec.rb +93 -0
- data/lib/active_record_streams/publishers/sns_client.rb +25 -0
- data/lib/active_record_streams/publishers/sns_client_spec.rb +46 -0
- data/lib/active_record_streams/publishers/sns_stream.rb +52 -0
- data/lib/active_record_streams/publishers/sns_stream_spec.rb +89 -0
- data/lib/active_record_streams/version.rb +5 -0
- data/lib/active_record_streams/version_spec.rb +9 -0
- data/lib/active_record_streams_spec.rb +21 -0
- metadata +199 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'message'
|
4
|
+
|
5
|
+
RSpec.describe ActiveRecordStreams::Message do
|
6
|
+
let(:table_name) { 'hello_world' }
|
7
|
+
let(:action_type) { :create }
|
8
|
+
let(:old_image) { { 'hello' => 'world' } }
|
9
|
+
let(:new_image) { { 'hello' => 'new world' } }
|
10
|
+
|
11
|
+
subject do
|
12
|
+
described_class.new(table_name, action_type, old_image, new_image)
|
13
|
+
end
|
14
|
+
|
15
|
+
describe '#json' do
|
16
|
+
let(:expected_value) do
|
17
|
+
{
|
18
|
+
TableName: table_name,
|
19
|
+
ActionType: action_type,
|
20
|
+
OldImage: old_image,
|
21
|
+
NewImage: new_image
|
22
|
+
}.to_json
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'represents message in json format' do
|
26
|
+
expect(subject.json).to eq(expected_value)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
|
5
|
+
module ActiveRecordStreams
|
6
|
+
module Publishers
|
7
|
+
class HttpStream
|
8
|
+
ANY_TABLE = '*'
|
9
|
+
DEFAULT_CONTENT_TYPE = 'application/json'
|
10
|
+
|
11
|
+
def initialize(
|
12
|
+
url:,
|
13
|
+
headers: {},
|
14
|
+
table_name: ANY_TABLE,
|
15
|
+
ignored_tables: []
|
16
|
+
)
|
17
|
+
@url = url
|
18
|
+
@headers = headers
|
19
|
+
@table_name = table_name
|
20
|
+
@ignored_tables = ignored_tables
|
21
|
+
end
|
22
|
+
|
23
|
+
def publish(table_name, message)
|
24
|
+
return unless (any_table? && allowed_table?(table_name)) ||
|
25
|
+
table_name == @table_name
|
26
|
+
|
27
|
+
request.body = message.json
|
28
|
+
http.request(request)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def any_table?
|
34
|
+
@table_name == ANY_TABLE
|
35
|
+
end
|
36
|
+
|
37
|
+
def allowed_table?(table_name)
|
38
|
+
!@ignored_tables.include?(table_name)
|
39
|
+
end
|
40
|
+
|
41
|
+
def request
|
42
|
+
@request ||= Net::HTTP::Post.new(uri.request_uri, headers)
|
43
|
+
end
|
44
|
+
|
45
|
+
def http
|
46
|
+
@http ||= Net::HTTP.new(uri.host, uri.port)
|
47
|
+
end
|
48
|
+
|
49
|
+
def uri
|
50
|
+
@uri ||= URI.parse(@url)
|
51
|
+
end
|
52
|
+
|
53
|
+
def headers
|
54
|
+
{ 'Content-Type': DEFAULT_CONTENT_TYPE }.merge(@headers)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'http_stream'
|
4
|
+
|
5
|
+
RSpec.describe ActiveRecordStreams::Publishers::HttpStream do
|
6
|
+
let(:url) { 'http://hello.world' }
|
7
|
+
let(:headers) { { 'Authorization' => 'Bearer hello' } }
|
8
|
+
let(:desired_table_name) { '*' }
|
9
|
+
let(:actual_table_name) { 'lovely_records' }
|
10
|
+
let(:ignored_tables) { [] }
|
11
|
+
|
12
|
+
let(:request) { double('body=': nil, request: nil) }
|
13
|
+
let(:http_client) { double('body=': nil, request: nil) }
|
14
|
+
let(:message) { double(json: '{}') }
|
15
|
+
|
16
|
+
subject do
|
17
|
+
described_class.new(
|
18
|
+
url: url,
|
19
|
+
headers: headers,
|
20
|
+
table_name: desired_table_name,
|
21
|
+
ignored_tables: ignored_tables
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
before do
|
26
|
+
allow(Net::HTTP::Post).to receive(:new).and_return(request)
|
27
|
+
allow(Net::HTTP).to receive(:new).and_return(http_client)
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#publish' do
|
31
|
+
context 'any table' do
|
32
|
+
it 'sends event to a http target' do
|
33
|
+
subject.publish(actual_table_name, message)
|
34
|
+
|
35
|
+
expect(request).to have_received(:body=).with(message.json)
|
36
|
+
expect(http_client).to have_received(:request).with(request)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'specific table' do
|
41
|
+
let(:desired_table_name) { actual_table_name }
|
42
|
+
|
43
|
+
it 'sends event to a http target' do
|
44
|
+
subject.publish(actual_table_name, message)
|
45
|
+
|
46
|
+
expect(request).to have_received(:body=).with(message.json)
|
47
|
+
expect(http_client).to have_received(:request).with(request)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'non-matching table' do
|
52
|
+
let(:desired_table_name) { 'another_table' }
|
53
|
+
|
54
|
+
it 'does not send event to a http target' do
|
55
|
+
subject.publish(actual_table_name, message)
|
56
|
+
|
57
|
+
expect(request).not_to have_received(:body=).with(message.json)
|
58
|
+
expect(http_client).not_to have_received(:request).with(request)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'blacklisted table' do
|
63
|
+
let(:ignored_tables) { [actual_table_name] }
|
64
|
+
|
65
|
+
it 'does not send event to a http target' do
|
66
|
+
subject.publish(actual_table_name, message)
|
67
|
+
|
68
|
+
expect(request).not_to have_received(:body=).with(message.json)
|
69
|
+
expect(http_client).not_to have_received(:request).with(request)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aws-sdk'
|
4
|
+
|
5
|
+
module ActiveRecordStreams
|
6
|
+
module Publishers
|
7
|
+
class KinesisClient
|
8
|
+
def publish(stream_name, partition_key, data, overrides = {})
|
9
|
+
client.put_record(
|
10
|
+
stream_name: stream_name,
|
11
|
+
data: data,
|
12
|
+
partition_key: partition_key,
|
13
|
+
**overrides
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def client
|
20
|
+
@client ||= Aws::Kinesis::Client.new(
|
21
|
+
::ActiveRecordStreams::Credentials.new.build_credentials
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'kinesis_client'
|
4
|
+
|
5
|
+
RSpec.describe ActiveRecordStreams::Publishers::KinesisClient do
|
6
|
+
let(:stream_name) { 'lovely-stream' }
|
7
|
+
let(:partition_key) { 'abcdefghihklmopqrstuvwxyz' }
|
8
|
+
let(:data) { 'dummy data' }
|
9
|
+
let(:overrides) { {} }
|
10
|
+
|
11
|
+
let(:aws_kinesis_client) { double }
|
12
|
+
|
13
|
+
before do
|
14
|
+
allow(Aws::Kinesis::Client).to receive(:new).and_return(aws_kinesis_client)
|
15
|
+
end
|
16
|
+
|
17
|
+
describe '#publish' do
|
18
|
+
subject do
|
19
|
+
described_class.new.publish(stream_name, partition_key, data, overrides)
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'no overrides' do
|
23
|
+
it 'calls aws sdk' do
|
24
|
+
expect(aws_kinesis_client).to receive(:put_record).with(
|
25
|
+
stream_name: stream_name,
|
26
|
+
data: data,
|
27
|
+
partition_key: partition_key
|
28
|
+
)
|
29
|
+
|
30
|
+
subject
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context 'overrides' do
|
35
|
+
let(:overrides) { { additional_param: true } }
|
36
|
+
|
37
|
+
it 'calls aws sdk' do
|
38
|
+
expect(aws_kinesis_client).to receive(:put_record).with(
|
39
|
+
stream_name: stream_name,
|
40
|
+
data: data,
|
41
|
+
partition_key: partition_key,
|
42
|
+
additional_param: true
|
43
|
+
)
|
44
|
+
|
45
|
+
subject
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aws-sdk'
|
4
|
+
|
5
|
+
module ActiveRecordStreams
|
6
|
+
module Publishers
|
7
|
+
class KinesisStream
|
8
|
+
ANY_TABLE = '*'
|
9
|
+
PARTITION_KEY_TIME_FORMAT = '%Y%m%dT%H%M%S'
|
10
|
+
|
11
|
+
##
|
12
|
+
# @param [String] stream_name
|
13
|
+
# @param [String] table_name
|
14
|
+
# @param [Enumerable<String>] ignored_tables
|
15
|
+
# @param [Hash] overrides
|
16
|
+
|
17
|
+
def initialize(
|
18
|
+
stream_name:,
|
19
|
+
table_name: ANY_TABLE,
|
20
|
+
ignored_tables: [],
|
21
|
+
overrides: {}
|
22
|
+
)
|
23
|
+
@stream_name = stream_name
|
24
|
+
@table_name = table_name
|
25
|
+
@ignored_tables = ignored_tables
|
26
|
+
@overrides = overrides
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# @param [String] table_name
|
31
|
+
# @param [ActiveRecordStreams::Message] message
|
32
|
+
|
33
|
+
def publish(table_name, message)
|
34
|
+
return unless (any_table? && allowed_table?(table_name)) ||
|
35
|
+
table_name == @table_name
|
36
|
+
|
37
|
+
client.publish(
|
38
|
+
@stream_name,
|
39
|
+
partition_key(table_name),
|
40
|
+
message.json,
|
41
|
+
@overrides
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def any_table?
|
48
|
+
@table_name == ANY_TABLE
|
49
|
+
end
|
50
|
+
|
51
|
+
def allowed_table?(table_name)
|
52
|
+
!@ignored_tables.include?(table_name)
|
53
|
+
end
|
54
|
+
|
55
|
+
def partition_key(table_name)
|
56
|
+
"#{table_name}-#{Time.now.utc.strftime(PARTITION_KEY_TIME_FORMAT)}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def client
|
60
|
+
@client ||= KinesisClient.new
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'kinesis_stream'
|
4
|
+
|
5
|
+
RSpec.describe ActiveRecordStreams::Publishers::KinesisStream do
|
6
|
+
let(:stream_name) {}
|
7
|
+
let(:partition_key) {}
|
8
|
+
let(:desired_table_name) { '*' }
|
9
|
+
let(:actual_table_name) { 'lovely_records' }
|
10
|
+
let(:ignored_tables) { [] }
|
11
|
+
let(:overrides) { {} }
|
12
|
+
|
13
|
+
let(:message) { double(json: '{}') }
|
14
|
+
let(:kinesis_client) { double(publish: nil) }
|
15
|
+
|
16
|
+
subject do
|
17
|
+
described_class.new(
|
18
|
+
stream_name: stream_name,
|
19
|
+
table_name: desired_table_name,
|
20
|
+
ignored_tables: ignored_tables,
|
21
|
+
overrides: overrides
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
before do
|
26
|
+
allow(ActiveRecordStreams::Publishers::KinesisClient)
|
27
|
+
.to receive(:new)
|
28
|
+
.and_return(kinesis_client)
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#publish' do
|
32
|
+
context 'any table' do
|
33
|
+
it 'sends event to kinesis stream' do
|
34
|
+
expect(kinesis_client).to receive(:publish) do |stream, partition_key, data, _overrides|
|
35
|
+
expect(stream).to eq(stream_name)
|
36
|
+
expect(partition_key).to match(/\A#{actual_table_name}-/i)
|
37
|
+
expect(data).to eq(message.json)
|
38
|
+
end
|
39
|
+
|
40
|
+
subject.publish(actual_table_name, message)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
context 'specific table' do
|
45
|
+
let(:desired_table_name) { actual_table_name }
|
46
|
+
|
47
|
+
it 'sends event to a kinesis stream' do
|
48
|
+
expect(kinesis_client).to receive(:publish) do |stream, partition_key, data, _overrides|
|
49
|
+
expect(stream).to eq(stream_name)
|
50
|
+
expect(partition_key).to match(/\A#{actual_table_name}-/i)
|
51
|
+
expect(data).to eq(message.json)
|
52
|
+
end
|
53
|
+
|
54
|
+
subject.publish(actual_table_name, message)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'non-matching table' do
|
59
|
+
let(:desired_table_name) { 'another_table' }
|
60
|
+
|
61
|
+
it 'does not send event to kinesis stream' do
|
62
|
+
expect(kinesis_client).not_to receive(:publish)
|
63
|
+
|
64
|
+
subject.publish(actual_table_name, message)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context 'blacklisted table' do
|
69
|
+
let(:ignored_tables) { [actual_table_name] }
|
70
|
+
|
71
|
+
it 'does not send event to kinesis stream' do
|
72
|
+
expect(kinesis_client).not_to receive(:publish)
|
73
|
+
|
74
|
+
subject.publish(actual_table_name, message)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
context 'with overrides' do
|
79
|
+
let(:overrides) { { 'hello' => 'world' } }
|
80
|
+
|
81
|
+
it 'sends event to a kinesis stream' do
|
82
|
+
expect(kinesis_client).to receive(:publish) do |stream, partition_key, data, overrides|
|
83
|
+
expect(stream).to eq(stream_name)
|
84
|
+
expect(partition_key).to match(/\A#{actual_table_name}-/i)
|
85
|
+
expect(data).to eq(message.json)
|
86
|
+
expect(overrides).to eq(overrides)
|
87
|
+
end
|
88
|
+
|
89
|
+
subject.publish(actual_table_name, message)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aws-sdk'
|
4
|
+
|
5
|
+
module ActiveRecordStreams
|
6
|
+
module Publishers
|
7
|
+
class SnsClient
|
8
|
+
def publish(topic_arn, message, overrides = {})
|
9
|
+
client.publish(
|
10
|
+
topic_arn: topic_arn,
|
11
|
+
message: message,
|
12
|
+
**overrides
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def client
|
19
|
+
@client ||= Aws::SNS::Client.new(
|
20
|
+
::ActiveRecordStreams::Credentials.new.build_credentials
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'sns_client'
|
4
|
+
|
5
|
+
RSpec.describe ActiveRecordStreams::Publishers::SnsClient do
|
6
|
+
let(:topic_arn) { 'arn::some-topic-arn' }
|
7
|
+
let(:message) { 'dummy data' }
|
8
|
+
let(:overrides) { {} }
|
9
|
+
|
10
|
+
let(:aws_sns_client) { double }
|
11
|
+
|
12
|
+
before do
|
13
|
+
allow(Aws::SNS::Client).to receive(:new).and_return(aws_sns_client)
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#publish' do
|
17
|
+
subject do
|
18
|
+
described_class.new.publish(topic_arn, message, overrides)
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'no overrides' do
|
22
|
+
it 'calls aws sdk' do
|
23
|
+
expect(aws_sns_client).to receive(:publish).with(
|
24
|
+
topic_arn: topic_arn,
|
25
|
+
message: message
|
26
|
+
)
|
27
|
+
|
28
|
+
subject
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'overrides' do
|
33
|
+
let(:overrides) { { additional_param: true } }
|
34
|
+
|
35
|
+
it 'calls aws sdk' do
|
36
|
+
expect(aws_sns_client).to receive(:publish).with(
|
37
|
+
topic_arn: topic_arn,
|
38
|
+
message: message,
|
39
|
+
additional_param: true
|
40
|
+
)
|
41
|
+
|
42
|
+
subject
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|