permittable 0.9.0 → 0.11.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.
@@ -15,6 +15,11 @@ module Permittable
15
15
  # bin/rails permittable:audit[strict] # ...and exit 1 if a write action
16
16
  # # is unguarded (a CI gate)
17
17
  #
18
+ # What is not the app's to guard — ActiveStorage's direct uploads, a
19
+ # catch-all 404 — is left out with Permittable.audit_ignore, and every
20
+ # controller whose source is outside Rails.root is left out by default.
21
+ # Either way it leaves the counts and [strict], never the report.
22
+ #
18
23
  # Plain Ruby over the frozen registry and a list of route descriptors (the
19
24
  # same `{ controller:, action:, verb:, path: }` shape OpenAPI.rails_routes
20
25
  # produces, plus the optional `route:` it tags each one with), so it is
@@ -30,6 +35,14 @@ module Permittable
30
35
  # is usually fine; a POST without one is the finding.
31
36
  BODY_VERBS = %w[post put patch].freeze
32
37
 
38
+ # Why an entry is left out of the counts, keyed as Entry#ignored holds it,
39
+ # with the heading its section of the report carries. Listed in this
40
+ # order: what the app chose to ignore first, then what it got by default.
41
+ IGNORE_REASONS = {
42
+ configured: "Ignored by Permittable.audit_ignore (left out of the counts above):",
43
+ outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
44
+ }.freeze
45
+
33
46
  # One routed action, paired with the rule a request for it would resolve
34
47
  # through (nil when nothing covers it — including when the controller
35
48
  # never included the concern, which is exactly the case worth finding).
@@ -37,8 +50,10 @@ module Permittable
37
50
  # `missing_action` marks a route to an action the controller does not
38
51
  # define — see Audit.missing_action?. `route` identifies the route the
39
52
  # path came from (nil for a hand-built descriptor, which is then its own
40
- # route); see `summary`.
41
- Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, keyword_init: true) do
53
+ # route); see `summary`. `ignored` is why the entry is left out of the
54
+ # counts — a key of IGNORE_REASONS — or nil when it is audited.
55
+ Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, :ignored,
56
+ keyword_init: true) do
42
57
  def covered?
43
58
  !rule.nil?
44
59
  end
@@ -47,6 +62,10 @@ module Permittable
47
62
  missing_action == true
48
63
  end
49
64
 
65
+ def ignored?
66
+ !ignored.nil?
67
+ end
68
+
50
69
  # The mode this action would actually run in, rule-level declaration
51
70
  # first and the app-wide default behind it.
52
71
  def mode
@@ -69,25 +88,137 @@ module Permittable
69
88
  # Every routed action of every given controller, sorted for stable output.
70
89
  # Pass ALL controllers, not just the ones including Permittable — a
71
90
  # controller that never included it is unguarded, which is the point.
72
- def entries(controllers:, routes:)
91
+ #
92
+ # Ignored actions are returned too, marked with why (Entry#ignored):
93
+ # `ignore:` is the Permittable.audit_ignore list, and `root:` the app
94
+ # root a controller's source must be inside (see app_root; nil ignores
95
+ # nothing on that ground). Both default to the app's own configuration,
96
+ # as `mode` does.
97
+ def entries(controllers:, routes:, ignore: Permittable.audit_ignore, root: app_root)
73
98
  routes = routes.to_a
74
- controllers.flat_map { |controller| controller_entries(controller, routes) }
99
+ controllers.flat_map { |controller| controller_entries(controller, routes, ignore: ignore, root: root) }
75
100
  .sort_by { |entry| [entry.controller, entry.path, entry.verb.to_s] }
76
101
  end
77
102
 
78
- def controller_entries(controller, routes)
103
+ # The app's own choice outranks the default: a controller that is both
104
+ # configured and outside the root is reported as configured, so its entry
105
+ # counts as matched (see unmatched_ignores) — the README's own example
106
+ # names ActiveStorage, which the default already covers. The location is
107
+ # looked up once per controller, not once per route.
108
+ def controller_entries(controller, routes, ignore: Permittable.audit_ignore, root: app_root)
79
109
  key = OpenAPI.controller_key(controller) || controller.inspect
