permittable 0.11.1 → 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.
data/lib/permittable.rb CHANGED
@@ -132,10 +132,14 @@ 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 one exception is a field
136
- # declaring `transform:`: its default is stored exactly AS AUTHORED, never
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.
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.
139
143
  # An `example:` is cast either way and never transformed: it documents what a
140
144
  # client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
141
145
  # copy, so no request can corrupt it for the next.
@@ -190,8 +194,10 @@ require "permittable/filter_parameter_registry"
190
194
  # into an Array. Runs only on request-supplied values: absent fields stay
191
195
  # absent, and a `default:` is handed out exactly AS AUTHORED — validated
192
196
  # against the field's own contract at class load (as any default is), but
193
- # neither cast nor transformed — so a request sending a field's default
194
- # gets the transformed value, a request omitting it the untransformed one.
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.
195
201
  # Author a default already in the shape the action should receive:
196
202
  # `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
197
203
  # paths the Integer 25. A field with no `transform:` still gets its
@@ -248,6 +254,18 @@ module Permittable
248
254
  # rather than leaving a bare ROUTING_KEYS to read like an oversight.
249
255
  UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
250
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/
251
269
  # The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
252
270
  # rootless contract declaring the wrapper key as one of these reads the
253
271
  # copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
@@ -690,17 +708,10 @@ module Permittable
690
708
  # Free-form hash. The shape is deliberately undeclared, so the only
691
709
  # checks are the bounds the field asked for: breadth (`length:`, the
692
710
  # top-level key count, same reading as an array's element count) and
693
- # nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
694
- # checking, like check_scalar.
695
- def check_json(field, value)
696
- status, code = check_json_bounds(field, value)
697
- return [status, code] unless status == :ok
698
-
699
- check_custom(field[:validate], value)
700
- end
701
-
702
- # The structural half of check_json, which the request walker runs on
703
- # 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).
704
715
  def check_json_bounds(field, value)
705
716
  return [:error, "invalid_type"] unless value.is_a?(Hash)
706
717
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
@@ -831,7 +842,8 @@ module Permittable
831
842
  # here (a plain :string field, not :decimal itself) must render the same
832
843
  # way regardless of whether that patch happens to be loaded.
833
844
  when BigDecimal then [:ok, value.to_s("F")]
834
- when Numeric, true, false then [:ok, value.to_s]
845
+ when Numeric then foreign_numeric { [:ok, value.to_s] }
846
+ when true, false then [:ok, value.to_s]
835
847
  else [:error, "invalid_type"]
836
848
  end
837
849
  end
@@ -863,7 +875,19 @@ module Permittable
863
875
 
864
876
  def cast_float(value)
865
877
  case value
866
- when Numeric then finite_float(value.to_f)
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
867
891
  when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
868
892
  else [:error, "invalid_type"]
869
893
  end
@@ -871,6 +895,18 @@ module Permittable
871
895
  [:error, "invalid_type"]
872
896
  end
873
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
+
874
910
  # A Float that is not finite does not represent what was sent. "1e400"
875
911
  # overflows to Infinity and "1e-400" underflows to zero — both silently,
876
912
  # and both leaving a value no column can faithfully store.
@@ -879,7 +915,8 @@ module Permittable
879
915
  # an ordinary 0.0: a zero result is rejected when the string it came from
880
916
  # named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
881
917
  # genuine zero whose exponent digits say nothing about the value — as are
882
- # "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).
883
920
  def finite_float(result, source: nil)
884
921
  return [:error, "invalid_type"] unless result.finite?
885
922
  return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
@@ -898,8 +935,8 @@ module Permittable
898
935
  def cast_decimal(value)
899
936
  case value
900
937
  when Rational then rational_to_decimal(value)
901
- when Numeric then finite_decimal(BigDecimal(value.to_s))
902
- when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
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"]
903
940
  else [:error, "invalid_type"]
904
941
  end
905
942
  rescue ArgumentError
@@ -920,13 +957,38 @@ module Permittable
920
957
  finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
921
958
  end
922
959
 
923
- # BigDecimal has no exponent limit, so a :decimal cannot overflow — but
924
960
  # BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
925
961
  # raises, so a client could send the literal string "NaN" for a price and
926
962
  # have it stored. Nothing else in the gem disagreed with itself this
927
963
  # loudly: :float rejected those strings and :decimal did not.
928
- def finite_decimal(result)
929
- result.finite? ? [:ok, result] : [:error, "invalid_type"]
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
930
992
  end
931
993
 
932
994
  def cast_boolean(value)
@@ -945,6 +1007,15 @@ module Permittable
945
1007
  # ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
946
1008
  def cast_date(value)
947
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"]
948
1019
  when Date then [:ok, value]
949
1020
  when String
