typed_params 0.2.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 (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