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.
- 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
|