active_typed_params 0.1.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 +5 -0
- data/LICENSE.txt +20 -0
- data/README.md +534 -0
- data/lib/active_typed_params/builder.rb +357 -0
- data/lib/active_typed_params/compiler.rb +360 -0
- data/lib/active_typed_params/controller.rb +29 -0
- data/lib/active_typed_params/declaration.rb +90 -0
- data/lib/active_typed_params/enum_adapter.rb +28 -0
- data/lib/active_typed_params/errors.rb +78 -0
- data/lib/active_typed_params/inference.rb +264 -0
- data/lib/active_typed_params/inline.rb +94 -0
- data/lib/active_typed_params/node.rb +70 -0
- data/lib/active_typed_params/railtie.rb +19 -0
- data/lib/active_typed_params/runtime.rb +182 -0
- data/lib/active_typed_params/schema.rb +176 -0
- data/lib/active_typed_params/version.rb +5 -0
- data/lib/active_typed_params.rb +148 -0
- metadata +92 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Declares parameters from an ActiveInteraction's filters or a model's
|
|
5
|
+
# columns, so the expected values are written once.
|
|
6
|
+
module Inference
|
|
7
|
+
ID_BYTES = [4, 8].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Filters whose values arrive in requests. Records and plain objects are
|
|
12
|
+
# passed by the controller, so they are skipped, as is a filter declared
|
|
13
|
+
# with `from_params: false`; an object filter whose class an enum adapter
|
|
14
|
+
# handles becomes an enum.
|
|
15
|
+
#
|
|
16
|
+
# Every input is optional and accepts null, at any depth, because the
|
|
17
|
+
# interaction reports a missing or null input as a form error and fills in
|
|
18
|
+
# the default for a null one; rejecting either here would preempt that. For
|
|
19
|
+
# the same reason a string the interaction validates the length of is not
|
|
20
|
+
# capped, and a date or time the filter parses itself stays a string.
|
|
21
|
+
def from_filter(filter, interaction = nil)
|
|
22
|
+
return if filter.options[:from_params] == false
|
|
23
|
+
|
|
24
|
+
declaration = from_custom_filter(filter) || from_builtin_filter(filter, interaction)
|
|
25
|
+
declaration&.optional(allow_nil: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from_builtin_filter(filter, interaction)
|
|
29
|
+
case filter.class.slug
|
|
30
|
+
when :string, :symbol then uncapped(Declaration.build(:string), interaction, filter.name)
|
|
31
|
+
when :integer, :float, :decimal, :boolean, :file then Declaration.build(filter.class.slug)
|
|
32
|
+
when :date, :time then Declaration.build(filter.options.key?(:format) ? :string : filter.class.slug)
|
|
33
|
+
when :date_time then Declaration.build(:string)
|
|
34
|
+
when :hash then from_hash_filter(filter)
|
|
35
|
+
when :array then from_array_filter(filter)
|
|
36
|
+
when :object then from_object_filter(filter)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def from_custom_filter(filter)
|
|
41
|
+
ActiveTypedParams.config.filter_inferrers.lazy.map { |inferrer| inferrer.call(filter) }.find(&:itself)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from_hash_filter(filter)
|
|
45
|
+
children = filter.filters.filter_map { |name, child| [name, from_filter(child)] if from_filter(child) }
|
|
46
|
+
options = filter.options.fetch(:strip, true) ? {} : {open: true}
|
|
47
|
+
Declaration.build(:hash, children:, options:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def from_array_filter(filter)
|
|
51
|
+
item_filter = filter.filters[:"0"]
|
|
52
|
+
item = item_filter ? from_filter(item_filter) : Declaration.build(:any).optional(allow_nil: true)
|
|
53
|
+
Declaration.build(:array, item:) if item
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def from_object_filter(filter)
|
|
57
|
+
klass = Object.const_get(filter.options.fetch(:class, filter.name).to_s.camelize)
|
|
58
|
+
Declaration.build(:enum, source: klass) if EnumAdapter.lookup(klass)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Presence follows the schema. Nullability follows the column, unless the
|
|
62
|
+
# model validates presence and so reports a null itself; likewise a
|
|
63
|
+
# string the model validates the length of is not capped here.
|
|
64
|
+
def from_attribute(model, name)
|
|
65
|
+
name = name.to_s
|
|
66
|
+
attribute = model.respond_to?(:attribute_aliases) ? model.attribute_aliases.fetch(name, name) : name
|
|
67
|
+
attachment = from_attachment(model, attribute)
|
|
68
|
+
return attachment if attachment
|
|
69
|
+
return from_attribute_container(model, attribute) unless model.respond_to?(:columns_hash)
|
|
70
|
+
|
|
71
|
+
column = model.columns_hash[attribute] || raise(SchemaError, "#{model} has no column #{name.inspect}")
|
|
72
|
+
declaration = from_attribute_type(model, attribute) || from_column(model, attribute, column)
|
|
73
|
+
uncapped(declaration, model, attribute).merge_options(allow_nil: column.null || validates?(model, attribute, :presence))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# StoreModel and ActiveModel::Attributes classes declare types but no columns.
|
|
77
|
+
def from_attribute_container(model, name)
|
|
78
|
+
type = model.attribute_types[name] || raise(SchemaError, "#{model} has no attribute #{name.inspect}")
|
|
79
|
+
uncapped(from_type(type), model, name).merge_options(allow_nil: true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def from_attachment(model, name)
|
|
83
|
+
reflection = model.reflect_on_attachment(name) if model.respond_to?(:reflect_on_attachment)
|
|
84
|
+
return if reflection.nil?
|
|
85
|
+
|
|
86
|
+
if reflection.macro == :has_many_attached
|
|
87
|
+
Declaration.build(:array, item: Declaration.build(:string), options: {invalid: :drop, allow_nil: true})
|
|
88
|
+
else
|
|
89
|
+
Declaration.direct_upload(allow_nil: true)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def from_attribute_type(model, name)
|
|
94
|
+
type = model.attribute_types[name]
|
|
95
|
+
return Declaration.build(:any) if defined?(ActiveRecord::Type::Serialized) && type.is_a?(ActiveRecord::Type::Serialized)
|
|
96
|
+
|
|
97
|
+
type && from_type(type, custom_only: true)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def from_type(type, custom_only: false)
|
|
101
|
+
custom = ActiveTypedParams.config.type_inferrers.lazy.map { |inferrer| inferrer.call(type) }.find(&:itself) || from_store_model(type)
|
|
102
|
+
return custom if custom || custom_only
|
|
103
|
+
|
|
104
|
+
case type.type
|
|
105
|
+
when :string then Declaration.build(:string)
|
|
106
|
+
when :text then Declaration.build(:string, options: text_options)
|
|
107
|
+
when :integer, :float, :decimal, :boolean, :date then Declaration.build(type.type)
|
|
108
|
+
when :datetime, :time then Declaration.build(:time)
|
|
109
|
+
else Declaration.build(:any)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A StoreModel attribute is a hash of its model's attributes, or an array
|
|
114
|
+
# of them. StoreModel keeps the model class private.
|
|
115
|
+
def from_store_model(type)
|
|
116
|
+
return unless defined?(StoreModel::Types::One)
|
|
117
|
+
|
|
118
|
+
case type
|
|
119
|
+
when StoreModel::Types::One then store_model_hash(type.instance_variable_get(:@model_klass))
|
|
120
|
+
when StoreModel::Types::Many then Declaration.build(:array, item: store_model_hash(type.instance_variable_get(:@model_klass)))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def store_model_hash(model)
|
|
125
|
+
children = model.attribute_types.map { |name, type| [name, from_type(type).optional(allow_nil: true)] }
|
|
126
|
+
Declaration.build(:hash, children:)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def from_column(model, name, column)
|
|
130
|
+
element = from_scalar_column(model, name, column)
|
|
131
|
+
(column.respond_to?(:array) && column.array) ? Declaration.build(:array, item: element) : element
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def from_scalar_column(model, name, column)
|
|
135
|
+
enum = model.defined_enums[name] if model.respond_to?(:defined_enums)
|
|
136
|
+
return Declaration.build(:string, options: {in: enum.keys}) if enum
|
|
137
|
+
|
|
138
|
+
case column.type
|
|
139
|
+
when :string then Declaration.build(:string, options: {max_length: column.limit}.compact)
|
|
140
|
+
when :text then Declaration.build(:string, options: text_options)
|
|
141
|
+
when :enum then Declaration.build(:string)
|
|
142
|
+
when :integer
|
|
143
|
+
if name.end_with?("_id")
|
|
144
|
+
Declaration.build(:id, options: {bytes: ID_BYTES.include?(column.limit) ? column.limit : 8})
|
|
145
|
+
else
|
|
146
|
+
Declaration.build(:integer)
|
|
147
|
+
end
|
|
148
|
+
when :decimal, :float, :boolean, :date then Declaration.build(column.type)
|
|
149
|
+
when :datetime, :timestamp, :timestamptz, :time then Declaration.build(:time)
|
|
150
|
+
when :json, :jsonb then Declaration.build(:any)
|
|
151
|
+
else raise SchemaError, "cannot infer a parameter type for the #{column.type.inspect} column #{name.inspect}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def text_options
|
|
156
|
+
{max_length: ActiveTypedParams.config.text_max_length}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A length validator on an array counts its items, so only a string is left
|
|
160
|
+
# for the owner to cap.
|
|
161
|
+
def uncapped(declaration, owner, name)
|
|
162
|
+
return declaration unless declaration.type == :string && validates?(owner, name, :length)
|
|
163
|
+
|
|
164
|
+
declaration.merge_options(text_options)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The cap the model's attribute of the same name would get, or none when
|
|
168
|
+
# the model has no such attribute or no parameter type for it.
|
|
169
|
+
def model_cap(model, name)
|
|
170
|
+
from_attribute(model, name).options.slice(:max_length)
|
|
171
|
+
rescue SchemaError
|
|
172
|
+
{}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validates?(owner, name, kind)
|
|
176
|
+
owner.respond_to?(:validators_on) && owner.validators_on(name.to_sym).any? { |validator| validator.kind == kind }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# `key: {option: value}` pairs add options to an inferred key, such as a
|
|
180
|
+
# condition or a presence flip, while its type still comes from the
|
|
181
|
+
# source; options named like a child of an inferred hash adjust that
|
|
182
|
+
# child. Unlike in `param`, a Hash here never declares nested keys:
|
|
183
|
+
# `key: type`, such as `key: hash(...)`, replaces the inferred type in place.
|
|
184
|
+
module BuilderMethods
|
|
185
|
+
# `model:` names the record the inputs are saved to, so string inputs take
|
|
186
|
+
# the caps of its columns of the same name.
|
|
187
|
+
def inputs_from(interaction, only: nil, except: nil, model: nil, **overrides)
|
|
188
|
+
filters = interaction.filters
|
|
189
|
+
available = filters.keys - ActiveTypedParams.config.ignored_filters.map(&:to_sym)
|
|
190
|
+
names = only ? restrict(available, only, "only") : available
|
|
191
|
+
names -= restrict(filters.keys, except, "except") if except
|
|
192
|
+
named = (Array(only).map(&:to_sym) | overrides.keys.map(&:to_sym))
|
|
193
|
+
names = filters.keys & (names | restrict(available, overrides.keys, "inputs_from"))
|
|
194
|
+
names.each do |name|
|
|
195
|
+
declaration = Inference.from_filter(filters[name], interaction)
|
|
196
|
+
raise SchemaError, "#{interaction}'s #{name.inspect} input cannot come from a request" if declaration.nil? && named.include?(name)
|
|
197
|
+
|
|
198
|
+
if model && declaration&.type == :string && !declaration.options.key?(:max_length)
|
|
199
|
+
declaration = declaration.merge_options(Inference.model_cap(model, name))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
apply_declaration(name, override(declaration, overrides[name])) if declaration
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Infers the attribute named by the key it is given to, as in
|
|
207
|
+
# `address: attribute_from(User)`.
|
|
208
|
+
def attribute_from(model)
|
|
209
|
+
described(Declaration.build(:attribute, source: model))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Lists the attributes of a model; a StoreModel or ActiveModel class
|
|
213
|
+
# without names declares every attribute but those in `except:`.
|
|
214
|
+
def attributes_from(model, *names, except: nil, **overrides)
|
|
215
|
+
if names.empty? && !model.respond_to?(:columns_hash)
|
|
216
|
+
names = model.attribute_types.keys.map(&:to_sym)
|
|
217
|
+
names -= restrict(names, except, "except") if except
|
|
218
|
+
elsif except
|
|
219
|
+
raise SchemaError, "attributes_from #{model} takes except: only without names, for a StoreModel or ActiveModel class"
|
|
220
|
+
end
|
|
221
|
+
names |= overrides.keys
|
|
222
|
+
raise SchemaError, "attributes_from #{model} needs at least one attribute" if names.empty?
|
|
223
|
+
|
|
224
|
+
names.each { |name| apply_declaration(name, override(Inference.from_attribute(model, name), overrides[name])) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def restrict(names, subset, option)
|
|
230
|
+
subset = Array(subset).map(&:to_sym)
|
|
231
|
+
unknown = subset - names
|
|
232
|
+
raise SchemaError, "#{option}: names filters that do not exist: #{unknown.inspect}" if unknown.any?
|
|
233
|
+
|
|
234
|
+
names & subset
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def override(declaration, options)
|
|
238
|
+
return declaration if options.nil?
|
|
239
|
+
unless options.is_a?(Hash)
|
|
240
|
+
consume(options)
|
|
241
|
+
return Declaration.from_literal(options)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
declaration, options = override_children(declaration, options)
|
|
245
|
+
required = presence_option(options)
|
|
246
|
+
declaration = declaration.with(required:, options: declaration.options.except(:allow_nil)) unless required.nil?
|
|
247
|
+
declaration.merge_options(options.except(*Builder::PRESENCE_OPTIONS))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def override_children(declaration, options)
|
|
251
|
+
return [declaration, options] unless declaration.type == :hash && declaration.children&.any?
|
|
252
|
+
|
|
253
|
+
child_names = declaration.children.map { |name, _| name.to_sym }
|
|
254
|
+
nested, options = options.partition { |key, _| child_names.include?(key.to_sym) }.map(&:to_h)
|
|
255
|
+
return [declaration, options] if nested.empty?
|
|
256
|
+
|
|
257
|
+
children = declaration.children.map { |name, child| [name, nested.key?(name.to_sym) ? override(child, nested[name.to_sym]) : child] }
|
|
258
|
+
[declaration.with(children:), options]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
Builder.include Inference::BuilderMethods
|
|
264
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Each distinct declaration compiles once per process, keyed by the literal
|
|
5
|
+
# itself. A proc would be a new object on every request and grow the cache
|
|
6
|
+
# without bound, so a declaration holding one is rejected before it is cached.
|
|
7
|
+
class Inline
|
|
8
|
+
def self.reject_procs(nodes, label)
|
|
9
|
+
nodes.each do |node|
|
|
10
|
+
if [node.in, node.default, node.condition&.last].any?(Proc)
|
|
11
|
+
raise SchemaError, "#{label} cannot hold a proc at #{node.key.inspect}; declare a schema instead"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
reject_procs(node.children, label) if node.children
|
|
15
|
+
reject_procs([node.item], label) if node.item
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(context)
|
|
20
|
+
@context = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def require(*names, **pairs)
|
|
24
|
+
run(true, names, pairs)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def optional(*names, **pairs)
|
|
28
|
+
run(false, names, pairs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The keys that were sent, as a Hash even for one key, so an update does
|
|
32
|
+
# not clear an attribute the request left out.
|
|
33
|
+
def slice(*names, **pairs)
|
|
34
|
+
run(false, names, pairs, unwrap: false)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Type helpers describe a value for `require`, `optional` and `slice`, as
|
|
38
|
+
# in `typed_params.optional(ids: typed_params.array(:id))`. They run on
|
|
39
|
+
# every request, before the cache lookup, so they build the declaration
|
|
40
|
+
# directly; its options are validated when the schema is first built.
|
|
41
|
+
LITERAL_TYPES.each do |type|
|
|
42
|
+
define_method(type) { |**options| describe(type, options) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def text(**options)
|
|
46
|
+
describe(:string, Inference.text_options.merge(options))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def enum(source, **options)
|
|
50
|
+
describe(:enum, options, source:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def array(item, **options)
|
|
54
|
+
describe(:array, options, item: Declaration.from_literal(item))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def map(value, **options)
|
|
58
|
+
describe(:map, options, item: Declaration.from_literal(value))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hash(*fields, **options)
|
|
62
|
+
options, pairs = options.partition { |key, _| Builder::OPTIONS[:hash].include?(key) }.map(&:to_h)
|
|
63
|
+
list = pairs.empty? ? fields : [*fields, pairs]
|
|
64
|
+
describe(:hash, options, children: list.empty? && options[:open] ? [] : Declaration.from_list(list).children)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :context
|
|
70
|
+
|
|
71
|
+
def describe(type, options, **parts)
|
|
72
|
+
required = options.key?(:optional) ? !options[:optional] : options[:required]
|
|
73
|
+
Declaration.build(type, required:, options: options.except(*Builder::PRESENCE_OPTIONS), **parts)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run(required, names, pairs, unwrap: true)
|
|
77
|
+
schema = ActiveTypedParams.inline_schema([required, names, pairs]) { build(required, names, pairs) }
|
|
78
|
+
request = context.request if context.respond_to?(:request, true)
|
|
79
|
+
result = schema.call(context, context.params, request)
|
|
80
|
+
(unwrap && schema.nodes.size == 1) ? result[schema.nodes.first.result_key] : result
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build(required, names, pairs)
|
|
84
|
+
builder = Builder.new
|
|
85
|
+
required ? builder.required(*names, **pairs) : builder.optional(*names, **pairs)
|
|
86
|
+
label = "typed_params.#{required ? "require" : "optional"}"
|
|
87
|
+
nodes = builder.nodes
|
|
88
|
+
raise SchemaError, "#{label} declares no keys" if nodes.empty?
|
|
89
|
+
|
|
90
|
+
Inline.reject_procs(nodes, label)
|
|
91
|
+
Schema.new(name: :inline, nodes:, module_name: "Inline__#{Digest::SHA256.hexdigest(nodes.inspect)[0, 12]}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Marks a node without a `default:`, so that `default: nil` stays distinct.
|
|
5
|
+
NO_DEFAULT = Object.new.freeze
|
|
6
|
+
|
|
7
|
+
TYPES = %i[string integer id decimal float boolean date time enum file hash array map any one_of].freeze
|
|
8
|
+
SCALAR_TYPES = (TYPES - %i[hash array map any one_of]).freeze
|
|
9
|
+
# The JSON type a `one_of` picks each alternative by.
|
|
10
|
+
JSON_TYPES = {
|
|
11
|
+
string: :string, date: :string, time: :string, enum: :string,
|
|
12
|
+
integer: :number, id: :number, decimal: :number, float: :number,
|
|
13
|
+
boolean: :boolean, array: :array, hash: :object, map: :object
|
|
14
|
+
}.freeze
|
|
15
|
+
LITERAL_TYPES = (SCALAR_TYPES - [:enum] + [:any]).freeze
|
|
16
|
+
# `text` is a string without the default cap, for free text of any length.
|
|
17
|
+
LITERAL_NAMES = (LITERAL_TYPES + [:text, :direct_upload]).freeze
|
|
18
|
+
|
|
19
|
+
Node = Data.define(
|
|
20
|
+
:key, :type, :required, :allow_nil, :allow_blank, :default, :in, :max_length,
|
|
21
|
+
:condition, :as, :json, :open, :bytes, :enum_map, :invalid, :children, :item, :map_key
|
|
22
|
+
) do
|
|
23
|
+
def self.build(key:, type:, required:, **options)
|
|
24
|
+
new(
|
|
25
|
+
key: key&.to_s,
|
|
26
|
+
type:,
|
|
27
|
+
required:,
|
|
28
|
+
allow_nil: options.fetch(:allow_nil, false),
|
|
29
|
+
allow_blank: options.fetch(:allow_blank, false),
|
|
30
|
+
default: options.fetch(:default, NO_DEFAULT),
|
|
31
|
+
in: options.fetch(:in, nil),
|
|
32
|
+
max_length: options.fetch(:max_length, nil),
|
|
33
|
+
condition: options.fetch(:condition, nil),
|
|
34
|
+
as: options.fetch(:as, nil),
|
|
35
|
+
json: options.fetch(:json, false),
|
|
36
|
+
open: options.fetch(:open, false),
|
|
37
|
+
bytes: options.fetch(:bytes, nil),
|
|
38
|
+
enum_map: options.fetch(:enum_map, nil),
|
|
39
|
+
invalid: options.fetch(:invalid, :raise),
|
|
40
|
+
children: options.fetch(:children, nil),
|
|
41
|
+
item: options.fetch(:item, nil),
|
|
42
|
+
map_key: options.fetch(:map_key, nil),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default?
|
|
47
|
+
!default.equal?(NO_DEFAULT)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def result_key
|
|
51
|
+
(as || key).to_sym
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hash?
|
|
55
|
+
type == :hash
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def array?
|
|
59
|
+
type == :array
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def map?
|
|
63
|
+
type == :map
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def child_keys
|
|
67
|
+
children.map(&:key)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Makes `typed_params` available in every controller, compiles every schema
|
|
5
|
+
# at boot when eager loading, and starts over when code reloads.
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "active_typed_params.controller" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller) { include ActiveTypedParams::Controller }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "active_typed_params.reloader" do |app|
|
|
12
|
+
app.reloader.before_class_unload { ActiveTypedParams.unload! }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
config.after_initialize do |app|
|
|
16
|
+
ActiveTypedParams.compile_all! if app.config.eager_load
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Helpers the compiled methods call. Each one is either a type conversion
|
|
5
|
+
# (returning nil when the value is invalid) or a boundary with the host
|
|
6
|
+
# framework.
|
|
7
|
+
module Runtime
|
|
8
|
+
INTEGER = /\A-?\d+\z/
|
|
9
|
+
DECIMAL = /\A-?\d+(?:\.\d+)?\z/
|
|
10
|
+
FLOAT = /\A-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
|
|
11
|
+
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
12
|
+
TIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
|
|
13
|
+
BOOLEANS = {"true" => true, "false" => false, "1" => true, "0" => false}.freeze
|
|
14
|
+
FRAMEWORK_KEYS = Set.new(%w[controller action format authenticity_token _method utf8 commit]).freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# The string-keyed hash behind ActionController::Parameters. Reading that
|
|
19
|
+
# internal (here, in `unwrap_hash` and in `plain`) avoids the deep copy
|
|
20
|
+
# `to_unsafe_h` makes; `spec/active_typed_params/runtime_spec.rb` pins it.
|
|
21
|
+
def raw(params)
|
|
22
|
+
case params
|
|
23
|
+
when ActionController::Parameters then params.instance_variable_get(:@parameters)
|
|
24
|
+
when ActiveSupport::HashWithIndifferentAccess then params
|
|
25
|
+
when Hash then ActiveSupport::HashWithIndifferentAccess.new(params)
|
|
26
|
+
else raise ArgumentError, "expected a Hash or ActionController::Parameters, got #{params.class}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# What a `source:` returns: a hash, or nil or an empty string for none.
|
|
31
|
+
def source_hash(value)
|
|
32
|
+
return ActiveSupport::HashWithIndifferentAccess.new if value.nil? || value == ""
|
|
33
|
+
|
|
34
|
+
Runtime.raw(unwrap_hash(value) || fail(:invalid_type, [], :hash))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def unwrap_hash(value)
|
|
38
|
+
case value
|
|
39
|
+
when Hash then value
|
|
40
|
+
when ActionController::Parameters then value.instance_variable_get(:@parameters)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fail(code, path, type = nil)
|
|
45
|
+
raise InvalidParameter.for(code, path, type)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def integer(value)
|
|
49
|
+
Integer(value, 10) if value.is_a?(String) && INTEGER.match?(value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def decimal(value)
|
|
53
|
+
case value
|
|
54
|
+
when Integer then BigDecimal(value)
|
|
55
|
+
when BigDecimal then value
|
|
56
|
+
when Float then BigDecimal(value, Float::DIG) if value.finite?
|
|
57
|
+
when String then BigDecimal(value) if DECIMAL.match?(value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def float(value)
|
|
62
|
+
parsed = case value
|
|
63
|
+
when Float then value
|
|
64
|
+
when Integer then value.to_f
|
|
65
|
+
when String then Float(value) if FLOAT.match?(value)
|
|
66
|
+
end
|
|
67
|
+
parsed if parsed&.finite?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def boolean(value)
|
|
71
|
+
if value == true || value == false
|
|
72
|
+
value
|
|
73
|
+
elsif value.is_a?(String)
|
|
74
|
+
BOOLEANS[value]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def date(value)
|
|
79
|
+
return unless value.is_a?(String) && DATE.match?(value)
|
|
80
|
+
|
|
81
|
+
Date.iso8601(value)
|
|
82
|
+
rescue Date::Error
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A date alone is rejected, since Time.zone would read it as midnight in
|
|
87
|
+
# the server's zone rather than in the zone the client meant.
|
|
88
|
+
def time(value)
|
|
89
|
+
return unless value.is_a?(String) && TIME.match?(value)
|
|
90
|
+
|
|
91
|
+
if Time.respond_to?(:zone) && Time.zone
|
|
92
|
+
Time.zone.iso8601(value)
|
|
93
|
+
else
|
|
94
|
+
Time.iso8601(value)
|
|
95
|
+
end
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def file?(value)
|
|
101
|
+
ActiveTypedParams.config.file_classes.any? { |klass| value.is_a?(klass) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_json(value, path, type)
|
|
105
|
+
JSON.parse(value)
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
fail(:invalid_type, path, type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Keys are symbols at every level, like the keys a schema declares.
|
|
111
|
+
def plain(value)
|
|
112
|
+
case value
|
|
113
|
+
when Hash then value.each_with_object({}) { |(key, child), result| result[key.to_sym] = plain(child) }
|
|
114
|
+
when Array then value.map { |child| plain(child) }
|
|
115
|
+
when ActionController::Parameters then plain(value.instance_variable_get(:@parameters))
|
|
116
|
+
else value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def deep_freeze(value)
|
|
121
|
+
case value
|
|
122
|
+
when Hash then value.each_value { |child| deep_freeze(child) }.freeze
|
|
123
|
+
when Array then value.each { |child| deep_freeze(child) }.freeze
|
|
124
|
+
else value.freeze
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def allowed?(allowed, value)
|
|
129
|
+
allowed.is_a?(Range) ? allowed.cover?(value) : allowed.include?(value)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Undeclared keys are reported with `unknown: :report`, or rejected with
|
|
133
|
+
# `unknown: :reject`. Route and framework keys are never unknown at the
|
|
134
|
+
# root, and neither is the copy ParamsWrapper makes of a JSON body.
|
|
135
|
+
def check_unknown(hash, declared, mode, path, context, request)
|
|
136
|
+
unknown = nil
|
|
137
|
+
path_parameters = request&.path_parameters
|
|
138
|
+
wrapped = wrapped_keys(hash, declared, context) if request
|
|
139
|
+
hash.each_key do |key|
|
|
140
|
+
next if declared.include?(key)
|
|
141
|
+
next if request && (FRAMEWORK_KEYS.include?(key) || path_parameters&.key?(key.to_sym) || wrapped&.include?(key))
|
|
142
|
+
|
|
143
|
+
fail(:unknown, [*path, key]) if mode == :reject
|
|
144
|
+
(unknown ||= []) << [*path, key].join(".")
|
|
145
|
+
end
|
|
146
|
+
report_unknown(unknown, context) if unknown
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The wrapper key, plus the root keys it copied when the schema reads the
|
|
150
|
+
# wrapped hash instead of the root.
|
|
151
|
+
def wrapped_keys(hash, declared, context)
|
|
152
|
+
return unless context.respond_to?(:_wrapper_key, true)
|
|
153
|
+
|
|
154
|
+
wrapper = context.send(:_wrapper_key)&.to_s
|
|
155
|
+
return if wrapper.nil?
|
|
156
|
+
|
|
157
|
+
wrapped = unwrap_hash(hash[wrapper]) if declared.include?(wrapper)
|
|
158
|
+
wrapped ? [wrapper, *wrapped.keys] : [wrapper]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def report_unknown(keys, context)
|
|
162
|
+
ActiveSupport::Notifications.instrument("unknown_parameters.typed_params", keys:, **context_payload(context))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def report_invalid(error, context)
|
|
166
|
+
ActiveSupport::Notifications.instrument(
|
|
167
|
+
"invalid_parameters.typed_params",
|
|
168
|
+
path: error.path.join("."), code: error.code, **context_payload(context),
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def context_payload(context)
|
|
173
|
+
return {} if context.nil? || context.instance_of?(Object)
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
controller: context.class.name,
|
|
177
|
+
action: (context.action_name if context.respond_to?(:action_name)),
|
|
178
|
+
request: (context.request if context.respond_to?(:request, true)),
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|