permittable 0.13.0 → 0.13.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 +22 -0
- data/README.md +10 -2
- data/lib/permittable/authored_values.rb +37 -1
- data/lib/permittable/column_guard.rb +33 -0
- data/lib/permittable/open_api.rb +50 -1
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +66 -8
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbb9461a44e4918ca7a982fc42ad55e7c2c6a36b231ea9560eb298c85fea259c
|
|
4
|
+
data.tar.gz: 324137849cfc36dde9a3ff2764aeef2ba02beec9e6487bd70b4990b0ee3b95e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f612dc7e5b04cf543c34b4b9ff4810e0620f80a8dfcc6cbed92d2969eab40a4396b3472d9ac2f8b4a37bc1b1a8d92ddc166312edba7de78508de2c4435dcba31
|
|
7
|
+
data.tar.gz: 7dd8145f4968a76cd10332a17f74a30531a4c4d549becd967b80c5f2a7f4ad41b54751209abf94b9386aa6a0aaa939d3be8f1eb1a3dd1b309f05bd39c5d212d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.13.1 (2026-11-04)
|
|
4
|
+
<!-- title: contracts that cannot be exported are refused at class load, a Metal catch-all and a date-parts example export correctly, and a pre-1582 date reads as the day it names -->
|
|
5
|
+
|
|
6
|
+
Six fixes, one documentation change and a development dependency bump, no new API. A `:datetime` `default:` or `example:` finer than a nanosecond and a `:json` `default:` or `example:` holding NaN or Infinity are now refused at class load, instead of loading and then breaking the export. The OpenAPI export of a catch-all contract on an `ActionController::Metal` controller no longer documents the framework's own methods as operations, and an array `example:` written with date parts is exported as the date it spells. A midnight-UTC `Time` or `DateTime` sent to a `:date` before 1582 is read as the day it names. A missing-column error for an ignored column no longer suggests a migration, and the README now says that a catch-all `rescue_from` declared after the include wins.
|
|
7
|
+
|
|
8
|
+
Patch rather than minor: nothing that works today stops working. Every entry is a crash or a wrong value on input that was already mishandled, an export correction that makes the document agree with what the server does, a clearer error message, documentation, or development tooling. Four things are visible from outside and worth knowing before you upgrade. A contract now fails at class load, naming the field, in three cases: its `:datetime` `default:` or `example:` is finer than a nanosecond (#159), which the export published as a different instant; its `:json` `default:` or `example:` holds NaN or Infinity (#163), which made the export raise; or its `:date` `in:` member is a `Time` on one of the days 1582-10-05 to 1582-10-14 (#164), which was read silently as 1582-09-30. The OpenAPI export of a Metal catch-all loses its bogus framework operations (#161). An array `example:` written with date parts is now exported correctly (#162).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **An ignored column that is in the table was reported as missing, with a migration hint that would add it twice.** `ColumnGuard` checked a field against `column_names`, which leaves out `ignored_columns`, so `optional :legacy, :integer` on a model with `self.ignored_columns = %w[legacy]` raised "does not exist in the database" and suggested `bin/rails generate migration`. The guard still refuses the field, since assigning an ignored column raises `ActiveModel::UnknownAttributeError`, but the message now says the model ignores the column and suggests removing it from `ignored_columns` or declaring the field `virtual: true`. When the column was dropped and its name was left in `ignored_columns`, the message says the table has no such column and gives no migration. (#157)
|
|
12
|
+
- **A `:datetime` `default:` or `example:` finer than a nanosecond loaded, and the export published a different instant.** 0.12.0 refused such an `in:` member (#106), but `Time.at(Rational(1, 3)).utc` as a `default:` was exported as `.333333333Z`, so a client that sent the published default sent a different instant. A `default:` or `example:` finer than a nanosecond now raises `ArgumentError` at class load, naming the field, at any depth: a scalar, an `of: :datetime` array, an array of hashes and a sub-field. A String with ten or more fractional digits is refused too, unlike an `in:` String member, which is published as written. A nanosecond-exact value is still accepted. (#159)
|
|
13
|
+
- **The OpenAPI export documented about 36 framework methods of a Metal controller as operations.** A catch-all `permit_params` on an `ActionController::Metal` controller expanded from `action_methods`, which for a Metal subclass counts every public method of an included module, so `render`, `head`, `_prefixes`, `view_paths` and the rest were exported with operationIds such as `pings_render`. The catch-all now lists only the controller's own actions, as it does on an `ActionController::Base` controller. (#161)
|
|
14
|
+
- **An array `example:` written with date parts was exported as `[{}]`.** `array :items, example: [{ "on(1i)" => "2026", "on(2i)" => "10", "on(3i)" => "17" }]` was published as `[[{}]]`, though a request with the same parts reads as `2026-10-17`, and the server would fill in the sub-field's default for the published payload. The example is now judged on the hash with the date parts assembled, and is exported as `[{"on" => "2026-10-17"}]`. This covers `:date` and `:datetime` parts, in arrays and in a nested block inside an array. Requests are unchanged. (#162)
|
|
15
|
+
- **A `:json` `default:` or `example:` holding NaN or Infinity loaded, and the export raised.** `optional :meta, :json, default: { "score" => Float::NAN }` loaded, and `JSON.generate(json_schema)` then raised `JSON::GeneratorError`. No JSON request can carry NaN, so the value is one no client can send. A non-finite Float at any depth of a `:json` default or example, including a `:json` sub-field and a `:json` value inside an array, now raises `ArgumentError` at class load, naming the field and the path, such as `x[1].y`. Requests are unchanged. (#163)
|
|
16
|
+
- **A midnight-UTC `Time` sent to a `:date` before 1582 was read as the wrong day.** `Time.utc(1500, 1, 1)` became 1499-12-23, because `Time#to_date` labels days before the reform in the Julian calendar, while the String `"1500-01-01"` is proleptic Gregorian. The same applied to a `DateTime` and a `TimeWithZone`, and to `in:` members and `default:` values, which never matched or were exported as a different day. The day is now built from the instant's own year, month and day, so a Time, a DateTime and the String `"1500-01-01T00:00:00Z"` all give 1500-01-01. (#164)
|
|
17
|
+
- **A pre-1582 `DateTime` was read wrongly without ActiveSupport.** With a plain `require "permittable"`, `DateTime.new(1500, 1, 1)` sent to a `:date` landed on 1500-01-10. It now reads the DateTime's own calendar labels and gives 1500-01-01, with or without ActiveSupport. The days 1582-10-05 to 1582-10-14 do not exist in the calendar a `:date` reads, so a Time or DateTime on one of them is `invalid_type`, and an `in:` member on one raises the normal class-load error naming the field. A `:datetime` still accepts those days. (#164)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- **The README now says that a catch-all `rescue_from` declared after `include Permittable` wins.** `include Permittable` registers `rescue_from Permittable::InvalidParameters`, and Rails searches handlers from the last declared, so a `rescue_from StandardError` placed after the include answers an invalid request with its own response, often a 500, and the gem's 422 never runs. The README gives the two fixes: declare the catch-all before the include, or declare `rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters` after it. (#158)
|
|
21
|
+
|
|
22
|
+
### Development
|
|
23
|
+
- **Bump `sqlite3` 2.10.0 to 2.10.1 and `rubocop` (bundler-minor-patch group).** Development dependencies only. (#167)
|
|
24
|
+
|
|
3
25
|
## 0.13.0 (2026-10-24)
|
|
4
26
|
<!-- title: the audit and the OpenAPI export follow mounted engines and Metal controllers, and a path variable is documented as one -->
|
|
5
27
|
|
data/README.md
CHANGED
|
@@ -716,7 +716,7 @@ What you give up is the shape. What you keep:
|
|
|
716
716
|
| `max_depth:` | Caps **container nesting**, counting arrays as a level: `{"a": 1}` is 1, `{"a": {"b": 1}}` and `{"a": [1, 2]}` are 2, `{"a": [{"b": 1}]}` is 3. Violation code `depth` |
|
|
717
717
|
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
718
718
|
| `model:` | The field maps onto a column like a scalar does, so the [drift guard](#the-schema-drift-guard) still catches a dropped `metadata` column |
|
|
719
|
-
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash, and are checked against the field's own bounds at class load) |
|
|
719
|
+
| `sensitive:` / `nullable:` / `message:` / `desc:` / `default:` / `example:` | Behave as on any other field (`default:`/`example:` must be a hash holding no NaN or Infinity, and are checked against the field's own bounds at class load) |
|
|
720
720
|
|
|
721
721
|
Bounding it matters more than it looks: an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects. `max_depth:` and `length:` are how a contract says "opaque, but not unlimited" — which is strictly more than `permit(metadata: {})` can say.
|
|
722
722
|
|
|
@@ -773,6 +773,12 @@ Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth ab
|
|
|
773
773
|
|
|
774
774
|
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope shown at the [top of this README](#permittable).
|
|
775
775
|
|
|
776
|
+
Rails searches `rescue_from` handlers from the last declared to the first, so a catch-all such as `rescue_from StandardError` declared *after* `include Permittable` answers an invalid request first, and the client gets that handler's response instead of the 422 or 400. Either declare the catch-all before the include, or re-declare the gem's handler after it:
|
|
777
|
+
|
|
778
|
+
```ruby
|
|
779
|
+
rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
|
|
780
|
+
```
|
|
781
|
+
|
|
776
782
|
| Code | Raised when |
|
|
777
783
|
|---|---|
|
|
778
784
|
| `missing` | A required field is absent, or the `root:` key is absent (that one is a **400**) |
|
|
@@ -1015,6 +1021,8 @@ If this parameter is not backed by a column, declare it with virtual: true.
|
|
|
1015
1021
|
|
|
1016
1022
|
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
1017
1023
|
|
|
1024
|
+
A field naming a column the model lists in `ignored_columns` raises too, but says so (remove it from `ignored_columns`, or declare it `virtual: true`) rather than suggesting a migration.
|
|
1025
|
+
|
|
1018
1026
|
#### Checking types too (opt-in)
|
|
1019
1027
|
|
|
1020
1028
|
A dropped column fails the deploy; a **retyped** one doesn't, unless you ask:
|
|
@@ -1592,7 +1600,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1592
1600
|
- `length:` that isn't a non-negative `Integer` or a `Range`, or a `Range` with a negative or fractional end (`..-1`, `-2..3`, `1.5..3.5`; a whole-number Float such as `1..10.0` is fine; leave an end off, or use `Float::INFINITY`, for no bound); an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1593
1601
|
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
|
|
1594
1602
|
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
|
|
1595
|
-
- A `Time` `in:` member on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal
|
|
1603
|
+
- A `Time` `in:` member, `default:` or `example:` on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal the member, and a published default or example would be a different instant
|
|
1596
1604
|
- `validate:` or `transform:` that isn't callable
|
|
1597
1605
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
|
|
1598
1606
|
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
@@ -103,6 +103,16 @@ module Permittable
|
|
|
103
103
|
found
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# An authored :datetime Time must survive the export's nine digits, at
|
|
107
|
+
# any depth, as a scalar `default:` or `example:` must (see Coercion).
|
|
108
|
+
def permittable_check_scalar(field, value)
|
|
109
|
+
Coercion.refuse_finer_than_nanosecond(field[:type], super)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def permittable_cast_element(field, element)
|
|
113
|
+
Coercion.refuse_finer_than_nanosecond(field[:of], super)
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
# Nor does it run an array-level validate:, which expects the transformed
|
|
107
117
|
# sub-fields a request hands it. The validating read has already run it
|
|
108
118
|
# on exactly those, so the stored read, which would hand it the
|
|
@@ -114,11 +124,13 @@ module Permittable
|
|
|
114
124
|
# What a client sends has no defaults filled in: an omitted sub-field
|
|
115
125
|
# stays omitted, at any depth, so the published example does not show a
|
|
116
126
|
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
117
|
-
# exactly what it filled, by the walker's own absence rule
|
|
127
|
+
# exactly what it filled, by the walker's own absence rule, judged on the
|
|
128
|
+
# hash the walker walked: one with the date parts read into their field.
|
|
118
129
|
def permittable_check_hash(fields, hash, **)
|
|
119
130
|
result = super
|
|
120
131
|
return result unless @as_sent
|
|
121
132
|
|
|
133
|
+
hash = permittable_read_date_parts(fields, hash)
|
|
122
134
|
fields.each do |field|
|
|
123
135
|
key = field[:name].to_s
|
|
124
136
|
next unless field.key?(:default)
|
|
@@ -130,6 +142,30 @@ module Permittable
|
|
|
130
142
|
result
|
|
131
143
|
end
|
|
132
144
|
|
|
145
|
+
# An authored hash is exported as a schema `default`/`example`, and JSON
|
|
146
|
+
# has no NaN or Infinity: JSON.generate raises on them when the document
|
|
147
|
+
# is written, long after the class loaded. A request cannot carry one
|
|
148
|
+
# (JSON has none; a form sends Strings), so only the authored value is
|
|
149
|
+
# held to it, here, and not permittable_check_json for every request.
|
|
150
|
+
def permittable_check_json(field, value)
|
|
151
|
+
status, read = super
|
|
152
|
+
return [status, read] unless status == :ok
|
|
153
|
+
|
|
154
|
+
at = non_finite_path(value, "")
|
|
155
|
+
return [:error, "holds NaN or Infinity at #{at}, which JSON cannot carry"] if at
|
|
156
|
+
|
|
157
|
+
[status, read]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Where the first NaN or infinite Float sits, as `a.b[1].c`, or nil.
|
|
161
|
+
def non_finite_path(value, path)
|
|
162
|
+
case value
|
|
163
|
+
when Float then path unless value.finite?
|
|
164
|
+
when Hash then value.each_pair.lazy.filter_map { |k, v| non_finite_path(v, path.empty? ? k.to_s : "#{path}.#{k}") }.first
|
|
165
|
+
when Array then value.each_with_index.lazy.filter_map { |v, i| non_finite_path(v, "#{path}[#{i}]") }.first
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
133
169
|
def permittable_violation(_field, param, code)
|
|
134
170
|
{ param: param, code: code.to_s }
|
|
135
171
|
end
|
|
@@ -49,6 +49,8 @@ module Permittable
|
|
|
49
49
|
|
|
50
50
|
fields.flatten.compact.each do |field|
|
|
51
51
|
unless klass.column_names.include?(field.to_s)
|
|
52
|
+
raise ArgumentError, ignored_column_message(label, klass, field) if ignored_column?(klass, field)
|
|
53
|
+
|
|
52
54
|
raise ArgumentError,
|
|
53
55
|
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
54
56
|
"#{column_migration_hint(klass, field, types)}"
|
|
@@ -204,6 +206,37 @@ module Permittable
|
|
|
204
206
|
klass.name || "Model"
|
|
205
207
|
end
|
|
206
208
|
|
|
209
|
+
# `klass.column_names` leaves out `self.ignored_columns`, so an ignored
|
|
210
|
+
# column that IS in the database reads as missing. Raising stays right
|
|
211
|
+
# (`Model.new(field: ...)` raises UnknownAttributeError), but the
|
|
212
|
+
# migration the missing-column message teaches would duplicate it.
|
|
213
|
+
def ignored_column?(klass, field)
|
|
214
|
+
klass.respond_to?(:ignored_columns) && klass.ignored_columns.map(&:to_s).include?(field.to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The column may have been dropped since it was ignored, with its name
|
|
218
|
+
# left in ignored_columns; the table itself says which. That case needs
|
|
219
|
+
# different advice: removing the name would only lead to a migration
|
|
220
|
+
# that re-adds a column the app no longer wants.
|
|
221
|
+
def ignored_column_message(label, klass, field)
|
|
222
|
+
subject = "#{label}: '#{field}' is in #{model_expr(klass)}'s ignored_columns"
|
|
223
|
+
if column_in_table?(klass, field)
|
|
224
|
+
"#{subject} (table: #{klass.table_name}), so the model ignores that column " \
|
|
225
|
+
"and assigning it raises ActiveModel::UnknownAttributeError. " \
|
|
226
|
+
"Remove it from ignored_columns, or declare the field virtual: true if the app handles it itself."
|
|
227
|
+
else
|
|
228
|
+
"#{subject} but the table has no such column (table: #{klass.table_name}). " \
|
|
229
|
+
"Remove it from ignored_columns and the field from the contract, " \
|
|
230
|
+
"or declare the field virtual: true if the app handles it itself."
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Unlike `column_names`, the connection's own column list includes the
|
|
235
|
+
# columns the model ignores.
|
|
236
|
+
def column_in_table?(klass, field)
|
|
237
|
+
klass.connection_pool.with_connection { |connection| connection.column_exists?(klass.table_name, field) }
|
|
238
|
+
end
|
|
239
|
+
|
|
207
240
|
def column_migration_hint(klass, field, types)
|
|
208
241
|
type = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
209
242
|
column = [field, type].compact.join(":")
|
data/lib/permittable/open_api.rb
CHANGED
|
@@ -91,6 +91,54 @@ module Permittable
|
|
|
91
91
|
@concern_methods ||= Permittable.public_instance_methods(false).map(&:to_s).freeze
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Gems whose public methods are the framework's own: the ones a controller
|
|
95
|
+
# gets from the modules Rails mixes in (Rendering, Head, Callbacks, ViewPaths,
|
|
96
|
+
# Rescuable, ...) and from the class attributes those define on it.
|
|
97
|
+
FRAMEWORK_GEMS = %w[actionpack actionview activesupport activemodel railties].freeze
|
|
98
|
+
|
|
99
|
+
# Rails reads a controller's internal methods off its nearest abstract
|
|
100
|
+
# class: ActionController::Base includes all of those modules, so they are
|
|
101
|
+
# internal on a Base or API controller, but ActionController::Metal includes
|
|
102
|
+
# none, so on one built directly on it every public method of an included
|
|
103
|
+
# module is an action_methods entry. A method is the framework's when it
|
|
104
|
+
# is defined in one of its gems, by whichever module or class_attribute
|
|
105
|
+
# put it on the controller; a method the app defines, in the class or in a
|
|
106
|
+
# concern of its own, stays an action. So does one a superclass declares in
|
|
107
|
+
# its own file, even a framework controller's (Rails::HealthController#show):
|
|
108
|
+
# a method defined elsewhere in a gem, such as the class_attribute readers
|
|
109
|
+
# an app's parent class gets from the modules it includes, is not one.
|
|
110
|
+
def framework_method?(controller, action)
|
|
111
|
+
return false unless controller.respond_to?(:instance_method)
|
|
112
|
+
|
|
113
|
+
method = controller.instance_method(action)
|
|
114
|
+
file = method.source_location&.first
|
|
115
|
+
return false if file.nil? || framework_roots.none? { |root| file.start_with?(root) }
|
|
116
|
+
|
|
117
|
+
!declared_by_superclass?(method, controller, file)
|
|
118
|
+
rescue NameError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether `method` is written in the file that defines its owner, a class
|
|
123
|
+
# other than the controller itself. An anonymous owner has no such file.
|
|
124
|
+
def declared_by_superclass?(method, controller, file)
|
|
125
|
+
owner = method.owner
|
|
126
|
+
owner.is_a?(Class) && owner != controller && !owner.name.nil? &&
|
|
127
|
+
Object.const_source_location(owner.name)&.first == file
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Resolved lazily, like concern_methods: the gems load after this file.
|
|
131
|
+
# Real paths, which is what source_location reports: a gem's own path is
|
|
132
|
+
# only expanded, so one reached through a symlink would never match.
|
|
133
|
+
def framework_roots
|
|
134
|
+
@framework_roots ||= FRAMEWORK_GEMS.filter_map do |name|
|
|
135
|
+
path = Gem.loaded_specs[name]&.full_gem_path or next
|
|
136
|
+
File.join(File.realpath(path), "")
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
File.join(path, "")
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
94
142
|
# Shared `components` for any document referencing Permittable responses.
|
|
95
143
|
#
|
|
96
144
|
# Unlike a rule's monitor mode — which the exporter reads only from the
|
|
@@ -176,7 +224,8 @@ module Permittable
|
|
|
176
224
|
return explicit unless contracts.any? { |rule| rule[:actions].empty? }
|
|
177
225
|
return explicit + ["*"] unless controller.respond_to?(:action_methods)
|
|
178
226
|
|
|
179
|
-
|
|
227
|
+
actions = controller.action_methods.map(&:to_s).sort - explicit - concern_methods
|
|
228
|
+
explicit + actions.reject { |action| framework_method?(controller, action) }
|
|
180
229
|
end
|
|
181
230
|
|
|
182
231
|
def operation_for(controller, action)
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -1046,9 +1046,31 @@ module Permittable
|
|
|
1046
1046
|
# with a time of day always got. Shared by cast_date and
|
|
1047
1047
|
# instant_as_date, so a value and an in: member cannot be read
|
|
1048
1048
|
# differently; and always a plain Date, never the DateTime it came as.
|
|
1049
|
+
#
|
|
1050
|
+
# Built from the UTC components, as a String is (cast_date), not with
|
|
1051
|
+
# Time#to_date: that keeps the instant's day NUMBER and labels it in the
|
|
1052
|
+
# Julian calendar before the 1582 reform, so Time.utc(1500, 1, 1) came
|
|
1053
|
+
# out as 1499-12-23 while "1500-01-01" was 1500-01-01.
|
|
1054
|
+
#
|
|
1055
|
+
# A day the 1582 reform skipped (1582-10-05..14) is no Date, as the String
|
|
1056
|
+
# "1582-10-10" is none, so it is nil here and every caller gives its usual
|
|
1057
|
+
# error. :datetime is proleptic Gregorian and does accept those days; the
|
|
1058
|
+
# two differ there by design, :date being the String path's calendar.
|
|
1059
|
+
#
|
|
1060
|
+
# A DateTime is read by its own civil day, not through #to_time: stdlib's
|
|
1061
|
+
# converts by day number, so outside Rails (ActiveSupport converts by
|
|
1062
|
+
# component) a DateTime before 1582 landed 9 days late.
|
|
1049
1063
|
def date_of_instant(instant)
|
|
1050
|
-
|
|
1051
|
-
|
|
1064
|
+
if instant.is_a?(DateTime)
|
|
1065
|
+
utc = instant.new_offset(0)
|
|
1066
|
+
return unless utc.hour.zero? && utc.minute.zero? && utc.second.zero? && utc.sec_fraction.zero?
|
|
1067
|
+
else
|
|
1068
|
+
utc = instant.to_time.getutc
|
|
1069
|
+
return unless utc == utc.beginning_of_day
|
|
1070
|
+
end
|
|
1071
|
+
return unless Date.valid_date?(utc.year, utc.month, utc.day)
|
|
1072
|
+
|
|
1073
|
+
Date.new(utc.year, utc.month, utc.day)
|
|
1052
1074
|
end
|
|
1053
1075
|
|
|
1054
1076
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
@@ -1241,11 +1263,34 @@ module Permittable
|
|
|
1241
1263
|
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
1242
1264
|
|
|
1243
1265
|
status, value = cast(type, member)
|
|
1244
|
-
return [status, value] unless status == :ok && type
|
|
1266
|
+
return [status, value] unless status == :ok && finer_than_nanosecond?(type, member, value)
|
|
1245
1267
|
|
|
1246
1268
|
[:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
|
|
1247
1269
|
end
|
|
1248
1270
|
|
|
1271
|
+
# Whether `raw`, authored as a Time-like `in:` member of a :datetime
|
|
1272
|
+
# field and cast to `cast_value`, holds more than the nine digits the
|
|
1273
|
+
# export prints. A String `in:` member is exempt: it is cast exactly as a
|
|
1274
|
+
# request sending it is, and published as written (see
|
|
1275
|
+
# published_in_member), so whatever precision it spells is reachable.
|
|
1276
|
+
def finer_than_nanosecond?(type, raw, cast_value)
|
|
1277
|
+
type == :datetime && !raw.is_a?(String) && !whole_nanoseconds?(cast_value)
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
# An authored `default:` or `example:` is held to the same rule as an
|
|
1281
|
+
# `in:` member: the exported schema prints it to nine digits, so a value
|
|
1282
|
+
# finer than that was published as a different instant than the action
|
|
1283
|
+
# got for an omitted field, and a client sending the published default
|
|
1284
|
+
# was not sending the default. Unlike an `in:` member, a String is NOT
|
|
1285
|
+
# exempt here: a default or example is stored as the cast Time and
|
|
1286
|
+
# exported from that, never as written, so "...00.1234567891Z" printed
|
|
1287
|
+
# as .123456789Z. `outcome` is the cast's [status, value].
|
|
1288
|
+
def refuse_finer_than_nanosecond(type, outcome)
|
|
1289
|
+
return outcome unless outcome.first == :ok && type == :datetime && !whole_nanoseconds?(outcome.last)
|
|
1290
|
+
|
|
1291
|
+
[:error, "finer than a nanosecond, so the export publishes a different instant; round it, e.g. with .round(9)"]
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1249
1294
|
# A request's fraction of a second is a finite decimal, and the export
|
|
1250
1295
|
# prints a member to at most nine digits, so a member that is not a whole
|
|
1251
1296
|
# number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
|
|
@@ -1267,7 +1312,7 @@ module Permittable
|
|
|
1267
1312
|
# class-load error a reason where a request gets `invalid_type`.
|
|
1268
1313
|
def instant_as_date(member)
|
|
1269
1314
|
date = date_of_instant(member)
|
|
1270
|
-
date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
|
|
1315
|
+
date ? [:ok, date] : [:error, "not midnight UTC of an existing day, so it never equals a date"]
|
|
1271
1316
|
end
|
|
1272
1317
|
|
|
1273
1318
|
# What an exported enum lists for one member: the cast value, re-encoded
|
|
@@ -1885,7 +1930,9 @@ module Permittable
|
|
|
1885
1930
|
# proc cannot rewrite the host's own literal.
|
|
1886
1931
|
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1887
1932
|
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1888
|
-
status, result = Coercion.
|
|
1933
|
+
status, result = Coercion.refuse_finer_than_nanosecond(
|
|
1934
|
+
field[:type], Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
|
|
1935
|
+
)
|
|
1889
1936
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1890
1937
|
|
|
1891
1938
|
field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
|
|
@@ -2758,7 +2805,7 @@ module Permittable
|
|
|
2758
2805
|
case field[:kind]
|
|
2759
2806
|
when :scalar
|
|
2760
2807
|
# Already normalized by permittable_normalized, before the absence rule.
|
|
2761
|
-
permittable_check_whole(field,
|
|
2808
|
+
permittable_check_whole(field, permittable_check_scalar(field, value), full, result, violations: violations)
|
|
2762
2809
|
when :json
|
|
2763
2810
|
permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
|
|
2764
2811
|
when :nested
|
|
@@ -2883,7 +2930,18 @@ module Permittable
|
|
|
2883
2930
|
field[:transform] ? field[:transform].call(value) : value
|
|
2884
2931
|
end
|
|
2885
2932
|
|
|
2886
|
-
#
|
|
2933
|
+
# More seams, for the same reader: how a scalar field's value, and an
|
|
2934
|
+
# array element, are cast. A request's are cast as they come; an authored
|
|
2935
|
+
# default: or example: is also held to the nine digits the export prints.
|
|
2936
|
+
def permittable_check_scalar(field, value)
|
|
2937
|
+
Coercion.check_scalar(field, value)
|
|
2938
|
+
end
|
|
2939
|
+
|
|
2940
|
+
def permittable_cast_element(field, element)
|
|
2941
|
+
Coercion.cast(field[:of], element)
|
|
2942
|
+
end
|
|
2943
|
+
|
|
2944
|
+
# A third seam, for the same reader: the array-level `validate:` to run.
|
|
2887
2945
|
def permittable_array_validator(field)
|
|
2888
2946
|
field[:validate]
|
|
2889
2947
|
end
|
|
@@ -2898,7 +2956,7 @@ module Permittable
|
|
|
2898
2956
|
path: path, unknown: unknown, top_level: false, violations: violations)
|
|
2899
2957
|
end
|
|
2900
2958
|
|
|
2901
|
-
status, out =
|
|
2959
|
+
status, out = permittable_cast_element(field, permittable_own(element))
|
|
2902
2960
|
return out if status == :ok
|
|
2903
2961
|
|
|
2904
2962
|
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.13.
|
|
4
|
+
version: 0.13.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
|
+
date: 2026-11-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|