permittable 0.13.0 → 0.14.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
@@ -218,7 +218,11 @@ module Permittable
218
218
  extend ActiveSupport::Concern
219
219
 
220
220
  LABEL = "Permittable".freeze
221
- SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
221
+ SCALAR_TYPES = %i[string integer float decimal boolean date datetime file].freeze
222
+ # An upload, as multipart params carry it. It is a scalar in strong params'
223
+ # sense (an upload object is permitted), but it is never cast, and a
224
+ # request's String can never be one.
225
+ FILE_TYPE = :file
222
226
  # Not a scalar: an opaque hash whose shape is deliberately undeclared, for
223
227
  # the json/jsonb column a contract has to be able to carry.
224
228
  JSON_TYPE = :json
@@ -640,15 +644,14 @@ module Permittable
640
644
 
641
645
  included do
642
646
  class_attribute :permittable_contracts, instance_accessor: false, default: []
643
-
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`.
651
- before_action :enforce_params_contract if respond_to?(:before_action)
647
+ # Whether the gem itself ever registered its rescue_from / before_action
648
+ # for this class (a subclass inherits the answer). Asking the callback
649
+ # chain instead would bring back a check the app took out with
650
+ # `skip_before_action :enforce_params_contract`.
651
+ class_attribute :permittable_rescue_registered, :permittable_callback_registered,
652
+ instance_accessor: false, default: false
653
+
654
+ permittable_register_hooks
652
655
  end
653
656
 
654
657
  # Strict params-shaped coercion, shared by request-time validation and
@@ -746,6 +749,7 @@ module Permittable
746
749
  end
747
750
 
748
751
  def cast(type, value)
752
+ return cast_file(value) if type == FILE_TYPE
749
753
  return [:error, "invalid_type"] unless scalar_shaped?(value)
750
754
  return public_send("cast_#{type}", value) unless value.is_a?(String)
751
755
  # Bytes that are not valid in the String's OWN encoding are not text in
@@ -819,6 +823,17 @@ module Permittable
819
823
  value.dup.force_encoding(Encoding::UTF_8).scrub
820
824
  end
821
825
 
826
+ # Exactly the upload objects ActionController::Parameters permits as
827
+ # scalars (its PERMITTED_SCALAR_TYPES): what Rails' multipart parser
828
+ # builds, and what rack-test sends. Each constant is looked up at call
829
+ # time, so the gem loads and a standalone Contract works without
830
+ # ActionDispatch or rack-test. The object is handed back as it came.
831
+ def cast_file(value)
832
+ upload = (defined?(ActionDispatch::Http::UploadedFile) && value.is_a?(ActionDispatch::Http::UploadedFile)) ||
833
+ (defined?(Rack::Test::UploadedFile) && value.is_a?(Rack::Test::UploadedFile))
834
+ upload ? [:ok, value] : [:error, "invalid_type"]
835
+ end
836
+
822
837
  # Arrays, hashes, and nested ActionController::Parameters
823
838
  # (`?age[]=1`, `?age[x]=1`) can never satisfy a scalar type.
824
839
  def scalar_shaped?(value)
@@ -1018,8 +1033,17 @@ module Permittable
1018
1033
  date ? [:ok, date] : [:error, "invalid_type"]
1019
1034
  when Date then [:ok, value]
1020
1035
  when String
1036
+ # A bare ISO date ("2026-09-05", the common case) carries no zone, T
1037
+ # or offset, so it skips every check for one.
1038
+ plain = PLAIN_ISO_DATE.match?(value)
1039
+ return cast_date_instant(value) if !plain && (ISO_DATE_AT_OFFSET.match?(value) || ISO_DATE_T.match?(value))
1040
+
1021
1041
  found = Date._parse(value)
1022
1042
  return [:error, "invalid_type"] unless complete_date?(found)
1043
+ return cast_date_instant(value, found) if names_an_instant?(found)
1044
+ # No time found, but an offset may have been dropped ("5/9/2026
1045
+ # -0500"), which :datetime refuses too.
1046
+ return [:error, "invalid_type"] if !plain && offset_misread?(value, found)
1023
1047
 
1024
1048
  # Built from the components rather than re-running Date.parse, which
1025
1049
  # would parse the same string a second time — and Date.parse is the
@@ -1038,6 +1062,32 @@ module Permittable
1038
1062
  found.key?(:year) && found.key?(:mon) && found.key?(:mday)
1039
1063
  end
1040
1064
 
1065
+ # A time of day or a zone, as opposed to a bare calendar date.
1066
+ def names_an_instant?(found)
1067
+ %i[hour min sec sec_fraction zone offset].any? { |part| found.key?(part) }
1068
+ end
1069
+
1070
+ # A String that carries a time or a zone is the instant it names, read
1071
+ # exactly as :datetime reads it (a zoneless one as UTC, an impossible
1072
+ # time or an offset Ruby cannot honour refused) and then as a Time is
1073
+ # read: a date only at midnight UTC. Keeping just the calendar day
1074
+ # accepted "T25:00:00Z" and "+24:00", and turned "T23:30:00-05:00",
1075
+ # which is the next day in UTC, into the day before.
1076
+ #
1077
+ # The Date is built from the components, like a date-only String's,
1078
+ # because Time#to_date reads the proleptic Gregorian day in the Julian
1079
+ # calendar before 1582 ("1500-01-01T00:00:00Z" became 1499-12-23).
1080
+ #
1081
+ # `found` is the String's Date._parse, when the caller has it and has
1082
+ # ruled out the ISO forms cast_datetime reads without one; :datetime's
1083
+ # reading of it is then the same, with one parse fewer.
1084
+ def cast_date_instant(value, found = nil)
1085
+ status, instant = found ? cast_parsed_datetime(value, found) : cast_datetime(value)
1086
+ return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1087
+
1088
+ [:ok, Date.new(instant.year, instant.month, instant.day)]
1089
+ end
1090
+
1041
1091
  # The date a Time or DateTime IS, or nil: its UTC date when it is
1042
1092
  # exactly midnight UTC. That is the instant ActiveSupport compares a
1043
1093
  # Date as, and the one :datetime reads a Date as, so the two types map
@@ -1046,9 +1096,31 @@ module Permittable
1046
1096
  # with a time of day always got. Shared by cast_date and
1047
1097
  # instant_as_date, so a value and an in: member cannot be read
1048
1098
  # differently; and always a plain Date, never the DateTime it came as.
1099
+ #
1100
+ # Built from the UTC components, as a String is (cast_date), not with
1101
+ # Time#to_date: that keeps the instant's day NUMBER and labels it in the
1102
+ # Julian calendar before the 1582 reform, so Time.utc(1500, 1, 1) came
1103
+ # out as 1499-12-23 while "1500-01-01" was 1500-01-01.
1104
+ #
1105
+ # A day the 1582 reform skipped (1582-10-05..14) is no Date, as the String
1106
+ # "1582-10-10" is none, so it is nil here and every caller gives its usual
1107
+ # error. :datetime is proleptic Gregorian and does accept those days; the
1108
+ # two differ there by design, :date being the String path's calendar.
1109
+ #
1110
+ # A DateTime is read by its own civil day, not through #to_time: stdlib's
1111
+ # converts by day number, so outside Rails (ActiveSupport converts by
1112
+ # component) a DateTime before 1582 landed 9 days late.
1049
1113
  def date_of_instant(instant)
1050
- utc = instant.to_time.getutc
1051
- utc.to_date if utc == utc.beginning_of_day
1114
+ if instant.is_a?(DateTime)
1115
+ utc = instant.new_offset(0)
1116
+ return unless utc.hour.zero? && utc.minute.zero? && utc.second.zero? && utc.sec_fraction.zero?
1117
+ else
1118
+ utc = instant.to_time.getutc
1119
+ return unless utc == utc.beginning_of_day
1120
+ end
1121
+ return unless Date.valid_date?(utc.year, utc.month, utc.day)
1122
+
1123
+ Date.new(utc.year, utc.month, utc.day)
1052
1124
  end
1053
1125
 
1054
1126
  # A zoneless String parses as UTC regardless of the host timezone
@@ -1080,16 +1152,28 @@ module Permittable
1080
1152
  # converts by day number, so "1500-01-01" became 1500-01-10 — unless
1081
1153
  # the host had loaded ActiveSupport's DateTime#to_time, which
1082
1154
  # converts by component. Gregorian, both agree.
1083
- found = Date._parse(value)
1084
- return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1155
+ if (iso = ISO_DATE_AT_OFFSET.match(value)) then return midnight_at_offset(*iso.captures)
1156
+ elsif ISO_DATE_T.match?(value) then return [:error, "invalid_type"]
1157
+ end
1085
1158
 
1086
- [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1159
+ cast_parsed_datetime(value, Date._parse(value))
1087
1160
  else [:error, "invalid_type"]
1088
1161
  end
1089
1162
  rescue ArgumentError, RangeError
1090
1163
  [:error, "invalid_type"]
1091
1164
  end
1092
1165
 
1166
+ # cast_datetime's reading of a String once Date._parse has `found` its
1167
+ # parts, shared with cast_date_instant so a :date need not parse twice.
1168
+ def cast_parsed_datetime(value, found)
1169
+ return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1170
+ return [:error, "invalid_type"] if offset_misread?(value, found)
1171
+
1172
+ [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1173
+ rescue ArgumentError, RangeError
1174
+ [:error, "invalid_type"]
1175
+ end
1176
+
1093
1177
  # A zone the parser could not turn into an offset — an offset of a day
1094
1178
  # or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
1095
1179
  # know ("XYZ") — is reported as a zone with no offset, or with one
@@ -1103,6 +1187,74 @@ module Permittable
1103
1187
  !offset.nil? && offset.abs < 86_400
1104
1188
  end
1105
1189
 
1190
+ # An ISO date followed by an offset or Z and no time of day
1191
+ # ("2026-09-05-05:00", "2026-09-05 +05:00", "20260905Z"): XSD xs:date and
1192
+ # Java's ISO_DATE, for midnight at that offset. Date._parse does not read
1193
+ # it so — it takes "-05:00" for the TIME 05:00 and drops the sign, or
1194
+ # drops "-0500" altogether — so the instant is built from the captures.
1195
+ # Only the whole String counts, so a plain "2026-09-05" (which ends in
1196
+ # "-05") never matches, and costs nothing more than the match.
1197
+ ISO_DATE_AT_OFFSET = /\A\s*(\d{4})(-?)(\d{2})\2(\d{2})\s*(Z|z|[+-]\d{2}(?::?\d{2})?)\s*\z/
1198
+
1199
+ # An ISO date followed by a "T" and no time ("2026-09-05T",
1200
+ # "20260905T-0500"). Ruby reads a lone letter as a military zone, T as
1201
+ # -07:00, or drops it with whatever follows, so it is refused rather
1202
+ # than read as some instant the client did not write.
1203
+ ISO_DATE_T = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt](?!\s*\d)/
1204
+
1205
+ # An ISO date and nothing else, which none of the above can match.
1206
+ PLAIN_ISO_DATE = /\A\s*\d{4}-?\d{2}-?\d{2}\s*\z/
1207
+
1208
+ # A signed number at the end of a String, and whether a space sets it
1209
+ # apart from what comes before ("5/9/2026 -0500" but not "2026-09-05").
1210
+ SIGNED_TAIL = /(\s)?[+-]\d+(?::\d+)*\s*\z/
1211
+ private_constant :ISO_DATE_AT_OFFSET, :ISO_DATE_T, :PLAIN_ISO_DATE, :SIGNED_TAIL
1212
+
1213
+ # The captures of ISO_DATE_AT_OFFSET as the instant they name, held to
1214
+ # the rule any other offset is (honoured_zone?): "+24:00" and "-99:00"
1215
+ # are refused, and so are minutes past 59 ("+05:99"), which the parser
1216
+ # reports with no offset. Date.new checks the day ("2026-02-30Z"), which
1217
+ # Time.utc would roll over into March.
1218
+ def midnight_at_offset(year, _dash, mon, mday, zone)
1219
+ year, mon, mday = [year, mon, mday].map { |part| Integer(part, 10) }
1220
+ offset = zone_offset(zone)
1221
+ return [:error, "invalid_type"] unless honoured_zone?(zone: zone, offset: offset)
1222
+
1223
+ Date.new(year, mon, mday, Date::GREGORIAN)
1224
+ [:ok, Time.utc(year, mon, mday) - offset]
1225
+ end
1226
+
1227
+ # "Z", "+05", "-0530" or "+05:30" in seconds east of UTC, or nil when
1228
+ # the minutes are not minutes.
1229
+ def zone_offset(zone)
1230
+ return 0 if zone.casecmp?("z")
1231
+
1232
+ hours = Integer(zone[1, 2], 10)
1233
+ minutes = Integer(zone[3..].delete(":").rjust(1, "0"), 10)
1234
+ return if minutes > 59
1235
+
1236
+ (zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
1237
+ end
1238
+
1239
+ # Whether a date's trailing offset was taken for its time of day
1240
+ # ("Sep 5 2026 -05:00" reports 05:00, "2026-09-05 UTC-05:00" too) or
1241
+ # dropped without a trace ("5/9/2026 -0500", "2026-09-05 +5"), outside
1242
+ # the ISO form midnight_at_offset reads. The tail is the culprit when the
1243
+ # parser found no zone and the String without it names the same date and
1244
+ # no time. Only a String that ends in a signed number and either got an
1245
+ # hour or has a space before the number is parsed again, so a plain
1246
+ # "2026-09-05" is not.
1247
+ def offset_misread?(value, found)
1248
+ return false if found.key?(:zone)
1249
+
1250
+ tail = SIGNED_TAIL.match(value) or return false
1251
+ return false unless tail[1] || found.key?(:hour)
1252
+
1253
+ rest = Date._parse(tail.pre_match)
1254
+ complete_date?(rest) && !rest.key?(:hour) &&
1255
+ rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
1256
+ end
1257
+
1106
1258
  # Presets only make sense on String input; a non-String value (JSON
1107
1259
  # numbers, booleans) skips normalization and goes straight to the cast.
1108
1260
  #
@@ -1241,11 +1393,34 @@ module Permittable
1241
1393
  return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
1242
1394
 
1243
1395
  status, value = cast(type, member)
1244
- return [status, value] unless status == :ok && type == :datetime && !member.is_a?(String) && !whole_nanoseconds?(value)
1396
+ return [status, value] unless status == :ok && finer_than_nanosecond?(type, member, value)
1245
1397
 
1246
1398
  [:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
1247
1399
  end
1248
1400
 
1401
+ # Whether `raw`, authored as a Time-like `in:` member of a :datetime
1402
+ # field and cast to `cast_value`, holds more than the nine digits the
1403
+ # export prints. A String `in:` member is exempt: it is cast exactly as a
1404
+ # request sending it is, and published as written (see
1405
+ # published_in_member), so whatever precision it spells is reachable.
1406
+ def finer_than_nanosecond?(type, raw, cast_value)
1407
+ type == :datetime && !raw.is_a?(String) && !whole_nanoseconds?(cast_value)
1408
+ end
1409
+
1410
+ # An authored `default:` or `example:` is held to the same rule as an
1411
+ # `in:` member: the exported schema prints it to nine digits, so a value
1412
+ # finer than that was published as a different instant than the action
1413
+ # got for an omitted field, and a client sending the published default
1414
+ # was not sending the default. Unlike an `in:` member, a String is NOT
1415
+ # exempt here: a default or example is stored as the cast Time and
1416
+ # exported from that, never as written, so "...00.1234567891Z" printed
1417
+ # as .123456789Z. `outcome` is the cast's [status, value].
1418
+ def refuse_finer_than_nanosecond(type, outcome)
1419
+ return outcome unless outcome.first == :ok && type == :datetime && !whole_nanoseconds?(outcome.last)
1420
+
1421
+ [:error, "finer than a nanosecond, so the export publishes a different instant; round it, e.g. with .round(9)"]
1422
+ end
1423
+
1249
1424
  # A request's fraction of a second is a finite decimal, and the export
1250
1425
  # prints a member to at most nine digits, so a member that is not a whole
1251
1426
  # number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
@@ -1267,7 +1442,7 @@ module Permittable
1267
1442
  # class-load error a reason where a request gets `invalid_type`.
1268
1443
  def instant_as_date(member)
1269
1444
  date = date_of_instant(member)
1270
- date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
1445
+ date ? [:ok, date] : [:error, "not midnight UTC of an existing day, so it never equals a date"]
1271
1446
  end
1272
1447
 
1273
1448
  # What an exported enum lists for one member: the cast value, re-encoded
@@ -1297,6 +1472,7 @@ module Permittable
1297
1472
  class ContractBuilder
1298
1473
  SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
1299
1474
  nullable].freeze
1475
+ FILE_REFUSED_OPTS = %i[in format length default normalize example].freeze
1300
1476
  NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
1301
1477
  JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
1302
1478
  nullable].freeze
@@ -1348,6 +1524,7 @@ module Permittable
1348
1524
  name = field_name!(name)
1349
1525
  assert_opts!(name, opts, ARRAY_OPTS)
1350
1526
  required = opts.delete(:required) ? true : false
1527
+ refuse_file_opts!(name, opts.keys & %i[default example], FILE_TYPE) if opts[:of]&.to_sym == FILE_TYPE
1351
1528
 
1352
1529
  field = { name: name, kind: :array, required: required, **opts }
1353
1530
  if field[:required] && field.key?(:default)
@@ -1553,6 +1730,7 @@ module Permittable
1553
1730
 
1554
1731
  def validate_scalar_opts!(field)
1555
1732
  name = field[:name]
1733
+ refuse_file_opts!(name, field.keys & FILE_REFUSED_OPTS, FILE_TYPE) if field[:type] == FILE_TYPE
1556
1734
  if field[:required] && field.key?(:default)
1557
1735
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
1558
1736
  end
@@ -1728,6 +1906,17 @@ module Permittable
1728
1906
 
1729
1907
  # format / length / normalize reason about characters; on any other
1730
1908
  # type they would silently apply to a cast non-String and mislead.
1909
+ # An upload is passed through as it came, so what shapes a String or
1910
+ # picks a value to stand in for one has nothing to act on. `validate:`
1911
+ # is where a size or content-type check belongs.
1912
+ def refuse_file_opts!(name, opts, type)
1913
+ opt = opts.first
1914
+ return unless opt
1915
+
1916
+ raise ArgumentError, "#{LABEL}: :#{opt} is not supported on :#{type} fields (field :#{name} is :#{type}); " \
1917
+ "use validate: for a size or content-type check"
1918
+ end
1919
+
1731
1920
  def validate_string_only_opts!(field)
1732
1921
  return if field[:type] == :string
1733
1922
 
@@ -1885,7 +2074,17 @@ module Permittable
1885
2074
  # proc cannot rewrite the host's own literal.
1886
2075
  authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1887
2076
  value = Coercion.apply_normalize(field[:normalize], authored)
1888
- status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
2077
+ # The walker's absence rule: on a nullable field a value that reads as
2078
+ # absent ("" or one normalizing to "") is the explicit nil a request
2079
+ # sending it gets, with nothing to cast or check — as an array's
2080
+ # nullable sub-field already reads it (see AuthoredValues).
2081
+ status, result = if field[:nullable] && Coercion.absent_value?(value)
2082
+ [:ok, nil]
2083
+ else
2084
+ Coercion.refuse_finer_than_nanosecond(
2085
+ field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
2086
+ )
2087
+ end
1889
2088
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1890
2089
 
1891
2090
  field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
@@ -2106,6 +2305,8 @@ module Permittable
2106
2305
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
2107
2306
  model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
2108
2307
  finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
2308
+ permittable_register_hooks
2309
+ permittable_ensure_runnable!(rule)
2109
2310
  self.permittable_contracts = permittable_contracts + [rule]
2110
2311
  end
2111
2312
 
@@ -2119,6 +2320,69 @@ module Permittable
2119
2320
 
2120
2321
  private
2121
2322
 
2323
+ # Registers the rescue_from handler and the before_action the gem relies
2324
+ # on, each once per class, as soon as the class can take them. They used
2325
+ # to be registered at the include alone, so a bare ActionController::Metal
2326
+ # controller that included Permittable BEFORE AbstractController::Callbacks
2327
+ # and ActionController::Rescue never got them: an `enforce: true` rule let
2328
+ # bad requests through, and a violation escaped as a 500. Called at the
2329
+ # include and again at every permit_params, which is where such a class
2330
+ # has by now been given its modules. The callback is still registered
2331
+ # ahead of any declared after this point (see the README's "Per-request
2332
+ # locales").
2333
+ def permittable_register_hooks
2334
+ if !permittable_rescue_registered && respond_to?(:rescue_from)
2335
+ rescue_from InvalidParameters, with: :render_invalid_parameters
2336
+ self.permittable_rescue_registered = true
2337
+ end
2338
+ return if permittable_callback_registered || !respond_to?(:before_action)
2339
+
2340
+ # Registered at the include, and not at the first `permit_params`, when
2341
+ # the host has callbacks by then: the README documents
2342
+ # `skip_before_action :enforce_params_contract`, and Rails raises when it
2343
+ # is asked to skip a callback that is not defined yet. The cost is the
2344
+ # order: this check runs before any callback declared after the include,
2345
+ # so a locale switch that must cover it is a `prepend_around_action`.
2346
+ before_action :enforce_params_contract
2347
+ self.permittable_callback_registered = true
2348
+ end
2349
+
2350
+ # A bare ActionController::Metal controller has neither callbacks nor
2351
+ # rescue_from until the app includes them, and a rule that needs one of
2352
+ # them would otherwise load and then not run: raise at class load, naming
2353
+ # the module. Other hosts (a plain class, a duck) are left as they are.
2354
+ def permittable_ensure_runnable!(rule)
2355
+ return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
2356
+
2357
+ monitored = rule[:mode] == :monitor
2358
+ missing = []
2359
+ missing << "AbstractController::Callbacks" if (rule[:enforce] || monitored) && !respond_to?(:before_action)
2360
+ missing << "ActionController::Rescue" if rule[:enforce] && !monitored && !respond_to?(:rescue_from)
2361
+ if missing.empty?
2362
+ return unless rule[:enforce] && !monitored && permittable_rescue_inside_callbacks?
2363
+
2364
+ raise ArgumentError,
2365
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot rescue " \
2366
+ "this enforce: true rule: include ActionController::Rescue after AbstractController::Callbacks, " \
2367
+ "as ActionController::Base does. Included before it, Rescue wraps only the action, so the " \
2368
+ "before_action's InvalidParameters escapes as a 500"
2369
+ end
2370
+
2371
+ raise ArgumentError,
2372
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot run " \
2373
+ "this #{monitored ? 'monitor-mode' : 'enforce: true'} rule: include #{missing.join(' and ')} " \
2374
+ "before permit_params (ActionController::Rescue after AbstractController::Callbacks, as ActionController::Base does)"
2375
+ end
2376
+
2377
+ # A module included later sits earlier in the ancestors and so runs
2378
+ # outside the others' process_action: Rescue is only around the
2379
+ # callbacks when it was included after them.
2380
+ def permittable_rescue_inside_callbacks?
2381
+ rescue_at = ancestors.index(::ActionController::Rescue)
2382
+ callbacks_at = ancestors.index(::AbstractController::Callbacks)
2383
+ !rescue_at.nil? && !callbacks_at.nil? && rescue_at > callbacks_at
2384
+ end
2385
+
2122
2386
  def resolve_permit_model(model)
2123
2387
  case model
2124
2388
  when nil, false then nil
@@ -2146,8 +2410,12 @@ module Permittable
2146
2410
  # The drift guard. Nested/array fields are implicitly virtual — only
2147
2411
  # scalar fields, and the opaque `:json` field standing in for a
2148
2412
  # json/jsonb column, map one-to-one onto columns.
2413
+ #
2414
+ # A :file field is an upload, which maps to an attachment (ActiveStorage's
2415
+ # has_one_attached, Shrine) rather than a column, so it is never demanded
2416
+ # a column, as if it were virtual: true.
2149
2417
  def guard_contract_columns!(model_class, fields)
2150
- checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
2418
+ checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] && f[:type] != FILE_TYPE }
2151
2419
  return if checked.empty?
2152
2420
 
2153
2421
  types = checked.to_h { |f| [f[:name], f[:type]] }
@@ -2758,7 +3026,7 @@ module Permittable
2758
3026
  case field[:kind]
2759
3027
  when :scalar
2760
3028
  # Already normalized by permittable_normalized, before the absence rule.
2761
- permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
3029
+ permittable_check_whole(field, permittable_check_scalar(field, value), full, result, violations: violations)
2762
3030
  when :json
2763
3031
  permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
2764
3032
  when :nested
@@ -2883,7 +3151,18 @@ module Permittable
2883
3151
  field[:transform] ? field[:transform].call(value) : value
2884
3152
  end
2885
3153
 
2886
- # A second seam, for the same reader: the array-level `validate:` to run.
3154
+ # More seams, for the same reader: how a scalar field's value, and an
3155
+ # array element, are cast. A request's are cast as they come; an authored
3156
+ # default: or example: is also held to the nine digits the export prints.
3157
+ def permittable_check_scalar(field, value)
3158
+ Coercion.check_scalar(field, value)
3159
+ end
3160
+
3161
+ def permittable_cast_element(field, element)
3162
+ Coercion.cast(field[:of], element)
3163
+ end
3164
+
3165
+ # A third seam, for the same reader: the array-level `validate:` to run.
2887
3166
  def permittable_array_validator(field)
2888
3167
  field[:validate]
2889
3168
  end
@@ -2898,7 +3177,7 @@ module Permittable
2898
3177
  path: path, unknown: unknown, top_level: false, violations: violations)
2899
3178
  end
2900
3179
 
2901
- status, out = Coercion.cast(field[:of], permittable_own(element))
3180
+ status, out = permittable_cast_element(field, permittable_own(element))
2902
3181
  return out if status == :ok
2903
3182
 
2904
3183
  violations << permittable_violation(field, path, out)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport