permittable 0.8.0 → 0.10.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.
@@ -0,0 +1,268 @@
1
+ require "permittable/open_api"
2
+
3
+ module Permittable
4
+ # Contract COVERAGE — the fourth reader of the registry, and the one that
5
+ # answers a question none of the others can: what is *not* covered?
6
+ #
7
+ # A controller declaring `permit_params :create` looks adopted. If it also
8
+ # answers PATCH, that action is validating nothing, and nothing in the gem
9
+ # said so — the generator only notices controllers with no contract at all,
10
+ # and the OpenAPI exporter documents what exists rather than what is
11
+ # missing. The audit crosses the registry with the ROUTE SET, so a
12
+ # half-covered controller is as visible as an uncovered one.
13
+ #
14
+ # bin/rails permittable:audit # the table, plus a summary
15
+ # bin/rails permittable:audit[strict] # ...and exit 1 if a write action
16
+ # # is unguarded (a CI gate)
17
+ #
18
+ # Plain Ruby over the frozen registry and a list of route descriptors (the
19
+ # same `{ controller:, action:, verb:, path: }` shape OpenAPI.rails_routes
20
+ # produces, plus the optional `route:` it tags each one with), so it is
21
+ # unit-testable without Rails. Unlike the exporter it
22
+ # reports the EFFECTIVE mode: an audit runs inside the app that sets
23
+ # `Permittable.mode`, so it can resolve what the exporter deliberately
24
+ # cannot.
25
+ module Audit
26
+ module_function
27
+
28
+ # Verbs that carry a request body — the ones where "no contract" means
29
+ # untrusted input reaching the action unchecked. A GET without a contract
30
+ # is usually fine; a POST without one is the finding.
31
+ BODY_VERBS = %w[post put patch].freeze
32
+
33
+ # One routed action, paired with the rule a request for it would resolve
34
+ # through (nil when nothing covers it — including when the controller
35
+ # never included the concern, which is exactly the case worth finding).
36
+ #
37
+ # `missing_action` marks a route to an action the controller does not
38
+ # define — see Audit.missing_action?. `route` identifies the route the
39
+ # 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
42
+ def covered?
43
+ !rule.nil?
44
+ end
45
+
46
+ def missing_action?
47
+ missing_action == true
48
+ end
49
+
50
+ # The mode this action would actually run in, rule-level declaration
51
+ # first and the app-wide default behind it.
52
+ def mode
53
+ rule && (rule[:mode] || Permittable.mode)
54
+ end
55
+
56
+ def model
57
+ rule && rule[:model]
58
+ end
59
+
60
+ def unknown
61
+ rule && rule[:unknown]
62
+ end
63
+
64
+ def body?
65
+ BODY_VERBS.include?(verb.to_s.downcase)
66
+ end
67
+ end
68
+
69
+ # Every routed action of every given controller, sorted for stable output.
70
+ # Pass ALL controllers, not just the ones including Permittable — a
71
+ # controller that never included it is unguarded, which is the point.
72
+ def entries(controllers:, routes:)
73
+ routes = routes.to_a
74
+ controllers.flat_map { |controller| controller_entries(controller, routes) }
75
+ .sort_by { |entry| [entry.controller, entry.path, entry.verb.to_s] }
76
+ end
77
+
78
+ def controller_entries(controller, routes)
79
+ key = OpenAPI.controller_key(controller) || controller.inspect
80
+ missing = missing_lookup(controller)
81
+ routes.select { |route| route[:controller].to_s == key }.map do |route|
82
+ action = route[:action].to_s
83
+ Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
84
+ rule: rule_for(controller, action), missing_action: missing[action],
85
+ route: route[:route])
86
+ end
87
+ end
88
+
89
+ # action => missing?, answered once per action: a `via: :all` route is
90
+ # five entries for one action, and each resolver call builds a controller.
91
+ # The action list is read once per controller, not once per route.
92
+ def missing_lookup(controller)
93
+ listed = listed_actions(controller)
94
+ Hash.new { |memo, action| memo[action] = missing_action?(controller, action, listed) }
95
+ end
96
+
97
+ # Normalised like OpenAPI.documented_actions: a duck listing Symbols would
98
+ # otherwise be missing every action, and strict would pass everything.
99
+ # nil when the controller cannot list its actions.
100
+ def listed_actions(controller)
101
+ controller.action_methods.to_set(&:to_s) if controller.respond_to?(:action_methods)
102
+ end
103
+
104
+ # `resources :posts` routes all seven actions whether or not the
105
+ # controller defines them, and Rails 404s the ones it does not — so an
106
+ # undefined `create` is no unguarded input, and failing a strict run over
107
+ # it was a false positive. Labelled rather than dropped, so the reader
108
+ # still sees the route. A controller that cannot list its actions is
109
+ # assumed to have them all.
110
+ def missing_action?(controller, action, listed = listed_actions(controller))
111
+ return false if listed.nil? || listed.include?(action)
112
+
113
+ !dispatchable?(controller, action)
114
+ end
115
+
116
+ # action_methods is not all Rails dispatches: an `action_missing` handler
117
+ # takes every unlisted action, and ImplicitRender renders a template with
118
+ # no method behind it — both with the body parsed, so both are input.
119
+ # Rather than re-derive that, ask Rails's own resolver: method_for_action
120
+ # is nil exactly when dispatch would raise ActionNotFound (the same
121
+ # private method, unchanged from 6.1 to 8.1). It needs no request. A
122
+ # plain-Ruby duck has no resolver, so its action list is the answer; a
123
+ # resolver that raises is inconclusive, so assume the action exists: for
124
+ # a gate, a false alarm beats a missed endpoint.
125
+ def dispatchable?(controller, action)
126
+ return false unless controller.is_a?(Class) &&
127
+ (controller.method_defined?(:method_for_action) ||
128
+ controller.private_method_defined?(:method_for_action))
129
+
130
+ begin
131
+ !controller.new.send(:method_for_action, action).nil?
132
+ rescue StandardError
133
+ true
134
+ end
135
+ end
136
+
137
+ def rule_for(controller, action)
138
+ controller.respond_to?(:permit_rule_for) ? controller.permit_rule_for(action) : nil
139
+ end
140
+
141
+ # { controller => [action, ...] } for contracts declared against actions
142
+ # no route reaches, or that a route reaches but Rails would 404 — either
143
+ # way a renamed or deleted action leaving its contract behind. The second
144
+ # kind is in no summary bucket, so without this it would vanish from the
145
+ # report's conclusions. Catch-all rules declare no actions, so they never
146
+ # appear here.
147
+ def stale(controllers:, routes:)
148
+ routes = routes.to_a
149
+ controllers.each_with_object({}) do |controller, found|
150
+ next unless controller.respond_to?(:permittable_contracts)
151
+
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?
158
+ end
159
+ end
160
+
161
+ # The numbers worth putting in a CI log. `uncovered_with_body` is the one
162
+ # that should be zero; `unguarded_models` counts covered actions whose
163
+ # rule declares no `model:`, so no schema-drift guard runs for them.
164
+ #
165
+ # Counted per route and verb rather than per row: `scope "(:locale)"`
166
+ # expands one route into two paths, both listed in the table, and one
167
+ # unguarded POST must not count as two. Only a route's OWN variants
168
+ # collapse — `post "/users"` and `post "/admin/users"` are two ways into
169
+ # users#create, two gaps if neither is covered, and `[strict]` must see
170
+ # both.
171
+ #
172
+ # An action the controller does not define takes no body, and a contract
173
+ # left on one guards nothing, so it is counted as `missing_actions` and in
174
+ # no other bucket — the buckets stay disjoint and still add up to `actions`.
175
+ def summary(entries)
176
+ entries = routed(entries)
177
+ found, missing = entries.partition { |e| !e.missing_action? }
178
+ {
179
+ actions: entries.length,
180
+ enforced: found.count { |e| e.mode == :enforce },
181
+ monitored: found.count { |e| e.mode == :monitor },
182
+ uncovered: found.count { |e| !e.covered? },
183
+ uncovered_with_body: found.count { |e| !e.covered? && e.body? },
184
+ unguarded_models: found.count { |e| e.covered? && e.model.nil? },
185
+ missing_actions: missing.length
186
+ }
187
+ end
188
+
189
+ # One entry per route and verb, in the entries' own order. `route` is a
190
+ # position within ONE rails_routes call, so it is keyed with the
191
+ # controller and action: an app's list concatenated with an engine's
192
+ # reuses the same small integers for unrelated routes. An entry with no
193
+ # `route` (a hand-built descriptor) is its own route.
194
+ def routed(entries)
195
+ entries.uniq { |e| e.route.nil? ? e.object_id : [e.controller, e.action, e.route, e.verb.to_s.downcase] }
196
+ end
197
+
198
+ # The human-readable report: one block per controller, then the summary,
199
+ # then anything stale.
200
+ def format(entries, stale: {})
201
+ return "Permittable audit: no routed actions to report.\n" if entries.empty?
202
+
203
+ out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
204
+ out << summary_lines(summary(entries), rows: entries.length)
205
+ out << stale_lines(stale) unless stale.empty?
206
+ "#{out.join("\n")}\n"
207
+ end
208
+
209
+ def controller_block(key, group)
210
+ rows = group.map do |entry|
211
+ " #{entry.verb.to_s.upcase.ljust(6)} #{entry.path.ljust(34)} #{entry.action.ljust(12)} #{status(entry)}"
212
+ end
213
+ "#{key}\n#{rows.join("\n")}"
214
+ end
215
+
216
+ # A covered action reads as its effective mode; an uncovered one says so,
217
+ # and says whether that matters (a body-carrying verb with no contract is
218
+ # unvalidated input, not just a gap in the table). A route to an action
219
+ # the controller does not define says that instead of claiming a body.
220
+ def status(entry)
221
+ return ["no contract", uncovered_note(entry)].compact.join(" — ") unless entry.covered?
222
+
223
+ parts = [entry.mode.to_s]
224
+ parts << "model: #{entry.model.name}" if entry.model.respond_to?(:name) && entry.model.name
225
+ parts << "unknown: #{entry.unknown}" unless entry.unknown == :ignore
226
+ parts << not_found_note(entry)
227
+ parts.compact.join(" ")
228
+ end
229
+
230
+ def uncovered_note(entry)
231
+ not_found_note(entry) || ("ACCEPTS A BODY" if entry.body?)
232
+ end
233
+
234
+ # The one wording for a route Rails would 404, covered or not.
235
+ def not_found_note(entry)
236
+ "action not found" if entry.missing_action?
237
+ end
238
+
239
+ # The table lists rows (a verb on a path) and the summary counts routes;
240
+ # when the two numbers differ, the line gives both and names each. Rows,
241
+ # not distinct paths: PATCH and PUT on one path are two rows. It does not
242
+ # say WHY they differ: an optional segment's variants are the usual
243
+ # reason, but two concatenated route lists sharing a controller, action
244
+ # and index collapse the same way.
245
+ def summary_lines(counts, rows: counts[:actions])
246
+ body = counts[:uncovered_with_body]
247
+ missing = counts[:missing_actions]
248
+ # Its own bucket, so the head line still adds up to the route count.
249
+ not_found = ", #{missing} not found (Rails 404s #{missing == 1 ? 'it' : 'them'})" unless missing.zero?
250
+ listed = " in #{rows} rows" unless rows == counts[:actions]
251
+ [
252
+ "",
253
+ "#{counts[:actions]} routed action#{'s' unless counts[:actions] == 1}#{listed}: " \
254
+ "#{counts[:enforced]} enforced, #{counts[:monitored]} in monitor mode, " \
255
+ "#{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"
259
+ ].join("\n")
260
+ end
261
+
262
+ def stale_lines(stale)
263
+ pairs = stale.flat_map { |key, actions| actions.map { |action| "#{key}##{action}" } }
264
+ "\nContracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):\n" \
265
+ "#{pairs.map { |pair| " #{pair}" }.join("\n")}"
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,63 @@
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:
5
+ #
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.
11
+ # * violations carry no `message:` — they become an ArgumentError for
12
+ # the contract's author, not a response for a client, and I18n may not
13
+ # be loaded yet.
14
+ class AuthoredValues
15
+ include Permittable
16
+
17
+ def self.read_array(field, value)
18
+ new.read_array(field, value)
19
+ end
20
+
21
+ def self.summary(violations)
22
+ new.summary(violations)
23
+ end
24
+
25
+ # [value as a request would get it, violations]
26
+ def read_array(field, value)
27
+ violations = []
28
+ @field = field
29
+ read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
30
+ [read, violations]
31
+ end
32
+
33
+ def summary(violations)
34
+ permittable_violation_summary(violations)
35
+ end
36
+
37
+ private
38
+
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
57
+ end
58
+
59
+ def permittable_violation(_field, param, code)
60
+ { param: param, code: code.to_s }
61
+ end
62
+ end
63
+ end
@@ -10,24 +10,151 @@ module Permittable
10
10
  # (NameError from a typo etc.) keep surfacing. Skipping is self-healing:
11
11
  # once the migration runs and classes reload, validation happens for real.
12
12
  module ColumnGuard
13
+ # Column types that mean the same thing for a contract's purposes, grouped
14
+ # so the check can catch a column RETYPED out from under a contract
15
+ # without second-guessing a declaration that merely differs in flavour.
16
+ #
17
+ # The numeric group deliberately includes :boolean — a boolean stored as
18
+ # an integer 0/1 is a real legacy pattern, and ActiveRecord casts cleanly
19
+ # between all of them. The temporal group is one group for the same
20
+ # reason: a :date contract on a datetime column is a narrowing, not drift.
21
+ #
22
+ # Anything absent here — :json, :jsonb, :binary, an adapter's own :inet or
23
+ # :money — is NOT checked. A contract has no faithful type for those, so
24
+ # whatever an app improvised is left alone rather than guessed about.
25
+ TYPE_GROUPS = {
26
+ string: :text, text: :text, citext: :text, uuid: :text, enum: :text, char: :text,
27
+ integer: :numeric, bigint: :numeric, float: :numeric, decimal: :numeric, boolean: :numeric,
28
+ date: :temporal, datetime: :temporal, time: :temporal, timestamp: :temporal, timestamptz: :temporal
29
+ }.freeze
30
+
13
31
  module_function
14
32
 
15
33
  # `types:` teaches the error message: a Symbol/String applies to every
16
34
  # listed field, a Hash maps field => type. The raised ArgumentError then
17
- # appends a ready-to-paste migration command.
18
- def ensure_columns_on!(label, klass, *fields, types: nil)
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)
19
38
  return false unless schema_reachable?(klass)
20
39
 
21
40
  fields.flatten.compact.each do |field|
22
- next if klass.column_names.include?(field.to_s)
41
+ unless klass.column_names.include?(field.to_s)
42
+ raise ArgumentError,
43
+ "#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
44
+ "#{column_migration_hint(klass, field, types)}"
45
+ end
23
46
 
24
- raise ArgumentError,
25
- "#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
26
- "#{column_migration_hint(klass, field, types)}"
47
+ ensure_column_type!(label, klass, field, types, allowed) if check_types
27
48
  end
28
49
  true
29
50
  end
30
51
 
52
+ # The type half of the drift guard, opt-in via Permittable
53
+ # .check_column_types. It compares GROUPS rather than exact types (see
54
+ # TYPE_GROUPS) and stays silent unless both sides are known, so it can
55
+ # only fire on a genuine cross-family mismatch — a contract still saying
56
+ # :datetime after the column became a string, say.
57
+ def ensure_column_type!(label, klass, field, types, allowed = nil)
58
+ declared = types.is_a?(Hash) ? types[field.to_sym] : types
59
+ column = klass.columns_hash[field.to_s]
60
+ return unless declared && column
61
+
62
+ wanted = TYPE_GROUPS[declared.to_sym]
63
+ enum = enum_attribute?(klass, field)
64
+ # Checked before the column's type, which an enum's contract does not
65
+ # 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
+
68
+ # `column.type` is nil for a SQL type the adapter does not recognise
69
+ # (a PostGIS geometry column without the extension loaded, a custom
70
+ # domain type). That is exactly the "no faithful contract type" case
71
+ # this guard documents staying silent for — not a reason to raise
72
+ # NoMethodError out of a controller's class body.
73
+ return unless column.type
74
+
75
+ actual = TYPE_GROUPS[column.type.to_sym]
76
+ return if wanted.nil? || actual.nil? || wanted == actual
77
+
78
+ # On an enum, `virtual: true` would be the wrong advice: it switches
79
+ # off the existence check too, and the field IS backed by this column.
80
+ fix = if enum
81
+ "'#{field}' is an enum on #{model_expr(klass)}: declare it :string, in: #{enum_keys_expr(klass, field)}."
82
+ else
83
+ "Change the contract to match the column, migrate the column to match the contract, " \
84
+ "or declare the field virtual: true if it is not backed by this column."
85
+ end
86
+ raise ArgumentError,
87
+ "#{label}: '#{field}' is declared :#{declared} but the column is :#{column.type} " \
88
+ "(table: #{klass.table_name}). #{fix}"
89
+ end
90
+
91
+ # `model:` is only duck-typed on column_names, hence the respond_to?.
92
+ def enum_attribute?(klass, field)
93
+ klass.respond_to?(:defined_enums) && klass.defined_enums.key?(field.to_s)
94
+ end
95
+
96
+ # A Rails enum is submitted by its NAME — `status: "shipped"` — whatever
97
+ # the column stores, so a text declaration is the right contract for any
98
+ # enum, integer-backed included, and is not held to the column's group.
99
+ # The price of that exemption is an `in:` naming what the enum accepts:
100
+ # assignment raises ArgumentError for anything else, so without one
101
+ # `status: "bogus"` passes the contract and becomes a 500 in the action.
102
+ # A string-backed enum is held to the same rule even though its column
103
+ # group already matched — the 500 is identical. Any other declaration on
104
+ # an enum is still held to the column's own group (`:integer` on an
105
+ # integer-backed enum passes, `:datetime` does not).
106
+ #
107
+ # Accepted means what the enum's cast accepts: every name, plus every
108
+ # stored value that is a String (a string-backed enum takes `"p"` for
109
+ # `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.
113
+ #
114
+ # Only `enum` is recognised. The attribute API (`attribute :x, :datetime`
115
+ # over a string column) is left compared against the column by choice:
116
+ # an enum's mapping says exactly which strings are valid, an attribute
117
+ # override says nothing a contract could be checked against.
118
+ def ensure_enum_contract!(label, klass, field, declared, listed)
119
+ problem =
120
+ if listed.nil?
121
+ "declared :#{declared} without an in:"
122
+ elsif listed.is_a?(Range) || !listed.respond_to?(:to_a)
123
+ "declared :#{declared} with an in: that does not list its values"
124
+ else
125
+ stray = listed.to_a - enum_values(klass, field)
126
+ return if stray.empty?
127
+
128
+ "declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}"
129
+ end
130
+
131
+ raise ArgumentError,
132
+ "#{label}: '#{field}' is an enum on #{model_expr(klass)}, #{problem} (table: #{klass.table_name}). " \
133
+ "A value outside the enum would pass the contract and then raise on assignment. " \
134
+ "Declare it with in: #{enum_keys_expr(klass, field)}."
135
+ end
136
+
137
+ def enum_values(klass, field)
138
+ mapping = klass.defined_enums[field.to_s]
139
+ mapping.keys.map(&:to_s) + mapping.values.grep(String)
140
+ end
141
+
142
+ # `Order.statuses.keys` when the enum's plural reader exists, and the
143
+ # always-valid `defined_enums["..."]` spelling when it does not (a name
144
+ # that is not a Ruby identifier, a duck-typed model).
145
+ def enum_keys_expr(klass, field)
146
+ reader = field.to_s.pluralize
147
+ if reader.match?(/\A[a-z_][a-zA-Z0-9_]*\z/) && klass.respond_to?(reader)
148
+ "#{model_expr(klass)}.#{reader}.keys"
149
+ else
150
+ "#{model_expr(klass)}.defined_enums[#{field.to_s.inspect}].keys"
151
+ end
152
+ end
153
+
154
+ def model_expr(klass)
155
+ klass.name || "Model"
156
+ end
157
+
31
158
  def column_migration_hint(klass, field, types)
32
159
  type = types.is_a?(Hash) ? types[field.to_sym] : types
33
160
  column = [field, type].compact.join(":")
@@ -20,8 +20,9 @@ module Permittable
20
20
  # * A Contract always ENFORCES. Monitor mode is a request-rollout switch;
21
21
  # standalone callers read the Result instead, so the app-wide
22
22
  # `Permittable.mode` is ignored here.
23
- # * The router's bookkeeping keys (controller/action/format) get no
24
- # exemption from `unknown:` checking — standalone input has no router.
23
+ # * The router's bookkeeping keys (controller/action/format), path
24
+ # parameters and ParamsWrapper's key get no exemption from `unknown:`
25
+ # checking — standalone input has no router and no request.
25
26
  # * No memoization: every #call validates fresh, so one frozen Contract
26
27
  # is safely reusable and shareable.
27
28
  #
@@ -76,6 +77,14 @@ module Permittable
76
77
  @host_class.permittable_contracts.last
77
78
  end
78
79
 
80
+ # The contract's fields, which is also what makes a Contract usable as a
81
+ # field group: `use SomeContract` inside a permit_params block splices
82
+ # them in, so a webhook payload and a controller action can share one
83
+ # definition instead of two that drift.
84
+ def fields
85
+ rule[:fields]
86
+ end
87
+
79
88
  # RSpec-matcher parity with controllers: `expect(MyContract).to
80
89
  # permit_param(:email)` reads the registry through these. A standalone
81
90
  # contract covers every "action", so the argument is irrelevant.
@@ -1,12 +1,49 @@
1
1
  module Permittable
2
- # One home for the error envelope: prefer the host's #render_error when the
3
- # controller defines one (e.g. concerns_on_rails' Respondable), otherwise
4
- # render the identical inline shape — and the single place to change when
5
- # e.g. an RFC 9457 problem+json mode lands.
2
+ # One home for the error response, in either of two shapes.
3
+ #
4
+ # `:envelope` (the default) is the gem's original shape: the host's
5
+ # #render_error when the controller defines one (e.g. concerns_on_rails'
6
+ # Respondable), otherwise the identical inline JSON.
7
+ #
8
+ # `:problem` renders RFC 9457 Problem Details — `application/problem+json`
9
+ # with type/title/status/detail/instance members and the field violations as
10
+ # an `errors` extension. Set it app-wide, because the error format of an API
11
+ # is a property of the API rather than of any one contract:
12
+ #
13
+ # # config/initializers/permittable.rb
14
+ # Permittable.error_format = :problem
15
+ # Permittable.problem_base_uri = "https://api.example.com/problems"
16
+ #
17
+ # Choosing `:problem` deliberately opts OUT of #render_error delegation: a
18
+ # host envelope and a problem document are two answers to the same question,
19
+ # and the explicit setting is the one to honour.
6
20
  module ErrorEnvelope
