launchdarkly-server-sdk 5.8.2 → 6.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +28 -122
  3. data/.ldrelease/circleci/linux/execute.sh +18 -0
  4. data/.ldrelease/circleci/mac/execute.sh +18 -0
  5. data/.ldrelease/circleci/template/build.sh +29 -0
  6. data/.ldrelease/circleci/template/publish.sh +23 -0
  7. data/.ldrelease/circleci/template/set-gem-home.sh +7 -0
  8. data/.ldrelease/circleci/template/test.sh +10 -0
  9. data/.ldrelease/circleci/template/update-version.sh +8 -0
  10. data/.ldrelease/circleci/windows/execute.ps1 +19 -0
  11. data/.ldrelease/config.yml +7 -3
  12. data/CHANGELOG.md +9 -0
  13. data/CONTRIBUTING.md +1 -1
  14. data/Gemfile.lock +69 -42
  15. data/README.md +2 -2
  16. data/azure-pipelines.yml +1 -1
  17. data/launchdarkly-server-sdk.gemspec +16 -16
  18. data/lib/ldclient-rb.rb +0 -1
  19. data/lib/ldclient-rb/config.rb +15 -3
  20. data/lib/ldclient-rb/evaluation_detail.rb +293 -0
  21. data/lib/ldclient-rb/events.rb +1 -4
  22. data/lib/ldclient-rb/file_data_source.rb +1 -1
  23. data/lib/ldclient-rb/impl/evaluator.rb +225 -0
  24. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +74 -0
  25. data/lib/ldclient-rb/impl/evaluator_operators.rb +160 -0
  26. data/lib/ldclient-rb/impl/event_sender.rb +56 -40
  27. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +5 -5
  28. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +5 -5
  29. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +5 -9
  30. data/lib/ldclient-rb/impl/model/serialization.rb +62 -0
  31. data/lib/ldclient-rb/impl/unbounded_pool.rb +34 -0
  32. data/lib/ldclient-rb/ldclient.rb +14 -9
  33. data/lib/ldclient-rb/polling.rb +1 -4
  34. data/lib/ldclient-rb/requestor.rb +25 -15
  35. data/lib/ldclient-rb/stream.rb +9 -6
  36. data/lib/ldclient-rb/util.rb +12 -8
  37. data/lib/ldclient-rb/version.rb +1 -1
  38. data/spec/evaluation_detail_spec.rb +135 -0
  39. data/spec/event_sender_spec.rb +20 -2
  40. data/spec/http_util.rb +11 -1
  41. data/spec/impl/evaluator_bucketing_spec.rb +111 -0
  42. data/spec/impl/evaluator_clause_spec.rb +55 -0
  43. data/spec/impl/evaluator_operators_spec.rb +141 -0
  44. data/spec/impl/evaluator_rule_spec.rb +96 -0
  45. data/spec/impl/evaluator_segment_spec.rb +125 -0
  46. data/spec/impl/evaluator_spec.rb +305 -0
  47. data/spec/impl/evaluator_spec_base.rb +75 -0
  48. data/spec/impl/model/serialization_spec.rb +41 -0
  49. data/spec/launchdarkly-server-sdk_spec.rb +1 -1
  50. data/spec/ldclient_end_to_end_spec.rb +34 -0
  51. data/spec/ldclient_spec.rb +10 -8
  52. data/spec/polling_spec.rb +2 -2
  53. data/spec/redis_feature_store_spec.rb +2 -2
  54. data/spec/requestor_spec.rb +11 -11
  55. metadata +89 -46
  56. data/lib/ldclient-rb/evaluation.rb +0 -462
  57. data/spec/evaluation_spec.rb +0 -789
