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.
@@ -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
- return "Permittable audit: no routed actions to report.\n" if entries.empty?
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 `default:`/`example:`
3
- # at class load — the same permittable_check_array a request goes
4
- # through, so the two cannot drift apart. Two seams are overridden:
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:, its result is discarded anyway
49
- # (validate_array_authored_value! stores the value AS AUTHORED instead —
50
- # see its comment), so nothing inside its subtree is worth reading
51
- # transformed: suppressing every nested call too keeps that walk free of
52
- # app code, exactly as a scalar default's cast-only check already is.
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 @field[:transform] || field.equal?(@field)
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; only the enum rule reads it.
37
- def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil)
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
- return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym]) if enum && wanted == :text
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. A
111
- # Range, or anything else without a finite list, cannot be checked, so it
112
- # is refused rather than trusted.
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
- stray = listed.to_a - enum_values(klass, field)
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).
@@ -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
- # definition instead of two that drift.
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.is_a?(Hash) || input.respond_to?(:to_unsafe_h)
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 identical inline JSON.
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
- if controller.respond_to?(:render_error)
48
- # errors: only when there are details — a host may document its
49
- # render_error contract as `(message:, status:, code:)`, and an
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
- controller.render(json: { success: false, error: error }, status: status)
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