spacebunny 1.0.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.
@@ -0,0 +1,124 @@
1
+ require 'bunny'
2
+
3
+ module Spacebunny
4
+ module LiveStream
5
+ class Amqp < Base
6
+ DEFAULT_QUEUE_OPTIONS = { passive: true }
7
+ DEFAULT_EXCHANGE_OPTIONS = { passive: true }
8
+ ACK_TYPES = [:manual, :auto]
9
+
10
+ attr_reader :built_live_streams, :client
11
+
12
+ def initialize(*args)
13
+ super(:amqp, *args)
14
+ @built_live_streams = {}
15
+ end
16
+
17
+ def connect
18
+ # 'Fix' attributes: start from common connection configs and adjust attributes to match what Bunny
19
+ # wants as connection args
20
+ connection_params = connection_configs.dup
21
+ connection_params[:user] = connection_params.delete :client
22
+ connection_params[:password] = connection_params.delete :secret
23
+ connection_params[:port] = connection_params.delete(:tls_port) if connection_params[:tls]
24
+ connection_params[:recover_from_connection_close] = connection_params.delete :auto_recover
25
+ connection_params[:log_level] = connection_params.delete(:log_level) || ::Logger::ERROR
26
+
27
+ # Re-create client every time connect is called
28
+ @client = Bunny.new(connection_params)
29
+ @client.start
30
+ end
31
+
32
+ def channel_from_name(name)
33
+ # In @built_channels in fact we have exchanges
34
+ with_channel_check name do
35
+ @built_exchanges[name]
36
+ end
37
+ end
38
+
39
+ def disconnect
40
+ super
41
+ client.stop
42
+ end
43
+
44
+ # Subscribe for messages coming from Live Stream with name 'name'
45
+ # Each subscriber will receive a copy of messages flowing through the Live Stream
46
+ def message_from(name, options = {}, &block)
47
+ receive_message_from name, options, &block
48
+ end
49
+
50
+ # Subscribe for messages coming from cache of Live Stream with name 'name'
51
+ # The Live Stream will dispatch a message to the first ready subscriber in a round-robin fashion.
52
+ def message_from_cache(name, options = {}, &block)
53
+ options[:from_cache] = true
54
+ receive_message_from name, options, &block
55
+ end
56
+
57
+ private
58
+
59
+ def check_client
60
+ raise ClientNotConnected, 'Client not connected. Did you call client.connect?' unless client_connected?
61
+ end
62
+
63
+ def client_connected?
64
+ client && client.status.eql?(:open)
65
+ end
66
+
67
+ def live_stream_data_from_name(name)
68
+ # Find the live_stream from provided name
69
+ unless live_stream_data = live_streams.find { |ls| ls[:name] == name }
70
+ raise LiveStreamNotFound.new(name)
71
+ end
72
+ live_stream_data
73
+ end
74
+
75
+ def parse_ack(ack)
76
+ to_ack = false
77
+ auto_ack = false
78
+ if ack
79
+ raise AckTypeError unless ACK_TYPES.include?(ack)
80
+ to_ack = true
81
+ case ack
82
+ when :manual
83
+ auto_ack = false
84
+ when :auto
85
+ auto_ack = true
86
+ end
87
+ end
88
+ return to_ack, auto_ack
89
+ end
90
+
91
+ def receive_message_from(name, options)
92
+ unless block_given?
93
+ raise BlockRequired
94
+ end
95
+ name = name.to_s
96
+ blocking = options.fetch :wait, false
97
+ to_ack, auto_ack = parse_ack options.fetch(:ack, :manual)
98
+ from_cache = options.fetch :from_cache, false
99
+
100
+ check_client
101
+ ls_channel = client.create_channel
102
+ live_stream_name = "#{live_stream_data_from_name(name)[:id]}.live_stream"
103
+ if from_cache
104
+ live_stream = ls_channel.queue live_stream_name, DEFAULT_QUEUE_OPTIONS
105
+ else
106
+ ls_exchange = ls_channel.fanout live_stream_name, DEFAULT_EXCHANGE_OPTIONS
107
+ live_stream = ls_channel.queue("#{client}_#{Time.now.to_f}.live_stream.temp", auto_delete: true)
108
+ .bind ls_exchange, routing_key: '#'
109
+ end
110
+
111
+ live_stream.subscribe(block: blocking, manual_ack: to_ack) do |delivery_info, metadata, payload|
112
+ message = LiveStream::Message.new ls_channel, options, delivery_info, metadata, payload
113
+
114
+ yield message
115
+
116
+ # If ack is :auto then ack current message
117
+ if to_ack && auto_ack
118
+ message.ack
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,198 @@
1
+ require 'json'
2
+ require 'http'
3
+
4
+ module Spacebunny
5
+ module LiveStream
6
+
7
+ # Proxy to Base.new
8
+ def self.new(*args)
9
+ Amqp.new *args
10
+ end
11
+
12
+ class Base
13
+ attr_accessor :api_endpoint, :auto_recover, :raise_on_error, :client, :secret, :host, :vhost, :live_streams
14
+ attr_reader :log_to, :log_level, :logger, :custom_connection_configs, :auto_connection_configs,
15
+ :connection_configs, :auto_configs, :tls, :tls_cert, :tls_key, :tls_ca_certificates, :verify_peer
16
+
17
+ def initialize(protocol, *args)
18
+ @protocol = protocol
19
+ @custom_connection_configs = {}
20
+ @auto_connection_configs = {}
21
+ options = args.extract_options.deep_symbolize_keys
22
+
23
+ @client = options[:client] || raise(ClientRequired)
24
+ @secret = options[:secret] || raise(SecretRequired)
25
+ @api_endpoint = options[:api_endpoint] || {}
26
+
27
+ extract_custom_connection_configs_from options
28
+ set_live_streams options[:live_streams]
29
+
30
+ @raise_on_error = options[:raise_on_error]
31
+ @log_to = options[:log_to] || STDOUT
32
+ @log_level = options[:log_level] || ::Logger::WARN
33
+ @logger = options[:logger] || build_logger
34
+ end
35
+
36
+ def api_endpoint=(options)
37
+ unless options.is_a? Hash
38
+ raise ArgumentError, 'api_endpoint must be an Hash. See doc for further info'
39
+ end
40
+ @api_endpoint = options.deep_symbolize_keys
41
+ end
42
+
43
+ def connection_configs
44
+ return @connection_configs if @connection_configs
45
+ if auto_configure?
46
+ # If key is specified, retrieve configs from APIs endpoint
47
+ @auto_configs = EndpointConnection.new(@api_endpoint.merge(client: @client, secret: @secret)).configs
48
+ check_and_add_live_streams @auto_configs[:live_streams]
49
+ @auto_connection_configs = normalize_auto_connection_configs
50
+ end
51
+ # Build final connection_configs
52
+ @connection_configs = merge_connection_configs
53
+ # Check for required params presence
54
+ check_connection_configs
55
+ @connection_configs
56
+ end
57
+
58
+ def connect
59
+ logger.warn "connect method must be implemented on class responsibile to handle protocol '#{@protocol}'"
60
+ end
61
+
62
+ def connection_options=(options)
63
+ unless options.is_a? Hash
64
+ raise ArgumentError, 'connection_options must be an Hash. See doc for further info'
65
+ end
66
+ extract_custom_connection_configs_from options.with_indifferent_access
67
+ end
68
+
69
+ def disconnect
70
+ @connection_configs = nil
71
+ end
72
+
73
+ # Stub method: must be implemented on the class responsible to handle the protocol
74
+ def on_receive(options = {})
75
+ logger.warn "on_receive method must be implemented on class responsibile to handle protocol '#{@protocol}'"
76
+ end
77
+
78
+ def auto_configure?
79
+ @client && @secret
80
+ end
81
+
82
+ def auto_recover
83
+ connection_configs[:auto_recover]
84
+ end
85
+
86
+ def host
87
+ connection_configs[:host]
88
+ end
89
+
90
+ def secret
91
+ connection_configs[:secret]
92
+ end
93
+
94
+ def vhost
95
+ connection_configs[:vhost]
96
+ end
97
+
98
+ private
99
+
100
+ # @private
101
+ # Check if live_streams are an array
102
+ def set_live_streams(live_streams)
103
+ if live_streams && !live_streams.is_a?(Array)
104
+ raise StreamsMustBeAnArray
105
+ end
106
+ check_and_add_live_streams(live_streams)
107
+ end
108
+
109
+ # @private
110
+ # Check for required params presence
111
+ def check_connection_configs
112
+ # Do nothing ATM
113
+ end
114
+
115
+ # @private
116
+ # Merge auto_connection_configs and custom_connection_configs
117
+ def merge_connection_configs
118
+ auto_connection_configs.merge(custom_connection_configs) do |key, old_val, new_val|
119
+ if new_val.nil?
120
+ old_val
121
+ else
122
+ new_val
123
+ end
124
+ end
125
+ end
126
+
127
+ # @private
128
+ def build_logger
129
+ logger = ::Logger.new(@log_to)
130
+ logger.level = normalize_log_level
131
+ logger.progname = 'Spacebunny'
132
+ Spacebunny.logger = logger
133
+ end
134
+
135
+ # @private
136
+ def extract_custom_connection_configs_from(options)
137
+ @custom_connection_configs = options
138
+ # Auto_recover from connection.close by default
139
+ @custom_connection_configs[:auto_recover] = @custom_connection_configs.delete(:auto_recover) || true
140
+ @custom_connection_configs[:host] = @custom_connection_configs.delete :host
141
+ if @custom_connection_configs[:protocols] && custom_connection_configs[:protocols][@protocol]
142
+ @custom_connection_configs[:port] = @custom_connection_configs[:protocols][@protocol].delete :port
143
+ @custom_connection_configs[:tls_port] = @custom_connection_configs[:protocols][@protocol].delete :tls_port
144
+ end
145
+ @custom_connection_configs[:vhost] = @custom_connection_configs.delete :vhost
146
+ @custom_connection_configs[:client] = @custom_connection_configs.delete :client
147
+ @custom_connection_configs[:secret] = @custom_connection_configs.delete :secret
148
+ end
149
+
150
+ # @private
151
+ def check_and_add_live_streams(chs)
152
+ @live_streams = [] unless @live_streams
153
+ return unless chs
154
+ chs.each do |ch|
155
+ case ch
156
+ when Hash
157
+ ch.symbolize_keys!
158
+ # Check for required attributes
159
+ [:id, :name].each do |attr|
160
+ unless ch[attr]
161
+ raise LiveStreamParamError(ch[:name], attr)
162
+ end
163
+ end
164
+ @live_streams << ch
165
+ else
166
+ raise LiveStreamFormatError
167
+ end
168
+ end
169
+ end
170
+
171
+ # @private
172
+ # Translate from auto configs given by APIs endpoint to a common format
173
+ def normalize_auto_connection_configs
174
+ {
175
+ host: @auto_configs[:connection][:host],
176
+ port: @auto_configs[:connection][:protocols][@protocol][:port],
177
+ tls_port: @auto_configs[:connection][:protocols][@protocol][:tls_port],
178
+ vhost: @auto_configs[:connection][:vhost],
179
+ client: @auto_configs[:connection][:client],
180
+ secret: @auto_configs[:connection][:secret]
181
+ }
182
+ end
183
+
184
+ # @private
185
+ def normalize_log_level
186
+ case @log_level
187
+ when :debug, ::Logger::DEBUG, 'debug' then ::Logger::DEBUG
188
+ when :info, ::Logger::INFO, 'info' then ::Logger::INFO
189
+ when :warn, ::Logger::WARN, 'warn' then ::Logger::WARN
190
+ when :error, ::Logger::ERROR, 'error' then ::Logger::ERROR
191
+ when :fatal, ::Logger::FATAL, 'fatal' then ::Logger::FATAL
192
+ else
193
+ Logger::WARN
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,38 @@
1
+ module Spacebunny
2
+ module LiveStream
3
+ class Message
4
+ attr_reader :live_stream, :sender_id, :channel_name, :delivery_info, :metadata, :payload
5
+
6
+ def initialize(live_stream, options, delivery_info, metadata, payload)
7
+ @live_stream = live_stream
8
+ @options = options
9
+ @delivery_info = delivery_info
10
+ @metadata = metadata
11
+ @payload = payload
12
+
13
+ set_sender_id_and_channel
14
+ end
15
+
16
+ def ack(options = {})
17
+ multiple = options.fetch :multiple, false
18
+ @live_stream.acknowledge @delivery_info.delivery_tag, multiple
19
+ end
20
+
21
+ def nack(options = {})
22
+ multiple = options.fetch :multiple, false
23
+ requeue = options.fetch :requeue, false
24
+ @live_stream.nack @delivery_info.delivery_tag, multiple, requeue
25
+ end
26
+
27
+ def from_api?
28
+ !@metadata[:headers].nil? && @metadata[:headers]['x-from-sb-api']
29
+ end
30
+
31
+ private
32
+
33
+ def set_sender_id_and_channel
34
+ @sender_id, @channel_name = @delivery_info[:routing_key].split('.')
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ module Spacebunny
2
+ # Log on multiple outputs at the same time
3
+ #
4
+ # Usage example:
5
+ #
6
+ # log_file = File.open("log/debug.log", "a")
7
+ # Logger.new Spacebunny::Logger.new(STDOUT, log_file)
8
+
9
+ class Logger
10
+ def initialize(*args)
11
+ @streams = []
12
+ args.each do |a|
13
+ case a
14
+ when String
15
+ # This is a file path
16
+ puts File.open(a, 'a+').inspect
17
+ @streams << File.open(a, 'a+')
18
+ else
19
+ @streams << a
20
+ end
21
+ end
22
+ end
23
+
24
+ def write(*args)
25
+ @streams.each do |lo|
26
+ lo.write(*args)
27
+ lo.flush
28
+ end
29
+ end
30
+
31
+ def close
32
+ @streams.each(&:close)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,185 @@
1
+ class Hash
2
+ # Returns a new hash with all keys converted using the block operation.
3
+ #
4
+ # hash = { name: 'Rob', age: '28' }
5
+ #
6
+ # hash.transform_keys{ |key| key.to_s.upcase }
7
+ # # => {"NAME"=>"Rob", "AGE"=>"28"}
8
+ def transform_keys
9
+ return enum_for(:transform_keys) unless block_given?
10
+ result = self.class.new
11
+ each_key do |key|
12
+ result[yield(key)] = self[key]
13
+ end
14
+ result
15
+ end
16
+
17
+ # Destructively convert all keys using the block operations.
18
+ # Same as transform_keys but modifies +self+.
19
+ def transform_keys!
20
+ return enum_for(:transform_keys!) unless block_given?
21
+ keys.each do |key|
22
+ self[yield(key)] = delete(key)
23
+ end
24
+ self
25
+ end
26
+
27
+ # Returns a new hash with all keys converted to strings.
28
+ #
29
+ # hash = { name: 'Rob', age: '28' }
30
+ #
31
+ # hash.stringify_keys
32
+ # # => {"name"=>"Rob", "age"=>"28"}
33
+ def stringify_keys
34
+ transform_keys{ |key| key.to_s }
35
+ end
36
+
37
+ # Destructively convert all keys to strings. Same as
38
+ # +stringify_keys+, but modifies +self+.
39
+ def stringify_keys!
40
+ transform_keys!{ |key| key.to_s }
41
+ end
42
+
43
+ # Returns a new hash with all keys converted to symbols, as long as
44
+ # they respond to +to_sym+.
45
+ #
46
+ # hash = { 'name' => 'Rob', 'age' => '28' }
47
+ #
48
+ # hash.symbolize_keys
49
+ # # => {:name=>"Rob", :age=>"28"}
50
+ def symbolize_keys
51
+ transform_keys{ |key| key.to_sym rescue key }
52
+ end
53
+ alias_method :to_options, :symbolize_keys
54
+
55
+ # Destructively convert all keys to symbols, as long as they respond
56
+ # to +to_sym+. Same as +symbolize_keys+, but modifies +self+.
57
+ def symbolize_keys!
58
+ transform_keys!{ |key| key.to_sym rescue key }
59
+ end
60
+ alias_method :to_options!, :symbolize_keys!
61
+
62
+ # Validate all keys in a hash match <tt>*valid_keys</tt>, raising
63
+ # ArgumentError on a mismatch.
64
+ #
65
+ # Note that keys are treated differently than HashWithIndifferentAccess,
66
+ # meaning that string and symbol keys will not match.
67
+ #
68
+ # { name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age"
69
+ # { name: 'Rob', age: '28' }.assert_valid_keys('name', 'age') # => raises "ArgumentError: Unknown key: :name. Valid keys are: 'name', 'age'"
70
+ # { name: 'Rob', age: '28' }.assert_valid_keys(:name, :age) # => passes, raises nothing
71
+ def assert_valid_keys(*valid_keys)
72
+ valid_keys.flatten!
73
+ each_key do |k|
74
+ unless valid_keys.include?(k)
75
+ raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}")
76
+ end
77
+ end
78
+ end
79
+
80
+ # Returns a new hash with all keys converted by the block operation.
81
+ # This includes the keys from the root hash and from all
82
+ # nested hashes and arrays.
83
+ #
84
+ # hash = { person: { name: 'Rob', age: '28' } }
85
+ #
86
+ # hash.deep_transform_keys{ |key| key.to_s.upcase }
87
+ # # => {"PERSON"=>{"NAME"=>"Rob", "AGE"=>"28"}}
88
+ def deep_transform_keys(&block)
89
+ _deep_transform_keys_in_object(self, &block)
90
+ end
91
+
92
+ # Destructively convert all keys by using the block operation.
93
+ # This includes the keys from the root hash and from all
94
+ # nested hashes and arrays.
95
+ def deep_transform_keys!(&block)
96
+ _deep_transform_keys_in_object!(self, &block)
97
+ end
98
+
99
+ # Returns a new hash with all keys converted to strings.
100
+ # This includes the keys from the root hash and from all
101
+ # nested hashes and arrays.
102
+ #
103
+ # hash = { person: { name: 'Rob', age: '28' } }
104
+ #
105
+ # hash.deep_stringify_keys
106
+ # # => {"person"=>{"name"=>"Rob", "age"=>"28"}}
107
+ def deep_stringify_keys
108
+ deep_transform_keys{ |key| key.to_s }
109
+ end
110
+
111
+ # Destructively convert all keys to strings.
112
+ # This includes the keys from the root hash and from all
113
+ # nested hashes and arrays.
114
+ def deep_stringify_keys!
115
+ deep_transform_keys!{ |key| key.to_s }
116
+ end
117
+
118
+ # Returns a new hash with all keys converted to symbols, as long as
119
+ # they respond to +to_sym+. This includes the keys from the root hash
120
+ # and from all nested hashes and arrays.
121
+ #
122
+ # hash = { 'person' => { 'name' => 'Rob', 'age' => '28' } }
123
+ #
124
+ # hash.deep_symbolize_keys
125
+ # # => {:person=>{:name=>"Rob", :age=>"28"}}
126
+ def deep_symbolize_keys
127
+ deep_transform_keys{ |key| key.to_sym rescue key }
128
+ end
129
+
130
+ # Destructively convert all keys to symbols, as long as they respond
131
+ # to +to_sym+. This includes the keys from the root hash and from all
132
+ # nested hashes and arrays.
133
+ def deep_symbolize_keys!
134
+ deep_transform_keys!{ |key| key.to_sym rescue key }
135
+ end
136
+
137
+ private
138
+ # support methods for deep transforming nested hashes and arrays
139
+ def _deep_transform_keys_in_object(object, &block)
140
+ case object
141
+ when Hash
142
+ object.each_with_object({}) do |(key, value), result|
143
+ result[yield(key)] = _deep_transform_keys_in_object(value, &block)
144
+ end
145
+ when Array
146
+ object.map {|e| _deep_transform_keys_in_object(e, &block) }
147
+ else
148
+ object
149
+ end
150
+ end
151
+
152
+ def _deep_transform_keys_in_object!(object, &block)
153
+ case object
154
+ when Hash
155
+ object.keys.each do |key|
156
+ value = object.delete(key)
157
+ object[yield(key)] = _deep_transform_keys_in_object!(value, &block)
158
+ end
159
+ object
160
+ when Array
161
+ object.map! {|e| _deep_transform_keys_in_object!(e, &block)}
162
+ else
163
+ object
164
+ end
165
+ end
166
+ end
167
+
168
+ class Array
169
+ # Extracts options from a set of arguments. Removes and returns the last
170
+ # element in the array if it's a hash, otherwise returns a blank hash.
171
+ #
172
+ # def options(*args)
173
+ # args.extract_options!
174
+ # end
175
+ #
176
+ # options(1, 2) # => {}
177
+ # options(1, 2, a: :b) # => {:a=>:b}
178
+ def extract_options
179
+ if last.is_a?(Hash)
180
+ pop
181
+ else
182
+ {}
183
+ end
184
+ end
185
+ end