permittable 0.13.1 → 0.15.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,21 @@ 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
+
1045
+ # No time found, but a zone or offset may have been dropped
1046
+ # ("5/9/2026 -0500", "2026-09-05 EST") or an hour with it
1047
+ # ("2026-09-05T10"), which :datetime refuses or reads the same way.
1048
+ if !plain && (dropped = dropped_tail(value, found))
1049
+ return date_at_midnight(*dropped)
1050
+ end
1023
1051
 
1024
1052
  # Built from the components rather than re-running Date.parse, which
1025
1053
  # would parse the same string a second time — and Date.parse is the
@@ -1038,6 +1066,36 @@ module Permittable
1038
1066
  found.key?(:year) && found.key?(:mon) && found.key?(:mday)
1039
1067
  end
1040
1068
 
1069
+ # A time of day or a zone, as opposed to a bare calendar date.
1070
+ def names_an_instant?(found)
1071
+ %i[hour min sec sec_fraction zone offset].any? { |part| found.key?(part) }
1072
+ end
1073
+
1074
+ # A String that carries a time or a zone is the instant it names, read
1075
+ # exactly as :datetime reads it (a zoneless one as UTC, an impossible
1076
+ # time or an offset Ruby cannot honour refused) and then as a Time is
1077
+ # read: a date only at midnight UTC. Keeping just the calendar day
1078
+ # accepted "T25:00:00Z" and "+24:00", and turned "T23:30:00-05:00",
1079
+ # which is the next day in UTC, into the day before.
1080
+ #
1081
+ # The Date is built from the components, like a date-only String's,
1082
+ # because Time#to_date reads the proleptic Gregorian day in the Julian
1083
+ # calendar before 1582 ("1500-01-01T00:00:00Z" became 1499-12-23).
1084
+ #
1085
+ # `found` is the String's Date._parse, when the caller has it and has
1086
+ # ruled out the ISO forms cast_datetime reads without one; :datetime's
1087
+ # reading of it is then the same, with one parse fewer.
1088
+ def cast_date_instant(value, found = nil)
1089
+ date_at_midnight(*(found ? cast_parsed_datetime(value, found) : cast_datetime(value)))
1090
+ end
1091
+
1092
+ # The Date of an instant read from a String when it is midnight UTC.
1093
+ def date_at_midnight(status, instant)
1094
+ return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1095
+
1096
+ [:ok, Date.new(instant.year, instant.month, instant.day)]
1097
+ end
1098
+
1041
1099
  # The date a Time or DateTime IS, or nil: its UTC date when it is
1042
1100
  # exactly midnight UTC. That is the instant ActiveSupport compares a
1043
1101
  # Date as, and the one :datetime reads a Date as, so the two types map
@@ -1102,16 +1160,30 @@ module Permittable
1102
1160
  # converts by day number, so "1500-01-01" became 1500-01-10 — unless
1103
1161
  # the host had loaded ActiveSupport's DateTime#to_time, which
1104
1162
  # converts by component. Gregorian, both agree.
1105
- found = Date._parse(value)
1106
- return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1163
+ if (iso = ISO_DATE_AT_OFFSET.match(value)) then return midnight_at_offset(*iso.captures)
1164
+ elsif ISO_DATE_T.match?(value) then return [:error, "invalid_type"]
1165
+ end
1107
1166
 
1108
- [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1167
+ cast_parsed_datetime(value, Date._parse(value))
1109
1168
  else [:error, "invalid_type"]
1110
1169
  end
1111
1170
  rescue ArgumentError, RangeError
1112
1171
  [:error, "invalid_type"]
1113
1172
  end
1114
1173
 
1174
+ # cast_datetime's reading of a String once Date._parse has `found` its
1175
+ # parts, shared with cast_date_instant so a :date need not parse twice.
1176
+ def cast_parsed_datetime(value, found)
1177
+ return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1178
+
1179
+ dropped = dropped_tail(value, found)
1180
+ return dropped if dropped
1181
+
1182
+ [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1183
+ rescue ArgumentError, RangeError
1184
+ [:error, "invalid_type"]
1185
+ end
1186
+
1115
1187
  # A zone the parser could not turn into an offset — an offset of a day
1116
1188
  # or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
1117
1189
  # know ("XYZ") — is reported as a zone with no offset, or with one
@@ -1125,6 +1197,153 @@ module Permittable
1125
1197
  !offset.nil? && offset.abs < 86_400
1126
1198
  end
1127
1199
 
1200
+ # An ISO date followed by an offset or Z and no time of day
1201
+ # ("2026-09-05-05:00", "2026-09-05 +05:00", "20260905Z"): XSD xs:date and
1202
+ # Java's ISO_DATE, for midnight at that offset. Date._parse does not read
1203
+ # it so — it takes "-05:00" for the TIME 05:00 and drops the sign, or
1204
+ # drops "-0500" altogether — so the instant is built from the captures.
1205
+ # Only the whole String counts, so a plain "2026-09-05" (which ends in
1206
+ # "-05") never matches, and costs nothing more than the match.
1207
+ ISO_DATE_AT_OFFSET = /\A\s*(\d{4})(-?)(\d{2})\2(\d{2})\s*(Z|z|[+-]\d{2}(?::?\d{2})?)\s*\z/
1208
+
1209
+ # An ISO date followed by a "T" and no time ("2026-09-05T",
1210
+ # "20260905T-0500"). Ruby reads a lone letter as a military zone, T as
1211
+ # -07:00, or drops it with whatever follows, so it is refused rather
1212
+ # than read as some instant the client did not write.
1213
+ ISO_DATE_T = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt](?!\s*\d)/
1214
+
1215
+ # An ISO date and nothing else, which none of the above can match.
1216
+ PLAIN_ISO_DATE = /\A\s*\d{4}-?\d{2}-?\d{2}\s*\z/
1217
+
1218
+ # A signed number at the end of a String, and whether a space sets it
1219
+ # apart from what comes before ("5/9/2026 -0500" but not "2026-09-05").
1220
+ SIGNED_TAIL = /(\s)?[+-]\d+(?::\d+)*\s*\z/
1221
+ private_constant :ISO_DATE_AT_OFFSET, :ISO_DATE_T, :PLAIN_ISO_DATE, :SIGNED_TAIL
1222
+
1223
+ # The captures of ISO_DATE_AT_OFFSET as the instant they name, held to
1224
+ # the rule any other offset is (honoured_zone?): "+24:00" and "-99:00"
1225
+ # are refused, and so are minutes past 59 ("+05:99"), which the parser
1226
+ # reports with no offset. Date.new checks the day ("2026-02-30Z"), which
1227
+ # Time.utc would roll over into March.
1228
+ def midnight_at_offset(year, _dash, mon, mday, zone)
1229
+ year, mon, mday = [year, mon, mday].map { |part| Integer(part, 10) }
1230
+ offset = zone_offset(zone)
1231
+ return [:error, "invalid_type"] unless honoured_zone?(zone: zone, offset: offset)
1232
+
1233
+ Date.new(year, mon, mday, Date::GREGORIAN)
1234
+ [:ok, Time.utc(year, mon, mday) - offset]
1235
+ end
1236
+
1237
+ # "Z", "+05", "-0530" or "+05:30" in seconds east of UTC, or nil when
1238
+ # the minutes are not minutes.
1239
+ def zone_offset(zone)
1240
+ return 0 if zone.casecmp?("z")
1241
+
1242
+ hours = Integer(zone[1, 2], 10)
1243
+ minutes = Integer(zone[3..].delete(":").rjust(1, "0"), 10)
1244
+ return if minutes > 59
1245
+
1246
+ (zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
1247
+ end
1248
+
1249
+ # An ISO date, then a "T": what follows is an ISO time, so a parse that
1250
+ # found no hour ("2026-09-05T10", "T1030", "T10Z") dropped it.
1251
+ ISO_DATE_T_START = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt]/
1252
+
1253
+ # Trailing words, set apart by a space: a zone name ("EST", "Eastern
1254
+ # Standard Time", "W. Europe Standard Time") or a day or era word. The
1255
+ # first word has two letters or more, or one and a ".", as a lone letter
1256
+ # is a military zone or the ISO "T".
1257
+ ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1258
+
1259
+ # An era word at the start of such a tail: the parser reads it with the
1260
+ # date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
1261
+ ERA_WORD = /\A(?:a\.?\s?d\.?|c\.?\s?e\.?|b\.?\s?c\.?(?:\s?e\.?)?)(?:\s+|\z)/i
1262
+
1263
+ # What ends a date the way git, `date` and Time#to_s write it, with the
1264
+ # offset after the year: "Sat Sep 5 10:00:00 2026", "Sep 5 10:00 2026".
1265
+ TIME_THEN_YEAR = /\d:\d{2}(?::\d{2}(?:[.,]\d+)?)?\s+\d{4}\s*\z/
1266
+ private_constant :ZONE_NAME_TAIL, :ERA_WORD, :TIME_THEN_YEAR
1267
+
1268
+ # What Date._parse did with the END of a date or date-time String when it
1269
+ # dropped or misplaced it, as nil when it did neither, or the result
1270
+ # ([:ok, instant] or [:error, "invalid_type"]) the String must be given:
1271
+ #
1272
+ # - an hour after an ISO "T" it did not find ("2026-09-05T10");
1273
+ # - a trailing offset taken for a time of day ("Sep 5 2026 -05:00",
1274
+ # "2026-09-05 10 -05:00", "2026-09-05 UTC-05:00") or dropped without a
1275
+ # trace ("5/9/2026 -0500", "12:00 2026-09-05 -05:00"), outside the ISO
1276
+ # form midnight_at_offset reads. An offset after "10:00:00 2026" is the
1277
+ # exception: that is git's and ctime's format, which puts it after the
1278
+ # year, so it is read, like a zone name;
1279
+ # - a trailing zone NAME, which it reads only next to a time of day:
1280
+ # "2026-09-05 EST" is midnight at -05:00 and "Sat Sep 5 10:00:00 2026
1281
+ # EST" 10:00 there. A name it does not know is refused like any other
1282
+ # zone (honoured_zone?), and so is ANY other word after a complete date
1283
+ # ("2026-09-05 noon"), since all it can be is a zone. A day name and an
1284
+ # era word are what the parser reads as part of the date, not a zone.
1285
+ #
1286
+ # The tail is the culprit when the parser found no zone and the String
1287
+ # without it names the same date (and time). Only a String that ends in a
1288
+ # signed number or a word is parsed again, so a plain "2026-09-05" is not.
1289
+ def dropped_tail(value, found)
1290
+ return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
1291
+ return if found.key?(:zone)
1292
+
1293
+ if (tail = SIGNED_TAIL.match(value))
1294
+ return unless tail[1] || found.key?(:hour)
1295
+
1296
+ rest = Date._parse(tail.pre_match)
1297
+ return unless same_date?(rest, found)
1298
+
1299
+ git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
1300
+ git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
1301
+ elsif (tail = ZONE_NAME_TAIL.match(value))
1302
+ pre = tail.pre_match
1303
+ text = tail[0]
1304
+ if (era = ERA_WORD.match(text))
1305
+ pre += era[0]
1306
+ text = era.post_match
1307
+ end
1308
+ return if text.empty?
1309
+
1310
+ rest = Date._parse(pre)
1311
+ return unless same_date?(rest, found)
1312
+
1313
+ zone = Date._parse("00:00 #{text}")
1314
+ return unless zone.key?(:zone) # a day name
1315
+ # A single letter is a military zone, which is no zone here: alone it
1316
+ # is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
1317
+ # name Ruby did not know ("W. Europe") is dropped, so it is refused
1318
+ # like any other word it does not know.
1319
+ return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
1320
+ # The hour the tail made the parser lose ("2026-09-05 10 EST").
1321
+ return [:error, "invalid_type"] unless same_time?(rest, found)
1322
+
1323
+ instant_in_zone(pre, text)
1324
+ end
1325
+ end
1326
+
1327
+ # The wall clock `date` names, as it is read in the zone `text` names:
1328
+ # [:ok, instant], or an error when Ruby cannot honour the zone.
1329
+ def instant_in_zone(date, text)
1330
+ zone = Date._parse("00:00 #{text}")
1331
+ return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
1332
+
1333
+ [:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
1334
+ end
1335
+
1336
+ def same_time?(rest, found)
1337
+ TIME_PARTS.all? { |part| rest[part] == found[part] }
1338
+ end
1339
+
1340
+ TIME_PARTS = %i[hour min sec sec_fraction].freeze
1341
+ private_constant :TIME_PARTS
1342
+
1343
+ def same_date?(rest, found)
1344
+ complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
1345
+ end
1346
+
1128
1347
  # Presets only make sense on String input; a non-String value (JSON
1129
1348
  # numbers, booleans) skips normalization and goes straight to the cast.
1130
1349
  #
@@ -1316,13 +1535,17 @@ module Permittable
1316
1535
  end
1317
1536
 
1318
1537
  # What an exported enum lists for one member: the cast value, re-encoded
1319
- # as JSON — except a :date/:datetime member authored as a String, which
1320
- # is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
1321
- # so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
1322
- # the server refuses. The authored String went through the very cast a
1323
- # request does, so the server accepts it by construction.
1538
+ # as JSON — except a :datetime member authored as a String, which is
1539
+ # published AS WRITTEN. Re-encoding a cast Time prints whole seconds, so
1540
+ # "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value the
1541
+ # server refuses. The authored String went through the very cast a request
1542
+ # does, so the server accepts it by construction. A :date member is
1543
+ # re-encoded: a Date has no digits to lose, and the String as written
1544
+ # ("Sep 6, 2026", "2026-09-05T00:00:00Z") is a date the server reads but
1545
+ # not one `format: date` accepts, so it is published as the YYYY-MM-DD
1546
+ # the server compares against.
1324
1547
  def published_in_member(type, member, value)
1325
- member.is_a?(String) && %i[date datetime].include?(type) ? member : value
1548
+ member.is_a?(String) && type == :datetime ? member : value
1326
1549
  end
1327
1550
 
1328
1551
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
@@ -1342,6 +1565,7 @@ module Permittable
1342
1565
  class ContractBuilder
1343
1566
  SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
1344
1567
  nullable].freeze
1568
+ FILE_REFUSED_OPTS = %i[in format length default normalize example].freeze
1345
1569
  NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
1346
1570
  JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
1347
1571
  nullable].freeze
