permittable 0.11.0 → 0.11.1
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 +49 -0
- data/README.md +368 -24
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +294 -61
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +209 -29
- 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)}"
|
|
@@ -7,15 +7,23 @@ module Permittable
|
|
|
7
7
|
# its own `transform:` — see permittable_transform below for exactly
|
|
8
8
|
# which field that is and why. A SUB-FIELD's own transform: still
|
|
9
9
|
# runs, so what gets stored is what an equivalent request would
|
|
10
|
-
# produce
|
|
10
|
+
# produce — for a default:, and, under a transforming array, only
|
|
11
|
+
# where an array-level validate: reads its output. An example: is
|
|
12
|
+
# read `as_sent`, which is what gets stored: no transform at any
|
|
13
|
+
# depth, no sub-field default filled in and no array-level validate:.
|
|
14
|
+
# When an array-level validate: exists somewhere in it, it is read
|
|
15
|
+
# once more first, like a default, to VALIDATE it as a request would
|
|
16
|
+
# be (see ContractBuilder, which decides). An example documents what a
|
|
17
|
+
# client SENDS, so it must not be read through app code that reshapes
|
|
18
|
+
# the value into what the action receives.
|
|
11
19
|
# * violations carry no `message:` — they become an ArgumentError for
|
|
12
20
|
# the contract's author, not a response for a client, and I18n may not
|
|
13
21
|
# be loaded yet.
|
|
14
22
|
class AuthoredValues
|
|
15
23
|
include Permittable
|
|
16
24
|
|
|
17
|
-
def self.read_array(field, value)
|
|
18
|
-
new.read_array(field, value)
|
|
25
|
+
def self.read_array(field, value, as_sent: false)
|
|
26
|
+
new.read_array(field, value, as_sent: as_sent)
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
def self.summary(violations)
|
|
@@ -23,9 +31,11 @@ module Permittable
|
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
# [value as a request would get it, violations]
|
|
26
|
-
def read_array(field, value)
|
|
34
|
+
def read_array(field, value, as_sent: false)
|
|
27
35
|
violations = []
|
|
28
36
|
@field = field
|
|
37
|
+
@as_sent = as_sent
|
|
38
|
+
@read_by_validate = fields_under_validate(field) if field[:transform]
|
|
29
39
|
read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
|
|
30
40
|
[read, violations]
|
|
31
41
|
end
|
|
@@ -45,17 +55,67 @@ module Permittable
|
|
|
45
55
|
# identical value silently diverge (and the exported OpenAPI default,
|
|
46
56
|
# which reads this same value, documents one the server never produces).
|
|
47
57
|
#
|
|
48
|
-
# When @field itself has a transform:,
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
58
|
+
# When @field itself has a transform:, it never runs here either way and
|
|
59
|
+
# its result is discarded anyway (validate_array_authored_value! stores
|
|
60
|
+
# the value AS AUTHORED instead — see its comment). A sub-field's
|
|
61
|
+
# transform: output is another matter: a request runs it, then the
|
|
62
|
+
# array-level `validate:` on that result, and only then the array's own
|
|
63
|
+
# transform. So a `validate:` that reads `i[:tags].is_a?(Array)` must be
|
|
64
|
+
# handed the transformed `tags`, or a contract every request satisfies
|
|
65
|
+
# fails at class load. But a transform: is app code (`Account.find`), so
|
|
66
|
+
# under such an array it runs ONLY where its output is consumed: for a
|
|
67
|
+
# field below an array declaring `validate:` (the walked array counts),
|
|
68
|
+
# not merely somewhere in the same subtree, where a `validate:` on a
|
|
69
|
+
# sibling branch reads nothing of it. Without an own transform: of its
|
|
70
|
+
# own, @field's read is what gets stored, so every sub-field's runs.
|
|
71
|
+
#
|
|
72
|
+
# `as_sent` (the stored read of an example:) runs none, at any depth.
|
|
53
73
|
def permittable_transform(field, value)
|
|
54
|
-
return value if @
|
|
74
|
+
return value if @as_sent || field.equal?(@field)
|
|
75
|
+
return value if @field[:transform] && !@read_by_validate.key?(field)
|
|
55
76
|
|
|
56
77
|
super
|
|
57
78
|
end
|
|
58
79
|
|
|
80
|
+
# The fields below `field` whose output an array's `validate:` sees: all
|
|
81
|
+
# those under an array that declares one, counting `field` itself, by
|
|
82
|
+
# identity (the walker hands the very field hashes it was given).
|
|
83
|
+
def fields_under_validate(field, found = {}.compare_by_identity, under_validate: false)
|
|
84
|
+
under_validate ||= field[:kind] == :array && field[:validate]
|
|
85
|
+
Array(field[:fields]).each do |sub|
|
|
86
|
+
found[sub] = true if under_validate
|
|
87
|
+
fields_under_validate(sub, found, under_validate: under_validate)
|
|
88
|
+
end
|
|
89
|
+
found
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Nor does it run an array-level validate:, which expects the transformed
|
|
93
|
+
# sub-fields a request hands it. The validating read has already run it
|
|
94
|
+
# on exactly those, so the stored read, which would hand it the
|
|
95
|
+
# untransformed ones, must not (it could raise on a shape it never sees).
|
|
96
|
+
def permittable_array_validator(field)
|
|
97
|
+
super unless @as_sent
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# What a client sends has no defaults filled in: an omitted sub-field
|
|
101
|
+
# stays omitted, at any depth, so the published example does not show a
|
|
102
|
+
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
103
|
+
# exactly what it filled, by the walker's own absence rule.
|
|
104
|
+
def permittable_check_hash(fields, hash, **)
|
|
105
|
+
result = super
|
|
106
|
+
return result unless @as_sent
|
|
107
|
+
|
|
108
|
+
fields.each do |field|
|
|
109
|
+
key = field[:name].to_s
|
|
110
|
+
next unless field.key?(:default)
|
|
111
|
+
next unless permittable_absent?(permittable_normalized(field, hash[key]), hash, key)
|
|
112
|
+
next if permittable_explicit_null?(field, hash, key)
|
|
113
|
+
|
|
114
|
+
result.delete(key)
|
|
115
|
+
end
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
59
119
|
def permittable_violation(_field, param, code)
|
|
60
120
|
{ param: param, code: code.to_s }
|
|
61
121
|
end
|
|
@@ -33,8 +33,9 @@ module Permittable
|
|
|
33
33
|
# `types:` teaches the error message: a Symbol/String applies to every
|
|
34
34
|
# listed field, a Hash maps field => type. The raised ArgumentError then
|
|
35
35
|
# appends a ready-to-paste migration command. `allowed:` maps field =>
|
|
36
|
-
# its `in:`, for the fields that declare one
|
|
37
|
-
|
|
36
|
+
# its `in:`, for the fields that declare one, and `transformed:` lists
|
|
37
|
+
# the fields that declare a `transform:`; only the enum rule reads them.
|
|
38
|
+
def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil, transformed: nil)
|
|
38
39
|
return false unless schema_reachable?(klass)
|
|
39
40
|
|
|
40
41
|
fields.flatten.compact.each do |field|
|
|
@@ -44,7 +45,7 @@ module Permittable
|
|
|
44
45
|
"#{column_migration_hint(klass, field, types)}"
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
ensure_column_type!(label, klass, field, types, allowed) if check_types
|
|
48
|
+
ensure_column_type!(label, klass, field, types, allowed, transformed: transformed) if check_types
|
|
48
49
|
end
|
|
49
50
|
true
|
|
50
51
|
end
|
|
@@ -54,7 +55,7 @@ module Permittable
|
|
|
54
55
|
# TYPE_GROUPS) and stays silent unless both sides are known, so it can
|
|
55
56
|
# only fire on a genuine cross-family mismatch — a contract still saying
|
|
56
57
|
# :datetime after the column became a string, say.
|
|
57
|
-
def ensure_column_type!(label, klass, field, types, allowed = nil)
|
|
58
|
+
def ensure_column_type!(label, klass, field, types, allowed = nil, transformed: nil)
|
|
58
59
|
declared = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
59
60
|
column = klass.columns_hash[field.to_s]
|
|
60
61
|
return unless declared && column
|
|
@@ -63,7 +64,10 @@ module Permittable
|
|
|
63
64
|
enum = enum_attribute?(klass, field)
|
|
64
65
|
# Checked before the column's type, which an enum's contract does not
|
|
65
66
|
# depend on: see ensure_enum_contract!.
|
|
66
|
-
|
|
67
|
+
if enum && wanted == :text
|
|
68
|
+
return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym],
|
|
69
|
+
transformed: Array(transformed).include?(field.to_sym))
|
|
70
|
+
end
|
|
67
71
|
|
|
68
72
|
# `column.type` is nil for a SQL type the adapter does not recognise
|
|
69
73
|
# (a PostGIS geometry column without the extension loaded, a custom
|
|
@@ -107,25 +111,31 @@ module Permittable
|
|
|
107
111
|
# Accepted means what the enum's cast accepts: every name, plus every
|
|
108
112
|
# stored value that is a String (a string-backed enum takes `"p"` for
|
|
109
113
|
# `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
|
-
#
|
|
114
|
+
# are not accepted — a request carries `"0"`, which maps to nothing —
|
|
115
|
+
# unless the field declares a `transform:`, which runs after `in:` and is
|
|
116
|
+
# how permittable:generate's TODO maps them back to names. Only the stored
|
|
117
|
+
# integers are admitted that way; the transform is app code, trusted to
|
|
118
|
+
# do what the TODO spells. A Range, or anything else without a finite
|
|
119
|
+
# list, cannot be checked, so it is refused rather than trusted.
|
|
113
120
|
#
|
|
114
121
|
# Only `enum` is recognised. The attribute API (`attribute :x, :datetime`
|
|
115
122
|
# over a string column) is left compared against the column by choice:
|
|
116
123
|
# an enum's mapping says exactly which strings are valid, an attribute
|
|
117
124
|
# override says nothing a contract could be checked against.
|
|
118
|
-
def ensure_enum_contract!(label, klass, field, declared, listed)
|
|
125
|
+
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false)
|
|
119
126
|
problem =
|
|
120
127
|
if listed.nil?
|
|
121
128
|
"declared :#{declared} without an in:"
|
|
122
129
|
elsif listed.is_a?(Range) || !listed.respond_to?(:to_a)
|
|
123
130
|
"declared :#{declared} with an in: that does not list its values"
|
|
124
131
|
else
|
|
125
|
-
|
|
132
|
+
accepted = enum_values(klass, field)
|
|
133
|
+
accepted += stored_integers(klass, field) if transformed
|
|
134
|
+
stray = listed.to_a - accepted
|
|
126
135
|
return if stray.empty?
|
|
127
136
|
|
|
128
|
-
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}"
|
|
137
|
+
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}" \
|
|
138
|
+
"#{stored_integer_hint(klass, field, stray, transformed)}"
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
raise ArgumentError,
|
|
@@ -139,6 +149,16 @@ module Permittable
|
|
|
139
149
|
mapping.keys.map(&:to_s) + mapping.values.grep(String)
|
|
140
150
|
end
|
|
141
151
|
|
|
152
|
+
def stored_integers(klass, field)
|
|
153
|
+
klass.defined_enums[field.to_s].values.grep(Integer).map(&:to_s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stored_integer_hint(klass, field, stray, transformed)
|
|
157
|
+
return "" if transformed || !stray.intersect?(stored_integers(klass, field))
|
|
158
|
+
|
|
159
|
+
" (a stored integer is accepted only beside a transform: mapping it back to its name)"
|
|
160
|
+
end
|
|
161
|
+
|
|
142
162
|
# `Order.statuses.keys` when the enum's plural reader exists, and the
|
|
143
163
|
# always-valid `defined_enums["..."]` spelling when it does not (a name
|
|
144
164
|
# 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
|
|
@@ -118,9 +146,30 @@ module Permittable
|
|
|
118
146
|
|
|
119
147
|
def normalize_input(input)
|
|
120
148
|
return {} if input.nil?
|
|
121
|
-
return input if input.
|
|
149
|
+
return input if input.respond_to?(:to_unsafe_h)
|
|
150
|
+
return unwrap_parameters(input) if input.is_a?(Hash)
|
|
122
151
|
|
|
123
152
|
raise ArgumentError, "#{LABEL}: Contract#call expects a Hash (got #{input.class})"
|
|
124
153
|
end
|
|
154
|
+
|
|
155
|
+
# A controller's params reach the concern as ActionController::Parameters
|
|
156
|
+
# and are converted deeply by to_unsafe_h. A service object is usually
|
|
157
|
+
# handed a slice of them inside a plain Hash — `CreateUser.call(user:
|
|
158
|
+
# params[:user])` — and nothing converted those: a Parameters value is
|
|
159
|
+
# not a Hash, so a root read it as invalid_type (400), a nested block or
|
|
160
|
+
# :json field likewise (422). Each one is converted here, the way
|
|
161
|
+
# to_unsafe_h converts the whole. Only what holds one is copied.
|
|
162
|
+
def unwrap_parameters(value)
|
|
163
|
+
case value
|
|
164
|
+
when Hash
|
|
165
|
+
unwrapped = value.transform_values { |v| unwrap_parameters(v) }
|
|
166
|
+
unwrapped.each_value.zip(value.each_value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
167
|
+
when Array
|
|
168
|
+
unwrapped = value.map { |v| unwrap_parameters(v) }
|
|
169
|
+
unwrapped.zip(value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
170
|
+
else
|
|
171
|
+
value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
125
174
|
end
|
|
126
175
|
end
|
|
@@ -45,20 +45,74 @@ module Permittable
|
|
|
45
45
|
|
|
46
46
|
def render_envelope(controller, message:, status:, code: nil, details: nil)
|
|
47
47
|
if controller.respond_to?(:render_error)
|
|
48
|
-
|
|
49
|
-
# render_error contract as `(message:, status:, code:)`, and an
|
|
50
|
-
# unconditional errors: kwarg would break those implementations.
|
|
48
|
+
host = host_method(controller)
|
|
51
49
|
kwargs = { message: message, code: code, status: status }
|
|
52
|
-
kwargs[:errors] = details if details
|
|
53
|
-
controller.render_error(**kwargs)
|
|
50
|
+
kwargs[:errors] = details if details && (host.nil? || takes_errors?(host))
|
|
51
|
+
host ? host.call(**kwargs) : controller.render_error(**kwargs)
|
|
54
52
|
else
|
|
55
53
|
error = { message: message }
|
|
56
54
|
error[:code] = code if code
|
|
57
55
|
error[:details] = details if details
|
|
58
|
-
|
|
56
|
+
# content_type: because `render json:` only supplies application/json
|
|
57
|
+
# when the response has none yet, and `respond_to` has already set the
|
|
58
|
+
# negotiated one (text/html for a browser form, or for `Accept: */*`
|
|
59
|
+
# with html listed first). Without it this JSON body goes out as HTML.
|
|
60
|
+
# A JSON type the response already carries (a registered
|
|
61
|
+
# application/vnd.api+json, say) is the host's choice and is kept.
|
|
62
|
+
options = { json: { success: false, error: error }, status: status }
|
|
63
|
+
options[:content_type] = "application/json" unless json_media_type?(response_media_type(controller))
|
|
64
|
+
controller.render(**options)
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# The media type (no parameters) the response already carries, or nil when
|
|
69
|
+
# it has none yet or the host has no response to ask.
|
|
70
|
+
def response_media_type(controller)
|
|
71
|
+
return nil unless controller.respond_to?(:response)
|
|
72
|
+
|
|
73
|
+
response = controller.response
|
|
74
|
+
return nil unless response
|
|
75
|
+
|
|
76
|
+
type = response.respond_to?(:media_type) ? response.media_type : response.content_type
|
|
77
|
+
return nil unless type
|
|
78
|
+
|
|
79
|
+
type.to_s.split(";").first.to_s.strip.downcase
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def json_media_type?(type)
|
|
83
|
+
!type.nil? && (type == "application/json" || type.end_with?("+json"))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The Method for the host's render_error, or nil when the controller claims
|
|
87
|
+
# to respond (respond_to? is true) yet has no such method to inspect: one
|
|
88
|
+
# served by method_missing without respond_to_missing?. Master called it
|
|
89
|
+
# straight through with errors: included, and nil keeps doing exactly that
|
|
90
|
+
# rather than raising NameError out of the rescue_from handler.
|
|
91
|
+
def host_method(controller)
|
|
92
|
+
controller.method(:render_error)
|
|
93
|
+
rescue NameError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A rejection always carries details, so passing errors: whenever there
|
|
98
|
+
# are some passed it on every call — and a host documenting its contract
|
|
99
|
+
# as `(message:, status:, code:)`, which the README promises keeps
|
|
100
|
+
# working, raised ArgumentError out of the rescue_from handler: a 500 for
|
|
101
|
+
# every invalid request. errors: now goes only to a method that can take
|
|
102
|
+
# it: one naming it, taking **kwargs, or declaring no keywords at all
|
|
103
|
+
# (`*args`, which received it inside its trailing Hash before).
|
|
104
|
+
def takes_errors?(method)
|
|
105
|
+
parameters = method.parameters
|
|
106
|
+
return true if parameters.any? { |kind, _| kind == :keyrest }
|
|
107
|
+
|
|
108
|
+
names = keyword_names(parameters)
|
|
109
|
+
names.empty? || names.include?(:errors)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def keyword_names(parameters)
|
|
113
|
+
parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
|
|
114
|
+
end
|
|
115
|
+
|
|
62
116
|
# Members are emitted in the order RFC 9457 documents them, so the wire
|
|
63
117
|
# format is stable and readable. `instance` is omitted rather than guessed
|
|
64
118
|
# when the host cannot name the request path (a params duck, a job).
|