permittable 0.5.2 → 0.6.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 +18 -0
- data/README.md +75 -4
- data/lib/permittable/generator.rb +8 -5
- data/lib/permittable/json_schema.rb +33 -0
- data/lib/permittable/rspec.rb +7 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +176 -17
- 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: ddca88b2da7672d0995e338ab901188c4ba019f4d5928d7aaf0a5f73a7f8ba9c
|
|
4
|
+
data.tar.gz: 1a66ef88adc7ab843798f25c7a5e3444a3e550795d1353139d52b65e05ac5005
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7288c663fd8a2d740ca1c1e7fe9ad61d50854fa47e0e9c4a75bb0922bd119d7c4ca9fe99b020b6ea03edfb456d0fe88322161d53cc1bdd71afb6b88f6cf6ccda
|
|
7
|
+
data.tar.gz: a9ecd5c620fac068991a0401d76f339163eb20b6bed73ab83a33f5c3895f8bfb82479a86ab9d6385d85321732ce8ebc6c332819b32174bc8d0494c6021d1623f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-09-08)
|
|
4
|
+
<!-- title: nullable fields, :json, and strict dates -->
|
|
5
|
+
|
|
6
|
+
Two gaps in the field vocabulary closed and one guess removed. A contract can now say *clear this column* (`nullable:`) and *this hash has no shape, but it has bounds* (`:json`), and a date string must name the whole date instead of borrowing the missing parts from today. Contracts that use neither new option see only the date-parsing change, which is called out below.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`:json` field type — free-form hashes, the `jsonb` column case.** A `json`/`jsonb` column exists precisely so its contents need no schema, and every other field kind describes a shape. Until now a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it** and the column never saw the data. Strong parameters has always had an answer (`params.permit(metadata: {})`); now so does a contract. `optional :metadata, :json` passes an arbitrary Hash through untouched — keys neither filtered nor cast, nested arrays and mixed scalars intact, `unknown:` deliberately not descending into it, `{}` a value rather than an absence, anything that is not a Hash an `invalid_type`. What it gives up is the shape; what it keeps is every bound worth having: **`length:`** caps the top-level key count, **`max_depth:`** caps container nesting with arrays counting as a level (violation code `depth`), `validate:`/`transform:` see the whole hash, and the field maps onto a column like a scalar does, so the schema-drift guard still catches a dropped `metadata` column. That matters more than it looks — an unbounded `jsonb` column is where clients put megabytes and 200-level-deep objects, and "opaque, but not unlimited" is strictly more than `permit(metadata: {})` can say. Values arrive as plain data, never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe. Exported as `{"type": "object"}` with `minProperties`/`maxProperties`, plus `x-permittable-max-depth` for the nesting bound JSON Schema has no keyword for.
|
|
10
|
+
- **`permittable:generate` now drafts `json`, `jsonb` and `hstore` columns as `:json`** instead of leaving a TODO comment. Columns with no faithful representation at all (`binary`, geometry types) still become TODOs rather than guesses.
|
|
11
|
+
|
|
12
|
+
- **`nullable:` field option — an explicit null is now part of a contract's vocabulary.** One absence rule (`nil` and `""` are both absent) is right for `PATCH` and wrong for the request that means *clear this*; `nullable: true` splits it in two for a single field. A key the client never sent stays **absent** — `default:` applies to it, a `required` field still violates `missing` — but a key sent **empty** (JSON `null`, or `""` from a form) is an explicit null and yields `nil` in the result, **ahead of the field's `default:`**, which is exactly what a `PATCH` clearing a column needs. Nothing is cast or checked for an explicit null: `in:`, `format:`, `length:`, `validate:`, and `transform:` never see a `nil` they didn't agree to handle. `required` + `nullable` reads as it does in SQL (the client must state the field; `null` is a legal statement), `default: nil` — legal only on a nullable field — gives the `PUT` reading where absence also means clear, and on arrays and nested blocks `nullable:` applies to the array or object itself, never its contents (a null *element* is still `invalid_type`). Exported JSON Schema / OpenAPI stays truthful: the field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum` (the one keyword that constrains the instance rather than a type). The RSpec matcher gains a `.nullable` chain, and `default: nil` / `example: nil` on a non-nullable field now fails at class load naming the fix, instead of the confusing `invalid_type`.
|
|
13
|
+
|
|
14
|
+
Contracts that don't opt in are byte-for-byte unaffected: absence keeps its single meaning and nothing new appears in an exported schema.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **`:date` and `:datetime` invented the parts a string left out, from today's date.** Coercion is documented as strict — "a value the type cannot faithfully represent is a violation, not a guess" — but it handed strings straight to `Date.parse`, which fills in what they omit from the current date: `"09/2026"` became the 1st of September, `"5th"` became the 5th of *this* month of *this* year, `"Sept"` became the 1st of September *this* year. The same request therefore meant different things on different days, which is a guess and a non-deterministic one. A `:date` or `:datetime` string must now name all three of year, month and day; which **format** it names them in is still `Date.parse`'s business, so every complete format it understands keeps working (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`, `"5 September 2026"`). A `:datetime` may still omit the **time** part, which reads as midnight UTC as documented, but a string with only a time (`"10:30"`, previously *today* at 10:30) is now `invalid_type`. `Date`, `Time`, `DateTime` and `ActiveSupport::TimeWithZone` objects are unaffected.
|
|
18
|
+
|
|
19
|
+
This is a **behaviour change** for any endpoint that was relying on the fill-in, but the values it produced were not the ones the client meant, and an exported `"format": "date"` already promised RFC 3339 rather than `"5th"`.
|
|
20
|
+
|
|
3
21
|
## 0.5.2 (2026-09-06)
|
|
4
22
|
<!-- title: Railtie coverage and a new README -->
|
|
5
23
|
|
data/README.md
CHANGED
|
@@ -179,7 +179,9 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
179
179
|
- [The field DSL](#the-field-dsl)
|
|
180
180
|
- [Field options](#field-options)
|
|
181
181
|
- [Types and strict coercion](#types-and-strict-coercion)
|
|
182
|
+
- [Free-form hashes](#free-form-hashes-json)
|
|
182
183
|
- [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
184
|
+
- [Explicit nulls](#explicit-nulls-nullable)
|
|
183
185
|
- [Violations and error responses](#violations-and-error-responses)
|
|
184
186
|
- [Custom error messages](#custom-error-messages-message) · [Localizing with I18n](#localizing-default-messages-i18n)
|
|
185
187
|
- [Unknown parameters](#unknown-parameters)
|
|
@@ -272,6 +274,9 @@ array :line_items, required: true do
|
|
|
272
274
|
required :sku, :string
|
|
273
275
|
required :quantity, :integer, in: 1..99
|
|
274
276
|
end
|
|
277
|
+
|
|
278
|
+
# Free-form hashes — :json takes any hash, uncast and unfiltered, with bounds
|
|
279
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
275
280
|
```
|
|
276
281
|
|
|
277
282
|
Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
|
|
@@ -293,9 +298,11 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
293
298
|
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
294
299
|
| `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
|
|
295
300
|
| `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
|
|
301
|
+
| `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
|
|
296
302
|
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
297
303
|
| `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
298
304
|
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
|
|
305
|
+
| `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
|
|
299
306
|
|
|
300
307
|
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
301
308
|
|
|
@@ -316,14 +323,46 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
316
323
|
| `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
|
|
317
324
|
| `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
|
|
318
325
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
319
|
-
| `:date` | `Date`; any `Date.parse
|
|
320
|
-
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date
|
|
326
|
+
| `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
|
|
327
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
|
|
328
|
+
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
329
|
+
|
|
330
|
+
**Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC.
|
|
321
331
|
|
|
322
|
-
Two behaviours worth committing to memory:
|
|
332
|
+
Two more behaviours worth committing to memory:
|
|
323
333
|
|
|
324
334
|
- **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
|
|
325
335
|
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
|
|
326
336
|
|
|
337
|
+
### Free-form hashes (`:json`)
|
|
338
|
+
|
|
339
|
+
A `json`/`jsonb` column exists precisely so its contents need no schema. Every other field kind describes a shape, so until `:json` a contract had only bad options for one: declare sub-keys you don't know, or leave the key undeclared — in which case the contract **silently dropped it**, and the column never saw the data. Strong parameters has always had an answer here (`params.permit(metadata: {})`); now so does a contract.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
permit_params :create, root: :user, model: User do
|
|
343
|
+
required :name, :string
|
|
344
|
+
optional :metadata, :json, max_depth: 3, length: 0..32
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The hash passes through **untouched** — keys are neither filtered nor cast, nested arrays and mixed scalars survive, and `unknown:` does not descend into it. `{}` is a value, not an absence. Anything that is not a hash (an array, a string, a number) is `invalid_type`.
|
|
349
|
+
|
|
350
|
+
What you give up is the shape. What you keep:
|
|
351
|
+
|
|
352
|
+
| | |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `length:` | Caps the **top-level key count** — same reading as an array's element count |
|
|
355
|
+
| `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` |
|
|
356
|
+
| `validate:` / `transform:` | See the whole hash, so any check you can write in Ruby still applies |
|
|
357
|
+
| `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 |
|
|
358
|
+
| `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) |
|
|
359
|
+
|
|
360
|
+
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.
|
|
361
|
+
|
|
362
|
+
Values arrive as plain data (`HashWithIndifferentAccess`), never `ActionController::Parameters`, so assigning straight to a `jsonb` attribute is safe.
|
|
363
|
+
|
|
364
|
+
In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{"type": "object"}` plus `minProperties`/`maxProperties`; JSON Schema has no nesting-depth keyword, so `max_depth:` stays visible as `x-permittable-max-depth` rather than being dropped or mistranslated.
|
|
365
|
+
|
|
327
366
|
### Absence, defaults, and partial updates
|
|
328
367
|
|
|
329
368
|
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
|
|
@@ -336,10 +375,39 @@ That single rule produces the behaviour you want from a `PATCH`:
|
|
|
336
375
|
| absent and **required** | a `missing` violation |
|
|
337
376
|
| absent with a **`default:`** | the default — a defaulted field can never report `missing` |
|
|
338
377
|
|
|
339
|
-
Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here,
|
|
378
|
+
Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality. And because absence and `nil` are the same thing here, a plain field cannot clear a column to NULL — declare it [`nullable:`](#explicit-nulls-nullable) when it should.
|
|
340
379
|
|
|
341
380
|
Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
|
|
342
381
|
|
|
382
|
+
### Explicit nulls (`nullable:`)
|
|
383
|
+
|
|
384
|
+
One rule — `nil` and `""` are absent — is right for `PATCH` and wrong for the request that means *clear this*. `nullable: true` splits it in two for a single field:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
permit_params :update, root: :user, model: User do
|
|
388
|
+
optional :nickname, :string, nullable: true
|
|
389
|
+
optional :plan, :string, in: %w[free pro], default: "free", nullable: true
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
| Request | `nickname` in the result |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `{ "user": {} }` | **omitted** — the column is untouched |
|
|
396
|
+
| `{ "user": { "nickname": null } }` | `nil` — the column is cleared |
|
|
397
|
+
| `{ "user": { "nickname": "" } }` | `nil` — the form-encoded spelling of the same intent |
|
|
398
|
+
|
|
399
|
+
A key the client never sent is still **absent**: `default:` applies to it and a `required` field still violates with `missing`. Only *present-but-empty* changes meaning, and it changes it decisively — an explicit null wins over the field's `default:`, which is the behaviour a `PATCH` needs (`{ "plan": null }` clears the plan instead of silently resetting it to `"free"`).
|
|
400
|
+
|
|
401
|
+
Nothing is cast or checked for an explicit null. `in:`, `format:`, `length:`, `validate:`, and `transform:` all see a value or nothing at all — never a `nil` they never agreed to handle.
|
|
402
|
+
|
|
403
|
+
Three more readings worth knowing:
|
|
404
|
+
|
|
405
|
+
- **`required` + `nullable`** is coherent, and means what it says in SQL: the client *must* state the field, and `null` is a legal statement. A missing key still violates.
|
|
406
|
+
- **`default: nil`** — legal only on a nullable field — gives the `PUT` reading, where absence *also* means clear.
|
|
407
|
+
- **On arrays and nested blocks**, `nullable:` applies to the array or object itself, never to its contents. `{ "tags": null }` yields `nil` (distinct from `[]`, which still gets length-checked); a null *element* inside `tags` is still `invalid_type`.
|
|
408
|
+
|
|
409
|
+
Exported [OpenAPI](#exporting-openapi-docs-that-cannot-drift) tells the truth about all of this: a nullable field's `type` gains `"null"`, and a nullable `in:` set lists `null` in its `enum`.
|
|
410
|
+
|
|
343
411
|
### Violations and error responses
|
|
344
412
|
|
|
345
413
|
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).
|
|
@@ -740,6 +808,9 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
740
808
|
- `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
|
|
741
809
|
- `validate:` or `transform:` that isn't callable
|
|
742
810
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
|
|
811
|
+
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
812
|
+
- A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
|
|
813
|
+
- A `max_depth:` that isn't a positive Integer
|
|
743
814
|
- `required: true` combined with `default:`
|
|
744
815
|
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
745
816
|
- An empty contract, or a nested block declaring no sub-fields
|
|
@@ -19,14 +19,17 @@ module Permittable
|
|
|
19
19
|
DEFAULT_ACTIONS = %i[create update].freeze
|
|
20
20
|
SKIPPED_COLUMNS = %w[created_at updated_at].freeze
|
|
21
21
|
|
|
22
|
-
# Column type => contract type.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# Column type => contract type. Document-shaped columns map onto the
|
|
23
|
+
# opaque `:json` field — the shape stays undeclared, which is what a
|
|
24
|
+
# jsonb column is for, and `max_depth:`/`length:` can bound it later.
|
|
25
|
+
# Anything absent here (binary, geometry, ...) has no faithful
|
|
26
|
+
# representation and becomes a TODO comment rather than a guess.
|
|
25
27
|
COLUMN_TYPES = {
|
|
26
28
|
string: :string, text: :string, citext: :string, uuid: :string,
|
|
27
29
|
integer: :integer, bigint: :integer, float: :float, decimal: :decimal,
|
|
28
30
|
boolean: :boolean, date: :date, datetime: :datetime,
|
|
29
|
-
timestamp: :datetime, timestamptz: :datetime
|
|
31
|
+
timestamp: :datetime, timestamptz: :datetime,
|
|
32
|
+
json: :json, jsonb: :json, hstore: :json
|
|
30
33
|
}.freeze
|
|
31
34
|
|
|
32
35
|
# What a source scan recovered from existing permit calls. `scalars` are
|
|
@@ -168,7 +171,7 @@ module Permittable
|
|
|
168
171
|
|
|
169
172
|
def column_line(column)
|
|
170
173
|
type = COLUMN_TYPES[column.type]
|
|
171
|
-
return "# TODO: #{column.name} (#{column.type}) has no
|
|
174
|
+
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
172
175
|
|
|
173
176
|
line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
|
|
174
177
|
line += " # database default: #{column.default.inspect}" unless column.default.nil?
|
|
@@ -75,12 +75,29 @@ module Permittable
|
|
|
75
75
|
def field(field, unknown: :ignore)
|
|
76
76
|
schema = case field[:kind]
|
|
77
77
|
when :scalar then scalar_schema(field)
|
|
78
|
+
when :json then opaque_schema(field)
|
|
78
79
|
when :nested then object(field[:fields], unknown: unknown)
|
|
79
80
|
when :array then array_schema(field, unknown: unknown)
|
|
80
81
|
end
|
|
82
|
+
nullify!(schema, field)
|
|
81
83
|
annotate(schema, field)
|
|
82
84
|
end
|
|
83
85
|
|
|
86
|
+
# `nullable: true` means an explicitly-sent empty value yields null, so
|
|
87
|
+
# the type gains "null". Assigning over the existing key keeps its
|
|
88
|
+
# position, preserving deterministic emission. `enum` is the one keyword
|
|
89
|
+
# that constrains the instance rather than one type (minLength, pattern,
|
|
90
|
+
# minimum and friends only apply to instances of their own type), so a
|
|
91
|
+
# nullable enum has to list null itself or it would reject the very null
|
|
92
|
+
# the type now permits.
|
|
93
|
+
def nullify!(schema, field)
|
|
94
|
+
return schema unless field[:nullable]
|
|
95
|
+
|
|
96
|
+
schema["type"] = Array(schema["type"]) + ["null"] if schema["type"]
|
|
97
|
+
schema["enum"] += [nil] if schema.key?("enum")
|
|
98
|
+
schema
|
|
99
|
+
end
|
|
100
|
+
|
|
84
101
|
def scalar_schema(field)
|
|
85
102
|
schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
|
|
86
103
|
apply_in!(schema, field[:in])
|
|
@@ -89,6 +106,19 @@ module Permittable
|
|
|
89
106
|
schema
|
|
90
107
|
end
|
|
91
108
|
|
|
109
|
+
# A `:json` field's shape is deliberately undeclared, so the schema says
|
|
110
|
+
# "an object" and carries only the bounds the field does declare. JSON
|
|
111
|
+
# Schema has no nesting-depth keyword, so `max_depth:` stays visible as an
|
|
112
|
+
# extension rather than being dropped or mistranslated.
|
|
113
|
+
def opaque_schema(field)
|
|
114
|
+
schema = { "type" => "object" }
|
|
115
|
+
min, max = length_bounds(field[:length])
|
|
116
|
+
schema["minProperties"] = min if min
|
|
117
|
+
schema["maxProperties"] = max if max
|
|
118
|
+
schema["x-permittable-max-depth"] = field[:max_depth] if field[:max_depth]
|
|
119
|
+
schema
|
|
120
|
+
end
|
|
121
|
+
|
|
92
122
|
def array_schema(field, unknown:)
|
|
93
123
|
schema = { "type" => "array" }
|
|
94
124
|
min, max = length_bounds(field[:length])
|
|
@@ -183,6 +213,9 @@ module Permittable
|
|
|
183
213
|
def json_value(value)
|
|
184
214
|
case value
|
|
185
215
|
when Array then value.map { |v| json_value(v) }
|
|
216
|
+
# An authored `:json` default/example is a whole hash; its values get
|
|
217
|
+
# the same re-encoding as any other authored scalar.
|
|
218
|
+
when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] }
|
|
186
219
|
when BigDecimal then value.to_s("F")
|
|
187
220
|
when Time then value.utc.iso8601
|
|
188
221
|
# DateTime subclasses Date, so it must match first.
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -91,6 +91,11 @@ module Permittable
|
|
|
91
91
|
self
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
def nullable
|
|
95
|
+
@expected[:nullable] = true
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
94
99
|
# -- RSpec protocol ---------------------------------------------------
|
|
95
100
|
|
|
96
101
|
def matches?(subject)
|
|
@@ -191,7 +196,7 @@ module Permittable
|
|
|
191
196
|
when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
|
|
192
197
|
when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
|
|
193
198
|
when :required then required_mismatch(field, value)
|
|
194
|
-
when :virtual, :sensitive then "expected the field to be #{key}, but it is not" unless field[key]
|
|
199
|
+
when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
|
|
195
200
|
else option_mismatch(field, key, value)
|
|
196
201
|
end
|
|
197
202
|
end
|
|
@@ -226,7 +231,7 @@ module Permittable
|
|
|
226
231
|
when :array then "as an array"
|
|
227
232
|
when :of then "of :#{value}"
|
|
228
233
|
when :required then value ? "required" : "optional"
|
|
229
|
-
when :virtual, :sensitive then key.to_s
|
|
234
|
+
when :virtual, :sensitive, :nullable then key.to_s
|
|
230
235
|
else "#{OPTION_LABELS.fetch(key)} #{value.inspect}"
|
|
231
236
|
end
|
|
232
237
|
end
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -34,6 +34,7 @@ require "permittable/filter_parameter_registry"
|
|
|
34
34
|
# optional :ssn, :string, sensitive: true
|
|
35
35
|
# optional :plan, :string, in: %w[free pro], default: "free"
|
|
36
36
|
# array :tag_names, of: :string, length: 0..10, virtual: true
|
|
37
|
+
# optional :metadata, :json, max_depth: 3, length: 0..32
|
|
37
38
|
# optional :address do
|
|
38
39
|
# required :city, :string
|
|
39
40
|
# optional :zip, :string, format: /\A\d{5}\z/
|
|
@@ -79,6 +80,17 @@ require "permittable/filter_parameter_registry"
|
|
|
79
80
|
# request. `permittable_violations` reads the recorded details ([] when
|
|
80
81
|
# the request was clean).
|
|
81
82
|
#
|
|
83
|
+
# THE :json FIELD — the deliberate hole. A json/jsonb column exists precisely
|
|
84
|
+
# so its contents need no schema, and until it was declarable a contract could
|
|
85
|
+
# only drop that key (strong parameters spells it `permit(metadata: {})`).
|
|
86
|
+
# `optional :metadata, :json` passes an arbitrary Hash through untouched —
|
|
87
|
+
# keys are neither filtered nor cast, and `unknown:` does not descend into it
|
|
88
|
+
# — while still letting the contract bound the shape it refuses to describe:
|
|
89
|
+
# `length:` caps the top-level key count, `max_depth:` caps container nesting
|
|
90
|
+
# (arrays count as a level), and `validate:`/`transform:` see the whole hash.
|
|
91
|
+
# Anything that is not a Hash is `invalid_type`, and the field still maps onto
|
|
92
|
+
# a column for the drift guard.
|
|
93
|
+
#
|
|
82
94
|
# Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
|
|
83
95
|
# casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
|
|
84
96
|
# and silently corrupting untrusted input is exactly what a contract must not
|
|
@@ -86,8 +98,16 @@ require "permittable/filter_parameter_registry"
|
|
|
86
98
|
# guess. nil and "" are both treated as ABSENT (the query-param convention):
|
|
87
99
|
# absent optional fields are OMITTED from the result (so partial updates never
|
|
88
100
|
# nil-out columns), absent required fields violate, and `default:` fills
|
|
89
|
-
# absence.
|
|
90
|
-
#
|
|
101
|
+
# absence.
|
|
102
|
+
#
|
|
103
|
+
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
104
|
+
# clears a column: a key the client never sent stays absent (defaults apply,
|
|
105
|
+
# required violates), but a key sent EMPTY (JSON null, or "" from a form) is an
|
|
106
|
+
# explicit null and yields nil in the result — ahead of any `default:`, and
|
|
107
|
+
# without casting or checking a value that isn't there. It reads on arrays and
|
|
108
|
+
# nested blocks too (the array/object itself may be null, never its elements),
|
|
109
|
+
# and `default: nil` — legal only on a nullable field — gives the PUT reading
|
|
110
|
+
# where absence also means clear.
|
|
91
111
|
#
|
|
92
112
|
# Failures raise Permittable::InvalidParameters, rescued (on a real
|
|
93
113
|
# controller) into the shared ErrorEnvelope shape with `details:` entries of
|
|
@@ -134,6 +154,9 @@ module Permittable
|
|
|
134
154
|
|
|
135
155
|
LABEL = "Permittable".freeze
|
|
136
156
|
SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
|
|
157
|
+
# Not a scalar: an opaque hash whose shape is deliberately undeclared, for
|
|
158
|
+
# the json/jsonb column a contract has to be able to carry.
|
|
159
|
+
JSON_TYPE = :json
|
|
137
160
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
138
161
|
MODES = %i[enforce monitor].freeze
|
|
139
162
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
@@ -245,6 +268,31 @@ module Permittable
|
|
|
245
268
|
check_custom(field[:validate], value)
|
|
246
269
|
end
|
|
247
270
|
|
|
271
|
+
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
272
|
+
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
273
|
+
# top-level key count, same reading as an array's element count) and
|
|
274
|
+
# nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
|
|
275
|
+
# checking, like check_scalar.
|
|
276
|
+
def check_json(field, value)
|
|
277
|
+
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
278
|
+
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
279
|
+
return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
|
|
280
|
+
|
|
281
|
+
check_custom(field[:validate], value)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Container nesting, with the field's own hash as level 1. An Array counts
|
|
285
|
+
# as a level too — a deeply nested payload is a deeply nested payload
|
|
286
|
+
# whichever container carries it. Bails at the first breach instead of
|
|
287
|
+
# measuring the whole tree.
|
|
288
|
+
def depth_exceeds?(value, limit)
|
|
289
|
+
return false unless value.is_a?(Hash) || value.is_a?(Array)
|
|
290
|
+
return true if limit < 1
|
|
291
|
+
|
|
292
|
+
children = value.is_a?(Hash) ? value.each_value : value.each
|
|
293
|
+
children.any? { |child| depth_exceeds?(child, limit - 1) }
|
|
294
|
+
end
|
|
295
|
+
|
|
248
296
|
# A custom validator returning a Symbol fails with that symbol as the
|
|
249
297
|
# violation code; false/nil fails as "invalid"; any other truthy value
|
|
250
298
|
# passes.
|
|
@@ -318,16 +366,37 @@ module Permittable
|
|
|
318
366
|
[:error, "invalid_type"]
|
|
319
367
|
end
|
|
320
368
|
|
|
369
|
+
# Date.parse fills in what a string omits FROM TODAY: "09/2026" becomes
|
|
370
|
+
# the 1st, "5th" becomes this month of this year. That is a guess, and a
|
|
371
|
+
# non-deterministic one — the same request means different things on
|
|
372
|
+
# different days — which is exactly what this coercion exists to refuse.
|
|
373
|
+
# So the string must name all three parts; which format it names them in
|
|
374
|
+
# is Date.parse's business, and every complete format it understands
|
|
375
|
+
# ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
|
|
321
376
|
def cast_date(value)
|
|
322
377
|
case value
|
|
323
378
|
when Date then [:ok, value]
|
|
324
|
-
when String
|
|
379
|
+
when String
|
|
380
|
+
found = Date._parse(value)
|
|
381
|
+
return [:error, "invalid_type"] unless complete_date?(found)
|
|
382
|
+
|
|
383
|
+
# Built from the components rather than re-running Date.parse, which
|
|
384
|
+
# would parse the same string a second time — and Date.parse is the
|
|
385
|
+
# expensive half. Date.new applies the same calendar validation, so
|
|
386
|
+
# "2026-02-30" still fails.
|
|
387
|
+
[:ok, Date.new(found[:year], found[:mon], found[:mday])]
|
|
325
388
|
else [:error, "invalid_type"]
|
|
326
389
|
end
|
|
327
390
|
rescue ArgumentError, RangeError
|
|
328
391
|
[:error, "invalid_type"]
|
|
329
392
|
end
|
|
330
393
|
|
|
394
|
+
# Date._parse is the layer under Date.parse, and reports which components
|
|
395
|
+
# it actually FOUND rather than the filled-in result.
|
|
396
|
+
def complete_date?(found)
|
|
397
|
+
found.key?(:year) && found.key?(:mon) && found.key?(:mday)
|
|
398
|
+
end
|
|
399
|
+
|
|
331
400
|
# A zoneless String parses as UTC regardless of the host timezone
|
|
332
401
|
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
333
402
|
def cast_datetime(value)
|
|
@@ -335,7 +404,17 @@ module Permittable
|
|
|
335
404
|
# DateTime is listed here, ahead of Date, because it subclasses Date.
|
|
336
405
|
when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
|
|
337
406
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
338
|
-
when String
|
|
407
|
+
when String
|
|
408
|
+
# Same rule as :date — the DATE part must be named in full, or it is
|
|
409
|
+
# taken from today ("10:30" meant today at 10:30). An absent TIME part
|
|
410
|
+
# is fine and means midnight, which is the documented reading of a
|
|
411
|
+
# date given to a :datetime field.
|
|
412
|
+
#
|
|
413
|
+
# Unlike :date this still parses twice, deliberately: rebuilding a
|
|
414
|
+
# Time from components would have to reimplement DateTime.parse's
|
|
415
|
+
# handling of offsets, zone names and sub-second precision, and
|
|
416
|
+
# getting that subtly wrong costs more than the parse.
|
|
417
|
+
complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
|
|
339
418
|
else [:error, "invalid_type"]
|
|
340
419
|
end
|
|
341
420
|
rescue ArgumentError, RangeError
|
|
@@ -365,9 +444,13 @@ module Permittable
|
|
|
365
444
|
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
366
445
|
# should fail at class load, not at request time.
|
|
367
446
|
class ContractBuilder
|
|
368
|
-
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
369
|
-
|
|
370
|
-
|
|
447
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message desc example
|
|
448
|
+
nullable].freeze
|
|
449
|
+
NESTED_OPTS = %i[virtual sensitive message desc nullable].freeze
|
|
450
|
+
JSON_OPTS = %i[length max_depth default validate virtual sensitive transform message desc example
|
|
451
|
+
nullable].freeze
|
|
452
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
|
|
453
|
+
nullable].freeze
|
|
371
454
|
|
|
372
455
|
attr_reader :finalizer
|
|
373
456
|
|
|
@@ -437,6 +520,12 @@ module Permittable
|
|
|
437
520
|
field = { name: name, kind: :nested, required: required,
|
|
438
521
|
fields: nested_fields!(name, &block), **opts }
|
|
439
522
|
validate_message!(field)
|
|
523
|
+
elsif type&.to_sym == JSON_TYPE
|
|
524
|
+
assert_opts!(name, opts, JSON_OPTS)
|
|
525
|
+
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
526
|
+
# chain and the same error wording work as for a scalar.
|
|
527
|
+
field = { name: name, kind: :json, required: required, type: JSON_TYPE, **opts }
|
|
528
|
+
validate_json_opts!(field)
|
|
440
529
|
else
|
|
441
530
|
assert_opts!(name, opts, SCALAR_OPTS)
|
|
442
531
|
field = { name: name, kind: :scalar, required: required,
|
|
@@ -500,6 +589,40 @@ module Permittable
|
|
|
500
589
|
validate_message!(field)
|
|
501
590
|
end
|
|
502
591
|
|
|
592
|
+
def validate_json_opts!(field)
|
|
593
|
+
name = field[:name]
|
|
594
|
+
if field[:required] && field.key?(:default)
|
|
595
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
validate_length!(name, field[:length]) if field.key?(:length)
|
|
599
|
+
validate_max_depth!(name, field[:max_depth]) if field.key?(:max_depth)
|
|
600
|
+
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
601
|
+
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
602
|
+
validate_json_authored_value!(field, :default)
|
|
603
|
+
validate_json_authored_value!(field, :example)
|
|
604
|
+
validate_message!(field)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def validate_max_depth!(name, depth)
|
|
608
|
+
return if depth.is_a?(Integer) && depth.positive?
|
|
609
|
+
|
|
610
|
+
raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Same rule as a scalar's authored value, over check_json: a `default:` or
|
|
614
|
+
# `example:` that its own bounds would reject fails at class load.
|
|
615
|
+
def validate_json_authored_value!(field, opt)
|
|
616
|
+
return unless field.key?(opt)
|
|
617
|
+
return if authored_nil!(field, opt)
|
|
618
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
|
|
619
|
+
|
|
620
|
+
status, code = Coercion.check_json(field, field[opt])
|
|
621
|
+
return if status == :ok
|
|
622
|
+
|
|
623
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
|
|
624
|
+
end
|
|
625
|
+
|
|
503
626
|
# format / length / normalize reason about characters; on any other
|
|
504
627
|
# type they would silently apply to a cast non-String and mislead.
|
|
505
628
|
def validate_string_only_opts!(field)
|
|
@@ -540,6 +663,7 @@ module Permittable
|
|
|
540
663
|
# shipping it to every request (or publishing it in generated docs).
|
|
541
664
|
def validate_authored_value!(field, opt)
|
|
542
665
|
return unless field.key?(opt)
|
|
666
|
+
return if authored_nil!(field, opt)
|
|
543
667
|
|
|
544
668
|
status, code = Coercion.check_scalar(field, field[opt])
|
|
545
669
|
return if status == :ok
|
|
@@ -549,6 +673,7 @@ module Permittable
|
|
|
549
673
|
|
|
550
674
|
def validate_array_authored_value!(field, opt)
|
|
551
675
|
value = field[opt]
|
|
676
|
+
return if authored_nil!(field, opt)
|
|
552
677
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
553
678
|
return unless field[:of]
|
|
554
679
|
|
|
@@ -560,6 +685,19 @@ module Permittable
|
|
|
560
685
|
end
|
|
561
686
|
end
|
|
562
687
|
|
|
688
|
+
# An authored nil is only meaningful on a nullable field, where it says
|
|
689
|
+
# "absent means clear" (PUT semantics) rather than "no default". On any
|
|
690
|
+
# other field it is a value nil could never satisfy, so it fails at class
|
|
691
|
+
# load with the fix named.
|
|
692
|
+
def authored_nil!(field, opt)
|
|
693
|
+
return false unless field[opt].nil?
|
|
694
|
+
return true if field[:nullable]
|
|
695
|
+
|
|
696
|
+
raise ArgumentError,
|
|
697
|
+
"#{LABEL}: :#{opt} for field :#{field[:name]} is nil but the field is not nullable — " \
|
|
698
|
+
"declare nullable: true to make an explicit null part of the contract"
|
|
699
|
+
end
|
|
700
|
+
|
|
563
701
|
# `message:` customizes what the client reads for a violation on this
|
|
564
702
|
# field: one String covering every code, or a Hash of code => String
|
|
565
703
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -694,9 +832,10 @@ module Permittable
|
|
|
694
832
|
end
|
|
695
833
|
|
|
696
834
|
# The drift guard. Nested/array fields are implicitly virtual — only
|
|
697
|
-
# scalar fields
|
|
835
|
+
# scalar fields, and the opaque `:json` field standing in for a
|
|
836
|
+
# json/jsonb column, map one-to-one onto columns.
|
|
698
837
|
def guard_contract_columns!(model_class, fields)
|
|
699
|
-
checked = fields.select { |f| f[:kind]
|
|
838
|
+
checked = fields.select { |f| %i[scalar json].include?(f[:kind]) && !f[:virtual] }
|
|
700
839
|
return if checked.empty?
|
|
701
840
|
|
|
702
841
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
@@ -903,7 +1042,9 @@ module Permittable
|
|
|
903
1042
|
value = hash[key]
|
|
904
1043
|
|
|
905
1044
|
if permittable_absent?(value, hash, key)
|
|
906
|
-
if field
|
|
1045
|
+
if permittable_explicit_null?(field, hash, key)
|
|
1046
|
+
result[key] = nil
|
|
1047
|
+
elsif field.key?(:default)
|
|
907
1048
|
result[key] = field[:default]
|
|
908
1049
|
elsif field[:required]
|
|
909
1050
|
violations << permittable_violation(field, full, "missing")
|
|
@@ -921,13 +1062,9 @@ module Permittable
|
|
|
921
1062
|
key = field[:name].to_s
|
|
922
1063
|
case field[:kind]
|
|
923
1064
|
when :scalar
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
result[key] = out
|
|
928
|
-
else
|
|
929
|
-
violations << permittable_violation(field, full, out)
|
|
930
|
-
end
|
|
1065
|
+
permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
|
|
1066
|
+
when :json
|
|
1067
|
+
permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
|
|
931
1068
|
when :nested
|
|
932
1069
|
if value.is_a?(Hash)
|
|
933
1070
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
@@ -944,6 +1081,19 @@ module Permittable
|
|
|
944
1081
|
end
|
|
945
1082
|
end
|
|
946
1083
|
|
|
1084
|
+
# The shared tail of the two kinds whose entire value is checked in one
|
|
1085
|
+
# call — a scalar, or an opaque hash. A clean value is transformed into the
|
|
1086
|
+
# result; anything else records its code.
|
|
1087
|
+
def permittable_check_whole(field, outcome, full, result, violations:)
|
|
1088
|
+
status, out = outcome
|
|
1089
|
+
if status == :ok
|
|
1090
|
+
out = field[:transform].call(out) if field[:transform]
|
|
1091
|
+
result[field[:name].to_s] = out
|
|
1092
|
+
else
|
|
1093
|
+
violations << permittable_violation(field, full, out)
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
947
1097
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
948
1098
|
before = violations.length
|
|
949
1099
|
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
@@ -982,6 +1132,15 @@ module Permittable
|
|
|
982
1132
|
!hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
|
|
983
1133
|
end
|
|
984
1134
|
|
|
1135
|
+
# `nullable: true` splits the one absence rule in two: a key the client
|
|
1136
|
+
# never sent is still absent (defaults apply, required violates), but a key
|
|
1137
|
+
# sent EMPTY is an explicit null — the field yields nil, so a PATCH can
|
|
1138
|
+
# clear a column. Nothing is cast or checked: there is no value to check,
|
|
1139
|
+
# and `transform:` never sees a nil it did not agree to.
|
|
1140
|
+
def permittable_explicit_null?(field, hash, key)
|
|
1141
|
+
field[:nullable] && hash.key?(key)
|
|
1142
|
+
end
|
|
1143
|
+
|
|
985
1144
|
def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
|
|
986
1145
|
return if unknown == :ignore
|
|
987
1146
|
|
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.6.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-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|