80
110
  missing = missing_lookup(controller)
111
+ outside = :outside_root if outside_root?(controller, root)
81
112
  routes.select { |route| route[:controller].to_s == key }.map do |route|
82
113
  action = route[:action].to_s
83
114
  Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
84
115
  rule: rule_for(controller, action), missing_action: missing[action],
85
- route: route[:route])
116
+ route: route[:route], ignored: configured_ignore(key, action, ignore) || outside)
117
+ end
118
+ end
119
+
120
+ # Why `controller` — or one of its actions — is left out of the audit and
121
+ # of permittable:generate: :configured (Permittable.audit_ignore names the
122
+ # controller, or "controller#action"), :outside_root, or nil. The
123
+ # generator asks per action; see Generator.targets.
124
+ def ignore_reason(controller, action = nil, ignore: Permittable.audit_ignore, root: app_root)
125
+ key = OpenAPI.controller_key(controller) || controller.inspect
126
+ configured_ignore(key, action, ignore) || (:outside_root if outside_root?(controller, root))
127
+ end
128
+
129
+ def configured_ignore(key, action, ignore)
130
+ :configured if ignore.include?(key) || (action && ignore.include?("#{key}##{action}"))
131
+ end
132
+
133
+ # The ignore entries no routed action answers to — a controller that is
134
+ # not routed, an action it does not route, or a typo of either. Ignored
135
+ # entries count as matches: the question is whether the entry names
136
+ # something, not whether it was the only reason that something was
137
+ # ignored.
138
+ def unmatched_ignores(entries, ignore: Permittable.audit_ignore)
139
+ ignore.reject do |item|
140
+ key, action = item.split("#", 2)
141
+ entries.any? { |entry| entry.controller == key && (action.nil? || entry.action == action) }
142
+ end
143
+ end
144
+
145
+ # The root a controller's source must be inside to be audited:
146
+ # Rails.root, unless Permittable.audit_ignore_outside_root is off — or nil
147
+ # without Rails, where there is no app to be outside of.
148
+ def app_root
149
+ return nil unless Permittable.audit_ignore_outside_root
150
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:root)
151
+
152
+ ::Rails.root&.to_s
153
+ end
154
+
155
+ # Whether `controller` is an engine's or a gem's rather than the app's:
156
+ # its source is outside `root`, or inside a directory gems are installed
157
+ # in — `bundle config path vendor/bundle`, which CI caches and Docker
158
+ # images commonly use, puts every gem inside the app's root, and
159
+ # ActiveStorage's controller is no more the app's for being there. A gem
160
+ # directory that holds the root itself (a GEM_HOME set to the app, or to
161
+ # a parent of it) says nothing about which side a file is on, so it is
162
+ # not consulted — otherwise every app controller would read as a gem's
163
+ # and [strict] would pass on nothing. A controller that cannot be located
164
+ # is audited: for a gate, a false alarm beats a missed endpoint.
165
+ def outside_root?(controller, root)
166
+ return false if root.nil?
167
+
168
+ path = source_path(controller)
169
+ return false if path.nil?
170
+
171
+ !within?(path, root) || Gem.path.any? { |dir| within?(path, dir) && !within?(root, dir) }
172
+ end
173
+
174
+ # Where `controller` is defined: its constant's own definition (the
175
+ # `class` line — reopening a gem's controller in app/ does not move it),
176
+ # or, for a class whose constant cannot be found, the first of its own
177
+ # methods that has a location. nil when neither says.
178
+ #
179
+ # A constant that is registered for autoload but not loaded reports the
180
+ # `autoload` call instead — never the case here, since only loaded
181
+ # classes have descendants to audit.
182
+ def source_path(controller)
183
+ name = controller.name if controller.respond_to?(:name)
184
+ located = begin
185
+ # [] for a constant defined in C, so `first` is nil there too.
186
+ Object.const_source_location(name)&.first if name.is_a?(String) && !name.empty?
187
+ rescue NameError
188
+ nil
189
+ end
190
+ located || method_source_path(controller)
191
+ end
192
+
193
+ def method_source_path(controller)
194
+ return nil unless controller.is_a?(Module)
195
+
196
+ methods = controller.instance_methods(false) + controller.private_instance_methods(false)
197
+ methods.lazy.filter_map { |name| controller.instance_method(name).source_location&.first }.first
198
+ end
199
+
200
+ # Whether `path` is `dir` or inside it, compared both as written and with
201
+ # symlinks resolved: Rails.root is a realpath, and on macOS a tmpdir under
202
+ # /var is really under /private/var — either side alone can disagree with
203
+ # the other, and an app controller misread as outside the root would drop
204
+ # out of [strict]. A form that cannot be resolved (a path that does not
205
+ # exist) is compared as written only.
206
+ def within?(path, dir)
207
+ [[File.expand_path(path), File.expand_path(dir)], [realpath(path), realpath(dir)]].any? do |file, base|
208
+ file && base && (file == base || file.start_with?(File.join(base, "")))
86
209
  end
