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
data/lib/permittable.rb
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# Before active_support: on activesupport <= 7.0.8.4, requiring it raises
|
|
2
|
+
# NameError (ActiveSupport::LoggerThreadSafeLevel::Logger) unless `logger` is
|
|
3
|
+
# already loaded, because concurrent-ruby 1.3.5 stopped requiring it for them.
|
|
4
|
+
# One stdlib require makes `require "permittable"` work on every activesupport
|
|
5
|
+
# version the gemspec claims, whatever the host's own boot order.
|
|
6
|
+
require "logger"
|
|
7
|
+
|
|
1
8
|
require "active_support"
|
|
2
9
|
require "active_support/concern"
|
|
3
10
|
require "active_support/notifications"
|
|
@@ -22,6 +29,7 @@ require "active_support/core_ext/time/calculations"
|
|
|
22
29
|
require "bigdecimal"
|
|
23
30
|
require "date"
|
|
24
31
|
require "time"
|
|
32
|
+
require "uri" # URI::MailTo::EMAIL_REGEXP backs the :email format preset
|
|
25
33
|
|
|
26
34
|
require "permittable/version"
|
|
27
35
|
require "permittable/error_envelope"
|
|
@@ -74,6 +82,14 @@ require "permittable/filter_parameter_registry"
|
|
|
74
82
|
# (db:create, assets:precompile) the check skips. In CI, one
|
|
75
83
|
# `Rails.application.eager_load!` spec exercises every contract in the app.
|
|
76
84
|
#
|
|
85
|
+
# REUSING FIELDS — `Permittable.fields { ... }` builds a FieldGroup, a frozen
|
|
86
|
+
# reusable field list, and the builder's `use` verb splices one in wherever
|
|
87
|
+
# fields are declared (a contract, a nested block, another group). `use G,
|
|
88
|
+
# optional: true` relaxes every spliced field, which is how an update contract
|
|
89
|
+
# reuses a create contract; `only:`/`except:` select a subset. Because a group
|
|
90
|
+
# is built by the same builder, its declarations are validated once, at the
|
|
91
|
+
# group. See FieldGroup.
|
|
92
|
+
#
|
|
77
93
|
# Validation is LAZY: it runs on the first `permitted_params` call, so an
|
|
78
94
|
# action that never reads params never pays. `enforce: true` installs the
|
|
79
95
|
# check as a before_action instead (reject before the action body runs).
|
|
@@ -114,9 +130,14 @@ require "permittable/filter_parameter_registry"
|
|
|
114
130
|
# absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
|
|
115
131
|
# there is exactly one reading of absence and a value that normalizes to empty
|
|
116
132
|
# (" " under :squish) cannot satisfy a required field by becoming "". An
|
|
117
|
-
# authored `default:`/`example:` is stored
|
|
118
|
-
#
|
|
119
|
-
# the
|
|
133
|
+
# authored `default:`/`example:` is stored as the contract reads it —
|
|
134
|
+
# normalized and cast, so `default: "18"` on an :integer is 18, and an
|
|
135
|
+
# array's read by the request walker itself. The one exception is a field
|
|
136
|
+
# declaring `transform:`: its default is stored exactly AS AUTHORED, never
|
|
137
|
+
# cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
|
|
138
|
+
# so author such a default already in the shape the action should receive.
|
|
139
|
+
# Either way it is deep-frozen on a copy, and each request gets its own deep
|
|
140
|
+
# copy, so no request can corrupt it for the next.
|
|
120
141
|
#
|
|
121
142
|
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
122
143
|
# clears a column: a key the client never sent stays absent (defaults apply,
|
|
@@ -166,7 +187,14 @@ require "permittable/filter_parameter_registry"
|
|
|
166
187
|
# and validation to reshape that field's output, e.g.
|
|
167
188
|
# `transform: ->(v) { v.split(",") }` turns a validated delimited String
|
|
168
189
|
# into an Array. Runs only on request-supplied values: absent fields stay
|
|
169
|
-
# absent and `default:`
|
|
190
|
+
# absent, and a `default:` is handed out exactly AS AUTHORED — validated
|
|
191
|
+
# against the field's own contract at class load (as any default is), but
|
|
192
|
+
# neither cast nor transformed — so a request sending a field's default
|
|
193
|
+
# gets the transformed value, a request omitting it the untransformed one.
|
|
194
|
+
# Author a default already in the shape the action should receive:
|
|
195
|
+
# `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
|
|
196
|
+
# paths the Integer 25. A field with no `transform:` still gets its
|
|
197
|
+
# default cast (see the module-level default:/example: paragraph above).
|
|
170
198
|
# * `finalize do |p| ... end` (once per contract) — runs after every field
|
|
171
199
|
# validated cleanly, receives the result hash, and must return the
|
|
172
200
|
# (possibly restructured) Hash: combine parallel fields, build value
|
|
@@ -189,6 +217,7 @@ module Permittable
|
|
|
189
217
|
JSON_TYPE = :json
|
|
190
218
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
191
219
|
MODES = %i[enforce monitor].freeze
|
|
220
|
+
ERROR_FORMATS = %i[envelope problem].freeze
|
|
192
221
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
193
222
|
# unknown-keys check must not flag them.
|
|
194
223
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
@@ -207,6 +236,10 @@ module Permittable
|
|
|
207
236
|
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
208
237
|
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
209
238
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
239
|
+
# The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
|
|
240
|
+
# rootless contract declaring the wrapper key as one of these reads the
|
|
241
|
+
# copy deliberately, so it is kept (see permittable_without_wrapper_copy).
|
|
242
|
+
WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
|
|
210
243
|
# A log line and an exception message are PROSE, written for a person. They
|
|
211
244
|
# list at most this many names and count the rest, so one request cannot
|
|
212
245
|
# write a megabyte of them. The machine-readable channels — a violation's
|
|
@@ -216,6 +249,49 @@ module Permittable
|
|
|
216
249
|
# ...and each name it does list is truncated. Capping the COUNT alone still
|
|
217
250
|
# let ONE 1 MB key name write the 1 MB log line the cap exists to prevent.
|
|
218
251
|
PROSE_ITEM_LIMIT = 120
|
|
252
|
+
# ...and a name that could break the sentence out of its line is escaped.
|
|
253
|
+
# The names are client-sent, and bounding their length escaped nothing: a
|
|
254
|
+
# key of "x\nE, [...] ERROR -- : ..." wrote a second, forged log entry.
|
|
255
|
+
# The set is, by Unicode property:
|
|
256
|
+
# - every control character (Cc: C0, DEL and C1 — C1 because U+0085 is
|
|
257
|
+
# NEL, a line break to many readers, and U+009B is the 8-bit CSI that
|
|
258
|
+
# starts a terminal escape);
|
|
259
|
+
# - U+2028/U+2029 (Zl, Zp), the separators a JSON-lines or JavaScript
|
|
260
|
+
# reader splits a line on;
|
|
261
|
+
# - every format character (Cf): the bidi embeddings, overrides and
|
|
262
|
+
# isolates, which can visually reorder a line and so move text across
|
|
263
|
+
# the closing quote of an escaped name, and the zero-width and marker
|
|
264
|
+
# characters (U+200B, U+200E/U+200F, U+061C, U+FEFF, ...), which make
|
|
265
|
+
# two different names print identically;
|
|
266
|
+
# - every space but U+0020 (Zs), so a no-break or ideographic space
|
|
267
|
+
# cannot make "x,<NBSP>y" pass for the ", " between two names.
|
|
268
|
+
PROSE_UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}&&[^ ]]/
|
|
269
|
+
# A name is also quoted when it merely LOOKS like prose structure. These
|
|
270
|
+
# characters print as they are — only the quoting marks them:
|
|
271
|
+
# - Unicode's own Quotation_Mark property, rather than a hand-picked list:
|
|
272
|
+
# it already covers the plain and fullwidth `"`, every curly quote and
|
|
273
|
+
# guillemet (Pi/Pf), AND the CJK corner brackets U+300C/U+300D, which
|
|
274
|
+
# are real quotation marks in Japanese and Chinese text but are punctuation
|
|
275
|
+
# category Ps/Pe, not Pi/Pf, so a Pi/Pf-only check missed them;
|
|
276
|
+
# - the list separator, or a fullwidth, small, ideographic or small-form-
|
|
277
|
+
# ideographic comma (the last, U+FE51, is U+3001's small-form sibling,
|
|
278
|
+
# the way U+FE50 is the plain comma's), could pass for the ", " between
|
|
279
|
+
# two names;
|
|
280
|
+
# - a name that begins "and N more" (matched case-insensitively — "And"/
|
|
281
|
+
# "AND" reads identically once rendered) could pass for the overflow
|
|
282
|
+
# count. This still only catches the literal word: a homoglyph
|
|
283
|
+
# substitution such as Cyrillic "а" for Latin "a" is not detected, and
|
|
284
|
+
# no Unicode confusable-detection is attempted here — see CHANGELOG.
|
|
285
|
+
PROSE_AMBIGUOUS = /\p{Quotation_Mark}|[\u{FF0C}\u{FE50}\u{3001}\u{FE51}]|, |\A(?i:and \p{Nd}+ more)/
|
|
286
|
+
# \n, \r and \t, which a person recognises, get their short escape; any
|
|
287
|
+
# other unsafe character is \uXXXX, which JSON, JavaScript and Ruby all
|
|
288
|
+
# read the same way. The quote and backslash are escaped too, but only
|
|
289
|
+
# inside an escaped (quoted) name, where they would otherwise be ambiguous.
|
|
290
|
+
PROSE_ESCAPES = { "\n" => '\n', "\r" => '\r', "\t" => '\t', '"' => '\"', "\\" => '\\\\' }.freeze
|
|
291
|
+
# How much of a name the prose ever reads. Every character the rendering
|
|
292
|
+
# could show lies inside it even when each one is a 4-byte sequence in a
|
|
293
|
+
# binary key, so a 1 MB name is converted and scanned no further than this.
|
|
294
|
+
PROSE_SCAN_LIMIT = PROSE_ITEM_LIMIT * 4
|
|
219
295
|
|
|
220
296
|
# The single proc Permittable::Railtie appends to config.filter_parameters.
|
|
221
297
|
# Declared with an optional third parameter so its own arity is -3 and Rails
|
|
@@ -227,6 +303,28 @@ module Permittable
|
|
|
227
303
|
inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
|
|
228
304
|
end.freeze
|
|
229
305
|
|
|
306
|
+
# Named `format:` presets — the regexps every app writes by hand, defined
|
|
307
|
+
# once. A preset carries something a hand-written Regexp cannot: the JSON
|
|
308
|
+
# Schema `format` keyword the ecosystem understands, so an exported schema
|
|
309
|
+
# says `"format": "uuid"` and not only a wall of pattern.
|
|
310
|
+
#
|
|
311
|
+
# :email is deliberately URI::MailTo::EMAIL_REGEXP itself, the regexp Rails
|
|
312
|
+
# apps already paste into their contracts, so adopting the preset cannot
|
|
313
|
+
# change which addresses an endpoint accepts. The rest avoid flags and
|
|
314
|
+
# Ruby-only constructs (no \h, no /i) so they translate to ECMA-262 and
|
|
315
|
+
# export as a real `pattern` rather than an x-permittable-pattern
|
|
316
|
+
# extension. :url and :hostname are shape checks, not reachability
|
|
317
|
+
# guarantees.
|
|
318
|
+
FORMATS = {
|
|
319
|
+
email: { pattern: URI::MailTo::EMAIL_REGEXP, json: "email" }.freeze,
|
|
320
|
+
uuid: { pattern: /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
|
|
321
|
+
json: "uuid" }.freeze,
|
|
322
|
+
url: { pattern: %r{\Ahttps?://[^\s/?\#]+[^\s]*\z}, json: "uri" }.freeze,
|
|
323
|
+
slug: { pattern: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }.freeze,
|
|
324
|
+
hostname: { pattern: /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/,
|
|
325
|
+
json: "hostname" }.freeze
|
|
326
|
+
}.freeze
|
|
327
|
+
|
|
230
328
|
NORMALIZERS = {
|
|
231
329
|
squish: ->(v) { v.squish },
|
|
232
330
|
strip: ->(v) { v.strip },
|
|
@@ -305,7 +403,14 @@ module Permittable
|
|
|
305
403
|
# replay of #names — otherwise a sink deduplicating by value would hold
|
|
306
404
|
# both :ssn and "ssn".
|
|
307
405
|
name = name.to_s.downcase
|
|
308
|
-
|
|
406
|
+
unless name.empty?
|
|
407
|
+
# sensitive_parameter_sinks is the same process-global Array
|
|
408
|
+
# on_sensitive_parameter appends to under @registry_mutex. Reading it
|
|
409
|
+
# here without that lock is an unsynchronized concurrent mutation
|
|
410
|
+
# during iteration on any Ruby without a GVL — a thread class-loading
|
|
411
|
+
# a sensitive: true contract can race a thread installing a sink.
|
|
412
|
+
@registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
|
|
413
|
+
end
|
|
309
414
|
nil
|
|
310
415
|
end
|
|
311
416
|
|
|
@@ -326,6 +431,19 @@ module Permittable
|
|
|
326
431
|
@sensitive_parameter_sinks ||= []
|
|
327
432
|
end
|
|
328
433
|
|
|
434
|
+
# A reusable field list, shareable by any number of contracts — see
|
|
435
|
+
# FieldGroup for the whole story.
|
|
436
|
+
#
|
|
437
|
+
# AddressFields = Permittable.fields do
|
|
438
|
+
# required :city, :string
|
|
439
|
+
# optional :zip, :string, format: /\A\d{5}\z/
|
|
440
|
+
# end
|
|
441
|
+
#
|
|
442
|
+
# Splice it into a contract (or another group) with `use`.
|
|
443
|
+
def fields(&)
|
|
444
|
+
FieldGroup.new(&)
|
|
445
|
+
end
|
|
446
|
+
|
|
329
447
|
# App-wide default for rules that don't declare their own mode:.
|
|
330
448
|
# :enforce (the default) rejects violating requests; :monitor reports
|
|
331
449
|
# them — same instrumentation event with payload mode: :monitor, plus a
|
|
@@ -346,6 +464,54 @@ module Permittable
|
|
|
346
464
|
@mode = value
|
|
347
465
|
end
|
|
348
466
|
|
|
467
|
+
# The shape of a rejection: :envelope (the default — the host's
|
|
468
|
+
# #render_error, or the gem's inline JSON) or :problem, which renders RFC
|
|
469
|
+
# 9457 Problem Details as application/problem+json. App-wide, because the
|
|
470
|
+
# error format of an API is a property of the API rather than of any one
|
|
471
|
+
# contract, and set from an initializer:
|
|
472
|
+
#
|
|
473
|
+
# Permittable.error_format = :problem
|
|
474
|
+
#
|
|
475
|
+
# See ErrorEnvelope, including why :problem opts out of #render_error.
|
|
476
|
+
def error_format
|
|
477
|
+
@error_format || :envelope
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def error_format=(value)
|
|
481
|
+
value = value.to_sym
|
|
482
|
+
raise ArgumentError, "#{LABEL}: error_format must be one of #{ERROR_FORMATS.join(', ')}" unless ERROR_FORMATS.include?(value)
|
|
483
|
+
|
|
484
|
+
@error_format = value
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Base URI for problem `type` members. Unset (the default) leaves the type
|
|
488
|
+
# as RFC 9457's "about:blank"; set it to where the app documents its
|
|
489
|
+
# problem types and each type gets its own URI under it.
|
|
490
|
+
attr_accessor :problem_base_uri
|
|
491
|
+
|
|
492
|
+
# Whether the schema-drift guard also checks that a field's declared type
|
|
493
|
+
# matches its column's, on top of checking the column exists.
|
|
494
|
+
#
|
|
495
|
+
# OFF by default, deliberately. Every cross-type declaration has some
|
|
496
|
+
# legitimate use — a :string contract on a date column that lets
|
|
497
|
+
# ActiveRecord do the casting, a :boolean contract on a legacy integer
|
|
498
|
+
# column — and breaking those apps on an upgrade would cost more than the
|
|
499
|
+
# drift it catches. Turn it on and fix what it finds:
|
|
500
|
+
#
|
|
501
|
+
# Permittable.check_column_types = true
|
|
502
|
+
#
|
|
503
|
+
# It compares type GROUPS rather than exact types, and never fires on a
|
|
504
|
+
# column it has no faithful contract type for. See ColumnGuard.
|
|
505
|
+
def check_column_types
|
|
506
|
+
@check_column_types || false
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def check_column_types=(value)
|
|
510
|
+
raise ArgumentError, "#{LABEL}: check_column_types must be true or false" unless [true, false].include?(value)
|
|
511
|
+
|
|
512
|
+
@check_column_types = value
|
|
513
|
+
end
|
|
514
|
+
|
|
349
515
|
# App-wide fallback copy for a violation code, looked up through I18n
|
|
350
516
|
# under permittable.errors.<code> ("missing", "inclusion", or any Symbol
|
|
351
517
|
# a validate: returned). Consulted only when the field declares no
|
|
@@ -418,22 +584,44 @@ module Permittable
|
|
|
418
584
|
def check_scalar_rules(field, value)
|
|
419
585
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
420
586
|
return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
|
|
421
|
-
return [:error, "format"] if field[:format] && !field[:format]
|
|
587
|
+
return [:error, "format"] if field[:format] && !format_match?(field[:format], value)
|
|
422
588
|
|
|
423
589
|
check_custom(field[:validate], value)
|
|
424
590
|
end
|
|
425
591
|
|
|
592
|
+
# A Regexp RAISES rather than answers when a String's encoding cannot meet
|
|
593
|
+
# it — a UTF-8 pattern with non-ASCII characters against UTF-16,
|
|
594
|
+
# Shift_JIS or binary bytes (Encoding::CompatibilityError). A value the
|
|
595
|
+
# pattern cannot even be applied to has not been shown to match, so it is
|
|
596
|
+
# a `format` violation, the answer the client can act on. An app that
|
|
597
|
+
# takes other encodings on purpose (`skip_parameter_encoding`) sees what
|
|
598
|
+
# it saw before this rule, except that the crash is now a 422.
|
|
599
|
+
def format_match?(pattern, value)
|
|
600
|
+
pattern.match?(value)
|
|
601
|
+
rescue EncodingError, ArgumentError
|
|
602
|
+
false
|
|
603
|
+
end
|
|
604
|
+
|
|
426
605
|
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
427
606
|
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
428
607
|
# top-level key count, same reading as an array's element count) and
|
|
429
608
|
# nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
|
|
430
609
|
# checking, like check_scalar.
|
|
431
610
|
def check_json(field, value)
|
|
611
|
+
status, code = check_json_bounds(field, value)
|
|
612
|
+
return [status, code] unless status == :ok
|
|
613
|
+
|
|
614
|
+
check_custom(field[:validate], value)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# The structural half of check_json, which the request walker runs on
|
|
618
|
+
# its own so that it can copy an accepted hash before app code sees it.
|
|
619
|
+
def check_json_bounds(field, value)
|
|
432
620
|
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
433
621
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
434
622
|
return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
|
|
435
623
|
|
|
436
|
-
|
|
624
|
+
[:ok, value]
|
|
437
625
|
end
|
|
438
626
|
|
|
439
627
|
# Container nesting, with the field's own hash as level 1. An Array counts
|
|
@@ -463,8 +651,76 @@ module Permittable
|
|
|
463
651
|
|
|
464
652
|
def cast(type, value)
|
|
465
653
|
return [:error, "invalid_type"] unless scalar_shaped?(value)
|
|
654
|
+
return public_send("cast_#{type}", value) unless value.is_a?(String)
|
|
655
|
+
# Bytes that are not valid in the String's OWN encoding are not text in
|
|
656
|
+
# any encoding: every String operation after the cast raises on them
|
|
657
|
+
# (`format:`, the normalize presets, an app's `validate:`). Rails'
|
|
658
|
+
# params builder guards a controller; a standalone Contract#call on a
|
|
659
|
+
# webhook payload has nothing in front of it, so `"caf\xC3"` was a 500.
|
|
660
|
+
return [:error, "invalid_type"] unless value.valid_encoding?
|
|
661
|
+
# A :string is handed back exactly as it arrived, in its own encoding.
|
|
662
|
+
# `skip_parameter_encoding` / `param_encoding` send a controller binary
|
|
663
|
+
# or Shift_JIS text ON PURPOSE, and converting it would hand the app
|
|
664
|
+
# something other than what it asked Rails for.
|
|
665
|
+
return cast_string(value) if type == :string
|
|
666
|
+
|
|
667
|
+
# A UTF-8 String IS already its own inspection copy — the check just
|
|
668
|
+
# above already scanned it — so utf8_text would only scan the same
|
|
669
|
+
# object a second time for no new answer. Every other encoding still
|
|
670
|
+
# goes through it: a US-ASCII or binary String is a NEW object once
|
|
671
|
+
# force_encoding'd, and one only `encode` could produce is not yet
|
|
672
|
+
# known to be valid UTF-8 at all.
|
|
673
|
+
text = value.encoding == Encoding::UTF_8 ? value : utf8_text(value)
|
|
674
|
+
text ? public_send("cast_#{type}", text) : [:error, "invalid_type"]
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Encodings whose bytes are READ as UTF-8 rather than converted: UTF-8
|
|
678
|
+
# itself, US-ASCII (a subset of it), and binary — which names no
|
|
679
|
+
# encoding at all, and is how a raw socket read or an unlabelled file
|
|
680
|
+
# hands a payload over.
|
|
681
|
+
UTF8_READABLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
|
|
682
|
+
|
|
683
|
+
# A UTF-8 copy of a String, for INSPECTION only — the text a number, a
|
|
684
|
+
# boolean or a date is parsed from — or nil when there is no UTF-8
|
|
685
|
+
# reading of it. The value handed back to the app is never this copy.
|
|
686
|
+
#
|
|
687
|
+
# Parsing the String itself went wrong for any encoding but UTF-8:
|
|
688
|
+
# `Integer()` on UTF-16 "12" raised Encoding::CompatibilityError, and
|
|
689
|
+
# `BigDecimal` read the same String byte by byte and returned 1 — a wrong
|
|
690
|
+
# answer where the other at least crashed.
|
|
691
|
+
#
|
|
692
|
+
# Binary and US-ASCII are read as UTF-8 (on a copy — the caller's String
|
|
693
|
+
# keeps its encoding), anything else is converted with `encode`, and a
|
|
694
|
+
# result that is not valid UTF-8, or a conversion that raises, is nil.
|
|
695
|
+
def utf8_text(value)
|
|
696
|
+
text = if value.encoding == Encoding::UTF_8 then value
|
|
697
|
+
elsif UTF8_READABLE.include?(value.encoding) then value.dup.force_encoding(Encoding::UTF_8)
|
|
698
|
+
else value.encode(Encoding::UTF_8)
|
|
699
|
+
end
|
|
700
|
+
text.valid_encoding? ? text : nil
|
|
701
|
+
rescue EncodingError
|
|
702
|
+
nil
|
|
703
|
+
end
|
|
466
704
|
|
|
467
|
-
|
|
705
|
+
# utf8_text for text the gem must REPORT rather than judge — a client's
|
|
706
|
+
# undeclared key, written into a violation's `param`, the exception
|
|
707
|
+
# message and the log line. Refusing is not an option there, so what
|
|
708
|
+
# cannot be read is replaced with U+FFFD. Otherwise the undeclared key
|
|
709
|
+
# "caf\xC3" was copied raw into the 422 and rendering it raised
|
|
710
|
+
# JSON::GeneratorError, and a UTF-16 key raised
|
|
711
|
+
# Encoding::CompatibilityError while its path was being interpolated.
|
|
712
|
+
# Only undeclared keys come here (see permittable_unknown_key_violation):
|
|
713
|
+
# a declared key is the contract's own UTF-8 name.
|
|
714
|
+
def reportable_text(value)
|
|
715
|
+
utf8_text(value) || scrubbed_text(value)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def scrubbed_text(value)
|
|
719
|
+
return value.dup.force_encoding(Encoding::UTF_8).scrub if UTF8_READABLE.include?(value.encoding)
|
|
720
|
+
|
|
721
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
722
|
+
rescue EncodingError
|
|
723
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
468
724
|
end
|
|
469
725
|
|
|
470
726
|
# Arrays, hashes, and nested ActionController::Parameters
|
|
@@ -476,19 +732,44 @@ module Permittable
|
|
|
476
732
|
true
|
|
477
733
|
end
|
|
478
734
|
|
|
735
|
+
# A String is returned as given. The request walker has already copied
|
|
736
|
+
# it on the way in (Permittable#permittable_normalized), before
|
|
737
|
+
# `normalize:` could see it — copying here as well would allocate twice
|
|
738
|
+
# per value and still come too late for a mutating `normalize:` proc.
|
|
479
739
|
def cast_string(value)
|
|
480
740
|
case value
|
|
481
741
|
when String then [:ok, value]
|
|
742
|
+
# BigDecimal#to_s defaults to engineering notation ("0.15e1" for 1.5) —
|
|
743
|
+
# stdlib's own rendering, only ever masked in a host that has loaded
|
|
744
|
+
# Rails' active_support/core_ext/big_decimal/conversions, which patches
|
|
745
|
+
# the default format to "F". A :decimal default:/example: cast through
|
|
746
|
+
# here (a plain :string field, not :decimal itself) must render the same
|
|
747
|
+
# way regardless of whether that patch happens to be loaded.
|
|
748
|
+
when BigDecimal then [:ok, value.to_s("F")]
|
|
482
749
|
when Numeric, true, false then [:ok, value.to_s]
|
|
483
750
|
else [:error, "invalid_type"]
|
|
484
751
|
end
|
|
485
752
|
end
|
|
486
753
|
|
|
754
|
+
# Kernel#Integer/Float and BigDecimal() all accept underscore digit
|
|
755
|
+
# separators and surrounding whitespace — a convenience for a NUMBER
|
|
756
|
+
# LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
|
|
757
|
+
# client spells eighteen, and " 99 " is not how one spells ninety-nine;
|
|
758
|
+
# silently accepting either is the same kind of leniency as the
|
|
759
|
+
# NaN/Infinity/`0e10` cases below, just arriving from a different door.
|
|
760
|
+
# Checked against the raw String before any of those delegate, so a
|
|
761
|
+
# non-canonical spelling never reaches them at all.
|
|
762
|
+
INTEGER_FORMAT = /\A[+-]?\d+\z/
|
|
763
|
+
NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
764
|
+
|
|
487
765
|
def cast_integer(value)
|
|
488
766
|
case value
|
|
489
767
|
when Integer then [:ok, value]
|
|
490
|
-
|
|
491
|
-
|
|
768
|
+
# NaN and Infinity first: `truncate` raises FloatDomainError on them (a
|
|
769
|
+
# RangeError, which the ArgumentError rescue below does not catch), and
|
|
770
|
+
# no integer is what either one sent. Same rule as finite_float.
|
|
771
|
+
when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
|
|
772
|
+
when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
|
|
492
773
|
else [:error, "invalid_type"]
|
|
493
774
|
end
|
|
494
775
|
rescue ArgumentError
|
|
@@ -498,7 +779,7 @@ module Permittable
|
|
|
498
779
|
def cast_float(value)
|
|
499
780
|
case value
|
|
500
781
|
when Numeric then finite_float(value.to_f)
|
|
501
|
-
when String then finite_float(Float(value), source: value)
|
|
782
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
|
|
502
783
|
else [:error, "invalid_type"]
|
|
503
784
|
end
|
|
504
785
|
rescue ArgumentError
|
|
@@ -529,7 +810,8 @@ module Permittable
|
|
|
529
810
|
|
|
530
811
|
def cast_decimal(value)
|
|
531
812
|
case value
|
|
532
|
-
when Numeric
|
|
813
|
+
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
814
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
533
815
|
else [:error, "invalid_type"]
|
|
534
816
|
end
|
|
535
817
|
rescue ArgumentError
|
|
@@ -614,10 +896,37 @@ module Permittable
|
|
|
614
896
|
|
|
615
897
|
# Presets only make sense on String input; a non-String value (JSON
|
|
616
898
|
# numbers, booleans) skips normalization and goes straight to the cast.
|
|
899
|
+
#
|
|
900
|
+
# A String that is not valid in its own encoding is left as it came, for
|
|
901
|
+
# the cast to refuse: every preset raises on invalid bytes, and an app's
|
|
902
|
+
# own proc would be handed input it never agreed to see. It is not empty,
|
|
903
|
+
# so the absence rule in between cannot mistake it for a missing value.
|
|
904
|
+
#
|
|
905
|
+
# A VALID String in another encoding is normalized in that encoding —
|
|
906
|
+
# `:strip` works on binary and Shift_JIS alike — and when a BUILT-IN
|
|
907
|
+
# PRESET cannot handle the encoding (`:squish` on UTF-16 raises
|
|
908
|
+
# Encoding::CompatibilityError) the value is left as it is rather than
|
|
909
|
+
# raising.
|
|
910
|
+
#
|
|
911
|
+
# That leniency is only for the gem's own presets, identified by object
|
|
912
|
+
# identity against NORMALIZERS' values (resolve_normalizer! replaces
|
|
913
|
+
# field[:normalize] with the exact Proc from that Hash, so a preset and
|
|
914
|
+
# an app-supplied Proc are never the same object). An app's own Proc
|
|
915
|
+
# raising is never swallowed, on ANY encoding: `normalize: ->(v) { raise
|
|
916
|
+
# ArgumentError, "..." if ... }` is a business rule, not an encoding
|
|
917
|
+
# failure, and treating its raise as "this encoding defeated the
|
|
918
|
+
# normalizer" would have let exactly the input a UTF-8 request could not
|
|
919
|
+
# bypass the very check it names.
|
|
617
920
|
def apply_normalize(normalizer, value)
|
|
618
921
|
return value unless normalizer && value.is_a?(String)
|
|
922
|
+
return value unless value.valid_encoding?
|
|
923
|
+
return normalizer.call(value) unless NORMALIZERS.value?(normalizer)
|
|
619
924
|
|
|
620
|
-
|
|
925
|
+
begin
|
|
926
|
+
normalizer.call(value)
|
|
927
|
+
rescue EncodingError, ArgumentError
|
|
928
|
+
value
|
|
929
|
+
end
|
|
621
930
|
end
|
|
622
931
|
|
|
623
932
|
# nil and "" are both ABSENT — see the module comment. The VALUE half of
|
|
@@ -628,6 +937,109 @@ module Permittable
|
|
|
628
937
|
value.nil? || (value.is_a?(String) && value.empty?)
|
|
629
938
|
end
|
|
630
939
|
|
|
940
|
+
# An `in:` list as the runtime holds it: every member cast by the field's
|
|
941
|
+
# own type, because included_in? compares the CAST request value against
|
|
942
|
+
# it. Comparing against the members as authored meant `in: %i[draft
|
|
943
|
+
# published]` on a :string field (and `in: %w[1 2 3]` on an :integer one)
|
|
944
|
+
# held values no cast could ever produce, and rejected every request.
|
|
945
|
+
#
|
|
946
|
+
# `normalize:` is deliberately not applied — it rewrites what a client
|
|
947
|
+
# sent, not what the contract author wrote. Duplicates the cast collapses
|
|
948
|
+
# ("1" and 1 on an :integer) are dropped, and a Set stays a Set, so an
|
|
949
|
+
# author who chose one for its O(1) include? keeps it. A nil member is
|
|
950
|
+
# dropped on a nullable field, where an explicit null is accepted before
|
|
951
|
+
# in: is ever consulted; anywhere else it is a member no value can equal,
|
|
952
|
+
# and is an error like any other.
|
|
953
|
+
#
|
|
954
|
+
# `members` is what in_list returned. Returns [:ok, cast, published] —
|
|
955
|
+
# `published` being what an exported enum lists, see published_in_member
|
|
956
|
+
# — or [:error, offending_member, code]. Shared by ContractBuilder and the
|
|
957
|
+
# RSpec matcher's `within` chain so the two cannot read a list differently.
|
|
958
|
+
def cast_in_members(type, members, nullable: false)
|
|
959
|
+
pairs = []
|
|
960
|
+
members.each do |member|
|
|
961
|
+
next if member.nil? && nullable
|
|
962
|
+
|
|
963
|
+
status, value = cast_in_member(type, member)
|
|
964
|
+
return [:error, member, value] unless status == :ok
|
|
965
|
+
|
|
966
|
+
pairs << [value, published_in_member(type, member, value)]
|
|
967
|
+
end
|
|
968
|
+
pairs = pairs.uniq(&:first)
|
|
969
|
+
cast_members = pairs.map(&:first)
|
|
970
|
+
[:ok, members.is_a?(Set) ? cast_members.to_set : cast_members, pairs.map(&:last)]
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# The members of an `in:` that is a LIST, or nil when it is not one.
|
|
974
|
+
# Only Array, Set, Hash and Enumerator count, and only when the object's
|
|
975
|
+
# OWN class provides the collection's ordinary include? — not a Hash,
|
|
976
|
+
# Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
|
|
977
|
+
# fuzzy Set, a registry matching some other way entirely). `case allowed;
|
|
978
|
+
# when Hash ...` matches with ===, which for a Class is is_a?, so a
|
|
979
|
+
# subclass would otherwise match its ancestor's branch and have its
|
|
980
|
+
# override silently discarded — read for its raw keys/elements instead,
|
|
981
|
+
# which can invert which values it actually accepts. It is left opaque
|
|
982
|
+
# instead, exactly like any other object whose include? is the point
|
|
983
|
+
# (see resolve_in!) and enumerating it may be expensive (a DB-backed
|
|
984
|
+
# registry).
|
|
985
|
+
#
|
|
986
|
+
# A Hash lists its KEYS, which is what Hash#include? asks about — the
|
|
987
|
+
# Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
|
|
988
|
+
# Set, so membership stays O(1) per request.
|
|
989
|
+
# ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
|
|
990
|
+
# accepted anyway: its include? override only canonicalises the argument
|
|
991
|
+
# (String/Symbol) before the SAME key lookup, so its keys are still
|
|
992
|
+
# exactly its members — and it is what a Rails enum's own reader
|
|
993
|
+
# (`Post.statuses`) actually returns.
|
|
994
|
+
# Enumerator::Lazy is the same story on the Enumerator side: Lazy
|
|
995
|
+
# overrides chain methods like map and select, but not include?, so it
|
|
996
|
+
# is still read as a list — and forced to an Array here, once, since
|
|
997
|
+
# left lazy it would be cast on every request instead of at class load.
|
|
998
|
+
def in_list(allowed)
|
|
999
|
+
case allowed
|
|
1000
|
+
when Hash then allowed.keys.to_set if plain_hash?(allowed)
|
|
1001
|
+
when Set then allowed if allowed.instance_of?(Set)
|
|
1002
|
+
when Array then allowed.to_a if allowed.instance_of?(Array)
|
|
1003
|
+
when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
def plain_hash?(allowed)
|
|
1008
|
+
allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# A Symbol is read as its String: it is how Ruby spells a constant
|
|
1012
|
+
# string, and a request never carries one, so no cast accepts it as is.
|
|
1013
|
+
def cast_in_member(type, member)
|
|
1014
|
+
member = member.to_s if member.is_a?(Symbol)
|
|
1015
|
+
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
1016
|
+
|
|
1017
|
+
cast(type, member)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# A Time or DateTime member of a :date field. ActiveSupport compares one
|
|
1021
|
+
# with a Date as INSTANTS, the Date standing for its midnight UTC, so
|
|
1022
|
+
# that instant is the only one that ever equalled a request's date. It
|
|
1023
|
+
# is read as that UTC date; any other instant never matched anything,
|
|
1024
|
+
# and is refused like any member no request could equal. (cast_date
|
|
1025
|
+
# would keep a DateTime whole — it IS a Date — and refuse a Time.)
|
|
1026
|
+
def instant_as_date(member)
|
|
1027
|
+
utc = member.to_time.getutc
|
|
1028
|
+
return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
|
|
1029
|
+
|
|
1030
|
+
[:ok, utc.to_date]
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# What an exported enum lists for one member: the cast value, re-encoded
|
|
1034
|
+
# as JSON — except a :date/:datetime member authored as a String, which
|
|
1035
|
+
# is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
|
|
1036
|
+
# so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
|
|
1037
|
+
# the server refuses. The authored String went through the very cast a
|
|
1038
|
+
# request does, so the server accepts it by construction.
|
|
1039
|
+
def published_in_member(type, member, value)
|
|
1040
|
+
member.is_a?(String) && %i[date datetime].include?(type) ? member : value
|
|
1041
|
+
end
|
|
1042
|
+
|
|
631
1043
|
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
632
1044
|
# and the right semantics for validation.
|
|
633
1045
|
def included_in?(allowed, value)
|
|
@@ -651,6 +1063,13 @@ module Permittable
|
|
|
651
1063
|
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
|
|
652
1064
|
nullable].freeze
|
|
653
1065
|
|
|
1066
|
+
# One value of each scalar type as a cast produces it, for asking whether
|
|
1067
|
+
# an `in:` Range's endpoints can be compared with that type at all.
|
|
1068
|
+
RANGE_PROBES = {
|
|
1069
|
+
string: "", integer: 0, float: 0.0, decimal: BigDecimal("0"), boolean: true,
|
|
1070
|
+
date: Date.new(2000, 1, 1), datetime: Time.utc(2000)
|
|
1071
|
+
}.freeze
|
|
1072
|
+
|
|
654
1073
|
attr_reader :finalizer
|
|
655
1074
|
|
|
656
1075
|
def initialize
|
|
@@ -691,6 +1110,10 @@ module Permittable
|
|
|
691
1110
|
required = opts.delete(:required) ? true : false
|
|
692
1111
|
|
|
693
1112
|
field = { name: name, kind: :array, required: required, **opts }
|
|
1113
|
+
if field[:required] && field.key?(:default)
|
|
1114
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
1115
|
+
end
|
|
1116
|
+
|
|
694
1117
|
if block
|
|
695
1118
|
raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
|
|
696
1119
|
|
|
@@ -708,8 +1131,69 @@ module Permittable
|
|
|
708
1131
|
@fields << field
|
|
709
1132
|
end
|
|
710
1133
|
|
|
1134
|
+
# Splice a reusable field group in at this point — the same fields, in the
|
|
1135
|
+
# same order, as if they had been typed here. Works at the top level of a
|
|
1136
|
+
# contract, inside a nested or array block, and inside another group.
|
|
1137
|
+
#
|
|
1138
|
+
# permit_params :create, root: :user do
|
|
1139
|
+
# required :name, :string
|
|
1140
|
+
# optional :address do
|
|
1141
|
+
# use AddressFields
|
|
1142
|
+
# end
|
|
1143
|
+
# end
|
|
1144
|
+
#
|
|
1145
|
+
# `optional: true` relaxes every spliced field, which is how an update
|
|
1146
|
+
# contract reuses a create contract: nothing is mandatory, but `default:`,
|
|
1147
|
+
# types and bounds all still apply. It relaxes the TOP LEVEL only — if a
|
|
1148
|
+
# client sends an address at all, the address's own required sub-fields
|
|
1149
|
+
# still hold.
|
|
1150
|
+
#
|
|
1151
|
+
# `only:`/`except:` select a subset, in the group's own order. Naming a
|
|
1152
|
+
# field the group doesn't declare is a class-load error, so a typo cannot
|
|
1153
|
+
# silently drop a field. A field declared twice still raises, so
|
|
1154
|
+
# overriding one field of a group is deliberate: `use G, except: [:city]`
|
|
1155
|
+
# and then declare `:city` yourself.
|
|
1156
|
+
def use(group, only: nil, except: nil, optional: false)
|
|
1157
|
+
fields = select_group_fields!(group_fields!(group), only: only, except: except)
|
|
1158
|
+
fields = fields.map { |field| field.merge(required: false).freeze } if optional
|
|
1159
|
+
fields.each do |field|
|
|
1160
|
+
field_name!(field[:name])
|
|
1161
|
+
@fields << field
|
|
1162
|
+
end
|
|
1163
|
+
nil
|
|
1164
|
+
end
|
|
1165
|
+
|
|
711
1166
|
private
|
|
712
1167
|
|
|
1168
|
+
def group_fields!(group)
|
|
1169
|
+
return group.fields if group.respond_to?(:fields)
|
|
1170
|
+
|
|
1171
|
+
raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
|
|
1172
|
+
"answering #fields, such as a Permittable::Contract — got #{group.class}"
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def select_group_fields!(fields, only:, except:)
|
|
1176
|
+
raise ArgumentError, "#{LABEL}: use takes only: OR except:, not both" if only && except
|
|
1177
|
+
|
|
1178
|
+
if only || except
|
|
1179
|
+
wanted = assert_group_names!(fields, only || except, only ? "only" : "except")
|
|
1180
|
+
fields = only ? fields.select { |f| wanted.include?(f[:name]) } : fields.reject { |f| wanted.include?(f[:name]) }
|
|
1181
|
+
end
|
|
1182
|
+
return fields unless fields.empty?
|
|
1183
|
+
|
|
1184
|
+
raise ArgumentError, "#{LABEL}: use selects no fields from the group"
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
def assert_group_names!(fields, names, label)
|
|
1188
|
+
wanted = Array(names).map(&:to_sym)
|
|
1189
|
+
declared = fields.map { |f| f[:name] }
|
|
1190
|
+
missing = wanted - declared
|
|
1191
|
+
return wanted if missing.empty?
|
|
1192
|
+
|
|
1193
|
+
raise ArgumentError, "#{LABEL}: use #{label}: names #{missing.map(&:inspect).join(', ')}, which the group " \
|
|
1194
|
+
"does not declare (it declares: #{declared.map(&:inspect).join(', ')})"
|
|
1195
|
+
end
|
|
1196
|
+
|
|
713
1197
|
def add_field(name, type, required:, opts:, &block)
|
|
714
1198
|
name = field_name!(name)
|
|
715
1199
|
if block
|
|
@@ -813,25 +1297,109 @@ module Permittable
|
|
|
813
1297
|
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
814
1298
|
end
|
|
815
1299
|
|
|
816
|
-
if field.key?(:in)
|
|
817
|
-
unless field[:in].respond_to?(:include?)
|
|
818
|
-
raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
|
|
819
|
-
end
|
|
820
|
-
|
|
821
|
-
assert_satisfiable!(name, :in, field[:in])
|
|
822
|
-
end
|
|
823
|
-
|
|
1300
|
+
resolve_in!(field) if field.key?(:in)
|
|
824
1301
|
validate_string_only_opts!(field)
|
|
825
1302
|
validate_length!(name, field[:length]) if field.key?(:length)
|
|
826
1303
|
validate_required_length!(field)
|
|
827
1304
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
828
1305
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
1306
|
+
resolve_format!(field)
|
|
829
1307
|
resolve_normalizer!(field)
|
|
830
1308
|
validate_authored_value!(field, :default)
|
|
831
1309
|
validate_authored_value!(field, :example)
|
|
832
1310
|
validate_message!(field)
|
|
833
1311
|
end
|
|
834
1312
|
|
|
1313
|
+
# `in:` is a Range (bounds-checked with cover?), a list of values, or an
|
|
1314
|
+
# object of the host's own that answers include? — kept exactly as given,
|
|
1315
|
+
# since nothing here can know what it accepts. It used to be anything
|
|
1316
|
+
# answering include?, which let a String through, and String#include? is
|
|
1317
|
+
# a SUBSTRING test: `in: "free pro"` accepted "e", "fr" and "ee p". A
|
|
1318
|
+
# String is refused here, along with anything answering neither.
|
|
1319
|
+
#
|
|
1320
|
+
# A list (see Coercion.in_list — a Hash lists its keys) is stored cast by
|
|
1321
|
+
# the field's type (see Coercion.cast_in_members), so request-time
|
|
1322
|
+
# matching, the exported enum, the RSpec matcher and the column guard's
|
|
1323
|
+
# enum rule all read the members the runtime compares against. A member
|
|
1324
|
+
# no request value could ever equal is a contract mistake, and fails here
|
|
1325
|
+
# rather than as an `inclusion` on every request.
|
|
1326
|
+
def resolve_in!(field)
|
|
1327
|
+
name = field[:name]
|
|
1328
|
+
allowed = field[:in]
|
|
1329
|
+
if allowed.is_a?(Range)
|
|
1330
|
+
assert_comparable_range!(field, allowed)
|
|
1331
|
+
elsif (members = Coercion.in_list(allowed))
|
|
1332
|
+
cast_in_members!(field, members)
|
|
1333
|
+
elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
|
|
1334
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
|
|
1335
|
+
"or a Hash read as its keys), or an object answering include? " \
|
|
1336
|
+
"(got #{allowed.inspect})#{string_in_hint(allowed)}"
|
|
1337
|
+
end
|
|
1338
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
def string_in_hint(allowed)
|
|
1342
|
+
return "" unless allowed.is_a?(String)
|
|
1343
|
+
|
|
1344
|
+
" — String#include? would accept any substring; list the values instead, e.g. in: %w[#{allowed}]"
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
# `published` is stored only where it differs from the cast members (a
|
|
1348
|
+
# String-authored :date/:datetime member), so it is read as an override.
|
|
1349
|
+
def cast_in_members!(field, members)
|
|
1350
|
+
status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
|
|
1351
|
+
unless status == :ok
|
|
1352
|
+
# cast is the offending member here, and published its error code.
|
|
1353
|
+
# nil is the one member written on purpose, meaning "null is allowed"
|
|
1354
|
+
# — but an absent value never reaches in:, so the fix is worth naming.
|
|
1355
|
+
hint = cast.nil? ? " — an absent value never reaches in:; declare nullable: true to accept an explicit null" : ""
|
|
1356
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} contains #{cast.inspect}, " \
|
|
1357
|
+
"which is not a valid :#{field[:type]} (#{published})#{hint}"
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
field[:in] = freeze_in_members(cast)
|
|
1361
|
+
field[:in_published] = freeze_authored(published) unless published == cast.to_a
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
def freeze_in_members(members)
|
|
1365
|
+
members.is_a?(Set) ? members.to_set { |member| freeze_authored(member) }.freeze : freeze_authored(members)
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
# A Range is kept exactly as written, unlike a list: casting its
|
|
1369
|
+
# endpoints would change what it means. `0..Float::INFINITY` on a :float
|
|
1370
|
+
# and `1.5..3` on an :integer are real bounds whose endpoints no cast
|
|
1371
|
+
# accepts, and a :decimal's `0..100` would become BigDecimal endpoints
|
|
1372
|
+
# that export as the STRING "0.0" where `minimum` needs a number.
|
|
1373
|
+
#
|
|
1374
|
+
# What does fail every request is an endpoint the cast value cannot be
|
|
1375
|
+
# compared with — `"1".."5"` on an :integer, `1..5` on a :string,
|
|
1376
|
+
# `.."9.99"` on a :decimal. cover? then answers false for every value, so
|
|
1377
|
+
# that is caught here. The probe asks exactly what cover? will — begin
|
|
1378
|
+
# <=> value, then value <=> end — so whatever the host's own <=> allows
|
|
1379
|
+
# (ActiveSupport lets a Date range bound a :datetime) is allowed here too.
|
|
1380
|
+
def assert_comparable_range!(field, range)
|
|
1381
|
+
probe = RANGE_PROBES.fetch(field[:type])
|
|
1382
|
+
# A NaN endpoint compares to nothing, by design, whatever it stands
|
|
1383
|
+
# beside — not evidence of a wrong-TYPED bound (a String range on an
|
|
1384
|
+
# :integer), which is what this check exists to catch. It is left
|
|
1385
|
+
# alone here exactly as an infinite endpoint already is (INFINITY
|
|
1386
|
+
# compares fine); the exporter separately omits it, since it is
|
|
1387
|
+
# never `finite?`.
|
|
1388
|
+
# Wrapped in an Array so a `false` endpoint still reads as found.
|
|
1389
|
+
stray = if !range.begin.nil? && !nan?(range.begin) && (range.begin <=> probe).nil? then [range.begin]
|
|
1390
|
+
elsif !range.end.nil? && !nan?(range.end) && (probe <=> range.end).nil? then [range.end]
|
|
1391
|
+
end
|
|
1392
|
+
return unless stray
|
|
1393
|
+
|
|
1394
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} is a Range of #{stray.first.class} " \
|
|
1395
|
+
"(#{range.inspect}), which a :#{field[:type]} value cannot be compared with — " \
|
|
1396
|
+
"no value could satisfy it; write the bounds as :#{field[:type]} values"
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
def nan?(value)
|
|
1400
|
+
value.respond_to?(:nan?) && value.nan?
|
|
1401
|
+
end
|
|
1402
|
+
|
|
835
1403
|
def validate_json_opts!(field)
|
|
836
1404
|
name = field[:name]
|
|
837
1405
|
if field[:required] && field.key?(:default)
|
|
@@ -930,6 +1498,28 @@ module Permittable
|
|
|
930
1498
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
|
|
931
1499
|
end
|
|
932
1500
|
|
|
1501
|
+
# A Symbol (or String) `format:` names a preset; a Regexp is used as
|
|
1502
|
+
# given. Resolving here means request-time matching stays a plain
|
|
1503
|
+
# Regexp#match?, and an authored `default:`/`example:` is checked against
|
|
1504
|
+
# the resolved pattern like any other. The preset NAME is kept on the
|
|
1505
|
+
# field so exporters and the RSpec matcher can speak in presets.
|
|
1506
|
+
def resolve_format!(field)
|
|
1507
|
+
preset = field[:format]
|
|
1508
|
+
return if preset.nil? || preset.is_a?(Regexp)
|
|
1509
|
+
|
|
1510
|
+
unless preset.is_a?(Symbol) || preset.is_a?(String)
|
|
1511
|
+
raise ArgumentError, "#{LABEL}: :format for field :#{field[:name]} must be a Regexp or a preset name " \
|
|
1512
|
+
"(presets: #{FORMATS.keys.join(', ')})"
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1515
|
+
spec = FORMATS.fetch(preset.to_sym) do
|
|
1516
|
+
raise ArgumentError, "#{LABEL}: unknown :format preset :#{preset} for field :#{field[:name]} " \
|
|
1517
|
+
"(presets: #{FORMATS.keys.join(', ')}, or pass a Regexp)"
|
|
1518
|
+
end
|
|
1519
|
+
field[:format_name] = preset.to_sym
|
|
1520
|
+
field[:format] = spec[:pattern]
|
|
1521
|
+
end
|
|
1522
|
+
|
|
933
1523
|
def resolve_normalizer!(field)
|
|
934
1524
|
normalizer = field[:normalize]
|
|
935
1525
|
return if normalizer.nil?
|
|
@@ -943,85 +1533,69 @@ module Permittable
|
|
|
943
1533
|
|
|
944
1534
|
# An authored value (`default:`, or a documentation `example:`) must
|
|
945
1535
|
# satisfy the field's own contract — catching a lie at class load beats
|
|
946
|
-
# shipping it to every request (or publishing it in generated docs)
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
1536
|
+
# shipping it to every request (or publishing it in generated docs) —
|
|
1537
|
+
# which is checked here by normalizing and casting it, same as a request's
|
|
1538
|
+
# value. `default: " free "` with `normalize: :squish` is checked as
|
|
1539
|
+
# "free"; `default: "18"` on an :integer is checked as 18.
|
|
1540
|
+
#
|
|
1541
|
+
# What is STORED from that differs by whether the field has `transform:`.
|
|
1542
|
+
# With none, the cast result is stored — the form a request sending the
|
|
1543
|
+
# same value gets, and what used to be thrown away: `default: "18"` used
|
|
1544
|
+
# to be handed to every request omitting it as the String "18", and
|
|
1545
|
+
# `:boolean, default: "false"` gave the app a truthy String.
|
|
1546
|
+
# With a `transform:`, the value is stored exactly AS AUTHORED instead —
|
|
1547
|
+
# `transform:` never runs on a default (see AuthoredValues), so casting it
|
|
1548
|
+
# here would silently change its type out from under an author who, per
|
|
1549
|
+
# the README, writes such a default in the shape the action should
|
|
1550
|
+
# receive: `default: 25` beside `transform: ->(v) { v.to_i }` on a
|
|
1551
|
+
# :string field means the app gets the Integer 25 either way, whether the
|
|
1552
|
+
# request sent "25" (cast then transformed) or omitted the field
|
|
1553
|
+
# (authored as the already-final Integer).
|
|
950
1554
|
def validate_authored_value!(field, opt)
|
|
951
1555
|
return unless field.key?(opt)
|
|
952
1556
|
return if authored_nil!(field, opt)
|
|
953
1557
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1558
|
+
# Copied first, like a request's String, so a mutating `normalize:`
|
|
1559
|
+
# proc cannot rewrite the host's own literal.
|
|
1560
|
+
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1561
|
+
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1562
|
+
status, result = Coercion.check_scalar(field, value)
|
|
1563
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1564
|
+
|
|
1565
|
+
field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
# An array's authored value is validated by the REQUEST walker itself
|
|
1569
|
+
# (see AuthoredValues) exactly as validate_authored_value! validates a
|
|
1570
|
+
# scalar's: elements cast, nested hashes and arrays read at every depth, a
|
|
1571
|
+
# sub-field's own default: filled in, `""` on a nullable sub-field made
|
|
1572
|
+
# the explicit nil a request would get, keys the block does not declare
|
|
1573
|
+
# dropped (as `unknown: :ignore` drops them), and the array's own
|
|
1574
|
+
# `validate:` run over the result. A hand-rolled one-level check used to
|
|
1575
|
+
# cast only the top level of each element, and got every one of those
|
|
1576
|
+
# wrong.
|
|
1577
|
+
#
|
|
1578
|
+
# What is STORED follows the same split as a scalar's: without
|
|
1579
|
+
# `transform:`, the walker's read (exactly what a request sending it
|
|
1580
|
+
# gets); with one, the array exactly AS AUTHORED — the walker still runs,
|
|
1581
|
+
# so a declaration mistake (an element `validate:` refuses, a sub-field
|
|
1582
|
+
# default out of bounds) still fails at class load, but its cast result
|
|
1583
|
+
# is discarded rather than stored. The array's OWN `transform:` is
|
|
1584
|
+
# deliberately never run on a default either way; a SUB-FIELD's
|
|
1585
|
+
# `transform:` still runs during that walk, so "the walker's read" above
|
|
1586
|
+
# really is what an equivalent request produces — see AuthoredValues.
|
|
961
1587
|
def validate_array_authored_value!(field, opt)
|
|
962
1588
|
value = field[opt]
|
|
963
1589
|
return if authored_nil!(field, opt)
|
|
964
1590
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
965
|
-
if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
966
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract (length)"
|
|
967
|
-
end
|
|
968
|
-
|
|
969
|
-
validate_array_elements!(field, opt, value) if field[:of]
|
|
970
|
-
validate_array_element_hashes!(field, opt, value) if field[:fields]
|
|
971
|
-
field[opt] = freeze_authored(value)
|
|
972
|
-
end
|
|
973
|
-
|
|
974
|
-
# The nested-block counterpart of the of: element check below. Without it
|
|
975
|
-
# `field[:of]` was nil for a block array, so its `default:` skipped
|
|
976
|
-
# validation entirely and whatever was authored went straight to every
|
|
977
|
-
# request that omitted the key. Shallow in the same way the of: check is:
|
|
978
|
-
# required sub-fields must be present and scalar ones must satisfy their
|
|
979
|
-
# own contract, which is what an authored value gets wrong.
|
|
980
|
-
def validate_array_element_hashes!(field, opt, value)
|
|
981
|
-
value.each do |element|
|
|
982
|
-
unless element.is_a?(Hash)
|
|
983
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \
|
|
984
|
-
"where the block declares a hash"
|
|
985
|
-
end
|
|
986
1591
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1592
|
+
read, violations = AuthoredValues.read_array(field, value)
|
|
1593
|
+
unless violations.empty?
|
|
1594
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
|
|
1595
|
+
"#{AuthoredValues.summary(violations)}"
|
|
991
1596
|
end
|
|
992
|
-
end
|
|
993
|
-
|
|
994
|
-
def validate_array_element_field!(field, opt, element, sub)
|
|
995
|
-
# Normalized before absence is read, and absence read with the runtime's
|
|
996
|
-
# own rule: a default: is applied WITHOUT revalidation, so anything this
|
|
997
|
-
# check waves through is handed to the app unexamined — and "" here used
|
|
998
|
-
# to mean a default could carry the very value a client is refused.
|
|
999
|
-
value = Coercion.apply_normalize(sub[:normalize], element[sub[:name]])
|
|
1000
|
-
if Coercion.absent_value?(value)
|
|
1001
|
-
# nullable: splits that rule exactly as permittable_explicit_null?
|
|
1002
|
-
# does — a key present but empty is an explicit null, not an absence.
|
|
1003
|
-
return if sub[:nullable] && element.key?(sub[:name])
|
|
1004
|
-
return unless sub[:required]
|
|
1005
|
-
|
|
1006
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \
|
|
1007
|
-
"which the block declares as required"
|
|
1008
|
-
end
|
|
1009
|
-
return unless sub[:kind] == :scalar
|
|
1010
1597
|
|
|
1011
|
-
|
|
1012
|
-
return if status == :ok
|
|
1013
|
-
|
|
1014
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} has :#{sub[:name]} " \
|
|
1015
|
-
"violating its own contract (#{code})"
|
|
1016
|
-
end
|
|
1017
|
-
|
|
1018
|
-
def validate_array_elements!(field, opt, value)
|
|
1019
|
-
value.each do |element|
|
|
1020
|
-
status, code = Coercion.cast(field[:of], element)
|
|
1021
|
-
next if status == :ok
|
|
1022
|
-
|
|
1023
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
|
|
1024
|
-
end
|
|
1598
|
+
field[opt] = freeze_authored(field[:transform] ? value : read)
|
|
1025
1599
|
end
|
|
1026
1600
|
|
|
1027
1601
|
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
@@ -1068,7 +1642,11 @@ module Permittable
|
|
|
1068
1642
|
def validate_message!(field)
|
|
1069
1643
|
spec = field[:message]
|
|
1070
1644
|
return if spec.nil?
|
|
1071
|
-
|
|
1645
|
+
|
|
1646
|
+
if spec.is_a?(String)
|
|
1647
|
+
field[:message] = freeze_authored(spec)
|
|
1648
|
+
return
|
|
1649
|
+
end
|
|
1072
1650
|
|
|
1073
1651
|
valid_hash = spec.is_a?(Hash) && !spec.empty? &&
|
|
1074
1652
|
spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
|
|
@@ -1077,7 +1655,7 @@ module Permittable
|
|
|
1077
1655
|
"or a Hash of violation code => String (e.g. { missing: \"is required\" })"
|
|
1078
1656
|
end
|
|
1079
1657
|
|
|
1080
|
-
field[:message] = spec.transform_keys(&:to_sym)
|
|
1658
|
+
field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
|
|
1081
1659
|
end
|
|
1082
1660
|
end
|
|
1083
1661
|
|
|
@@ -1201,9 +1779,16 @@ module Permittable
|
|
|
1201
1779
|
return if checked.empty?
|
|
1202
1780
|
|
|
1203
1781
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
1782
|
+
allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
|
|
1204
1783
|
begin
|
|
1205
|
-
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1784
|
+
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1785
|
+
types: types, check_types: Permittable.check_column_types,
|
|
1786
|
+
allowed: allowed)
|
|
1206
1787
|
rescue ArgumentError => e
|
|
1788
|
+
# The type error carries its own guidance; only the missing-column one
|
|
1789
|
+
# needs the virtual: hint appended.
|
|
1790
|
+
raise e unless e.message.include?("does not exist in the database")
|
|
1791
|
+
|
|
1207
1792
|
raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
|
|
1208
1793
|
end
|
|
1209
1794
|
end
|
|
@@ -1308,6 +1893,39 @@ module Permittable
|
|
|
1308
1893
|
|
|
1309
1894
|
private
|
|
1310
1895
|
|
|
1896
|
+
# ParamsWrapper copies a JSON body under the controller's wrapper key
|
|
1897
|
+
# (`user` for UsersController) — on by default in a Rails app — and a
|
|
1898
|
+
# rootless contract then saw that copy as an unknown top-level key on every
|
|
1899
|
+
# well-formed request. Whether the copy is Rails' own has to be read HERE,
|
|
1900
|
+
# before ParamsWrapper#process_action (next in the chain) runs: once it has
|
|
1901
|
+
# wrapped, `_wrapper_enabled?` answers false, because params now carry the
|
|
1902
|
+
# key. Asking afterwards could not tell Rails' copy from a client that sent
|
|
1903
|
+
# `user` itself, which is exactly the key the check must still flag.
|
|
1904
|
+
# Private, like the method it wraps — a public one would become an action.
|
|
1905
|
+
# A plain duck has no process_action and no ParamsWrapper, so this never
|
|
1906
|
+
# runs there, and the guards keep an actionpack-free host inert.
|
|
1907
|
+
# Assigned on every request, never only when true: a controller instance
|
|
1908
|
+
# dispatched twice would otherwise carry one request's exemption into the
|
|
1909
|
+
# next, where a `user` the client did send would pass as Rails' copy.
|
|
1910
|
+
def process_action(*)
|
|
1911
|
+
@permittable_wrapper_key = permittable_wrapper_copy_key
|
|
1912
|
+
super
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
# The wrapper key, if ParamsWrapper is about to copy the body under it;
|
|
1916
|
+
# nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
|
|
1917
|
+
# string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
|
|
1918
|
+
# — the form the Rails docs use — answers "not sent" even when the client
|
|
1919
|
+
# sent `user` itself, and Rails wraps anyway. Asking the same params for
|
|
1920
|
+
# the String keeps that client's key the client's, whichever spelling the
|
|
1921
|
+
# host chose.
|
|
1922
|
+
def permittable_wrapper_copy_key
|
|
1923
|
+
return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
|
|
1924
|
+
|
|
1925
|
+
key = _wrapper_key.to_s
|
|
1926
|
+
key unless request.parameters.key?(key)
|
|
1927
|
+
end
|
|
1928
|
+
|
|
1311
1929
|
# The value permitted_params memoizes: the validated params, or the
|
|
1312
1930
|
# InvalidParameters that rejected them. ArgumentError is deliberately NOT
|
|
1313
1931
|
# memoized — a contract that does not cover the action is a bug to fix, not
|
|
@@ -1326,8 +1944,9 @@ module Permittable
|
|
|
1326
1944
|
source = permittable_root_hash(rule, violations)
|
|
1327
1945
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
1328
1946
|
if source
|
|
1329
|
-
|
|
1330
|
-
|
|
1947
|
+
checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
|
|
1948
|
+
result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
|
|
1949
|
+
unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
|
|
1331
1950
|
end
|
|
1332
1951
|
# finalize only sees a hash every field vouched for — never garbage.
|
|
1333
1952
|
result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
|
|
@@ -1359,6 +1978,10 @@ module Permittable
|
|
|
1359
1978
|
end
|
|
1360
1979
|
return ActiveSupport::HashWithIndifferentAccess.new unless source
|
|
1361
1980
|
|
|
1981
|
+
# Deliberately NOT deep-copied, unlike the enforce path's result: this is
|
|
1982
|
+
# the pre-contract app's own params, and `params.permit` hands its Strings
|
|
1983
|
+
# back by reference too — copying here would change behaviour in the one
|
|
1984
|
+
# mode whose promise is that nothing changes.
|
|
1362
1985
|
passed = ActiveSupport::HashWithIndifferentAccess.new(source)
|
|
1363
1986
|
rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
|
|
1364
1987
|
end
|
|
@@ -1377,25 +2000,121 @@ module Permittable
|
|
|
1377
2000
|
end
|
|
1378
2001
|
|
|
1379
2002
|
def permittable_violation_summary(violations)
|
|
2003
|
+
# The param is the client-controlled part; the message (or code) is the
|
|
2004
|
+
# developer's, so it is handed over separately and never escaped — a
|
|
2005
|
+
# YAML `|` message ending in "\n" must not quote every name it follows.
|
|
2006
|
+
#
|
|
2007
|
+
# An unknown-key violation's `param:` is already reportable_text — valid
|
|
2008
|
+
# UTF-8, but scrubbed to U+FFFD wherever the key wasn't. That is right
|
|
2009
|
+
# for `details`/instrumentation (a machine reads it and only needs it not
|
|
2010
|
+
# to crash `to_json`), but prose can do better: permittable_prose_utf8
|
|
2011
|
+
# keeps a legacy byte transcodable and an invalid one visible as \xNN
|
|
2012
|
+
# rather than replacing it, so prose reads the RAW key when one was
|
|
2013
|
+
# saved (see permittable_unknown_key_violation), and falls back to the
|
|
2014
|
+
# param for any other violation.
|
|
1380
2015
|
permittable_prose_list(violations) do |v|
|
|
1381
|
-
|
|
2016
|
+
name = @permittable_unknown_key_raw&.[](v) || v[:param].to_s
|
|
2017
|
+
[name, v[:message] ? " #{v[:message]}" : " (#{v[:code]})"]
|
|
1382
2018
|
end
|
|
1383
2019
|
end
|
|
1384
2020
|
|
|
1385
2021
|
# See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
|
|
1386
2022
|
# undeclared keys used to produce a 50,000-item sentence — a megabyte of
|
|
1387
2023
|
# log line, or of exception message handed to every error tracker.
|
|
1388
|
-
# The block
|
|
1389
|
-
# shown — the rest are counted, never rendered.
|
|
2024
|
+
# The block returns one item's name, or [name, suffix], and is called
|
|
2025
|
+
# only for the items actually shown — the rest are counted, never rendered.
|
|
1390
2026
|
def permittable_prose_list(items)
|
|
1391
|
-
shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(yield(item)) }.join(", ")
|
|
2027
|
+
shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(*yield(item)) }.join(", ")
|
|
1392
2028
|
return shown if items.length <= PROSE_LIST_LIMIT
|
|
1393
2029
|
|
|
1394
2030
|
"#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
|
|
1395
2031
|
end
|
|
1396
2032
|
|
|
1397
|
-
|
|
1398
|
-
|
|
2033
|
+
# See PROSE_ITEM_LIMIT, PROSE_UNSAFE and PROSE_AMBIGUOUS. The item is the
|
|
2034
|
+
# name plus the suffix, truncated as one. Only a name that needs it is
|
|
2035
|
+
# quoted and escaped, judged by the part of it the truncated item would
|
|
2036
|
+
# SHOW — a control character past the cut is not printed, so it quotes
|
|
2037
|
+
# nothing. Every ordinary name therefore prints exactly as before, just
|
|
2038
|
+
# always as UTF-8: a Windows-1252 or binary key is converted rather than
|
|
2039
|
+
# written raw, and names of mixed encodings can be joined.
|
|
2040
|
+
def permittable_prose_item(name, suffix = "")
|
|
2041
|
+
text = permittable_prose_utf8(name[0, PROSE_SCAN_LIMIT])
|
|
2042
|
+
suffix = permittable_prose_utf8(suffix)
|
|
2043
|
+
more = !name[PROSE_SCAN_LIMIT].nil?
|
|
2044
|
+
fits = !more && text.length + suffix.length <= PROSE_ITEM_LIMIT
|
|
2045
|
+
shown = fits ? text : text[0, PROSE_ITEM_LIMIT - 3]
|
|
2046
|
+
if !shown.valid_encoding? || shown.match?(PROSE_UNSAFE) || shown.match?(PROSE_AMBIGUOUS)
|
|
2047
|
+
return permittable_prose_quoted(text, more, suffix)
|
|
2048
|
+
end
|
|
2049
|
+
|
|
2050
|
+
fits ? "#{text}#{suffix}" : "#{"#{text}#{suffix}"[0, PROSE_ITEM_LIMIT - 3]}..."
|
|
2051
|
+
end
|
|
2052
|
+
|
|
2053
|
+
# The name is truncated by whole escapes, never through one: cutting the
|
|
2054
|
+
# escaped text at a fixed width could print a dangling backslash, or half
|
|
2055
|
+
# of an escape. When the name itself is cut, the "..." goes outside the
|
|
2056
|
+
# closing quote, so the quotes still delimit exactly what is shown. A
|
|
2057
|
+
# quoted name that fits the limit on its own is never cut: the suffix is
|
|
2058
|
+
# cut instead, to whatever room is left — possibly none — and the "..."
|
|
2059
|
+
# that marks it may then run up to three characters past the limit. A
|
|
2060
|
+
# dropped developer suffix is better flagged than hidden, and the name is
|
|
2061
|
+
# the part a reader is there for. Only as many characters are escaped as
|
|
2062
|
+
# can be shown.
|
|
2063
|
+
def permittable_prose_quoted(text, more, suffix)
|
|
2064
|
+
budget = PROSE_ITEM_LIMIT - 2 # the two quotes
|
|
2065
|
+
pieces = []
|
|
2066
|
+
length = 0
|
|
2067
|
+
text.each_char do |char|
|
|
2068
|
+
pieces << permittable_prose_escape(char)
|
|
2069
|
+
length += pieces.last.length
|
|
2070
|
+
break if length > budget
|
|
2071
|
+
end
|
|
2072
|
+
if !more && length <= budget
|
|
2073
|
+
quoted = "\"#{pieces.join}\""
|
|
2074
|
+
return "#{quoted}#{suffix}" if quoted.length + suffix.length <= PROSE_ITEM_LIMIT
|
|
2075
|
+
|
|
2076
|
+
return "#{quoted}#{suffix[0, [PROSE_ITEM_LIMIT - 3 - quoted.length, 0].max]}..."
|
|
2077
|
+
end
|
|
2078
|
+
|
|
2079
|
+
length -= pieces.pop.length while length > budget - 3
|
|
2080
|
+
"\"#{pieces.join}\"..."
|
|
2081
|
+
end
|
|
2082
|
+
|
|
2083
|
+
# A byte that is not valid UTF-8 is shown as \xNN rather than passed
|
|
2084
|
+
# through: it is not a character a person can read, and a lone 0x85 or
|
|
2085
|
+
# 0x9B is NEL or CSI to a Latin-1 terminal. A character beyond the BMP
|
|
2086
|
+
# (the Cf tag characters) is \u{XXXXX}, since \uXXXX holds only four digits.
|
|
2087
|
+
def permittable_prose_escape(char)
|
|
2088
|
+
return char.bytes.map { |byte| format('\x%02X', byte) }.join unless char.valid_encoding?
|
|
2089
|
+
|
|
2090
|
+
PROSE_ESCAPES.fetch(char) do
|
|
2091
|
+
next char unless char.match?(PROSE_UNSAFE)
|
|
2092
|
+
|
|
2093
|
+
char.ord > 0xFFFF ? format('\u{%X}', char.ord) : format('\u%04X', char.ord)
|
|
2094
|
+
end
|
|
2095
|
+
end
|
|
2096
|
+
|
|
2097
|
+
# PROSE_UNSAFE is a UTF-8 pattern, and matching it against a binary key
|
|
2098
|
+
# with high bytes raises Encoding::CompatibilityError — a log line must
|
|
2099
|
+
# never be what fails a request. A binary key has no charset to convert
|
|
2100
|
+
# from, and Rack hands UTF-8 bytes over as binary, so it is read as UTF-8.
|
|
2101
|
+
# A key in a real encoding is transcoded character by character: what
|
|
2102
|
+
# maps is converted, and only a byte that does not (Windows-1252 leaves
|
|
2103
|
+
# 0x81, 0x8D, 0x8F, 0x90 and 0x9D undefined) is kept as an invalid byte,
|
|
2104
|
+
# which the escaper then shows as \xNN — rather than reading the whole
|
|
2105
|
+
# key as UTF-8, which turned a mappable é into \xE9 and mojibake into
|
|
2106
|
+
# characters the client never sent.
|
|
2107
|
+
def permittable_prose_utf8(item)
|
|
2108
|
+
return item if item.encoding == Encoding::UTF_8
|
|
2109
|
+
return item.dup.force_encoding(Encoding::UTF_8) if item.encoding == Encoding::BINARY || item.ascii_only?
|
|
2110
|
+
|
|
2111
|
+
converter = Encoding::Converter.new(item.encoding, Encoding::UTF_8)
|
|
2112
|
+
source = item.dup
|
|
2113
|
+
out = String.new(encoding: Encoding::UTF_8)
|
|
2114
|
+
out << converter.primitive_errinfo[3].force_encoding(Encoding::UTF_8) until converter.primitive_convert(source, out) == :finished
|
|
2115
|
+
out
|
|
2116
|
+
rescue EncodingError # no converter, as for a dummy encoding such as UTF-7
|
|
2117
|
+
item.dup.force_encoding(Encoding::UTF_8)
|
|
1399
2118
|
end
|
|
1400
2119
|
|
|
1401
2120
|
# One violation detail entry. A field's `message:` (String, or Hash keyed
|
|
@@ -1495,7 +2214,7 @@ module Permittable
|
|
|
1495
2214
|
# Already normalized by permittable_normalized, before the absence rule.
|
|
1496
2215
|
permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
|
|
1497
2216
|
when :json
|
|
1498
|
-
permittable_check_whole(field,
|
|
2217
|
+
permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
|
|
1499
2218
|
when :nested
|
|
1500
2219
|
if value.is_a?(Hash)
|
|
1501
2220
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
@@ -1512,14 +2231,27 @@ module Permittable
|
|
|
1512
2231
|
end
|
|
1513
2232
|
end
|
|
1514
2233
|
|
|
2234
|
+
# Coercion.check_json, with the copy the result needs made in the middle.
|
|
2235
|
+
# The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2236
|
+
# rebuilt its containers but not the Strings inside them, so it is
|
|
2237
|
+
# deep-copied for the reason permittable_normalized copies a String — but
|
|
2238
|
+
# only once it is within its bounds. Copying first meant a megabyte
|
|
2239
|
+
# payload refused on `length:` or `max_depth:` was copied in full just to
|
|
2240
|
+
# be refused, undoing the early exit those bounds exist for.
|
|
2241
|
+
def permittable_check_json(field, value)
|
|
2242
|
+
status, code = Coercion.check_json_bounds(field, value)
|
|
2243
|
+
return [status, code] unless status == :ok
|
|
2244
|
+
|
|
2245
|
+
Coercion.check_custom(field[:validate], value.deep_dup)
|
|
2246
|
+
end
|
|
2247
|
+
|
|
1515
2248
|
# The shared tail of the two kinds whose entire value is checked in one
|
|
1516
2249
|
# call — a scalar, or an opaque hash. A clean value is transformed into the
|
|
1517
2250
|
# result; anything else records its code.
|
|
1518
2251
|
def permittable_check_whole(field, outcome, full, result, violations:)
|
|
1519
2252
|
status, out = outcome
|
|
1520
2253
|
if status == :ok
|
|
1521
|
-
|
|
1522
|
-
result[field[:name].to_s] = out
|
|
2254
|
+
result[field[:name].to_s] = permittable_transform(field, out)
|
|
1523
2255
|
else
|
|
1524
2256
|
violations << permittable_violation(field, full, out)
|
|
1525
2257
|
end
|
|
@@ -1541,14 +2273,40 @@ module Permittable
|
|
|
1541
2273
|
out = value.each_with_index.map do |element, index|
|
|
1542
2274
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
1543
2275
|
end
|
|
1544
|
-
|
|
2276
|
+
# validate: and transform: see only a fully-valid array. A partially-nil
|
|
2277
|
+
# one (element violations) would hand user code garbage it never agreed
|
|
2278
|
+
# to see — and for validate: that was a crash, not just garbage:
|
|
2279
|
+
# `validate: ->(a) { a.sum < 100 }` sent `["x", 2]` raised TypeError on
|
|
2280
|
+
# the nil where "x" failed to cast, turning the element's 422 into a 500.
|
|
2281
|
+
#
|
|
2282
|
+
# The cost is real and accepted: the whole-array verdict is no longer
|
|
2283
|
+
# reported ALONGSIDE element violations. `[1, "x", 1]` against a
|
|
2284
|
+
# uniqueness validator reports only `ids[1]`; the client fixes it,
|
|
2285
|
+
# resends, and only then learns of the duplicate. Running app code over
|
|
2286
|
+
# nils it never agreed to handle is the worse failure.
|
|
2287
|
+
#
|
|
2288
|
+
# An undeclared key inside an element (`unknown: :error`) is not such a
|
|
2289
|
+
# violation: it removes nothing from the element validate: sees, so it
|
|
2290
|
+
# does not stop validate: from running.
|
|
2291
|
+
#
|
|
2292
|
+
# transform: is stricter, as on the scalar path: it runs only when
|
|
2293
|
+
# NOTHING violated, validate: included — a transform may rely on what
|
|
2294
|
+
# validate: checked (`Math.sqrt` after "all positive").
|
|
2295
|
+
elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
|
|
2296
|
+
if field[:validate] && elements_valid
|
|
1545
2297
|
status, code = Coercion.check_custom(field[:validate], out)
|
|
1546
2298
|
violations << permittable_violation(field, path, code) unless status == :ok
|
|
1547
2299
|
end
|
|
1548
2300
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
1549
2301
|
# violations) would hand user code garbage it never agreed to see.
|
|
1550
|
-
|
|
1551
|
-
|
|
2302
|
+
violations.length == before ? permittable_transform(field, out) : out
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
# The one place a field's `transform:` is applied — a seam, so that
|
|
2306
|
+
# AuthoredValues can walk an authored default through this same walker
|
|
2307
|
+
# without running app code over it at class load.
|
|
2308
|
+
def permittable_transform(field, value)
|
|
2309
|
+
field[:transform] ? field[:transform].call(value) : value
|
|
1552
2310
|
end
|
|
1553
2311
|
|
|
1554
2312
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
@@ -1561,7 +2319,7 @@ module Permittable
|
|
|
1561
2319
|
path: path, unknown: unknown, top_level: false, violations: violations)
|
|
1562
2320
|
end
|
|
1563
2321
|
|
|
1564
|
-
status, out = Coercion.cast(field[:of], element)
|
|
2322
|
+
status, out = Coercion.cast(field[:of], permittable_own(element))
|
|
1565
2323
|
return out if status == :ok
|
|
1566
2324
|
|
|
1567
2325
|
violations << permittable_violation(field, path, out)
|
|
@@ -1575,18 +2333,34 @@ module Permittable
|
|
|
1575
2333
|
# corruption strict coercion exists to refuse, delivered by the gem's own
|
|
1576
2334
|
# preset. Only scalars take normalize:, and apply_normalize is itself a
|
|
1577
2335
|
# no-op without one, so it owns that decision for every caller.
|
|
2336
|
+
#
|
|
2337
|
+
# It is also where a request's String stops being the request's. Nothing
|
|
2338
|
+
# the walker hands to app code — `normalize:`, `validate:`, `transform:`,
|
|
2339
|
+
# the result — may alias the caller's params, or `permitted_params[:name]
|
|
2340
|
+
# << "x"` (or `normalize: ->(v) { v.strip! || v }`) rewrites the caller's
|
|
2341
|
+
# Hash or ActionController::Parameters behind the app's back. Copying at
|
|
2342
|
+
# the walker's input, ahead of normalize:, makes it one copy per String;
|
|
2343
|
+
# String#dup shares a long String's buffer copy-on-write, so the copy is
|
|
2344
|
+
# cheap until someone writes to it. `of:` elements get the same treatment
|
|
2345
|
+
# in permittable_check_element, and a :json hash in permittable_check_json.
|
|
1578
2346
|
def permittable_normalized(field, value)
|
|
1579
|
-
Coercion.apply_normalize(field[:normalize], value)
|
|
2347
|
+
Coercion.apply_normalize(field[:normalize], permittable_own(value))
|
|
2348
|
+
end
|
|
2349
|
+
|
|
2350
|
+
def permittable_own(value)
|
|
2351
|
+
value.is_a?(String) ? value.dup : value
|
|
1580
2352
|
end
|
|
1581
2353
|
|
|
1582
2354
|
# An authored default belongs to the contract, which is frozen data (see
|
|
1583
|
-
# ContractBuilder#freeze_authored)
|
|
1584
|
-
#
|
|
1585
|
-
#
|
|
2355
|
+
# ContractBuilder#freeze_authored), so every request gets a deep copy of it.
|
|
2356
|
+
# Copying only a top-level String was not enough: HashWithIndifferentAccess
|
|
2357
|
+
# copies a frozen Array or Hash as it assigns it, but not what is INSIDE
|
|
2358
|
+
# one, so `permitted_params[:tags].first << "x"` on an `of: :string`
|
|
2359
|
+
# default — or any edit to a String in a :json default — raised
|
|
2360
|
+
# FrozenError. A deep copy leaves every value in the result the app's own
|
|
1586
2361
|
# to mutate.
|
|
1587
2362
|
def permittable_default(field)
|
|
1588
|
-
|
|
1589
|
-
value.is_a?(String) ? value.dup : value
|
|
2363
|
+
field[:default].deep_dup
|
|
1590
2364
|
end
|
|
1591
2365
|
|
|
1592
2366
|
# nil and "" are both ABSENT — see the module comment.
|
|
@@ -1608,21 +2382,105 @@ module Permittable
|
|
|
1608
2382
|
|
|
1609
2383
|
declared = fields.map { |f| f[:name].to_s }
|
|
1610
2384
|
extra = hash.keys.map(&:to_s) - declared
|
|
1611
|
-
|
|
2385
|
+
if top_level
|
|
2386
|
+
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2387
|
+
extra -= [permittable_configured_csrf_key].compact
|
|
2388
|
+
end
|
|
1612
2389
|
return if extra.empty?
|
|
1613
2390
|
|
|
1614
2391
|
if unknown == :error
|
|
1615
|
-
extra.each { |key| violations <<
|
|
2392
|
+
extra.each { |key| violations << permittable_unknown_key_violation(path, key) }
|
|
1616
2393
|
elsif respond_to?(:logger) && logger
|
|
1617
|
-
listed = permittable_prose_list(extra) { |key| permittable_path(path, key) }
|
|
2394
|
+
listed = permittable_prose_list(extra) { |key| permittable_path(path, permittable_prose_utf8(key)) }
|
|
1618
2395
|
logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
|
|
1619
2396
|
end
|
|
1620
2397
|
end
|
|
1621
2398
|
|
|
2399
|
+
# The top-level keys THIS request's router put into params, beyond the
|
|
2400
|
+
# fixed UNCHECKED_TOP_LEVEL_KEYS: whatever it matched out of the URL
|
|
2401
|
+
# (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a
|
|
2402
|
+
# path parameter, not a body field). A controller only — a plain params
|
|
2403
|
+
# duck and a standalone Contract have no request, so they exempt nothing
|
|
2404
|
+
# extra. Subtracted from the undeclared keys, so a contract that DECLARES
|
|
2405
|
+
# `id` still has that field checked like any other: the value is real, the
|
|
2406
|
+
# URL carried it.
|
|
2407
|
+
def permittable_request_supplied_keys
|
|
2408
|
+
return [] unless respond_to?(:request) && request.respond_to?(:path_parameters)
|
|
2409
|
+
|
|
2410
|
+
request.path_parameters.keys.map(&:to_s)
|
|
2411
|
+
end
|
|
2412
|
+
|
|
2413
|
+
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2414
|
+
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2415
|
+
# lets an app rename it, and an app that does trips `unknown: :error` on
|
|
2416
|
+
# every ordinary form submission: exactly the bug FORM_KEYS exists to
|
|
2417
|
+
# prevent, just spelled with the app's own key instead of the default one.
|
|
2418
|
+
# The configured name isn't knowable at class-load time (it can vary per
|
|
2419
|
+
# controller, and Rails may not have finished initializing yet), so it's
|
|
2420
|
+
# read fresh here off the live controller instead of folded into a frozen
|
|
2421
|
+
# constant. `request_forgery_protection_token` comes from
|
|
2422
|
+
# ActionController::RequestForgeryProtection, included by ActionController
|
|
2423
|
+
# ::Base; a plain params duck or a standalone Contract has no such method
|
|
2424
|
+
# and exempts nothing beyond FORM_KEYS's own "authenticity_token".
|
|
2425
|
+
def permittable_configured_csrf_key
|
|
2426
|
+
return nil unless respond_to?(:request_forgery_protection_token)
|
|
2427
|
+
|
|
2428
|
+
token = request_forgery_protection_token
|
|
2429
|
+
token && token.to_s
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
# A rootless contract's input without ParamsWrapper's copy of the body,
|
|
2433
|
+
# when Rails made one (see process_action). Removed rather than merely
|
|
2434
|
+
# exempted from the unknown-keys check, because the client never sent that
|
|
2435
|
+
# key: a contract that happens to declare a scalar or array field of the
|
|
2436
|
+
# wrapper's name (`optional :feedback, :string` on FeedbackController)
|
|
2437
|
+
# would otherwise validate Rails' copy of the whole body as that field — a
|
|
2438
|
+
# false 422 invalid_type for a well-formed request.
|
|
2439
|
+
#
|
|
2440
|
+
# Kept, though, when the contract declares that key as a hash container (a
|
|
2441
|
+
# nested block or :json): that rootless contract is reading the copy ON
|
|
2442
|
+
# PURPOSE, a root: spelled as a field, and it worked that way before the
|
|
2443
|
+
# copy was ever dropped — dropping it would turn every such request into
|
|
2444
|
+
# `user missing`. Top level only, where the copy lives; a rooted contract
|
|
2445
|
+
# reads the copy as its root, which is exactly what ParamsWrapper is for.
|
|
2446
|
+
# What is checked changes, not what monitor mode hands back: its raw
|
|
2447
|
+
# pass-through still carries the copy, as the pre-contract app's params did.
|
|
2448
|
+
def permittable_without_wrapper_copy(fields, source)
|
|
2449
|
+
key = @permittable_wrapper_key
|
|
2450
|
+
return source unless key
|
|
2451
|
+
return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
|
|
2452
|
+
|
|
2453
|
+
source.except(key)
|
|
2454
|
+
end
|
|
2455
|
+
|
|
1622
2456
|
def permittable_path(path, key)
|
|
1623
2457
|
path ? "#{path}.#{key}" : key
|
|
1624
2458
|
end
|
|
1625
2459
|
|
|
2460
|
+
# The one place a CLIENT's key enters a path, so the only one converted to
|
|
2461
|
+
# reportable UTF-8 (see Coercion.reportable_text) — every declared key a
|
|
2462
|
+
# request walks through is the contract's own name and is left alone.
|
|
2463
|
+
#
|
|
2464
|
+
# The entry is also remembered by identity, which is how
|
|
2465
|
+
# permittable_check_array tells an undeclared key from a sub-field that
|
|
2466
|
+
# failed. The code alone cannot: a sub-field's validate: may itself
|
|
2467
|
+
# return :unknown.
|
|
2468
|
+
def permittable_unknown_key_violation(path, key)
|
|
2469
|
+
entry = permittable_violation({}, permittable_path(path, Coercion.reportable_text(key)), "unknown")
|
|
2470
|
+
(@permittable_unknown_key_violations ||= {}.compare_by_identity)[entry] = true
|
|
2471
|
+
# Prose (the exception message) gets the richer transcoding instead of
|
|
2472
|
+
# `param:`'s scrubbed-to-U+FFFD text — see permittable_violation_summary.
|
|
2473
|
+
# permittable_prose_utf8, not Coercion.reportable_text, is what keeps the
|
|
2474
|
+
# concatenation with `path` (the contract's own UTF-8 field names) from
|
|
2475
|
+
# raising Encoding::CompatibilityError, the same as the :log line below.
|
|
2476
|
+
(@permittable_unknown_key_raw ||= {}.compare_by_identity)[entry] = permittable_path(path, permittable_prose_utf8(key))
|
|
2477
|
+
entry
|
|
2478
|
+
end
|
|
2479
|
+
|
|
2480
|
+
def permittable_unknown_key_violation?(entry)
|
|
2481
|
+
@permittable_unknown_key_violations&.key?(entry) || false
|
|
2482
|
+
end
|
|
2483
|
+
|
|
1626
2484
|
def permittable_action_name
|
|
1627
2485
|
respond_to?(:action_name) && action_name ? action_name.to_s : nil
|
|
1628
2486
|
end
|
|
@@ -1634,6 +2492,10 @@ module Permittable
|
|
|
1634
2492
|
end
|
|
1635
2493
|
end
|
|
1636
2494
|
|
|
2495
|
+
# Class-load reading of an authored array default:/example: — the request
|
|
2496
|
+
# walker itself, so it needs the concern's body loaded.
|
|
2497
|
+
require "permittable/authored_values"
|
|
2498
|
+
|
|
1637
2499
|
# Contract exporters — the other readers of the frozen contract registry.
|
|
1638
2500
|
# Loaded after the module body so OpenAPI can see the concern's own methods.
|
|
1639
2501
|
require "permittable/json_schema"
|
|
@@ -1646,6 +2508,13 @@ require "permittable/generator"
|
|
|
1646
2508
|
# Standalone contracts — the same DSL callable on any Hash, no controller.
|
|
1647
2509
|
require "permittable/contract"
|
|
1648
2510
|
|
|
2511
|
+
# Contract COVERAGE — the registry crossed with the route set, so a
|
|
2512
|
+
# half-covered controller is as visible as an uncovered one.
|
|
2513
|
+
require "permittable/audit"
|
|
2514
|
+
|
|
2515
|
+
# Reusable field lists — `Permittable.fields` + the builder's `use` verb.
|
|
2516
|
+
require "permittable/field_group"
|
|
2517
|
+
|
|
1649
2518
|
# Boot-time integration (filter_parameters registration, the
|
|
1650
2519
|
# permittable:openapi rake task), Rails apps only
|
|
1651
2520
|
require "permittable/railtie" if defined?(Rails::Railtie)
|