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.
@@ -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)}"
@@ -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:, 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.
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 @field[:transform] || field.equal?(@field)
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; only the enum rule reads it.
37
- def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil)
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
- return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym]) if enum && wanted == :text
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. A
111
- # Range, or anything else without a finite list, cannot be checked, so it
112
- # is refused rather than trusted.
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
- stray = listed.to_a - enum_values(klass, field)
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).
@@ -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.is_a?(Hash) || input.respond_to?(:to_unsafe_h)
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
- # 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
+ 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
- controller.render(json: { success: false, error: error }, status: status)
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).