typed_params 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CONTRIBUTING.md +33 -0
  4. data/LICENSE +20 -0
  5. data/README.md +736 -0
  6. data/SECURITY.md +8 -0
  7. data/lib/typed_params/bouncer.rb +34 -0
  8. data/lib/typed_params/coercer.rb +21 -0
  9. data/lib/typed_params/configuration.rb +40 -0
  10. data/lib/typed_params/controller.rb +192 -0
  11. data/lib/typed_params/formatters/formatter.rb +20 -0
  12. data/lib/typed_params/formatters/jsonapi.rb +142 -0
  13. data/lib/typed_params/formatters/rails.rb +31 -0
  14. data/lib/typed_params/formatters.rb +20 -0
  15. data/lib/typed_params/handler.rb +24 -0
  16. data/lib/typed_params/handler_set.rb +19 -0
  17. data/lib/typed_params/mapper.rb +74 -0
  18. data/lib/typed_params/namespaced_set.rb +59 -0
  19. data/lib/typed_params/parameter.rb +100 -0
  20. data/lib/typed_params/parameterizer.rb +87 -0
  21. data/lib/typed_params/path.rb +57 -0
  22. data/lib/typed_params/pipeline.rb +13 -0
  23. data/lib/typed_params/processor.rb +27 -0
  24. data/lib/typed_params/schema.rb +290 -0
  25. data/lib/typed_params/schema_set.rb +7 -0
  26. data/lib/typed_params/transformer.rb +49 -0
  27. data/lib/typed_params/transforms/key_alias.rb +16 -0
  28. data/lib/typed_params/transforms/key_casing.rb +59 -0
  29. data/lib/typed_params/transforms/nilify_blanks.rb +16 -0
  30. data/lib/typed_params/transforms/noop.rb +11 -0
  31. data/lib/typed_params/transforms/transform.rb +11 -0
  32. data/lib/typed_params/types/array.rb +12 -0
  33. data/lib/typed_params/types/boolean.rb +33 -0
  34. data/lib/typed_params/types/date.rb +10 -0
  35. data/lib/typed_params/types/decimal.rb +10 -0
  36. data/lib/typed_params/types/float.rb +10 -0
  37. data/lib/typed_params/types/hash.rb +13 -0
  38. data/lib/typed_params/types/integer.rb +10 -0
  39. data/lib/typed_params/types/nil.rb +11 -0
  40. data/lib/typed_params/types/number.rb +10 -0
  41. data/lib/typed_params/types/string.rb +10 -0
  42. data/lib/typed_params/types/symbol.rb +10 -0
  43. data/lib/typed_params/types/time.rb +20 -0
  44. data/lib/typed_params/types/type.rb +78 -0
  45. data/lib/typed_params/types.rb +69 -0
  46. data/lib/typed_params/validations/exclusion.rb +17 -0
  47. data/lib/typed_params/validations/format.rb +19 -0
  48. data/lib/typed_params/validations/inclusion.rb +17 -0
  49. data/lib/typed_params/validations/length.rb +29 -0
  50. data/lib/typed_params/validations/validation.rb +18 -0
  51. data/lib/typed_params/validator.rb +75 -0
  52. data/lib/typed_params/version.rb +5 -0
  53. data/lib/typed_params.rb +89 -0
  54. metadata +124 -0
data/SECURITY.md ADDED
@@ -0,0 +1,8 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ If you find a vulnerability in `typed_params`, please contact Keygen via
6
+ [email](mailto:security@keygen.sh).
7
+
8
+ You will be given public credit for your disclosure.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/mapper'
4
+
5
+ module TypedParams
6
+ class Bouncer < Mapper
7
+ def call(params)
8
+ depth_first_map(params) do |param|
9
+ next unless
10
+ param.schema.if? || param.schema.unless?
11
+
12
+ cond = param.schema.if? ? param.schema.if : param.schema.unless
13
+ res = case cond
14
+ in Proc => method
15
+ controller.instance_exec(&method)
16
+ in Symbol => method
17
+ controller.send(method)
18
+ else
19
+ raise InvalidMethodError, "invalid method: #{cond.inspect}"
20
+ end
21
+
22
+ next if
23
+ param.schema.unless? && !res ||
24
+ param.schema.if? && res
25
+
26
+ if param.schema.strict?
27
+ raise UnpermittedParameterError.new('unpermitted parameter', path: param.path, source: schema.source)
28
+ else
29
+ param.delete
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/mapper'
4
+
5
+ module TypedParams
6
+ class Coercer < Mapper
7
+ def call(params)
8
+ depth_first_map(params) do |param|
9
+ schema = param.schema
10
+ next unless
11
+ schema.coerce?
12
+
13
+ param.value = schema.type.coerce(param.value)
14
+ rescue CoercionError
15
+ type = Types.for(param.value)
16
+
17
+ raise InvalidParameterError.new("failed to coerce #{type} to #{schema.type}", path: param.path, source: schema.source)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Configuration
5
+ include ActiveSupport::Configurable
6
+
7
+ ##
8
+ # ignore_nil_optionals defines how nil optionals are handled.
9
+ # When enabled, optional params that are nil will be dropped
10
+ # given the schema does not allow_nil. Essentially, they
11
+ # will be treated as if they weren't provided.
12
+ config_accessor(:ignore_nil_optionals) { false }
13
+
14
+ ##
15
+ # path_transform defines the casing for parameter paths.
16
+ #
17
+ # One of:
18
+ #
19
+ # - :underscore
20
+ # - :camel
21
+ # - :lower_camel
22
+ # - :dash
23
+ # - nil
24
+ #
25
+ config_accessor(:path_transform) { nil }
26
+
27
+ ##
28
+ # key_transform defines the casing for parameter keys.
29
+ #
30
+ # One of:
31
+ #
32
+ # - :underscore
33
+ # - :camel
34
+ # - :lower_camel
35
+ # - :dash
36
+ # - nil
37
+ #
38
+ config_accessor(:key_transform) { nil }
39
+ end
40
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/handler'
4
+ require 'typed_params/handler_set'
5
+ require 'typed_params/schema_set'
6
+
7
+ module TypedParams
8
+ module Controller
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ cattr_accessor :typed_handlers, default: HandlerSet.new
13
+ cattr_accessor :typed_schemas, default: SchemaSet.new
14
+
15
+ def typed_params(format: AUTO)
16
+ handler = typed_handlers.params[self.class, action_name.to_sym]
17
+
18
+ raise UndefinedActionError, "params have not been defined for action: #{action_name.inspect}" if
19
+ handler.nil?
20
+
21
+ schema = handler.schema
22
+ processor = Processor.new(controller: self, schema:)
23
+ paramz = Parameterizer.new(schema:)
24
+ # TODO(ezekg) Add a config here that accepts a block, similar to a Rack app
25
+ # so that users can define their own parameter source. E.g.
26
+ # using and parsing request.body can allow array roots.
27
+ params = paramz.call(value: request.request_parameters.deep_symbolize_keys)
28
+ formatter = case format
29
+ when Symbol, String
30
+ Formatters[format]
31
+ when AUTO
32
+ schema.formatter
33
+ else
34
+ nil
35
+ end
36
+
37
+ processor.call(params)
38
+
39
+ params.unwrap(
40
+ controller: self,
41
+ formatter:,
42
+ )
43
+ end
44
+
45
+ def typed_query(format: AUTO)
46
+ handler = typed_handlers.query[self.class, action_name.to_sym]
47
+
48
+ raise UndefinedActionError, "query has not been defined for action: #{action_name.inspect}" if
49
+ handler.nil?
50
+
51
+ schema = handler.schema
52
+ processor = Processor.new(controller: self, schema:)
53
+ paramz = Parameterizer.new(schema:)
54
+ params = paramz.call(value: request.query_parameters.deep_symbolize_keys)
55
+ formatter = case format
56
+ when Symbol, String
57
+ Formatters[format]
58
+ when AUTO
59
+ schema.formatter
60
+ else
61
+ nil
62
+ end
63
+
64
+ processor.call(params)
65
+
66
+ params.unwrap(
67
+ controller: self,
68
+ formatter:,
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def typed_namespace = self.class
75
+
76
+ def respond_to_missing?(method_name, *)
77
+ return super unless
78
+ /_(params|query)\z/.match?(method_name)
79
+
80
+ name = controller_name&.classify&.underscore
81
+ return super unless
82
+ name.present?
83
+
84
+ aliases = [
85
+ :"#{name}_params",
86
+ :"#{name}_query",
87
+ ]
88
+
89
+ aliases.include?(method_name) || super
90
+ end
91
+
92
+ def method_missing(method_name, ...)
93
+ return super unless
94
+ /_(params|query)\z/.match?(method_name)
95
+
96
+ name = controller_name&.classify&.underscore
97
+ return super unless
98
+ name.present?
99
+
100
+ case method_name
101
+ when :"#{name}_params"
102
+ typed_params(...)
103
+ when :"#{name}_query"
104
+ typed_query(...)
105
+ else
106
+ super
107
+ end
108
+ end
109
+ end
110
+
111
+ class_methods do
112
+ def typed_params(on: nil, schema: nil, format: nil, **kwargs, &)
113
+ schema = case schema
114
+ in Array(Symbol, Symbol) => namespace, key
115
+ typed_schemas[namespace, key] || raise(ArgumentError, "schema does not exist: #{namespace.inspect}/#{key.inspect}")
116
+ in Symbol => key
117
+ typed_schemas[self, key] || raise(ArgumentError, "schema does not exist: #{key.inspect}")
118
+ in nil
119
+ Schema.new(**kwargs, controller: self, source: :params, &)
120
+ end
121
+
122
+ case on
123
+ in Array => actions
124
+ actions.each do |action|
125
+ typed_handlers.params[self, action] = Handler.new(for: :params, action:, schema:, format:)
126
+ end
127
+ in Symbol => action
128
+ typed_handlers.params[self, action] = Handler.new(for: :params, action:, schema:, format:)
129
+ in nil
130
+ typed_handlers.deferred << Handler.new(for: :params, schema:, format:)
131
+ end
132
+ end
133
+
134
+ def typed_query(on: nil, schema: nil, **kwargs, &)
135
+ schema = case schema
136
+ in Array(Symbol, Symbol) => namespace, key
137
+ typed_schemas[namespace, key] || raise(ArgumentError, "schema does not exist: #{namespace.inspect}/#{key.inspect}")
138
+ in Symbol => key
139
+ typed_schemas[self, key] || raise(ArgumentError, "schema does not exist: #{key.inspect}")
140
+ in nil
141
+ # FIXME(ezekg) Should query params :coerce by default?
142
+ Schema.new(nilify_blanks: true, strict: false, **kwargs, controller: self, source: :query, &)
143
+ end
144
+
145
+ case on
146
+ in Array => actions
147
+ actions.each do |action|
148
+ typed_handlers.query[self, action] = Handler.new(for: :query, action:, schema:)
149
+ end
150
+ in Symbol => action
151
+ typed_handlers.query[self, action] = Handler.new(for: :query, action:, schema:)
152
+ in nil
153
+ typed_handlers.deferred << Handler.new(for: :query, schema:)
154
+ end
155
+ end
156
+
157
+ def typed_schema(key, namespace: self, **kwargs, &)
158
+ raise ArgumentError, "schema already exists: #{key.inspect}" if
159
+ typed_schemas.exists?(namespace, key)
160
+
161
+ typed_schemas[namespace, key] = Schema.new(**kwargs, controller: self, &)
162
+ end
163
+
164
+ private
165
+
166
+ def method_added(method_name)
167
+ return super unless
168
+ typed_handlers.deferred?
169
+
170
+ while handler = typed_handlers.deferred.shift
171
+ handler.action = method_name
172
+
173
+ case handler.for
174
+ when :params
175
+ typed_handlers.params[self, handler.action] = handler
176
+ when :query
177
+ typed_handlers.query[self, handler.action] = handler
178
+ end
179
+ end
180
+
181
+ super
182
+ end
183
+ end
184
+
185
+ def self.included(klass)
186
+ raise ArgumentError, "cannot be used outside of controller (got #{klass.ancestors})" unless
187
+ klass < ::ActionController::Metal
188
+
189
+ super(klass)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Formatters
5
+ class Formatter
6
+ attr_reader :decorator,
7
+ :format
8
+
9
+ def initialize(format, transform:, decorate:)
10
+ @format = format
11
+ @transform = transform
12
+ @decorator = decorate
13
+ end
14
+
15
+ def decorator? = decorator.present?
16
+
17
+ delegate :arity, :call, to: :@transform
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/formatters/formatter'
4
+
5
+ module TypedParams
6
+ module Formatters
7
+ ##
8
+ # The JSONAPI formatter transforms a JSONAPI document into Rails'
9
+ # standard params format that can be passed to a model.
10
+ #
11
+ # For example, given the following params:
12
+ #
13
+ # {
14
+ # data: {
15
+ # type: 'users',
16
+ # id: '1',
17
+ # attributes: { email: 'foo@bar.example' },
18
+ # relationships: {
19
+ # friends: {
20
+ # data: [{ type: 'users', id: '2' }]
21
+ # }
22
+ # }
23
+ # }
24
+ # }
25
+ #
26
+ # The final params would become:
27
+ #
28
+ # {
29
+ # id: '1',
30
+ # email: 'foo@bar.example',
31
+ # friend_ids: ['2']
32
+ # }
33
+ #
34
+ module JSONAPI
35
+ def self.call(params)
36
+ case params
37
+ in data: Array => data
38
+ format_array_data(data)
39
+ in data: Hash => data
40
+ format_hash_data(data)
41
+ else
42
+ params
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def self.format_array_data(data)
49
+ data.map { format_hash_data(_1) }
50
+ end
51
+
52
+ def self.format_hash_data(data)
53
+ rels = data[:relationships]
54
+ attrs = data[:attributes]
55
+ res = data.except(
56
+ :attributes,
57
+ :links,
58
+ :meta,
59
+ :relationships,
60
+ :type,
61
+ )
62
+
63
+ # Move attributes over to top-level params
64
+ attrs&.each do |key, attr|
65
+ res[key] = attr
66
+ end
67
+
68
+ # Move relationships over. This will use x_id and x_ids when the
69
+ # relationship data only contains :type and :id, otherwise it
70
+ # will use the x_attributes key.
71
+ rels&.each do |key, rel|
72
+ case rel
73
+ # FIXME(ezekg) We need https://bugs.ruby-lang.org/issues/18961 to
74
+ # clean this up (i.e. remove the if guard).
75
+ in data: [{ type:, id:, **nil }, *] => linkage if linkage.all? { _1 in type:, id:, **nil }
76
+ res[:"#{key.to_s.singularize}_ids"] = linkage.map { _1[:id] }
77
+ in data: []
78
+ res[:"#{key.to_s.singularize}_ids"] = []
79
+ # FIXME(ezekg) Not sure how to make this cleaner, but this handles polymorphic relationships.
80
+ in data: { type:, id:, **nil } if key.to_s.underscore.classify != type.underscore.classify
81
+ res[:"#{key}_type"], res[:"#{key}_id"] = type.underscore.classify, id
82
+ in data: { type:, id:, **nil }
83
+ res[:"#{key}_id"] = id
84
+ in data: nil
85
+ res[:"#{key}_id"] = nil
86
+ else
87
+ # NOTE(ezekg) Embedded relationships are non-standard as per the
88
+ # JSONAPI spec, but I don't really care. :)
89
+ res[:"#{key}_attributes"] = call(rel)
90
+ end
91
+ end
92
+
93
+ res
94
+ end
95
+ end
96
+
97
+ register(:jsonapi,
98
+ transform: JSONAPI.method(:call),
99
+ decorate: -> {
100
+ next if
101
+ respond_to?(:typed_meta)
102
+
103
+ mod = Module.new
104
+
105
+ mod.define_method :respond_to_missing? do |method_name, *args|
106
+ next super(method_name, *args) unless
107
+ /_meta\z/.match?(method_name)
108
+
109
+ name = controller_name&.classify&.underscore
110
+ next super(method_name, *args) unless
111
+ name.present?
112
+
113
+ aliases = %I[
114
+ #{name}_meta
115
+ typed_meta
116
+ ]
117
+
118
+ aliases.include?(method_name) || super(method_name, *args)
119
+ end
120
+
121
+ mod.define_method :method_missing do |method_name, *args|
122
+ next super(method_name, *args) unless
123
+ /_meta\z/.match?(method_name)
124
+
125
+ name = controller_name&.classify&.underscore
126
+ next super(method_name, *args) unless
127
+ name.present?
128
+
129
+ case method_name
130
+ when :"#{name}_meta",
131
+ :typed_meta
132
+ typed_params(format: nil).fetch(:meta) { {} }
133
+ else
134
+ super(method_name, *args)
135
+ end
136
+ end
137
+
138
+ include mod
139
+ },
140
+ )
141
+ end
142
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/formatters/formatter'
4
+
5
+ module TypedParams
6
+ module Formatters
7
+ ##
8
+ # The Rails formatter wraps the params in a key matching the current
9
+ # controller's name.
10
+ #
11
+ # For example, in a UsersController context, given the params:
12
+ #
13
+ # { email: 'foo@bar.example' }
14
+ #
15
+ # The final params would become:
16
+ #
17
+ # { user: { email: 'foo@bar.example' } }
18
+ #
19
+ module Rails
20
+ def self.call(params, controller:)
21
+ key = controller.controller_name.singularize.to_sym
22
+
23
+ { key => params }
24
+ end
25
+ end
26
+
27
+ register(:rails,
28
+ transform: Rails.method(:call),
29
+ )
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/formatters/formatter'
4
+
5
+ module TypedParams
6
+ module Formatters
7
+ cattr_reader :formats, default: {}
8
+
9
+ def self.register(format, transform:, decorate: nil)
10
+ raise ArgumentError, "format is already registered: #{format.inspect}" if
11
+ formats.key?(format)
12
+
13
+ formats[format] = Formatter.new(format, transform:, decorate:)
14
+ end
15
+
16
+ def self.unregister(type) = formats.delete(type)
17
+
18
+ def self.[](format) = formats[format] || raise(ArgumentError, "invalid format: #{format.inspect}")
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Handler
5
+ attr_reader :for,
6
+ :action,
7
+ :schema,
8
+ :format
9
+
10
+ def initialize(for:, schema:, action: nil, format: nil)
11
+ @for = binding.local_variable_get(:for)
12
+ @schema = schema
13
+ @action = action
14
+ @format = format
15
+ end
16
+
17
+ def action=(action)
18
+ raise ArgumentError, 'cannot redefine action' if
19
+ @action.present?
20
+
21
+ @action = action
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/namespaced_set'
4
+
5
+ module TypedParams
6
+ class HandlerSet
7
+ attr_reader :deferred,
8
+ :params,
9
+ :query
10
+
11
+ def initialize
12
+ @deferred = []
13
+ @params = NamespacedSet.new
14
+ @query = NamespacedSet.new
15
+ end
16
+
17
+ def deferred? = @deferred.any?
18
+ end
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Mapper
5
+ def initialize(schema:, controller: nil)
6
+ @controller = controller
7
+ @schema = schema
8
+ end
9
+
10
+ def call(*, &) = raise NotImplementedError
11
+
12
+ def self.call(*args, **kwargs, &block)
13
+ new(**kwargs).call(*args, &block)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :controller,
19
+ :schema
20
+
21
+ ##
22
+ # depth_first_map performs a postorder DFS-like traversal algorithm
23
+ # over the params. A postorder DFS starts at the leftmost leaf, and
24
+ # works its way through to the rightmost sibling, then it backtracks
25
+ # to the parent node and performs the same all the way up the tree
26
+ # until it reaches the root.
27
+ #
28
+ # The algorithm is used to perform bouncing, coercing, validations
29
+ # and transforms. For example, with transforms, this ensures that
30
+ # the node's children are transformed before the parent.
31
+ #
32
+ # Visualized, the traversal algorithm would look like this:
33
+ #
34
+ # ┌───┐
35
+ # │ 9 │
36
+ # └─┬─┘
37
+ # │
38
+ # ┌─▼─┐
39
+ # ┌────┬──┤ 8 ├───────┐
40
+ # │ │ └─┬─┘ │
41
+ # │ │ │ │
42
+ # ┌─▼─┐┌─▼─┐┌─▼─┐ ┌─▼─┐
43
+ # ┌──┤ 3 ││ 4 ││ 6 │ │ 7 │
44
+ # │ └─┬─┘└───┘└─┬─┘ └───┘
45
+ # │ │ │
46
+ # ┌─▼─┐┌─▼─┐ ┌─▼─┐
47
+ # │ 1 ││ 2 │ │ 5 │
48
+ # └───┘└───┘ └───┘
49
+ #
50
+ def depth_first_map(param, &)
51
+ return if param.nil?
52
+
53
+ # Postorder DFS, so we'll visit the children first.
54
+ if param.schema.children&.any?
55
+ case param.schema.children
56
+ in Array if param.array?
57
+ if param.schema.indexed?
58
+ param.schema.children.each_with_index { |v, i| self.class.call(param[i], schema: v, controller:, &) }
59
+ else
60
+ param.value.each { |v| self.class.call(v, schema: param.schema.children.first, controller:, &) }
61
+ end
62
+ in Hash if param.hash?
63
+ param.schema.children.each { |k, v| self.class.call(param[k], schema: v, controller:, &) }
64
+ else
65
+ end
66
+ end
67
+
68
+ # Then we visit the node.
69
+ yield param
70
+
71
+ param
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ ##
5
+ # NamespacedSet is a set of key-values that are namespaced by a class.
6
+ # What makes this special is that access supports class inheritance.
7
+ # For example, given a Parent class and a Child class that inherits
8
+ # from Parent, the Child namespace can access the Parent namespace
9
+ # as long as a Child namespace doesn't also exist, in which case
10
+ # it will take precedence.
11
+ #
12
+ # For example, the above, codified:
13
+ #
14
+ # class Parent; end
15
+ # class Child < Parent; end
16
+ #
17
+ # s = NamespacedSet.new
18
+ # s[Parent, :foo] = :bar
19
+ #
20
+ # s[Parent, :foo] => :bar
21
+ # s[Child, :foo] => :bar
22
+ #
23
+ # s[Child, :baz] = :qux
24
+ #
25
+ # s[Parent, :baz] => nil
26
+ # s[Child, :baz] => :qux
27
+ #
28
+ class NamespacedSet
29
+ def initialize = @store = {}
30
+
31
+ def []=(namespace, key, value)
32
+ store.deep_merge!(namespace => { key => value })
33
+ end
34
+
35
+ def [](namespace, key)
36
+ _, data = store.find { |k, _| k == namespace } ||
37
+ store.find { |k, _| namespace <= k }
38
+
39
+ return nil if
40
+ data.nil?
41
+
42
+ data[key]
43
+ end
44
+
45
+ def exists?(namespace, key)
46
+ _, data = store.find { |k, _| k == namespace } ||
47
+ store.find { |k, _| namespace <= k }
48
+
49
+ return false if
50
+ data.nil?
51
+
52
+ data.key?(key)
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :store
58
+ end
59
+ end