permittable 0.14.0 → 0.15.1
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 +31 -0
- data/README.md +10 -8
- 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/json_schema.rb +1 -1
- data/lib/permittable/open_api.rb +48 -15
- data/lib/permittable/rspec.rb +154 -23
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +156 -29
- metadata +2 -2
data/lib/permittable.rb
CHANGED
|
@@ -1041,9 +1041,13 @@ module Permittable
|
|
|
1041
1041
|
found = Date._parse(value)
|
|
1042
1042
|
return [:error, "invalid_type"] unless complete_date?(found)
|
|
1043
1043
|
return cast_date_instant(value, found) if names_an_instant?(found)
|
|
1044
|
-
|
|
1045
|
-
#
|
|
1046
|
-
|
|
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
|
|
1047
1051
|
|
|
1048
1052
|
# Built from the components rather than re-running Date.parse, which
|
|
1049
1053
|
# would parse the same string a second time — and Date.parse is the
|
|
@@ -1082,10 +1086,13 @@ module Permittable
|
|
|
1082
1086
|
# ruled out the ISO forms cast_datetime reads without one; :datetime's
|
|
1083
1087
|
# reading of it is then the same, with one parse fewer.
|
|
1084
1088
|
def cast_date_instant(value, found = nil)
|
|
1085
|
-
|
|
1086
|
-
|
|
1089
|
+
date_at_midnight(*(found ? cast_parsed_datetime(value, found) : cast_datetime(value)))
|
|
1090
|
+
end
|
|
1087
1091
|
|
|
1088
|
-
|
|
1092
|
+
# The Date of an instant read from a String when it is midnight UTC.
|
|
1093
|
+
def date_at_midnight(status, instant)
|
|
1094
|
+
date = date_of_instant(instant) if status == :ok
|
|
1095
|
+
date ? [:ok, date] : [:error, "invalid_type"]
|
|
1089
1096
|
end
|
|
1090
1097
|
|
|
1091
1098
|
# The date a Time or DateTime IS, or nil: its UTC date when it is
|
|
@@ -1167,7 +1174,9 @@ module Permittable
|
|
|
1167
1174
|
# parts, shared with cast_date_instant so a :date need not parse twice.
|
|
1168
1175
|
def cast_parsed_datetime(value, found)
|
|
1169
1176
|
return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
|
|
1170
|
-
|
|
1177
|
+
|
|
1178
|
+
dropped = dropped_tail(value, found)
|
|
1179
|
+
return dropped if dropped
|
|
1171
1180
|
|
|
1172
1181
|
[:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
|
|
1173
1182
|
rescue ArgumentError, RangeError
|
|
@@ -1236,23 +1245,126 @@ module Permittable
|
|
|
1236
1245
|
(zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
|
|
1237
1246
|
end
|
|
1238
1247
|
|
|
1239
|
-
#
|
|
1240
|
-
#
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
#
|
|
1244
|
-
#
|
|
1245
|
-
#
|
|
1246
|
-
#
|
|
1247
|
-
|
|
1248
|
-
|
|
1248
|
+
# An ISO date, then a "T": what follows is an ISO time, so a parse that
|
|
1249
|
+
# found no hour ("2026-09-05T10", "T1030", "T10Z") dropped it.
|
|
1250
|
+
ISO_DATE_T_START = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt]/
|
|
1251
|
+
|
|
1252
|
+
# Trailing words, set apart by a space: a zone name ("EST", "Eastern
|
|
1253
|
+
# Standard Time", "W. Europe Standard Time") or a day or era word. The
|
|
1254
|
+
# first word may be one letter, which is a military zone or the ISO "T"
|
|
1255
|
+
# and so no zone here, but still stands where an hour was lost.
|
|
1256
|
+
ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)?[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
|
|
1257
|
+
|
|
1258
|
+
# An era word at the start of such a tail: the parser reads it with the
|
|
1259
|
+
# date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
|
|
1260
|
+
ERA_WORD = /\A(?:a\.?\s?d\.?|c\.?\s?e\.?|b\.?\s?c\.?(?:\s?e\.?)?)(?:\s+|\z)/i
|
|
1249
1261
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1262
|
+
# What ends a date the way git, `date` and Time#to_s write it, with the
|
|
1263
|
+
# offset after the year: "Sat Sep 5 10:00:00 2026", "Sep 5 10:00 2026".
|
|
1264
|
+
TIME_THEN_YEAR = /\d:\d{2}(?::\d{2}(?:[.,]\d+)?)?\s+\d{4}\s*\z/
|
|
1265
|
+
private_constant :ZONE_NAME_TAIL, :ERA_WORD, :TIME_THEN_YEAR
|
|
1252
1266
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1267
|
+
# What Date._parse did with the END of a date or date-time String when it
|
|
1268
|
+
# dropped or misplaced it, as nil when it did neither, or the result
|
|
1269
|
+
# ([:ok, instant] or [:error, "invalid_type"]) the String must be given:
|
|
1270
|
+
#
|
|
1271
|
+
# - an hour after an ISO "T" it did not find ("2026-09-05T10");
|
|
1272
|
+
# - a trailing offset taken for a time of day ("Sep 5 2026 -05:00",
|
|
1273
|
+
# "2026-09-05 10 -05:00", "2026-09-05 UTC-05:00") or dropped without a
|
|
1274
|
+
# trace ("5/9/2026 -0500", "12:00 2026-09-05 -05:00"), outside the ISO
|
|
1275
|
+
# form midnight_at_offset reads. An offset after "10:00:00 2026" is the
|
|
1276
|
+
# exception: that is git's and ctime's format, which puts it after the
|
|
1277
|
+
# year, so it is read, like a zone name;
|
|
1278
|
+
# - a trailing zone NAME, which it reads only next to a time of day:
|
|
1279
|
+
# "2026-09-05 EST" is midnight at -05:00 and "Sat Sep 5 10:00:00 2026
|
|
1280
|
+
# EST" 10:00 there. A name it does not know is refused like any other
|
|
1281
|
+
# zone (honoured_zone?), and so is ANY other word after a complete date
|
|
1282
|
+
# ("2026-09-05 noon"), since all it can be is a zone. A day name and an
|
|
1283
|
+
# era word are what the parser reads as part of the date, not a zone.
|
|
1284
|
+
#
|
|
1285
|
+
# The tail is the culprit when the parser found no zone and the String
|
|
1286
|
+
# without it names the same date (and time). Only a String that ends in a
|
|
1287
|
+
# signed number or a word is parsed again, so a plain "2026-09-05" is not.
|
|
1288
|
+
def dropped_tail(value, found)
|
|
1289
|
+
return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
|
|
1290
|
+
return if found.key?(:zone)
|
|
1291
|
+
|
|
1292
|
+
if (tail = SIGNED_TAIL.match(value))
|
|
1293
|
+
# An offset glued to a zone name with no time ("2026-09-05 GMT+5") is
|
|
1294
|
+
# read below as midnight at that offset; one glued to a date is a
|
|
1295
|
+
# part of it ("2026-09-05" ends in "-05").
|
|
1296
|
+
named = !tail[1] && !found.key?(:hour) && /\s[[:alpha:]]+\z/.match(tail.pre_match)
|
|
1297
|
+
return unless tail[1] || found.key?(:hour) || named
|
|
1298
|
+
|
|
1299
|
+
rest = Date._parse(tail.pre_match)
|
|
1300
|
+
return unless same_date?(rest, found)
|
|
1301
|
+
return glued_zone(named, tail[0].strip, found) if named
|
|
1302
|
+
|
|
1303
|
+
git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
|
|
1304
|
+
git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
|
|
1305
|
+
elsif (tail = ZONE_NAME_TAIL.match(value))
|
|
1306
|
+
pre = tail.pre_match
|
|
1307
|
+
text = tail[0]
|
|
1308
|
+
if (era = ERA_WORD.match(text))
|
|
1309
|
+
pre += era[0]
|
|
1310
|
+
text = era.post_match
|
|
1311
|
+
end
|
|
1312
|
+
# The era is part of the date, so the hour a lost one hid is looked
|
|
1313
|
+
# for before it ("2026-09-05 10 A.D.", "2026-09-05 10 AD EST").
|
|
1314
|
+
head = era ? Date._parse(tail.pre_match) : nil
|
|
1315
|
+
return same_time?(head, found) ? nil : [:error, "invalid_type"] if text.empty?
|
|
1316
|
+
|
|
1317
|
+
rest = Date._parse(pre)
|
|
1318
|
+
return unless same_date?(rest, found)
|
|
1319
|
+
|
|
1320
|
+
zone = Date._parse("00:00 #{text}")
|
|
1321
|
+
return unless zone.key?(:zone) # a day name
|
|
1322
|
+
# The hour the tail made the parser lose ("2026-09-05 10 EST").
|
|
1323
|
+
return [:error, "invalid_type"] unless same_time?(head || rest, found)
|
|
1324
|
+
# A single letter is a military zone, which is no zone here: alone it
|
|
1325
|
+
# is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
|
|
1326
|
+
# name Ruby did not know ("W. Europe") is dropped, so it is refused
|
|
1327
|
+
# like any other word it does not know.
|
|
1328
|
+
return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
|
|
1329
|
+
|
|
1330
|
+
instant_in_zone(pre, text)
|
|
1331
|
+
end
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
# A signed offset after a zone name ("GMT+5", "UTC+0530") is that zone to
|
|
1335
|
+
# Ruby when it reads it next to a time, so the String is midnight at it.
|
|
1336
|
+
# A name it does not read whole ("EST+5") or honour ("UTC+99") is refused.
|
|
1337
|
+
# What comes before the zone is held to the rules of any date String: no
|
|
1338
|
+
# hour a lost tail hid ("2026-09-05 10 AD GMT+5"), no second zone, and the
|
|
1339
|
+
# offset is hours ("+5", "+05") or hhmm with minutes a minute ("+0530").
|
|
1340
|
+
def glued_zone(word, offset, found)
|
|
1341
|
+
zone = "#{word[0].strip}#{offset}"
|
|
1342
|
+
pre = Date._parse(word.pre_match)
|
|
1343
|
+
return [:error, "invalid_type"] unless offset.match?(GLUED_OFFSET) && Date._parse("00:00 #{zone}")[:zone] == zone
|
|
1344
|
+
return [:error, "invalid_type"] unless same_time?(pre, found) && !pre.key?(:zone) && dropped_tail(word.pre_match, pre).nil?
|
|
1345
|
+
|
|
1346
|
+
instant_in_zone(word.pre_match, zone)
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# The wall clock `date` names, as it is read in the zone `text` names:
|
|
1350
|
+
# [:ok, instant], or an error when Ruby cannot honour the zone.
|
|
1351
|
+
def instant_in_zone(date, text)
|
|
1352
|
+
zone = Date._parse("00:00 #{text}")
|
|
1353
|
+
return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
|
|
1354
|
+
|
|
1355
|
+
[:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def same_time?(rest, found)
|
|
1359
|
+
TIME_PARTS.all? { |part| rest[part] == found[part] }
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
TIME_PARTS = %i[hour min sec sec_fraction].freeze
|
|
1363
|
+
GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
|
|
1364
|
+
private_constant :TIME_PARTS, :GLUED_OFFSET
|
|
1365
|
+
|
|
1366
|
+
def same_date?(rest, found)
|
|
1367
|
+
complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
|
|
1256
1368
|
end
|
|
1257
1369
|
|
|
1258
1370
|
# Presets only make sense on String input; a non-String value (JSON
|
|
@@ -1446,13 +1558,17 @@ module Permittable
|
|
|
1446
1558
|
end
|
|
1447
1559
|
|
|
1448
1560
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
1449
|
-
# as JSON — except a :
|
|
1450
|
-
#
|
|
1451
|
-
#
|
|
1452
|
-
#
|
|
1453
|
-
#
|
|
1561
|
+
# as JSON — except a :datetime member authored as a String, which is
|
|
1562
|
+
# published AS WRITTEN. Re-encoding a cast Time prints whole seconds, so
|
|
1563
|
+
# "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value the
|
|
1564
|
+
# server refuses. The authored String went through the very cast a request
|
|
1565
|
+
# does, so the server accepts it by construction. A :date member is
|
|
1566
|
+
# re-encoded: a Date has no digits to lose, and the String as written
|
|
1567
|
+
# ("Sep 6, 2026", "2026-09-05T00:00:00Z") is a date the server reads but
|
|
1568
|
+
# not one `format: date` accepts, so it is published as the YYYY-MM-DD
|
|
1569
|
+
# the server compares against.
|
|
1454
1570
|
def published_in_member(type, member, value)
|
|
1455
|
-
member.is_a?(String) &&
|
|
1571
|
+
member.is_a?(String) && type == :datetime ? member : value
|
|
1456
1572
|
end
|
|
1457
1573
|
|
|
1458
1574
|
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
@@ -1799,7 +1915,7 @@ module Permittable
|
|
|
1799
1915
|
end
|
|
1800
1916
|
|
|
1801
1917
|
# `published` is stored only where it differs from the cast members (a
|
|
1802
|
-
# String-authored :
|
|
1918
|
+
# String-authored :datetime member), so it is read as an override.
|
|
1803
1919
|
def cast_in_members!(field, members)
|
|
1804
1920
|
status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
|
|
1805
1921
|
unless status == :ok
|
|
@@ -1896,6 +2012,12 @@ module Permittable
|
|
|
1896
2012
|
def validate_json_authored_value!(field, opt)
|
|
1897
2013
|
return unless field.key?(opt)
|
|
1898
2014
|
return if authored_nil!(field, opt)
|
|
2015
|
+
|
|
2016
|
+
# A request sending "" for a nullable :json field is the explicit null
|
|
2017
|
+
# the walker reads it as, so a blank authored value is that nil, as a
|
|
2018
|
+
# nullable scalar's is (see validate_authored_value!).
|
|
2019
|
+
return field[opt] = nil if field[:nullable] && Coercion.absent_value?(field[opt])
|
|
2020
|
+
|
|
1899
2021
|
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
1900
2022
|
|
|
1901
2023
|
status, read = AuthoredValues.read_json(field, field[opt])
|
|
@@ -2351,6 +2473,11 @@ module Permittable
|
|
|
2351
2473
|
# rescue_from until the app includes them, and a rule that needs one of
|
|
2352
2474
|
# them would otherwise load and then not run: raise at class load, naming
|
|
2353
2475
|
# the module. Other hosts (a plain class, a duck) are left as they are.
|
|
2476
|
+
# Only the rule's OWN declared mode is read, never Permittable.mode: that
|
|
2477
|
+
# is usually set by an initializer, and an app must not stop booting over
|
|
2478
|
+
# it. The effective mode can only add a requirement, so an action it makes
|
|
2479
|
+
# monitored without a before_action is left to Audit.inert?, which reports
|
|
2480
|
+
# it as not covered.
|
|
2354
2481
|
def permittable_ensure_runnable!(rule)
|
|
2355
2482
|
return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
|
|
2356
2483
|
|
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.1
|
|
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-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|