Skip to content

WW-5790 fix(rest): copy only what the body handler changed in the two-phase copy - #2051

Merged
lukaszlenart merged 4 commits into
mainfrom
WW-5790-two-phase-copy-baseline
Oct 23, 2026
Merged

lukaszlenart merged 4 commits into
mainfrom
WW-5790-two-phase-copy-baseline

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Fixes WW-5790

What was wrong

With struts.parameters.requireAnnotations=true, a content-type handler that does not implement AuthorizationAwareContentTypeHandler goes through the two-phase copy in ContentTypeInterceptor: a fresh instance is built with the no-arg constructor, the handler fills it, and every non-null authorized property is copied onto the real target. The copy could not tell a value the body supplied from a value the constructor assigned, so every property the body omitted but the constructor initialised was written over what params had bound or prepare() had loaded. Scalars, constructor-initialised collections (an empty list replaced a populated one) and constructor-initialised nested beans (the recursion reset their leaves) all suffered. Follow-up to #2050, which covered the handlers that never read the body; this one covers XStreamHandler and application-provided handlers.

Change

  • applyTwoPhaseDeserialize builds a second, untouched fresh instance as a baseline.
  • copyAuthorizedProperties takes the baseline and skips a property whose filled value Objects.deepEquals the baseline value. The baseline is threaded through the nested-bean recursion: the baseline's own nested value, else a fresh instance of the nested class, else null, which keeps today's copy-non-null rule for that subtree. Collection, map and array elements are new objects already, so their recursion passes no baseline.
  • The authorization check runs before any copy, unchanged. The copy now writes a subset of what it wrote before; nothing new becomes bindable.
  • No public API or configuration change.

Documented limits

  • A body value equal to the constructor default, sent while the target holds a different value, is not applied. That includes a primitive at its zero value and an empty collection. Mirrors the null ambiguity accepted in WW-5624.
  • Nested beans are compared leaf by leaf, never through their own equals, and against a baseline of the handler's runtime type, so a subtype with extra properties and an entity with an id-only equals both bind correctly.
  • The target's constructor runs twice per request on this path.
  • Non-deterministic defaults (UUID.randomUUID(), Instant.now()) always differ from the baseline and are still copied, as before.

Behaviour changes only when all three hold: requireAnnotations=true, a non-aware handler, and a property with a non-null constructor default. The default handlers are unaffected.

Tests

Eleven tests in ContentTypeInterceptorIntegrationTest on a ModelDriven fixture driven by a scripted non-aware handler: omitted scalar default kept, set scalar applied, omitted collection default kept, omitted nested default kept, partially set nested bean applies only the set leaf, nested type without a no-arg constructor still applies the set leaf, the documented limits pinned by name (body value equal to the default, primitive zero value, empty collection), a nested subtype whose own leaf is applied, and a nested bean whose id-only equals does not hide a changed leaf. Five failed before the change with the constructor value in place of the pre-existing one; the subtype and partial-equals tests came out of the review and failed before their fix.

Verification

🤖 Generated with Claude Code

lukaszlenart and others added 3 commits October 23, 2026 07:36
…-phase copy

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…lass baseline

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
@lukaszlenart
lukaszlenart marked this pull request as ready for review October 23, 2026 06:05
@lukaszlenart
lukaszlenart requested a balanced review from Copilot October 23, 2026 06:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

5 open issues
What changed in this PR

Fixes WW-5790 by preventing ContentTypeInterceptor’s two-phase copy from overwriting pre-existing model values with constructor defaults when the request body omits fields (under struts.parameters.requireAnnotations=true and non-AuthorizationAwareContentTypeHandler handlers).

Changes:

  • Add baseline instance creation in applyTwoPhaseDeserialize and compare against it to copy only body-changed values.
  • Thread baseline through nested-bean recursion while keeping existing authorization checks and deep-copy behavior for collections/maps/arrays.
  • Add integration tests (and fixtures) that cover omitted defaults, nested beans, subtype handling, and documented limits.
File Description
plugins/​rest/​src/​main/​java/​org/​apache/​struts2/​rest/​ContentTypeInterceptor.java Introduces baseline comparison to avoid copying constructor defaults during two-phase deserialization.
plugins/​rest/​src/​test/​java/​org/​apache/​struts2/​rest/​ContentTypeInterceptorIntegrationTest.java Adds WW-5790 regression coverage via a scripted non-aware handler and new model fixtures.
docs/​superpowers/​plans/​2026-10-23-WW-5790-two-phase-copy-baseline.md Adds an implementation plan document describing the approach and steps.

Review effort: Lite

Comment thread plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java Outdated
Comment thread plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java Outdated
…n needs it

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@lukaszlenart
lukaszlenart merged commit c85ec6d into main Oct 23, 2026
8 checks passed
@lukaszlenart
lukaszlenart deleted the WW-5790-two-phase-copy-baseline branch October 23, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants