spacebunny 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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