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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +361 -33
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +63 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +822 -51
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +252 -47
- data/lib/permittable/open_api.rb +247 -42
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +985 -116
- metadata +9 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(":")
|
data/lib/permittable/contract.rb
CHANGED
|
@@ -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)
|
|
24
|
-
#
|
|
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
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|