@@ -0,0 +1,74 @@
1
+
2
+ module LaunchDarkly
3
+ module Impl
4
+ # Encapsulates the logic for percentage rollouts.
5
+ module EvaluatorBucketing
6
+ # Applies either a fixed variation or a rollout for a rule (or the fallthrough rule).
7
+ #
8
+ # @param flag [Object] the feature flag
9
+ # @param rule [Object] the rule
10
+ # @param user [Object] the user properties
11
+ # @return [Number] the variation index, or nil if there is an error
12
+ def self.variation_index_for_user(flag, rule, user)
13
+ variation = rule[:variation]
14
+ return variation if !variation.nil? # fixed variation
15
+ rollout = rule[:rollout]
16
+ return nil if rollout.nil?
17
+ variations = rollout[:variations]
18
+ if !variations.nil? && variations.length > 0 # percentage rollout
19
+ rollout = rule[:rollout]
20
+ bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
21
+ bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
22
+ sum = 0;
23
+ variations.each do |variate|
24
+ sum += variate[:weight].to_f / 100000.0
25
+ if bucket < sum
26
+ return variate[:variation]
27
+ end
28
+ end
29
+ # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due
30
+ # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag
31
+ # data could contain buckets that don't actually add up to 100000. Rather than returning an error in
32
+ # this case (or changing the scaling, which would potentially change the results for *all* users), we
33
+ # will simply put the user in the last bucket.
34
+ variations[-1][:variation]
35
+ else # the rule isn't well-formed
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Returns a user's bucket value as a floating-point value in `[0, 1)`.
41
+ #
42
+ # @param user [Object] the user properties
43
+ # @param key [String] the feature flag key (or segment key, if this is for a segment rule)
44
+ # @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing
45
+ # @param salt [String] the feature flag's or segment's salt value
46
+ # @return [Number] the bucket value, from 0 inclusive to 1 exclusive
47
+ def self.bucket_user(user, key, bucket_by, salt)
48
+ return nil unless user[:key]
49
+
50
+ id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by))
51
+ if id_hash.nil?
52
+ return 0.0
53
+ end
54
+
55
+ if user[:secondary]
56
+ id_hash += "." + user[:secondary].to_s
57
+ end
58
+
59
+ hash_key = "%s.%s.%s" % [key, salt, id_hash]
60
+
61
+ hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
62
+ hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
63
+ end
64
+
65
+ private
66
+
67
+ def self.bucketable_string_value(value)
68
+ return value if value.is_a? String
69
+ return value.to_s if value.is_a? Integer
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,160 @@
1
+ require "date"
2
+ require "semantic"
3
+ require "set"
4
+
5
+ module LaunchDarkly
6
+ module Impl
7
+ # Defines the behavior of all operators that can be used in feature flag rules and segment rules.
8
+ module EvaluatorOperators
9
+ # Applies an operator to produce a boolean result.
10
+ #
11
+ # @param op [Symbol] one of the supported LaunchDarkly operators, as a symbol
12
+ # @param user_value the value of the user attribute that is referenced in the current clause (left-hand
13
+ # side of the expression)
14
+ # @param clause_value the constant value that `user_value` is being compared to (right-hand side of the
15
+ # expression)
16
+ # @return [Boolean] true if the expression should be considered a match; false if it is not a match, or
17
+ # if the values cannot be compared because they are of the wrong types, or if the operator is unknown
18
+ def self.apply(op, user_value, clause_value)
19
+ case op
20
+ when :in
21
+ user_value == clause_value
22
+ when :startsWith
23
+ string_op(user_value, clause_value, lambda { |a, b| a.start_with? b })
24
+ when :endsWith
25
+ string_op(user_value, clause_value, lambda { |a, b| a.end_with? b })
26
+ when :contains
27
+ string_op(user_value, clause_value, lambda { |a, b| a.include? b })
28
+ when :matches
29
+ string_op(user_value, clause_value, lambda { |a, b|
30
+ begin
31
+ re = Regexp.new b
32
+ !re.match(a).nil?
33
+ rescue
34
+ false
35
+ end
36
+ })
37
+ when :lessThan
38
+ numeric_op(user_value, clause_value, lambda { |a, b| a < b })
39
+ when :lessThanOrEqual
40
+ numeric_op(user_value, clause_value, lambda { |a, b| a <= b })
41
+ when :greaterThan
42
+ numeric_op(user_value, clause_value, lambda { |a, b| a > b })
43
+ when :greaterThanOrEqual
44
+ numeric_op(user_value, clause_value, lambda { |a, b| a >= b })
45
+ when :before
46
+ date_op(user_value, clause_value, lambda { |a, b| a < b })
47
+ when :after
48
+ date_op(user_value, clause_value, lambda { |a, b| a > b })
49
+ when :semVerEqual
50
+ semver_op(user_value, clause_value, lambda { |a, b| a == b })
51
+ when :semVerLessThan
52
+ semver_op(user_value, clause_value, lambda { |a, b| a < b })
53
+ when :semVerGreaterThan
54
+ semver_op(user_value, clause_value, lambda { |a, b| a > b })
55
+ when :segmentMatch
56
+ # We should never reach this; it can't be evaluated based on just two parameters, because it requires
57
+ # looking up the segment from the data store. Instead, we special-case this operator in clause_match_user.
58
+ false
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ # Retrieves the value of a user attribute by name.
65
+ #
66
+ # Built-in attributes correspond to top-level properties in the user object. They are treated as strings and
67
+ # non-string values are coerced to strings, except for `anonymous` which is meant to be a boolean if present
68
+ # and is not currently coerced. This behavior is consistent with earlier versions of the Ruby SDK, but is not
69
+ # guaranteed to be consistent with other SDKs, since the evaluator specification is based on the strongly-typed
70
+ # SDKs where it is not possible for an attribute to have the wrong type.
71
+ #
72
+ # Custom attributes correspond to properties within the `custom` property, if any, and can be of any type.
73
+ #
74
+ # @param user [Object] the user properties
75
+ # @param attribute [String|Symbol] the attribute to get, for instance `:key` or `:name` or `:some_custom_attr`
76
+ # @return the attribute value, or nil if the attribute is unknown
77
+ def self.user_value(user, attribute)
78
+ attribute = attribute.to_sym
79
+ if BUILTINS.include? attribute
80
+ value = user[attribute]
81
+ return nil if value.nil?
82
+ (attribute == :anonymous) ? value : value.to_s
83
+ elsif !user[:custom].nil?
84
+ user[:custom][attribute]
85
+ else
86
+ nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
93
+ NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*")
94
+
95
+ private_constant :BUILTINS
96
+ private_constant :NUMERIC_VERSION_COMPONENTS_REGEX
97
+
98
+ def self.string_op(user_value, clause_value, fn)
99
+ (user_value.is_a? String) && (clause_value.is_a? String) && fn.call(user_value, clause_value)
100
+ end
101
+
102
+ def self.numeric_op(user_value, clause_value, fn)
103
+ (user_value.is_a? Numeric) && (clause_value.is_a? Numeric) && fn.call(user_value, clause_value)
104
+ end
105
+
106
+ def self.date_op(user_value, clause_value, fn)
107
+ ud = to_date(user_value)
108
+ if !ud.nil?
109
+ cd = to_date(clause_value)
110
+ !cd.nil? && fn.call(ud, cd)
111
+ else
112
+ false
113
+ end
114
+ end
115
+
116
+ def self.semver_op(user_value, clause_value, fn)
117
+ uv = to_semver(user_value)
118
+ if !uv.nil?
119
+ cv = to_semver(clause_value)
120
+ !cv.nil? && fn.call(uv, cv)
121
+ else
122
+ false
123
+ end
124
+ end
125
+
126
+ def self.to_date(value)
127
+ if value.is_a? String
128
+ begin
129
+ DateTime.rfc3339(value).strftime("%Q").to_i
130
+ rescue => e
131
+ nil
132
+ end
133
+ elsif value.is_a? Numeric
134
+ value
135
+ else
136
+ nil
137
+ end
138
+ end
139
+
140
+ def self.to_semver(value)
141
+ if value.is_a? String
142
+ for _ in 0..2 do
143
+ begin
144
+ return Semantic::Version.new(value)
145
+ rescue ArgumentError
146
+ value = add_zero_version_component(value)
147
+ end
148
+ end
149
+ end
150
+ nil
151
+ end
152
+
153
+ def self.add_zero_version_component(v)
154
+ NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m|
155
+ m[0] + ".0" + v[m[0].length..-1]
156
+ }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,4 +1,7 @@
1
+ require "ldclient-rb/impl/unbounded_pool"
2
+
1
3
  require "securerandom"
