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
|
@@ -3,8 +3,9 @@ require "ripper"
|
|
|
3
3
|
module Permittable
|
|
4
4
|
# Drafts a permit_params contract from what the app already knows: the
|
|
5
5
|
# model's columns (types, NOT NULL, database defaults) and, when the
|
|
6
|
-
# controller source is available, the
|
|
7
|
-
#
|
|
6
|
+
# controller source is available, the params calls already in it — both
|
|
7
|
+
# spellings, `params.require(:user).permit(:name, tags: [])` and Rails 8's
|
|
8
|
+
# `params.expect(user: [:name, tags: []])`. The draft is a
|
|
8
9
|
# STARTING POINT, not an oracle — everything the generator cannot know for
|
|
9
10
|
# sure is marked with a TODO comment instead of guessed, and the whole
|
|
10
11
|
# contract is emitted in monitor mode so pasting it changes nothing until
|
|
@@ -21,6 +22,27 @@ module Permittable
|
|
|
21
22
|
DEFAULT_ACTIONS = %i[create update].freeze
|
|
22
23
|
SKIPPED_COLUMNS = %w[created_at updated_at].freeze
|
|
23
24
|
|
|
25
|
+
# One column as the drafting code sees it: the database facts, plus what
|
|
26
|
+
# the MODEL layers on top — an enum accessor that changes what a client
|
|
27
|
+
# sends (`enum_integers` when it stores Integers), a default the model
|
|
28
|
+
# fills in, or a `sensitive` role (:sti, :locking) that makes the column
|
|
29
|
+
# dangerous to leave client-writable. `default` is the comment describing
|
|
30
|
+
# whichever default applies, and `unread` the error that stopped the
|
|
31
|
+
# model's enum and default from being read.
|
|
32
|
+
# Building these once in columns_for keeps every drafting path (columns
|
|
33
|
+
# alone, or a scan typed from columns) reading the same answers.
|
|
34
|
+
#
|
|
35
|
+
# `rule` and `listed` are how column_line is told what it is drafting
|
|
36
|
+
# for: the rule (see render) and whether the controller's permit call
|
|
37
|
+
# lists the column. They ride on the column, set by in_rule, so the scan
|
|
38
|
+
# path hands them to column_line without its own methods changing.
|
|
39
|
+
DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
|
|
40
|
+
:unread, :rule, :listed, keyword_init: true) do
|
|
41
|
+
def in_rule(rule, listed: false)
|
|
42
|
+
self.class.new(**to_h, rule: rule, listed: listed)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
24
46
|
# Column type => contract type. Document-shaped columns map onto the
|
|
25
47
|
# opaque `:json` field — the shape stays undeclared, which is what a
|
|
26
48
|
# jsonb column is for, and `max_depth:`/`length:` can bound it later.
|
|
@@ -34,14 +56,34 @@ module Permittable
|
|
|
34
56
|
json: :json, jsonb: :json, hstore: :json
|
|
35
57
|
}.freeze
|
|
36
58
|
|
|
37
|
-
# What a source scan recovered from
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
59
|
+
# What a source scan recovered from the `params.permit` and Rails 8
|
|
60
|
+
# `params.expect` calls already in a controller. `scalars` are plain
|
|
61
|
+
# `:key` arguments, `arrays` are `key: []`, `nested` maps `key: [:a, :b]`
|
|
62
|
+
# onto its sub-keys, `nested_arrays` maps the `key: [[:a, :b]]` an
|
|
63
|
+
# `expect` call spells an array of hashes with, and `unparsed` keeps
|
|
64
|
+
# verbatim anything the conservative parser would otherwise have silently
|
|
65
|
+
# dropped. `conflicts` names each key permitted in more than one shape
|
|
66
|
+
# (see resolve_conflicts), and `undecided` lists the keys among them
|
|
67
|
+
# drafted as no shape at all. What parsed fine but belongs outside the
|
|
68
|
+
# chosen root's contract is kept apart from `unparsed`, so its TODO can
|
|
69
|
+
# say why: `route_params` holds what is never a body field (a single-key
|
|
70
|
+
# expect lookup, and what an expect call spells beside its envelope);
|
|
71
|
+
# `rootless` holds the keys of the rootless calls once an envelope won —
|
|
72
|
+
# a filter or a body, the scan cannot tell; `other_envelopes` maps each
|
|
73
|
+
# losing root onto its calls, spelled as the source spells them. `calls`
|
|
74
|
+
# counts the params calls found.
|
|
75
|
+
Scan = Struct.new(:root, :scalars, :arrays, :nested, :nested_arrays, :unparsed, :conflicts, :undecided,
|
|
76
|
+
:route_params, :rootless, :other_envelopes, :calls, keyword_init: true) do
|
|
42
77
|
def found?
|
|
43
78
|
calls.positive?
|
|
44
79
|
end
|
|
80
|
+
|
|
81
|
+
# Whether the chosen root's calls parsed into any field. A draft checks
|
|
82
|
+
# its lines instead (Generator.declares_field?): a scanned key whose
|
|
83
|
+
# column has no contract type is a field here but only a TODO there.
|
|
84
|
+
def fields?
|
|
85
|
+
[scalars, arrays, nested, nested_arrays].any? { |shape| !shape.empty? }
|
|
86
|
+
end
|
|
45
87
|
end
|
|
46
88
|
|
|
47
89
|
# One permit call, with an optional leading `.require(:root)`. The args
|
|
@@ -50,11 +92,59 @@ module Permittable
|
|
|
50
92
|
# half-read.
|
|
51
93
|
PERMIT_CALL = /params\s*(?:\.\s*require\(\s*:(\w+)\s*\))?\s*\.\s*permit\(([^()]*)\)/m
|
|
52
94
|
|
|
95
|
+
# One Rails 8 `params.expect` call — the replacement for
|
|
96
|
+
# `require(...).permit(...)`, and the reason this scanner exists twice: a
|
|
97
|
+
# Rails 8 controller has no permit calls to read, so without this the
|
|
98
|
+
# generator would fall back to columns alone and lose everything the app
|
|
99
|
+
# already knows about its own params. Same conservative capture as
|
|
100
|
+
# PERMIT_CALL: brackets and newlines are fine, a parenthesis means a
|
|
101
|
+
# method call in the arguments and the whole call is skipped rather than
|
|
102
|
+
# half-read.
|
|
103
|
+
EXPECT_CALL = /params\s*\.\s*expect\(([^()]*)\)/m
|
|
104
|
+
|
|
105
|
+
# The required root envelope of an expect call: `user: [...]`, where the
|
|
106
|
+
# brackets hold fields — not the empty `tag_names: []` of an
|
|
107
|
+
# array-of-scalars root, and not the `comments: [[...]]` of an
|
|
108
|
+
# array-of-hashes root, neither of which a rooted contract can express.
|
|
109
|
+
# The lookahead skips whitespace itself: `\[\s*(?!...)` let `\s*` match
|
|
110
|
+
# nothing and waved the spaced `tag_names: [ ]` through as an envelope
|
|
111
|
+
# with no fields.
|
|
112
|
+
EXPECT_ENVELOPE = /\A(\w+):\s*\[(?!\s*[\[\]])\s*(.*?)\s*\]\z/m
|
|
113
|
+
|
|
114
|
+
# An envelope whose fields live in a constant: `post: PERMITTED_PARAMS`.
|
|
115
|
+
# The fields cannot be read, but the root can, and read as a rootless
|
|
116
|
+
# argument it let a `params.permit(:page)` beside it take the root.
|
|
117
|
+
EXPECT_CONSTANT_ENVELOPE = /\A(\w+):\s*((?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\z/
|
|
118
|
+
|
|
119
|
+
# The key of a single-key rootless EXPECT lookup that is a route param,
|
|
120
|
+
# not input: the Rails 8 scaffold's `Post.find(params.expect(:id))`, or a
|
|
121
|
+
# nested resource's `params.expect(:post_id)`. Only an expect call with
|
|
122
|
+
# that one key counts: `params.expect(:id, :q)` is a filter that names an
|
|
123
|
+
# id, and `params.permit(:group_id)` is mass assignment, not a lookup.
|
|
124
|
+
ROUTE_PARAM_KEY = /\A(?:id|\w+_id)\z/
|
|
125
|
+
|
|
53
126
|
# A permit key: `:name`, `"name"`, or `'name'` (quotes must match —
|
|
54
127
|
# anything else stays unparsed rather than guessed).
|
|
55
128
|
SCALAR_KEY = /\A(?::(\w+)|"(\w+)"|'(\w+)')\z/
|
|
56
129
|
ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
|
|
57
130
|
NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m
|
|
131
|
+
# expect-only: `comments: [[:body, :author]]` is an array of hashes.
|
|
132
|
+
NESTED_ARRAY_ARG = /\A(\w+):\s*\[\s*\[([^\[\]]*)\]\s*\]\z/m
|
|
133
|
+
|
|
134
|
+
# The shapes a scanned key can take, richest first, with how a conflict
|
|
135
|
+
# TODO names each (see resolve_conflicts).
|
|
136
|
+
SHAPES = {
|
|
137
|
+
nested_arrays: "an array of hashes", nested: "a nested hash", arrays: "an array", scalars: "a scalar"
|
|
138
|
+
}.freeze
|
|
139
|
+
HASH_SHAPES = %i[nested nested_arrays].freeze
|
|
140
|
+
|
|
141
|
+
# One params call, or one envelope of an expect call, at its offset in
|
|
142
|
+
# the source. `route_params` are never body fields, whichever root wins:
|
|
143
|
+
# the arguments an expect call spells beside its envelope, and the key of
|
|
144
|
+
# a single-key route-param lookup (ROUTE_PARAM_KEY). `spelling` is how an
|
|
145
|
+
# envelope's TODO quotes it if it loses.
|
|
146
|
+
Call = Struct.new(:position, :root, :fields, :route_params, :spelling)
|
|
147
|
+
private_constant :Call
|
|
58
148
|
|
|
59
149
|
# Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
|
|
60
150
|
# is only a comment sometimes — it also appears inside string literals and
|
|
@@ -63,6 +153,16 @@ module Permittable
|
|
|
63
153
|
# spelling, and its keys live in string tokens.
|
|
64
154
|
COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze
|
|
65
155
|
|
|
156
|
+
# A string/heredoc/regexp literal's CONTENT, as opposed to the Ruby
|
|
157
|
+
# syntax around it. Used by masked_source — see there for why this is
|
|
158
|
+
# kept apart from comments rather than treated the same way.
|
|
159
|
+
STRING_CONTENT_TOKENS = %i[on_tstring_content].freeze
|
|
160
|
+
|
|
161
|
+
# What a masked string/heredoc/regexp content token's characters become,
|
|
162
|
+
# except `(`/`)` (see mask_content): not a word character, so `params`,
|
|
163
|
+
# `permit`, `require`, and `expect` can never spell out of it.
|
|
164
|
+
MASK_CHAR = "\u0000".freeze
|
|
165
|
+
|
|
66
166
|
module_function
|
|
67
167
|
|
|
68
168
|
# `source` with its comments removed. A controller keeping a commented-out
|
|
@@ -74,44 +174,211 @@ module Permittable
|
|
|
74
174
|
# syntactically odd file scans exactly as it did before rather than not at
|
|
75
175
|
# all.
|
|
76
176
|
def executable_source(source)
|
|
77
|
-
tokens =
|
|
78
|
-
return source
|
|
177
|
+
tokens = code_tokens(source)
|
|
178
|
+
return source unless tokens
|
|
79
179
|
|
|
80
|
-
tokens.
|
|
180
|
+
tokens.map { |token| token[2] }.join
|
|
81
181
|
rescue StandardError
|
|
82
182
|
source
|
|
83
183
|
end
|
|
84
184
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
185
|
+
# executable_source with every string/heredoc/regexp literal's CONTENT
|
|
186
|
+
# run through mask_content, same length so a MatchData's offsets against
|
|
187
|
+
# this text still locate the same characters in executable_source.
|
|
188
|
+
#
|
|
189
|
+
# A call spelled out as TEXT inside a string — a log line quoting
|
|
190
|
+
# `params.require(:admin).permit(:superuser)` for humans — has no
|
|
191
|
+
# `params`, `permit`, `require`, or `expect` left once masked, so
|
|
192
|
+
# PERMIT_CALL and EXPECT_CALL can no longer match it there; the same
|
|
193
|
+
# conflation as a `#` comment (see executable_source), just reached
|
|
194
|
+
# through a string literal instead. A REAL call's own string argument
|
|
195
|
+
# (`permit("name")`) still matches: `params`, `.`, `permit`, and the
|
|
196
|
+
# parens around it are code tokens, never string content, so masking
|
|
197
|
+
# never touches them — only the argument text inside the parens is
|
|
198
|
+
# masked here, and scan reads that text back out of executable_source by
|
|
199
|
+
# offset rather than off this string.
|
|
200
|
+
def masked_source(source)
|
|
201
|
+
tokens = code_tokens(source)
|
|
202
|
+
return source unless tokens
|
|
203
|
+
|
|
204
|
+
tokens.map { |token| STRING_CONTENT_TOKENS.include?(token[1]) ? mask_content(token[2]) : token[2] }.join
|
|
205
|
+
rescue StandardError
|
|
206
|
+
source
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# One string/heredoc/regexp content token, masked: every character
|
|
210
|
+
# becomes MASK_CHAR except `(`/`)`, which are left alone. Keeping parens
|
|
211
|
+
# literal costs nothing PERMIT_CALL/EXPECT_CALL look for — the words
|
|
212
|
+
# they require are gone either way — and keeps a call already open
|
|
213
|
+
# before the string closing on the same paren it always did: a lenient
|
|
214
|
+
# lex can swallow real code into an unterminated string's content, exactly
|
|
215
|
+
# as a stray `"` does today (see the "mismatched quotes" scan spec), and
|
|
216
|
+
# masking every character there would eat the `)` that call is
|
|
217
|
+
# conservatively parsed with.
|
|
218
|
+
def mask_content(text)
|
|
219
|
+
text.gsub(/[^()]/, MASK_CHAR)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The lexed tokens shared by executable_source and masked_source, with
|
|
223
|
+
# comments already dropped — nil when Ripper could not lex `source` at
|
|
224
|
+
# all, or found nothing.
|
|
225
|
+
def code_tokens(source)
|
|
226
|
+
tokens = Ripper.lex(source)
|
|
227
|
+
return nil if tokens.nil? || tokens.empty?
|
|
228
|
+
|
|
229
|
+
tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Merge every `params.permit` and `params.expect` call found in the
|
|
233
|
+
# EXECUTABLE part of `source` into one Scan, under one root (see
|
|
234
|
+
# choose_root), matching how a controller normally sticks to one
|
|
235
|
+
# envelope across actions. Comments are stripped first for both call shapes — a
|
|
236
|
+
# commented-out `expect` is no more code than a commented-out `permit`.
|
|
237
|
+
#
|
|
238
|
+
# The root is chosen from ALL the calls before any field is merged,
|
|
239
|
+
# because one rooted contract can express only one envelope. A Rails 8
|
|
240
|
+
# scaffold's `Post.find(params.expect(:id))` in `set_post`, or an index
|
|
241
|
+
# action's `params.permit(:page)`, sits beside `params.expect(post: [...])`
|
|
242
|
+
# and is a route param or a filter, not a body field — merged in, it was
|
|
243
|
+
# drafted as `optional :id` inside the `post` envelope. So only the chosen
|
|
244
|
+
# root's calls become fields; another envelope, and a rootless call once
|
|
245
|
+
# there is an envelope, stay visible as TODOs. A file of only rootless
|
|
246
|
+
# calls is a filter contract and still drafts them as fields.
|
|
247
|
+
#
|
|
248
|
+
# The calls are read in SOURCE order, whichever spelling each uses:
|
|
249
|
+
# scanning every permit call before every expect call made "first seen"
|
|
250
|
+
# mean "first permit call", so a later search form could outrank the
|
|
251
|
+
# expect envelope above it. The sort is made stable by the index, because
|
|
252
|
+
# the envelopes of one expect call share a position and `sort_by` alone
|
|
253
|
+
# may reorder them.
|
|
254
|
+
#
|
|
255
|
+
# `model:` names the envelope a Rails form for that model sends; see
|
|
256
|
+
# choose_root. `exclude:` lists roots (nil for the rootless calls) that
|
|
257
|
+
# may not be chosen — see for_controller.
|
|
258
|
+
def scan(source, model: nil, exclude: [])
|
|
259
|
+
result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, nested_arrays: {}, unparsed: [],
|
|
260
|
+
conflicts: [], undecided: [], route_params: [], rootless: [], other_envelopes: {}, calls: 0)
|
|
261
|
+
raw = source.to_s
|
|
262
|
+
source = executable_source(raw)
|
|
263
|
+
masked = masked_source(raw)
|
|
264
|
+
permits = matches(masked, PERMIT_CALL).map { |match| permit_call(source, match) }
|
|
265
|
+
expects = matches(masked, EXPECT_CALL).map { |match| expect_calls(match.begin(0), split_args(group(source, match, 1))) }
|
|
266
|
+
result.calls = permits.size + expects.size
|
|
267
|
+
calls = (permits + expects).flatten.sort_by.with_index { |call, index| [call.position, index] }
|
|
268
|
+
result.root = choose_root(calls, model&.name && default_root(model), exclude)
|
|
269
|
+
calls.each { |call| merge_call(result, call) }
|
|
270
|
+
resolve_conflicts(result)
|
|
271
|
+
drop_drafted_todos(result)
|
|
95
272
|
result
|
|
96
273
|
end
|
|
97
274
|
|
|
275
|
+
def matches(source, pattern)
|
|
276
|
+
source.to_enum(:scan, pattern).map { Regexp.last_match }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The real text under one of masked_source's MatchData groups, read back
|
|
280
|
+
# out of `source` (executable_source, not masked_source) at the same
|
|
281
|
+
# offsets — nil when the group did not participate in the match. See
|
|
282
|
+
# masked_source for why a matched call's own text lives in `source`
|
|
283
|
+
# rather than in the MatchData itself.
|
|
284
|
+
def group(source, match, index)
|
|
285
|
+
return nil unless match.begin(index)
|
|
286
|
+
|
|
287
|
+
source[match.begin(index)...match.end(index)]
|
|
288
|
+
end
|
|
289
|
+
|
|
98
290
|
# Draft a contract for one controller: model inferred from
|
|
99
291
|
# controller_name (or passed explicitly), permit calls scanned from
|
|
100
292
|
# `source:` when given. Returns nil when there is nothing to draft from.
|
|
293
|
+
#
|
|
294
|
+
# When the chosen root cannot be drafted at all — the model's envelope
|
|
295
|
+
# permits only a binary column, and the model has no column a contract
|
|
296
|
+
# can declare — the next candidate root is tried rather than returning
|
|
297
|
+
# nil, until one drafts or none is left: a draft rooted at the other
|
|
298
|
+
# envelope in the file is a better starting point than no draft, and it
|
|
299
|
+
# is what master drafted.
|
|
101
300
|
def for_controller(controller, source: nil, model: nil)
|
|
102
|
-
|
|
301
|
+
model ||= infer_model(controller)
|
|
302
|
+
excluded = []
|
|
303
|
+
loop do
|
|
304
|
+
scan = scan(source, model: model, exclude: excluded)
|
|
305
|
+
result = draft(model: model, scan: scan)
|
|
306
|
+
return result if result || !scan.found? || excluded.include?(scan.root)
|
|
307
|
+
|
|
308
|
+
excluded << scan.root
|
|
309
|
+
end
|
|
103
310
|
end
|
|
104
311
|
|
|
105
312
|
# The core: knowledge in (columns and/or a scan), snippet out. Returns a
|
|
106
|
-
# String of valid Ruby, or nil when neither source of knowledge exists
|
|
313
|
+
# String of valid Ruby, or nil when neither source of knowledge exists —
|
|
314
|
+
# or when neither can declare a single field, since a contract of only
|
|
315
|
+
# TODO lines raises `a contract must declare at least one field` the
|
|
316
|
+
# moment it is pasted.
|
|
107
317
|
def draft(model: nil, scan: nil)
|
|
108
318
|
columns = columns_for(model)
|
|
109
319
|
scan = nil unless scan&.found?
|
|
110
320
|
return nil unless columns || scan
|
|
321
|
+
return column_draft(model, columns.values, []) unless scan
|
|
322
|
+
|
|
323
|
+
shared = scanned_lines(scan, listed_columns(columns, :shared))
|
|
324
|
+
return fallback_draft(model, scan, columns) unless declares_field?(shared)
|
|
325
|
+
|
|
326
|
+
render(root: scan.root, model: columns && model) { |rule| scanned_lines(scan, listed_columns(columns, rule)) }
|
|
327
|
+
end
|
|
111
328
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
329
|
+
# The columns as one rule drafts them (see DraftColumn#in_rule), each
|
|
330
|
+
# marked listed: on the scan path a column is drafted only because the
|
|
331
|
+
# controller's own calls name it.
|
|
332
|
+
def listed_columns(columns, rule)
|
|
333
|
+
columns&.transform_values { |column| column.in_rule(rule, listed: true) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Whether any line declares a field. Scan#fields? cannot answer this
|
|
337
|
+
# alone: a scanned key whose column has no contract type (binary,
|
|
338
|
+
# geometry) drafts only a TODO.
|
|
339
|
+
def declares_field?(lines)
|
|
340
|
+
lines.any? { |line| !line.start_with?("#") }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A scan that found calls but no fields — `permit(*PERMITTED)`, or every
|
|
344
|
+
# call belonging to another envelope — drafted a contract with only TODO
|
|
345
|
+
# lines, which raises `a contract must declare at least one field` the
|
|
346
|
+
# moment it is pasted. The columns are the next best knowledge, so they
|
|
347
|
+
# are drafted instead, with the scan's TODOs kept beneath them — minus
|
|
348
|
+
# any column the scan's TODOs say is not drafted: a key permitted in
|
|
349
|
+
# shapes that accept different input, and a route param.
|
|
350
|
+
#
|
|
351
|
+
# The scan's rootlessness is kept only when the rootless calls carried a
|
|
352
|
+
# body field (see rootless_body?): a rootless `params.permit(*KEYS)`
|
|
353
|
+
# controller drafted under the model's root would answer 400 to every
|
|
354
|
+
# request it already serves once enforced. A "rootless" scan that found
|
|
355
|
+
# only a route-param lookup — `Post.find(params.expect(:id))` beside a
|
|
356
|
+
# `permit(policy(@post).permitted_attributes)` the scanner skips — says
|
|
357
|
+
# nothing about the body, so it gets the model's root, as master drafted.
|
|
358
|
+
# With no columns either there is nothing loadable to draft, so nil.
|
|
359
|
+
def fallback_draft(model, scan, columns)
|
|
360
|
+
return nil unless columns
|
|
361
|
+
|
|
362
|
+
root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
|
|
363
|
+
undrafted = (Array(scan.undecided) + Array(scan.route_params).filter_map { |arg| scalar_key(arg) }).map(&:to_s)
|
|
364
|
+
drafted = columns.except(*undrafted)
|
|
365
|
+
# As in drop_drafted_todos: a rootless key the columns now declare is
|
|
366
|
+
# not also a "not in this contract" TODO.
|
|
367
|
+
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)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Whether a rootless scan's calls carried at least one body field —
|
|
372
|
+
# parsed, unparsable, or permitted in conflicting shapes. Route-param
|
|
373
|
+
# lookups are filed under `route_params`, not here.
|
|
374
|
+
def rootless_body?(scan)
|
|
375
|
+
scan.fields? || !scan.unparsed.empty? || !Array(scan.conflicts).empty?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# A draft from the columns, or nil when none of them has a contract type
|
|
379
|
+
# 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 }
|
|
115
382
|
end
|
|
116
383
|
|
|
117
384
|
def infer_model(controller)
|
|
@@ -125,17 +392,175 @@ module Permittable
|
|
|
125
392
|
# is no model or its schema is unreachable (same philosophy as the drift
|
|
126
393
|
# guard: never let generation crash on a half-migrated database).
|
|
127
394
|
def columns_for(model)
|
|
395
|
+
columns = schema_columns(model)
|
|
396
|
+
columns&.to_h { |column| [column.name, draft_column(model, column)] }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Only schema access is rescued here. The model introspection layered on
|
|
400
|
+
# top (draft_column) must not share this rescue: an error there would
|
|
401
|
+
# otherwise discard EVERY column — the draft silently falls back to a
|
|
402
|
+
# scan alone, or to nothing — for what is one column's problem.
|
|
403
|
+
#
|
|
404
|
+
# The rescue is scoped to ActiveRecord::ActiveRecordError, same as
|
|
405
|
+
# ColumnGuard.schema_reachable? and for the same reason: a genuinely
|
|
406
|
+
# unreachable schema (no database yet, table not migrated) degrades to
|
|
407
|
+
# no columns, but a real bug — a broken custom type adapter, a NameError
|
|
408
|
+
# from a typo — must keep surfacing instead of quietly emitting an empty
|
|
409
|
+
# draft. The defined? guard keeps this gem loadable without
|
|
410
|
+
# activerecord, same as schema_reachable? (a host without it duck-types
|
|
411
|
+
# `model:` and cannot raise an ActiveRecordError in the first place).
|
|
412
|
+
def schema_columns(model)
|
|
128
413
|
return nil unless model.respond_to?(:columns)
|
|
129
414
|
return nil unless model.table_exists?
|
|
130
415
|
|
|
131
416
|
# Array() flattens a composite primary key (an Array in Rails 7.1+)
|
|
132
417
|
# into its column names; a nil primary key becomes [].
|
|
133
418
|
skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
|
|
134
|
-
model.columns.reject { |c| skipped.include?(c.name) }
|
|
135
|
-
rescue StandardError
|
|
419
|
+
model.columns.reject { |c| skipped.include?(c.name) }
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
422
|
+
|
|
136
423
|
nil
|
|
137
424
|
end
|
|
138
425
|
|
|
426
|
+
# The sensitive role is read OUTSIDE model_facts' rescue: it only
|
|
427
|
+
# compares the column's name with the model's STI and locking settings,
|
|
428
|
+
# and a failed enum or default read must not also make `type` or
|
|
429
|
+
# `lock_version` client-writable.
|
|
430
|
+
def draft_column(model, column)
|
|
431
|
+
DraftColumn.new(name: column.name, type: column.type, null: column.null,
|
|
432
|
+
default_function: column.respond_to?(:default_function) && column.default_function,
|
|
433
|
+
sensitive: sensitive_role(model, column.name), **model_facts(model, column))
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# What the model adds to one column. An error reading it degrades THAT
|
|
437
|
+
# column to its database facts, with the error in a TODO — rather than
|
|
438
|
+
# raising, which would abort permittable:generate for every controller
|
|
439
|
+
# over one odd model, or dropping the column, which would hide it. The
|
|
440
|
+
# message is squished because it lands in a one-line comment, where a
|
|
441
|
+
# newline would end the comment and leave the draft unparseable.
|
|
442
|
+
def model_facts(model, column)
|
|
443
|
+
enum = enum_for(model, column.name)
|
|
444
|
+
{ enum: enum && enum[:accessor], enum_integers: enum && enum[:mapping].values.all?(Integer),
|
|
445
|
+
default: default_note(model, column, enum) }
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
{ default: database_default_note(column, nil), unread: "#{e.class}: #{e.message}".squish }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# The default Rails actually applies, as a comment. One the MODEL
|
|
451
|
+
# declares — `attribute :carrier, default: "post"`, `enum ..., default:
|
|
452
|
+
# :pending` — never reaches the schema, yet it fills the field on create
|
|
453
|
+
# just as a database default does, so it too keeps a NOT NULL column
|
|
454
|
+
# from being `required`. It wins over the database's, as it does in
|
|
455
|
+
# Rails.
|
|
456
|
+
def default_note(model, column, enum)
|
|
457
|
+
declared = model_default(model, column.name, enum)
|
|
458
|
+
declared ? "model default: #{declared}" : database_default_note(column, enum)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# An enum's database default is its stored integer; shown as its key
|
|
462
|
+
# (`"pending"`, not `0`), which is what the drafted field accepts.
|
|
463
|
+
def database_default_note(column, enum)
|
|
464
|
+
default = column.default
|
|
465
|
+
return nil if default.nil?
|
|
466
|
+
|
|
467
|
+
default = enum[:mapping].find { |_key, value| value.to_s == default.to_s }&.first || default if enum
|
|
468
|
+
"database default: #{default.inspect}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# `_default_attributes` (nodoc, but what `column_defaults` is built from
|
|
472
|
+
# in every supported Rails, 6.1–8.1) holds a UserProvidedDefault for each
|
|
473
|
+
# default the model declares; the rest came from the database. Read per
|
|
474
|
+
# attribute rather than through column_defaults, which evaluates every
|
|
475
|
+
# Proc default at once.
|
|
476
|
+
#
|
|
477
|
+
# The note is written from the default AS DECLARED (its private
|
|
478
|
+
# `user_provided_value`, the same in 6.1–8.1), never from `value`:
|
|
479
|
+
# casting runs the attribute type's code, which is the app's, and can
|
|
480
|
+
# raise — `attribute :prefs, :json, default: {}` does. A Proc is not
|
|
481
|
+
# called either: drafting must not run app code with side effects, and
|
|
482
|
+
# today's value says nothing about tomorrow's. An enum default declared
|
|
483
|
+
# by its stored value is shown as its key, like a database one.
|
|
484
|
+
def model_default(model, name, enum)
|
|
485
|
+
return nil unless model.respond_to?(:_default_attributes) && defined?(ActiveModel::Attribute::UserProvidedDefault)
|
|
486
|
+
|
|
487
|
+
attribute = model._default_attributes[name]
|
|
488
|
+
return nil unless attribute.is_a?(ActiveModel::Attribute::UserProvidedDefault)
|
|
489
|
+
|
|
490
|
+
declared = attribute.send(:user_provided_value)
|
|
491
|
+
return "computed by a Proc" if declared.is_a?(Proc)
|
|
492
|
+
return nil if declared.nil?
|
|
493
|
+
|
|
494
|
+
declared = enum[:mapping].key(declared) || declared if enum
|
|
495
|
+
default_literal(declared)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# How a declared default reads in a comment: strings and Symbols (an
|
|
499
|
+
# enum key given as `:pending`) quoted, numbers and times as people
|
|
500
|
+
# write them — `1.5` rather than BigDecimal's `0.15e1`. Anything else is
|
|
501
|
+
# inspected and squished, since the note must stay on one line.
|
|
502
|
+
def default_literal(value)
|
|
503
|
+
case value
|
|
504
|
+
when String, Symbol then value.to_s.inspect
|
|
505
|
+
when BigDecimal then value.to_s("F")
|
|
506
|
+
when Numeric then value.to_s
|
|
507
|
+
else value.respond_to?(:strftime) ? value.to_s : value.inspect.squish
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# A Rails enum stores an integer but is ASSIGNED its key: a form sends
|
|
512
|
+
# "shipped", never 1, so drafting the column type (:integer) would reject
|
|
513
|
+
# every legitimate request. The draft references the model's own
|
|
514
|
+
# accessor rather than inlining today's keys, so adding a value to the
|
|
515
|
+
# enum cannot leave the contract rejecting it.
|
|
516
|
+
#
|
|
517
|
+
# Rails defines the accessor for any enum name, but only an identifier
|
|
518
|
+
# can be CALLED as `Model.name`: a `first-status` enum's
|
|
519
|
+
# `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
|
|
520
|
+
# which runs a query when the draft loads. defined_enums reaches the
|
|
521
|
+
# same mapping by name. The model is also checked for the accessor
|
|
522
|
+
# itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
|
|
523
|
+
# valid identifier that the model does not actually answer to — renamed
|
|
524
|
+
# or otherwise excluded — and calling it would raise NoMethodError when
|
|
525
|
+
# the draft loads.
|
|
526
|
+
def enum_for(model, name)
|
|
527
|
+
return nil unless model.respond_to?(:defined_enums)
|
|
528
|
+
|
|
529
|
+
mapping = model.defined_enums[name]
|
|
530
|
+
return nil unless mapping
|
|
531
|
+
|
|
532
|
+
plural = name.pluralize
|
|
533
|
+
accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
|
|
534
|
+
"#{model.name}.#{plural}"
|
|
535
|
+
else
|
|
536
|
+
"#{model.name}.defined_enums[#{name.inspect}]"
|
|
537
|
+
end
|
|
538
|
+
{ mapping: mapping, accessor: accessor }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
542
|
+
|
|
543
|
+
# The role that makes a column dangerous for a client to write — :sti,
|
|
544
|
+
# :locking, or nil. What the draft then does with it is column_line's
|
|
545
|
+
# call, since that depends on whether the permit call lists it.
|
|
546
|
+
def sensitive_role(model, name)
|
|
547
|
+
# Only a model that actually uses STI: the inheritance column is set
|
|
548
|
+
# (not nil) AND exists. Mass-assigning it changes which class the
|
|
549
|
+
# record is loaded as — `type: "Admin"` on a signup form.
|
|
550
|
+
return :sti if model.respond_to?(:inheritance_column) && model.inheritance_column.to_s == name
|
|
551
|
+
|
|
552
|
+
:locking if locking_column?(model, name)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Mirrors the STI check's respect for `inheritance_column = nil`: with
|
|
556
|
+
# `self.lock_optimistically = false` Rails never reads or bumps the
|
|
557
|
+
# column, so it is an ordinary integer and drafts as one.
|
|
558
|
+
def locking_column?(model, name)
|
|
559
|
+
return false unless model.respond_to?(:locking_column) && model.locking_column.to_s == name
|
|
560
|
+
|
|
561
|
+
!model.respond_to?(:lock_optimistically) || model.lock_optimistically
|
|
562
|
+
end
|
|
563
|
+
|
|
139
564
|
# -- scan parsing -------------------------------------------------------
|
|
140
565
|
|
|
141
566
|
# Split a permit argument list on top-level commas only, so `address:
|
|
@@ -153,23 +578,231 @@ module Permittable
|
|
|
153
578
|
parts.map(&:strip).reject(&:empty?)
|
|
154
579
|
end
|
|
155
580
|
|
|
581
|
+
# An expect call's arguments, as one Call per envelope. The bracketed
|
|
582
|
+
# arguments are required root envelopes and their contents are fields.
|
|
583
|
+
# Plain symbols are fields only when there is no envelope
|
|
584
|
+
# (`params.expect(:q, :page)` is a rootless filter); alongside one they
|
|
585
|
+
# are route params rather than body fields, so they stay visible instead
|
|
586
|
+
# of being drafted as contract fields. Each envelope is its own candidate
|
|
587
|
+
# root: `expect(post: [...], comment: [...])` can be where the comment
|
|
588
|
+
# contract's fields live, and reading only the first envelope hid them.
|
|
589
|
+
def expect_calls(position, args)
|
|
590
|
+
envelopes, others = args.partition { |arg| envelope(arg) }
|
|
591
|
+
return [expect_rootless_call(position, others)] if envelopes.empty?
|
|
592
|
+
|
|
593
|
+
envelopes.each_with_index.map do |arg, index|
|
|
594
|
+
root, fields = envelope(arg)
|
|
595
|
+
Call.new(position, root, fields, index.zero? ? others : [], unparsed_arg(arg))
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# An expect argument as `[root, fields]` when it is an envelope, else nil.
|
|
600
|
+
# A constant envelope's one "field" is the constant, kept as unparsable.
|
|
601
|
+
def envelope(arg)
|
|
602
|
+
if (match = EXPECT_ENVELOPE.match(arg))
|
|
603
|
+
[match[1].to_sym, split_args(match[2])]
|
|
604
|
+
elsif (match = EXPECT_CONSTANT_ENVELOPE.match(arg))
|
|
605
|
+
[match[1].to_sym, [match[2]]]
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# A rooted permit call is quoted as the call itself, on one line, so its
|
|
610
|
+
# TODO can be found in the source it came from. `source` is
|
|
611
|
+
# executable_source: `match` was found by scanning masked_source, whose
|
|
612
|
+
# own text may hold placeholders rather than a real string argument's
|
|
613
|
+
# characters (see masked_source), so every group is read back out of
|
|
614
|
+
# `source` by position instead of off `match` directly.
|
|
615
|
+
def permit_call(source, match)
|
|
616
|
+
args = split_args(group(source, match, 2))
|
|
617
|
+
root = group(source, match, 1)
|
|
618
|
+
return Call.new(match.begin(0), nil, args, []) unless root
|
|
619
|
+
|
|
620
|
+
spelling = unparsed_arg(group(source, match, 0)).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
|
|
621
|
+
Call.new(match.begin(0), root.to_sym, args, [], spelling)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# A rootless expect call — or, when its one key is a route param
|
|
625
|
+
# (ROUTE_PARAM_KEY), a lookup with no fields at all, so it can neither
|
|
626
|
+
# score toward the root nor be drafted as a field when the rootless calls
|
|
627
|
+
# win.
|
|
628
|
+
def expect_rootless_call(position, args)
|
|
629
|
+
route = args.size == 1 && (key = scalar_key(args.first)) && ROUTE_PARAM_KEY.match?(key.to_s)
|
|
630
|
+
route ? Call.new(position, nil, [], args) : Call.new(position, nil, args, [])
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# The model's own envelope wins outright when one of the calls uses it
|
|
634
|
+
# (`preferred`, derived like default_root): it is the envelope a Rails
|
|
635
|
+
# form for the model sends, and the only way to root a
|
|
636
|
+
# `require(:post).permit(*PERMITTED)` that has no field to score with.
|
|
637
|
+
#
|
|
638
|
+
# With no model to say which envelope is the form's, an envelope with a
|
|
639
|
+
# parsed field beats the rootless calls, as any envelope always did: an
|
|
640
|
+
# index action's `params.permit(:page, :per_page)` must not take the root
|
|
641
|
+
# from a one-field `post_params` on a guess. An envelope with NO parsed
|
|
642
|
+
# field — `expect(search: FILTERS)` — only beats rootless calls that have
|
|
643
|
+
# none either: beside `params.permit(:title, :body)` it would root a draft
|
|
644
|
+
# with nothing to declare, where master drafted title and body.
|
|
645
|
+
#
|
|
646
|
+
# Otherwise the candidate — an envelope, or, with a model that no
|
|
647
|
+
# envelope matches, the rootless calls together (root nil) — declaring
|
|
648
|
+
# the most distinct PARSED fields across its calls wins. Now that
|
|
649
|
+
# the losers become TODOs rather than fields, "first seen" alone let an
|
|
650
|
+
# index action's `require(:search).permit(:q)` win the root and push the
|
|
651
|
+
# real `expect(post: [...])` into a TODO; and considering envelopes only
|
|
652
|
+
# let that same search form beat a rootless `params.permit(:title, :body,
|
|
653
|
+
# :published)` carrying the whole create body. Unparsable arguments
|
|
654
|
+
# (`*PERMITTED`) count for nothing: they are not fields the draft can
|
|
655
|
+
# declare.
|
|
656
|
+
#
|
|
657
|
+
# A tie goes to an envelope, then to the first seen in the source. The
|
|
658
|
+
# envelope wins a tie because a rootless key beside one is usually a
|
|
659
|
+
# route or query param: a one-field Rails 8 scaffold calls
|
|
660
|
+
# `params.expect(:id)` in `set_post` above `params.expect(post: [:title])`.
|
|
661
|
+
def choose_root(calls, preferred = nil, exclude = [])
|
|
662
|
+
calls = calls.reject { |call| exclude.include?(call.root) }
|
|
663
|
+
return preferred if preferred && calls.any? { |call| call.root == preferred }
|
|
664
|
+
|
|
665
|
+
scores = calls.group_by(&:root).transform_values do |same|
|
|
666
|
+
same.flat_map { |call| call.fields.filter_map { |arg| parse_arg(arg)&.at(1) } }.uniq.size
|
|
667
|
+
end
|
|
668
|
+
scores = envelopes_first(scores) unless preferred
|
|
669
|
+
best = scores.each_with_index.max_by { |(root, score), index| [score, root ? 1 : 0, -index] }
|
|
670
|
+
best&.first&.first
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# The model-less rule above: the rootless candidate is dropped when an
|
|
674
|
+
# envelope has a parsed field, or when it has none itself.
|
|
675
|
+
def envelopes_first(scores)
|
|
676
|
+
envelopes = scores.except(nil)
|
|
677
|
+
return scores if envelopes.empty?
|
|
678
|
+
|
|
679
|
+
envelopes.values.max.positive? || scores.fetch(nil, 0).zero? ? envelopes : scores
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Fold one call into the scan: its fields when it shares the scan's root
|
|
683
|
+
# (including both having none), a TODO otherwise — its arguments filed
|
|
684
|
+
# under its own root, or as rootless keys, so the TODO says where they
|
|
685
|
+
# belong rather than that they could not be read.
|
|
686
|
+
def merge_call(result, call)
|
|
687
|
+
if call.root == result.root
|
|
688
|
+
call.fields.each { |arg| classify_arg(result, arg) }
|
|
689
|
+
elsif call.root
|
|
690
|
+
result.other_envelopes[call.root] = (result.other_envelopes[call.root] || []) | [call.spelling]
|
|
691
|
+
else
|
|
692
|
+
result.rootless |= call.fields.map { |arg| unparsed_arg(arg) }
|
|
693
|
+
end
|
|
694
|
+
result.route_params |= call.route_params.map { |arg| unparsed_arg(arg) }
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# A key the winning root drafts as a field is not also a TODO: beside
|
|
698
|
+
# `expect(post: [:title, :group_id])`, a `Group.find(params.expect(:group_id))`
|
|
699
|
+
# lookup or a rootless `params.permit(:group_id)` would otherwise say
|
|
700
|
+
# "not a body field" about a field the draft declares. Only a bare key is
|
|
701
|
+
# dropped: a shaped copy (`tags: [:z]` beside the envelope's `tags: [:a]`)
|
|
702
|
+
# may carry sub-keys the drafted field lacks, and dropping its TODO would
|
|
703
|
+
# lose them.
|
|
704
|
+
def drop_drafted_todos(result)
|
|
705
|
+
drafted = SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }
|
|
706
|
+
%i[route_params rootless].each do |member|
|
|
707
|
+
result[member] = result[member].reject { |arg| drafted.include?(scalar_key(arg)) }
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# A key permitted in two shapes across actions is declared once — a
|
|
712
|
+
# contract rejects a field declared twice — and how depends on whether
|
|
713
|
+
# one shape accepts what the other is sent:
|
|
714
|
+
#
|
|
715
|
+
# - a nested hash and an array of hashes merge into the array of hashes,
|
|
716
|
+
# with the sub-keys of both: `key: [[:a]]` is expect's definitive
|
|
717
|
+
# spelling of the array of hashes that `key: [:a]` leaves ambiguous,
|
|
718
|
+
# and dropping the loser's sub-keys would reject the ones its action
|
|
719
|
+
# sends;
|
|
720
|
+
# - an array of scalars and either hash shape accept disjoint input, so
|
|
721
|
+
# neither is drafted — picking one would reject what the other action
|
|
722
|
+
# sends — and the TODO names both;
|
|
723
|
+
# - anything else goes to the richer shape (SHAPES is ordered richest
|
|
724
|
+
# first), the one at least one action demonstrably accepts: a scalar
|
|
725
|
+
# declaration would reject the hash or array that action is sent.
|
|
726
|
+
def resolve_conflicts(result)
|
|
727
|
+
SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }.uniq.each do |key|
|
|
728
|
+
shapes = SHAPES.keys.select { |shape| shape_keys(result, shape).include?(key) }
|
|
729
|
+
next if shapes.size < 2
|
|
730
|
+
|
|
731
|
+
result.conflicts << resolve_conflict(result, key, shapes)
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def resolve_conflict(result, key, shapes)
|
|
736
|
+
merged = (HASH_SHAPES - shapes).empty?
|
|
737
|
+
result.nested_arrays[key] |= result.nested[key] if merged
|
|
738
|
+
if shapes.include?(:arrays) && shapes.intersect?(HASH_SHAPES)
|
|
739
|
+
shapes.each { |shape| result[shape].delete(key) }
|
|
740
|
+
result.undecided << key
|
|
741
|
+
return "#{key} is permitted as #{listed_shapes(shapes)}, which accept different input — " \
|
|
742
|
+
"drafted as #{shapes.size == 2 ? 'neither' : 'none of them'}; declare the shape its actions share"
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
shapes.drop(1).each { |shape| result[shape].delete(key) }
|
|
746
|
+
"#{key} is permitted as #{listed_shapes(shapes)} — drafted as #{the_shape(shapes.first)}" \
|
|
747
|
+
"#{merge_note(shapes) if merged}"
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# A merge keeps the nested hash's sub-keys, so only a scalar is dropped.
|
|
751
|
+
def merge_note(shapes)
|
|
752
|
+
" with the nested hash's sub-keys merged in#{', dropping the scalar' if shapes.include?(:scalars)}"
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def the_shape(shape)
|
|
756
|
+
SHAPES[shape].sub(/\Aan? /, "the ")
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def shape_keys(result, shape)
|
|
760
|
+
keys = result[shape]
|
|
761
|
+
keys.is_a?(Hash) ? keys.keys : keys
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# "both a scalar and an array", "a scalar, a nested hash and an array of
|
|
765
|
+
# hashes" — poorest first.
|
|
766
|
+
def listed_shapes(shapes)
|
|
767
|
+
names = shapes.reverse.map { |shape| SHAPES[shape] }
|
|
768
|
+
names.size == 2 ? "both #{names.join(' and ')}" : "#{names[0..-2].join(', ')} and #{names.last}"
|
|
769
|
+
end
|
|
770
|
+
|
|
156
771
|
def classify_arg(result, arg)
|
|
772
|
+
shape, key, sub_keys = parse_arg(arg)
|
|
773
|
+
if shape.nil?
|
|
774
|
+
result.unparsed |= [unparsed_arg(arg)]
|
|
775
|
+
elsif sub_keys
|
|
776
|
+
result[shape][key] = (result[shape][key] || []) | sub_keys
|
|
777
|
+
else
|
|
778
|
+
result[shape] |= [key]
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
|
|
783
|
+
# the two hash shapes — nil when the conservative parser cannot read it,
|
|
784
|
+
# including a nested list with any sub-key it cannot read.
|
|
785
|
+
def parse_arg(arg)
|
|
157
786
|
if (key = scalar_key(arg))
|
|
158
|
-
|
|
787
|
+
[:scalars, key]
|
|
159
788
|
elsif (match = ARRAY_ARG.match(arg))
|
|
160
|
-
|
|
789
|
+
[:arrays, match[1].to_sym]
|
|
790
|
+
elsif (match = NESTED_ARRAY_ARG.match(arg))
|
|
791
|
+
nested_arg(:nested_arrays, match)
|
|
161
792
|
elsif (match = NESTED_ARG.match(arg))
|
|
162
|
-
|
|
163
|
-
else
|
|
164
|
-
result.unparsed |= [arg.gsub(/\s+/, " ")]
|
|
793
|
+
nested_arg(:nested, match)
|
|
165
794
|
end
|
|
166
795
|
end
|
|
167
796
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
797
|
+
# An empty list (`meta: [[ ]]`, `meta: [ , ]`) is unparsable too: it
|
|
798
|
+
# would draft a `do end` block, which the DSL rejects.
|
|
799
|
+
def nested_arg(shape, match)
|
|
800
|
+
sub_keys = split_args(match[2]).map { |part| scalar_key(part) }
|
|
801
|
+
[shape, match[1].to_sym, sub_keys] unless sub_keys.empty? || sub_keys.any?(&:nil?)
|
|
802
|
+
end
|
|
171
803
|
|
|
172
|
-
|
|
804
|
+
def unparsed_arg(arg)
|
|
805
|
+
arg.gsub(/\s+/, " ")
|
|
173
806
|
end
|
|
174
807
|
|
|
175
808
|
def scalar_key(part)
|
|
@@ -179,12 +812,17 @@ module Permittable
|
|
|
179
812
|
|
|
180
813
|
# -- drafting -----------------------------------------------------------
|
|
181
814
|
|
|
815
|
+
# model_name.param_key is the key Rails form helpers submit under and
|
|
816
|
+
# `params.require` reads — `blog_post` for Blog::Post, where demodulizing
|
|
817
|
+
# the class name gave `post` and an enforced draft 400'd every submit.
|
|
182
818
|
def default_root(model)
|
|
819
|
+
return model.model_name.param_key.to_sym if model.respond_to?(:model_name)
|
|
820
|
+
|
|
183
821
|
model.name.demodulize.underscore.to_sym
|
|
184
822
|
end
|
|
185
823
|
|
|
186
|
-
def signature(root:, model:)
|
|
187
|
-
parts = ["permit_params #{
|
|
824
|
+
def signature(root:, model:, actions: DEFAULT_ACTIONS)
|
|
825
|
+
parts = ["permit_params #{actions.map(&:inspect).join(', ')}"]
|
|
188
826
|
parts << "root: :#{root}" if root
|
|
189
827
|
parts << "model: #{model.name}" if model
|
|
190
828
|
parts << "mode: :monitor do"
|
|
@@ -195,13 +833,77 @@ module Permittable
|
|
|
195
833
|
columns.map { |column| column_line(column) }
|
|
196
834
|
end
|
|
197
835
|
|
|
836
|
+
# One column's line in one rule — `column.rule` is :shared (a single
|
|
837
|
+
# :create, :update rule), :create, or :update, which never requires
|
|
838
|
+
# anything (see render). `column.listed` says the controller's own
|
|
839
|
+
# permit call lists the column, which decides what a sensitive column
|
|
840
|
+
# becomes.
|
|
841
|
+
#
|
|
842
|
+
# Names are emitted with Symbol#inspect, so a column called `first-name`
|
|
843
|
+
# or `2fa_enabled` drafts as `:"first-name"` rather than as Ruby that
|
|
844
|
+
# does not parse.
|
|
198
845
|
def column_line(column)
|
|
199
|
-
|
|
846
|
+
return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
|
|
847
|
+
|
|
848
|
+
type = column.enum ? :string : COLUMN_TYPES[column.type]
|
|
200
849
|
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
201
850
|
|
|
202
|
-
|
|
203
|
-
line
|
|
204
|
-
line
|
|
851
|
+
required = column.rule != :update && required_column?(column)
|
|
852
|
+
line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
|
|
853
|
+
line += ", in: #{column.enum}.keys" if column.enum
|
|
854
|
+
notes = [column.default, *column_todos(column)].compact
|
|
855
|
+
notes.empty? ? line : "#{line} # #{notes.join('; ')}"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Drafting from columns alone, a sensitive column is left out of the
|
|
859
|
+
# fields — nothing says any client sends it — and named here instead.
|
|
860
|
+
# The lock_version note says where to declare it in the rules actually
|
|
861
|
+
# drafted: stale-update detection is an update's concern, so a split
|
|
862
|
+
# :create rule points at the :update rule rather than at itself.
|
|
863
|
+
def omitted_note(column)
|
|
864
|
+
return STI_OMITTED if column.sensitive == :sti
|
|
865
|
+
|
|
866
|
+
where = column.rule == :create ? "in the :update rule below" : "here"
|
|
867
|
+
"is the optimistic-locking column — Rails increments it on every save; if your edit forms round-trip it " \
|
|
868
|
+
"as a hidden field for stale-update detection, declare `optional #{column.name.to_sym.inspect}, :integer` #{where}"
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
STI_OMITTED = "is the STI inheritance column — assigning it changes the record's class, so no client should " \
|
|
872
|
+
"send it; if clients really pick the subclass, declare it with in: the allowed class names".freeze
|
|
873
|
+
|
|
874
|
+
# The TODOs a drafted column line carries. A sensitive column the permit
|
|
875
|
+
# call lists stays a field — omitting lock_version there would silently
|
|
876
|
+
# switch off the stale-update detection the app wired up, the moment the
|
|
877
|
+
# draft is enforced — but says why it deserves a second look.
|
|
878
|
+
def column_todos(column)
|
|
879
|
+
todos = []
|
|
880
|
+
todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
|
|
881
|
+
todos << enum_todo(column) if column.enum_integers
|
|
882
|
+
todos << unread_todo(column) if column.unread
|
|
883
|
+
todos
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
SCANNED_SENSITIVE = {
|
|
887
|
+
locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
|
|
888
|
+
"round-trips it is how Rails detects a stale update; never give it a default:",
|
|
889
|
+
sti: "is the STI inheritance column — kept because the permit call lists it, but assigning it changes the " \
|
|
890
|
+
"record's class: restrict it with in: the subclass names a client may pick"
|
|
891
|
+
}.freeze
|
|
892
|
+
|
|
893
|
+
# Rails assigns an enum its stored integer too (`status: 1` from a JSON
|
|
894
|
+
# client), but a :string field passes that on as "1" — which in: rejects,
|
|
895
|
+
# and which Rails' enum rejects as well, so admitting it also means mapping
|
|
896
|
+
# it back to its key. Left as a TODO rather than drafted: forms, the
|
|
897
|
+
# common client, send the key. Only for an enum that stores Integers — a
|
|
898
|
+
# string-backed one has no second spelling to admit.
|
|
899
|
+
def enum_todo(column)
|
|
900
|
+
"TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
|
|
901
|
+
"#{column.enum}.values.map(&:to_s) to in: and map them back to keys with transform:"
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def unread_todo(column)
|
|
905
|
+
"TODO: could not read what the model adds to #{column.name} (#{column.unread}) — check its enum and " \
|
|
906
|
+
"default by hand"
|
|
205
907
|
end
|
|
206
908
|
|
|
207
909
|
# NOT NULL without a database default is the only case a client truly
|
|
@@ -213,37 +915,106 @@ module Permittable
|
|
|
213
915
|
return false if column.null
|
|
214
916
|
return false unless column.default.nil?
|
|
215
917
|
|
|
216
|
-
column.
|
|
918
|
+
!column.default_function
|
|
217
919
|
end
|
|
218
920
|
|
|
921
|
+
# Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
|
|
922
|
+
# string-keyed `permit("2fa")` scans to a name that is not a bare symbol
|
|
923
|
+
# literal, and `:2fa` would make the whole draft a SyntaxError.
|
|
219
924
|
def scanned_lines(scan, columns)
|
|
220
925
|
lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
|
|
221
926
|
lines += scan.arrays.map do |name|
|
|
222
|
-
"array
|
|
927
|
+
"array #{name.to_sym.inspect}, of: :string " \
|
|
928
|
+
"# TODO: confirm the element type, and declare length: — an array without one is unbounded"
|
|
223
929
|
end
|
|
224
930
|
scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
|
|
225
|
-
|
|
931
|
+
scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
|
|
932
|
+
lines + scan_todo_lines(scan)
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# Each TODO says why the scan did not draft it: a parsed argument that
|
|
936
|
+
# belongs outside this contract is not one the parser failed to read.
|
|
937
|
+
def scan_todo_lines(scan)
|
|
938
|
+
# Array()/to_h: a Scan built by hand before these members existed leaves them nil.
|
|
939
|
+
Array(scan.conflicts).map { |conflict| "# TODO: #{conflict}" } +
|
|
940
|
+
scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" } +
|
|
941
|
+
scan.other_envelopes.to_h.flat_map do |root, spellings|
|
|
942
|
+
spellings.map { |spelling| "# TODO: belongs to another envelope (#{root}): #{spelling}" }
|
|
943
|
+
end +
|
|
944
|
+
Array(scan.route_params).map { |arg| route_param_todo(scan, arg) } +
|
|
945
|
+
Array(scan.rootless).map { |arg| "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" }
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Only a bare key is a route or query param; an array or hash an expect
|
|
949
|
+
# call spells beside its envelope is body input this root cannot reach.
|
|
950
|
+
# A rootless call's keys (`rootless`) get the neutral wording whatever
|
|
951
|
+
# their shape: beside an envelope they may be a filter or a whole body.
|
|
952
|
+
def route_param_todo(scan, arg)
|
|
953
|
+
return "# TODO: route or query param, not a body field: #{arg}" if scalar_key(arg)
|
|
954
|
+
return "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" if scan.root
|
|
955
|
+
|
|
956
|
+
"# TODO: sent beside another envelope, so not in this contract: #{arg}"
|
|
226
957
|
end
|
|
227
958
|
|
|
228
959
|
def scanned_scalar_line(name, columns)
|
|
229
960
|
column = columns && columns[name.to_s]
|
|
230
961
|
return column_line(column) if column
|
|
231
|
-
return "optional :#{name}, :string, virtual: true # TODO: not a database column — confirm the type" if columns
|
|
232
962
|
|
|
233
|
-
"optional
|
|
963
|
+
field = "optional #{name.to_sym.inspect}, :string"
|
|
964
|
+
return "#{field}, virtual: true # TODO: not a database column — confirm the type" if columns
|
|
965
|
+
|
|
966
|
+
"#{field} # TODO: confirm the type"
|
|
234
967
|
end
|
|
235
968
|
|
|
236
969
|
def nested_lines(name, keys)
|
|
237
|
-
["optional
|
|
238
|
-
|
|
970
|
+
["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
|
|
971
|
+
"if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
|
|
972
|
+
sub_field_lines(keys) +
|
|
239
973
|
["end"]
|
|
240
974
|
end
|
|
241
975
|
|
|
976
|
+
# No TODO on the kind here, unlike nested_lines: `params.expect` spells an
|
|
977
|
+
# array of hashes `#{name}: [[...]]`, which says definitively what the
|
|
978
|
+
# equivalent permit call (`#{name}: [...]`) leaves ambiguous.
|
|
979
|
+
def nested_array_lines(name, keys)
|
|
980
|
+
["array #{name.to_sym.inspect} do"] + sub_field_lines(keys) + ["end"]
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def sub_field_lines(keys)
|
|
984
|
+
keys.map { |key| " optional #{key.to_sym.inspect}, :string # TODO: confirm the type" }
|
|
985
|
+
end
|
|
986
|
+
|
|
242
987
|
HEADER = "# Drafted by permittable:generate — review the TODOs, then deploy: monitor\n" \
|
|
243
988
|
"# mode reports violations (instrumentation + log) without rejecting requests.\n".freeze
|
|
244
989
|
|
|
245
|
-
|
|
246
|
-
|
|
990
|
+
UPDATE_NOTE = "# :update has nothing required — a PATCH sends only the fields it changes.\n".freeze
|
|
991
|
+
|
|
992
|
+
# One rule, unless a column made something `required`: that is true of a
|
|
993
|
+
# create, but an update carrying only the edited field would be rejected
|
|
994
|
+
# for everything it left out. So the update gets its own rule — the same
|
|
995
|
+
# fields, every one optional.
|
|
996
|
+
#
|
|
997
|
+
# `lines` drafts the body for one rule (see DraftColumn#in_rule), so
|
|
998
|
+
# each rule is drafted rather than edited from another's text. The
|
|
999
|
+
# single-rule body and the :update body differ exactly when some column
|
|
1000
|
+
# was drafted `required`, which is the test for splitting.
|
|
1001
|
+
#
|
|
1002
|
+
# Every drafted line is a declaration or a `# TODO` comment. A body of
|
|
1003
|
+
# comments only — a model whose sole columns are `type` and
|
|
1004
|
+
# `lock_version`, or have no contract type — would be a rule declaring
|
|
1005
|
+
# no field, which raises `a contract must declare at least one field`
|
|
1006
|
+
# when pasted. There is then nothing loadable to draft: nil, as for no
|
|
1007
|
+
# knowledge at all, which the rake task already skips.
|
|
1008
|
+
def render(root:, model:, &lines)
|
|
1009
|
+
shared = lines.call(:shared)
|
|
1010
|
+
return nil if shared.all? { |line| line.start_with?("#") }
|
|
1011
|
+
|
|
1012
|
+
update = lines.call(:update)
|
|
1013
|
+
rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
|
|
1014
|
+
bodies = rules.map do |actions, body|
|
|
1015
|
+
"#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
|
|
1016
|
+
end
|
|
1017
|
+
HEADER + bodies.join("\n#{UPDATE_NOTE}")
|
|
247
1018
|
end
|
|
248
1019
|
end
|
|
249
1020
|
end
|