@@ -1393,6 +1617,7 @@ module Permittable
1393
1617
  name = field_name!(name)
1394
1618
  assert_opts!(name, opts, ARRAY_OPTS)
1395
1619
  required = opts.delete(:required) ? true : false
1620
+ refuse_file_opts!(name, opts.keys & %i[default example], FILE_TYPE) if opts[:of]&.to_sym == FILE_TYPE
1396
1621
 
1397
1622
  field = { name: name, kind: :array, required: required, **opts }
1398
1623
  if field[:required] && field.key?(:default)
@@ -1598,6 +1823,7 @@ module Permittable
1598
1823
 
1599
1824
  def validate_scalar_opts!(field)
1600
1825
  name = field[:name]
1826
+ refuse_file_opts!(name, field.keys & FILE_REFUSED_OPTS, FILE_TYPE) if field[:type] == FILE_TYPE
1601
1827
  if field[:required] && field.key?(:default)
1602
1828
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
1603
1829
  end
@@ -1666,7 +1892,7 @@ module Permittable
1666
1892
  end
1667
1893
 
1668
1894
  # `published` is stored only where it differs from the cast members (a
1669
- # String-authored :date/:datetime member), so it is read as an override.
1895
+ # String-authored :datetime member), so it is read as an override.
1670
1896
  def cast_in_members!(field, members)