7
21
  module_function
8
22
 
23
+ PROBLEM_MEDIA_TYPE = "application/problem+json".freeze
24
+
25
+ # The two statuses this gem raises, plus the Rails 7.2+ spelling of 422.
26
+ # Kept as a literal so a problem document can carry a numeric status
27
+ # without activesupport-only hosts needing Rack; anything else defers to
28
+ # Rack::Utils when the host has it.
29
+ STATUS_CODES = { bad_request: 400, unprocessable_entity: 422, unprocessable_content: 422 }.freeze
30
+
31
+ # A short human-readable summary of the problem TYPE (RFC 9457 §3.1.2), so
32
+ # it describes the kind of failure, not this instance of it: a missing
33
+ # `root:` means the request envelope itself is wrong, while a field
34
+ # violation means a well-formed request said something invalid.
35
+ PROBLEM_TYPES = {
36
+ 400 => ["malformed-request", "Malformed request"].freeze,
37
+ 422 => ["invalid-parameters", "Invalid parameters"].freeze
38
+ }.freeze
39
+
9
40
  def render(controller, message:, status:, code: nil, details: nil)
41
+ return render_problem(controller, message: message, status: status, details: details) if Permittable.error_format == :problem
42
+
43
+ render_envelope(controller, message: message, status: status, code: code, details: details)
44
+ end
45
+
46
+ def render_envelope(controller, message:, status:, code: nil, details: nil)
10
47
  if controller.respond_to?(:render_error)
11
48
  # errors: only when there are details — a host may document its
12
49
  # render_error contract as `(message:, status:, code:)`, and an
@@ -21,5 +58,43 @@ module Permittable
21
58
  controller.render(json: { success: false, error: error }, status: status)
22
59
  end
23
60
  end
61
+
62
+ # Members are emitted in the order RFC 9457 documents them, so the wire
63
+ # format is stable and readable. `instance` is omitted rather than guessed
64
+ # when the host cannot name the request path (a params duck, a job).
65
+ def render_problem(controller, message:, status:, details: nil)
66
+ numeric = status_code(status)
67
+ slug, title = PROBLEM_TYPES.fetch(numeric, ["invalid-parameters", "Invalid parameters"])
68
+ problem = { type: problem_type(slug), title: title }
69
+ problem[:status] = numeric if numeric
70
+ problem[:detail] = message
71
+ instance = request_path(controller)
72
+ problem[:instance] = instance if instance
73
+ problem[:errors] = details if details && !details.empty?
74
+ controller.render(json: problem, status: status, content_type: PROBLEM_MEDIA_TYPE)
75
+ end
76
+
77
+ # RFC 9457: an absent `type` means "about:blank", so that is the honest
78
+ # default until an app publishes documents to point at.
79
+ def problem_type(slug)
80
+ base = Permittable.problem_base_uri
81
+ return "about:blank" unless base
82
+
83
+ "#{base.to_s.chomp('/')}/#{slug}"
84
+ end
85
+
86
+ def status_code(status)
87
+ return status if status.is_a?(Integer)
88
+
89
+ symbol = status.to_sym
90
+ STATUS_CODES[symbol] ||
91
+ (defined?(Rack::Utils) && Rack::Utils::SYMBOL_TO_STATUS_CODE[symbol])
92
+ end
93
+
94
+ def request_path(controller)
95
+ return nil unless controller.respond_to?(:request) && controller.request.respond_to?(:path)
96
+
97
+ controller.request.path
98
+ end
24
99
  end
25
100
  end