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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +34 -11
- data/lib/permittable/audit.rb +64 -7
- data/lib/permittable/authored_values.rb +23 -11
- data/lib/permittable/column_guard.rb +133 -12
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +21 -4
- data/lib/permittable/json_schema.rb +9 -3
- data/lib/permittable/open_api.rb +213 -11
- data/lib/permittable/rspec.rb +193 -12
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +349 -24
- 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,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
|
-
|
|
1106
|
-
return [:error, "invalid_type"]
|
|
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
|
-
|
|
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 :
|
|
1320
|
-
#
|
|
1321
|
-
#
|
|
1322
|
-
#
|
|
1323
|
-
#
|
|
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) &&
|
|
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 :
|
|
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
|
-
|
|
1934
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-11-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|