spacebunny 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,229 @@
1
+ require 'json'
2
+ require 'http'
3
+
4
+ module Spacebunny
5
+ module Device
6
+
7
+ # Proxy to Base.new
8
+ def self.new(*args)
9
+ Amqp.new *args
10
+ end
11
+
12
+ class Base
13
+ attr_accessor :key, :api_endpoint, :auto_recover, :raise_on_error, :id, :name, :host, :secret, :vhost, :channels
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
+ key = args.first
23
+
24
+ @key = key || options[:key]
25
+ @api_endpoint = options[:api_endpoint] || {}
26
+
27
+ extract_custom_connection_configs_from options
28
+ set_channels options[:channels]
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(key: @key)).configs
48
+ normalize_and_add_channels @auto_configs[:channels]
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 publish(channel, message, options = {})
75
+ logger.warn "publish method must be implemented on class responsibile to handle protocol '#{@protocol}'"
76
+ end
77
+
78
+ # Stub method: must be implemented on the class responsible to handle the protocol
79
+ def on_receive(options = {}, &block)
80
+ logger.warn "on_receive method must be implemented on class responsibile to handle protocol '#{@protocol}'"
81
+ end
82
+
83
+ def auto_recover
84
+ connection_configs[:auto_recover]
85
+ end
86
+
87
+ def id
88
+ connection_configs[:device_id]
89
+ end
90
+
91
+ def name
92
+ connection_configs[:device_name]
93
+ end
94
+
95
+ def host
96
+ connection_configs[:host]
97
+ end
98
+
99
+ def secret
100
+ connection_configs[:secret]
101
+ end
102
+
103
+ def vhost
104
+ connection_configs[:vhost]
105
+ end
106
+
107
+ protected
108
+
109
+ # @protected
110
+ def auto_configure?
111
+ !@key.nil?
112
+ end
113
+
114
+ def with_channel_check(name)
115
+ unless res = channels.include?(name)
116
+ logger.warn <<-MSG
117
+
118
+ You're going to publish on channel '#{name}', but it does not appear a configured channel.
119
+ If using auto-configuration (device-key) associate the channel to device '#{@auto_configs[:connection][:name]}'
120
+ from web interface.
121
+ If providing manual configuration, please specify channels list through the :channels option
122
+ or through given setter, e.g. client.channels = [:first_channel, :second_channel, ... ])
123
+
124
+ MSG
125
+ end
126
+ if block_given?
127
+ yield
128
+ else
129
+ res
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # @private
136
+ # Check if channels are an array
137
+ def set_channels(channels)
138
+ if channels && !channels.is_a?(Array)
139
+ raise ChannelsMustBeAnArray
140
+ end
141
+ normalize_and_add_channels(channels)
142
+ end
143
+
144
+ # @private
145
+ # Check for required params presence
146
+ def check_connection_configs
147
+ raise DeviceIdMissing unless @connection_configs[:device_id]
148
+ end
149
+
150
+ # @private
151
+ # Merge auto_connection_configs and custom_connection_configs
152
+ def merge_connection_configs
153
+ auto_connection_configs.merge(custom_connection_configs) do |key, old_val, new_val|
154
+ if new_val.nil?
155
+ old_val
156
+ else
157
+ new_val
158
+ end
159
+ end
160
+ end
161
+
162
+ # @private
163
+ def build_logger
164
+ logger = ::Logger.new(@log_to)
165
+ logger.level = normalize_log_level
166
+ logger.progname = 'Spacebunny'
167
+ Spacebunny.logger = logger
168
+ end
169
+
170
+ # @private
171
+ # Copy options to custom_connection_configs and normalize some of the attributes overwriting it
172
+ def extract_custom_connection_configs_from(options)
173
+ @custom_connection_configs = options
174
+ # Auto_recover from connection.close by default
175
+ @custom_connection_configs[:auto_recover] = @custom_connection_configs.delete(:auto_recover) || true
176
+ @custom_connection_configs[:host] = @custom_connection_configs.delete :host
177
+ if @custom_connection_configs[:protocols] && custom_connection_configs[:protocols][@protocol]
178
+ @custom_connection_configs[:port] = @custom_connection_configs[:protocols][@protocol].delete :port
179
+ @custom_connection_configs[:tls_port] = @custom_connection_configs[:protocols][@protocol].delete :tls_port
180
+ end
181
+ @custom_connection_configs[:vhost] = @custom_connection_configs.delete :vhost
182
+ @custom_connection_configs[:device_id] = @custom_connection_configs.delete :device_id
183
+ @custom_connection_configs[:device_name] = @custom_connection_configs.delete :device_name
184
+ @custom_connection_configs[:secret] = @custom_connection_configs.delete :secret
185
+ end
186
+
187
+ # @private
188
+ def normalize_and_add_channels(chs)
189
+ @channels = [] unless @channels
190
+ return unless chs
191
+ chs.each do |ch|
192
+ case ch
193
+ when Hash
194
+ @channels << ch[:name].to_sym
195
+ else
196
+ ch.to_sym
197
+ end
198
+ end
199
+ end
200
+
201
+ # @private
202
+ # Translate from auto configs given by APIs endpoint to a common format
203
+ def normalize_auto_connection_configs
204
+ {
205
+ host: @auto_configs[:connection][:host],
206
+ port: @auto_configs[:connection][:protocols][@protocol][:port],
207
+ tls_port: @auto_configs[:connection][:protocols][@protocol][:tls_port],
208
+ vhost: @auto_configs[:connection][:vhost],
209
+ device_id: @auto_configs[:connection][:device_id],
210
+ device_name: @auto_configs[:connection][:device_name],
211
+ secret: @auto_configs[:connection][:secret]
212
+ }
213
+ end
214
+
215
+ # @private
216
+ def normalize_log_level
217
+ case @log_level
218
+ when :debug, ::Logger::DEBUG, 'debug' then ::Logger::DEBUG
219
+ when :info, ::Logger::INFO, 'info' then ::Logger::INFO
220
+ when :warn, ::Logger::WARN, 'warn' then ::Logger::WARN
221
+ when :error, ::Logger::ERROR, 'error' then ::Logger::ERROR
222
+ when :fatal, ::Logger::FATAL, 'fatal' then ::Logger::FATAL
223
+ else
224
+ Logger::WARN
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,56 @@
1
+ module Spacebunny
2
+ module Device
3
+ class Message
4
+ attr_reader :device, :sender_id, :channel_name, :delivery_info, :metadata, :payload
5
+
6
+ def initialize(device, options, delivery_info, metadata, payload)
7
+ @device = device
8
+ @options = options
9
+ @delivery_info = delivery_info
10
+ @metadata = metadata
11
+ @payload = payload
12
+
13
+ extract_options
14
+ set_sender_id_and_channel
15
+ end
16
+
17
+ def ack(options = {})
18
+ multiple = options.fetch :multiple, false
19
+ @device.input_channel.acknowledge @delivery_info.delivery_tag, multiple
20
+ end
21
+
22
+ def nack(options = {})
23
+ multiple = options.fetch :multiple, false
24
+ requeue = options.fetch :requeue, false
25
+ @device.input_channel.nack @delivery_info.delivery_tag, multiple, requeue
26
+ end
27
+
28
+ def blacklisted?
29
+ # Discard packet if it has been sent from me
30
+ if @discard_mine && @device.id.eql?(@sender_id) && !from_api?
31
+ return true
32
+ end
33
+ # Discard packet if has been published from APIs
34
+ if @discard_from_api && from_api?
35
+ return true
36
+ end
37
+ false
38
+ end
39
+
40
+ def from_api?
41
+ !@metadata[:headers].nil? && @metadata[:headers]['x-from-sb-api']
42
+ end
43
+
44
+ private
45
+
46
+ def extract_options
47
+ @discard_mine = @options.fetch :discard_mine, false
48
+ @discard_from_api = @options.fetch :discard_from_api, false
49
+ end
50
+
51
+ def set_sender_id_and_channel
52
+ @sender_id, @channel_name = @delivery_info[:routing_key].split('.')
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,128 @@
1
+ require 'uri/http'
2
+ require 'uri/https'
3
+
4
+ # Handle retrieve of Device and LiveStream configs from APIs endpoint
5
+
6
+ module Spacebunny
7
+ class EndpointConnection
8
+ DEFAULT_OPTIONS = {
9
+ scheme: 'https',
10
+ host: 'api.spacebunny.io',
11
+ port: 443,
12
+ api_version: '/v1',
13
+ configs_path: {
14
+ device: '/device_configurations',
15
+ live_stream: '/live_stream_key_configurations'
16
+ }
17
+ }.freeze
18
+
19
+ attr_accessor :scheme, :host, :port, :api_version, :configs_path
20
+
21
+ def initialize(options = {})
22
+ unless options.is_a? Hash
23
+ fail ArgumentError, 'connection options must be an Hash'
24
+ end
25
+ options = merge_with_default options
26
+ @key = options[:key]
27
+ @client = options[:client]
28
+ @secret = options[:secret]
29
+
30
+ ensure_credentials_have_been_provided
31
+ # API endpoint params
32
+ @scheme = options[:scheme]
33
+ @host = options[:host]
34
+ @port = options[:port]
35
+ @api_version = options[:api_version]
36
+ @configs_path = options[:configs_path]
37
+ end
38
+
39
+ def configs
40
+ unless @configs
41
+ @configs = fetch
42
+ end
43
+ @configs
44
+ end
45
+
46
+ def configs_path
47
+ if @configs_path.is_a? Hash
48
+ if device?
49
+ @configs_path[:device]
50
+ else
51
+ @configs_path[:live_stream]
52
+ end
53
+ else
54
+ @configs_path
55
+ end
56
+ end
57
+
58
+ # Contact APIs endpoint to retrieve configs
59
+ def fetch
60
+ uri_builder = case scheme
61
+ when 'http'
62
+ URI::HTTP
63
+ when 'https'
64
+ URI::HTTPS
65
+ end
66
+
67
+ unless uri = uri_builder.build(host: host, port: port, path: "#{api_version}#{configs_path}")
68
+ raise SchemeNotValid.new(scheme)
69
+ end
70
+
71
+ response = contact_endpoint_with uri
72
+ content = JSON.parse(response, symbolize_names: true) rescue nil
73
+ status = response.status
74
+ if status != 200
75
+ if content
76
+ phrase = "Auto-configuration failed: #{response.status} => #{content[:error]}"
77
+ if status == 401
78
+ if device?
79
+ phrase = "#{phrase}. Is Device Key correct?"
80
+ else
81
+ phrase = "#{phrase} Are Client and Secret correct?"
82
+ end
83
+ end
84
+ else
85
+ phrase = "#{response.status}"
86
+ end
87
+ raise EndpointError, phrase
88
+ end
89
+ content
90
+ end
91
+
92
+ private
93
+
94
+ def ensure_credentials_have_been_provided
95
+ if !@key && !(@client && @secret)
96
+ raise DeviceKeyOrClientAndSecretRequired
97
+ end
98
+ end
99
+
100
+ def device?
101
+ !@key.nil?
102
+ end
103
+
104
+ def contact_endpoint_with(uri)
105
+ if device?
106
+ request = HTTP.headers('Device-Key' => @key)
107
+ else
108
+ request = HTTP.headers('Live-Stream-Key-Client' => @client, 'Live-Stream-Key-Secret' => @secret)
109
+ end
110
+ request = request.headers(content_type: 'application/json', accept: 'application/json')
111
+ begin
112
+ request.get(uri.to_s)
113
+ rescue => e
114
+ logger.error e.message
115
+ logger.error e.backtrace.join "\n"
116
+ raise EndPointNotReachable
117
+ end
118
+ end
119
+
120
+ def logger
121
+ Spacebunny.logger
122
+ end
123
+
124
+ def merge_with_default(options)
125
+ DEFAULT_OPTIONS.merge(options) { |key, old_val, new_val| new_val.nil? ? old_val : new_val }
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,143 @@
1
+ module Spacebunny
2
+ class DeviceKeyOrClientAndSecretRequired < Exception
3
+ def initialize(message = nil)
4
+ message = message || "A valid 'Api Key' or valid 'Client' and 'Secret' are required for auto-configuration"
5
+ super(message)
6
+ end
7
+ end
8
+
9
+ class DeviceKeyOrConfigurationsRequired < Exception
10
+ def initialize(message = nil)
11
+ message = message || 'Neither key or connection options provided!'
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class AckTypeError < Exception
17
+ def initialize(message = nil)
18
+ message = message || "Ack type not valid. Use one of #{Spacebunny::AmqpClient::ACK_TYPES.map{ |t| ":#{t}" }.join(', ')}"
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ class BlockRequired < Exception
24
+ def initialize(message = nil)
25
+ message = message || 'block missing. Please provide a block'
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ class ChannelsMustBeAnArray < Exception
31
+ def initialize
32
+ message = "channels option must be an Array. E.g. [:data, :alarms]"
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ class ChannelNotExists < Exception
38
+ def initialize(channel = nil)
39
+ message = if channel
40
+ "Channel '#{channel}' does not exists. Is this channel enabled for the device or did you specified it on client initialization?"
41
+ else
42
+ 'Channel does not exists'
43
+ end
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ class ClientRequired < Exception
49
+ def initialize(message = nil)
50
+ message = message || "Missing mandatory 'client'. Spacebunny::LiveStream.new(:client => 'a_valid_client', :secret: 'a_valid_secret')"
51
+ super(message)
52
+ end
53
+ end
54
+
55
+ class LiveStreamFormatError < Exception
56
+ def initialize
57
+ message = "Live Stream not correctly formatted. It must be an Hash with at least 'name' and 'id' attributes"
58
+ super(message)
59
+ end
60
+ end
61
+
62
+ class LiveStreamNotFound < Exception
63
+ def initialize(name = nil)
64
+ message = if name
65
+ "Live Stream '#{name}' not found. Did you created and configured it?"
66
+ else
67
+ 'Live Stream not found'
68
+ end
69
+ super(message)
70
+ end
71
+ end
72
+
73
+ class LiveStreamParamError < Exception
74
+ def initialize(live_stream_name, param_name)
75
+ live_stream_name = live_stream_name || 'no-name-provided'
76
+ "Live Stream '#{live_stream_name}' misses mandatory '#{param_name}' param"
77
+ super(message)
78
+ end
79
+ end
80
+
81
+ class SecretRequired < Exception
82
+ def initialize(message = nil)
83
+ message = message || "Missing mandatory 'secret' Spacebunny::LiveStream.new(:client => 'a_valid_client', :secret: 'a_valid_secret')"
84
+ super(message)
85
+ end
86
+ end
87
+
88
+ class ClientNotConnected < Exception
89
+ def initialize(message = nil)
90
+ message = message || 'Client not connected! Check internet connection'
91
+ super(message)
92
+ end
93
+ end
94
+
95
+ class ClientNotSetup < Exception
96
+ def initialize(message = nil)
97
+ message = message || "'Client not setup. Did you call 'connect'?'"
98
+ super(message)
99
+ end
100
+ end
101
+
102
+ class DeviceIdMissing < Exception
103
+ def initialize(message = nil)
104
+ message = message || "missing mandatory 'device_id' parameter. Please provide it on client initialization (see doc) or use auto-configuration"
105
+ super(message)
106
+ end
107
+ end
108
+
109
+ class EndpointError < Exception
110
+ def initialize(message = nil)
111
+ message = message || 'Error while contacting endpoint for auto-configuration'
112
+ super(message)
113
+ end
114
+ end
115
+
116
+ class EndPointNotReachable < Exception
117
+ def initialize(message = nil)
118
+ message = message || 'Endpoint not reachable'
119
+ super(message)
120
+ end
121
+ end
122
+
123
+ class ProtocolNotRegistered < Exception
124
+ def initialize(protocol)
125
+ message = "protocol #{protocol} is not registered"
126
+ super(message)
127
+ end
128
+ end
129
+
130
+ class SchemeNotValid < Exception
131
+ def initialize(scheme)
132
+ message = "Provided scheme #{scheme} is not valid"
133
+ super(message)
134
+ end
135
+ end
136
+
137
+ class StreamsMustBeAnArray < Exception
138
+ def initialize
139
+ message = "streams option must be an Array"
140
+ super(message)
141
+ end
142
+ end
143
+ end