active_record_streams 0.1.0

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