permittable 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- metadata +2 -2
data/lib/permittable/audit.rb
CHANGED
|
@@ -378,18 +378,24 @@ module Permittable
|
|
|
378
378
|
# entries; what was ignored follows in sections of its own, then any
|
|
379
379
|
# ignore entry that matched nothing — `unmatched` defaults to checking
|
|
380
380
|
# Permittable.audit_ignore, the list `entries` defaults to.
|
|
381
|
+
#
|
|
382
|
+
# With no routed action at all there is no table or summary, but the
|
|
383
|
+
# stale and unmatched sections still follow: that is when a contract no
|
|
384
|
+
# route reaches, or an ignore entry naming nothing, most needs saying.
|
|
381
385
|
def format(entries, stale: {}, unmatched: unmatched_ignores(entries))
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
ignored, entries = entries.partition(&:ignored?)
|
|
385
|
-
out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
|
|
386
|
-
out << summary_lines(summary(entries), rows: entries.length)
|
|
386
|
+
ignored, audited = entries.partition(&:ignored?)
|
|
387
|
+
out = entries.empty? ? ["Permittable audit: no routed actions to report."] : table_lines(audited)
|
|
387
388
|
out << stale_lines(stale) unless stale.empty?
|
|
388
389
|
out << ignored_lines(ignored) unless ignored.empty?
|
|
389
390
|
out << unmatched_lines(unmatched) unless unmatched.empty?
|
|
390
391
|
"#{out.join("\n")}\n"
|
|
391
392
|
end
|
|
392
393
|
|
|
394
|
+
def table_lines(entries)
|
|
395
|
+
entries.group_by(&:controller).map { |key, group| controller_block(key, group) } <<
|
|
396
|
+
summary_lines(summary(entries), rows: entries.length)
|
|
397
|
+
end
|
|
398
|
+
|
|
393
399
|
def controller_block(key, group)
|
|
394
400
|
rows = group.map do |entry|
|
|
395
401
|
" #{entry.verb.to_s.upcase.ljust(6)} #{entry.path.ljust(34)} #{entry.action.ljust(12)} #{status(entry)}"
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
module Permittable
|
|
2
|
-
# The request walker, pointed at an authored array `
|
|
3
|
-
# at class load — the same permittable_check_array
|
|
4
|
-
# through, so the two cannot
|
|
2
|
+
# The request walker, pointed at an authored array or `:json`
|
|
3
|
+
# `default:`/`example:` at class load — the same permittable_check_array
|
|
4
|
+
# and permittable_check_json a request goes through, so the two cannot
|
|
5
|
+
# drift apart. Two seams are overridden:
|
|
5
6
|
#
|
|
6
7
|
# * the field WHOSE default:/example: is being validated does not run
|
|
7
8
|
# its own `transform:` — see permittable_transform below for exactly
|
|
8
9
|
# which field that is and why. A SUB-FIELD's own transform: still
|
|
9
10
|
# runs, so what gets stored is what an equivalent request would
|
|
10
|
-
# produce
|
|
11
|
+
# produce — for a default:, and, under a transforming array, only
|
|
12
|
+
# where an array-level validate: reads its output. An example: is
|
|
13
|
+
# read `as_sent`, which is what gets stored: no transform at any
|
|
14
|
+
# depth, no sub-field default filled in and no array-level validate:.
|
|
15
|
+
# When an array-level validate: exists somewhere in it, it is read
|
|
16
|
+
# once more first, like a default, to VALIDATE it as a request would
|
|
17
|
+
# be (see ContractBuilder, which decides). An example documents what a
|
|
18
|
+
# client SENDS, so it must not be read through app code that reshapes
|
|
19
|
+
# the value into what the action receives.
|
|
11
20
|
# * violations carry no `message:` — they become an ArgumentError for
|
|
12
21
|
# the contract's author, not a response for a client, and I18n may not
|
|
13
22
|
# be loaded yet.
|
|
14
23
|
class AuthoredValues
|
|
15
24
|
include Permittable
|
|
16
25
|
|
|
17
|
-
def self.read_array(field, value)
|
|
18
|
-
new.read_array(field, value)
|
|
26
|
+
def self.read_array(field, value, as_sent: false)
|
|
27
|
+
new.read_array(field, value, as_sent: as_sent)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.read_json(field, value)
|
|
31
|
+
new.read_json(field, value)
|
|
19
32
|
end
|
|
20
33
|
|
|
21
34
|
def self.summary(violations)
|
|
@@ -23,13 +36,24 @@ module Permittable
|
|
|
23
36
|
end
|
|
24
37
|
|
|
25
38
|
# [value as a request would get it, violations]
|
|
26
|
-
def read_array(field, value)
|
|
39
|
+
def read_array(field, value, as_sent: false)
|
|
27
40
|
violations = []
|
|
28
41
|
@field = field
|
|
42
|
+
@as_sent = as_sent
|
|
43
|
+
@read_by_validate = fields_under_validate(field) if field[:transform]
|
|
29
44
|
read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
|
|
30
45
|
[read, violations]
|
|
31
46
|
end
|
|
32
47
|
|
|
48
|
+
# [:ok, value as a request would get it] or [:error, code]. A request's
|
|
49
|
+
# hash reaches permittable_check_json indifferent, as every Hash inside
|
|
50
|
+
# the walker's params is, so the authored one is made so first, the way
|
|
51
|
+
# the walker reads a nested hash. permittable_check_json then hands
|
|
52
|
+
# `validate:` a deep copy, as it does a request's, and returns that copy.
|
|
53
|
+
def read_json(field, value)
|
|
54
|
+
permittable_check_json(field, ActiveSupport::HashWithIndifferentAccess.new(value))
|
|
55
|
+
end
|
|
56
|
+
|
|
33
57
|
def summary(violations)
|
|
34
58
|
permittable_violation_summary(violations)
|
|
35
59
|
end
|
|
@@ -45,17 +69,67 @@ module Permittable
|
|
|
45
69
|
# identical value silently diverge (and the exported OpenAPI default,
|
|
46
70
|
# which reads this same value, documents one the server never produces).
|
|
47
71
|
#
|
|
48
|
-
# When @field itself has a transform:,
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
72
|
+
# When @field itself has a transform:, it never runs here either way and
|
|
73
|
+
# its result is discarded anyway (validate_array_authored_value! stores
|
|
74
|
+
# the value AS AUTHORED instead — see its comment). A sub-field's
|
|
75
|
+
# transform: output is another matter: a request runs it, then the
|
|
76
|
+
# array-level `validate:` on that result, and only then the array's own
|
|
77
|
+
# transform. So a `validate:` that reads `i[:tags].is_a?(Array)` must be
|
|
78
|
+
# handed the transformed `tags`, or a contract every request satisfies
|
|
79
|
+
# fails at class load. But a transform: is app code (`Account.find`), so
|
|
80
|
+
# under such an array it runs ONLY where its output is consumed: for a
|
|
81
|
+
# field below an array declaring `validate:` (the walked array counts),
|
|
82
|
+
# not merely somewhere in the same subtree, where a `validate:` on a
|
|
83
|
+
# sibling branch reads nothing of it. Without an own transform: of its
|
|
84
|
+
# own, @field's read is what gets stored, so every sub-field's runs.
|
|
85
|
+
#
|
|
86
|
+
# `as_sent` (the stored read of an example:) runs none, at any depth.
|
|
53
87
|
def permittable_transform(field, value)
|
|
54
|
-
return value if @
|
|
88
|
+
return value if @as_sent || field.equal?(@field)
|
|
89
|
+
return value if @field[:transform] && !@read_by_validate.key?(field)
|
|
55
90
|
|
|
56
91
|
super
|
|
57
92
|
end
|
|
58
93
|
|
|
94
|
+
# The fields below `field` whose output an array's `validate:` sees: all
|
|
95
|
+
# those under an array that declares one, counting `field` itself, by
|
|
96
|
+
# identity (the walker hands the very field hashes it was given).
|
|
97
|
+
def fields_under_validate(field, found = {}.compare_by_identity, under_validate: false)
|
|
98
|
+
under_validate ||= field[:kind] == :array && field[:validate]
|
|
99
|
+
Array(field[:fields]).each do |sub|
|
|
100
|
+
found[sub] = true if under_validate
|
|
101
|
+
fields_under_validate(sub, found, under_validate: under_validate)
|
|
102
|
+
end
|
|
103
|
+
found
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Nor does it run an array-level validate:, which expects the transformed
|
|
107
|
+
# sub-fields a request hands it. The validating read has already run it
|
|
108
|
+
# on exactly those, so the stored read, which would hand it the
|
|
109
|
+
# untransformed ones, must not (it could raise on a shape it never sees).
|
|
110
|
+
def permittable_array_validator(field)
|
|
111
|
+
super unless @as_sent
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# What a client sends has no defaults filled in: an omitted sub-field
|
|
115
|
+
# stays omitted, at any depth, so the published example does not show a
|
|
116
|
+
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
117
|
+
# exactly what it filled, by the walker's own absence rule.
|
|
118
|
+
def permittable_check_hash(fields, hash, **)
|
|
119
|
+
result = super
|
|
120
|
+
return result unless @as_sent
|
|
121
|
+
|
|
122
|
+
fields.each do |field|
|
|
123
|
+
key = field[:name].to_s
|
|
124
|
+
next unless field.key?(:default)
|
|
125
|
+
next unless permittable_absent?(permittable_normalized(field, hash[key]), hash, key)
|
|
126
|
+
next if permittable_explicit_null?(field, hash, key)
|
|
127
|
+
|
|
128
|
+
result.delete(key)
|
|
129
|
+
end
|
|
130
|
+
result
|
|
131
|
+
end
|
|
132
|
+
|
|
59
133
|
def permittable_violation(_field, param, code)
|
|
60
134
|
{ param: param, code: code.to_s }
|
|
61
135
|
end
|
|
@@ -28,13 +28,23 @@ module Permittable
|
|
|
28
28
|
date: :temporal, datetime: :temporal, time: :temporal, timestamp: :temporal, timestamptz: :temporal
|
|
29
29
|
}.freeze
|
|
30
30
|
|
|
31
|
+
# The contract type an `array ..., of:` takes for a column element type,
|
|
32
|
+
# for the array-column hint. Only scalar element types: `of:` takes no
|
|
33
|
+
# other, so a jsonb[] column gets the hint without an `of:`.
|
|
34
|
+
ARRAY_ELEMENT_TYPES = {
|
|
35
|
+
string: :string, text: :string, citext: :string, uuid: :string,
|
|
36
|
+
integer: :integer, bigint: :integer, float: :float, decimal: :decimal, boolean: :boolean,
|
|
37
|
+
date: :date, datetime: :datetime, timestamp: :datetime, timestamptz: :datetime
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
31
40
|
module_function
|
|
32
41
|
|
|
33
42
|
# `types:` teaches the error message: a Symbol/String applies to every
|
|
34
43
|
# listed field, a Hash maps field => type. The raised ArgumentError then
|
|
35
44
|
# appends a ready-to-paste migration command. `allowed:` maps field =>
|
|
36
|
-
# its `in:`, for the fields that declare one
|
|
37
|
-
|
|
45
|
+
# its `in:`, for the fields that declare one, and `transformed:` lists
|
|
46
|
+
# the fields that declare a `transform:`; only the enum rule reads them.
|
|
47
|
+
def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil, transformed: nil)
|
|
38
48
|
return false unless schema_reachable?(klass)
|
|
39
49
|
|
|
40
50
|
fields.flatten.compact.each do |field|
|
|
@@ -44,7 +54,7 @@ module Permittable
|
|
|
44
54
|
"#{column_migration_hint(klass, field, types)}"
|
|
45
55
|
end
|
|
46
56
|
|
|
47
|
-
ensure_column_type!(label, klass, field, types, allowed) if check_types
|
|
57
|
+
ensure_column_type!(label, klass, field, types, allowed, transformed: transformed) if check_types
|
|
48
58
|
end
|
|
49
59
|
true
|
|
50
60
|
end
|
|
@@ -54,16 +64,21 @@ module Permittable
|
|
|
54
64
|
# TYPE_GROUPS) and stays silent unless both sides are known, so it can
|
|
55
65
|
# only fire on a genuine cross-family mismatch — a contract still saying
|
|
56
66
|
# :datetime after the column became a string, say.
|
|
57
|
-
def ensure_column_type!(label, klass, field, types, allowed = nil)
|
|
67
|
+
def ensure_column_type!(label, klass, field, types, allowed = nil, transformed: nil)
|
|
58
68
|
declared = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
59
69
|
column = klass.columns_hash[field.to_s]
|
|
60
70
|
return unless declared && column
|
|
61
71
|
|
|
72
|
+
ensure_not_array_column!(label, klass, field, declared, column)
|
|
73
|
+
|
|
62
74
|
wanted = TYPE_GROUPS[declared.to_sym]
|
|
63
75
|
enum = enum_attribute?(klass, field)
|
|
64
76
|
# Checked before the column's type, which an enum's contract does not
|
|
65
77
|
# depend on: see ensure_enum_contract!.
|
|
66
|
-
|
|
78
|
+
if enum && wanted == :text
|
|
79
|
+
return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym],
|
|
80
|
+
transformed: Array(transformed).include?(field.to_sym))
|
|
81
|
+
end
|
|
67
82
|
|
|
68
83
|
# `column.type` is nil for a SQL type the adapter does not recognise
|
|
69
84
|
# (a PostGIS geometry column without the extension loaded, a custom
|
|
@@ -88,6 +103,24 @@ module Permittable
|
|
|
88
103
|
"(table: #{klass.table_name}). #{fix}"
|
|
89
104
|
end
|
|
90
105
|
|
|
106
|
+
# A PostgreSQL array column reports its ELEMENT type as `type`, so
|
|
107
|
+
# `optional :tags, :string` over `t.string :tags, array: true` sits in
|
|
108
|
+
# the same group as the column and the group check passes — yet the
|
|
109
|
+
# field rejects as invalid_type every array the column stores. Only a
|
|
110
|
+
# scalar or :json field reaches this guard (an `array` field is
|
|
111
|
+
# implicitly virtual), and neither accepts an Array, so any declaration
|
|
112
|
+
# over an array column is the wrong shape.
|
|
113
|
+
def ensure_not_array_column!(label, klass, field, declared, column)
|
|
114
|
+
return unless column.respond_to?(:array?) && column.array?
|
|
115
|
+
|
|
116
|
+
element = ARRAY_ELEMENT_TYPES[column.type&.to_sym]
|
|
117
|
+
spelled = element ? "array :#{field}, of: :#{element}" : "array :#{field}"
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"#{label}: '#{field}' is declared :#{declared} but the column is an array#{" (of :#{column.type})" if column.type} " \
|
|
120
|
+
"(table: #{klass.table_name}), so every array it stores would be rejected as invalid_type. " \
|
|
121
|
+
"Declare it `#{spelled}`, or declare the field virtual: true if it is not backed by this column."
|
|
122
|
+
end
|
|
123
|
+
|
|
91
124
|
# `model:` is only duck-typed on column_names, hence the respond_to?.
|
|
92
125
|
def enum_attribute?(klass, field)
|
|
93
126
|
klass.respond_to?(:defined_enums) && klass.defined_enums.key?(field.to_s)
|
|
@@ -107,25 +140,31 @@ module Permittable
|
|
|
107
140
|
# Accepted means what the enum's cast accepts: every name, plus every
|
|
108
141
|
# stored value that is a String (a string-backed enum takes `"p"` for
|
|
109
142
|
# `pro:` as readily as `"pro"`). An integer-backed enum's stored values
|
|
110
|
-
# are not accepted — a request carries `"0"`, which maps to nothing
|
|
111
|
-
#
|
|
112
|
-
#
|
|
143
|
+
# are not accepted — a request carries `"0"`, which maps to nothing —
|
|
144
|
+
# unless the field declares a `transform:`, which runs after `in:` and is
|
|
145
|
+
# how permittable:generate's TODO maps them back to names. Only the stored
|
|
146
|
+
# integers are admitted that way; the transform is app code, trusted to
|
|
147
|
+
# do what the TODO spells. A Range, or anything else without a finite
|
|
148
|
+
# list, cannot be checked, so it is refused rather than trusted.
|
|
113
149
|
#
|
|
114
150
|
# Only `enum` is recognised. The attribute API (`attribute :x, :datetime`
|
|
115
151
|
# over a string column) is left compared against the column by choice:
|
|
116
152
|
# an enum's mapping says exactly which strings are valid, an attribute
|
|
117
153
|
# override says nothing a contract could be checked against.
|
|
118
|
-
def ensure_enum_contract!(label, klass, field, declared, listed)
|
|
154
|
+
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false)
|
|
119
155
|
problem =
|
|
120
156
|
if listed.nil?
|
|
121
157
|
"declared :#{declared} without an in:"
|
|
122
158
|
elsif listed.is_a?(Range) || !listed.respond_to?(:to_a)
|
|
123
159
|
"declared :#{declared} with an in: that does not list its values"
|
|
124
160
|
else
|
|
125
|
-
|
|
161
|
+
accepted = enum_values(klass, field)
|
|
162
|
+
accepted += stored_integers(klass, field) if transformed
|
|
163
|
+
stray = listed.to_a - accepted
|
|
126
164
|
return if stray.empty?
|
|
127
165
|
|
|
128
|
-
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}"
|
|
166
|
+
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}" \
|
|
167
|
+
"#{stored_integer_hint(klass, field, stray, transformed)}"
|
|
129
168
|
end
|
|
130
169
|
|
|
131
170
|
raise ArgumentError,
|
|
@@ -139,6 +178,16 @@ module Permittable
|
|
|
139
178
|
mapping.keys.map(&:to_s) + mapping.values.grep(String)
|
|
140
179
|
end
|
|
141
180
|
|
|
181
|
+
def stored_integers(klass, field)
|
|
182
|
+
klass.defined_enums[field.to_s].values.grep(Integer).map(&:to_s)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def stored_integer_hint(klass, field, stray, transformed)
|
|
186
|
+
return "" if transformed || !stray.intersect?(stored_integers(klass, field))
|
|
187
|
+
|
|
188
|
+
" (a stored integer is accepted only beside a transform: mapping it back to its name)"
|
|
189
|
+
end
|
|
190
|
+
|
|
142
191
|
# `Order.statuses.keys` when the enum's plural reader exists, and the
|
|
143
192
|
# always-valid `defined_enums["..."]` spelling when it does not (a name
|
|
144
193
|
# that is not a Ruby identifier, a duck-typed model).
|
data/lib/permittable/contract.rb
CHANGED
|
@@ -62,10 +62,38 @@ module Permittable
|
|
|
62
62
|
super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# unknown: :log warns through `logger`, and a controller gets one from
|
|
66
|
+
# ActionController. This host has none, so :log used to drop the keys
|
|
67
|
+
# without a word, exactly like :ignore. Rails.logger is read on every
|
|
68
|
+
# call, not at definition: contracts are usually constants, loaded
|
|
69
|
+
# before an app's initializers may have assigned it. With no Rails
|
|
70
|
+
# (a script, a job, a CSV import) the warning goes to $stderr rather
|
|
71
|
+
# than nowhere, since a :log that cannot log is :ignore by another
|
|
72
|
+
# name. Defining the contract does not raise for a missing logger:
|
|
73
|
+
# whether one exists is only known when the contract is called.
|
|
74
|
+
#
|
|
75
|
+
# The fallback Logger is built on every call, not memoized: #call!
|
|
76
|
+
# makes a new host per call, so an instance memo never applied, and a
|
|
77
|
+
# class-level one would capture $stderr once, hiding a later
|
|
78
|
+
# reassignment (a test's capture, a daemonized process's redirect).
|
|
79
|
+
def logger
|
|
80
|
+
return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
81
|
+
|
|
82
|
+
::Logger.new($stderr)
|
|
83
|
+
end
|
|
84
|
+
|
|
65
85
|
# Instrumentation payload label (anonymous classes have no name).
|
|
66
86
|
def permittable_controller_name
|
|
67
87
|
"Permittable::Contract"
|
|
68
88
|
end
|
|
89
|
+
|
|
90
|
+
# ... and its action: the host has no action_name, so the payload's
|
|
91
|
+
# `action` (documented as a String) and the log lines that name the
|
|
92
|
+
# action would be nil. A Contract has one entry point, the same
|
|
93
|
+
# "call" #call! passes to permitted_params.
|
|
94
|
+
def permittable_action_name
|
|
95
|
+
ACTION
|
|
96
|
+
end
|
|
69
97
|
end
|
|
70
98
|
@host_class.permit_params(root: root, unknown: unknown, model: model, mode: :enforce, desc: desc, &)
|
|
71
99
|
end
|
|
@@ -80,7 +108,10 @@ module Permittable
|
|
|
80
108
|
# The contract's fields, which is also what makes a Contract usable as a
|
|
81
109
|
# field group: `use SomeContract` inside a permit_params block splices
|
|
82
110
|
# them in, so a webhook payload and a controller action can share one
|
|
83
|
-
#
|
|
111
|
+
# set of fields instead of two that drift. Only the fields — `use`
|
|
112
|
+
# refuses a contract that declares a finalize, which no field carries.
|
|
113
|
+
# To share both, put the fields in a `Permittable.fields` group that
|
|
114
|
+
# each side uses, and declare finalize in each.
|
|
84
115
|
def fields
|
|
85
116
|
rule[:fields]
|
|
86
117
|
end
|
|
@@ -118,9 +149,30 @@ module Permittable
|
|
|
118
149
|
|
|
119
150
|
def normalize_input(input)
|
|
120
151
|
return {} if input.nil?
|
|
121
|
-
return input if input.
|
|
152
|
+
return input if input.respond_to?(:to_unsafe_h)
|
|
153
|
+
return unwrap_parameters(input) if input.is_a?(Hash)
|
|
122
154
|
|
|
123
155
|
raise ArgumentError, "#{LABEL}: Contract#call expects a Hash (got #{input.class})"
|
|
124
156
|
end
|
|
157
|
+
|
|
158
|
+
# A controller's params reach the concern as ActionController::Parameters
|
|
159
|
+
# and are converted deeply by to_unsafe_h. A service object is usually
|
|
160
|
+
# handed a slice of them inside a plain Hash — `CreateUser.call(user:
|
|
161
|
+
# params[:user])` — and nothing converted those: a Parameters value is
|
|
162
|
+
# not a Hash, so a root read it as invalid_type (400), a nested block or
|
|
163
|
+
# :json field likewise (422). Each one is converted here, the way
|
|
164
|
+
# to_unsafe_h converts the whole. Only what holds one is copied.
|
|
165
|
+
def unwrap_parameters(value)
|
|
166
|
+
case value
|
|
167
|
+
when Hash
|
|
168
|
+
unwrapped = value.transform_values { |v| unwrap_parameters(v) }
|
|
169
|
+
unwrapped.each_value.zip(value.each_value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
170
|
+
when Array
|
|
171
|
+
unwrapped = value.map { |v| unwrap_parameters(v) }
|
|
172
|
+
unwrapped.zip(value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
173
|
+
else
|
|
174
|
+
value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
125
177
|
end
|
|
126
178
|
end
|
|
@@ -3,7 +3,8 @@ module Permittable
|
|
|
3
3
|
#
|
|
4
4
|
# `:envelope` (the default) is the gem's original shape: the host's
|
|
5
5
|
# #render_error when the controller defines one (e.g. concerns_on_rails'
|
|
6
|
-
# Respondable), otherwise the
|
|
6
|
+
# Respondable; see host_render_error for a private one), otherwise the
|
|
7
|
+
# identical inline JSON.
|
|
7
8
|
#
|
|
8
9
|
# `:problem` renders RFC 9457 Problem Details — `application/problem+json`
|
|
9
10
|
# with type/title/status/detail/instance members and the field violations as
|
|
@@ -44,21 +45,109 @@ module Permittable
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def render_envelope(controller, message:, status:, code: nil, details: nil)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# unconditional errors: kwarg would break those implementations.
|
|
48
|
+
public_host = controller.respond_to?(:render_error)
|
|
49
|
+
host = public_host ? host_method(controller) : private_host_method(controller)
|
|
50
|
+
if public_host || host
|
|
51
51
|
kwargs = { message: message, code: code, status: status }
|
|
52
|
-
kwargs[:errors] = details if details
|
|
53
|
-
controller.render_error(**kwargs)
|
|
52
|
+
kwargs[:errors] = details if details && (host.nil? || takes_errors?(host))
|
|
53
|
+
host ? host.call(**kwargs) : controller.render_error(**kwargs)
|
|
54
54
|
else
|
|
55
55
|
error = { message: message }
|
|
56
56
|
error[:code] = code if code
|
|
57
57
|
error[:details] = details if details
|
|
58
|
-
|
|
58
|
+
# content_type: because `render json:` only supplies application/json
|
|
59
|
+
# when the response has none yet, and `respond_to` has already set the
|
|
60
|
+
# negotiated one (text/html for a browser form, or for `Accept: */*`
|
|
61
|
+
# with html listed first). Without it this JSON body goes out as HTML.
|
|
62
|
+
# A JSON type the response already carries (a registered
|
|
63
|
+
# application/vnd.api+json, say) is the host's choice and is kept.
|
|
64
|
+
options = { json: { success: false, error: error }, status: status }
|
|
65
|
+
options[:content_type] = "application/json" unless json_media_type?(response_media_type(controller))
|
|
66
|
+
controller.render(**options)
|
|
59
67
|
end
|
|
60
68
|
end
|
|
61
69
|
|
|
70
|
+
# The media type (no parameters) the response already carries, or nil when
|
|
71
|
+
# it has none yet or the host has no response to ask.
|
|
72
|
+
def response_media_type(controller)
|
|
73
|
+
return nil unless controller.respond_to?(:response)
|
|
74
|
+
|
|
75
|
+
response = controller.response
|
|
76
|
+
return nil unless response
|
|
77
|
+
|
|
78
|
+
type = response.respond_to?(:media_type) ? response.media_type : response.content_type
|
|
79
|
+
return nil unless type
|
|
80
|
+
|
|
81
|
+
type.to_s.split(";").first.to_s.strip.downcase
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def json_media_type?(type)
|
|
85
|
+
!type.nil? && (type == "application/json" || type.end_with?("+json"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The Method for the host's public render_error, or nil when the controller
|
|
89
|
+
# claims to respond (respond_to? is true) yet has no such method to
|
|
90
|
+
# inspect: one served by method_missing without respond_to_missing?.
|
|
91
|
+
# Master called it straight through with errors: included, and nil keeps
|
|
92
|
+
# doing exactly that rather than raising NameError out of the rescue_from
|
|
93
|
+
# handler.
|
|
94
|
+
def host_method(controller)
|
|
95
|
+
controller.method(:render_error)
|
|
96
|
+
rescue NameError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A private render_error is the usual spelling on an ApplicationController
|
|
101
|
+
# (a public method there is routable as an action in every subclass), so
|
|
102
|
+
# it is used too — but only when it is shaped like the documented keyword
|
|
103
|
+
# contract: a private `render_error(message, status)` helper written for
|
|
104
|
+
# something else was never consulted, and must not start turning every 422
|
|
105
|
+
# into a 500. Nil renders the inline shape.
|
|
106
|
+
def private_host_method(controller)
|
|
107
|
+
return nil unless controller.respond_to?(:render_error, true)
|
|
108
|
+
|
|
109
|
+
method = host_method(controller)
|
|
110
|
+
method if method && keyword_contract?(method.parameters)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The keywords render_envelope may pass: message:, code: and status:
|
|
114
|
+
# always, errors: when the method takes it.
|
|
115
|
+
DELEGATED_KEYWORDS = %i[message code status errors].freeze
|
|
116
|
+
|
|
117
|
+
# Shaped like the documented contract: it takes message:, code: and
|
|
118
|
+
# status: (by name or through **), and requires nothing the gem does not
|
|
119
|
+
# pass — no positional argument, no keyword outside DELEGATED_KEYWORDS.
|
|
120
|
+
# `(message:, status:)` is a common private helper on an
|
|
121
|
+
# ApplicationController, and calling it with code: would raise.
|
|
122
|
+
def keyword_contract?(parameters)
|
|
123
|
+
return false if parameters.any? { |kind, _| kind == :req }
|
|
124
|
+
|
|
125
|
+
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
|
|
126
|
+
return false unless (required - DELEGATED_KEYWORDS).empty?
|
|
127
|
+
return true if parameters.any? { |kind, _| kind == :keyrest }
|
|
128
|
+
|
|
129
|
+
(%i[message code status] - keyword_names(parameters)).empty?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A rejection always carries details, so passing errors: whenever there
|
|
133
|
+
# are some passed it on every call — and a host documenting its contract
|
|
134
|
+
# as `(message:, status:, code:)`, which the README promises keeps
|
|
135
|
+
# working, raised ArgumentError out of the rescue_from handler: a 500 for
|
|
136
|
+
# every invalid request. errors: now goes only to a method that can take
|
|
137
|
+
# it: one naming it, taking **kwargs, or declaring no keywords at all
|
|
138
|
+
# (`*args`, which received it inside its trailing Hash before).
|
|
139
|
+
def takes_errors?(method)
|
|
140
|
+
parameters = method.parameters
|
|
141
|
+
return true if parameters.any? { |kind, _| kind == :keyrest }
|
|
142
|
+
|
|
143
|
+
names = keyword_names(parameters)
|
|
144
|
+
names.empty? || names.include?(:errors)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def keyword_names(parameters)
|
|
148
|
+
parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
|
|
149
|
+
end
|
|
150
|
+
|
|
62
151
|
# Members are emitted in the order RFC 9457 documents them, so the wire
|
|
63
152
|
# format is stable and readable. `instance` is omitted rather than guessed
|
|
64
153
|
# when the host cannot name the request path (a params duck, a job).
|
|
@@ -25,7 +25,8 @@ module Permittable
|
|
|
25
25
|
# use UserFields
|
|
26
26
|
# end
|
|
27
27
|
#
|
|
28
|
-
# # PATCH: same fields, nothing mandatory
|
|
28
|
+
# # PATCH: same fields, nothing mandatory, no defaults (a default
|
|
29
|
+
# # would reset plan to "free" on every PATCH that omits it).
|
|
29
30
|
# permit_params :update, root: :user, model: User do
|
|
30
31
|
# use UserFields, optional: true
|
|
31
32
|
# end
|