1671
1897
  status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
1672
1898
  unless status == :ok
@@ -1763,6 +1989,12 @@ module Permittable
1763
1989
  def validate_json_authored_value!(field, opt)
1764
1990
  return unless field.key?(opt)
1765
1991
  return if authored_nil!(field, opt)
1992
+
1993
+ # A request sending "" for a nullable :json field is the explicit null
1994
+ # the walker reads it as, so a blank authored value is that nil, as a
1995
+ # nullable scalar's is (see validate_authored_value!).
1996
+ return field[opt] = nil if field[:nullable] && Coercion.absent_value?(field[opt])
1997
+
1766
1998
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
1767
1999
 
1768
2000
  status, read = AuthoredValues.read_json(field, field[opt])
@@ -1773,6 +2005,17 @@ module Permittable
1773
2005
 
1774
2006
  # format / length / normalize reason about characters; on any other
1775
2007
  # type they would silently apply to a cast non-String and mislead.
2008
+ # An upload is passed through as it came, so what shapes a String or
2009
+ # picks a value to stand in for one has nothing to act on. `validate:`
2010
+ # is where a size or content-type check belongs.
2011
+ def refuse_file_opts!(name, opts, type)
2012
+ opt = opts.first
2013
+ return unless opt
2014
+
2015
+ raise ArgumentError, "#{LABEL}: :#{opt} is not supported on :#{type} fields (field :#{name} is :#{type}); " \
2016
+ "use validate: for a size or content-type check"
2017
+ end
2018
+
1776
2019
  def validate_string_only_opts!(field)