4
+ require "http"
2
5
 
3
6
  module LaunchDarkly
4
7
  module Impl
@@ -9,62 +12,75 @@ module LaunchDarkly
9
12
  DEFAULT_RETRY_INTERVAL = 1
10
13
 
11
14
  def initialize(sdk_key, config, http_client = nil, retry_interval = DEFAULT_RETRY_INTERVAL)
12
- @client = http_client ? http_client : LaunchDarkly::Util.new_http_client(config.events_uri, config)
13
15
  @sdk_key = sdk_key
14
16
  @config = config
15
17
  @events_uri = config.events_uri + "/bulk"
16
18
  @diagnostic_uri = config.events_uri + "/diagnostic"
17
19
  @logger = config.logger
18
20
  @retry_interval = retry_interval
21
+ @http_client_pool = UnboundedPool.new(
22
+ lambda { LaunchDarkly::Util.new_http_client(@config.events_uri, @config) },
23
+ lambda { |client| client.close })
24
+ end
25
+
26
+ def stop
27
+ @http_client_pool.dispose_all()
19
28
  end
20
29
 
21
30
  def send_event_data(event_data, description, is_diagnostic)
22
31
  uri = is_diagnostic ? @diagnostic_uri : @events_uri
23
32
  payload_id = is_diagnostic ? nil : SecureRandom.uuid
24
- res = nil
25
- (0..1).each do |attempt|
26
- if attempt > 0
27
- @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" }
28
- sleep(@retry_interval)
29
- end
30
- begin
31
- @client.start if !@client.started?
32
- @logger.debug { "[LDClient] sending #{description}: #{event_data}" }
33
- req = Net::HTTP::Post.new(uri)
34
- req.content_type = "application/json"
35
- req.body = event_data
36
- Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| req[k] = v }
37
- if !is_diagnostic
38
- req["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s
39
- req["X-LaunchDarkly-Payload-ID"] = payload_id
33
+ begin
34
+ http_client = @http_client_pool.acquire()
35
+ response = nil
36
+ (0..1).each do |attempt|
37
+ if attempt > 0
38
+ @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" }
39
+ sleep(@retry_interval)
40
40
  end
41
- req["Connection"] = "keep-alive"
42
- res = @client.request(req)
43
- rescue StandardError => exn
44
- @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." }
45
- next
46
- end
47
- status = res.code.to_i
48
- if status >= 200 && status < 300
49
- res_time = nil
50
- if !res["date"].nil?
51
- begin
52
- res_time = Time.httpdate(res["date"])
53
- rescue ArgumentError
41
+ begin
42
+ @logger.debug { "[LDClient] sending #{description}: #{event_data}" }
43
+ headers = {}
44
+ headers["content-type"] = "application/json"
45
+ Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v }
46
+ if !is_diagnostic
47
+ headers["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s
48
+ headers["X-LaunchDarkly-Payload-ID"] = payload_id
54
49
  end
50
+ response = http_client.request("POST", uri, {
51
+ headers: headers,
52
+ body: event_data
53
+ })
54
+ rescue StandardError => exn
55
+ @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." }
56
+ next
57
+ end
58
+ status = response.status.code
59
+ # must fully read body for persistent connections
60
+ body = response.to_s
61
+ if status >= 200 && status < 300
62
+ res_time = nil
63
+ if !response.headers["date"].nil?
64
+ begin
65
+ res_time = Time.httpdate(response.headers["date"])
66
+ rescue ArgumentError
67
+ end
68
+ end
69
+ return EventSenderResult.new(true, false, res_time)
70
+ end
71
+ must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status)
72
+ can_retry = !must_shutdown && attempt == 0
73
+ message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped")
74
+ @logger.error { "[LDClient] #{message}" }
75
+ if must_shutdown
76
+ return EventSenderResult.new(false, true, nil)
55
77
  end
56
- return EventSenderResult.new(true, false, res_time)
57
- end
58
- must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status)
59
- can_retry = !must_shutdown && attempt == 0
60
- message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped")
61
- @logger.error { "[LDClient] #{message}" }
62
- if must_shutdown
63
- return EventSenderResult.new(false, true, nil)
64
78
  end
79
+ # used up our retries
80
+ return EventSenderResult.new(false, false, nil)
81
+ ensure
82
+ @http_client_pool.release(http_client)
65
83
  end
66
- # used up our retries
67
- return EventSenderResult.new(false, false, nil)
68
84
  end
69
85
  end
70
86
  end
@@ -39,7 +39,7 @@ module LaunchDarkly
39
39
  # Insert or update every provided item
40
40
  all_data.each do |kind, items|
41
41
  items.values.each do |item|
42
- value = item.to_json
42
+ value = Model.serialize(kind, item)
43
43
  key = item_key(kind, item[:key])
44
44
  ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => key, 'Value' => value } })
45
45
  unused_old_keys.delete(key)
@@ -62,7 +62,7 @@ module LaunchDarkly
62
62
 
63
63
  def get_internal(kind, key)
64
64
  value = Diplomat::Kv.get(item_key(kind, key), {}, :return) # :return means "don't throw an error if not found"
65
- (value.nil? || value == "") ? nil : JSON.parse(value, symbolize_names: true)
65
+ (value.nil? || value == "") ? nil : Model.deserialize(kind, value)
66
66
  end
67
67
 
68
68
  def get_all_internal(kind)
@@ -71,7 +71,7 @@ module LaunchDarkly
71
71
  (results == "" ? [] : results).each do |result|
72
72
  value = result[:value]
73
73
  if !value.nil?
74
- item = JSON.parse(value, symbolize_names: true)
74
+ item = Model.deserialize(kind, value)
75
75
  items_out[item[:key].to_sym] = item
76
76
  end
77
77
  end
@@ -80,7 +80,7 @@ module LaunchDarkly
80
80
 
81
81
  def upsert_internal(kind, new_item)
82
82
  key = item_key(kind, new_item[:key])
83
- json = new_item.to_json
83
+ json = Model.serialize(kind, new_item)
84
84
 
