spacebunny 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +190 -0
- data/Rakefile +6 -0
- data/assets/logo.png +0 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/examples/device/auto_config_publish.rb +87 -0
- data/examples/device/manual_config.rb +83 -0
- data/examples/device/publish_with_confirm.rb +85 -0
- data/examples/device/receive_messages.rb +109 -0
- data/examples/device/tls_connection.rb +65 -0
- data/examples/live_stream/receive_messages.rb +128 -0
- data/examples/live_stream/tls_connection.rb +22 -0
- data/lib/spacebunny.rb +23 -0
- data/lib/spacebunny/device/amqp.rb +158 -0
- data/lib/spacebunny/device/base.rb +229 -0
- data/lib/spacebunny/device/message.rb +56 -0
- data/lib/spacebunny/endpoint_connection.rb +128 -0
- data/lib/spacebunny/exceptions.rb +143 -0
- data/lib/spacebunny/live_stream/amqp.rb +124 -0
- data/lib/spacebunny/live_stream/base.rb +198 -0
- data/lib/spacebunny/live_stream/message.rb +38 -0
- data/lib/spacebunny/logger.rb +35 -0
- data/lib/spacebunny/utils.rb +185 -0
- data/lib/spacebunny/version.rb +3 -0
- data/spacebunny.gemspec +27 -0
- metadata +157 -0
@@ -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
|