From 76f3664769fb541fc3a868748301d39d0a8b7b18 Mon Sep 17 00:00:00 2001 From: Sang Date: Mon, 28 Sep 2026 20:19:26 +0700 Subject: [PATCH] Stop an array default's own transform: suppression from leaking to its sub-fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthoredValues, the walker that validates an array field's default:/example: at class load, overrode permittable_transform unconditionally to skip the field's own transform: (its result is discarded anyway — see validate_array_authored_value!). But the override ignored which field it was being asked about, so it also suppressed transform: for every nested sub-field the walk touched. That meant a request omitting the array (falling back to the stored default:) and an equivalent request explicitly sending the same shape silently diverged whenever a sub-field declared transform:: array :line_items, default: [{"price"=>"10.00"}] do optional :price, :decimal, transform: ->(v){ v * 100 } end Omitting line_items yielded price == 10; sending {"price"=>"10.00"} explicitly yielded price == 1000. The exported OpenAPI default, which reads the same field[:default], documented the untransformed value the server never actually produced for equivalent input. The override now compares the field it is asked about against the one default:/example: validation is running for (by identity), so it still suppresses that field's own transform: but runs every nested sub-field's transform: normally, matching what an equivalent request would produce. Co-Authored-By: Claude Sonnet 5 --- lib/permittable.rb | 6 ++++-- lib/permittable/authored_values.rb | 32 ++++++++++++++++++++++-------- spec/permittable_spec.rb | 15 ++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/lib/permittable.rb b/lib/permittable.rb index 745f905..144a62e 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -1561,8 +1561,10 @@ def validate_authored_value!(field, opt) # gets); with one, the array exactly AS AUTHORED — the walker still runs, # so a declaration mistake (an element `validate:` refuses, a sub-field # default out of bounds) still fails at class load, but its cast result - # is discarded rather than stored. `transform:` itself is deliberately - # never run on a default either way — see AuthoredValues. + # is discarded rather than stored. The array's OWN `transform:` is + # deliberately never run on a default either way; a SUB-FIELD's + # `transform:` still runs during that walk, so "the walker's read" above + # really is what an equivalent request produces — see AuthoredValues. def validate_array_authored_value!(field, opt) value = field[opt] return if authored_nil!(field, opt) diff --git a/lib/permittable/authored_values.rb b/lib/permittable/authored_values.rb index c225331..757dd22 100644 --- a/lib/permittable/authored_values.rb +++ b/lib/permittable/authored_values.rb @@ -3,12 +3,11 @@ module Permittable # at class load — the same permittable_check_array a request goes # through, so the two cannot drift apart. Two seams are overridden: # - # * `transform:` is NOT run. It is app code reshaping a value the client - # sent, and a default is stored as the contract reads it, not as the - # app reshapes it — so a contract with transform: hands out its - # default untransformed, exactly as it always has, and nothing of the - # app's runs at class load beyond the `validate:` an authored value was - # already checked with. + # * the field WHOSE default:/example: is being validated does not run + # its own `transform:` — see permittable_transform below for exactly + # which field that is and why. A SUB-FIELD's own transform: still + # runs, so what gets stored is what an equivalent request would + # produce. # * violations carry no `message:` — they become an ArgumentError for # the contract's author, not a response for a client, and I18n may not # be loaded yet. @@ -26,6 +25,7 @@ def self.summary(violations) # [value as a request would get it, violations] def read_array(field, value) violations = [] + @field = field read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations) [read, violations] end @@ -36,8 +36,24 @@ def summary(violations) private - def permittable_transform(_field, value) - value + # Suppresses transform: for the field WHOSE default/example is being + # authored-validated (@field, compared by identity) — never for a + # sub-field nested inside it. A sub-field's own transform: is app code + # too, but it belongs to a DIFFERENT field's contract: an equivalent + # request sending that sub-field's value would run it, so the stored + # default has to match, or an omitted field and an explicitly-sent + # identical value silently diverge (and the exported OpenAPI default, + # which reads this same value, documents one the server never produces). + # + # When @field itself has a transform:, its result is discarded anyway + # (validate_array_authored_value! stores the value AS AUTHORED instead — + # see its comment), so nothing inside its subtree is worth reading + # transformed: suppressing every nested call too keeps that walk free of + # app code, exactly as a scalar default's cast-only check already is. + def permittable_transform(field, value) + return value if @field[:transform] || field.equal?(@field) + + super end def permittable_violation(_field, param, code) diff --git a/spec/permittable_spec.rb b/spec/permittable_spec.rb index 5cc9e93..470c448 100644 --- a/spec/permittable_spec.rb +++ b/spec/permittable_spec.rb @@ -1532,6 +1532,21 @@ def match?(value) expect(calls).to be_empty end + it "runs a sub-field's own transform: over an array default:, matching an equivalent explicit request" do + klass = permittable_class do + permit_params(:create) do + array :line_items, default: [{ "price" => "10.00" }] do + optional :price, :decimal, transform: ->(v) { v * 100 } + end + end + end + defaulted = controller(klass).permitted_params + sent = controller(klass, params: { line_items: [{ price: "10.00" }] }).permitted_params + + expect(defaulted[:line_items].map(&:to_h)).to eq([{ "price" => BigDecimal("1000") }]) + expect(defaulted).to eq(sent) + end + it "casts an authored example: the same way, so docs publish the value a request would carry" do klass = permittable_class do permit_params(:create) do