typed_params 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/CONTRIBUTING.md +33 -0
- data/LICENSE +20 -0
- data/README.md +736 -0
- data/SECURITY.md +8 -0
- data/lib/typed_params/bouncer.rb +34 -0
- data/lib/typed_params/coercer.rb +21 -0
- data/lib/typed_params/configuration.rb +40 -0
- data/lib/typed_params/controller.rb +192 -0
- data/lib/typed_params/formatters/formatter.rb +20 -0
- data/lib/typed_params/formatters/jsonapi.rb +142 -0
- data/lib/typed_params/formatters/rails.rb +31 -0
- data/lib/typed_params/formatters.rb +20 -0
- data/lib/typed_params/handler.rb +24 -0
- data/lib/typed_params/handler_set.rb +19 -0
- data/lib/typed_params/mapper.rb +74 -0
- data/lib/typed_params/namespaced_set.rb +59 -0
- data/lib/typed_params/parameter.rb +100 -0
- data/lib/typed_params/parameterizer.rb +87 -0
- data/lib/typed_params/path.rb +57 -0
- data/lib/typed_params/pipeline.rb +13 -0
- data/lib/typed_params/processor.rb +27 -0
- data/lib/typed_params/schema.rb +290 -0
- data/lib/typed_params/schema_set.rb +7 -0
- data/lib/typed_params/transformer.rb +49 -0
- data/lib/typed_params/transforms/key_alias.rb +16 -0
- data/lib/typed_params/transforms/key_casing.rb +59 -0
- data/lib/typed_params/transforms/nilify_blanks.rb +16 -0
- data/lib/typed_params/transforms/noop.rb +11 -0
- data/lib/typed_params/transforms/transform.rb +11 -0
- data/lib/typed_params/types/array.rb +12 -0
- data/lib/typed_params/types/boolean.rb +33 -0
- data/lib/typed_params/types/date.rb +10 -0
- data/lib/typed_params/types/decimal.rb +10 -0
- data/lib/typed_params/types/float.rb +10 -0
- data/lib/typed_params/types/hash.rb +13 -0
- data/lib/typed_params/types/integer.rb +10 -0
- data/lib/typed_params/types/nil.rb +11 -0
- data/lib/typed_params/types/number.rb +10 -0
- data/lib/typed_params/types/string.rb +10 -0
- data/lib/typed_params/types/symbol.rb +10 -0
- data/lib/typed_params/types/time.rb +20 -0
- data/lib/typed_params/types/type.rb +78 -0
- data/lib/typed_params/types.rb +69 -0
- data/lib/typed_params/validations/exclusion.rb +17 -0
- data/lib/typed_params/validations/format.rb +19 -0
- data/lib/typed_params/validations/inclusion.rb +17 -0
- data/lib/typed_params/validations/length.rb +29 -0
- data/lib/typed_params/validations/validation.rb +18 -0
- data/lib/typed_params/validator.rb +75 -0
- data/lib/typed_params/version.rb +5 -0
- data/lib/typed_params.rb +89 -0
- metadata +124 -0
data/SECURITY.md
ADDED
@@ -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
|