87
210
  end
88
211
 
212
+ def realpath(path)
213
+ File.realpath(path)
214
+ rescue SystemCallError
215
+ nil
216
+ end
217
+
89
218
  # action => missing?, answered once per action: a `via: :all` route is
90
- # five entries for one action, and each resolver call builds a controller.
219
+ # up to five entries for one action (fewer when an earlier, unconstrained
220
+ # route already answers some of its verbs on that path, see
221
+ # OpenAPI.drop_shadowed), and each resolver call builds a controller.
91
222
  # The action list is read once per controller, not once per route.
92
223
  def missing_lookup(controller)
93
224
  listed = listed_actions(controller)
@@ -144,20 +275,64 @@ module Permittable
144
275
  # kind is in no summary bucket, so without this it would vanish from the
145
276
  # report's conclusions. Catch-all rules declare no actions, so they never
146
277
  # appear here.
278
+ #
279
+ # Judged per RULE, not per class: permittable_contracts is a
280
+ # class_attribute, so a subclass carries every rule its parent declared,
281
+ # and judging each class alone listed a shared base (routed to nothing)
282
+ # and every subclass routed to a different slice of its actions. A rule's
283
+ # action is stale only when NO controller carrying that rule reaches it —
284
+ # the class that declared it, or any descendant among `controllers:` — and
285
+ # it is listed once, under the declaring class, where the fix is made.
286
+ # Counting only the rules a class added itself would not do: the shared
287
+ # base still has no routes. Shadowing is not judged: an action some
288
+ # carrier reaches is not stale, even if that carrier declares its own rule
289
+ # for it, just as a rule redeclared within one class never was.
147
290
  def stale(controllers:, routes:)
148
291
  routes = routes.to_a
149
- controllers.each_with_object({}) do |controller, found|
292
+ reaches = Hash.new { |memo, controller| memo[controller] = reach_lookup(controller, routes) }
293
+ carriers(controllers).each_with_object({}) do |(rule, held_by), found|
294
+ declarer = declaring_class(held_by.first, rule)
295
+ holders = held_by | [declarer]
296
+ left = rule[:actions].reject { |action| holders.any? { |controller| reaches[controller][action] } }
297
+ next if left.empty?
298
+
299
+ key = OpenAPI.controller_key(declarer) || declarer.inspect
300
+ found[key] = (found.fetch(key, []) + left).uniq
301
+ end
302
+ end
303
+
304
+ # rule => the given controllers carrying it, in first-seen order. Keyed by
305
+ # IDENTITY: permit_params hands a subclass its parent's rule objects, while
306
+ # two classes declaring the same fields build equal but distinct rules.
307
+ def carriers(controllers)
308
+ controllers.each_with_object({}.compare_by_identity) do |controller, held|
150
309
  next unless controller.respond_to?(:permittable_contracts)
151
310
 
152
- key = OpenAPI.controller_key(controller) || controller.inspect
153
- routed = routes.select { |route| route[:controller].to_s == key }.map { |route| route[:action].to_s }
154
- declared = controller.permittable_contracts.flat_map { |rule| rule[:actions] }.uniq
155
- missing = missing_lookup(controller)
156
- left = declared.select { |action| !routed.include?(action) || missing[action] }
157
- found[key] = left unless left.empty?
311
+ controller.permittable_contracts.each { |rule| (held[rule] ||= []) << controller }
158
312
  end
159
313
  end
160
314
 
315
+ # action => whether a request for it reaches `controller`: routed there,
316
+ # and not to an action Rails would 404. Lazy, so the resolver is asked
317
+ # only about actions some contract declares.
318
+ def reach_lookup(controller, routes)
319
+ key = OpenAPI.controller_key(controller) || controller.inspect
320
+ routed = routes.select { |route| route[:controller].to_s == key }.to_set { |route| route[:action].to_s }
321
+ missing = missing_lookup(controller)
322
+ Hash.new { |memo, action| memo[action] = routed.include?(action) && !missing[action] }
323
+ end
324
+
325
+ # The highest ancestor still carrying `rule` — the class whose
326
+ # permit_params declared it, whether or not it was among `controllers:`.
327
+ def declaring_class(controller, rule)
328
+ controller = controller.superclass while controller.respond_to?(:superclass) && carries?(controller.superclass, rule)
329
+ controller
330
+ end
331
+
332
+ def carries?(controller, rule)
333
+ controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any? { |held| held.equal?(rule) }
334
+ end
335
+
161
336
  # The numbers worth putting in a CI log. `uncovered_with_body` is the one
162
337
  # that should be zero; `unguarded_models` counts covered actions whose
163
338
  # rule declares no `model:`, so no schema-drift guard runs for them.
@@ -172,8 +347,11 @@ module Permittable
172
347
  # An action the controller does not define takes no body, and a contract
173
348
  # left on one guards nothing, so it is counted as `missing_actions` and in
174
349
  # no other bucket — the buckets stay disjoint and still add up to `actions`.
350
+ #
351
+ # An ignored entry (Entry#ignored) is in none of the counts, so it never
352
+ # fails [strict]; `format` lists it in a section of its own instead.
175
353
  def summary(entries)
176
- entries = routed(entries)
354
+ entries = routed(entries.reject(&:ignored?))
177
355
  found, missing = entries.partition { |e| !e.missing_action? }
178
356
  {
179
357
  actions: entries.length,
@@ -196,13 +374,19 @@ module Permittable
196
374
  end
197
375
 
198
376
  # The human-readable report: one block per controller, then the summary,
199
- # then anything stale.
200
- def format(entries, stale: {})
377
+ # then anything stale. The table and the summary cover the audited
378
+ # entries; what was ignored follows in sections of its own, then any
379
+ # ignore entry that matched nothing — `unmatched` defaults to checking
380
+ # Permittable.audit_ignore, the list `entries` defaults to.
381
+ def format(entries, stale: {}, unmatched: unmatched_ignores(entries))
201
382
  return "Permittable audit: no routed actions to report.\n" if entries.empty?
202
383
 
384
+ ignored, entries = entries.partition(&:ignored?)
203
385
  out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
204
386
  out << summary_lines(summary(entries), rows: entries.length)
205
387
  out << stale_lines(stale) unless stale.empty?
388
+ out << ignored_lines(ignored) unless ignored.empty?
389
+ out << unmatched_lines(unmatched) unless unmatched.empty?
206
390
  "#{out.join("\n")}\n"
207
391
  end
208
392
 
@@ -242,9 +426,14 @@ module Permittable
242
426
  # say WHY they differ: an optional segment's variants are the usual
243
427
  # reason, but two concatenated route lists sharing a controller, action
244
428
  # and index collapse the same way.
429
+ #
430
+ # Each line agrees with its own count: "1 of those accepts", "1 covered
431
+ # action declares ... for it". `rows` needs no singular — it only prints
432
+ # when it exceeds the route count, so it is at least 2.
245
433
  def summary_lines(counts, rows: counts[:actions])
246
434
  body = counts[:uncovered_with_body]
247
435
  missing = counts[:missing_actions]
436
+ models = counts[:unguarded_models]
248
437
  # Its own bucket, so the head line still adds up to the route count.
249
438
  not_found = ", #{missing} not found (Rails 404s #{missing == 1 ? 'it' : 'them'})" unless missing.zero?
250
439
  listed = " in #{rows} rows" unless rows == counts[:actions]
@@ -253,9 +442,10 @@ module Permittable
253
442
  "#{counts[:actions]} routed action#{'s' unless counts[:actions] == 1}#{listed}: " \
254
443
  "#{counts[:enforced]} enforced, #{counts[:monitored]} in monitor mode, " \
255
444
  "#{counts[:uncovered]} without a contract#{not_found}",
256
- " #{body} of those accept a request body#{' — untrusted input reaches the action unchecked' unless body.zero?}",
257
- " #{counts[:unguarded_models]} covered action#{'s' unless counts[:unguarded_models] == 1} " \
258
- "declare no model:, so no schema-drift guard runs for them"
445
+ " #{body} of those accept#{'s' if body == 1} a request body" \
446
+ "#{' — untrusted input reaches the action unchecked' unless body.zero?}",
447
+ " #{models} covered action#{models == 1 ? ' declares' : 's declare'} no model:, " \
448
+ "so no schema-drift guard runs for #{models == 1 ? 'it' : 'them'}"
259
449
  ].join("\n")