950
1021
  found = Date._parse(value)
@@ -967,6 +1038,19 @@ module Permittable
967
1038
  found.key?(:year) && found.key?(:mon) && found.key?(:mday)
968
1039
  end
969
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
+
970
1054
  # A zoneless String parses as UTC regardless of the host timezone
971
1055
  # (deterministic); explicit offsets are honoured and normalised to UTC.
972
1056
  def cast_datetime(value)
@@ -989,13 +1073,36 @@ module Permittable
989
1073
  # Time from components would have to reimplement DateTime.parse's
990
1074
  # handling of offsets, zone names and sub-second precision, and
991
1075
  # getting that subtly wrong costs more than the parse.
992
- complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
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]
993
1087
  else [:error, "invalid_type"]
994
1088
  end
995
1089
  rescue ArgumentError, RangeError
996
1090
  [:error, "invalid_type"]
997
1091
  end
998
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
+
999
1106
  # Presets only make sense on String input; a non-String value (JSON
1000
1107
  # numbers, booleans) skips normalization and goes straight to the cast.
1001
1108
  #
@@ -1073,41 +1180,58 @@ module Permittable
1073
1180
  end
1074
1181
 
1075
1182
  # The members of an `in:` that is a LIST, or nil when it is not one.
1076
- # Only Array, Set, Hash and Enumerator count, and only when the object's
1077
- # OWN class provides the collection's ordinary include? — not a Hash,
1078
- # Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
1079
- # fuzzy Set, a registry matching some other way entirely). `case allowed;
1080
- # when Hash ...` matches with ===, which for a Class is is_a?, so a
1081
- # subclass would otherwise match its ancestor's branch and have its
1082
- # override silently discarded — read for its raw keys/elements instead,
1083
- # which can invert which values it actually accepts. It is left opaque
1084
- # instead, exactly like any other object whose include? is the point
1085
- # (see resolve_in!) and enumerating it may be expensive (a DB-backed
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
1086
1193
  # registry).
1087
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
+ #
1088
1202
  # A Hash lists its KEYS, which is what Hash#include? asks about — the
1089
1203
  # Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
1090
1204
  # Set, so membership stays O(1) per request.
1091
- # ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
1092
- # accepted anyway: its include? override only canonicalises the argument
1093
- # (String/Symbol) before the SAME key lookup, so its keys are still
1094
- # exactly its members — and it is what a Rails enum's own reader
1095
- # (`Post.statuses`) actually returns.
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.
1096
1210
  # Enumerator::Lazy is the same story on the Enumerator side: Lazy
1097
1211
  # overrides chain methods like map and select, but not include?, so it
1098
1212
  # is still read as a list — and forced to an Array here, once, since
1099
1213
  # left lazy it would be cast on every request instead of at class load.
1100
1214
  def in_list(allowed)
1101
1215
  case allowed
1102
- when Hash then allowed.keys.to_set if plain_hash?(allowed)
1103
- when Set then allowed if allowed.instance_of?(Set)
1104
- when Array then allowed.to_a if allowed.instance_of?(Array)
1105
- when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
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)
1106
1220
  end
1107
1221
  end
1108
1222
 
1109
- def plain_hash?(allowed)
1110
- allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
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 }
1111
1235
  end
1112
1236
 
1113
1237
  # A Symbol is read as its String: it is how Ruby spells a constant
@@ -1116,20 +1240,34 @@ module Permittable
1116
1240
  member = member.to_s if member.is_a?(Symbol)
1117
1241
  return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
1118
1242
 
1119
- 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
1120
1259
  end
1121
1260
 
1122
1261
  # A Time or DateTime member of a :date field. ActiveSupport compares one
1123
1262
  # with a Date as INSTANTS, the Date standing for its midnight UTC, so
1124
1263
  # that instant is the only one that ever equalled a request's date. It
1125
1264
  # is read as that UTC date; any other instant never matched anything,
1126
- # and is refused like any member no request could equal. (cast_date
1127
- # would keep a DateTime whole — it IS a Date — and refuse a Time.)
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`.
1128
1268
  def instant_as_date(member)
1129
- utc = member.to_time.getutc
1130
- return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
1131
-
1132
- [:ok, utc.to_date]
1269
+ date = date_of_instant(member)
1270
+ date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
1133
1271
  end
1134
1272
 
1135
1273
  # What an exported enum lists for one member: the cast value, re-encoded
@@ -1246,10 +1384,13 @@ module Permittable
1246
1384
  # end
1247
1385
  #
1248
1386
  # `optional: true` relaxes every spliced field, which is how an update
1249
- # contract reuses a create contract: nothing is mandatory, but `default:`,
1250
- # types and bounds all still apply. It relaxes the TOP LEVEL only — if a
1251
- # client sends an address at all, the address's own required sub-fields
1252
- # still hold.
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.
1253
1394
  #
1254
1395
  # `only:`/`except:` select a subset, in the group's own order. Naming a
1255
1396
  # field the group doesn't declare is a class-load error, so a typo cannot
@@ -1258,7 +1399,7 @@ module Permittable
1258
1399
  # and then declare `:city` yourself.
1259
1400
  def use(group, only: nil, except: nil, optional: false)
1260
1401
  fields = select_group_fields!(group_fields!(group), only: only, except: except)
1261
- 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
1262
1403
  fields.each do |field|
1263
1404
  field_name!(field[:name])
1264
1405
  @fields << field
@@ -1268,7 +1409,22 @@ module Permittable
1268
1409
 
1269
1410
  private
1270
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.
1271
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
1272
1428
  return group.fields if group.respond_to?(:fields)
1273
1429
 
1274
1430
  raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
@@ -1542,17 +1698,32 @@ module Permittable
1542
1698
  raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
1543
1699
  end
1544
1700
 
1545
- # Same rule as a scalar's authored value, over check_json: a `default:` or
1546
- # `example:` that its own bounds would reject fails at class load.
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.
1547
1718
  def validate_json_authored_value!(field, opt)
1548
1719
  return unless field.key?(opt)
1549
1720
  return if authored_nil!(field, opt)
1550
1721
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
1551
1722
 
1552
- status, code = Coercion.check_json(field, field[opt])
1553
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
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
1554
1725
 
1555
- field[opt] = freeze_authored(field[opt])
1726
+ field[opt] = freeze_authored(opt == :example ? ActiveSupport::HashWithIndifferentAccess.new(field[opt]) : read)
1556
1727
  end
1557
1728
 
1558
1729
  # format / length / normalize reason about characters; on any other
@@ -1572,10 +1743,33 @@ module Permittable
1572
1743
  raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
1573
1744
  "(got #{length.inspect})"
1574
1745
  end
1746
+ validate_length_ends!(name, length) if length.is_a?(Range)
1575
1747
 
1576
1748
  assert_satisfiable!(name, :length, length)
1577
1749
  end
1578
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
+
1579
1773
  # A reversed Range (5..2), an exclusive Range with equal endpoints
1580
1774
  # (3...3), or an empty set (in: []) excludes every value there is, so the
1581
1775
  # field it bounds can never validate. That used to surface as every
@@ -2423,6 +2617,7 @@ module Permittable
2423
2617
 
2424
2618
  def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
2425
2619
  result = ActiveSupport::HashWithIndifferentAccess.new
2620
+ hash = permittable_read_date_parts(fields, hash)
2426
2621
  fields.each do |field|
2427
2622
  key = field[:name].to_s
2428
2623
  full = permittable_path(path, key)
@@ -2445,6 +2640,119 @@ module Permittable
2445
2640
  result
2446
2641
  end
2447
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
+
2448
2756
  def permittable_check_field(field, value, full, result, unknown:, violations:)
2449
2757
  key = field[:name].to_s
2450
2758
  case field[:kind]
@@ -2484,8 +2792,8 @@ module Permittable
2484
2792
  value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
2485
2793
  end
2486
2794
 
2487
- # Coercion.check_json, with the copy the result needs made in the middle.
2488
- # The opaque hash is handed over whole, and HashWithIndifferentAccess
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
2489
2797
  # rebuilt its containers but not the Strings inside them, so it is
2490
2798
  # deep-copied for the reason permittable_normalized copies a String — but
2491
2799
  # only once it is within its bounds. Copying first meant a megabyte
@@ -2511,19 +2819,31 @@ module Permittable
2511
2819
  end
2512
2820
 
2513
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
+
2514
2832
  # `length:` is a BOUND, not a report. An array outside it is rejected
2515
2833
  # whatever its contents, so checking those contents can only add work and
2516
2834
  # noise: a 200k-element payload against `length: 0..10` used to cast every
2517
2835
  # element, collect 200k more violations, and answer with a multi-megabyte
2518
2836
  # 422 — for a request already refused by its first check. Stopping here
2519
- # keeps the cost of an oversized array proportional to rejecting it.
2520
- if field[:length] && !Coercion.length_ok?(field[:length], value.length)
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)
2521
2840
  violations << permittable_violation(field, path, "length")
2522
2841
  return nil
2523
2842
  end
2524
2843
 
2525
2844
  before = violations.length
2526
- out = value.each_with_index.map do |element, index|
2845
+ elements = value.each_with_index.reject { |element, _| blank.call(element) }
2846
+ out = elements.map do |element, index|
2527
2847
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
2528
2848
  end
2529
2849
  # validate: and transform: see only a fully-valid array. A partially-nil