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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +40 -11
- data/lib/permittable/authored_values.rb +37 -1
- data/lib/permittable/column_guard.rb +33 -0
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +8 -2
- data/lib/permittable/open_api.rb +248 -12
- data/lib/permittable/rspec.rb +52 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +301 -22
- metadata +2 -2
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
|
-
|
|
645
|
-
#
|
|
646
|
-
#
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1084
|
-
return [:error, "invalid_type"]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
#
|
|
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 =
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-11-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|