active_record_streams 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/PULL_REQUEST_TEMPLATE.md +35 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +5 -0
  5. data/.rubocop.yml +10 -0
  6. data/.travis.yml +12 -0
  7. data/CHANGELOG.md +13 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +96 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +168 -0
  12. data/Rakefile +8 -0
  13. data/active_record_streams.gemspec +55 -0
  14. data/lib/active_record_streams.rb +26 -0
  15. data/lib/active_record_streams/config.rb +15 -0
  16. data/lib/active_record_streams/config_spec.rb +32 -0
  17. data/lib/active_record_streams/credentials.rb +31 -0
  18. data/lib/active_record_streams/credentials_spec.rb +79 -0
  19. data/lib/active_record_streams/extensions/active_record/base.rb +41 -0
  20. data/lib/active_record_streams/extensions/active_record/base_spec.rb +45 -0
  21. data/lib/active_record_streams/extensions/active_record/persistence.rb +17 -0
  22. data/lib/active_record_streams/extensions/active_record/persistence_spec.rb +36 -0
  23. data/lib/active_record_streams/extensions/active_record/relation.rb +60 -0
  24. data/lib/active_record_streams/extensions/active_record/relation_spec.rb +41 -0
  25. data/lib/active_record_streams/message.rb +21 -0
  26. data/lib/active_record_streams/message_spec.rb +29 -0
  27. data/lib/active_record_streams/publishers/http_stream.rb +58 -0
  28. data/lib/active_record_streams/publishers/http_stream_spec.rb +73 -0
  29. data/lib/active_record_streams/publishers/kinesis_client.rb +26 -0
  30. data/lib/active_record_streams/publishers/kinesis_client_spec.rb +49 -0
  31. data/lib/active_record_streams/publishers/kinesis_stream.rb +64 -0
  32. data/lib/active_record_streams/publishers/kinesis_stream_spec.rb +93 -0
  33. data/lib/active_record_streams/publishers/sns_client.rb +25 -0
  34. data/lib/active_record_streams/publishers/sns_client_spec.rb +46 -0
  35. data/lib/active_record_streams/publishers/sns_stream.rb +52 -0
  36. data/lib/active_record_streams/publishers/sns_stream_spec.rb +89 -0
  37. data/lib/active_record_streams/version.rb +5 -0
  38. data/lib/active_record_streams/version_spec.rb +9 -0
  39. data/lib/active_record_streams_spec.rb +21 -0
  40. 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