260
450
  end
261
451
 
@@ -264,5 +454,26 @@ module Permittable
264
454
  "\nContracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):\n" \
265
455
  "#{pairs.map { |pair| " #{pair}" }.join("\n")}"
266
456
  end
457
+
458
+ # One section per reason, in IGNORE_REASONS order, and one line per
459
+ # controller#action with every verb it is routed on: a `via: :all`
460
+ # catch-all is one line rather than five rows, so what was left out stays
461
+ # visible without crowding the table it was left out of.
462
+ def ignored_lines(ignored)
463
+ IGNORE_REASONS.filter_map do |reason, heading|
464
+ group = ignored.select { |entry| entry.ignored == reason }
465
+ next if group.empty?
466
+
467
+ verbs = group.group_by { |entry| "#{entry.controller}##{entry.action}" }
468
+ .transform_values { |list| list.map { |entry| entry.verb.to_s.upcase }.uniq.join(", ") }
469
+ width = verbs.keys.map(&:length).max
470
+ "\n#{heading}\n#{verbs.map { |pair, list| " #{pair.ljust(width)} #{list}" }.join("\n")}"
471
+ end.join("\n")
472
+ end
473
+
474
+ def unmatched_lines(unmatched)
475
+ "\nPermittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):\n" \
476
+ "#{unmatched.map { |item| " #{item}" }.join("\n")}"
477
+ end
267
478
  end
268
479
  end
@@ -3,12 +3,11 @@ module Permittable
3
3
  # at class load — the same permittable_check_array a request goes
4
4
  # through, so the two cannot drift apart. Two seams are overridden:
5
5
  #
6
- # * `transform:` is NOT run. It is app code reshaping a value the client
7
- # sent, and a default is stored as the contract reads it, not as the
8
- # app reshapes it — so a contract with transform: hands out its
9
- # default untransformed, exactly as it always has, and nothing of the
10
- # app's runs at class load beyond the `validate:` an authored value was
11
- # already checked with.
6
+ # * the field WHOSE default:/example: is being validated does not run
7
+ # its own `transform:` — see permittable_transform below for exactly
8
+ # which field that is and why. A SUB-FIELD's own transform: still
9
+ # runs, so what gets stored is what an equivalent request would
10
+ # produce.
12
11
  # * violations carry no `message:` — they become an ArgumentError for
13
12
  # the contract's author, not a response for a client, and I18n may not
14
13
  # be loaded yet.
@@ -26,6 +25,7 @@ module Permittable
26
25
  # [value as a request would get it, violations]
27
26
  def read_array(field, value)
28
27
  violations = []
28
+ @field = field
29
29
  read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
30
30
  [read, violations]
31
31
  end
@@ -36,8 +36,24 @@ module Permittable
36
36
 
37
37
  private
38
38
 
39
- def permittable_transform(_field, value)
40
- value
39
+ # Suppresses transform: for the field WHOSE default/example is being
40
+ # authored-validated (@field, compared by identity) — never for a
41
+ # sub-field nested inside it. A sub-field's own transform: is app code
42
+ # too, but it belongs to a DIFFERENT field's contract: an equivalent
43
+ # request sending that sub-field's value would run it, so the stored
44
+ # default has to match, or an omitted field and an explicitly-sent
45
+ # identical value silently diverge (and the exported OpenAPI default,
46
+ # which reads this same value, documents one the server never produces).
47
+ #
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.
53
+ def permittable_transform(field, value)
54
+ return value if @field[:transform] || field.equal?(@field)
55
+
56
+ super
41
57
  end
42
58
 
43
59
  def permittable_violation(_field, param, code)
@@ -297,33 +297,59 @@ module Permittable
297
297
  # nil, until one drafts or none is left: a draft rooted at the other
298
298
  # envelope in the file is a better starting point than no draft, and it
299
299
  # is what master drafted.
300
- def for_controller(controller, source: nil, model: nil)
300
+ #
301
+ # `except:` names actions of DEFAULT_ACTIONS to leave out of the draft —
302
+ # the ones Permittable.audit_ignore names (see targets). With none left
303
+ # there is nothing to draft.
304
+ def for_controller(controller, source: nil, model: nil, except: [])
305
+ return nil if (DEFAULT_ACTIONS - except.map(&:to_sym)).empty?
306
+
301
307
  model ||= infer_model(controller)
302
308
  excluded = []
303
309
  loop do
304
310
  scan = scan(source, model: model, exclude: excluded)
305
- result = draft(model: model, scan: scan)
311
+ result = draft(model: model, scan: scan, except: except)
306
312
  return result if result || !scan.found? || excluded.include?(scan.root)
307
313
 
308
314
  excluded << scan.root
309
315
  end
310
316
  end
311
317
 
318
+ # What `permittable:generate` drafts when no controller is named, as
319
+ # { controller => actions to leave out of its draft }: every controller
320
+ # with no contract yet, less what the audit ignores (Audit.ignore_reason).
321
+ # ActiveStorage's direct uploads controller is the case this exists for —
322
+ # it was drafted, and a contract pasted into a gem's controller is not
323
+ # one the app can keep. A "controller#action" entry takes only that
324
+ # action out of the draft, and a controller with no drafted action left
325
+ # is skipped. Naming a controller to the task drafts it regardless, the
326
+ # way it drafts a covered one.
327
+ def targets(controllers, ignore: Permittable.audit_ignore, root: Audit.app_root)
328
+ controllers.each_with_object({}) do |controller, found|
329
+ next if controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
330
+
331
+ skipped = DEFAULT_ACTIONS.select { |action| Audit.ignore_reason(controller, action, ignore: ignore, root: root) }
332
+ found[controller] = skipped unless skipped == DEFAULT_ACTIONS
333
+ end
334
+ end
335
+
312
336
  # The core: knowledge in (columns and/or a scan), snippet out. Returns a
313
337
  # String of valid Ruby, or nil when neither source of knowledge exists —
314
338
  # or when neither can declare a single field, since a contract of only
315
339
  # TODO lines raises `a contract must declare at least one field` the
316
340
  # moment it is pasted.
317
- def draft(model: nil, scan: nil)
341
+ def draft(model: nil, scan: nil, except: [])
318
342
  columns = columns_for(model)
319
343
  scan = nil unless scan&.found?
320
344
  return nil unless columns || scan
321
- return column_draft(model, columns.values, []) unless scan
345
+ return column_draft(model, columns.values, [], except: except) unless scan
322
346
 
323
347
  shared = scanned_lines(scan, listed_columns(columns, :shared))
324
- return fallback_draft(model, scan, columns) unless declares_field?(shared)
348
+ return fallback_draft(model, scan, columns, except: except) unless declares_field?(shared)
325
349
 
326
- render(root: scan.root, model: columns && model) { |rule| scanned_lines(scan, listed_columns(columns, rule)) }
350
+ render(root: scan.root, model: columns && model, except: except) do |rule|
351
+ scanned_lines(scan, listed_columns(columns, rule))
352
+ end
327
353
  end
