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