permittable 0.11.0 → 0.12.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 +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- metadata +2 -2
|
@@ -16,22 +16,31 @@ module Permittable
|
|
|
16
16
|
# the same as `{}`. JSON Schema cannot say that — `type: integer` reads
|
|
17
17
|
# null as a present value of the wrong type — so the schema rejects a
|
|
18
18
|
# null the server would accept and ignore.
|
|
19
|
-
# * `validate:`/`transform:`/`finalize` are opaque callables
|
|
20
|
-
#
|
|
21
|
-
# with `x-permittable-*`
|
|
19
|
+
# * `validate:`/`transform:`/`finalize` are opaque callables the schema
|
|
20
|
+
# cannot run, so the field carrying one (or, for `finalize`, the rule's
|
|
21
|
+
# own object) is flagged with an `x-permittable-*` extension rather than
|
|
22
|
+
# mistranslated. `transform:` only reshapes a value already accepted, so
|
|
23
|
+
# it changes no verdict; `validate:` and `finalize` can refuse a value.
|
|
22
24
|
#
|
|
23
|
-
#
|
|
25
|
+
# The first two leave the schema STRICTER than the server, never looser: a
|
|
24
26
|
# client that validates against the published document is conservative,
|
|
25
|
-
# never surprised by a 422.
|
|
27
|
+
# never surprised by a 422. The third does not: `validate:` and `finalize`
|
|
28
|
+
# leave it LOOSER (below). spec/schema_conformance_spec.rb holds the line,
|
|
26
29
|
# asserting the direction of every divergence it permits.
|
|
27
30
|
#
|
|
28
31
|
# Where a rule has no JSON Schema keyword, the schema is LOOSER instead,
|
|
29
32
|
# and a client following it can still earn a 422. Each such rule stays
|
|
30
|
-
# visible on its own field,
|
|
31
|
-
# rather than letting it
|
|
33
|
+
# visible in the schema (on its own field, or for `finalize` on the rule's
|
|
34
|
+
# object), and the conformance spec labels each one rather than letting it
|
|
35
|
+
# pass as agreement:
|
|
32
36
|
# * a `:json` field's `max_depth:` — `x-permittable-max-depth`;
|
|
33
37
|
# * a Range of non-numbers (`in: "a".."m"`) — `x-permittable-range`;
|
|
34
38
|
# * a `validate:` proc — `x-permittable-custom-validation`;
|
|
39
|
+
# * a `finalize` block, whose `violate!` cross-field checks no field's
|
|
40
|
+
# schema can carry — `x-permittable-finalize` on the rule's object;
|
|
41
|
+
# * a `sensitive:` field's `in:` and `format:`, withheld because they
|
|
42
|
+
# would publish the secret itself — the same marker (see
|
|
43
|
+
# withholds_constraints?);
|
|
35
44
|
# * a `normalize:` step, which the server runs BEFORE it checks, so
|
|
36
45
|
# " " passes a minLength of 3 and is then absent —
|
|
37
46
|
# `x-permittable-normalize`;
|
|
@@ -66,8 +75,16 @@ module Permittable
|
|
|
66
75
|
# `root:` envelope when the rule declares one. The wrapper itself stays
|
|
67
76
|
# permissive even under `unknown: :error` — the runtime never inspects
|
|
68
77
|
# sibling keys outside the root.
|
|
78
|
+
#
|
|
79
|
+
# `finalize` is the one opaque callable that belongs to the rule, not to
|
|
80
|
+
# a field: it runs once every field has passed and can still refuse the
|
|
81
|
+
# payload (`ends_on` before `starts_on`), which no per-field keyword can
|
|
82
|
+
# say. The flag therefore rides on the rule's own object, inside the
|
|
83
|
+
# `root:` wrapper where the fields are, so a client or generator that
|
|
84
|
+
# reads the document knows the schema alone is not the whole verdict.
|
|
69
85
|
def rule(permit_rule)
|
|
70
86
|
schema = object(permit_rule[:fields], unknown: permit_rule[:unknown])
|
|
87
|
+
schema["x-permittable-finalize"] = true if permit_rule[:finalize]
|
|
71
88
|
return schema unless permit_rule[:root]
|
|
72
89
|
|
|
73
90
|
root = permit_rule[:root].to_s
|
|
@@ -125,12 +142,31 @@ module Permittable
|
|
|
125
142
|
def scalar_schema(field)
|
|
126
143
|
schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
|
|
127
144
|
apply_format_name!(schema, field)
|
|
128
|
-
|
|
145
|
+
withheld = withholds_constraints?(field)
|
|
146
|
+
apply_in!(schema, field) unless withheld
|
|
129
147
|
apply_string_bounds!(schema, field)
|
|
130
|
-
apply_pattern!(schema, field[:format])
|
|
148
|
+
apply_pattern!(schema, field[:format]) unless withheld
|
|
149
|
+
schema["x-permittable-custom-validation"] = true if withheld
|
|
131
150
|
schema
|
|
132
151
|
end
|
|
133
152
|
|
|
153
|
+
# A `sensitive:` field's `in:` list/Range and hand-written `format:` ARE
|
|
154
|
+
# the secret: an enum of the only accepted PINs is an allowlist, and
|
|
155
|
+
# `pattern: "^FMT_SECRET$"` or a 4000..4999 range names or narrows the
|
|
156
|
+
# value the same way, so publishing them would defeat the redaction as
|
|
157
|
+
# surely as publishing a `default:` (see annotate). They are left out,
|
|
158
|
+
# with `x-permittable-custom-validation` as the only signal, which makes
|
|
159
|
+
# the exported schema LOOSER than the server for that field. A
|
|
160
|
+
# `format:` PRESET is the exception: it names a public rule, not a
|
|
161
|
+
# secret, so it keeps its `format` and `pattern`. `field[:sensitive]` is
|
|
162
|
+
# already resolved onto a cascaded child, so a child counts. Only a field
|
|
163
|
+
# that actually declares one of the two is flagged.
|
|
164
|
+
def withholds_constraints?(field)
|
|
165
|
+
return false unless field[:sensitive]
|
|
166
|
+
|
|
167
|
+
!field[:in].nil? || (!field[:format].nil? && field[:format_name].nil?)
|
|
168
|
+
end
|
|
169
|
+
|
|
134
170
|
# A `:json` field's shape is deliberately undeclared, so the schema says
|
|
135
171
|
# "an object" and carries only the bounds the field does declare. JSON
|
|
136
172
|
# Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
|
|
@@ -293,17 +329,39 @@ module Permittable
|
|
|
293
329
|
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
294
330
|
end
|
|
295
331
|
|
|
332
|
+
# Every field whose schema admits a string gets its bounds here: :string,
|
|
333
|
+
# and the string encodings of :decimal, :date and :datetime. minLength
|
|
334
|
+
# and maxLength only ever constrain the string branch, so the number
|
|
335
|
+
# branch of a :decimal is untouched. `length:` itself is :string-only.
|
|
296
336
|
def apply_string_bounds!(schema, field)
|
|
297
|
-
return unless
|
|
337
|
+
return unless Array(schema["type"]).include?("string")
|
|
298
338
|
|
|
299
339
|
min, max = length_bounds(field[:length])
|
|
300
|
-
|
|
301
|
-
# string can never validly be empty — the schema says so.
|
|
302
|
-
min = 1 if field[:required] && min.to_i < 1
|
|
340
|
+
min = 1 if blank_floor?(field) && min.to_i < 1
|
|
303
341
|
schema["minLength"] = min if min
|
|
304
342
|
schema["maxLength"] = max if max
|
|
305
343
|
end
|
|
306
344
|
|
|
345
|
+
# "" is ABSENT whatever the type, and an absent required field violates,
|
|
346
|
+
# so a required field can never validly be "" — the schema says so. For
|
|
347
|
+
# :date, :datetime and :decimal that is the only thing the schema can say
|
|
348
|
+
# about a blank: `format` is an annotation, and "decimal" is no format a
|
|
349
|
+
# validator knows. (A whitespace-only string needs no floor there: those
|
|
350
|
+
# types refuse it as invalid_type, and the schema never claims to catch a
|
|
351
|
+
# cast failure. For :date and :datetime `format` at least names the
|
|
352
|
+
# failure to a validator that asserts it; for :decimal nothing does, so
|
|
353
|
+
# " " passes the schema and earns a 422. That is a documented looser
|
|
354
|
+
# case, the same one as "abc" for a :decimal, and deliberately has no
|
|
355
|
+
# `pattern` to close it.)
|
|
356
|
+
#
|
|
357
|
+
# A nullable field is the exception: it reads "" as an explicit null, a
|
|
358
|
+
# value rather than an absence, so a floor would reject what the server
|
|
359
|
+
# takes. A nullable :string keeps the floor it has always had (stricter
|
|
360
|
+
# than the server, the safe direction); the other types start without.
|
|
361
|
+
def blank_floor?(field)
|
|
362
|
+
field[:required] && (field[:type] == :string || !field[:nullable])
|
|
363
|
+
end
|
|
364
|
+
|
|
307
365
|
def apply_pattern!(schema, regexp)
|
|
308
366
|
return unless regexp
|
|
309
367
|
|
|
@@ -333,16 +391,28 @@ module Permittable
|
|
|
333
391
|
# length: reasons about characters on strings and element count on
|
|
334
392
|
# arrays; either way it is an exact Integer or a Range (possibly endless
|
|
335
393
|
# / beginless, possibly exclusive).
|
|
394
|
+
# An infinite end (`1..Float::INFINITY`) is no bound, as for numeric
|
|
395
|
+
# `in:` — and JSON has no Infinity, so exporting it raised.
|
|
336
396
|
def length_bounds(spec)
|
|
337
397
|
case spec
|
|
338
398
|
when Integer then [spec, spec]
|
|
339
399
|
when Range
|
|
340
|
-
|
|
341
|
-
|
|
400
|
+
min = finite_length(spec.begin)
|
|
401
|
+
max = finite_length(spec.end)
|
|
402
|
+
max -= 1 if max && spec.exclude_end?
|
|
403
|
+
[min, max]
|
|
342
404
|
else [nil, nil]
|
|
343
405
|
end
|
|
344
406
|
end
|
|
345
407
|
|
|
408
|
+
# A whole-number Float/Rational end (`1..10.0`) is published as the
|
|
409
|
+
# Integer it means: minLength and friends are counts.
|
|
410
|
+
def finite_length(value)
|
|
411
|
+
return nil if value.nil? || (value.is_a?(Float) && value.infinite?)
|
|
412
|
+
|
|
413
|
+
value.to_i
|
|
414
|
+
end
|
|
415
|
+
|
|
346
416
|
# Documentation keys shared by every field kind. `default:`/`example:`
|
|
347
417
|
# are stored as the contract casts them (Date, Time, BigDecimal), so they
|
|
348
418
|
# are re-encoded as JSON scalars.
|
|
@@ -365,13 +435,18 @@ module Permittable
|
|
|
365
435
|
# untranslatable or opaque fact in this file is handled: left out, with
|
|
366
436
|
# the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
|
|
367
437
|
# sensitive`) as the only signal that something is missing.
|
|
438
|
+
#
|
|
439
|
+
# The same goes for an array whose elements hold a sensitive field at
|
|
440
|
+
# any depth (see holds_sensitive?): its `default:`/`example:` carries
|
|
441
|
+
# that field's value, so it is omitted too.
|
|
368
442
|
def annotate(schema, field)
|
|
369
443
|
decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
|
|
370
|
-
unless field
|
|
444
|
+
unless holds_sensitive?(field)
|
|
371
445
|
schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
|
|
372
446
|
schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
|
|
373
447
|
end
|
|
374
|
-
|
|
448
|
+
# dup: the stored desc is frozen; the export is editable and detached.
|
|
449
|
+
schema["description"] = field[:desc].dup if field[:desc]
|
|
375
450
|
if field[:sensitive]
|
|
376
451
|
schema["writeOnly"] = true
|
|
377
452
|
schema["x-permittable-sensitive"] = true
|
|
@@ -382,6 +457,30 @@ module Permittable
|
|
|
382
457
|
schema
|
|
383
458
|
end
|
|
384
459
|
|
|
460
|
+
# Is this field sensitive, or does it contain a sensitive field at any
|
|
461
|
+
# depth? A container's `default:`/`example:` is a whole value, so a
|
|
462
|
+
# sensitive sub-field's value is inside it. `array :cards, default:
|
|
463
|
+
# [{}]` with `optional :pan, sensitive: true, default: "4111..."` even
|
|
464
|
+
# stores `[{"pan" => "4111..."}]`, because the walker fills in each
|
|
465
|
+
# element's sub-field defaults (see AuthoredValues). Omitting pan's own
|
|
466
|
+
# default protected nothing while the array still published the value.
|
|
467
|
+
#
|
|
468
|
+
# The whole container value is omitted. Redacting only the sensitive
|
|
469
|
+
# keys inside it was the alternative, and it would mislead. Without the
|
|
470
|
+
# key, `[{}]` documents a default the server never hands out, since it
|
|
471
|
+
# fills pan in. An example without its required secret fails the very
|
|
472
|
+
# items schema it is attached to. A placeholder can fail the
|
|
473
|
+
# sub-field's own format or in:. The field's own default is omitted
|
|
474
|
+
# rather than replaced for the same reasons.
|
|
475
|
+
#
|
|
476
|
+
# The answer is read off the declaration, not the value, so even
|
|
477
|
+
# `default: []` is withheld. A value stored as authored (beside a
|
|
478
|
+
# `transform:`) can key its hashes by String or Symbol, and a check
|
|
479
|
+
# that has to find the sensitive key there is one more way to miss it.
|
|
480
|
+
def holds_sensitive?(field)
|
|
481
|
+
field[:sensitive] || Array(field[:fields]).any? { |child| holds_sensitive?(child) }
|
|
482
|
+
end
|
|
483
|
+
|
|
385
484
|
# The server checks the NORMALIZED string, so minLength/maxLength/pattern
|
|
386
485
|
# describe a value the client never sends: under `normalize: :squish`,
|
|
387
486
|
# " " satisfies a minLength of 3 and then squishes to "" (absent), and
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -188,7 +188,9 @@ module Permittable
|
|
|
188
188
|
operation = {}
|
|
189
189
|
key = controller_key(controller)
|
|
190
190
|
operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
|
|
191
|
-
|
|
191
|
+
# dup: the stored desc is frozen, and a caller editing the generated
|
|
192
|
+
# document in place must neither raise nor reach the contract.
|
|
193
|
+
operation["description"] = rule[:desc].dup if rule[:desc]
|
|
192
194
|
operation["requestBody"] = rule_request_body(rule)
|
|
193
195
|
operation["responses"] = responses_for(rule)
|
|
194
196
|
# The docs must not promise a 422 the server doesn't yet send. Only
|
|
@@ -196,6 +198,12 @@ module Permittable
|
|
|
196
198
|
# Permittable.mode is runtime configuration the export can't see.
|
|
197
199
|
operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
|
|
198
200
|
operation["x-permittable-catch-all"] = true if action == "*"
|
|
201
|
+
# `finalize` is flagged on the rule's object schema too (see
|
|
202
|
+
# JsonSchema.rule), but that sits inside the requestBody, and an
|
|
203
|
+
# operation can end up with no body (every field a path variable) while
|
|
204
|
+
# finalize still refuses requests. The operation is the one object that
|
|
205
|
+
# is always there, so the marker rides on it as well.
|
|
206
|
+
operation["x-permittable-finalize"] = true if rule[:finalize]
|
|
199
207
|
operation
|
|
200
208
|
end
|
|
201
209
|
|
|
@@ -249,15 +257,26 @@ module Permittable
|
|
|
249
257
|
# once — not counted as an operation colliding with itself. `template`
|
|
250
258
|
# is the route's own spelling when collapse_shapes placed it under
|
|
251
259
|
# another route's, nil otherwise.
|
|
260
|
+
#
|
|
261
|
+
# Only OpenAPI's own verbs name a slot. Rails routes any verb in
|
|
262
|
+
# ActionDispatch::Request::HTTP_METHODS — `via: :report`, `:mkcalendar`,
|
|
263
|
+
# `:search` and the rest of WebDAV and CalDAV — but a Path Item has fields
|
|
264
|
+
# for eight, and any other key makes the whole document invalid. A route
|
|
265
|
+
# on such a verb places nothing; an operation reached by no other route
|
|
266
|
+
# lands under x-permittable-controllers, as an unrouted one does.
|
|
252
267
|
def route_targets(routes)
|
|
253
268
|
return {} if routes.nil?
|
|
254
269
|
|
|
255
|
-
routes.
|
|
270
|
+
routes.select { |route| OPENAPI_VERBS.include?(verb_of(route)) }
|
|
271
|
+
.group_by { |route| [route[:controller].to_s, route[:action].to_s] }
|
|
256
272
|
.transform_values do |matching|
|
|
257
273
|
matching.map { |route| [route[:path], verb_of(route), route[:template]] }.uniq
|
|
258
274
|
end
|
|
259
275
|
end
|
|
260
276
|
|
|
277
|
+
# The fixed fields of an OpenAPI 3.1 Path Item that hold an operation.
|
|
278
|
+
OPENAPI_VERBS = %w[get put post delete options head patch trace].freeze
|
|
279
|
+
|
|
261
280
|
# OpenAPI forbids two path templates that differ only in their variable
|
|
262
281
|
# names: `/{locale}` and `/{slug}` are one path to it. rails_routes keeps
|
|
263
282
|
# one route's own variants apart, but two routes can still spell one
|
|
@@ -665,6 +684,14 @@ module Permittable
|
|
|
665
684
|
# named in its shadows rather than emitted. Distinct shapes are both
|
|
666
685
|
# kept; OpenAPI allows `/x/new` beside `/x/{a}`.
|
|
667
686
|
#
|
|
687
|
+
# Before any of that, a variant that is no request path of its own is
|
|
688
|
+
# dropped (request_path?). An absent group at the start of a route
|
|
689
|
+
# leaves them: `/(:locale)(/:theme)` without :locale is `//{theme}`,
|
|
690
|
+
# but Rails squeezes `GET //dark` to `GET /dark`, which binds :locale
|
|
691
|
+
# (or, constrained away from it, no route at all); and
|
|
692
|
+
# `(/:locale(/:region))` with nothing present is empty, so the route
|
|
693
|
+
# does not answer `GET /`.
|
|
694
|
+
#
|
|
668
695
|
# The list is then reversed, which puts every group's ABSENT variant
|
|
669
696
|
# first at every nesting level, the order the document lists them in.
|
|
670
697
|
# The variants of one route share an operation, and it is the operationId
|
|
@@ -689,6 +716,8 @@ module Permittable
|
|
|
689
716
|
|
|
690
717
|
kept = {}
|
|
691
718
|
first_of_shape = {}
|
|
719
|
+
# Such a variant matches no request, so it shadows nothing either.
|
|
720
|
+
variants = variants.select { |tokens| request_path?(tokens) }
|
|
692
721
|
variants.each_with_index do |tokens, index|
|
|
693
722
|
next if variants.take(index).any? { |earlier| shadowed?(earlier, tokens) }
|
|
694
723
|
|
|
@@ -784,6 +813,16 @@ module Permittable
|
|
|
784
813
|
path.empty? ? "/" : path
|
|
785
814
|
end
|
|
786
815
|
|
|
816
|
+
# Whether a variant could be a request's path. Rails normalizes the
|
|
817
|
+
# path before it matches any route (Journey::Router::Utils.
|
|
818
|
+
# normalize_path, from 6.1 to 8.1): it is given a leading "/", and every
|
|
819
|
+
# run of slashes is squeezed to one. So a variant that does not start
|
|
820
|
+
# with "/", or that holds two in a row, is matched by no request: the
|
|
821
|
+
# URL a client would build from it reaches the router as another path.
|
|
822
|
+
def request_path?(tokens)
|
|
823
|
+
tokens.first == "/" && tokens.each_cons(2).none? { |pair| pair == %w[/ /] }
|
|
824
|
+
end
|
|
825
|
+
|
|
787
826
|
# Whether Rails, trying `earlier` first, leaves `later` no URL: wherever
|
|
788
827
|
# `later` has a token, `earlier` accepts everything it does. Text must be
|
|
789
828
|
# equal; a parameter accepts text its pattern matches whole, and another
|
data/lib/permittable/railtie.rb
CHANGED
|
@@ -23,12 +23,99 @@ module Permittable
|
|
|
23
23
|
# this array in place and ActionDispatch reads the same object per
|
|
24
24
|
# request, so a name registered when a controller loads is seen by the
|
|
25
25
|
# next request. It is the array, not a snapshot, that has to be fed.
|
|
26
|
+
#
|
|
27
|
+
# ActiveRecord is a second sink: see FilterAttributesFeed, which also
|
|
28
|
+
# explains why it is fed here and installed by the initializer below.
|
|
29
|
+
feed = filter_attributes_feed
|
|
26
30
|
::Permittable.on_sensitive_parameter do |name|
|
|
27
31
|
filters = app.config.filter_parameters
|
|
28
32
|
filters << name unless filters.include?(name)
|
|
33
|
+
feed.add(name)
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# AFTER ActiveRecord's own hook, not before: both run when ActiveRecord::Base
|
|
38
|
+
# loads, in the order they were registered, and that one does
|
|
39
|
+
# `filter_attributes += config.filter_parameters`. Running first, the feed
|
|
40
|
+
# would add each name and that copy would add it again as a String (it holds
|
|
41
|
+
# Strings whenever precompile_filter_parameters is off, as on Rails 6.1).
|
|
42
|
+
initializer "permittable.filter_attributes",
|
|
43
|
+
after: "active_record.set_filter_attributes" do
|
|
44
|
+
feed = filter_attributes_feed
|
|
45
|
+
::ActiveSupport.on_load(:active_record) { feed.attach(self) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The second sink for sensitive names: ActiveRecord::Base.filter_attributes.
|
|
49
|
+
#
|
|
50
|
+
# It is a snapshot that never sees a name registered after it loaded. Its
|
|
51
|
+
# `filter_attributes` is copied from config.filter_parameters exactly once,
|
|
52
|
+
# when ActiveRecord::Base loads, and in a real app that is BEFORE any
|
|
53
|
+
# controller registers anything: the first controller's own `model: Person`
|
|
54
|
+
# loads it. So the copy holds only the proc the initializer appended, which
|
|
55
|
+
# cannot redact an Integer in the SQL bind log (`[["pin_code", 4321]]`, where
|
|
56
|
+
# #inspect, handed an already-inspected String, was fine). Each name is
|
|
57
|
+
# therefore also fed to the model base class directly. The setter drops the
|
|
58
|
+
# memoized inspection filter, and only names that are missing are added, so
|
|
59
|
+
# the list holds one entry per name however often a reloaded controller
|
|
60
|
+
# registers it.
|
|
61
|
+
#
|
|
62
|
+
# An app that ASSIGNS `filter_attributes = [...]` (an initializer, or a
|
|
63
|
+
# to_prepare block, which runs on every reload) rather than appending to it
|
|
64
|
+
# discards everything fed in so far, and a name that was registered only once
|
|
65
|
+
# would never come back. So every registration restores EVERY name registered
|
|
66
|
+
# so far that is missing, not just its own: the next contract to load, or the
|
|
67
|
+
# next reload re-declaring one, heals the list. (A to_prepare of the gem's own
|
|
68
|
+
# would not help: railtie blocks run before the app's, so the app's
|
|
69
|
+
# assignment would still come last.)
|
|
70
|
+
#
|
|
71
|
+
# The read-modify-write is done under a lock, as two threads loading
|
|
72
|
+
# contracts at once would otherwise each write back a list that lacks the
|
|
73
|
+
# other's name. Nothing inside the lock calls back into the sink or loads
|
|
74
|
+
# ActiveRecord, so it cannot deadlock.
|
|
75
|
+
#
|
|
76
|
+
# ActiveRecord is hooked ONCE for the process, in #attach, rather than once
|
|
77
|
+
# per name: on_load runs the block at once when ActiveRecord is already
|
|
78
|
+
# loaded, waits for it otherwise, and never fires in an app without it, where
|
|
79
|
+
# a hook per registration would queue forever across every code reload.
|
|
80
|
+
class FilterAttributesFeed
|
|
81
|
+
def initialize
|
|
82
|
+
@names = {}
|
|
83
|
+
@base = nil
|
|
84
|
+
@lock = Mutex.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Called with ActiveRecord::Base once it has loaded.
|
|
88
|
+
def attach(base)
|
|
89
|
+
@lock.synchronize do
|
|
90
|
+
@base = base
|
|
91
|
+
restore
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def add(name)
|
|
96
|
+
@lock.synchronize do
|
|
97
|
+
@names[name] = true
|
|
98
|
+
restore if @base
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# The caller holds the lock.
|
|
105
|
+
def restore
|
|
106
|
+
missing = @names.keys - @base.filter_attributes
|
|
107
|
+
@base.filter_attributes += missing unless missing.empty?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Shared by the two initializers, which Rails runs as instance_exec on this
|
|
112
|
+
# one object. Memoized rather than built in the first, so the second does not
|
|
113
|
+
# depend on the order Rails settles on for initializers that name no target.
|
|
114
|
+
def filter_attributes_feed
|
|
115
|
+
@filter_attributes_feed ||= FilterAttributesFeed.new
|
|
116
|
+
end
|
|
117
|
+
private :filter_attributes_feed
|
|
118
|
+
|
|
32
119
|
rake_tasks do
|
|
33
120
|
load File.expand_path("tasks/openapi.rake", __dir__)
|
|
34
121
|
load File.expand_path("tasks/generate.rake", __dir__)
|