328
354
 
329
355
  # The columns as one rule drafts them (see DraftColumn#in_rule), each
@@ -356,7 +382,7 @@ module Permittable
356
382
  # `permit(policy(@post).permitted_attributes)` the scanner skips — says
357
383
  # nothing about the body, so it gets the model's root, as master drafted.
358
384
  # With no columns either there is nothing loadable to draft, so nil.
359
- def fallback_draft(model, scan, columns)
385
+ def fallback_draft(model, scan, columns, except: [])
360
386
  return nil unless columns
361
387
 
362
388
  root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
@@ -365,7 +391,7 @@ module Permittable
365
391
  # As in drop_drafted_todos: a rootless key the columns now declare is
366
392
  # not also a "not in this contract" TODO.
367
393
  scan = scan.dup.tap { |copy| copy.rootless = Array(scan.rootless).reject { |arg| drafted.key?(scalar_key(arg).to_s) } }
368
- column_draft(model, drafted.values, scan_todo_lines(scan), root: root)
394
+ column_draft(model, drafted.values, scan_todo_lines(scan), root: root, except: except)
369
395
  end
370
396
 
371
397
  # Whether a rootless scan's calls carried at least one body field —
@@ -377,8 +403,10 @@ module Permittable
377
403
 
378
404
  # A draft from the columns, or nil when none of them has a contract type
379
405
  # to declare it with (render returns nil for a body of comments only).
380
- def column_draft(model, columns, todos, root: default_root(model))
381
- render(root: root, model: model) { |rule| column_lines(columns.map { |column| column.in_rule(rule) }) + todos }
406
+ def column_draft(model, columns, todos, root: default_root(model), except: [])
407
+ render(root: root, model: model, except: except) do |rule|
408
+ column_lines(columns.map { |column| column.in_rule(rule) }) + todos
409
+ end
382
410
  end
383
411
 
384
412
  def infer_model(controller)
@@ -400,6 +428,15 @@ module Permittable
400
428
  # top (draft_column) must not share this rescue: an error there would
401
429
  # otherwise discard EVERY column — the draft silently falls back to a
402
430
  # scan alone, or to nothing — for what is one column's problem.
431
+ #
432
+ # The rescue is scoped to ActiveRecord::ActiveRecordError, same as
433
+ # ColumnGuard.schema_reachable? and for the same reason: a genuinely
434
+ # unreachable schema (no database yet, table not migrated) degrades to
435
+ # no columns, but a real bug — a broken custom type adapter, a NameError
436
+ # from a typo — must keep surfacing instead of quietly emitting an empty
437
+ # draft. The defined? guard keeps this gem loadable without
438
+ # activerecord, same as schema_reachable? (a host without it duck-types
439
+ # `model:` and cannot raise an ActiveRecordError in the first place).
403
440
  def schema_columns(model)
404
441
  return nil unless model.respond_to?(:columns)
405
442
  return nil unless model.table_exists?
@@ -408,7 +445,9 @@ module Permittable
408
445
  # into its column names; a nil primary key becomes [].
409
446
  skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
410
447
  model.columns.reject { |c| skipped.include?(c.name) }
411
- rescue StandardError
448
+ rescue StandardError => e
449
+ raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
450
+
412
451
  nil
413
452
  end
414
453
 
@@ -507,7 +546,11 @@ module Permittable
507
546
  # can be CALLED as `Model.name`: a `first-status` enum's
508
547
  # `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
509
548
  # which runs a query when the draft loads. defined_enums reaches the
510
- # same mapping by name.
549
+ # same mapping by name. The model is also checked for the accessor
550
+ # itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
551
+ # valid identifier that the model does not actually answer to — renamed
552
+ # or otherwise excluded — and calling it would raise NoMethodError when
553
+ # the draft loads.
511
554
  def enum_for(model, name)
512
555
  return nil unless model.respond_to?(:defined_enums)
513
556
 
@@ -515,7 +558,11 @@ module Permittable
515
558
  return nil unless mapping
516
559
 
517
560
  plural = name.pluralize
