Hand out defaults cast and owned, and never alias request strings - #62
Merged
Merged
Conversation
An authored default: was validated by casting and then stored in its authored form, so `default: "18"` on an :integer yielded "18" (and the JSON Schema published "type": "integer", "default": "18"), and a :boolean default of "false" was truthy. Store the cast value — for of: elements and block-array scalar sub-fields too — and the same for example:, which shares the check. Defaults were handed out with frozen Strings inside (only a top-level String was copied), so editing an array element or a :json default raised FrozenError. Each request now gets a deep copy. cast_string returned the caller's String, so mutating the result or a mutating transform: rewrote params. Copy it, and deep-copy a :json value before validate:/transform: see it. Monitor mode's pass-through is left as-is: it promises the pre-contract app's params, by reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
…way in Review follow-ups for #62: - Copy a request's String at the walker's input, before normalize:, instead of in cast_string, so a mutating normalize: proc cannot rewrite params either and each String is copied once. - Read an array's authored default:/example: with the request walker itself (permittable_check_array), minus transform:, so nested values are cast, nullable "" becomes nil, sub-defaults are filled in, undeclared keys are dropped and the array's validate: runs. - Never run transform: on a default, and say so precisely. - Deep-copy a :json value only after its bounds pass. - Export an exact BigDecimal default/example as a JSON number. - Cast with_default's argument before comparing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Addressed the review in 0e03c37 (after merging master):
|
…port edges
Final regression pass on the "values the app owns" PR fixes five findings:
- A field declaring transform: now stores its default: exactly as authored
(deep-frozen, still copied per request), instead of cast against its
declared type. Casting it silently broke the README's own advice to author
such a default already in the shape transform: would produce
(transform: ->(v) { v.to_i }, default: 25 on a :string field used to hand
out Integer 25; this PR's earlier fix cast it to "25"). A field with no
transform: is unaffected and keeps the cast-and-normalized storage.
- JsonSchema.apply_in! applies the same numeric-vs-string rule to a :decimal
field's enum (in:) that default:/example: already use, so a numerically
exported default stays a member of its own enum's exported list.
- PermitParamMatcher#cast_default copies its argument (a String) before
normalizing, matching validate_authored_value!'s own copy-first discipline,
so a mutating normalize: proc can't raise FrozenError or silently rewrite
the spec's own literal. It also compares a transform: field's default bare
rather than cast, matching the new storage rule above.
- The :decimal numeric-export rule in JsonSchema#annotate is now scoped to a
field's own kind and never recurses into a :json field's opaque contents,
which stay untyped: a BigDecimal inside a :json default renders as a
String via to_s("F") as before, rather than becoming a JSON number outside
any :decimal schema's justification, or Float::INFINITY (which crashes
JSON.generate) for a non-finite one.
- Coercion.cast_string renders a BigDecimal with to_s("F") instead of the
generic Numeric#to_s, so a BigDecimal default:/example: on a plain :string
field publishes as "1.5" rather than the scientific "0.15e1" BigDecimal#to_s
gives by default. This one is masked in the main spec suite because
active_record (required by spec_helper.rb) pulls in
active_support/core_ext/big_decimal/conversions, which patches that
default away — regression coverage lives in
spec/support/runtime_deps_smoke.rb, the one place that runs without it.
TDD throughout: each finding got a failing spec first. 661 examples (was
655), 0 failures; RuboCop clean. CHANGELOG.md and README.md updated to
describe the transform:-vs-cast split and the :json-scoped export rule
precisely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Owner
Author
|
Pushed 206cf7e — final regression pass, 5 findings, TDD (failing specs first):
661 examples (was 655), 0 failures. RuboCop clean. CHANGELOG.md/README.md updated. PR body updated with the details above. Noted a likely overlap with #65 in the PR body: that branch also touches |
Master had moved to include #59-#61, #64 and #66 since this branch's last merge, plus #60 and #65 which both touch json_schema.rb's :decimal/:datetime export and lib/permittable/rspec.rb's `in:`/`default:` matcher chains — real conflicts, not just additive. lib/permittable.rb: kept this branch's permittable_transform seam (AuthoredValues needs it to walk a default without running transform: on it) — same guard (`violations.length == before`) as master's inline form. lib/permittable/json_schema.rb (4 hunks): combined rather than picked a side. - apply_in!'s enum export now uses BOTH #65's `field[:in_published] || allowed` (keeps a :date/:datetime member's authored String form) AND this branch's `decimal: :number` (keeps a :decimal member numeric and consistent with its own default/example export). - kept #60's `apply_normalize!` method (unrelated to this branch) ahead of json_value, whose signature this branch changes to `decimal: :string`. - json_value's Hash/BigDecimal/Time cases: kept this branch's `decimal:` threading for Hash/BigDecimal, and #65's `exact_iso8601` for Time (fixes sub-second Time :in members exporting as whole seconds — this branch's plain `.utc.iso8601` would have regressed that). - kept both decimal_json (this branch) and exact_iso8601 (#65) methods; each is used from the merged json_value body above. Verified with a probe: a :decimal in: list now exports numerically AND agrees with its own numeric default, and a :datetime in: list with sub-second members still exports the authored string via in_published — both at once, which neither branch alone tested. lib/permittable/rspec.rb (2 hunks) and spec/matchers_spec.rb: purely additive — #65's `cast_in`/`same_in?`/`within` alongside this branch's `default_mismatch`/`cast_default`. Combined by keeping both. README.md: combined the `default:` row (this branch, the transform: exception) with the `validate:` row (master, the array-skip-on-failed- validate note) — same table, different rows each PR had touched. Fixed along the way (found while merging, not part of either PR): - spec/permittable_spec.rb: a #61 perf spec asserted `valid_encoding?` is called on the caller's OWN string object, but this branch's permittable_own copies a request's String before Coercion.cast ever sees it (to avoid aliasing params) — so the original object is never touched, though the copy is (correctly) scanned exactly once. Rewrote the spec to count via a shared counter that survives `dup`, so it asserts the real invariant (one scan overall) rather than one specific object's identity. - spec/matchers_spec.rb: my own conflict resolution (both PRs inserted a new `it` block at the same point in the file) left one block's closing `end` missing — a syntax error that silently dropped all 42 examples in this file from the suite total without failing the run. Fixed; the suite total is now 819 (was silently 777). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bugs
1. An authored
default:was stored in its authored form, not cast.validate_authored_value!cast the value to check it, then threw the cast away.The exported JSON Schema said the same thing:
"type": "integer", "default": "18".2. Defaults had frozen Strings inside them. Only a top-level String was copied on the way out. So
permitted_params[:tags].first << "x"on anof: :stringdefault raisedFrozenError, and so did any edit to a String inside a:jsondefault.3. The result shared the request's own Strings.
permitted_params[:name] << "!", or a mutatingtransform:/normalize:proc, rewrote the caller's Hash orActionController::Parameters. The module doc promises "the request'sparamsis never touched".The fix
default:/example:now goes through the request walker itself. The newPermittable::AuthoredValuesrunspermittable_check_arrayat class load, so the stored value is exactly what a request sending it gets:""on a nullable sub-field becomesnildefault:is filled inunknown: :ignoredrops themvalidate:runsThe hand-rolled one-level check this replaces is gone.
permittable_defaultis nowfield[:default].deep_dup.permittable_normalized, beforenormalize:sees the value, and inpermittable_check_elementforof:elements.cast_stringreturns its argument again. A:jsonhash is deep-copied after itslength:/max_depth:bounds pass, and beforevalidate:/transform:see it.Decisions I'd like reviewed
transform:never runs on a default, and — after the third-commit fix below — neither does the cast. A field declaringtransform:stores its default exactly as authored (still validated against the field's own contract at class load, like any default). A field with notransform:keeps the cast-and-normalized storage above. That split exists because casting atransform:field's default against its declared type used to silently fight the README's own advice to author such a default already in the shapetransform:would produce — see finding 1 under "Review follow-ups (third commit)".example:is read the same way. Both go through the same functions. For a block array, this means a sub-field default is filled into the published example too. That is still a valid request payload.New class-load strictness for arrays. A default that the array's own
validate:refuses now fails at class load, which scalars already did. The error names the walker's path and code, for example:default for array :items violates its own contract: items[0].sku (missing). This still runs even for atransform:field's default (to catch a genuine authoring mistake), independently of whether the cast result is the one stored.A
:decimaldefault/example is exported as a JSON number when a Float holds it exactly.BigDecimal(f.to_s) == valuedecides it; otherwise it stays a string. This keepsdefault: 1.5exported as1.5, as it was on master. It also turns an authoredexample: BigDecimal("19.99")from"19.99"into19.99. This applies to a:decimalfield's owndefault:/example:/enum(in:) — the third-commit fix below extends it toenumtoo, so a default stays a member of its own enum's exported list — but never recurses into a:jsonfield's opaque contents, which stay untyped.minimum/maximumkeep their existing encoding. Heads-up for #65: that branch changesin:/enum export too (castingin:members at class load, and re-encoding a:decimalenum as the same stringdefault:used to export) — the two touch the sameapply_in!code and will need reconciling at merge time, independently of whichever branch lands first.with_defaultreads its argument the way the contract reads its default — cast-and-normalized for a field with notransform:, bare for one that has it, matching how the default is now stored either way. The declaration's own spelling passes:with_default("18"),"2026-01-05","false",[{ sku: "a" }], orwith_default(25)against atransform:field's owndefault: 25. So does the stored value, and a failure shows what the spec wrote. It also copies its argument before normalizing (see finding 3 below).Monitor mode's pass-through is left alone. It promises the pre-contract app's params, and
params.permithands those Strings back by reference too. There is a comment at the call site explaining this.Review follow-ups (second commit)
normalize:. There is now one copy per String. Specs cover a Hash and a realActionController::Parameters.transform:never runs on a default, and the docs say so.:jsonis copied only on the:okpath. A spec shows a rejected payload is never copied.with_defaultcasts its argument before comparing.Review follow-ups (third commit)
A final regression pass over the second commit's own fix, five findings, TDD throughout (failing specs first):
transform:field's default changed its type.optional :page_size, :string, transform: ->(v){ v.to_i }, default: 25used to hand out the Integer25when the param was omitted (master's documented "author it in the final shape" behavior); this PR's cast-every-default fix instead cast it against the field's declared:stringtype and handed out"25". Same shape of bug for:date→:datetimetransforms and forof:array element casts. Fixed by storing atransform:field's default as authored (deep-frozen, still copied per request) instead of cast — seevalidate_authored_value!/validate_array_authored_value!inlib/permittable.rb. A field with notransform:is unaffected.:decimaldefault:exported as a number could fall outside its ownenum.in:still exported every BigDecimal member as a String, so a default that round-trips through Float exactly (and now exports as a number) was no longer one of the values its ownenumlists — schema linters reject that.JsonSchema.apply_in!now applies the same numeric-vs-string rule to a:decimalfield'senumthatdefault:/example:already use.with_default's cast could raiseFrozenError, or silently mutate the spec's own literal. It ran the field'snormalize:on the spec's argument directly, unlikevalidate_authored_value!, which copies first specifically so a mutatingnormalize:proc can't rewrite the host's own literal. Fixed by copying (a String only) before normalizing, inPermitParamMatcher#cast_default.:decimalnumeric-export rule leaked into:jsonfield contents. A:jsonfield's default is opaque and meant to pass through untouched, butannotateapplieddecimal: :numberto every default/example regardless of field kind, so aBigDecimalnested inside a:jsondefault was reinterpreted too — a finite one silently became a JSON number outside any:decimalschema's justification, andBigDecimal("Infinity")becameFloat::INFINITY, which then crashesJSON.generate.annotatenow scopes the numeric-export rule to non-:jsonfields; a:jsonfield's default/example always renders aBigDecimalas a String viato_s("F"), finite or not.default:/example:on a plain:stringfield rendered in scientific notation.cast_stringfell through to the genericNumeric#to_s, andBigDecimal#to_sdefaults to engineering notation ("0.15e1"for1.5) — stdlib's own behavior, only masked in this repo's own spec suite becauseactive_record(required byspec_helper.rb) pulls inactive_support/core_ext/big_decimal/conversions, which patches that default to"F"— the same way the 0.8.0TimeWithZoneregression was once masked.cast_stringnow special-casesBigDecimalwithto_s("F")explicitly, so a standalone host (no Rails) gets the same plain rendering. Regression coverage for this one lives inspec/support/runtime_deps_smoke.rb, the one place in the suite that genuinely runs withoutactive_recordloaded; a value-pinning unit spec is also inspec/permittable_spec.rbfor documentation, though it can't turn red in-suite for the reason above.CHANGELOG.md and README.md are updated to describe the
transform:-vs-cast default split, the:json-scoped numeric-export rule, and the enum consistency fix precisely.Verification
🤖 Generated with Claude Code