permittable 0.7.0 → 0.9.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 +173 -0
- data/README.md +400 -39
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +47 -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 +827 -47
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +226 -45
- data/lib/permittable/open_api.rb +279 -39
- 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 +1135 -118
- metadata +9 -4
data/lib/permittable/open_api.rb
CHANGED
|
@@ -11,11 +11,38 @@ module Permittable
|
|
|
11
11
|
# Everything the exporter cannot know is left visible rather than guessed:
|
|
12
12
|
# actions covered only by a catch-all rule on a host without
|
|
13
13
|
# `action_methods` appear under the "*" key with `x-permittable-catch-all`,
|
|
14
|
-
# and operations with no matching route
|
|
15
|
-
#
|
|
14
|
+
# and operations with no matching route — or whose path+verb slot another
|
|
15
|
+
# controller already claimed, which a document cannot represent twice —
|
|
16
|
+
# land in `x-permittable-controllers` instead of being dropped silently.
|
|
16
17
|
module OpenAPI
|
|
17
18
|
module_function
|
|
18
19
|
|
|
20
|
+
# One violation, in whichever shape the app renders — the same
|
|
21
|
+
# { param:, code: } entry rides in the envelope's `details` and the
|
|
22
|
+
# problem document's `errors`.
|
|
23
|
+
VIOLATION_SCHEMA = {
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"properties" => {
|
|
26
|
+
"param" => {
|
|
27
|
+
"type" => "string",
|
|
28
|
+
"description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
|
|
29
|
+
},
|
|
30
|
+
"code" => {
|
|
31
|
+
"type" => "string",
|
|
32
|
+
"description" => "missing / invalid_type / inclusion / format / length / depth / unknown / " \
|
|
33
|
+
"invalid, or a contract-specific symbol"
|
|
34
|
+
},
|
|
35
|
+
# Present only when the field declares `message:` or the app has
|
|
36
|
+
# I18n copy for the code; a violation without one keeps the bare
|
|
37
|
+
# { param:, code: } shape, so this is not required.
|
|
38
|
+
"message" => {
|
|
39
|
+
"type" => "string",
|
|
40
|
+
"description" => "Human-readable copy for this violation, when the contract or I18n supplies it"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"required" => %w[param code]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
19
46
|
# The error envelope rendered by render_invalid_parameters (see
|
|
20
47
|
# ErrorEnvelope): code/details are present on every violation this gem
|
|
21
48
|
# raises, message always.
|
|
@@ -28,24 +55,7 @@ module Permittable
|
|
|
28
55
|
"properties" => {
|
|
29
56
|
"message" => { "type" => "string" },
|
|
30
57
|
"code" => { "type" => "string", "enum" => ["invalid_parameters"] },
|
|
31
|
-
"details" => {
|
|
32
|
-
"type" => "array",
|
|
33
|
-
"items" => {
|
|
34
|
-
"type" => "object",
|
|
35
|
-
"properties" => {
|
|
36
|
-
"param" => {
|
|
37
|
-
"type" => "string",
|
|
38
|
-
"description" => "Fully-qualified parameter path, e.g. user.address.zip or line_items[1].sku"
|
|
39
|
-
},
|
|
40
|
-
"code" => {
|
|
41
|
-
"type" => "string",
|
|
42
|
-
"description" => "missing / invalid_type / inclusion / format / length / unknown / invalid, " \
|
|
43
|
-
"or a contract-specific symbol"
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
"required" => %w[param code]
|
|
47
|
-
}
|
|
48
|
-
}
|
|
58
|
+
"details" => { "type" => "array", "items" => VIOLATION_SCHEMA }
|
|
49
59
|
},
|
|
50
60
|
"required" => %w[message]
|
|
51
61
|
}
|
|
@@ -53,6 +63,25 @@ module Permittable
|
|
|
53
63
|
"required" => %w[success error]
|
|
54
64
|
}.freeze
|
|
55
65
|
|
|
66
|
+
# RFC 9457 Problem Details, the shape rendered when
|
|
67
|
+
# `Permittable.error_format = :problem`. `type` and `instance` are
|
|
68
|
+
# URI-references; `errors` is the field-violation extension member.
|
|
69
|
+
PROBLEM_SCHEMA = {
|
|
70
|
+
"type" => "object",
|
|
71
|
+
"properties" => {
|
|
72
|
+
"type" => {
|
|
73
|
+
"type" => "string", "format" => "uri-reference",
|
|
74
|
+
"description" => "Problem type URI — \"about:blank\" unless the app sets Permittable.problem_base_uri"
|
|
75
|
+
},
|
|
76
|
+
"title" => { "type" => "string", "enum" => ["Invalid parameters", "Malformed request"] },
|
|
77
|
+
"status" => { "type" => "integer", "enum" => [400, 422] },
|
|
78
|
+
"detail" => { "type" => "string" },
|
|
79
|
+
"instance" => { "type" => "string", "format" => "uri-reference" },
|
|
80
|
+
"errors" => { "type" => "array", "items" => VIOLATION_SCHEMA }
|
|
81
|
+
},
|
|
82
|
+
"required" => %w[title status]
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
56
85
|
# Instance methods the concern itself adds to every including controller;
|
|
57
86
|
# action_methods reports them as actions (they are public by design), but
|
|
58
87
|
# they are never routed and must not be documented as endpoints. Resolved
|
|
@@ -62,9 +91,15 @@ module Permittable
|
|
|
62
91
|
end
|
|
63
92
|
|
|
64
93
|
# Shared `components` for any document referencing Permittable responses.
|
|
94
|
+
#
|
|
95
|
+
# Unlike a rule's monitor mode — which the exporter reads only from the
|
|
96
|
+
# contract, never from runtime configuration — the error FORMAT has no
|
|
97
|
+
# per-contract declaration to read: it is one app-wide setting, and an
|
|
98
|
+
# export runs inside the app that made it. Reading it is what keeps the
|
|
99
|
+
# documented response shape from drifting from the rendered one.
|
|
65
100
|
def components
|
|
66
101
|
{
|
|
67
|
-
"schemas" => { "PermittableInvalidParameters" =>
|
|
102
|
+
"schemas" => { "PermittableInvalidParameters" => error_schema },
|
|
68
103
|
"responses" => {
|
|
69
104
|
"PermittableBadRequest" => error_response(
|
|
70
105
|
"The root: key is missing or not an object — the request envelope itself is malformed."
|
|
@@ -76,11 +111,23 @@ module Permittable
|
|
|
76
111
|
}
|
|
77
112
|
end
|
|
78
113
|
|
|
114
|
+
def problem_format?
|
|
115
|
+
Permittable.error_format == :problem
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def error_schema
|
|
119
|
+
problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def error_media_type
|
|
123
|
+
problem_format? ? ErrorEnvelope::PROBLEM_MEDIA_TYPE : "application/json"
|
|
124
|
+
end
|
|
125
|
+
|
|
79
126
|
def error_response(description)
|
|
80
127
|
{
|
|
81
128
|
"description" => description,
|
|
82
129
|
"content" => {
|
|
83
|
-
|
|
130
|
+
error_media_type => {
|
|
84
131
|
"schema" => { "$ref" => "#/components/schemas/PermittableInvalidParameters" }
|
|
85
132
|
}
|
|
86
133
|
}
|
|
@@ -157,12 +204,17 @@ module Permittable
|
|
|
157
204
|
def document(controllers:, info: {}, routes: nil)
|
|
158
205
|
paths = {}
|
|
159
206
|
unrouted = {}
|
|
160
|
-
|
|
207
|
+
slots = []
|
|
208
|
+
targets = route_targets(routes)
|
|
209
|
+
# A class listed twice would find every slot its first pass claimed and
|
|
210
|
+
# land under x-permittable-controllers, colliding with itself there.
|
|
211
|
+
controllers.uniq(&:object_id).each do |controller|
|
|
161
212
|
operations = operations_for(controller)
|
|
162
213
|
next if operations.empty?
|
|
163
214
|
|
|
164
|
-
place_operations(controller, operations,
|
|
215
|
+
slots.concat(place_operations(controller, operations, targets, paths, unrouted))
|
|
165
216
|
end
|
|
217
|
+
assign_unique_operation_ids(slots)
|
|
166
218
|
doc = {
|
|
167
219
|
"openapi" => "3.1.0",
|
|
168
220
|
"info" => { "title" => "Permittable contracts", "version" => VERSION }.merge(info),
|
|
@@ -173,38 +225,226 @@ module Permittable
|
|
|
173
225
|
doc
|
|
174
226
|
end
|
|
175
227
|
|
|
176
|
-
|
|
228
|
+
# Where one operation sits in the document: a path+verb slot under
|
|
229
|
+
# `paths`, or an action under x-permittable-controllers (verb nil).
|
|
230
|
+
# `holder[field]` is the placed operation; `id` its natural operationId;
|
|
231
|
+
# `owner` the [controller key, action] it documents.
|
|
232
|
+
OperationSlot = Struct.new(:holder, :field, :verb, :id, :owner)
|
|
233
|
+
|
|
234
|
+
# { [controller, action] => [[path, verb], ...] }, in route order. Built
|
|
235
|
+
# once, with each verb normalised once: matching every operation against
|
|
236
|
+
# every route made placement quadratic in the size of the route set. A
|
|
237
|
+
# route declared twice names one slot, so it is kept once — not counted
|
|
238
|
+
# as an operation colliding with itself.
|
|
239
|
+
def route_targets(routes)
|
|
240
|
+
return {} if routes.nil?
|
|
241
|
+
|
|
242
|
+
routes.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
|
|
243
|
+
.transform_values { |matching| matching.map { |route| [route[:path], verb_of(route)] }.uniq }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Places every operation and returns the slots it filled, in placement
|
|
247
|
+
# order (controller, action, route), for assign_unique_operation_ids.
|
|
248
|
+
def place_operations(controller, operations, targets, paths, unrouted)
|
|
177
249
|
key = controller_key(controller) || controller.inspect
|
|
178
|
-
operations.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
250
|
+
operations.each_with_object([]) do |(action, operation), slots|
|
|
251
|
+
owner = [key, action]
|
|
252
|
+
# A path+verb pair carries exactly one operation, so a slot another
|
|
253
|
+
# controller already claimed is not written over: the loser stays
|
|
254
|
+
# visible under x-permittable-controllers, where an operation with no
|
|
255
|
+
# route at all lands, rather than disappearing from the document.
|
|
256
|
+
free = action == "*" ? [] : targets.fetch(owner, []).reject { |path, verb| paths.dig(path, verb) }
|
|
257
|
+
if free.empty?
|
|
258
|
+
holder = (unrouted[key] ||= {})
|
|
259
|
+
slots << OperationSlot.new(holder, action, nil, operation["operationId"], owner) unless holder.key?(action)
|
|
260
|
+
holder[action] = operation
|
|
182
261
|
else
|
|
183
|
-
|
|
262
|
+
free.each do |path, verb|
|
|
263
|
+
holder = (paths[path] ||= {})
|
|
264
|
+
holder[verb] = with_path_parameters(operation, path)
|
|
265
|
+
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner)
|
|
266
|
+
end
|
|
184
267
|
end
|
|
185
268
|
end
|
|
186
269
|
end
|
|
187
270
|
|
|
188
|
-
|
|
189
|
-
|
|
271
|
+
# OpenAPI requires operationId to be unique across the document, and
|
|
272
|
+
# client generators name a method after it — a duplicate is an invalid
|
|
273
|
+
# document and, in practice, two methods with one name. One operation is
|
|
274
|
+
# placed at every slot its routes reach: the separate PATCH and PUT
|
|
275
|
+
# routes `resources` draws to update, or one `via: [:patch, :put]` route;
|
|
276
|
+
# with the optional-segment expansion, one route's several paths; with
|
|
277
|
+
# `via: :all` routes, which are documented under each verb, five verbs.
|
|
278
|
+
# And `key.tr("/", "_")` folds admin/users and admin_users into one id.
|
|
279
|
+
#
|
|
280
|
+
# Renaming the scheme would rename every generated client method, so an
|
|
281
|
+
# id that is already unique never changes. Every slot's natural id is
|
|
282
|
+
# known before any is renamed and all of them are reserved, so a suffix
|
|
283
|
+
# can never take a name that is another operation's own id — that
|
|
284
|
+
# operation would otherwise be renamed for a collision it never had.
|
|
285
|
+
# Within a colliding group the first slot keeps the plain id. Another
|
|
286
|
+
# takes its verb (users_update_put) when that verb differs from the plain
|
|
287
|
+
# id's and the suffixed id is free; otherwise it is numbered
|
|
288
|
+
# (posts_create_2 for a second POST, users_update_2 for a second PATCH).
|
|
289
|
+
# The suffix names how the slot differs from the plain one, so a verb
|
|
290
|
+
# the two share would say nothing true.
|
|
291
|
+
#
|
|
292
|
+
# Unrouted operations take part: x-permittable-controllers is in the same
|
|
293
|
+
# document and feeds the same generators. They yield the plain id to a
|
|
294
|
+
# routed operation, though, since `paths` is what a client calls.
|
|
295
|
+
def assign_unique_operation_ids(slots)
|
|
296
|
+
slots = slots.select(&:id)
|
|
297
|
+
taken = slots.to_set(&:id)
|
|
298
|
+
next_number = Hash.new(2)
|
|
299
|
+
slots.group_by(&:id).each_value do |group|
|
|
300
|
+
next if group.one?
|
|
190
301
|
|
|
191
|
-
|
|
302
|
+
plain, *renamed = collision_order(group)
|
|
303
|
+
renamed.each do |slot|
|
|
304
|
+
by_verb = "#{slot.id}_#{slot.verb}"
|
|
305
|
+
by_verb = nil if slot.verb.nil? || slot.verb == plain.verb || taken.include?(by_verb)
|
|
306
|
+
unique = by_verb || numbered_id(slot.id, taken, next_number)
|
|
307
|
+
taken << unique
|
|
308
|
+
# The same operation object may sit at other slots under its own
|
|
309
|
+
# id, so the rename goes on a copy; merge keeps the key order.
|
|
310
|
+
slot.holder[slot.field] = slot.holder[slot.field].merge("operationId" => unique)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
192
313
|
end
|
|
193
314
|
|
|
315
|
+
# Routed before unrouted, then placement order: controller, action, route.
|
|
316
|
+
# Within one operation a PUT sorts after its PATCH, because
|
|
317
|
+
# `match via: [:put, :patch]` lists PUT first where `resources` lists
|
|
318
|
+
# PATCH first, and without this one pair of routes would name the PATCH
|
|
319
|
+
# method two ways. Across operations the order alone decides: a PATCH in
|
|
320
|
+
# a later controller does not take the plain id from an earlier PUT.
|
|
321
|
+
def collision_order(group)
|
|
322
|
+
patched = group.select { |slot| slot.verb == "patch" }.to_set(&:owner)
|
|
323
|
+
first_at = {}
|
|
324
|
+
group.each_with_index { |slot, index| first_at[slot.owner] ||= index }
|
|
325
|
+
group.each_with_index.sort_by do |slot, index|
|
|
326
|
+
put_after_patch = slot.verb == "put" && patched.include?(slot.owner) ? 1 : 0
|
|
327
|
+
[slot.verb ? 0 : 1, first_at[slot.owner], put_after_patch, index]
|
|
328
|
+
end.map(&:first)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The next free "#{id}_n", n from 2. The counter per id means no number
|
|
332
|
+
# is tried twice for one id, which keeps the pass linear.
|
|
333
|
+
def numbered_id(id, taken, next_number)
|
|
334
|
+
number = next_number[id]
|
|
335
|
+
number += 1 while taken.include?("#{id}_#{number}")
|
|
336
|
+
next_number[id] = number + 1
|
|
337
|
+
"#{id}_#{number}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def verb_of(route)
|
|
341
|
+
route[:verb].to_s.downcase
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# OpenAPI 3.1 requires every variable in a path template to be declared as
|
|
345
|
+
# a path parameter — a document templating {id} without declaring it is
|
|
346
|
+
# invalid, which every member route produced. The route set does not say
|
|
347
|
+
# what an :id is and the exporter does not guess: a path segment arrives as
|
|
348
|
+
# a string, so that is what it is documented as.
|
|
349
|
+
def with_path_parameters(operation, path)
|
|
350
|
+
variables = path.scan(/\{(\w+)\}/).flatten
|
|
351
|
+
return operation if variables.empty?
|
|
352
|
+
|
|
353
|
+
parameters = variables.map do |name|
|
|
354
|
+
{ "name" => name, "in" => "path", "required" => true, "schema" => { "type" => "string" } }
|
|
355
|
+
end
|
|
356
|
+
# Inserted ahead of requestBody, where a reader of the document expects
|
|
357
|
+
# it; emission stays deterministic either way.
|
|
358
|
+
operation.each_with_object({}) do |(key, value), out|
|
|
359
|
+
out["parameters"] = parameters if key == "requestBody"
|
|
360
|
+
out[key] = value
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# What a `via: :all` route answers, in the "|"-joined form Journey uses
|
|
365
|
+
# for a route with several verbs.
|
|
366
|
+
ALL_VERBS = "GET|POST|PUT|PATCH|DELETE".freeze
|
|
367
|
+
|
|
194
368
|
# { controller:, action:, verb:, path: } descriptors from a Rails
|
|
195
369
|
# application's route set. Duck-typed against Journey routes (each one
|
|
196
370
|
# responds to requirements / verb / path.spec) so it stays unit-testable
|
|
197
|
-
# without Rails; Rails path params
|
|
371
|
+
# without Rails; Rails path params become OpenAPI templates — both the
|
|
372
|
+
# `:id` form and the `*rest` wildcard, which is a real route shape
|
|
373
|
+
# (`get "files/*path"`) and is not a valid OpenAPI template left as-is.
|
|
374
|
+
#
|
|
375
|
+
# An optional group is expanded into the concrete paths it stands for:
|
|
376
|
+
# parentheses are not valid in an OpenAPI path template, so leaving
|
|
377
|
+
# `scope "(:locale)"` as `(/{locale})/posts` made the whole document fail
|
|
378
|
+
# validation. Each variant is its own descriptor, so each path's variables
|
|
379
|
+
# are required there — which, for that path, they are.
|
|
380
|
+
#
|
|
381
|
+
# Each descriptor also carries `route:`, the index of the route it came
|
|
382
|
+
# from, so a reader counting routes rather than paths (Audit.summary) can
|
|
383
|
+
# tell one route's expanded variants from a second route that happens to
|
|
384
|
+
# reach the same action. The exporter ignores it.
|
|
198
385
|
def rails_routes(app)
|
|
199
|
-
app.routes.routes.
|
|
386
|
+
descriptors = app.routes.routes.each_with_index.flat_map do |route, index|
|
|
200
387
|
requirements = route.requirements
|
|
201
388
|
verb = route.verb.to_s
|
|
202
|
-
next if requirements[:controller].nil? || requirements[:action].nil?
|
|
389
|
+
next [] if requirements[:controller].nil? || requirements[:action].nil?
|
|
390
|
+
|
|
391
|
+
# `match ..., via: :all` leaves the verb EMPTY rather than listing
|
|
392
|
+
# them, and skipping it hid an action that takes POST bodies from the
|
|
393
|
+
# audit entirely. Expand it into the verbs it actually answers.
|
|
394
|
+
verb = ALL_VERBS if verb.empty?
|
|
203
395
|
|
|
204
|
-
path = route.path.spec.to_s.sub("(.:format)", "").gsub(
|
|
205
|
-
|
|
206
|
-
|
|
396
|
+
path = route.path.spec.to_s.sub("(.:format)", "").gsub(/[:*](\w+)/) { "{#{Regexp.last_match(1)}}" }
|
|
397
|
+
# One route can answer several verbs (`match via: [:patch, :put]`, and
|
|
398
|
+
# the PATCH|PUT pair resources generates); documenting only the first
|
|
399
|
+
# dropped the others from the export entirely.
|
|
400
|
+
verb.split("|").map do |single|
|
|
401
|
+
{ controller: requirements[:controller], action: requirements[:action],
|
|
402
|
+
verb: single.downcase, path: path, route: index }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
descriptors.flat_map do |descriptor|
|
|
406
|
+
optional_variants(descriptor[:path]).map { |variant| descriptor.merge(path: variant) }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Every concrete path an optionally-grouped template stands for:
|
|
411
|
+
# `/archive(/{year}(/{month}))` → `/archive`, `/archive/{year}`,
|
|
412
|
+
# `/archive/{year}/{month}`. Rails fills groups left to right, so
|
|
413
|
+
# `/x(/{a})(/{b})` with one segment present is always `/x/{a}` — the
|
|
414
|
+
# `/x/{b}` variant is the same URL under another name, and OpenAPI forbids
|
|
415
|
+
# two templates differing only in variable names. Variants are built with
|
|
416
|
+
# each group present first, so the one Rails would match is the one kept;
|
|
417
|
+
# the list is then reversed, which puts every group's ABSENT variant
|
|
418
|
+
# first at every nesting level. That order matters to the operationId
|
|
419
|
+
# dedupe (assign_unique_operation_ids, which numbers colliding ids in
|
|
420
|
+
# route order): the variants of one route share an operation, and it is
|
|
421
|
+
# that dedupe, not this expansion, that makes their ids unique. Absent
|
|
422
|
+
# first means `/posts` keeps `posts_create` and `/{locale}/posts` takes
|
|
423
|
+
# the suffix.
|
|
424
|
+
def optional_variants(path)
|
|
425
|
+
variants, = expand_optional_groups(path, 0)
|
|
426
|
+
variants.map { |variant| variant.empty? ? "/" : variant }
|
|
427
|
+
.uniq { |variant| variant.gsub(/\{\w+\}/, "{}") }
|
|
428
|
+
.reverse
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Walks the template from `pos` to the matching `)` (or the end),
|
|
432
|
+
# returning the variants of that stretch and the position after it.
|
|
433
|
+
def expand_optional_groups(path, pos)
|
|
434
|
+
variants = [+""]
|
|
435
|
+
while pos < path.length
|
|
436
|
+
char = path[pos]
|
|
437
|
+
if char == "("
|
|
438
|
+
inner, pos = expand_optional_groups(path, pos + 1)
|
|
439
|
+
variants = variants.product(inner + [""]).map(&:join)
|
|
440
|
+
elsif char == ")"
|
|
441
|
+
return [variants, pos + 1]
|
|
442
|
+
else
|
|
443
|
+
variants.each { |variant| variant << char }
|
|
444
|
+
pos += 1
|
|
445
|
+
end
|
|
207
446
|
end
|
|
447
|
+
[variants, pos]
|
|
208
448
|
end
|
|
209
449
|
|
|
210
450
|
def controller_key(controller)
|
data/lib/permittable/railtie.rb
CHANGED