1777
2020
  return if field[:type] == :string
1778
2021
 
@@ -1930,9 +2173,17 @@ module Permittable
1930
2173
  # proc cannot rewrite the host's own literal.
1931
2174
  authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1932
2175
  value = Coercion.apply_normalize(field[:normalize], authored)
1933
- status, result = Coercion.refuse_finer_than_nanosecond(
1934
- field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
1935
- )
2176
+ # The walker's absence rule: on a nullable field a value that reads as
2177
+ # absent ("" or one normalizing to "") is the explicit nil a request
2178
+ # sending it gets, with nothing to cast or check — as an array's
2179
+ # nullable sub-field already reads it (see AuthoredValues).
2180
+ status, result = if field[:nullable] && Coercion.absent_value?(value)
2181
+ [:ok, nil]
2182
+ else
2183
+ Coercion.refuse_finer_than_nanosecond(
2184
+ field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
2185
+ )
2186
+ end
1936
2187
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1937
2188
 
1938
2189
  field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
@@ -2153,6 +2404,8 @@ module Permittable
2153
2404
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
2154
2405
  model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
2155
2406
  finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
2407
+ permittable_register_hooks
2408
+ permittable_ensure_runnable!(rule)
2156
2409
  self.permittable_contracts = permittable_contracts + [rule]
2157
2410
  end
2158
2411
 
@@ -2166,6 +2419,74 @@ module Permittable
2166
2419
 
2167
2420
  private
2168
2421
 