518
- accessor = METHOD_NAME.match?(plural) ? "#{model.name}.#{plural}" : "#{model.name}.defined_enums[#{name.inspect}]"
561
+ accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
562
+ "#{model.name}.#{plural}"
563
+ else
564
+ "#{model.name}.defined_enums[#{name.inspect}]"
565
+ end
519
566
  { mapping: mapping, accessor: accessor }
520
567
  end
521
568
 
@@ -986,12 +1033,20 @@ module Permittable
986
1033
  # no field, which raises `a contract must declare at least one field`
987
1034
  # when pasted. There is then nothing loadable to draft: nil, as for no
988
1035
  # knowledge at all, which the rake task already skips.
989
- def render(root:, model:, &lines)
1036
+ #
1037
+ # An action in `except` is dropped from the rule that declares it, and a
1038
+ # rule left with no action is dropped: excepting :create from a split
1039
+ # draft leaves the :update rule as drafted, with nothing required.
1040
+ def render(root:, model:, except: [], &lines)
990
1041
  shared = lines.call(:shared)
991
1042
  return nil if shared.all? { |line| line.start_with?("#") }
992
1043
 
993
1044
  update = lines.call(:update)
994
1045
  rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
1046
+ skipped = except.map(&:to_sym)
1047
+ rules = rules.filter_map { |actions, body| [actions - skipped, body] unless (actions - skipped).empty? }
1048
+ return nil if rules.empty?
1049
+
995
1050
  bodies = rules.map do |actions, body|
996
1051
  "#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
997
1052
  end
@@ -114,8 +114,16 @@ module Permittable
114
114
  schema
115
115
  end
116
116
 
117
+ # deep_dup, not dup: .freeze is shallow, so the Array nested in an entry
118
+ # like :decimal ("type" => %w[string number]) stays live inside the frozen
119
+ # top-level Hash, and a shallow .dup would hand every :decimal field's
120
+ # exported schema that SAME Array. Nothing in this file mutates it in
121
+ # place (nullify! rebinds "type" to a new Array), but the exported
122
+ # document is caller-owned data, and a caller appending to it would
123
+ # otherwise silently rewrite the constant for every schema exported
124
+ # afterward in the process.
117
125
  def scalar_schema(field)
118
- schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
126
+ schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
119
127
  apply_format_name!(schema, field)
120
128
  apply_in!(schema, field)
121
129
  apply_string_bounds!(schema, field)
@@ -151,7 +159,11 @@ module Permittable
151
159
  min, max = length_bounds(field[:length])
152
160
  schema["minItems"] = min if min
153
161
  schema["maxItems"] = max if max
154
- schema["items"] = field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).dup
162
+ # deep_dup here for the same reason as scalar_schema: a scalar `of:`
163
+ # otherwise hands every array field the SAME nested Array/Hash from
164
+ # SCALAR_SCHEMAS.
165
+ schema["items"] =
166
+ field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup
155
167
  schema
156
168
  end
157
169
 
@@ -343,10 +355,22 @@ module Permittable
343
355
  # (silently changing a value outside any :decimal schema's justification)
344
356
  # or coerced through Float, where a non-finite BigDecimal (Infinity, NaN)
345
357
  # would crash JSON.generate.
358
+ #
359
+ # A `sensitive:` field's `default:`/`example:` are OMITTED rather than
360
+ # published: this exported document is the one channel meant to leave
361
+ # the app (client-generator tooling, a public docs endpoint), unlike the
362
+ # request log a `sensitive:` value is otherwise only redacted from, so
363
+ # shipping the real value here would defeat the redaction entirely.
364
+ # Omitting — rather than a placeholder string — matches how every other
365
+ # untranslatable or opaque fact in this file is handled: left out, with
366
+ # the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
367
+ # sensitive`) as the only signal that something is missing.
346
368
  def annotate(schema, field)
347
369
  decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
348
- schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
349
- schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
370
+ unless field[:sensitive]
371
+ schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
372
+ schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
373
+ end
350
374
  schema["description"] = field[:desc] if field[:desc]
351
375
  if field[:sensitive]
352
376
  schema["writeOnly"] = true