permittable 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- metadata +2 -2
data/lib/permittable.rb
CHANGED
|
@@ -132,11 +132,16 @@ require "permittable/filter_parameter_registry"
|
|
|
132
132
|
# (" " under :squish) cannot satisfy a required field by becoming "". An
|
|
133
133
|
# authored `default:`/`example:` is stored as the contract reads it —
|
|
134
134
|
# normalized and cast, so `default: "18"` on an :integer is 18, and an
|
|
135
|
-
# array's read by the request walker itself. The
|
|
136
|
-
# declaring `transform
|
|
135
|
+
# array's read by the request walker itself. The exceptions: a field
|
|
136
|
+
# declaring `transform:` stores its default exactly AS AUTHORED, never
|
|
137
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
|
-
#
|
|
138
|
+
# so author such a default already in the shape the action should receive —
|
|
139
|
+
# and a `:json` field's is always stored as the walker reads a request's hash
|
|
140
|
+
# (indifferent access, String keys; `transform:` or not), except that a
|
|
141
|
+
# `:json` `example:` is stored as authored (in that same shape), whatever a
|
|
142
|
+
# mutating `validate:` did to the copy it was checked on.
|
|
143
|
+
# An `example:` is cast either way and never transformed: it documents what a
|
|
144
|
+
# client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
|
|
140
145
|
# copy, so no request can corrupt it for the next.
|
|
141
146
|
#
|
|
142
147
|
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
@@ -189,8 +194,10 @@ require "permittable/filter_parameter_registry"
|
|
|
189
194
|
# into an Array. Runs only on request-supplied values: absent fields stay
|
|
190
195
|
# absent, and a `default:` is handed out exactly AS AUTHORED — validated
|
|
191
196
|
# against the field's own contract at class load (as any default is), but
|
|
192
|
-
# neither cast nor transformed
|
|
193
|
-
#
|
|
197
|
+
# neither cast nor transformed (a `:json` field's is read as a request's
|
|
198
|
+
# hash is, indifferent with String keys, but no more than that) — so a
|
|
199
|
+
# request sending a field's default gets the transformed value, a
|
|
200
|
+
# request omitting it the untransformed one.
|
|
194
201
|
# Author a default already in the shape the action should receive:
|
|
195
202
|
# `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
|
|
196
203
|
# paths the Integer 25. A field with no `transform:` still gets its
|
|
@@ -226,20 +233,39 @@ module Permittable
|
|
|
226
233
|
# unknown-keys check must not flag them.
|
|
227
234
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
228
235
|
# Nor the keys an ordinary form POST carries — the CSRF token, the verb
|
|
229
|
-
# override, the encoding probe, and the submit button's name
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
|
|
236
|
+
# override, the encoding probe, and the submit button's name: `commit` from
|
|
237
|
+
# f.submit/submit_tag, `button` from f.button/button_tag (which render
|
|
238
|
+
# <button name="button">, so a browser sends `button=` even with no
|
|
239
|
+
# value). Without this `unknown: :error` was unusable outside a JSON API:
|
|
240
|
+
# every browser form failed on the framework's own keys rather than on
|
|
241
|
+
# anything the client got wrong. Exempt from the CHECK only: unlike the
|
|
242
|
+
# routing keys these are NOT stripped from monitor mode's raw
|
|
243
|
+
# pass-through, where handing back an untouched params hash is the whole
|
|
244
|
+
# promise and a legacy action may well read `_method` itself.
|
|
245
|
+
#
|
|
246
|
+
# The click point of an image submit (image_submit_tag, which has no name
|
|
247
|
+
# of its own to exempt) is a form key too, but not a fixed one: `x` and `y`,
|
|
248
|
+
# or `<name>.x` and `<name>.y`, as a PAIR of digits. See
|
|
249
|
+
# permittable_image_submit_keys.
|
|
250
|
+
FORM_KEYS = %w[authenticity_token _method utf8 commit button].freeze
|
|
237
251
|
# ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
|
|
238
252
|
# is DECIDED with them, which is what the call sites care about — and the
|
|
239
253
|
# asymmetry between them is the deliberate point, so spell it once here
|
|
240
254
|
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
241
255
|
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
242
256
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
257
|
+
# Rails' date_select, datetime_select and time_select submit ONE value as
|
|
258
|
+
# several keys, each named for its position: `starts_on(1i)` the year,
|
|
259
|
+
# `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and
|
|
260
|
+
# `(6i)` the second. Strong parameters passes them through for
|
|
261
|
+
# `permit(:starts_on)` and ActiveRecord assembles them. A contract read
|
|
262
|
+
# only `starts_on`, so such a form lost its date (silently, under the
|
|
263
|
+
# default `unknown: :ignore`) and a required one was always missing. A
|
|
264
|
+
# :date or :datetime field now reads its parts as its own value (see
|
|
265
|
+
# permittable_read_date_parts); this is how many parts each type has.
|
|
266
|
+
DATE_PARTS = { date: 3, datetime: 6 }.freeze
|
|
267
|
+
# One part, as a select sends it: an unsigned decimal integer.
|
|
268
|
+
DATE_PART_FORMAT = /\A\d+\z/
|
|
243
269
|
# The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
|
|
244
270
|
# rootless contract declaring the wrapper key as one of these reads the
|
|
245
271
|
# copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
|
|
@@ -616,6 +642,12 @@ module Permittable
|
|
|
616
642
|
class_attribute :permittable_contracts, instance_accessor: false, default: []
|
|
617
643
|
|
|
618
644
|
rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
|
|
645
|
+
# Registered here, at the include, and not at the first `permit_params`:
|
|
646
|
+
# the README documents `skip_before_action :enforce_params_contract`, and
|
|
647
|
+
# Rails raises when it is asked to skip a callback that is not defined
|
|
648
|
+
# yet. The cost is the order: this check runs before any callback
|
|
649
|
+
# declared after the include (see the README's "Per-request locales"),
|
|
650
|
+
# so a locale switch that must cover it is a `prepend_around_action`.
|
|
619
651
|
before_action :enforce_params_contract if respond_to?(:before_action)
|
|
620
652
|
end
|
|
621
653
|
|
|
@@ -676,17 +708,10 @@ module Permittable
|
|
|
676
708
|
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
677
709
|
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
678
710
|
# top-level key count, same reading as an array's element count) and
|
|
679
|
-
# nesting (`max_depth:`).
|
|
680
|
-
#
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return [status, code] unless status == :ok
|
|
684
|
-
|
|
685
|
-
check_custom(field[:validate], value)
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
# The structural half of check_json, which the request walker runs on
|
|
689
|
-
# its own so that it can copy an accepted hash before app code sees it.
|
|
711
|
+
# nesting (`max_depth:`). The walker (permittable_check_json) runs them
|
|
712
|
+
# before `validate:`, so that it can copy an accepted hash before app
|
|
713
|
+
# code sees it, and an authored `default:`/`example:` goes through that
|
|
714
|
+
# walker too (see AuthoredValues).
|
|
690
715
|
def check_json_bounds(field, value)
|
|
691
716
|
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
692
717
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
@@ -817,7 +842,8 @@ module Permittable
|
|
|
817
842
|
# here (a plain :string field, not :decimal itself) must render the same
|
|
818
843
|
# way regardless of whether that patch happens to be loaded.
|
|
819
844
|
when BigDecimal then [:ok, value.to_s("F")]
|
|
820
|
-
when Numeric
|
|
845
|
+
when Numeric then foreign_numeric { [:ok, value.to_s] }
|
|
846
|
+
when true, false then [:ok, value.to_s]
|
|
821
847
|
else [:error, "invalid_type"]
|
|
822
848
|
end
|
|
823
849
|
end
|
|
@@ -849,7 +875,19 @@ module Permittable
|
|
|
849
875
|
|
|
850
876
|
def cast_float(value)
|
|
851
877
|
case value
|
|
852
|
-
|
|
878
|
+
# A Complex is not real? (a real number), so never a Float — even Complex(1, 0),
|
|
879
|
+
# which :integer and :decimal refuse too ("1+0i" is no spelling of either).
|
|
880
|
+
# Taking the ones whose to_f happens to work would make the rule "the
|
|
881
|
+
# imaginary part is an exact zero": Complex(1, 0.0).to_f raises RangeError
|
|
882
|
+
# just as Complex(1, 1) does, and a contract must not raise on input.
|
|
883
|
+
#
|
|
884
|
+
# A zero Numeric is a genuine zero; one that only BECAME zero in to_f
|
|
885
|
+
# (BigDecimal("1e-400"), Rational(1, 10**400)) is underflow, exactly as
|
|
886
|
+
# for the String of it, so a nonzero one passes its text for the check.
|
|
887
|
+
when Numeric
|
|
888
|
+
foreign_numeric do
|
|
889
|
+
value.real? ? finite_float(value.to_f, source: (value.to_s unless value.zero?)) : [:error, "invalid_type"]
|
|
890
|
+
end
|
|
853
891
|
when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
|
|
854
892
|
else [:error, "invalid_type"]
|
|
855
893
|
end
|
|
@@ -857,6 +895,18 @@ module Permittable
|
|
|
857
895
|
[:error, "invalid_type"]
|
|
858
896
|
end
|
|
859
897
|
|
|
898
|
+
# The Numeric branches call to_f, to_s and zero? on a value whose class
|
|
899
|
+
# the caller chose: Complex(1, 1).to_f raises RangeError, and a custom
|
|
900
|
+
# Numeric may raise anything. A value that cannot be converted is not a
|
|
901
|
+
# value of the type, so it is an invalid_type violation rather than an
|
|
902
|
+
# exception out of #call (a 500 where a 422 is due). Wrapped around the
|
|
903
|
+
# Numeric branches only; the other branches call nothing a caller wrote.
|
|
904
|
+
def foreign_numeric
|
|
905
|
+
yield
|
|
906
|
+
rescue StandardError
|
|
907
|
+
[:error, "invalid_type"]
|
|
908
|
+
end
|
|
909
|
+
|
|
860
910
|
# A Float that is not finite does not represent what was sent. "1e400"
|
|
861
911
|
# overflows to Infinity and "1e-400" underflows to zero — both silently,
|
|
862
912
|
# and both leaving a value no column can faithfully store.
|
|
@@ -865,7 +915,8 @@ module Permittable
|
|
|
865
915
|
# an ordinary 0.0: a zero result is rejected when the string it came from
|
|
866
916
|
# named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
|
|
867
917
|
# genuine zero whose exponent digits say nothing about the value — as are
|
|
868
|
-
# "0", "0.0" and "0.0000".
|
|
918
|
+
# "0", "0.0" and "0.0000". A Numeric is checked the same way, by its to_s
|
|
919
|
+
# (see cast_float).
|
|
869
920
|
def finite_float(result, source: nil)
|
|
870
921
|
return [:error, "invalid_type"] unless result.finite?
|
|
871
922
|
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
@@ -879,23 +930,65 @@ module Permittable
|
|
|
879
930
|
source.split(/[eE]/, 2).first.match?(/[1-9]/)
|
|
880
931
|
end
|
|
881
932
|
|
|
933
|
+
# Rational#to_s is "1/2", which BigDecimal() cannot read, so the generic
|
|
934
|
+
# Numeric branch refused every Rational, even a whole one (3r).
|
|
882
935
|
def cast_decimal(value)
|
|
883
936
|
case value
|
|
884
|
-
when
|
|
885
|
-
when
|
|
937
|
+
when Rational then rational_to_decimal(value)
|
|
938
|
+
when Numeric then foreign_numeric { finite_decimal(BigDecimal(value.to_s)) }
|
|
939
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value), source: value) : [:error, "invalid_type"]
|
|
886
940
|
else [:error, "invalid_type"]
|
|
887
941
|
end
|
|
888
942
|
rescue ArgumentError
|
|
889
943
|
[:error, "invalid_type"]
|
|
890
944
|
end
|
|
891
945
|
|
|
892
|
-
#
|
|
946
|
+
# A Rational is exactly a decimal only when its reduced denominator has
|
|
947
|
+
# no prime factor but 2 and 5 (1/2, 3/40), and then it divides 10**k for
|
|
948
|
+
# k = its bit length, which is at least the exponent of either factor.
|
|
949
|
+
# Anything else (1/3) has no finite expansion, so a BigDecimal could hold
|
|
950
|
+
# it only rounded at a precision this gem would have to pick. That is the
|
|
951
|
+
# guess every other coercion here refuses, so it is invalid_type.
|
|
952
|
+
def rational_to_decimal(value)
|
|
953
|
+
denominator = value.denominator
|
|
954
|
+
scale = denominator.bit_length
|
|
955
|
+
return [:error, "invalid_type"] unless ((10**scale) % denominator).zero?
|
|
956
|
+
|
|
957
|
+
finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
|
|
958
|
+
end
|
|
959
|
+
|
|
893
960
|
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
894
961
|
# raises, so a client could send the literal string "NaN" for a price and
|
|
895
962
|
# have it stored. Nothing else in the gem disagreed with itself this
|
|
896
963
|
# loudly: :float rejected those strings and :decimal did not.
|
|
897
|
-
|
|
898
|
-
|
|
964
|
+
#
|
|
965
|
+
# BigDecimal's exponent is far wider than a Float's, but it is bounded:
|
|
966
|
+
# "1e99999999999999999999" overflows to Infinity, refused above, and
|
|
967
|
+
# "1e-99999999999999999999" underflows to zero — silently, like a Float,
|
|
968
|
+
# so the same nonzero-significand rule as finite_float applies.
|
|
969
|
+
def finite_decimal(result, source: nil)
|
|
970
|
+
return [:error, "invalid_type"] unless result.finite?
|
|
971
|
+
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
972
|
+
return [:error, "invalid_type"] if source && amplified?(result, source)
|
|
973
|
+
|
|
974
|
+
[:ok, result]
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# How many digits a :decimal's plain form may run to beyond what the
|
|
978
|
+
# client actually sent. See amplified?.
|
|
979
|
+
DECIMAL_DIGIT_ALLOWANCE = 1_000
|
|
980
|
+
|
|
981
|
+
# BigDecimal keeps a huge exponent exactly: "1e4000000000000000000" is 22
|
|
982
|
+
# bytes and a finite value. But a database adapter quoting it, JSON
|
|
983
|
+
# rendering, BigDecimal#to_s and a log line all build its plain form,
|
|
984
|
+
# which is NoMemoryError there — and with a smaller exponent
|
|
985
|
+
# ("1e1000000000"), a gigabyte string from a dozen bytes of request.
|
|
986
|
+
# So a String whose plain form would run past both a thousand digits and
|
|
987
|
+
# its own length is refused: "1e400" and "1e-999" still cast, and so
|
|
988
|
+
# does any number spelled out digit by digit, however long, since the
|
|
989
|
+
# request already paid for every digit.
|
|
990
|
+
def amplified?(result, source)
|
|
991
|
+
result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
|
|
899
992
|
end
|
|
900
993
|
|
|
901
994
|
def cast_boolean(value)
|
|
@@ -914,6 +1007,15 @@ module Permittable
|
|
|
914
1007
|
# ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
|
|
915
1008
|
def cast_date(value)
|
|
916
1009
|
case value
|
|
1010
|
+
# Ahead of Date, which DateTime subclasses: `when Date` handed a
|
|
1011
|
+
# DateTime back whole, time of day included, so `default:
|
|
1012
|
+
# DateTime.new(2026, 1, 5, 12)` was stored as noon and exported as a
|
|
1013
|
+
# date-time under `format: date` — while a Time was refused outright,
|
|
1014
|
+
# and an in: member was read as an instant. Every instant is now read
|
|
1015
|
+
# the way that member is (see date_of_instant).
|
|
1016
|
+
when Time, DateTime
|
|
1017
|
+
date = date_of_instant(value)
|
|
1018
|
+
date ? [:ok, date] : [:error, "invalid_type"]
|
|
917
1019
|
when Date then [:ok, value]
|
|
918
1020
|
when String
|
|
919
1021
|
found = Date._parse(value)
|
|
@@ -936,6 +1038,19 @@ module Permittable
|
|
|
936
1038
|
found.key?(:year) && found.key?(:mon) && found.key?(:mday)
|
|
937
1039
|
end
|
|
938
1040
|
|
|
1041
|
+
# The date a Time or DateTime IS, or nil: its UTC date when it is
|
|
1042
|
+
# exactly midnight UTC. That is the instant ActiveSupport compares a
|
|
1043
|
+
# Date as, and the one :datetime reads a Date as, so the two types map
|
|
1044
|
+
# onto each other and back. Any other instant has a time of day, which
|
|
1045
|
+
# a Date cannot hold, so it is not a date at all — the answer a Time
|
|
1046
|
+
# with a time of day always got. Shared by cast_date and
|
|
1047
|
+
# instant_as_date, so a value and an in: member cannot be read
|
|
1048
|
+
# differently; and always a plain Date, never the DateTime it came as.
|
|
1049
|
+
def date_of_instant(instant)
|
|
1050
|
+
utc = instant.to_time.getutc
|
|
1051
|
+
utc.to_date if utc == utc.beginning_of_day
|
|
1052
|
+
end
|
|
1053
|
+
|
|
939
1054
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
940
1055
|
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
941
1056
|
def cast_datetime(value)
|
|
@@ -958,13 +1073,36 @@ module Permittable
|
|
|
958
1073
|
# Time from components would have to reimplement DateTime.parse's
|
|
959
1074
|
# handling of offsets, zone names and sub-second precision, and
|
|
960
1075
|
# getting that subtly wrong costs more than the parse.
|
|
961
|
-
|
|
1076
|
+
#
|
|
1077
|
+
# Parsed in the proleptic Gregorian calendar, which is what ISO 8601
|
|
1078
|
+
# and Time use. DateTime.parse defaults to the 1582 Italian reform
|
|
1079
|
+
# and read an earlier date as Julian; stdlib's DateTime#to_time then
|
|
1080
|
+
# converts by day number, so "1500-01-01" became 1500-01-10 — unless
|
|
1081
|
+
# the host had loaded ActiveSupport's DateTime#to_time, which
|
|
1082
|
+
# converts by component. Gregorian, both agree.
|
|
1083
|
+
found = Date._parse(value)
|
|
1084
|
+
return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
|
|
1085
|
+
|
|
1086
|
+
[:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
|
|
962
1087
|
else [:error, "invalid_type"]
|
|
963
1088
|
end
|
|
964
1089
|
rescue ArgumentError, RangeError
|
|
965
1090
|
[:error, "invalid_type"]
|
|
966
1091
|
end
|
|
967
1092
|
|
|
1093
|
+
# A zone the parser could not turn into an offset — an offset of a day
|
|
1094
|
+
# or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
|
|
1095
|
+
# know ("XYZ") — is reported as a zone with no offset, or with one
|
|
1096
|
+
# beyond a day, and DateTime.parse then reads the wall clock as UTC: an
|
|
1097
|
+
# instant hours away from the one the client named. The client stated a
|
|
1098
|
+
# zone, so it is refused rather than guessed at.
|
|
1099
|
+
def honoured_zone?(found)
|
|
1100
|
+
return true unless found.key?(:zone)
|
|
1101
|
+
|
|
1102
|
+
offset = found[:offset]
|
|
1103
|
+
!offset.nil? && offset.abs < 86_400
|
|
1104
|
+
end
|
|
1105
|
+
|
|
968
1106
|
# Presets only make sense on String input; a non-String value (JSON
|
|
969
1107
|
# numbers, booleans) skips normalization and goes straight to the cast.
|
|
970
1108
|
#
|
|
@@ -1042,41 +1180,58 @@ module Permittable
|
|
|
1042
1180
|
end
|
|
1043
1181
|
|
|
1044
1182
|
# The members of an `in:` that is a LIST, or nil when it is not one.
|
|
1045
|
-
# Only Array, Set, Hash and Enumerator count, and only when the object
|
|
1046
|
-
#
|
|
1047
|
-
# Array or Set SUBCLASS
|
|
1048
|
-
#
|
|
1049
|
-
#
|
|
1050
|
-
#
|
|
1051
|
-
#
|
|
1052
|
-
#
|
|
1053
|
-
#
|
|
1054
|
-
#
|
|
1183
|
+
# Only Array, Set, Hash and Enumerator count, and only when the object
|
|
1184
|
+
# answers with the collection's ordinary include? — not one a Hash,
|
|
1185
|
+
# Array or Set SUBCLASS overrides (a case-insensitive allowlist, a fuzzy
|
|
1186
|
+
# Set, a registry matching some other way entirely). `case allowed; when
|
|
1187
|
+
# Hash ...` matches with ===, which for a Class is is_a?, so a subclass
|
|
1188
|
+
# would otherwise match its ancestor's branch and have its override
|
|
1189
|
+
# silently discarded — read for its raw keys/elements instead, which can
|
|
1190
|
+
# invert which values it actually accepts. It is left opaque instead,
|
|
1191
|
+
# exactly like any other object whose include? is the point (see
|
|
1192
|
+
# resolve_in!) and enumerating it may be expensive (a DB-backed
|
|
1055
1193
|
# registry).
|
|
1056
1194
|
#
|
|
1195
|
+
# So the test is which module OWNS include?, not the object's exact
|
|
1196
|
+
# class. A subclass that keeps its base's include? answers exactly what
|
|
1197
|
+
# its keys or elements say, and is a list like its base. Rails'
|
|
1198
|
+
# `config_for(:plans)` returns one, an ActiveSupport::OrderedOptions:
|
|
1199
|
+
# an exact-class test left it opaque, so `include?("pro")` asked about
|
|
1200
|
+
# its Symbol keys and every request was refused.
|
|
1201
|
+
#
|
|
1057
1202
|
# A Hash lists its KEYS, which is what Hash#include? asks about — the
|
|
1058
1203
|
# Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
|
|
1059
1204
|
# Set, so membership stays O(1) per request.
|
|
1060
|
-
# ActiveSupport::HashWithIndifferentAccess is the one
|
|
1061
|
-
# accepted anyway:
|
|
1062
|
-
#
|
|
1063
|
-
#
|
|
1064
|
-
#
|
|
1205
|
+
# ActiveSupport::HashWithIndifferentAccess is the one include? override
|
|
1206
|
+
# accepted anyway: it only canonicalises the argument (String/Symbol)
|
|
1207
|
+
# before the SAME key lookup, so its keys are still exactly its members
|
|
1208
|
+
# — and it is what a Rails enum's own reader (`Post.statuses`) actually
|
|
1209
|
+
# returns.
|
|
1065
1210
|
# Enumerator::Lazy is the same story on the Enumerator side: Lazy
|
|
1066
1211
|
# overrides chain methods like map and select, but not include?, so it
|
|
1067
1212
|
# is still read as a list — and forced to an Array here, once, since
|
|
1068
1213
|
# left lazy it would be cast on every request instead of at class load.
|
|
1069
1214
|
def in_list(allowed)
|
|
1070
1215
|
case allowed
|
|
1071
|
-
when Hash then allowed.keys.to_set if
|
|
1072
|
-
when Set then allowed if
|
|
1073
|
-
when Array then allowed.to_a if
|
|
1074
|
-
when Enumerator then allowed.to_a if allowed
|
|
1216
|
+
when Hash then allowed.keys.to_set if include_from?(allowed, Hash, ActiveSupport::HashWithIndifferentAccess)
|
|
1217
|
+
when Set then allowed if include_from?(allowed, Set)
|
|
1218
|
+
when Array then allowed.to_a if include_from?(allowed, Array)
|
|
1219
|
+
when Enumerator then allowed.to_a if include_from?(allowed, Enumerator)
|
|
1075
1220
|
end
|
|
1076
1221
|
end
|
|
1077
1222
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1223
|
+
# Whether `allowed` answers include? as one of `bases` itself does. What
|
|
1224
|
+
# is compared is the owner the BASE class reports, not a literal class:
|
|
1225
|
+
# a gem that prepends a module defining include? into Array, Hash or Set
|
|
1226
|
+
# (an instrumenting one) makes that module the owner for every instance,
|
|
1227
|
+
# and a test against the literal class then read every plain
|
|
1228
|
+
# `in: %i[draft published]` as an object with an include? of its own,
|
|
1229
|
+
# used it as given, and refused "draft". A prepended module is the
|
|
1230
|
+
# base's own include?; a subclass, a singleton method or an `extend`
|
|
1231
|
+
# still has a different owner.
|
|
1232
|
+
def include_from?(allowed, *bases)
|
|
1233
|
+
owner = allowed.method(:include?).owner
|
|
1234
|
+
bases.any? { |base| base.instance_method(:include?).owner == owner }
|
|
1080
1235
|
end
|
|
1081
1236
|
|
|
1082
1237
|
# A Symbol is read as its String: it is how Ruby spells a constant
|
|
@@ -1085,20 +1240,34 @@ module Permittable
|
|
|
1085
1240
|
member = member.to_s if member.is_a?(Symbol)
|
|
1086
1241
|
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
1087
1242
|
|
|
1088
|
-
cast(type, member)
|
|
1243
|
+
status, value = cast(type, member)
|
|
1244
|
+
return [status, value] unless status == :ok && type == :datetime && !member.is_a?(String) && !whole_nanoseconds?(value)
|
|
1245
|
+
|
|
1246
|
+
[:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
# A request's fraction of a second is a finite decimal, and the export
|
|
1250
|
+
# prints a member to at most nine digits, so a member that is not a whole
|
|
1251
|
+
# number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
|
|
1252
|
+
# Time.utc(..., Rational(1, 3)) a third — was published as an enum value
|
|
1253
|
+
# the server refuses and could never be matched. The :datetime analogue
|
|
1254
|
+
# of instant_as_date. Only for a Time-like member: a String member is
|
|
1255
|
+
# cast exactly as a request sending it is, and published as written, so
|
|
1256
|
+
# whatever precision it spells is reachable.
|
|
1257
|
+
def whole_nanoseconds?(time)
|
|
1258
|
+
(time.subsec * 1_000_000_000).denominator == 1
|
|
1089
1259
|
end
|
|
1090
1260
|
|
|
1091
1261
|
# A Time or DateTime member of a :date field. ActiveSupport compares one
|
|
1092
1262
|
# with a Date as INSTANTS, the Date standing for its midnight UTC, so
|
|
1093
1263
|
# that instant is the only one that ever equalled a request's date. It
|
|
1094
1264
|
# is read as that UTC date; any other instant never matched anything,
|
|
1095
|
-
# and is refused like any member no request could equal.
|
|
1096
|
-
#
|
|
1265
|
+
# and is refused like any member no request could equal. cast_date
|
|
1266
|
+
# reads it the same way (date_of_instant); this only gives the
|
|
1267
|
+
# class-load error a reason where a request gets `invalid_type`.
|
|
1097
1268
|
def instant_as_date(member)
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
[:ok, utc.to_date]
|
|
1269
|
+
date = date_of_instant(member)
|
|
1270
|
+
date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
|
|
1102
1271
|
end
|
|
1103
1272
|
|
|
1104
1273
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
@@ -1199,6 +1368,7 @@ module Permittable
|
|
|
1199
1368
|
validate_array_authored_value!(field, :default) if field.key?(:default)
|
|
1200
1369
|
validate_array_authored_value!(field, :example) if field.key?(:example)
|
|
1201
1370
|
validate_message!(field)
|
|
1371
|
+
freeze_desc!(field)
|
|
1202
1372
|
@fields << field
|
|
1203
1373
|
end
|
|
1204
1374
|
|
|
@@ -1214,10 +1384,13 @@ module Permittable
|
|
|
1214
1384
|
# end
|
|
1215
1385
|
#
|
|
1216
1386
|
# `optional: true` relaxes every spliced field, which is how an update
|
|
1217
|
-
# contract reuses a create contract: nothing is mandatory,
|
|
1218
|
-
#
|
|
1219
|
-
#
|
|
1220
|
-
#
|
|
1387
|
+
# contract reuses a create contract: nothing is mandatory, and types and
|
|
1388
|
+
# bounds all still apply. It drops each relaxed field's `default:` too. A
|
|
1389
|
+
# default is filled in for every request that omits the field, so on a
|
|
1390
|
+
# PATCH it would overwrite the column the client left alone: `default:
|
|
1391
|
+
# "free"` reset every "pro" user who only renamed themself. It relaxes
|
|
1392
|
+
# the TOP LEVEL only — if a client sends an address at all, the
|
|
1393
|
+
# address's own required sub-fields and defaults still hold.
|
|
1221
1394
|
#
|
|
1222
1395
|
# `only:`/`except:` select a subset, in the group's own order. Naming a
|
|
1223
1396
|
# field the group doesn't declare is a class-load error, so a typo cannot
|
|
@@ -1226,7 +1399,7 @@ module Permittable
|
|
|
1226
1399
|
# and then declare `:city` yourself.
|
|
1227
1400
|
def use(group, only: nil, except: nil, optional: false)
|
|
1228
1401
|
fields = select_group_fields!(group_fields!(group), only: only, except: except)
|
|
1229
|
-
fields = fields.map { |field| field.merge(required: false).freeze } if optional
|
|
1402
|
+
fields = fields.map { |field| field.except(:default).merge(required: false).freeze } if optional
|
|
1230
1403
|
fields.each do |field|
|
|
1231
1404
|
field_name!(field[:name])
|
|
1232
1405
|
@fields << field
|
|
@@ -1236,7 +1409,22 @@ module Permittable
|
|
|
1236
1409
|
|
|
1237
1410
|
private
|
|
1238
1411
|
|
|
1412
|
+
# A Contract is usable as a group because it answers #fields — but its
|
|
1413
|
+
# finalize is not a field, so splicing would drop the contract's
|
|
1414
|
+
# cross-field rule: a standalone Booking contract rejecting an ends_on
|
|
1415
|
+
# before its starts_on, used by a controller that then accepts it. That
|
|
1416
|
+
# is the drift `use` exists to prevent, so it is refused here, loudly,
|
|
1417
|
+
# the way a field group's own finalize is. Also under only:/except: — a
|
|
1418
|
+
# finalize is opaque, so there is no part of it to keep — which the
|
|
1419
|
+
# message says, since that is the first thing a user tries.
|
|
1239
1420
|
def group_fields!(group)
|
|
1421
|
+
if group.is_a?(Contract) && group.rule[:finalize]
|
|
1422
|
+
raise ArgumentError, "#{LABEL}: use cannot carry a Permittable::Contract's finalize, so the contract's " \
|
|
1423
|
+
"cross-field rule would be silently dropped. Selecting fields with only:/except: " \
|
|
1424
|
+
"does not lift this: the finalize cannot be split, so it is refused for any part " \
|
|
1425
|
+
"of the contract. To share both, declare the fields in a Permittable.fields " \
|
|
1426
|
+
"group that the contract and this block each use, and declare finalize in each"
|
|
1427
|
+
end
|
|
1240
1428
|
return group.fields if group.respond_to?(:fields)
|
|
1241
1429
|
|
|
1242
1430
|
raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
|
|
@@ -1275,6 +1463,7 @@ module Permittable
|
|
|
1275
1463
|
fields: nested_fields!(name, &block), **opts }
|
|
1276
1464
|
field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
|
|
1277
1465
|
validate_message!(field)
|
|
1466
|
+
freeze_desc!(field)
|
|
1278
1467
|
elsif type&.to_sym == JSON_TYPE
|
|
1279
1468
|
assert_opts!(name, opts, JSON_OPTS)
|
|
1280
1469
|
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
@@ -1379,6 +1568,7 @@ module Permittable
|
|
|
1379
1568
|
validate_authored_value!(field, :default)
|
|
1380
1569
|
validate_authored_value!(field, :example)
|
|
1381
1570
|
validate_message!(field)
|
|
1571
|
+
freeze_desc!(field)
|
|
1382
1572
|
end
|
|
1383
1573
|
|
|
1384
1574
|
# `in:` is a Range (bounds-checked with cover?), a list of values, or an
|
|
@@ -1394,19 +1584,34 @@ module Permittable
|
|
|
1394
1584
|
# enum rule all read the members the runtime compares against. A member
|
|
1395
1585
|
# no request value could ever equal is a contract mistake, and fails here
|
|
1396
1586
|
# rather than as an `inclusion` on every request.
|
|
1587
|
+
#
|
|
1588
|
+
# An empty list or a reversed Range is refused for the same reason, but
|
|
1589
|
+
# an object used as given is not asked: it is read on every request, so
|
|
1590
|
+
# a live registry that is empty now (filled in an initializer or
|
|
1591
|
+
# `after_initialize`, after `eager_load!` has run this) is the very use
|
|
1592
|
+
# the README recommends it for.
|
|
1397
1593
|
def resolve_in!(field)
|
|
1398
1594
|
name = field[:name]
|
|
1399
1595
|
allowed = field[:in]
|
|
1400
1596
|
if allowed.is_a?(Range)
|
|
1401
1597
|
assert_comparable_range!(field, allowed)
|
|
1598
|
+
assert_satisfiable!(name, :in, allowed)
|
|
1402
1599
|
elsif (members = Coercion.in_list(allowed))
|
|
1403
1600
|
cast_in_members!(field, members)
|
|
1601
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
1404
1602
|
elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
|
|
1405
1603
|
raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
|
|
1406
1604
|
"or a Hash read as its keys), or an object answering include? " \
|
|
1407
1605
|
"(got #{allowed.inspect})#{string_in_hint(allowed)}"
|
|
1408
1606
|
end
|
|
1409
|
-
|
|
1607
|
+
end
|
|
1608
|
+
|
|
1609
|
+
# An `in:` that is neither a Range nor a list the gem read and cast
|
|
1610
|
+
# itself, so one resolve_in! kept exactly as given: its contents are the
|
|
1611
|
+
# host's, and unknown at class load.
|
|
1612
|
+
def live_in?(field)
|
|
1613
|
+
allowed = field[:in]
|
|
1614
|
+
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
1410
1615
|
end
|
|
1411
1616
|
|
|
1412
1617
|
def string_in_hint(allowed)
|
|
@@ -1484,6 +1689,7 @@ module Permittable
|
|
|
1484
1689
|
validate_json_authored_value!(field, :default)
|
|
1485
1690
|
validate_json_authored_value!(field, :example)
|
|
1486
1691
|
validate_message!(field)
|
|
1692
|
+
freeze_desc!(field)
|
|
1487
1693
|
end
|
|
1488
1694
|
|
|
1489
1695
|
def validate_max_depth!(name, depth)
|
|
@@ -1492,17 +1698,32 @@ module Permittable
|
|
|
1492
1698
|
raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
|
|
1493
1699
|
end
|
|
1494
1700
|
|
|
1495
|
-
# Same rule as a scalar's authored value
|
|
1496
|
-
#
|
|
1701
|
+
# Same rule as a scalar's authored value: a `default:` or `example:` that
|
|
1702
|
+
# its own bounds would reject fails at class load. Like an array's, it is
|
|
1703
|
+
# read by the request walker itself (see AuthoredValues), so it is checked
|
|
1704
|
+
# as a request sending the same hash is. The raw Hash used to be checked
|
|
1705
|
+
# instead: `validate: ->(p) { p[:theme] == "dark" }` passed a request's
|
|
1706
|
+
# {"theme":"dark"}, which the walker reads indifferently, and refused the
|
|
1707
|
+
# same Hash authored as `default:`. And a `validate:` that edits its
|
|
1708
|
+
# argument edited the host's own Hash, which a request's deep copy spares.
|
|
1709
|
+
#
|
|
1710
|
+
# For a `default:` the walker's copy is what is stored, so the action, the
|
|
1711
|
+
# export and the RSpec matcher all read what a request sending it would
|
|
1712
|
+
# have produced. An `example:` is not: it documents what a client SENDS,
|
|
1713
|
+
# so it is validated on that copy but stored as a fresh copy of the value
|
|
1714
|
+
# AS AUTHORED (request-shaped, indifferent with String keys), which a
|
|
1715
|
+
# `validate:` editing its argument cannot have added a key to or dropped
|
|
1716
|
+
# one from. That matches every other example on a contract, which is never
|
|
1717
|
+
# processed the way the action would see it.
|
|
1497
1718
|
def validate_json_authored_value!(field, opt)
|
|
1498
1719
|
return unless field.key?(opt)
|
|
1499
1720
|
return if authored_nil!(field, opt)
|
|
1500
1721
|
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
1501
1722
|
|
|
1502
|
-
status,
|
|
1503
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{
|
|
1723
|
+
status, read = AuthoredValues.read_json(field, field[opt])
|
|
1724
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{read})" unless status == :ok
|
|
1504
1725
|
|
|
1505
|
-
field[opt] = freeze_authored(field[opt])
|
|
1726
|
+
field[opt] = freeze_authored(opt == :example ? ActiveSupport::HashWithIndifferentAccess.new(field[opt]) : read)
|
|
1506
1727
|
end
|
|
1507
1728
|
|
|
1508
1729
|
# format / length / normalize reason about characters; on any other
|
|
@@ -1522,10 +1743,33 @@ module Permittable
|
|
|
1522
1743
|
raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
|
|
1523
1744
|
"(got #{length.inspect})"
|
|
1524
1745
|
end
|
|
1746
|
+
validate_length_ends!(name, length) if length.is_a?(Range)
|
|
1525
1747
|
|
|
1526
1748
|
assert_satisfiable!(name, :length, length)
|
|
1527
1749
|
end
|
|
1528
1750
|
|
|
1751
|
+
# A length is a count, so each end of a Range is a non-negative whole
|
|
1752
|
+
# number — an Integer, or a Float/Rational/BigDecimal with no fraction
|
|
1753
|
+
# (`1..10.0` means ten) — or open: nil, or an infinite Float on its own
|
|
1754
|
+
# side (`1..Float::INFINITY` predates endless Ranges). A negative end matched nothing (`..-1`
|
|
1755
|
+
# rejected every value, forever, as the client's fault) or meant nothing
|
|
1756
|
+
# (`-2..3` is `..3`), and a fractional one exported a minLength/maxLength
|
|
1757
|
+
# the JSON Schema metaschema refuses — Ajv rejects the whole document.
|
|
1758
|
+
def validate_length_ends!(name, length)
|
|
1759
|
+
ok = length_end?(length.begin, -Float::INFINITY) && length_end?(length.end, Float::INFINITY)
|
|
1760
|
+
return if ok
|
|
1761
|
+
|
|
1762
|
+
raise ArgumentError, "#{LABEL}: :length for :#{name} must have non-negative whole-number ends " \
|
|
1763
|
+
"(got #{length.inspect}); leave an end off for no bound, e.g. 1.. or ..80"
|
|
1764
|
+
end
|
|
1765
|
+
|
|
1766
|
+
def length_end?(value, open)
|
|
1767
|
+
return true if value.nil? || value == open
|
|
1768
|
+
return false unless value.is_a?(Numeric) && value.real? && value.finite?
|
|
1769
|
+
|
|
1770
|
+
!value.negative? && value == value.to_i
|
|
1771
|
+
end
|
|
1772
|
+
|
|
1529
1773
|
# A reversed Range (5..2), an exclusive Range with equal endpoints
|
|
1530
1774
|
# (3...3), or an empty set (in: []) excludes every value there is, so the
|
|
1531
1775
|
# field it bounds can never validate. That used to surface as every
|
|
@@ -1614,7 +1858,7 @@ module Permittable
|
|
|
1614
1858
|
# same value gets, and what used to be thrown away: `default: "18"` used
|
|
1615
1859
|
# to be handed to every request omitting it as the String "18", and
|
|
1616
1860
|
# `:boolean, default: "false"` gave the app a truthy String.
|
|
1617
|
-
# With a `transform:`,
|
|
1861
|
+
# With a `transform:`, a default is stored exactly AS AUTHORED instead —
|
|
1618
1862
|
# `transform:` never runs on a default (see AuthoredValues), so casting it
|
|
1619
1863
|
# here would silently change its type out from under an author who, per
|
|
1620
1864
|
# the README, writes such a default in the shape the action should
|
|
@@ -1622,6 +1866,17 @@ module Permittable
|
|
|
1622
1866
|
# :string field means the app gets the Integer 25 either way, whether the
|
|
1623
1867
|
# request sent "25" (cast then transformed) or omitted the field
|
|
1624
1868
|
# (authored as the already-final Integer).
|
|
1869
|
+
#
|
|
1870
|
+
# An `example:` is cast either way. It documents what a client SENDS and
|
|
1871
|
+
# never reaches the action, so that reason does not apply to it: under
|
|
1872
|
+
# `transform:` it used to be published uncast, `"type": "integer",
|
|
1873
|
+
# "examples": ["18"]`, the very lie the cast exists to stop.
|
|
1874
|
+
#
|
|
1875
|
+
# The one rule not checked is `in:` when it is an object used as given
|
|
1876
|
+
# (see resolve_in!): a live registry is empty at class load, so
|
|
1877
|
+
# `in: LIVE, default: "order.paid"` would be refused for a member it will
|
|
1878
|
+
# hold by the first request. What it will contain is unknown then, so its
|
|
1879
|
+
# `include?` is not asked; every other rule is still checked.
|
|
1625
1880
|
def validate_authored_value!(field, opt)
|
|
1626
1881
|
return unless field.key?(opt)
|
|
1627
1882
|
return if authored_nil!(field, opt)
|
|
@@ -1630,10 +1885,10 @@ module Permittable
|
|
|
1630
1885
|
# proc cannot rewrite the host's own literal.
|
|
1631
1886
|
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1632
1887
|
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1633
|
-
status, result = Coercion.check_scalar(field, value)
|
|
1888
|
+
status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
|
|
1634
1889
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1635
1890
|
|
|
1636
|
-
field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
|
|
1891
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
|
|
1637
1892
|
end
|
|
1638
1893
|
|
|
1639
1894
|
# An array's authored value is validated by the REQUEST walker itself
|
|
@@ -1646,27 +1901,61 @@ module Permittable
|
|
|
1646
1901
|
# cast only the top level of each element, and got every one of those
|
|
1647
1902
|
# wrong.
|
|
1648
1903
|
#
|
|
1649
|
-
# What is STORED follows the same split as a scalar's:
|
|
1650
|
-
# `transform:`, the walker's read (exactly what a request sending
|
|
1651
|
-
# gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1652
|
-
# so a declaration mistake (an element `validate:` refuses, a
|
|
1653
|
-
# default out of bounds) still fails at class load, but its
|
|
1654
|
-
# is discarded rather than stored. The array's OWN
|
|
1655
|
-
# deliberately never run on a default either way
|
|
1656
|
-
# `transform:` still runs during that walk, so "the walker's
|
|
1657
|
-
# really is what an equivalent request produces
|
|
1904
|
+
# What is STORED for a default follows the same split as a scalar's:
|
|
1905
|
+
# without `transform:`, the walker's read (exactly what a request sending
|
|
1906
|
+
# it gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1907
|
+
# runs, so a declaration mistake (an element `validate:` refuses, a
|
|
1908
|
+
# sub-field default out of bounds) still fails at class load, but its
|
|
1909
|
+
# cast result is discarded rather than stored. The array's OWN
|
|
1910
|
+
# `transform:` is deliberately never run on a default either way. A
|
|
1911
|
+
# SUB-FIELD's `transform:` still runs during that walk, so "the walker's
|
|
1912
|
+
# read" above really is what an equivalent request produces: all of them
|
|
1913
|
+
# when the array has no `transform:` of its own, and, when it has one,
|
|
1914
|
+
# those below an array declaring `validate:`, the only readers of their
|
|
1915
|
+
# output (a request runs sub-field transforms, then that `validate:`,
|
|
1916
|
+
# then the array's own transform) — see AuthoredValues.
|
|
1917
|
+
#
|
|
1918
|
+
# An `example:` is validated the same way, as a request would be, but what
|
|
1919
|
+
# is STORED is a second read of it exactly as a client SENDS it: cast and
|
|
1920
|
+
# normalized, with no `transform:` run at any depth and no sub-field
|
|
1921
|
+
# `default:` filled in. Read through a sub-field's transform,
|
|
1922
|
+
# `[{ price: "9.99" }]` under `transform: ->(d) { (d * 100).to_i }` was
|
|
1923
|
+
# published as `[{ "price": 999 }]`: 100 times the price, and a `tags`
|
|
1924
|
+
# split into an Array the server refuses. The two reads cannot be one: an
|
|
1925
|
+
# array-level `validate:` must see the transformed sub-fields a request
|
|
1926
|
+
# hands it (`i[:tags].is_a?(Array)`), so the validating read keeps them,
|
|
1927
|
+
# and the stored read, which runs no `validate:`, discards them.
|
|
1928
|
+
#
|
|
1929
|
+
# The transforming read exists only for that `validate:`, so it runs only
|
|
1930
|
+
# when one could consume it. Anywhere else its output is thrown away, yet
|
|
1931
|
+
# it would still call app code at class load: `optional :id, :string,
|
|
1932
|
+
# transform: ->(s) { Account.find(s) }` under an `example:` hit the
|
|
1933
|
+
# database, or raised, at boot. Only an array carries a `validate:` that
|
|
1934
|
+
# sees its transformed children: a scalar's or a :json field's runs on
|
|
1935
|
+
# its own value before its own transform, and a nested hash takes none.
|
|
1936
|
+
# Without one, the as-sent read both validates and is stored.
|
|
1658
1937
|
def validate_array_authored_value!(field, opt)
|
|
1659
1938
|
value = field[opt]
|
|
1660
1939
|
return if authored_nil!(field, opt)
|
|
1661
1940
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
1662
1941
|
|
|
1663
|
-
|
|
1942
|
+
as_sent = opt == :example && !array_validate_in?(field)
|
|
1943
|
+
read, violations = AuthoredValues.read_array(field, value, as_sent: as_sent)
|
|
1664
1944
|
unless violations.empty?
|
|
1665
1945
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
|
|
1666
1946
|
"#{AuthoredValues.summary(violations)}"
|
|
1667
1947
|
end
|
|
1668
1948
|
|
|
1669
|
-
|
|
1949
|
+
read, = AuthoredValues.read_array(field, value, as_sent: true) if opt == :example && !as_sent
|
|
1950
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? value : read)
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
# Whether `field`, or any array or nested hash below it, declares an
|
|
1954
|
+
# array-level `validate:`.
|
|
1955
|
+
def array_validate_in?(field)
|
|
1956
|
+
return true if field[:kind] == :array && field[:validate]
|
|
1957
|
+
|
|
1958
|
+
Array(field[:fields]).any? { |sub| array_validate_in?(sub) }
|
|
1670
1959
|
end
|
|
1671
1960
|
|
|
1672
1961
|
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
@@ -1705,6 +1994,18 @@ module Permittable
|
|
|
1705
1994
|
"declare nullable: true to make an explicit null part of the contract"
|
|
1706
1995
|
end
|
|
1707
1996
|
|
|
1997
|
+
# `desc:` is exported as the schema's `description`, and was handed out
|
|
1998
|
+
# by reference: a caller appending to a generated document's
|
|
1999
|
+
# description (" (deprecated)", a gsub! for localisation) rewrote the
|
|
2000
|
+
# contract inside the frozen rule, and every later export in the process
|
|
2001
|
+
# carried the edit. Frozen as a copy, like message:, default: and
|
|
2002
|
+
# example:, so the host's own String is left as it passed it. The
|
|
2003
|
+
# exporters hand out an unfrozen `dup` of it, so editing a generated
|
|
2004
|
+
# document in place works and still cannot reach the contract.
|
|
2005
|
+
def freeze_desc!(field)
|
|
2006
|
+
field[:desc] = freeze_authored(field[:desc]) unless field[:desc].nil?
|
|
2007
|
+
end
|
|
2008
|
+
|
|
1708
2009
|
# `message:` customizes what the client reads for a violation on this
|
|
1709
2010
|
# field: one String covering every code, or a Hash of code => String
|
|
1710
2011
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -1804,7 +2105,7 @@ module Permittable
|
|
|
1804
2105
|
|
|
1805
2106
|
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
1806
2107
|
model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
|
|
1807
|
-
finalize: builder.finalizer, desc: desc }.freeze
|
|
2108
|
+
finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
|
|
1808
2109
|
self.permittable_contracts = permittable_contracts + [rule]
|
|
1809
2110
|
end
|
|
1810
2111
|
|
|
@@ -1851,10 +2152,11 @@ module Permittable
|
|
|
1851
2152
|
|
|
1852
2153
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
1853
2154
|
allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
|
|
2155
|
+
transformed = checked.select { |f| f[:transform] }.map { |f| f[:name].to_sym }
|
|
1854
2156
|
begin
|
|
1855
2157
|
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1856
2158
|
types: types, check_types: Permittable.check_column_types,
|
|
1857
|
-
allowed: allowed)
|
|
2159
|
+
allowed: allowed, transformed: transformed)
|
|
1858
2160
|
rescue ArgumentError => e
|
|
1859
2161
|
# The type error carries its own guidance; only the missing-column one
|
|
1860
2162
|
# needs the virtual: hint appended.
|
|
@@ -1929,10 +2231,33 @@ module Permittable
|
|
|
1929
2231
|
return nil unless action
|
|
1930
2232
|
|
|
1931
2233
|
rule = self.class.permit_rule_for(action)
|
|
1932
|
-
|
|
2234
|
+
return nil unless rule
|
|
2235
|
+
|
|
2236
|
+
if permittable_mode(rule) == :monitor
|
|
2237
|
+
permittable_monitor_eagerly(action)
|
|
2238
|
+
elsif rule[:enforce]
|
|
2239
|
+
permitted_params(action)
|
|
2240
|
+
end
|
|
1933
2241
|
nil
|
|
1934
2242
|
end
|
|
1935
2243
|
|
|
2244
|
+
# Monitor mode's promise is that the action runs, whatever the request
|
|
2245
|
+
# holds — that is what makes it safe to turn on. Checking eagerly reads
|
|
2246
|
+
# `params`, which raises ActionDispatch's ParseError on a body Rails
|
|
2247
|
+
# cannot parse: a webhook action reading request.raw_post itself got a
|
|
2248
|
+
# 400 instead of running. An exception from the contract's own code
|
|
2249
|
+
# (validate:, transform:, finalize) became a 500 the same way. Either is
|
|
2250
|
+
# now logged, by class only (a parse error's message can quote the body),
|
|
2251
|
+
# and the action runs; one that reads permitted_params meets the same
|
|
2252
|
+
# exception itself, as it would with no contract at all.
|
|
2253
|
+
def permittable_monitor_eagerly(action)
|
|
2254
|
+
permitted_params(action)
|
|
2255
|
+
rescue StandardError => e
|
|
2256
|
+
return unless respond_to?(:logger) && logger
|
|
2257
|
+
|
|
2258
|
+
logger.warn("#{LABEL}: [monitor] ##{action} could not be checked: #{e.class} — the action runs unchecked")
|
|
2259
|
+
end
|
|
2260
|
+
|
|
1936
2261
|
# The violation details recorded by validating `action` (default: the
|
|
1937
2262
|
# current action) — [] when the request satisfied the contract. Triggers
|
|
1938
2263
|
# the same memoized validation as permitted_params, so under monitor mode
|
|
@@ -2292,6 +2617,7 @@ module Permittable
|
|
|
2292
2617
|
|
|
2293
2618
|
def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
|
|
2294
2619
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
2620
|
+
hash = permittable_read_date_parts(fields, hash)
|
|
2295
2621
|
fields.each do |field|
|
|
2296
2622
|
key = field[:name].to_s
|
|
2297
2623
|
full = permittable_path(path, key)
|
|
@@ -2314,6 +2640,119 @@ module Permittable
|
|
|
2314
2640
|
result
|
|
2315
2641
|
end
|
|
2316
2642
|
|
|
2643
|
+
# See DATE_PARTS. A :date or :datetime field reads the parts sent for it
|
|
2644
|
+
# as its own value. They are replaced, in a COPY of `hash`, by the one
|
|
2645
|
+
# value they make, under the field's own key, so the walk sees exactly
|
|
2646
|
+
# what it would have had the client sent that value: the cast, `in:`,
|
|
2647
|
+
# `validate:`, `transform:`, the absence rule and `nullable:` all apply
|
|
2648
|
+
# unchanged, the action gets the field's own key, and the unknown-keys
|
|
2649
|
+
# check never sees the parts it read. `hash` itself is left as it is: it
|
|
2650
|
+
# may be the request's own params, which monitor mode hands back as they
|
|
2651
|
+
# arrived.
|
|
2652
|
+
#
|
|
2653
|
+
# Only when the field's own key is ABSENT. When both are sent, the key the
|
|
2654
|
+
# contract declares wins the VALUE, and the parts are left in the copy,
|
|
2655
|
+
# untouched, for the unknown-keys check to judge as it always has: flagged
|
|
2656
|
+
# under `unknown: :error`, logged under `:log`, dropped under `:ignore`.
|
|
2657
|
+
# They must not be consumed unread: strong parameters passes them through
|
|
2658
|
+
# `permit(:starts_on)` (the legacy action of an `enforce: true` rollout) and
|
|
2659
|
+
# ActiveRecord assigns multiparameter attributes AFTER the plain ones, so
|
|
2660
|
+
# parts hidden from `unknown: :error` would overwrite the value the
|
|
2661
|
+
# contract validated. And not when the contract declares one of the parts
|
|
2662
|
+
# as a field of its own, which is how a contract could carry them through
|
|
2663
|
+
# before this: that field keeps it.
|
|
2664
|
+
def permittable_read_date_parts(fields, hash)
|
|
2665
|
+
read = nil
|
|
2666
|
+
fields.each do |field|
|
|
2667
|
+
count = field[:kind] == :scalar && DATE_PARTS[field[:type]]
|
|
2668
|
+
next unless count
|
|
2669
|
+
|
|
2670
|
+
key = field[:name].to_s
|
|
2671
|
+
names = Array.new(count) { |index| "#{key}(#{index + 1}i)" }
|
|
2672
|
+
next if hash.key?(key)
|
|
2673
|
+
next unless names.any? { |name| hash.key?(name) }
|
|
2674
|
+
next if fields.any? { |other| names.include?(other[:name].to_s) }
|
|
2675
|
+
|
|
2676
|
+
read ||= hash.dup
|
|
2677
|
+
parts = {}
|
|
2678
|
+
names.each.with_index(1) { |name, position| parts[position] = read.delete(name) if read.key?(name) }
|
|
2679
|
+
|
|
2680
|
+
# Parts that make no value are handed to the field as the several
|
|
2681
|
+
# values they are, which a scalar refuses as invalid_type: on the
|
|
2682
|
+
# field's own path, with its own message:, as for `?starts_on[]=x`.
|
|
2683
|
+
read[key] = permittable_date_from_parts(parts, field[:type]) || parts.values
|
|
2684
|
+
end
|
|
2685
|
+
read || hash
|
|
2686
|
+
end
|
|
2687
|
+
|
|
2688
|
+
# The ISO 8601 String that `parts` (keyed by Rails' positions, 1-3 the
|
|
2689
|
+
# date and 4-6 the time) spell for a field of `type`, "" when they spell
|
|
2690
|
+
# the field sent empty, or nil when they spell nothing at all. A String is
|
|
2691
|
+
# what a :date or :datetime field already reads, so its cast and that
|
|
2692
|
+
# cast's rules (a zoneless datetime is UTC) stay in one place.
|
|
2693
|
+
#
|
|
2694
|
+
# This follows ActiveRecord's own reading of the same parts
|
|
2695
|
+
# (ActiveModel::Type::Date and DateTime cast them as a Hash): a value
|
|
2696
|
+
# needs all of (1i)-(3i), and when any one of them is blank the attribute
|
|
2697
|
+
# is nil, so the field reads as sent empty. That has to be ANY and not all
|
|
2698
|
+
# of them: Rails fills a part it discards (discard_day, or the date of a
|
|
2699
|
+
# time_select) through a hidden input, and the year and month selects of
|
|
2700
|
+
# an untouched picker still sit on their prompts beside it, so the form
|
|
2701
|
+
# arrives as {"(1i)" => "", "(2i)" => "", "(3i)" => "1"}. The time parts
|
|
2702
|
+
# of a :datetime are the other way round: ActiveModel defaults a blank
|
|
2703
|
+
# (4i), (5i) or (6i) to 00 once the date is there, and so does this. The
|
|
2704
|
+
# blank part need only be among those SENT: ActiveModel reads a Hash with
|
|
2705
|
+
# (2i) and (3i) but no (1i), or with (1i) and (2i) but no (3i), as nil when
|
|
2706
|
+
# one of the ones it has is blank, and so does this. A part that is missing
|
|
2707
|
+
# with no blank sibling is nil here, for a scalar to refuse as
|
|
2708
|
+
# invalid_type, when it is one of (1i)-(3i): a time_select with
|
|
2709
|
+
# `ignore_date:` sends only (4i) and (5i), and a :datetime needs a date.
|
|
2710
|
+
# A MISSING (4i), (5i) or (6i) is 00 like a blank one, which is what
|
|
2711
|
+
# ActiveModel does with {1 => 2026, 2 => 10, 3 => 17}: a date_select on a
|
|
2712
|
+
# :datetime column, or a datetime_select with `discard_hour:`, sends the
|
|
2713
|
+
# date alone, and (6i) comes with `include_seconds:` only.
|
|
2714
|
+
# Whatever else is not an unsigned decimal integer is nil likewise.
|
|
2715
|
+
def permittable_date_from_parts(parts, type)
|
|
2716
|
+
return "" if (1..3).any? { |position| parts.key?(position) && Coercion.absent_value?(parts[position]) }
|
|
2717
|
+
|
|
2718
|
+
time = type == :datetime
|
|
2719
|
+
digits = (1..3).map { |position| permittable_date_part(parts[position], year: position == 1) }
|
|
2720
|
+
if time
|
|
2721
|
+
# A time part that is blank or missing is 00, as ActiveModel reads it.
|
|
2722
|
+
digits += (4..6).map do |position|
|
|
2723
|
+
next "00" if !parts.key?(position) || Coercion.absent_value?(parts[position])
|
|
2724
|
+
|
|
2725
|
+
permittable_date_part(parts[position])
|
|
2726
|
+
end
|
|
2727
|
+
end
|
|
2728
|
+
return nil if digits.include?(nil)
|
|
2729
|
+
|
|
2730
|
+
year, month, day, hour, minute, second = digits
|
|
2731
|
+
time ? "#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}" : "#{year}-#{month}-#{day}"
|
|
2732
|
+
end
|
|
2733
|
+
|
|
2734
|
+
# One part's digits, padded once its leading zeros are dropped: "7" and
|
|
2735
|
+
# "007" are both "07". Joined as sent, Date._parse reads "2026-007-5" as
|
|
2736
|
+
# May 7th. The year is padded to four digits, so "99" is the year 99 (as
|
|
2737
|
+
# ActiveRecord reads it) rather than a two-digit year Date._parse places
|
|
2738
|
+
# in 1999, and is otherwise left to the cast, as a year in a String is.
|
|
2739
|
+
# Any other part has at most two digits: a month of "100" is no value a
|
|
2740
|
+
# select offers, and Date._parse would read the string around it some
|
|
2741
|
+
# other way. A non-negative Integer is the digits it prints: a JSON body or
|
|
2742
|
+
# an `as: :json` request cannot send the String a form does, and
|
|
2743
|
+
# ActiveRecord reads either. nil for anything that is not an unsigned
|
|
2744
|
+
# decimal integer.
|
|
2745
|
+
def permittable_date_part(part, year: false)
|
|
2746
|
+
part = part.to_s if part.is_a?(Integer) && !part.negative?
|
|
2747
|
+
text = Coercion.utf8_text(part) if part.is_a?(String)
|
|
2748
|
+
return nil unless text&.match?(DATE_PART_FORMAT)
|
|
2749
|
+
|
|
2750
|
+
digits = text.sub(/\A0+(?=\d)/, "")
|
|
2751
|
+
return digits.rjust(4, "0") if year
|
|
2752
|
+
|
|
2753
|
+
digits.rjust(2, "0") if digits.length <= 2
|
|
2754
|
+
end
|
|
2755
|
+
|
|
2317
2756
|
def permittable_check_field(field, value, full, result, unknown:, violations:)
|
|
2318
2757
|
key = field[:name].to_s
|
|
2319
2758
|
case field[:kind]
|
|
@@ -2330,6 +2769,7 @@ module Permittable
|
|
|
2330
2769
|
violations << permittable_violation(field, full, "invalid_type")
|
|
2331
2770
|
end
|
|
2332
2771
|
when :array
|
|
2772
|
+
value = value.values if permittable_fields_for_records?(field, value)
|
|
2333
2773
|
if value.is_a?(Array)
|
|
2334
2774
|
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
2335
2775
|
else
|
|
@@ -2338,8 +2778,22 @@ module Permittable
|
|
|
2338
2778
|
end
|
|
2339
2779
|
end
|
|
2340
2780
|
|
|
2341
|
-
#
|
|
2342
|
-
#
|
|
2781
|
+
# Whether `value` is an array of hashes sent the way Rails' fields_for
|
|
2782
|
+
# sends one: `user[addresses_attributes][0][city]` reaches the params as a
|
|
2783
|
+
# Hash keyed "0", "1", ... (or a timestamp, for a record added in the
|
|
2784
|
+
# browser). Strong params permits exactly this shape (its
|
|
2785
|
+
# fields_for_style?: every key an integer, every value a hash) and
|
|
2786
|
+
# accepts_nested_attributes_for reads its values in order, so an
|
|
2787
|
+
# `array ... do` field reads them as its elements. Paths then count the
|
|
2788
|
+
# records in the order sent. An empty Hash, or one of scalars, is not this
|
|
2789
|
+
# shape and stays invalid_type.
|
|
2790
|
+
def permittable_fields_for_records?(field, value)
|
|
2791
|
+
field[:fields] && value.is_a?(Hash) && !value.empty? &&
|
|
2792
|
+
value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
|
|
2793
|
+
end
|
|
2794
|
+
|
|
2795
|
+
# The bounds, then `validate:`, with the copy the result needs made in the
|
|
2796
|
+
# middle. The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2343
2797
|
# rebuilt its containers but not the Strings inside them, so it is
|
|
2344
2798
|
# deep-copied for the reason permittable_normalized copies a String — but
|
|
2345
2799
|
# only once it is within its bounds. Copying first meant a megabyte
|
|
@@ -2365,19 +2819,31 @@ module Permittable
|
|
|
2365
2819
|
end
|
|
2366
2820
|
|
|
2367
2821
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
2822
|
+
# A scalar array's "" elements are absent, as "" is for a field: the
|
|
2823
|
+
# hidden `name="post[tag_ids][]" value=""` that collection_check_boxes
|
|
2824
|
+
# and `select multiple: true` render, so that unchecking every box still
|
|
2825
|
+
# sends the key, made every submit a 422 for `of: :integer`. Each kept
|
|
2826
|
+
# element keeps the index the client sent it at, so violation paths
|
|
2827
|
+
# still point into the request. A null element, which no form sends,
|
|
2828
|
+
# is still refused.
|
|
2829
|
+
blank = ->(element) { !field[:fields] && element.is_a?(String) && element.empty? }
|
|
2830
|
+
count = field[:fields] ? value.length : value.length - value.count(&blank)
|
|
2831
|
+
|
|
2368
2832
|
# `length:` is a BOUND, not a report. An array outside it is rejected
|
|
2369
2833
|
# whatever its contents, so checking those contents can only add work and
|
|
2370
2834
|
# noise: a 200k-element payload against `length: 0..10` used to cast every
|
|
2371
2835
|
# element, collect 200k more violations, and answer with a multi-megabyte
|
|
2372
2836
|
# 422 — for a request already refused by its first check. Stopping here
|
|
2373
|
-
# keeps the cost of an oversized array proportional to rejecting it
|
|
2374
|
-
|
|
2837
|
+
# keeps the cost of an oversized array proportional to rejecting it (the
|
|
2838
|
+
# blank count above allocates nothing).
|
|
2839
|
+
if field[:length] && !Coercion.length_ok?(field[:length], count)
|
|
2375
2840
|
violations << permittable_violation(field, path, "length")
|
|
2376
2841
|
return nil
|
|
2377
2842
|
end
|
|
2378
2843
|
|
|
2379
2844
|
before = violations.length
|
|
2380
|
-
|
|
2845
|
+
elements = value.each_with_index.reject { |element, _| blank.call(element) }
|
|
2846
|
+
out = elements.map do |element, index|
|
|
2381
2847
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
2382
2848
|
end
|
|
2383
2849
|
# validate: and transform: see only a fully-valid array. A partially-nil
|
|
@@ -2400,8 +2866,9 @@ module Permittable
|
|
|
2400
2866
|
# NOTHING violated, validate: included — a transform may rely on what
|
|
2401
2867
|
# validate: checked (`Math.sqrt` after "all positive").
|
|
2402
2868
|
elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
|
|
2403
|
-
|
|
2404
|
-
|
|
2869
|
+
validator = permittable_array_validator(field)
|
|
2870
|
+
if validator && elements_valid
|
|
2871
|
+
status, code = Coercion.check_custom(validator, out)
|
|
2405
2872
|
violations << permittable_violation(field, path, code) unless status == :ok
|
|
2406
2873
|
end
|
|
2407
2874
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
@@ -2416,6 +2883,11 @@ module Permittable
|
|
|
2416
2883
|
field[:transform] ? field[:transform].call(value) : value
|
|
2417
2884
|
end
|
|
2418
2885
|
|
|
2886
|
+
# A second seam, for the same reader: the array-level `validate:` to run.
|
|
2887
|
+
def permittable_array_validator(field)
|
|
2888
|
+
field[:validate]
|
|
2889
|
+
end
|
|
2890
|
+
|
|
2419
2891
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
2420
2892
|
if field[:fields]
|
|
2421
2893
|
unless element.is_a?(Hash)
|
|
@@ -2492,6 +2964,7 @@ module Permittable
|
|
|
2492
2964
|
if top_level
|
|
2493
2965
|
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2494
2966
|
extra -= [permittable_configured_csrf_key].compact
|
|
2967
|
+
extra -= permittable_image_submit_keys(hash, extra)
|
|
2495
2968
|
end
|
|
2496
2969
|
return if extra.empty?
|
|
2497
2970
|
|
|
@@ -2517,6 +2990,33 @@ module Permittable
|
|
|
2517
2990
|
request.path_parameters.keys.map(&:to_s)
|
|
2518
2991
|
end
|
|
2519
2992
|
|
|
2993
|
+
# The click point of an image submit among `keys` (the undeclared top-level
|
|
2994
|
+
# keys). image_submit_tag renders <input type="image">, and a browser
|
|
2995
|
+
# submitting through it posts where it was clicked: `x=12&y=7`, or
|
|
2996
|
+
# `save.x=12&save.y=7` when the tag is named "save" (Rack keeps a dotted
|
|
2997
|
+
# key as it is). Like the other FORM_KEYS these are the framework's own
|
|
2998
|
+
# keys, so `unknown: :error` must not fail the form for them. Unlike them
|
|
2999
|
+
# `x` and `y` are ordinary names a contract may really be sent, so a
|
|
3000
|
+
# lone `x` is still flagged: a point is exempt only as a PAIR, both of
|
|
3001
|
+
# them in `keys` (a declared `x` or `y` is not, and is checked as a field)
|
|
3002
|
+
# and both digits, which is all a browser sends (coordinates are never
|
|
3003
|
+
# negative or fractional). Read by bytes, since a client's key or value can
|
|
3004
|
+
# be invalid UTF-8, which a regexp refuses to match, or another encoding
|
|
3005
|
+
# altogether (a UTF-16 key makes `end_with?` raise).
|
|
3006
|
+
def permittable_image_submit_keys(hash, keys)
|
|
3007
|
+
by_bytes = keys.to_h { |key| [key.b, key] }
|
|
3008
|
+
by_bytes.each_with_object([]) do |(bytes, key), point|
|
|
3009
|
+
next unless bytes == "x" || bytes.end_with?(".x")
|
|
3010
|
+
|
|
3011
|
+
partner = by_bytes["#{bytes.delete_suffix('x')}y"]
|
|
3012
|
+
point.push(key, partner) if partner && [key, partner].all? { |name| permittable_digits?(hash[name]) }
|
|
3013
|
+
end
|
|
3014
|
+
end
|
|
3015
|
+
|
|
3016
|
+
def permittable_digits?(value)
|
|
3017
|
+
value.is_a?(String) && !value.empty? && value.each_byte.all? { |byte| byte.between?(48, 57) }
|
|
3018
|
+
end
|
|
3019
|
+
|
|
2520
3020
|
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2521
3021
|
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2522
3022
|
# lets an app rename it, and an app that does trips `unknown: :error` on
|