Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/permittable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 24 additions & 8 deletions lib/permittable/authored_values.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions spec/permittable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading