permittable 0.10.0 → 0.11.1

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.
@@ -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 — they never
20
- # change what a client may SEND, so fields carrying them are flagged
21
- # with `x-permittable-*` extensions rather than mistranslated.
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
- # All three leave the schema STRICTER than the server, never looser: a
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. spec/schema_conformance_spec.rb holds that line,
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, and the conformance spec labels each one
31
- # rather than letting it pass as agreement:
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
- apply_in!(schema, field)
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 field[:type] == :string
337
+ return unless Array(schema["type"]).include?("string")
298
338
 
299
339
  min, max = length_bounds(field[:length])
300
- # "" is ABSENT and an absent required field violates, so a required
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
 
@@ -365,13 +423,18 @@ module Permittable
365
423
  # untranslatable or opaque fact in this file is handled: left out, with
366
424
  # the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
367
425
  # sensitive`) as the only signal that something is missing.
426
+ #
427
+ # The same goes for an array whose elements hold a sensitive field at
428
+ # any depth (see holds_sensitive?): its `default:`/`example:` carries
429
+ # that field's value, so it is omitted too.
368
430
  def annotate(schema, field)
369
431
  decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
370
- unless field[:sensitive]
432
+ unless holds_sensitive?(field)
371
433
  schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
372
434
  schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
373
435
  end
374
- schema["description"] = field[:desc] if field[:desc]
436
+ # dup: the stored desc is frozen; the export is editable and detached.
437
+ schema["description"] = field[:desc].dup if field[:desc]
375
438
  if field[:sensitive]
376
439
  schema["writeOnly"] = true
377
440
  schema["x-permittable-sensitive"] = true
@@ -382,6 +445,30 @@ module Permittable
382
445
  schema
383
446
  end
384
447
 
448
+ # Is this field sensitive, or does it contain a sensitive field at any
449
+ # depth? A container's `default:`/`example:` is a whole value, so a
450
+ # sensitive sub-field's value is inside it. `array :cards, default:
451
+ # [{}]` with `optional :pan, sensitive: true, default: "4111..."` even
452
+ # stores `[{"pan" => "4111..."}]`, because the walker fills in each
453
+ # element's sub-field defaults (see AuthoredValues). Omitting pan's own
454
+ # default protected nothing while the array still published the value.
455
+ #
456
+ # The whole container value is omitted. Redacting only the sensitive
457
+ # keys inside it was the alternative, and it would mislead. Without the
458
+ # key, `[{}]` documents a default the server never hands out, since it
459
+ # fills pan in. An example without its required secret fails the very
460
+ # items schema it is attached to. A placeholder can fail the
461
+ # sub-field's own format or in:. The field's own default is omitted
462
+ # rather than replaced for the same reasons.
463
+ #
464
+ # The answer is read off the declaration, not the value, so even
465
+ # `default: []` is withheld. A value stored as authored (beside a
466
+ # `transform:`) can key its hashes by String or Symbol, and a check
467
+ # that has to find the sensitive key there is one more way to miss it.
468
+ def holds_sensitive?(field)
469
+ field[:sensitive] || Array(field[:fields]).any? { |child| holds_sensitive?(child) }
470
+ end
471
+
385
472
  # The server checks the NORMALIZED string, so minLength/maxLength/pattern
386
473
  # describe a value the client never sends: under `normalize: :squish`,
387
474
  # " " satisfies a minLength of 3 and then squishes to "" (absent), and