Skip to content

Commit 4b9aac6

Browse files
committed
Wrap Api#attributes with indifferent key access
A number of bugs have been introduced or missed due to the way attributes are exposed by fog. Fog transforms the top level of attributes from the JSON sources (String) to Symbols, but does not act recursively. This results in accessing nested structures with a mix of Symbol and String keys which inevitably is missed or incorrectly specced out. This change first transforms the keys to use Symbols then adds a simple wrapper around `Api#attributes` that takes the fog attributes and allows indifferent access with Symbols or Strings. To allow existing code to work even if Symbols were not used for any lookups.
1 parent 50c3986 commit 4b9aac6

File tree

5 files changed

+210
-0
lines changed

5 files changed

+210
-0
lines changed

lib/brightbox-cli/api.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ def initialize(model = nil)
5252
Brightbox.config.cache_id(@id) if Brightbox.config.respond_to?(:cache_id)
5353
end
5454

55+
def attributes
56+
fog_attributes
57+
end
58+
59+
# Returns the transformed attributes from the fog model with a
60+
# wrapper to allow access using either String or Symbol keys.
61+
def fog_attributes
62+
IndifferentAccessHash.new(deep_symbolize(fog_model.attributes))
63+
end
64+
5565
def fog_model
5666
@fog_model ||= self.class.find(@id)
5767
end
@@ -192,5 +202,24 @@ def created_on
192202

193203
fog_model.created_at.strftime("%Y-%m-%d")
194204
end
205+
206+
private
207+
208+
# Recursively converts all keys in a hash to symbols to ensure
209+
# consistent access.
210+
def deep_symbolize(hash)
211+
return hash unless hash.respond_to?(:each_with_object)
212+
213+
hash.each_with_object({}) do |(k, v), result|
214+
result[k.to_sym] = case v
215+
when Hash
216+
deep_symbolize(v)
217+
when Array
218+
v.map { |i| i.is_a?(Hash) ? deep_symbolize(i) : i }
219+
else
220+
v
221+
end
222+
end
223+
end
195224
end
196225
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# A simpler wrapper to allows either String or Symbol keys to be used
2+
# when accessing attributes since fog applies a change on the top
3+
# level resulting in a mix of both which has introduced issues.
4+
class IndifferentAccessHash
5+
def initialize(hash)
6+
@hash = hash
7+
end
8+
9+
# @param key [String, Symbol] the key to look up
10+
# @return [Object] the value of the key
11+
def [](key)
12+
value = @hash[key.to_s] || @hash[key.to_sym]
13+
wrap(value)
14+
end
15+
16+
# @param key [String, Symbol] the key to set
17+
# @param value [Object] the value to set
18+
# @return [Object] the value of the key
19+
def []=(key, value)
20+
@hash[key.to_s] = value
21+
end
22+
23+
# @param other [Object] the object to compare
24+
# @return [Object] the result of the comparison
25+
def ==(other)
26+
@hash == (other.is_a?(IndifferentAccessHash) ? other.to_h : other)
27+
end
28+
29+
def method_missing(method, *args, &block)
30+
@hash.send(method, *args, &block)
31+
end
32+
33+
def to_h
34+
@hash
35+
end
36+
37+
private
38+
39+
# This is to handle nested hashes to avoid the original issue again
40+
def wrap(value)
41+
case value
42+
when Hash
43+
IndifferentAccessHash.new(value)
44+
when Array
45+
value.map { |v| wrap(v) }
46+
else
47+
value
48+
end
49+
end
50+
end

lib/brightbox_cli.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ module Config
7171
require_relative "brightbox-cli/connection_manager"
7272
require_relative "brightbox-cli/tables"
7373
require_relative "brightbox-cli/logging"
74+
require_relative "brightbox-cli/indifferent_access_hash"
7475
require_relative "brightbox-cli/api"
7576
require_relative "brightbox-cli/config/cache"
7677
require_relative "brightbox-cli/config/gpg_encrypted_passwords"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
require "spec_helper"
2+
3+
RSpec.describe Brightbox::Api, "#attributes" do
4+
subject(:model_attributes) { api_model.attributes }
5+
6+
let(:api_model) { Brightbox::Api.new(fog_model) }
7+
let(:fog_model) do
8+
double(
9+
"Fog::Compute::Brightbox::Api",
10+
id: "res-12345",
11+
attributes: attributes
12+
)
13+
end
14+
15+
context "when attributes are not set" do
16+
let(:attributes) { nil }
17+
18+
it "returns an indifferent access hash" do
19+
expect(model_attributes).to be_instance_of(IndifferentAccessHash)
20+
end
21+
end
22+
23+
context "when attributes are set with a mix of string and symbols" do
24+
let(:attributes) do
25+
{
26+
id: "res-12345",
27+
name: "test",
28+
acme: acme_details
29+
}
30+
end
31+
let(:acme_details) do
32+
{
33+
"domains" => [
34+
{
35+
"identifier" => "example.test",
36+
"status" => "pending"
37+
}
38+
]
39+
}
40+
end
41+
42+
it "transforms the keys with deep symbolize" do
43+
expect(model_attributes).to eq(
44+
id: "res-12345",
45+
name: "test",
46+
acme: {
47+
domains: [
48+
{
49+
identifier: "example.test",
50+
status: "pending"
51+
}
52+
]
53+
}
54+
)
55+
end
56+
57+
it "can accessed by string keys" do
58+
expect(model_attributes["acme"]["domains"].first["identifier"]).to eq("example.test")
59+
end
60+
61+
it "can accessed by symbol keys" do
62+
expect(model_attributes[:acme][:domains].first[:identifier]).to eq("example.test")
63+
end
64+
end
65+
end
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
require "spec_helper"
2+
3+
RSpec.describe Brightbox::Api, "#fog_attributes" do
4+
subject(:model_attributes) { api_model.fog_attributes }
5+
6+
let(:api_model) { Brightbox::Api.new(fog_model) }
7+
let(:fog_model) do
8+
double(
9+
"Fog::Compute::Brightbox::Api",
10+
id: "res-12345",
11+
attributes: attributes
12+
)
13+
end
14+
15+
context "when attributes are not set" do
16+
let(:attributes) { nil }
17+
18+
it "returns an indifferent access hash" do
19+
expect(model_attributes).to be_instance_of(IndifferentAccessHash)
20+
end
21+
end
22+
23+
context "when attributes are set with a mix of string and symbols" do
24+
let(:attributes) do
25+
{
26+
id: "res-12345",
27+
name: "test",
28+
acme: acme_details
29+
}
30+
end
31+
let(:acme_details) do
32+
{
33+
"domains" => [
34+
{
35+
"identifier" => "example.test",
36+
"status" => "pending"
37+
}
38+
]
39+
}
40+
end
41+
42+
it "transforms the keys with deep symbolize" do
43+
expect(model_attributes).to eq(
44+
id: "res-12345",
45+
name: "test",
46+
acme: {
47+
domains: [
48+
{
49+
identifier: "example.test",
50+
status: "pending"
51+
}
52+
]
53+
}
54+
)
55+
end
56+
57+
it "can not accessed by string keys" do
58+
expect(model_attributes["acme"]["domains"].first["identifier"]).to eq("example.test")
59+
end
60+
61+
it "can accessed by symbol keys" do
62+
expect(model_attributes[:acme][:domains].first[:identifier]).to eq("example.test")
63+
end
64+
end
65+
end

0 commit comments

Comments
 (0)