85
85
  # We will potentially keep retrying indefinitely until someone's write succeeds
86
86
  while true
@@ -88,7 +88,7 @@ module LaunchDarkly
88
88
  if old_value.nil? || old_value == ""
89
89
  mod_index = 0
90
90
  else
91
- old_item = JSON.parse(old_value[0]["Value"], symbolize_names: true)
91
+ old_item = Model.deserialize(kind, old_value[0]["Value"])
92
92
  # Check whether the item is stale. If so, don't do the update (and return the existing item to
93
93
  # FeatureStoreWrapper so it can be cached)
94
94
  if old_item[:version] >= new_item[:version]
@@ -77,7 +77,7 @@ module LaunchDarkly
77
77
 
78
78
  def get_internal(kind, key)
79
79
  resp = get_item_by_keys(namespace_for_kind(kind), key)
80
- unmarshal_item(resp.item)
80
+ unmarshal_item(kind, resp.item)
81
81
  end
82
82
 
83
83
  def get_all_internal(kind)
@@ -86,7 +86,7 @@ module LaunchDarkly
86
86
  while true
87
87
  resp = @client.query(req)
88
88
  resp.items.each do |item|
89
- item_out = unmarshal_item(item)
89
+ item_out = unmarshal_item(kind, item)
90
90
  items_out[item_out[:key].to_sym] = item_out
91
91
  end
92
92
  break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0
@@ -196,15 +196,15 @@ module LaunchDarkly
196
196
  def marshal_item(kind, item)
197
197
  make_keys_hash(namespace_for_kind(kind), item[:key]).merge({
198
198
  VERSION_ATTRIBUTE => item[:version],
199
- ITEM_JSON_ATTRIBUTE => item.to_json
199
+ ITEM_JSON_ATTRIBUTE => Model.serialize(kind, item)
200
200
  })
201
201
  end
202
202
 
203
- def unmarshal_item(item)
203
+ def unmarshal_item(kind, item)
204
204
  return nil if item.nil? || item.length == 0
205
205
  json_attr = item[ITEM_JSON_ATTRIBUTE]
206
206
  raise RuntimeError.new("DynamoDB map did not contain expected item string") if json_attr.nil?
207
- JSON.parse(json_attr, symbolize_names: true)
207
+ Model.deserialize(kind, json_attr)
208
208
  end
209
209
  end
210
210
 
@@ -55,7 +55,7 @@ module LaunchDarkly
55
55
  multi.del(items_key(kind))
56
56
  count = count + items.count
57
57
  items.each do |key, item|
58
- multi.hset(items_key(kind), key, item.to_json)
58
+ multi.hset(items_key(kind), key, Model.serialize(kind,item))
59
59
  end
60
60
  end
61
61
  multi.set(inited_key, inited_key)
@@ -75,8 +75,7 @@ module LaunchDarkly
75
75
  with_connection do |redis|
76
76
  hashfs = redis.hgetall(items_key(kind))
77
77
  hashfs.each do |k, json_item|
78
- f = JSON.parse(json_item, symbolize_names: true)
79
- fs[k.to_sym] = f
78
+ fs[k.to_sym] = Model.deserialize(kind, json_item)
80
79
  end
81
80
  end
82
81
  fs
@@ -95,7 +94,7 @@ module LaunchDarkly
95
94
  before_update_transaction(base_key, key)
96
95
  if old_item.nil? || old_item[:version] < new_item[:version]
97
96
  result = redis.multi do |multi|
98
- multi.hset(base_key, key, new_item.to_json)
97
+ multi.hset(base_key, key, Model.serialize(kind, new_item))
99
98
  end
100
99
  if result.nil?
101
100
  @logger.debug { "RedisFeatureStore: concurrent modification detected, retrying" }
@@ -115,9 +114,7 @@ module LaunchDarkly
115
114
  end
116
115
 
117
116
  def initialized_internal?
118
- with_connection do |redis|
119
- redis.respond_to?(:exists?) ? redis.exists?(inited_key) : redis.exists(inited_key)
120
- end
117
+ with_connection { |redis| redis.exists?(inited_key) }
121
118
  end
122
119
 
123
120
  def stop
@@ -150,8 +147,7 @@ module LaunchDarkly
150
147
  end
151
148
 
152
149
  def get_redis(redis, kind, key)
153
- json_item = redis.hget(items_key(kind), key)
154
- json_item.nil? ? nil : JSON.parse(json_item, symbolize_names: true)
150
+ Model.deserialize(kind, redis.hget(items_key(kind), key))
155
151
  end
156
152
  end
157
153
  end