2422
+ # Registers the rescue_from handler and the before_action the gem relies
2423
+ # on, each once per class, as soon as the class can take them. They used
2424
+ # to be registered at the include alone, so a bare ActionController::Metal
2425
+ # controller that included Permittable BEFORE AbstractController::Callbacks
2426
+ # and ActionController::Rescue never got them: an `enforce: true` rule let
2427
+ # bad requests through, and a violation escaped as a 500. Called at the
2428
+ # include and again at every permit_params, which is where such a class
2429
+ # has by now been given its modules. The callback is still registered
2430
+ # ahead of any declared after this point (see the README's "Per-request
2431
+ # locales").
2432
+ def permittable_register_hooks
2433
+ if !permittable_rescue_registered && respond_to?(:rescue_from)
2434
+ rescue_from InvalidParameters, with: :render_invalid_parameters
2435
+ self.permittable_rescue_registered = true
2436
+ end
2437
+ return if permittable_callback_registered || !respond_to?(:before_action)
2438
+
2439
+ # Registered at the include, and not at the first `permit_params`, when
2440
+ # the host has callbacks by then: the README documents
2441
+ # `skip_before_action :enforce_params_contract`, and Rails raises when it
2442
+ # is asked to skip a callback that is not defined yet. The cost is the
2443
+ # order: this check runs before any callback declared after the include,
2444
+ # so a locale switch that must cover it is a `prepend_around_action`.
2445
+ before_action :enforce_params_contract
2446
+ self.permittable_callback_registered = true
2447
+ end
2448
+
2449
+ # A bare ActionController::Metal controller has neither callbacks nor
2450
+ # rescue_from until the app includes them, and a rule that needs one of
2451
+ # them would otherwise load and then not run: raise at class load, naming
2452
+ # the module. Other hosts (a plain class, a duck) are left as they are.
2453
+ # Only the rule's OWN declared mode is read, never Permittable.mode: that
2454
+ # is usually set by an initializer, and an app must not stop booting over
2455
+ # it. The effective mode can only add a requirement, so an action it makes
2456
+ # monitored without a before_action is left to Audit.inert?, which reports
2457
+ # it as not covered.
2458
+ def permittable_ensure_runnable!(rule)
2459
+ return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
2460
+
2461
+ monitored = rule[:mode] == :monitor
2462
+ missing = []
2463
+ missing << "AbstractController::Callbacks" if (rule[:enforce] || monitored) && !respond_to?(:before_action)
2464
+ missing << "ActionController::Rescue" if rule[:enforce] && !monitored && !respond_to?(:rescue_from)
2465
+ if missing.empty?
2466
+ return unless rule[:enforce] && !monitored && permittable_rescue_inside_callbacks?
2467
+
2468
+ raise ArgumentError,
2469
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot rescue " \
2470
+ "this enforce: true rule: include ActionController::Rescue after AbstractController::Callbacks, " \
2471
+ "as ActionController::Base does. Included before it, Rescue wraps only the action, so the " \
2472
+ "before_action's InvalidParameters escapes as a 500"
2473
+ end
2474
+
2475
+ raise ArgumentError,
2476
+ "#{LABEL}: permit_params on #{name || inspect}, an ActionController::Metal controller, cannot run " \
2477
+ "this #{monitored ? 'monitor-mode' : 'enforce: true'} rule: include #{missing.join(' and ')} " \
2478
+ "before permit_params (ActionController::Rescue after AbstractController::Callbacks, as ActionController::Base does)"
2479
+ end
2480
+
2481
+ # A module included later sits earlier in the ancestors and so runs
2482
+ # outside the others' process_action: Rescue is only around the
2483
+ # callbacks when it was included after them.
2484
+ def permittable_rescue_inside_callbacks?
2485
+ rescue_at = ancestors.index(::ActionController::Rescue)
2486
+ callbacks_at = ancestors.index(::AbstractController::Callbacks)
2487
+ !rescue_at.nil? && !callbacks_at.nil? && rescue_at > callbacks_at
2488
+ end
2489
+
2169
2490
  def resolve_permit_model(model)
2170
2491
  case model
2171
2492
  when nil, false then nil
@@ -2193,8 +2514,12 @@ module Permittable
2193
2514
  # The drift guard. Nested/array fields are implicitly virtual — only
2194
2515
  # scalar fields, and the opaque `:json` field standing in for a
2195
2516
  # json/jsonb column, map one-to-one onto columns.
2517
+ #
2518
+ # A :file field is an upload, which maps to an attachment (ActiveStorage's
2519
+ # has_one_attached, Shrine) rather than a column, so it is never demanded
2520
+ # a column, as if it were virtual: true.
2196
2521
  def guard_contract_columns!(model_class, fields)
2197
- checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
2522
+ checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] && f[:type] != FILE_TYPE }
2198
2523
  return if checked.empty?
2199
2524
 
2200
2525
  types = checked.to_h { |f| [f[:name], f[:type]] }
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.1
4
+ version: 0.15.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-11-04 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport