permittable 0.11.1 → 0.13.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 +50 -0
- data/README.md +38 -20
- data/lib/permittable/audit.rb +16 -0
- data/lib/permittable/authored_values.rb +17 -3
- data/lib/permittable/column_guard.rb +29 -0
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +43 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +40 -1
- data/lib/permittable/json_schema.rb +14 -2
- data/lib/permittable/open_api.rb +222 -25
- data/lib/permittable/rspec.rb +152 -29
- data/lib/permittable/tasks/audit.rake +3 -5
- data/lib/permittable/tasks/generate.rake +1 -4
- data/lib/permittable/tasks/openapi.rake +1 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +388 -68
- metadata +2 -2
data/lib/permittable.rb
CHANGED
|
@@ -132,10 +132,14 @@ require "permittable/filter_parameter_registry"
|
|
|
132
132
|
# (" " under :squish) cannot satisfy a required field by becoming "". An
|
|
133
133
|
# authored `default:`/`example:` is stored as the contract reads it —
|
|
134
134
|
# normalized and cast, so `default: "18"` on an :integer is 18, and an
|
|
135
|
-
# array's read by the request walker itself. The
|
|
136
|
-
# declaring `transform
|
|
135
|
+
# array's read by the request walker itself. The exceptions: a field
|
|
136
|
+
# declaring `transform:` stores its default exactly AS AUTHORED, never
|
|
137
137
|
# cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
|
|
138
|
-
# so author such a default already in the shape the action should receive
|
|
138
|
+
# so author such a default already in the shape the action should receive —
|
|
139
|
+
# and a `:json` field's is always stored as the walker reads a request's hash
|
|
140
|
+
# (indifferent access, String keys; `transform:` or not), except that a
|
|
141
|
+
# `:json` `example:` is stored as authored (in that same shape), whatever a
|
|
142
|
+
# mutating `validate:` did to the copy it was checked on.
|
|
139
143
|
# An `example:` is cast either way and never transformed: it documents what a
|
|
140
144
|
# client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
|
|
141
145
|
# copy, so no request can corrupt it for the next.
|
|
@@ -190,8 +194,10 @@ require "permittable/filter_parameter_registry"
|
|
|
190
194
|
# into an Array. Runs only on request-supplied values: absent fields stay
|
|
191
195
|
# absent, and a `default:` is handed out exactly AS AUTHORED — validated
|
|
192
196
|
# against the field's own contract at class load (as any default is), but
|
|
193
|
-
# neither cast nor transformed
|
|
194
|
-
#
|
|
197
|
+
# neither cast nor transformed (a `:json` field's is read as a request's
|
|
198
|
+
# hash is, indifferent with String keys, but no more than that) — so a
|
|
199
|
+
# request sending a field's default gets the transformed value, a
|
|
200
|
+
# request omitting it the untransformed one.
|
|
195
201
|
# Author a default already in the shape the action should receive:
|
|
196
202
|
# `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
|
|
197
203
|
# paths the Integer 25. A field with no `transform:` still gets its
|
|
@@ -248,6 +254,18 @@ module Permittable
|
|
|
248
254
|
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
249
255
|
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
250
256
|
MONITOR_DROPPED_KEYS = ROUTING_KEYS
|
|
257
|
+
# Rails' date_select, datetime_select and time_select submit ONE value as
|
|
258
|
+
# several keys, each named for its position: `starts_on(1i)` the year,
|
|
259
|
+
# `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and
|
|
260
|
+
# `(6i)` the second. Strong parameters passes them through for
|
|
261
|
+
# `permit(:starts_on)` and ActiveRecord assembles them. A contract read
|
|
262
|
+
# only `starts_on`, so such a form lost its date (silently, under the
|
|
263
|
+
# default `unknown: :ignore`) and a required one was always missing. A
|
|
264
|
+
# :date or :datetime field now reads its parts as its own value (see
|
|
265
|
+
# permittable_read_date_parts); this is how many parts each type has.
|
|
266
|
+
DATE_PARTS = { date: 3, datetime: 6 }.freeze
|
|
267
|
+
# One part, as a select sends it: an unsigned decimal integer.
|
|
268
|
+
DATE_PART_FORMAT = /\A\d+\z/
|
|
251
269
|
# The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
|
|
252
270
|
# rootless contract declaring the wrapper key as one of these reads the
|
|
253
271
|
# copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
|
|
@@ -690,17 +708,10 @@ module Permittable
|
|
|
690
708
|
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
691
709
|
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
692
710
|
# top-level key count, same reading as an array's element count) and
|
|
693
|
-
# nesting (`max_depth:`).
|
|
694
|
-
#
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
return [status, code] unless status == :ok
|
|
698
|
-
|
|
699
|
-
check_custom(field[:validate], value)
|
|
700
|
-
end
|
|
701
|
-
|
|
702
|
-
# The structural half of check_json, which the request walker runs on
|
|
703
|
-
# its own so that it can copy an accepted hash before app code sees it.
|
|
711
|
+
# nesting (`max_depth:`). The walker (permittable_check_json) runs them
|
|
712
|
+
# before `validate:`, so that it can copy an accepted hash before app
|
|
713
|
+
# code sees it, and an authored `default:`/`example:` goes through that
|
|
714
|
+
# walker too (see AuthoredValues).
|
|
704
715
|
def check_json_bounds(field, value)
|
|
705
716
|
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
706
717
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
@@ -831,7 +842,8 @@ module Permittable
|
|
|
831
842
|
# here (a plain :string field, not :decimal itself) must render the same
|
|
832
843
|
# way regardless of whether that patch happens to be loaded.
|
|
833
844
|
when BigDecimal then [:ok, value.to_s("F")]
|
|
834
|
-
when Numeric
|
|
845
|
+
when Numeric then foreign_numeric { [:ok, value.to_s] }
|
|
846
|
+
when true, false then [:ok, value.to_s]
|
|
835
847
|
else [:error, "invalid_type"]
|
|
836
848
|
end
|
|
837
849
|
end
|
|
@@ -863,7 +875,19 @@ module Permittable
|
|
|
863
875
|
|
|
864
876
|
def cast_float(value)
|
|
865
877
|
case value
|
|
866
|
-
|
|
878
|
+
# A Complex is not real? (a real number), so never a Float — even Complex(1, 0),
|
|
879
|
+
# which :integer and :decimal refuse too ("1+0i" is no spelling of either).
|
|
880
|
+
# Taking the ones whose to_f happens to work would make the rule "the
|
|
881
|
+
# imaginary part is an exact zero": Complex(1, 0.0).to_f raises RangeError
|
|
882
|
+
# just as Complex(1, 1) does, and a contract must not raise on input.
|
|
883
|
+
#
|
|
884
|
+
# A zero Numeric is a genuine zero; one that only BECAME zero in to_f
|
|
885
|
+
# (BigDecimal("1e-400"), Rational(1, 10**400)) is underflow, exactly as
|
|
886
|
+
# for the String of it, so a nonzero one passes its text for the check.
|
|
887
|
+
when Numeric
|
|
888
|
+
foreign_numeric do
|
|
889
|
+
value.real? ? finite_float(value.to_f, source: (value.to_s unless value.zero?)) : [:error, "invalid_type"]
|
|
890
|
+
end
|
|
867
891
|
when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
|
|
868
892
|
else [:error, "invalid_type"]
|
|
869
893
|
end
|
|
@@ -871,6 +895,18 @@ module Permittable
|
|
|
871
895
|
[:error, "invalid_type"]
|
|
872
896
|
end
|
|
873
897
|
|
|
898
|
+
# The Numeric branches call to_f, to_s and zero? on a value whose class
|
|
899
|
+
# the caller chose: Complex(1, 1).to_f raises RangeError, and a custom
|
|
900
|
+
# Numeric may raise anything. A value that cannot be converted is not a
|
|
901
|
+
# value of the type, so it is an invalid_type violation rather than an
|
|
902
|
+
# exception out of #call (a 500 where a 422 is due). Wrapped around the
|
|
903
|
+
# Numeric branches only; the other branches call nothing a caller wrote.
|
|
904
|
+
def foreign_numeric
|
|
905
|
+
yield
|
|
906
|
+
rescue StandardError
|
|
907
|
+
[:error, "invalid_type"]
|
|
908
|
+
end
|
|
909
|
+
|
|
874
910
|
# A Float that is not finite does not represent what was sent. "1e400"
|
|
875
911
|
# overflows to Infinity and "1e-400" underflows to zero — both silently,
|
|
876
912
|
# and both leaving a value no column can faithfully store.
|
|
@@ -879,7 +915,8 @@ module Permittable
|
|
|
879
915
|
# an ordinary 0.0: a zero result is rejected when the string it came from
|
|
880
916
|
# named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
|
|
881
917
|
# genuine zero whose exponent digits say nothing about the value — as are
|
|
882
|
-
# "0", "0.0" and "0.0000".
|
|
918
|
+
# "0", "0.0" and "0.0000". A Numeric is checked the same way, by its to_s
|
|
919
|
+
# (see cast_float).
|
|
883
920
|
def finite_float(result, source: nil)
|
|
884
921
|
return [:error, "invalid_type"] unless result.finite?
|
|
885
922
|
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
@@ -898,8 +935,8 @@ module Permittable
|
|
|
898
935
|
def cast_decimal(value)
|
|
899
936
|
case value
|
|
900
937
|
when Rational then rational_to_decimal(value)
|
|
901
|
-
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
902
|
-
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
938
|
+
when Numeric then foreign_numeric { finite_decimal(BigDecimal(value.to_s)) }
|
|
939
|
+
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value), source: value) : [:error, "invalid_type"]
|
|
903
940
|
else [:error, "invalid_type"]
|
|
904
941
|
end
|
|
905
942
|
rescue ArgumentError
|
|
@@ -920,13 +957,38 @@ module Permittable
|
|
|
920
957
|
finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
|
|
921
958
|
end
|
|
922
959
|
|
|
923
|
-
# BigDecimal has no exponent limit, so a :decimal cannot overflow — but
|
|
924
960
|
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
925
961
|
# raises, so a client could send the literal string "NaN" for a price and
|
|
926
962
|
# have it stored. Nothing else in the gem disagreed with itself this
|
|
927
963
|
# loudly: :float rejected those strings and :decimal did not.
|
|
928
|
-
|
|
929
|
-
|
|
964
|
+
#
|
|
965
|
+
# BigDecimal's exponent is far wider than a Float's, but it is bounded:
|
|
966
|
+
# "1e99999999999999999999" overflows to Infinity, refused above, and
|
|
967
|
+
# "1e-99999999999999999999" underflows to zero — silently, like a Float,
|
|
968
|
+
# so the same nonzero-significand rule as finite_float applies.
|
|
969
|
+
def finite_decimal(result, source: nil)
|
|
970
|
+
return [:error, "invalid_type"] unless result.finite?
|
|
971
|
+
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
972
|
+
return [:error, "invalid_type"] if source && amplified?(result, source)
|
|
973
|
+
|
|
974
|
+
[:ok, result]
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# How many digits a :decimal's plain form may run to beyond what the
|
|
978
|
+
# client actually sent. See amplified?.
|
|
979
|
+
DECIMAL_DIGIT_ALLOWANCE = 1_000
|
|
980
|
+
|
|
981
|
+
# BigDecimal keeps a huge exponent exactly: "1e4000000000000000000" is 22
|
|
982
|
+
# bytes and a finite value. But a database adapter quoting it, JSON
|
|
983
|
+
# rendering, BigDecimal#to_s and a log line all build its plain form,
|
|
984
|
+
# which is NoMemoryError there — and with a smaller exponent
|
|
985
|
+
# ("1e1000000000"), a gigabyte string from a dozen bytes of request.
|
|
986
|
+
# So a String whose plain form would run past both a thousand digits and
|
|
987
|
+
# its own length is refused: "1e400" and "1e-999" still cast, and so
|
|
988
|
+
# does any number spelled out digit by digit, however long, since the
|
|
989
|
+
# request already paid for every digit.
|
|
990
|
+
def amplified?(result, source)
|
|
991
|
+
result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
|
|
930
992
|
end
|
|
931
993
|
|
|
932
994
|
def cast_boolean(value)
|
|
@@ -945,6 +1007,15 @@ module Permittable
|
|
|
945
1007
|
# ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
|
|
946
1008
|
def cast_date(value)
|
|
947
1009
|
case value
|
|
1010
|
+
# Ahead of Date, which DateTime subclasses: `when Date` handed a
|
|
1011
|
+
# DateTime back whole, time of day included, so `default:
|
|
1012
|
+
# DateTime.new(2026, 1, 5, 12)` was stored as noon and exported as a
|
|
1013
|
+
# date-time under `format: date` — while a Time was refused outright,
|
|
1014
|
+
# and an in: member was read as an instant. Every instant is now read
|
|
1015
|
+
# the way that member is (see date_of_instant).
|
|
1016
|
+
when Time, DateTime
|
|
1017
|
+
date = date_of_instant(value)
|
|
1018
|
+
date ? [:ok, date] : [:error, "invalid_type"]
|
|
948
1019
|
when Date then [:ok, value]
|
|
949
1020
|
when String
|
|
950
1021
|
found = Date._parse(value)
|
|
@@ -967,6 +1038,19 @@ module Permittable
|
|
|
967
1038
|
found.key?(:year) && found.key?(:mon) && found.key?(:mday)
|
|
968
1039
|
end
|
|
969
1040
|
|
|
1041
|
+
# The date a Time or DateTime IS, or nil: its UTC date when it is
|
|
1042
|
+
# exactly midnight UTC. That is the instant ActiveSupport compares a
|
|
1043
|
+
# Date as, and the one :datetime reads a Date as, so the two types map
|
|
1044
|
+
# onto each other and back. Any other instant has a time of day, which
|
|
1045
|
+
# a Date cannot hold, so it is not a date at all — the answer a Time
|
|
1046
|
+
# with a time of day always got. Shared by cast_date and
|
|
1047
|
+
# instant_as_date, so a value and an in: member cannot be read
|
|
1048
|
+
# differently; and always a plain Date, never the DateTime it came as.
|
|
1049
|
+
def date_of_instant(instant)
|
|
1050
|
+
utc = instant.to_time.getutc
|
|
1051
|
+
utc.to_date if utc == utc.beginning_of_day
|
|
1052
|
+
end
|
|
1053
|
+
|
|
970
1054
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
971
1055
|
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
972
1056
|
def cast_datetime(value)
|
|
@@ -989,13 +1073,36 @@ module Permittable
|
|
|
989
1073
|
# Time from components would have to reimplement DateTime.parse's
|
|
990
1074
|
# handling of offsets, zone names and sub-second precision, and
|
|
991
1075
|
# getting that subtly wrong costs more than the parse.
|
|
992
|
-
|
|
1076
|
+
#
|
|
1077
|
+
# Parsed in the proleptic Gregorian calendar, which is what ISO 8601
|
|
1078
|
+
# and Time use. DateTime.parse defaults to the 1582 Italian reform
|
|
1079
|
+
# and read an earlier date as Julian; stdlib's DateTime#to_time then
|
|
1080
|
+
# converts by day number, so "1500-01-01" became 1500-01-10 — unless
|
|
1081
|
+
# the host had loaded ActiveSupport's DateTime#to_time, which
|
|
1082
|
+
# converts by component. Gregorian, both agree.
|
|
1083
|
+
found = Date._parse(value)
|
|
1084
|
+
return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
|
|
1085
|
+
|
|
1086
|
+
[:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
|
|
993
1087
|
else [:error, "invalid_type"]
|
|
994
1088
|
end
|
|
995
1089
|
rescue ArgumentError, RangeError
|
|
996
1090
|
[:error, "invalid_type"]
|
|
997
1091
|
end
|
|
998
1092
|
|
|
1093
|
+
# A zone the parser could not turn into an offset — an offset of a day
|
|
1094
|
+
# or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
|
|
1095
|
+
# know ("XYZ") — is reported as a zone with no offset, or with one
|
|
1096
|
+
# beyond a day, and DateTime.parse then reads the wall clock as UTC: an
|
|
1097
|
+
# instant hours away from the one the client named. The client stated a
|
|
1098
|
+
# zone, so it is refused rather than guessed at.
|
|
1099
|
+
def honoured_zone?(found)
|
|
1100
|
+
return true unless found.key?(:zone)
|
|
1101
|
+
|
|
1102
|
+
offset = found[:offset]
|
|
1103
|
+
!offset.nil? && offset.abs < 86_400
|
|
1104
|
+
end
|
|
1105
|
+
|
|
999
1106
|
# Presets only make sense on String input; a non-String value (JSON
|
|
1000
1107
|
# numbers, booleans) skips normalization and goes straight to the cast.
|
|
1001
1108
|
#
|
|
@@ -1073,41 +1180,58 @@ module Permittable
|
|
|
1073
1180
|
end
|
|
1074
1181
|
|
|
1075
1182
|
# The members of an `in:` that is a LIST, or nil when it is not one.
|
|
1076
|
-
# Only Array, Set, Hash and Enumerator count, and only when the object
|
|
1077
|
-
#
|
|
1078
|
-
# Array or Set SUBCLASS
|
|
1079
|
-
#
|
|
1080
|
-
#
|
|
1081
|
-
#
|
|
1082
|
-
#
|
|
1083
|
-
#
|
|
1084
|
-
#
|
|
1085
|
-
#
|
|
1183
|
+
# Only Array, Set, Hash and Enumerator count, and only when the object
|
|
1184
|
+
# answers with the collection's ordinary include? — not one a Hash,
|
|
1185
|
+
# Array or Set SUBCLASS overrides (a case-insensitive allowlist, a fuzzy
|
|
1186
|
+
# Set, a registry matching some other way entirely). `case allowed; when
|
|
1187
|
+
# Hash ...` matches with ===, which for a Class is is_a?, so a subclass
|
|
1188
|
+
# would otherwise match its ancestor's branch and have its override
|
|
1189
|
+
# silently discarded — read for its raw keys/elements instead, which can
|
|
1190
|
+
# invert which values it actually accepts. It is left opaque instead,
|
|
1191
|
+
# exactly like any other object whose include? is the point (see
|
|
1192
|
+
# resolve_in!) and enumerating it may be expensive (a DB-backed
|
|
1086
1193
|
# registry).
|
|
1087
1194
|
#
|
|
1195
|
+
# So the test is which module OWNS include?, not the object's exact
|
|
1196
|
+
# class. A subclass that keeps its base's include? answers exactly what
|
|
1197
|
+
# its keys or elements say, and is a list like its base. Rails'
|
|
1198
|
+
# `config_for(:plans)` returns one, an ActiveSupport::OrderedOptions:
|
|
1199
|
+
# an exact-class test left it opaque, so `include?("pro")` asked about
|
|
1200
|
+
# its Symbol keys and every request was refused.
|
|
1201
|
+
#
|
|
1088
1202
|
# A Hash lists its KEYS, which is what Hash#include? asks about — the
|
|
1089
1203
|
# Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
|
|
1090
1204
|
# Set, so membership stays O(1) per request.
|
|
1091
|
-
# ActiveSupport::HashWithIndifferentAccess is the one
|
|
1092
|
-
# accepted anyway:
|
|
1093
|
-
#
|
|
1094
|
-
#
|
|
1095
|
-
#
|
|
1205
|
+
# ActiveSupport::HashWithIndifferentAccess is the one include? override
|
|
1206
|
+
# accepted anyway: it only canonicalises the argument (String/Symbol)
|
|
1207
|
+
# before the SAME key lookup, so its keys are still exactly its members
|
|
1208
|
+
# — and it is what a Rails enum's own reader (`Post.statuses`) actually
|
|
1209
|
+
# returns.
|
|
1096
1210
|
# Enumerator::Lazy is the same story on the Enumerator side: Lazy
|
|
1097
1211
|
# overrides chain methods like map and select, but not include?, so it
|
|
1098
1212
|
# is still read as a list — and forced to an Array here, once, since
|
|
1099
1213
|
# left lazy it would be cast on every request instead of at class load.
|
|
1100
1214
|
def in_list(allowed)
|
|
1101
1215
|
case allowed
|
|
1102
|
-
when Hash then allowed.keys.to_set if
|
|
1103
|
-
when Set then allowed if
|
|
1104
|
-
when Array then allowed.to_a if
|
|
1105
|
-
when Enumerator then allowed.to_a if allowed
|
|
1216
|
+
when Hash then allowed.keys.to_set if include_from?(allowed, Hash, ActiveSupport::HashWithIndifferentAccess)
|
|
1217
|
+
when Set then allowed if include_from?(allowed, Set)
|
|
1218
|
+
when Array then allowed.to_a if include_from?(allowed, Array)
|
|
1219
|
+
when Enumerator then allowed.to_a if include_from?(allowed, Enumerator)
|
|
1106
1220
|
end
|
|
1107
1221
|
end
|
|
1108
1222
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1223
|
+
# Whether `allowed` answers include? as one of `bases` itself does. What
|
|
1224
|
+
# is compared is the owner the BASE class reports, not a literal class:
|
|
1225
|
+
# a gem that prepends a module defining include? into Array, Hash or Set
|
|
1226
|
+
# (an instrumenting one) makes that module the owner for every instance,
|
|
1227
|
+
# and a test against the literal class then read every plain
|
|
1228
|
+
# `in: %i[draft published]` as an object with an include? of its own,
|
|
1229
|
+
# used it as given, and refused "draft". A prepended module is the
|
|
1230
|
+
# base's own include?; a subclass, a singleton method or an `extend`
|
|
1231
|
+
# still has a different owner.
|
|
1232
|
+
def include_from?(allowed, *bases)
|
|
1233
|
+
owner = allowed.method(:include?).owner
|
|
1234
|
+
bases.any? { |base| base.instance_method(:include?).owner == owner }
|
|
1111
1235
|
end
|
|
1112
1236
|
|
|
1113
1237
|
# A Symbol is read as its String: it is how Ruby spells a constant
|
|
@@ -1116,20 +1240,34 @@ module Permittable
|
|
|
1116
1240
|
member = member.to_s if member.is_a?(Symbol)
|
|
1117
1241
|
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
1118
1242
|
|
|
1119
|
-
cast(type, member)
|
|
1243
|
+
status, value = cast(type, member)
|
|
1244
|
+
return [status, value] unless status == :ok && type == :datetime && !member.is_a?(String) && !whole_nanoseconds?(value)
|
|
1245
|
+
|
|
1246
|
+
[:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
# A request's fraction of a second is a finite decimal, and the export
|
|
1250
|
+
# prints a member to at most nine digits, so a member that is not a whole
|
|
1251
|
+
# number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
|
|
1252
|
+
# Time.utc(..., Rational(1, 3)) a third — was published as an enum value
|
|
1253
|
+
# the server refuses and could never be matched. The :datetime analogue
|
|
1254
|
+
# of instant_as_date. Only for a Time-like member: a String member is
|
|
1255
|
+
# cast exactly as a request sending it is, and published as written, so
|
|
1256
|
+
# whatever precision it spells is reachable.
|
|
1257
|
+
def whole_nanoseconds?(time)
|
|
1258
|
+
(time.subsec * 1_000_000_000).denominator == 1
|
|
1120
1259
|
end
|
|
1121
1260
|
|
|
1122
1261
|
# A Time or DateTime member of a :date field. ActiveSupport compares one
|
|
1123
1262
|
# with a Date as INSTANTS, the Date standing for its midnight UTC, so
|
|
1124
1263
|
# that instant is the only one that ever equalled a request's date. It
|
|
1125
1264
|
# is read as that UTC date; any other instant never matched anything,
|
|
1126
|
-
# and is refused like any member no request could equal.
|
|
1127
|
-
#
|
|
1265
|
+
# and is refused like any member no request could equal. cast_date
|
|
1266
|
+
# reads it the same way (date_of_instant); this only gives the
|
|
1267
|
+
# class-load error a reason where a request gets `invalid_type`.
|
|
1128
1268
|
def instant_as_date(member)
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
[:ok, utc.to_date]
|
|
1269
|
+
date = date_of_instant(member)
|
|
1270
|
+
date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
|
|
1133
1271
|
end
|
|
1134
1272
|
|
|
1135
1273
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
@@ -1246,10 +1384,13 @@ module Permittable
|
|
|
1246
1384
|
# end
|
|
1247
1385
|
#
|
|
1248
1386
|
# `optional: true` relaxes every spliced field, which is how an update
|
|
1249
|
-
# contract reuses a create contract: nothing is mandatory,
|
|
1250
|
-
#
|
|
1251
|
-
#
|
|
1252
|
-
#
|
|
1387
|
+
# contract reuses a create contract: nothing is mandatory, and types and
|
|
1388
|
+
# bounds all still apply. It drops each relaxed field's `default:` too. A
|
|
1389
|
+
# default is filled in for every request that omits the field, so on a
|
|
1390
|
+
# PATCH it would overwrite the column the client left alone: `default:
|
|
1391
|
+
# "free"` reset every "pro" user who only renamed themself. It relaxes
|
|
1392
|
+
# the TOP LEVEL only — if a client sends an address at all, the
|
|
1393
|
+
# address's own required sub-fields and defaults still hold.
|
|
1253
1394
|
#
|
|
1254
1395
|
# `only:`/`except:` select a subset, in the group's own order. Naming a
|
|
1255
1396
|
# field the group doesn't declare is a class-load error, so a typo cannot
|
|
@@ -1258,7 +1399,7 @@ module Permittable
|
|
|
1258
1399
|
# and then declare `:city` yourself.
|
|
1259
1400
|
def use(group, only: nil, except: nil, optional: false)
|
|
1260
1401
|
fields = select_group_fields!(group_fields!(group), only: only, except: except)
|
|
1261
|
-
fields = fields.map { |field| field.merge(required: false).freeze } if optional
|
|
1402
|
+
fields = fields.map { |field| field.except(:default).merge(required: false).freeze } if optional
|
|
1262
1403
|
fields.each do |field|
|
|
1263
1404
|
field_name!(field[:name])
|
|
1264
1405
|
@fields << field
|
|
@@ -1268,7 +1409,22 @@ module Permittable
|
|
|
1268
1409
|
|
|
1269
1410
|
private
|
|
1270
1411
|
|
|
1412
|
+
# A Contract is usable as a group because it answers #fields — but its
|
|
1413
|
+
# finalize is not a field, so splicing would drop the contract's
|
|
1414
|
+
# cross-field rule: a standalone Booking contract rejecting an ends_on
|
|
1415
|
+
# before its starts_on, used by a controller that then accepts it. That
|
|
1416
|
+
# is the drift `use` exists to prevent, so it is refused here, loudly,
|
|
1417
|
+
# the way a field group's own finalize is. Also under only:/except: — a
|
|
1418
|
+
# finalize is opaque, so there is no part of it to keep — which the
|
|
1419
|
+
# message says, since that is the first thing a user tries.
|
|
1271
1420
|
def group_fields!(group)
|
|
1421
|
+
if group.is_a?(Contract) && group.rule[:finalize]
|
|
1422
|
+
raise ArgumentError, "#{LABEL}: use cannot carry a Permittable::Contract's finalize, so the contract's " \
|
|
1423
|
+
"cross-field rule would be silently dropped. Selecting fields with only:/except: " \
|
|
1424
|
+
"does not lift this: the finalize cannot be split, so it is refused for any part " \
|
|
1425
|
+
"of the contract. To share both, declare the fields in a Permittable.fields " \
|
|
1426
|
+
"group that the contract and this block each use, and declare finalize in each"
|
|
1427
|
+
end
|
|
1272
1428
|
return group.fields if group.respond_to?(:fields)
|
|
1273
1429
|
|
|
1274
1430
|
raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
|
|
@@ -1542,17 +1698,32 @@ module Permittable
|
|
|
1542
1698
|
raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
|
|
1543
1699
|
end
|
|
1544
1700
|
|
|
1545
|
-
# Same rule as a scalar's authored value
|
|
1546
|
-
#
|
|
1701
|
+
# Same rule as a scalar's authored value: a `default:` or `example:` that
|
|
1702
|
+
# its own bounds would reject fails at class load. Like an array's, it is
|
|
1703
|
+
# read by the request walker itself (see AuthoredValues), so it is checked
|
|
1704
|
+
# as a request sending the same hash is. The raw Hash used to be checked
|
|
1705
|
+
# instead: `validate: ->(p) { p[:theme] == "dark" }` passed a request's
|
|
1706
|
+
# {"theme":"dark"}, which the walker reads indifferently, and refused the
|
|
1707
|
+
# same Hash authored as `default:`. And a `validate:` that edits its
|
|
1708
|
+
# argument edited the host's own Hash, which a request's deep copy spares.
|
|
1709
|
+
#
|
|
1710
|
+
# For a `default:` the walker's copy is what is stored, so the action, the
|
|
1711
|
+
# export and the RSpec matcher all read what a request sending it would
|
|
1712
|
+
# have produced. An `example:` is not: it documents what a client SENDS,
|
|
1713
|
+
# so it is validated on that copy but stored as a fresh copy of the value
|
|
1714
|
+
# AS AUTHORED (request-shaped, indifferent with String keys), which a
|
|
1715
|
+
# `validate:` editing its argument cannot have added a key to or dropped
|
|
1716
|
+
# one from. That matches every other example on a contract, which is never
|
|
1717
|
+
# processed the way the action would see it.
|
|
1547
1718
|
def validate_json_authored_value!(field, opt)
|
|
1548
1719
|
return unless field.key?(opt)
|
|
1549
1720
|
return if authored_nil!(field, opt)
|
|
1550
1721
|
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
1551
1722
|
|
|
1552
|
-
status,
|
|
1553
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{
|
|
1723
|
+
status, read = AuthoredValues.read_json(field, field[opt])
|
|
1724
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{read})" unless status == :ok
|
|
1554
1725
|
|
|
1555
|
-
field[opt] = freeze_authored(field[opt])
|
|
1726
|
+
field[opt] = freeze_authored(opt == :example ? ActiveSupport::HashWithIndifferentAccess.new(field[opt]) : read)
|
|
1556
1727
|
end
|
|
1557
1728
|
|
|
1558
1729
|
# format / length / normalize reason about characters; on any other
|
|
@@ -1572,10 +1743,33 @@ module Permittable
|
|
|
1572
1743
|
raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
|
|
1573
1744
|
"(got #{length.inspect})"
|
|
1574
1745
|
end
|
|
1746
|
+
validate_length_ends!(name, length) if length.is_a?(Range)
|
|
1575
1747
|
|
|
1576
1748
|
assert_satisfiable!(name, :length, length)
|
|
1577
1749
|
end
|
|
1578
1750
|
|
|
1751
|
+
# A length is a count, so each end of a Range is a non-negative whole
|
|
1752
|
+
# number — an Integer, or a Float/Rational/BigDecimal with no fraction
|
|
1753
|
+
# (`1..10.0` means ten) — or open: nil, or an infinite Float on its own
|
|
1754
|
+
# side (`1..Float::INFINITY` predates endless Ranges). A negative end matched nothing (`..-1`
|
|
1755
|
+
# rejected every value, forever, as the client's fault) or meant nothing
|
|
1756
|
+
# (`-2..3` is `..3`), and a fractional one exported a minLength/maxLength
|
|
1757
|
+
# the JSON Schema metaschema refuses — Ajv rejects the whole document.
|
|
1758
|
+
def validate_length_ends!(name, length)
|
|
1759
|
+
ok = length_end?(length.begin, -Float::INFINITY) && length_end?(length.end, Float::INFINITY)
|
|
1760
|
+
return if ok
|
|
1761
|
+
|
|
1762
|
+
raise ArgumentError, "#{LABEL}: :length for :#{name} must have non-negative whole-number ends " \
|
|
1763
|
+
"(got #{length.inspect}); leave an end off for no bound, e.g. 1.. or ..80"
|
|
1764
|
+
end
|
|
1765
|
+
|
|
1766
|
+
def length_end?(value, open)
|
|
1767
|
+
return true if value.nil? || value == open
|
|
1768
|
+
return false unless value.is_a?(Numeric) && value.real? && value.finite?
|
|
1769
|
+
|
|
1770
|
+
!value.negative? && value == value.to_i
|
|
1771
|
+
end
|
|
1772
|
+
|
|
1579
1773
|
# A reversed Range (5..2), an exclusive Range with equal endpoints
|
|
1580
1774
|
# (3...3), or an empty set (in: []) excludes every value there is, so the
|
|
1581
1775
|
# field it bounds can never validate. That used to surface as every
|
|
@@ -2423,6 +2617,7 @@ module Permittable
|
|
|
2423
2617
|
|
|
2424
2618
|
def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
|
|
2425
2619
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
2620
|
+
hash = permittable_read_date_parts(fields, hash)
|
|
2426
2621
|
fields.each do |field|
|
|
2427
2622
|
key = field[:name].to_s
|
|
2428
2623
|
full = permittable_path(path, key)
|
|
@@ -2445,6 +2640,119 @@ module Permittable
|
|
|
2445
2640
|
result
|
|
2446
2641
|
end
|
|
2447
2642
|
|
|
2643
|
+
# See DATE_PARTS. A :date or :datetime field reads the parts sent for it
|
|
2644
|
+
# as its own value. They are replaced, in a COPY of `hash`, by the one
|
|
2645
|
+
# value they make, under the field's own key, so the walk sees exactly
|
|
2646
|
+
# what it would have had the client sent that value: the cast, `in:`,
|
|
2647
|
+
# `validate:`, `transform:`, the absence rule and `nullable:` all apply
|
|
2648
|
+
# unchanged, the action gets the field's own key, and the unknown-keys
|
|
2649
|
+
# check never sees the parts it read. `hash` itself is left as it is: it
|
|
2650
|
+
# may be the request's own params, which monitor mode hands back as they
|
|
2651
|
+
# arrived.
|
|
2652
|
+
#
|
|
2653
|
+
# Only when the field's own key is ABSENT. When both are sent, the key the
|
|
2654
|
+
# contract declares wins the VALUE, and the parts are left in the copy,
|
|
2655
|
+
# untouched, for the unknown-keys check to judge as it always has: flagged
|
|
2656
|
+
# under `unknown: :error`, logged under `:log`, dropped under `:ignore`.
|
|
2657
|
+
# They must not be consumed unread: strong parameters passes them through
|
|
2658
|
+
# `permit(:starts_on)` (the legacy action of an `enforce: true` rollout) and
|
|
2659
|
+
# ActiveRecord assigns multiparameter attributes AFTER the plain ones, so
|
|
2660
|
+
# parts hidden from `unknown: :error` would overwrite the value the
|
|
2661
|
+
# contract validated. And not when the contract declares one of the parts
|
|
2662
|
+
# as a field of its own, which is how a contract could carry them through
|
|
2663
|
+
# before this: that field keeps it.
|
|
2664
|
+
def permittable_read_date_parts(fields, hash)
|
|
2665
|
+
read = nil
|
|
2666
|
+
fields.each do |field|
|
|
2667
|
+
count = field[:kind] == :scalar && DATE_PARTS[field[:type]]
|
|
2668
|
+
next unless count
|
|
2669
|
+
|
|
2670
|
+
key = field[:name].to_s
|
|
2671
|
+
names = Array.new(count) { |index| "#{key}(#{index + 1}i)" }
|
|
2672
|
+
next if hash.key?(key)
|
|
2673
|
+
next unless names.any? { |name| hash.key?(name) }
|
|
2674
|
+
next if fields.any? { |other| names.include?(other[:name].to_s) }
|
|
2675
|
+
|
|
2676
|
+
read ||= hash.dup
|
|
2677
|
+
parts = {}
|
|
2678
|
+
names.each.with_index(1) { |name, position| parts[position] = read.delete(name) if read.key?(name) }
|
|
2679
|
+
|
|
2680
|
+
# Parts that make no value are handed to the field as the several
|
|
2681
|
+
# values they are, which a scalar refuses as invalid_type: on the
|
|
2682
|
+
# field's own path, with its own message:, as for `?starts_on[]=x`.
|
|
2683
|
+
read[key] = permittable_date_from_parts(parts, field[:type]) || parts.values
|
|
2684
|
+
end
|
|
2685
|
+
read || hash
|
|
2686
|
+
end
|
|
2687
|
+
|
|
2688
|
+
# The ISO 8601 String that `parts` (keyed by Rails' positions, 1-3 the
|
|
2689
|
+
# date and 4-6 the time) spell for a field of `type`, "" when they spell
|
|
2690
|
+
# the field sent empty, or nil when they spell nothing at all. A String is
|
|
2691
|
+
# what a :date or :datetime field already reads, so its cast and that
|
|
2692
|
+
# cast's rules (a zoneless datetime is UTC) stay in one place.
|
|
2693
|
+
#
|
|
2694
|
+
# This follows ActiveRecord's own reading of the same parts
|
|
2695
|
+
# (ActiveModel::Type::Date and DateTime cast them as a Hash): a value
|
|
2696
|
+
# needs all of (1i)-(3i), and when any one of them is blank the attribute
|
|
2697
|
+
# is nil, so the field reads as sent empty. That has to be ANY and not all
|
|
2698
|
+
# of them: Rails fills a part it discards (discard_day, or the date of a
|
|
2699
|
+
# time_select) through a hidden input, and the year and month selects of
|
|
2700
|
+
# an untouched picker still sit on their prompts beside it, so the form
|
|
2701
|
+
# arrives as {"(1i)" => "", "(2i)" => "", "(3i)" => "1"}. The time parts
|
|
2702
|
+
# of a :datetime are the other way round: ActiveModel defaults a blank
|
|
2703
|
+
# (4i), (5i) or (6i) to 00 once the date is there, and so does this. The
|
|
2704
|
+
# blank part need only be among those SENT: ActiveModel reads a Hash with
|
|
2705
|
+
# (2i) and (3i) but no (1i), or with (1i) and (2i) but no (3i), as nil when
|
|
2706
|
+
# one of the ones it has is blank, and so does this. A part that is missing
|
|
2707
|
+
# with no blank sibling is nil here, for a scalar to refuse as
|
|
2708
|
+
# invalid_type, when it is one of (1i)-(3i): a time_select with
|
|
2709
|
+
# `ignore_date:` sends only (4i) and (5i), and a :datetime needs a date.
|
|
2710
|
+
# A MISSING (4i), (5i) or (6i) is 00 like a blank one, which is what
|
|
2711
|
+
# ActiveModel does with {1 => 2026, 2 => 10, 3 => 17}: a date_select on a
|
|
2712
|
+
# :datetime column, or a datetime_select with `discard_hour:`, sends the
|
|
2713
|
+
# date alone, and (6i) comes with `include_seconds:` only.
|
|
2714
|
+
# Whatever else is not an unsigned decimal integer is nil likewise.
|
|
2715
|
+
def permittable_date_from_parts(parts, type)
|
|
2716
|
+
return "" if (1..3).any? { |position| parts.key?(position) && Coercion.absent_value?(parts[position]) }
|
|
2717
|
+
|
|
2718
|
+
time = type == :datetime
|
|
2719
|
+
digits = (1..3).map { |position| permittable_date_part(parts[position], year: position == 1) }
|
|
2720
|
+
if time
|
|
2721
|
+
# A time part that is blank or missing is 00, as ActiveModel reads it.
|
|
2722
|
+
digits += (4..6).map do |position|
|
|
2723
|
+
next "00" if !parts.key?(position) || Coercion.absent_value?(parts[position])
|
|
2724
|
+
|
|
2725
|
+
permittable_date_part(parts[position])
|
|
2726
|
+
end
|
|
2727
|
+
end
|
|
2728
|
+
return nil if digits.include?(nil)
|
|
2729
|
+
|
|
2730
|
+
year, month, day, hour, minute, second = digits
|
|
2731
|
+
time ? "#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}" : "#{year}-#{month}-#{day}"
|
|
2732
|
+
end
|
|
2733
|
+
|
|
2734
|
+
# One part's digits, padded once its leading zeros are dropped: "7" and
|
|
2735
|
+
# "007" are both "07". Joined as sent, Date._parse reads "2026-007-5" as
|
|
2736
|
+
# May 7th. The year is padded to four digits, so "99" is the year 99 (as
|
|
2737
|
+
# ActiveRecord reads it) rather than a two-digit year Date._parse places
|
|
2738
|
+
# in 1999, and is otherwise left to the cast, as a year in a String is.
|
|
2739
|
+
# Any other part has at most two digits: a month of "100" is no value a
|
|
2740
|
+
# select offers, and Date._parse would read the string around it some
|
|
2741
|
+
# other way. A non-negative Integer is the digits it prints: a JSON body or
|
|
2742
|
+
# an `as: :json` request cannot send the String a form does, and
|
|
2743
|
+
# ActiveRecord reads either. nil for anything that is not an unsigned
|
|
2744
|
+
# decimal integer.
|
|
2745
|
+
def permittable_date_part(part, year: false)
|
|
2746
|
+
part = part.to_s if part.is_a?(Integer) && !part.negative?
|
|
2747
|
+
text = Coercion.utf8_text(part) if part.is_a?(String)
|
|
2748
|
+
return nil unless text&.match?(DATE_PART_FORMAT)
|
|
2749
|
+
|
|
2750
|
+
digits = text.sub(/\A0+(?=\d)/, "")
|
|
2751
|
+
return digits.rjust(4, "0") if year
|
|
2752
|
+
|
|
2753
|
+
digits.rjust(2, "0") if digits.length <= 2
|
|
2754
|
+
end
|
|
2755
|
+
|
|
2448
2756
|
def permittable_check_field(field, value, full, result, unknown:, violations:)
|
|
2449
2757
|
key = field[:name].to_s
|
|
2450
2758
|
case field[:kind]
|
|
@@ -2484,8 +2792,8 @@ module Permittable
|
|
|
2484
2792
|
value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
|
|
2485
2793
|
end
|
|
2486
2794
|
|
|
2487
|
-
#
|
|
2488
|
-
# The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2795
|
+
# The bounds, then `validate:`, with the copy the result needs made in the
|
|
2796
|
+
# middle. The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2489
2797
|
# rebuilt its containers but not the Strings inside them, so it is
|
|
2490
2798
|
# deep-copied for the reason permittable_normalized copies a String — but
|
|
2491
2799
|
# only once it is within its bounds. Copying first meant a megabyte
|
|
@@ -2511,19 +2819,31 @@ module Permittable
|
|
|
2511
2819
|
end
|
|
2512
2820
|
|
|
2513
2821
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
2822
|
+
# A scalar array's "" elements are absent, as "" is for a field: the
|
|
2823
|
+
# hidden `name="post[tag_ids][]" value=""` that collection_check_boxes
|
|
2824
|
+
# and `select multiple: true` render, so that unchecking every box still
|
|
2825
|
+
# sends the key, made every submit a 422 for `of: :integer`. Each kept
|
|
2826
|
+
# element keeps the index the client sent it at, so violation paths
|
|
2827
|
+
# still point into the request. A null element, which no form sends,
|
|
2828
|
+
# is still refused.
|
|
2829
|
+
blank = ->(element) { !field[:fields] && element.is_a?(String) && element.empty? }
|
|
2830
|
+
count = field[:fields] ? value.length : value.length - value.count(&blank)
|
|
2831
|
+
|
|
2514
2832
|
# `length:` is a BOUND, not a report. An array outside it is rejected
|
|
2515
2833
|
# whatever its contents, so checking those contents can only add work and
|
|
2516
2834
|
# noise: a 200k-element payload against `length: 0..10` used to cast every
|
|
2517
2835
|
# element, collect 200k more violations, and answer with a multi-megabyte
|
|
2518
2836
|
# 422 — for a request already refused by its first check. Stopping here
|
|
2519
|
-
# keeps the cost of an oversized array proportional to rejecting it
|
|
2520
|
-
|
|
2837
|
+
# keeps the cost of an oversized array proportional to rejecting it (the
|
|
2838
|
+
# blank count above allocates nothing).
|
|
2839
|
+
if field[:length] && !Coercion.length_ok?(field[:length], count)
|
|
2521
2840
|
violations << permittable_violation(field, path, "length")
|
|
2522
2841
|
return nil
|
|
2523
2842
|
end
|
|
2524
2843
|
|
|
2525
2844
|
before = violations.length
|
|
2526
|
-
|
|
2845
|
+
elements = value.each_with_index.reject { |element, _| blank.call(element) }
|
|
2846
|
+
out = elements.map do |element, index|
|
|
2527
2847
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
2528
2848
|
end
|
|
2529
2849
|
# validate: and transform: see only a fully-valid array. A partially-nil
|