This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 760164b8a4 REST debug 329 follow-ups: quick wins + revised specs
760164b8a4 is described below

commit 760164b8a4a2d9b5ded799c743d3c8295dae9d3d
Author: James Bognar <[email protected]>
AuthorDate: Sat Aug 15 18:48:01 2026 -0400

    REST debug 329 follow-ups: quick wins + revised specs
    
    - Implement READY-369 (capture caps + complete caching wrappers), READY-371 
(mixin op-logger naming/contract + tests), READY-373 (blast-radius 
migration/javadoc cleanup); archive to finished/.
    - Revise TODO-368 (JUL/Spring control plane), TODO-370 (secret-surface 
hardening; 2 review rounds), and split/revise TODO-372 into 372a/372b per 
adversarial cross-model review.
---
 .../org/apache/juneau/commons/inject/Bean.java     |  18 +-
 .../juneau/commons/inject/BeanInstantiator.java    |   4 +-
 .../apache/juneau/commons/inject/BeanStore.java    |  24 +-
 .../apache/juneau/test/junit/TestBeanStore.java    |   2 +-
 .../rest/child/ChildInheritance_Scalars_Test.java  |   2 +-
 .../juneau/rest/mock/RestDebugCapture_Test.java    | 287 +++++++++++++++++++++
 .../org/apache/juneau/rest/server/RestContext.java |  20 +-
 .../apache/juneau/rest/server/RestOpContext.java   |  34 ++-
 .../org/apache/juneau/rest/server/RestSession.java |  28 +-
 .../server/util/CachingHttpServletRequest.java     |  21 ++
 .../server/util/CachingHttpServletResponse.java    | 119 ++++++---
 .../util/CachingHttpServletRequest_Test.java       | 135 ++++++++++
 .../util/CachingHttpServletResponse_Test.java      | 150 +++++++++++
 src/test/resources/logging.properties              |   4 +-
 14 files changed, 757 insertions(+), 91 deletions(-)

diff --git 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/Bean.java
 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/Bean.java
index f4f99d3f6c..b19ad7848b 100644
--- 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/Bean.java
+++ 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/Bean.java
@@ -30,18 +30,18 @@ import java.lang.annotation.*;
  *
  * <h5 class='figure'>Example</h5>
  * <p class='bcode'>
- *     <jc>// Rest resource that uses a customized call logger.</jc>
+ *     <jc>// Rest resource that uses a customized logger.</jc>
  *     <ja>@Rest</ja>
  *     <jk>public class</jk> MyRest <jk>extends</jk> BasicRestServlet {
  *
  *             <jc>// Option #1:  As a field.</jc>
  *             <ja>@Bean</ja>
- *             CallLogger <jf>myCallLogger</jf> = 
CallLogger.<jsm>create</jsm>().logger(<js>"mylogger"</js>).build();
+ *             RichLogger <jf>myLogger</jf> = 
RichLogger.<jsm>getLogger</jsm>(<js>"mylogger"</js>);
  *
  *             <jc>// Option #2:  As a method.</jc>
  *             <ja>@Bean</ja>
- *             <jk>public</jk> CallLogger myCallLogger() {
- *                     <jk>return</jk> 
CallLogger.<jsm>create</jsm>().logger(<js>"mylogger"</js>).build();
+ *             <jk>public</jk> RichLogger myLogger() {
+ *                     <jk>return</jk> 
RichLogger.<jsm>getLogger</jsm>(<js>"mylogger"</js>);
  *             }
  *     }
  * </p>
@@ -99,7 +99,7 @@ import java.lang.annotation.*;
  * <h5 class='figure'>Example</h5>
  * <p class='bcode'>
  *     <jc>// Fields that get set during initialization based on beans found 
in the bean store.</jc>
- *     <ja>@Bean</ja> CallLogger <jf>callLogger</jf>;
+ *     <ja>@Bean</ja> RichLogger <jf>logger</jf>;
  *     <ja>@Bean</ja> BeanStore <jf>beanStore</jf>;  <jc>// Note that the 
BeanStore itself can be accessed this way.</jc>
  * </p>
  *
@@ -115,7 +115,7 @@ import java.lang.annotation.*;
  * <p>
  *     {@code @Bean} acts as a <i>programmable default</i>, analogous to 
Spring's
  *     <c>&#64;ConditionalOnMissingBean</c>.  When a REST context resolves a 
framework-managed bean
- *     (<c>CallLogger</c>, <c>EncoderSet</c>, <c>SerializerSet</c>, 
<c>ParserSet</c>, <c>ThrownStore</c>,
+ *     (<c>RichLogger</c>, <c>EncoderSet</c>, <c>SerializerSet</c>, 
<c>ParserSet</c>, <c>ThrownStore</c>,
  *     <c>Config</c>, <c>VarResolver</c>, <c>HttpPartSerializer</c>, 
<c>HttpPartParser</c>, etc.), the lookup
  *     walks the following tiers in order, returning the first hit:
  * </p>
@@ -128,9 +128,9 @@ import java.lang.annotation.*;
  *     <li><b>Memoizer-backed framework default</b> — built into the context 
as a default supplier.</li>
  * </ol>
  * <p>
- *     In other words: a Spring <c>&#64;Bean</c> of type <c>CallLogger</c> 
wins over a {@code @Bean CallLogger}
- *     method on the same servlet, which in turn wins over the framework's 
built-in <c>BasicCallLogger</c>.  Non-Spring
- *     deployments have an empty overriding-parent layer, so the chain 
naturally collapses to
+ *     In other words: a Spring <c>&#64;Bean</c> of type <c>RichLogger</c> 
wins over a {@code @Bean RichLogger}
+ *     method on the same servlet, which in turn wins over the framework's 
built-in class-name-based default logger.
+ *     Non-Spring deployments have an empty overriding-parent layer, so the 
chain naturally collapses to
  *     {@code @Bean > default}.
  * </p>
  * <p>
diff --git 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanInstantiator.java
 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanInstantiator.java
index 9ddfd7ae5c..701ad14eeb 100644
--- 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanInstantiator.java
+++ 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanInstantiator.java
@@ -1741,8 +1741,8 @@ public class BeanInstantiator<T> {
                                //   1. The build method's declared return type 
is exactly beanSubType, OR
                                //   2. The runtime instance produced by the 
build method is assignment-compatible with beanSubType.
                                // Case 2 supports the legacy pattern where a 
parent class's Builder.build() declares the parent
-                               // type but constructs a configured subclass at 
runtime (e.g. DebugEnablement.Builder.build() returning
-                               // a BasicDebugEnablement because the builder's 
internal type-binding was preset).
+                               // type but constructs a configured subclass at 
runtime because the builder's internal
+                               // type-binding was preset.
                                // If neither matches, we fall through to 
factory-method / constructor resolution on beanSubType
                                // rather than throwing — that lets standard 
"subclass adds its own constructor" patterns succeed.
                                if (returnType.is(beanSubType.inner()) || 
(builtBean != null && beanSubType.inner().isInstance(builtBean))) {
diff --git 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanStore.java
 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanStore.java
index 3a4ee51465..85bc32b845 100644
--- 
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanStore.java
+++ 
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/BeanStore.java
@@ -196,18 +196,18 @@ public interface BeanStore {
         *
         * <h5 class='section'>Example:</h5>
         * <p class='bjava'>
-        *      <jc>// Filter only</jc>
-        *      
<jv>beanStore</jv>.createBeanFromMethod(CallLogger.<jk>class</jk>, 
<jv>resource</jv>,
-        *              RestContext::isBeanMethod)
-        *              .ifPresent(<jv>creator</jv>::impl);
-        *
-        *      <jc>// Filter + extra bean not yet in the store</jc>
-        *      
<jv>beanStore</jv>.createBeanFromMethod(EncoderSet.<jk>class</jk>, 
<jv>resource</jv>,
-        *              RestContext::isBeanMethod, <jv>builder</jv>)
-        *              .ifPresent(<jv>x</jv> -&gt; 
<jv>builder</jv>.impl(<jv>x</jv>));
-        *
-        *      <jc>// No filter, no extra beans</jc>
-        *      
<jv>beanStore</jv>.createBeanFromMethod(CallLogger.<jk>class</jk>, 
<jv>resource</jv>);
+ *     <jc>// Filter only</jc>
+ *     <jv>beanStore</jv>.createBeanFromMethod(RichLogger.<jk>class</jk>, 
<jv>resource</jv>,
+ *             RestContext::isBeanMethod)
+ *             .ifPresent(<jv>creator</jv>::impl);
+ *
+ *     <jc>// Filter + extra bean not yet in the store</jc>
+ *     <jv>beanStore</jv>.createBeanFromMethod(EncoderSet.<jk>class</jk>, 
<jv>resource</jv>,
+ *             RestContext::isBeanMethod, <jv>builder</jv>)
+ *             .ifPresent(<jv>x</jv> -&gt; <jv>builder</jv>.impl(<jv>x</jv>));
+ *
+ *     <jc>// No filter, no extra beans</jc>
+ *     <jv>beanStore</jv>.createBeanFromMethod(RichLogger.<jk>class</jk>, 
<jv>resource</jv>);
         * </p>
         *
         * @param <T> The bean type.
diff --git 
a/juneau-core/juneau-test/src/main/java/org/apache/juneau/test/junit/TestBeanStore.java
 
b/juneau-core/juneau-test/src/main/java/org/apache/juneau/test/junit/TestBeanStore.java
index bc5a7cea52..43a692fe02 100644
--- 
a/juneau-core/juneau-test/src/main/java/org/apache/juneau/test/junit/TestBeanStore.java
+++ 
b/juneau-core/juneau-test/src/main/java/org/apache/juneau/test/junit/TestBeanStore.java
@@ -43,7 +43,7 @@ import org.apache.juneau.commons.inject.*;
  *     <jc>// Build the overlay.</jc>
  *     <jk>var</jk> <jv>overlay</jv> = <jk>new</jk> TestBeanStore()
  *             .override(MyExternalApi.<jk>class</jk>, mockApi)
- *             .override(CallLogger.<jk>class</jk>, () -&gt; spyLogger);
+ *             .override(RichLogger.<jk>class</jk>, () -&gt; spyLogger);
  *
  *     <jc>// Wire into the SUT.</jc>
  *     <jk>try</jk> (<jv>client</jv> = 
MockRestClient.<jsm>create</jsm>(MyResource.<jk>class</jk>)
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java
index 53e8b27537..120a41d098 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/child/ChildInheritance_Scalars_Test.java
@@ -29,7 +29,7 @@ import org.junit.jupiter.api.*;
 /**
  * Phase 4 &mdash; proves the CHILD-WINS scalar shape for {@code 
@Child(defaultCharset=...)} and
  * {@code @Child(maxInput=...)}, both of which resolve through {@code 
RestContext.mergeReplacedStringAttribute}
- * (last-non-sentinel-wins over the annotation chain), exactly like {@code 
callLogger}/{@code partSerializer}/
+ * (last-non-sentinel-wins over the annotation chain), exactly like {@code 
restDebugFormatter}/{@code partSerializer}/
  * {@code partParser}.
  *
  * <p>
diff --git 
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
index 70627714aa..fe89835d76 100644
--- 
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
+++ 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
@@ -18,10 +18,13 @@ package org.apache.juneau.rest.mock;
 
 import static org.junit.jupiter.api.Assertions.*;
 
+import java.io.*;
 import java.util.logging.*;
 
+import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.commons.logging.*;
 import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.logging.*;
 import org.junit.jupiter.api.*;
 
 /**
@@ -59,6 +62,256 @@ class RestDebugCapture_Test {
        @Rest(path="/mix", mixins=A05_Mixin.class)
        public static class A05_HostResource {}
 
+       /**
+        * A resource that IS its own {@link RestDebugFormatter} 
(highest-precedence resolution path, per
+        * {@code RestContext#getRestDebugFormatter()}) with a capture cap far 
below the 8&nbsp;KB wrapper default.
+        * Proves the cap is honored at <i>capture</i> time (Blocker #3 in the 
TODO-329 retrospective), not just as a
+        * post-hoc truncation-marker computation over already-8KB-capped bytes.
+        */
+       @Rest(path="/cap4")
+       public static class A06_Resource extends BasicRestDebugFormatter {
+               public A06_Resource() {
+                       bodyCap(4);
+               }
+
+               @RestPost(path="/echo")
+               public String echo(RestRequest req) throws IOException {
+                       return req.getContent().asString();
+               }
+       }
+
+       /** Same as {@link A06_Resource}, but with capture fully disabled via 
{@code bodyCap(0)}. */
+       @Rest(path="/cap0")
+       public static class A07_Resource extends BasicRestDebugFormatter {
+               public A07_Resource() {
+                       bodyCap(0);
+               }
+
+               @RestPost(path="/echo")
+               public String echo(RestRequest req) throws IOException {
+                       return req.getContent().asString();
+               }
+       }
+
+       /**
+        * A mixin composed by two independent hosts, used to prove host-level 
cascade (raising the host's own logger
+        * elevates the mixin op) <b>and</b> host isolation (raising one host 
does not leak into the other host's
+        * identically-shaped, identically-named-suffix op logger). TODO-329 
retrospective Should-fix #4.
+        */
+       public static class B_Mixin {
+               @RestGet(path="/who")
+               public String who() {
+                       return "ok";
+               }
+       }
+
+       @Rest(path="/mixHostA", mixins=B_Mixin.class)
+       public static class B_HostA {}
+
+       @Rest(path="/mixHostB", mixins=B_Mixin.class)
+       public static class B_HostB {}
+
+       @Test void 
b01_twoHostsComposingSameMixin_hostLevelCascadesAndHostsAreIsolated() throws 
Exception {
+               try (var ca = 
RichLogger.getLogger(B_HostA.class).captureEvents(Level.FINEST);
+                               var cb = 
RichLogger.getLogger(B_HostB.class).captureEvents(Level.FINEST)) {
+
+                       // .debug() raises ONLY B_HostA's own class logger to 
FINEST for the duration of the call.
+                       var clientA = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(B_HostA.class).debug().build();
+                       var clientB = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(B_HostB.class).build();
+
+                       
clientA.get("/who").run().assertStatus().asCode().is(200);
+                       
clientB.get("/who").run().assertStatus().asCode().is(200);
+
+                       var opA = ca.getRecords().stream()
+                               .filter(r -> (B_HostA.class.getName() + 
".who").equals(r.getLoggerName()))
+                               .findFirst().orElse(null);
+                       assertNotNull(opA, "expected a mixin op-logger record 
named <HostA>.who (host-level cascade)");
+                       assertEquals(Level.FINEST, opA.getLevel());
+
+                       var opB = cb.getRecords().stream()
+                               .filter(r -> (B_HostB.class.getName() + 
".who").equals(r.getLoggerName()))
+                               .findFirst().orElse(null);
+                       assertNotNull(opB, "expected a mixin op-logger record 
named <HostB>.who");
+                       assertEquals(Level.INFO, opB.getLevel(),
+                               "HostB's mixin op logger must NOT inherit 
HostA's elevated level -- hosts composing the same mixin "
+                                       + "must be isolated");
+               }
+       }
+
+       /** Two sibling operations on the same resource, used to prove 
per-operation child-logger elevation. */
+       @Rest(path="/perop")
+       public static class C_Resource {
+               @RestGet(path="/one")
+               public String one() {
+                       return "one";
+               }
+               @RestGet(path="/two")
+               public String two() {
+                       return "two";
+               }
+       }
+
+       @Test void 
c01_perOperationChildLoggerElevation_doesNotAffectSiblingOperation() throws 
Exception {
+               var opOneName = C_Resource.class.getName() + ".one";
+               var opOneLogger = Logger.getLogger(opOneName); // strong local 
ref -- avoids the LogManager weak-ref GC hazard
+               var prevLevel = opOneLogger.getLevel();
+               opOneLogger.setLevel(Level.FINEST);
+               try (var c = 
RichLogger.getLogger(C_Resource.class).captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(C_Resource.class).build();
+
+                       
client.get("/one").run().assertStatus().asCode().is(200);
+                       
client.get("/two").run().assertStatus().asCode().is(200);
+
+                       var recOne = c.getRecords().stream().filter(r -> 
opOneName.equals(r.getLoggerName())).findFirst().orElse(null);
+                       var recTwo = c.getRecords().stream()
+                               .filter(r -> (C_Resource.class.getName() + 
".two").equals(r.getLoggerName()))
+                               .findFirst().orElse(null);
+
+                       assertNotNull(recOne);
+                       assertNotNull(recTwo);
+                       assertEquals(Level.FINEST, recOne.getLevel());
+                       assertEquals(Level.INFO, recTwo.getLevel(),
+                               "sibling operation must not inherit the 
elevated per-op child logger level");
+               } finally {
+                       opOneLogger.setLevel(prevLevel);
+               }
+       }
+
+       /** Used to prove the two-phase pipeline: below {@code FINEST} no 
capture wrapper is installed, so the body
+        * never reaches the rendered record even though the handler still sees 
the full content. */
+       @Rest(path="/twophase")
+       public static class D_Resource {
+               @RestPost(path="/echo")
+               public String echo(RestRequest req) throws IOException {
+                       return req.getContent().asString();
+               }
+       }
+
+       @Test void d01_fineTier_noCaptureWrapperInstalled_bodyNeverRendered() 
throws Exception {
+               var target = Logger.getLogger(D_Resource.class.getName());
+               var prevLevel = target.getLevel();
+               target.setLevel(Level.FINE);
+               try (var c = 
RichLogger.getLogger(D_Resource.class).captureEvents(Level.FINE)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(D_Resource.class).build();
+
+                       // Downstream handler must still see the full body -- 
FINE tier just doesn't wrap/capture it.
+                       client.post("/echo", 
"two-phase-secret").run().assertContent("two-phase-secret");
+
+                       assertFalse(c.isEmpty());
+                       assertEquals(Level.FINE, c.last().getLevel());
+                       
assertFalse(c.last().getMessage().contains("two-phase-secret"),
+                               "FINE tier must not install the capture 
wrapper, so the body cannot appear in the record: " + c.last().getMessage());
+               } finally {
+                       target.setLevel(prevLevel);
+               }
+       }
+
+       @Test void d02_finestTier_captureWrapperInstalled_bodyRendered() throws 
Exception {
+               try (var c = 
RichLogger.getLogger(D_Resource.class).captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(D_Resource.class).debug().build();
+
+                       client.post("/echo", 
"two-phase-secret").run().assertContent("two-phase-secret");
+
+                       assertFalse(c.isEmpty());
+                       assertEquals(Level.FINEST, c.last().getLevel());
+                       
assertTrue(c.last().getMessage().contains("two-phase-secret"),
+                               "FINEST tier must install the capture wrapper 
and render the body: " + c.last().getMessage());
+               }
+       }
+
+       /** Formatter resolution precedence: resource-implements-the-SPI beats 
a bean-registered formatter. */
+       public static class E_BeanFormatter implements RestDebugFormatter {
+               @Override public String formatBasic(RestRequest req, 
RestResponse res) { return "BEAN-IMPL"; }
+       }
+
+       @Rest(path="/precedence1")
+       public static class E_ResourceImplementsAndHasBean implements 
RestDebugFormatter {
+               @Override public String formatBasic(RestRequest req, 
RestResponse res) { return "RESOURCE-IMPL"; }
+               @RestGet(path="/who") public String who() { return "ok"; }
+               @Bean public RestDebugFormatter formatter() { return new 
E_BeanFormatter(); }
+       }
+
+       @Rest(path="/precedence2")
+       public static class E_BeanOnly {
+               @RestGet(path="/who") public String who() { return "ok"; }
+               @Bean public RestDebugFormatter formatter() { return new 
E_BeanFormatter(); }
+       }
+
+       @Test void 
e01_resourceImplementsFormatter_beatsBeanRegisteredFormatter() throws Exception 
{
+               try (var c = 
RichLogger.getLogger(E_ResourceImplementsAndHasBean.class).captureEvents(Level.INFO))
 {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(E_ResourceImplementsAndHasBean.class).build();
+                       
client.get("/who").run().assertStatus().asCode().is(200);
+
+                       assertFalse(c.isEmpty());
+                       
assertTrue(c.last().getMessage().contains("RESOURCE-IMPL"), 
c.last().getMessage());
+                       
assertFalse(c.last().getMessage().contains("BEAN-IMPL"), c.last().getMessage());
+               }
+       }
+
+       @Test void e02_beanRegisteredFormatter_beatsDefaultFormatter() throws 
Exception {
+               try (var c = 
RichLogger.getLogger(E_BeanOnly.class).captureEvents(Level.INFO)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(E_BeanOnly.class).build();
+                       
client.get("/who").run().assertStatus().asCode().is(200);
+
+                       assertFalse(c.isEmpty());
+                       assertTrue(c.last().getMessage().contains("BEAN-IMPL"), 
c.last().getMessage());
+               }
+       }
+
+       /**
+        * A resource that replaces its resolved {@link RichLogger} via a 
{@code @Bean} factory, used to prove the
+        * per-operation logger is a hierarchical child of the <i>resolved</i> 
(bean-overridden) logger name, not the
+        * raw resource class name. TODO-329 retrospective Should-fix #10.
+        */
+       @Rest(path="/beanlogger")
+       public static class F_Resource {
+               @RestGet(path="/who") public String who() { return "ok"; }
+               @Bean public RichLogger logger() { return 
RichLogger.getLogger("todo371.custom.override.logger"); }
+       }
+
+       @Test void 
f01_beanOverriddenResourceLogger_isJulParentOfOpChildLogger() throws Exception {
+               var overrideLogger = 
RichLogger.getLogger("todo371.custom.override.logger"); // strong ref
+               var prevLevel = overrideLogger.getLevel();
+               overrideLogger.setLevel(Level.FINEST);
+               try (var c = overrideLogger.captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(F_Resource.class).build();
+                       
client.get("/who").run().assertStatus().asCode().is(200);
+
+                       var rec = c.getRecords().stream()
+                               .filter(r -> 
"todo371.custom.override.logger.who".equals(r.getLoggerName()))
+                               .findFirst().orElse(null);
+                       assertNotNull(rec, "op logger must be named as a child 
of the bean-overridden logger, not "
+                               + F_Resource.class.getName() + ".who");
+                       assertEquals(Level.FINEST, rec.getLevel());
+               } finally {
+                       overrideLogger.setLevel(prevLevel);
+               }
+       }
+
+       /**
+        * Proves the 404/no-op path renders <b>only</b> the basic status line 
even at the {@code FINEST} tier --
+        * headers and bodies are never rendered when no operation was 
resolved. TODO-329 retrospective Should-fix #12.
+        */
+       @Test void g01_noOpPath_atFinestTier_neverRendersHeadersOrBody() throws 
Exception {
+               try (var c = 
RichLogger.getLogger(A_Resource.class).captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient.create(A_Resource.class).debug().build();
+
+                       client.get("/does-not-exist").header("X-Secret-Header", 
"leak-test-value").ignoreErrors().run()
+                               .assertStatus().asCode().is(404);
+
+                       var rec = c.getRecords().stream()
+                               .filter(r -> 
A_Resource.class.getName().equals(r.getLoggerName()))
+                               .reduce((a, b) -> b)
+                               .orElse(null);
+                       assertNotNull(rec);
+                       assertEquals(Level.FINEST, rec.getLevel(), "the 
resolved resource logger's tier is still FINEST");
+                       
assertFalse(rec.getMessage().contains("X-Secret-Header"),
+                               "no headers should ever render on the 404/no-op 
path, even at FINEST: " + rec.getMessage());
+                       
assertFalse(rec.getMessage().contains("leak-test-value"), rec.getMessage());
+                       assertTrue(rec.getMessage().contains("[404]"), 
rec.getMessage());
+               }
+       }
+
        @Test void a01_debugEnabled_capturesAtFinestTier() throws Exception {
                try (var c = 
RichLogger.getLogger(A_Resource.class).captureEvents(Level.FINEST)) {
                        var client = 
org.apache.juneau.rest.mock.classic.MockRestClient
@@ -150,4 +403,38 @@ class RestDebugCapture_Test {
                        assertTrue(noOpRecord.getMessage().contains("[404] HTTP 
GET /mix/missing"));
                }
        }
+
+       @Test void a06_bodyCapOverride_lowersCaptureAtCaptureTime() throws 
Exception {
+               try (var c = 
RichLogger.getLogger(A06_Resource.class).captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient
+                               .create(A06_Resource.class)
+                               .debug()
+                               .build();
+
+                       // 10-byte body; formatter overrides the cap to 4, well 
below the 8KB wrapper default.
+                       client.post("/echo", 
"0123456789").run().assertContent("0123456789");
+
+                       assertFalse(c.isEmpty());
+                       var msg = c.last().getMessage();
+                       assertTrue(msg.contains("0123"), msg);
+                       assertFalse(msg.contains("456789"), "captured body must 
be capped to 4 bytes, not the 8KB default: " + msg);
+                       assertTrue(msg.contains("truncated 6 bytes"), msg);
+               }
+       }
+
+       @Test void a07_bodyCapZero_disablesCaptureWithoutAffectingDownstream() 
throws Exception {
+               try (var c = 
RichLogger.getLogger(A07_Resource.class).captureEvents(Level.FINEST)) {
+                       var client = 
org.apache.juneau.rest.mock.classic.MockRestClient
+                               .create(A07_Resource.class)
+                               .debug()
+                               .build();
+
+                       // Downstream handler must still see the full body even 
though capture is disabled.
+                       client.post("/echo", 
"0123456789").run().assertContent("0123456789");
+
+                       assertFalse(c.isEmpty());
+                       var msg = c.last().getMessage();
+                       assertFalse(msg.contains("Request Content"), "no body 
section should render when bodyCap(0): " + msg);
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
index 1fc65f3ca9..25925eb010 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
@@ -109,8 +109,8 @@ import jakarta.servlet.http.*;
  * <p>
  * Configuration is supplied declaratively through the {@link Rest @Rest} 
annotation on the resource class
  * (and inherited from any parent classes), and programmatically through 
{@link Bean @Bean}-annotated
- * methods/fields that contribute named beans (e.g. <c>encoders</c>, 
<c>parsers</c>, <c>callLogger</c>) to the REST
- * resource's bean store. Where direct construction is needed (test rigs, mock 
clients, embedded usage),
+ * methods/fields that contribute named beans (e.g. <c>encoders</c>, 
<c>parsers</c>, <c>restDebugFormatter</c>) to
+ * the REST resource's bean store. Where direct construction is needed (test 
rigs, mock clients, embedded usage),
  * the public constructor takes a {@link RestContext.Args} record carrying the 
bootstrap state.
  *
  * <h5 class='section'>Example:</h5>
@@ -994,7 +994,7 @@ public class RestContext extends Context {
         * A mixin sub-context is parent-linked to the host's {@link 
RestContext} so that
         * {@link #getRestAnnotationsForProperty(String) annotation-property 
walks} prepend the host's {@code @Rest}
         * chain before the mixin's own &mdash; serializers, parsers, encoders, 
converters, response processors,
-        * REST op args, guards, callLogger, debugEnablement, messages, and 
varResolver tokens all inherit from the
+        * REST op args, guards, restDebugFormatter, messages, and varResolver 
tokens all inherit from the
         * host first, with the mixin's contributions appended.  Use {@link 
Rest#noInherit() @Rest(noInherit)} on the
         * mixin class to cut off inheritance for any specific property.
         *
@@ -1977,7 +1977,7 @@ public class RestContext extends Context {
                var bs = beanStore();
                var creator = BeanInstantiator.of(StaticFiles.class, 
bs).type(BasicStaticFiles.class).noBuilder();
                bs.getBeanType(StaticFiles.class).ifPresent(creator::type);
-               // @Rest(staticFiles=X) — most-derived non-Void wins. See 
callLogger for the reduce-last rationale.
+               // @Rest(staticFiles=X) — most-derived non-Void wins 
(parent-to-child chain; reduce-last keeps the closest-to-child override).
                getRestAnnotationsForProperty(PROPERTY_staticFiles)
                        .map(ai -> ai.inner().staticFiles())
                        .filter(c -> c != StaticFiles.Void.class)
@@ -2001,7 +2001,7 @@ public class RestContext extends Context {
                bs.addBean(SwaggerResource.class, 
SwaggerResource.of(resourceClass()));
                var creator = BeanInstantiator.of(SwaggerProvider.class, 
bs).type(BasicSwaggerProvider.class).noBuilder();
                bs.getBeanType(SwaggerProvider.class).ifPresent(creator::type);
-               // @Rest(swaggerProvider=X) — most-derived non-Void wins. See 
callLogger for the reduce-last rationale.
+               // @Rest(swaggerProvider=X) — most-derived non-Void wins 
(parent-to-child chain; reduce-last keeps the closest-to-child override).
                getRestAnnotationsForProperty(PROPERTY_swaggerProvider)
                        .map(ai -> ai.inner().swaggerProvider())
                        .filter(c -> c != SwaggerProvider.Void.class)
@@ -2076,7 +2076,7 @@ public class RestContext extends Context {
         * Each sub-context is constructed with a {@link ContextKind.Mixin} 
{@link Args#kind() kind}, with its
         * {@code parentContext} pointing at this host context.  That 
parent-linkage drives the inheritance walk in
         * {@link #getRestAnnotationsForProperty(String)} for serializers, 
parsers, encoders, converters, response
-        * processors, REST op args, guards, callLogger, debugEnablement, 
messages, varResolver tokens, etc.
+        * processors, REST op args, guards, restDebugFormatter, messages, 
varResolver tokens, etc.
         *
         * @since 10.0.0
         */
@@ -2540,7 +2540,7 @@ public class RestContext extends Context {
                        // For mixin sub-contexts, the bean store is 
parent-linked to the host's full beanStore so that
                        // host-declared @Bean factory results (e.g. 
@Bean(name="db") HealthIndicator dbIndicator()) are
                        // visible through the mixin's lookup chain.  But the 
parent walk also picks up the host's
-                       // framework defaults (SerializerSet, ParserSet, 
CallLogger, ...) at the parent's tier-4 slot
+                       // framework defaults (SerializerSet, ParserSet, 
RichLogger, ...) at the parent's tier-4 slot
                        // before this store's tier-4 defaults can fire — which 
would shadow this mixin's per-context
                        // framework objects.  Promoting our defaults into 
local entries (tier 2) makes them resolve
                        // ahead of the parent walk while still letting an 
overriding parent (e.g. Spring) and explicit
@@ -2581,7 +2581,7 @@ public class RestContext extends Context {
                        // and store the result via addBean.
                        //
                        // For framework types (those with a default supplier 
registered above): the @Bean
-                       // scan already ran inside the corresponding memoizer 
body (see e.g. createCallLogger()),
+                       // scan already ran inside the corresponding memoizer 
body (see e.g. the `logger` memoizer above),
                        // so re-invoking createBeanFromMethod here would 
create a SECOND instance and produce
                        // inconsistent state between the framework's 
memoizer-backed bean and the bean store's
                        // local entry.  Instead, PROMOTE the existing default 
supplier (which is memoizer-backed
@@ -3377,7 +3377,7 @@ public class RestContext extends Context {
         * <p>
         * Mirrors the "List-shaped" override set documented on {@link Mixin}: 
these are append-semantics properties, so a
         * mixin's contributions can be safely concatenated after the host's 
own chain (with the individual list builders'
-        * same-class de-duplication).  Replace-shaped properties (e.g. {@code 
callLogger}, {@code partSerializer}) and the
+        * same-class de-duplication).  Replace-shaped properties (e.g. {@code 
restDebugFormatter}, {@code partSerializer}) and the
         * {@link #HOST_ONLY_PROPERTIES host-only} properties are deliberately 
excluded &mdash; merging them would be a
         * conflict-resolution / namespace concern, not an append, and is out 
of scope for this opt-in directive.
         */
@@ -3398,7 +3398,7 @@ public class RestContext extends Context {
         * <p>
         * When this context is a {@linkplain #isMixinContext() mixin 
sub-context}, the parent context's annotation
         * chain is prepended to the mixin's own &mdash; serializers, parsers, 
encoders, converters, response
-        * processors, guards, callLogger, debugEnablement, messages, and other 
contribution lists inherit from the
+        * processors, guards, restDebugFormatter, messages, and other 
contribution lists inherit from the
         * host first, with the mixin's contributions appended.  The local 
{@code @Rest(noInherit)} on the mixin
         * cuts off the parent walk for any specific property; the {@link 
#HOST_ONLY_PROPERTIES} allowlist
         * unconditionally skips the parent walk for properties whose semantics 
are host-only (mount paths, the
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
index 4f11a0112e..f7e6d67486 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
@@ -264,35 +264,39 @@ public class RestOpContext extends Context implements 
Comparable<RestOpContext>
         * The per-operation logger for debug capture.
         *
         * <p>
-        * A hierarchical child of the <b>host</b> resource logger ({@code 
<hostResourceClass>.<methodName>}) so an operator
-        * can raise the debug level for a single operation without affecting 
its siblings (JUL parent-level inheritance).
+        * A hierarchical child of the <b>host</b> resource's {@linkplain 
RestContext#getLogger() resolved logger}
+        * ({@code <hostLoggerName>.<methodName>}) so an operator can raise the 
debug level for a single operation
+        * without affecting its siblings (JUL parent-level inheritance). 
Deriving from the resolved logger &mdash;
+        * rather than the raw resource class name &mdash; means a bean-store 
{@code RichLogger} override on the host
+        * (see {@link RestContext#getLogger()}) is honored: the op logger is a 
true JUL child of whatever logger the
+        * host actually resolves to, not a same-named-but-unrelated sibling.
         *
         * <p>
-        * For an operation contributed by a composed {@linkplain Rest#mixins() 
mixin}, the class-name portion is the
-        * <b>host / top-level resource class</b> &mdash; not the mixin class 
&mdash; so raising the host resource's JUL
-        * level cascades to its mixin-served operations, and the same mixin 
composed into different hosts resolves to
-        * distinct, host-isolated loggers.  Non-mixin operations (including 
child resources, which are their own resources)
-        * resolve to their own resource class as before.
+        * For an operation contributed by a composed {@linkplain Rest#mixins() 
mixin}, the host is the <b>host /
+        * top-level resource</b> &mdash; not the mixin class &mdash; so 
raising the host resource's JUL level cascades
+        * to its mixin-served operations, and the same mixin composed into 
different hosts resolves to distinct,
+        * host-isolated loggers.  Non-mixin operations (including child 
resources, which are their own resources)
+        * resolve to their own resource's logger as before.
         */
        private final Memoizer<RichLogger> logger = memoizer(() ->
-               RichLogger.getLogger(hostResourceClass().getName() + "." + 
getJavaMethod().getName()));
+               RichLogger.getLogger(hostRestContext().getLogger().getName() + 
"." + getJavaMethod().getName()));
 
        /**
-        * Returns the host / top-level resource class for logger naming.
+        * Returns the host / top-level resource context for logger naming.
         *
         * <p>
         * When this operation's context originates from a {@linkplain 
RestContext#isMixinContext() mixin sub-context},
         * walks up the {@linkplain RestContext#getParentContext() 
parent-context} linkage past any composed mixin
         * sub-contexts to the host resource that composed the mixin.  
Non-mixin contexts (including child resources)
-        * return their own resource class unchanged.
+        * return their own context unchanged.
         *
-        * @return The host resource class.
+        * @return The host resource context.
         */
-       private Class<?> hostResourceClass() {
+       private RestContext hostRestContext() {
                var rc = restContext();
                while (rc.isMixinContext() && rc.getParentContext() != null)
                        rc = rc.getParentContext();
-               return rc.getResourceClass();
+               return rc;
        }
 
        /**
@@ -1753,7 +1757,9 @@ public class RestOpContext extends Context implements 
Comparable<RestOpContext>
         * Returns the per-operation logger used for debug capture.
         *
         * <p>
-        * A hierarchical child of the host resource logger ({@code 
<hostResourceClass>.<methodName>}).
+        * A hierarchical child of the host resource's resolved logger ({@code 
<hostLoggerName>.<methodName>}) &mdash;
+        * see the {@code logger} memoizer for how the host's 
bean-store-overridden {@link RichLogger} (if any) is
+        * honored.
         *
         * @return The per-operation logger.
         *      <br>Never <jk>null</jk>.
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
index 49ce47ecbd..926e0fa88e 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
@@ -31,6 +31,7 @@ import org.apache.juneau.http.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.marshall.*;
 import org.apache.juneau.rest.server.auth.*;
+import org.apache.juneau.rest.server.logging.*;
 import org.apache.juneau.rest.server.util.*;
 
 import jakarta.servlet.http.*;
@@ -243,15 +244,36 @@ public class RestSession extends ContextSession {
         * Called by the two-phase debug pipeline when the resolved logger is 
loggable at
         * {@link java.util.logging.Level#FINEST FINEST}. Idempotent — the 
wrappers no-op if already installed.
         *
+        * <p>
+        * The capture cap is snapshotted from {@link 
RestContext#getRestDebugFormatter()}<c>.bodyCap()</c> at install
+        * time, so a formatter override actually raises, lowers, or (via 
{@code bodyCap(0)}) disables the number of
+        * bytes retained &mdash; not just the wrapper's own 8&nbsp;KB default.
+        *
         * @return This object.
         * @throws IOException Occurs if the request/response streams could not 
be wrapped.
         */
        public RestSession installCapture() throws IOException {
-               req = CachingHttpServletRequest.wrap(req);
-               res = CachingHttpServletResponse.wrap(res);
+               var cap = resolveBodyCap();
+               req = CachingHttpServletRequest.wrap(req, cap);
+               res = CachingHttpServletResponse.wrap(res, cap);
                return this;
        }
 
+       /**
+        * Resolves the body capture cap in effect for this call, per {@link 
RestDebugFormatter#bodyCap()}.
+        *
+        * <p>
+        * Mirrors the formatter-resolution fallback used by {@link 
RestDebugPipeline} (a bean-store lookup can return
+        * <jk>null</jk> despite the {@link 
RestContext#getRestDebugFormatter()} javadoc contract) so the cap enforced at
+        * capture time always matches the cap the eventual render will report 
against.
+        *
+        * @return The body capture cap, in bytes.
+        */
+       private int resolveBodyCap() {
+               var formatter = context.getRestDebugFormatter();
+               return formatter != null ? formatter.bodyCap() : new 
BasicRestDebugFormatter().bodyCap();
+       }
+
        /**
         * Identifies that an exception occurred during this call.
         *
@@ -282,7 +304,7 @@ public class RestSession extends ContextSession {
                } catch (Exception e) {
                        exception(e);
                }
-               
org.apache.juneau.rest.server.logging.RestDebugPipeline.emit(this);
+               RestDebugPipeline.emit(this);
                return this;
        }
 
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest.java
index 50719b7688..4537d1dd4b 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest.java
@@ -17,6 +17,7 @@
 package org.apache.juneau.rest.server.util;
 
 import java.io.*;
+import java.nio.charset.*;
 
 import jakarta.servlet.*;
 import jakarta.servlet.http.*;
@@ -65,6 +66,7 @@ public class CachingHttpServletRequest extends 
HttpServletRequestWrapper {
        private final ByteArrayOutputStream buffer = new 
ByteArrayOutputStream();
        private long totalLength = 0;
        private TeeServletInputStream stream;
+       private BufferedReader reader;
 
        /**
         * Constructor.
@@ -98,6 +100,25 @@ public class CachingHttpServletRequest extends 
HttpServletRequestWrapper {
                return stream;
        }
 
+       /**
+        * Returns a character-stream tee over the same underlying byte stream 
captured by {@link #getInputStream()}.
+        *
+        * <p>
+        * A handler that reads the request body through {@code getReader()} 
instead of {@code getInputStream()} would
+        * otherwise bypass capture entirely &mdash; the default {@link 
HttpServletRequestWrapper#getReader()} delegates
+        * straight to the wrapped request. This override routes the reader 
through the tee'd stream so both access
+        * styles are captured identically.
+        */
+       @Override
+       public BufferedReader getReader() throws IOException {
+               if (reader == null) {
+                       var enc = getCharacterEncoding();
+                       var cs = enc == null ? StandardCharsets.ISO_8859_1 : 
Charset.forName(enc);
+                       reader = new BufferedReader(new 
InputStreamReader(getInputStream(), cs));
+               }
+               return reader;
+       }
+
        private void capture(int b) {
                totalLength++;
                if (buffer.size() < cap)
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse.java
index d7eb218884..e5df6e29b2 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse.java
@@ -17,6 +17,7 @@
 package org.apache.juneau.rest.server.util;
 
 import java.io.*;
+import java.nio.charset.*;
 
 import jakarta.servlet.*;
 import jakarta.servlet.http.*;
@@ -28,10 +29,14 @@ import jakarta.servlet.http.*;
  * At most a configured cap (default 8&nbsp;KB) of the response body is 
captured while all bytes are still written through
  * to the real client. The total number of bytes written is tracked so a 
truncation marker can be rendered.
  *
+ * <p>
+ * The underlying {@link HttpServletResponse#getOutputStream()} is not 
acquired until the first
+ * {@link #getOutputStream()} or {@link #getWriter()} call &mdash; acquiring 
it eagerly in the constructor would
+ * permanently lock the response into stream mode, making a subsequent direct 
{@code getWriter()} call on this
+ * wrapper (or on the underlying response) throw {@link IllegalStateException} 
per the servlet spec, even for
+ * calls that never write anything.
+ *
  */
-@SuppressWarnings({
-       "resource" // os is a servlet-container-managed stream obtained from 
the wrapped response; closed by the container
-})
 public class CachingHttpServletResponse extends HttpServletResponseWrapper {
 
        /** Default body capture cap, in bytes (8&nbsp;KB). */
@@ -42,9 +47,8 @@ public class CachingHttpServletResponse extends 
HttpServletResponseWrapper {
         *
         * @param res The response to wrap. Must not be <jk>null</jk>.
         * @return The wrapped response.
-        * @throws IOException Thrown by underlying content stream.
         */
-       public static CachingHttpServletResponse wrap(HttpServletResponse res) 
throws IOException {
+       public static CachingHttpServletResponse wrap(HttpServletResponse res) {
                return wrap(res, DEFAULT_CAP);
        }
 
@@ -54,9 +58,8 @@ public class CachingHttpServletResponse extends 
HttpServletResponseWrapper {
         * @param res The response to wrap. Must not be <jk>null</jk>.
         * @param cap The maximum number of body bytes to capture.
         * @return The wrapped response.
-        * @throws IOException Thrown by underlying content stream.
         */
-       public static CachingHttpServletResponse wrap(HttpServletResponse res, 
int cap) throws IOException {
+       public static CachingHttpServletResponse wrap(HttpServletResponse res, 
int cap) {
                if (res instanceof CachingHttpServletResponse res2)
                        return res2;
                return new CachingHttpServletResponse(res, cap);
@@ -65,19 +68,18 @@ public class CachingHttpServletResponse extends 
HttpServletResponseWrapper {
        private final int cap;
        private final ByteArrayOutputStream buffer = new 
ByteArrayOutputStream();
        private long totalLength = 0;
-       private final ServletOutputStream os;
+       private TeeServletOutputStream stream;
+       private PrintWriter writer;
 
        /**
         * Constructor.
         *
         * @param res The wrapped servlet response. Must not be <jk>null</jk>.
         * @param cap The maximum number of body bytes to capture.
-        * @throws IOException Thrown by underlying stream.
         */
-       protected CachingHttpServletResponse(HttpServletResponse res, int cap) 
throws IOException {
+       protected CachingHttpServletResponse(HttpServletResponse res, int cap) {
                super(res);
                this.cap = cap;
-               os = res.getOutputStream();
        }
 
        /**
@@ -100,33 +102,76 @@ public class CachingHttpServletResponse extends 
HttpServletResponseWrapper {
                        buffer.write(b);
        }
 
+       private void capture(byte[] b, int off, int len) {
+               totalLength += len;
+               var room = cap - buffer.size();
+               if (room > 0)
+                       buffer.write(b, off, Math.min(room, len));
+       }
+
        @Override
        public ServletOutputStream getOutputStream() throws IOException {
-               return new ServletOutputStream() {
-
-                       @Override
-                       public void close() throws IOException {
-                               os.close();
-                       }
-
-                       @Override
-                       public void flush() throws IOException {
-                               os.flush();
-                       }
-
-                       @Override
-                       public boolean isReady() { return os.isReady(); }
-
-                       @Override
-                       public void setWriteListener(WriteListener 
writeListener) {
-                               os.setWriteListener(writeListener);
-                       }
-
-                       @Override
-                       public void write(int b) throws IOException {
-                               capture(b);
-                               os.write(b);
-                       }
-               };
+               if (stream == null)
+                       stream = new 
TeeServletOutputStream(getResponse().getOutputStream());
+               return stream;
+       }
+
+       /**
+        * Returns a character-stream tee that writes through {@link 
#getOutputStream()}.
+        *
+        * <p>
+        * Juneau's own {@link 
org.apache.juneau.rest.server.RestResponse#getWriter()} always negotiates its 
writer over
+        * {@link #getOutputStream()}, so this override chiefly protects 
direct/raw consumers (filters, non-Juneau
+        * servlets in the same chain) that call {@code 
HttpServletResponse.getWriter()} on the wrapped response after
+        * capture has been installed &mdash; without it, that call would 
either bypass the tee entirely or throw
+        * {@link IllegalStateException} because {@link #getOutputStream()} may 
have already been called.
+        */
+       @Override
+       public PrintWriter getWriter() throws IOException {
+               if (writer == null) {
+                       var enc = getCharacterEncoding();
+                       var cs = enc == null ? StandardCharsets.ISO_8859_1 : 
Charset.forName(enc);
+                       writer = new PrintWriter(new 
OutputStreamWriter(getOutputStream(), cs));
+               }
+               return writer;
+       }
+
+       private final class TeeServletOutputStream extends ServletOutputStream {
+
+               private final ServletOutputStream delegate;
+
+               TeeServletOutputStream(ServletOutputStream delegate) {
+                       this.delegate = delegate;
+               }
+
+               @Override
+               public void close() throws IOException {
+                       delegate.close();
+               }
+
+               @Override
+               public void flush() throws IOException {
+                       delegate.flush();
+               }
+
+               @Override
+               public boolean isReady() { return delegate.isReady(); }
+
+               @Override
+               public void setWriteListener(WriteListener writeListener) {
+                       delegate.setWriteListener(writeListener);
+               }
+
+               @Override
+               public void write(int b) throws IOException {
+                       capture(b);
+                       delegate.write(b);
+               }
+
+               @Override
+               public void write(byte[] b, int off, int len) throws 
IOException {
+                       capture(b, off, len);
+                       delegate.write(b, off, len);
+               }
        }
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest_Test.java
new file mode 100644
index 0000000000..c5917126b7
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletRequest_Test.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.*;
+import java.nio.charset.*;
+
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for {@link CachingHttpServletRequest} &mdash; capture-cap enforcement 
(independent of downstream
+ * consumption) and the teeing {@link CachingHttpServletRequest#getReader()} 
path.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings("resource") // Mockito mocks / in-memory streams; nothing to 
close.
+class CachingHttpServletRequest_Test {
+
+       private static HttpServletRequest mockRequest(byte[] content) throws 
IOException {
+               var req = mock(HttpServletRequest.class);
+               when(req.getInputStream()).thenReturn(new 
BoundedServletInputStream(content));
+               return req;
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // a — wrap() / cap enforcement at capture time
+       // 
-----------------------------------------------------------------------------------------
+
+       @Test void a01_wrap_idempotent() throws IOException {
+               var req = mockRequest("abc".getBytes());
+               var wrapped = CachingHttpServletRequest.wrap(req, 10);
+               assertSame(wrapped, CachingHttpServletRequest.wrap(wrapped, 
10));
+       }
+
+       @Test void a02_wrap_defaultCap() throws IOException {
+               var req = mockRequest("abc".getBytes());
+               var wrapped = CachingHttpServletRequest.wrap(req);
+               wrapped.getInputStream().readAllBytes();
+               assertEquals(CachingHttpServletRequest.DEFAULT_CAP, 8 * 1024);
+               assertArrayEquals("abc".getBytes(), wrapped.getContent());
+       }
+
+       @Test void a03_capLimitsCapturedBytes_downstreamStillGetsEverything() 
throws IOException {
+               var content = "0123456789".getBytes();
+               var req = mockRequest(content);
+               var wrapped = CachingHttpServletRequest.wrap(req, 4);
+
+               var read = wrapped.getInputStream().readAllBytes();
+
+               assertArrayEquals(content, read);
+               assertArrayEquals("0123".getBytes(), wrapped.getContent());
+               assertEquals(10, wrapped.getTotalLength());
+       }
+
+       @Test void a04_capZero_disablesCaptureButNotDownstream() throws 
IOException {
+               var content = "0123456789".getBytes();
+               var req = mockRequest(content);
+               var wrapped = CachingHttpServletRequest.wrap(req, 0);
+
+               var read = wrapped.getInputStream().readAllBytes();
+
+               assertArrayEquals(content, read);
+               assertEquals(0, wrapped.getContent().length);
+               assertEquals(10, wrapped.getTotalLength());
+       }
+
+       @Test void a05_getInputStream_cachedOnRepeatedCalls() throws 
IOException {
+               var req = mockRequest("abc".getBytes());
+               var wrapped = CachingHttpServletRequest.wrap(req, 10);
+               assertSame(wrapped.getInputStream(), wrapped.getInputStream());
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // b — getReader() teeing
+       // 
-----------------------------------------------------------------------------------------
+
+       @Test void b01_getReader_teesThroughCapturedStream() throws IOException 
{
+               var content = "hello world".getBytes(StandardCharsets.UTF_8);
+               var req = mockRequest(content);
+               when(req.getCharacterEncoding()).thenReturn(null);
+               var wrapped = CachingHttpServletRequest.wrap(req, 100);
+
+               var line = wrapped.getReader().readLine();
+
+               assertEquals("hello world", line);
+               assertArrayEquals(content, wrapped.getContent());
+               assertEquals(11, wrapped.getTotalLength());
+       }
+
+       @Test void b02_getReader_honorsCharacterEncoding() throws IOException {
+               var content = "h\u00e9llo".getBytes(StandardCharsets.UTF_8);
+               var req = mockRequest(content);
+               when(req.getCharacterEncoding()).thenReturn("UTF-8");
+               var wrapped = CachingHttpServletRequest.wrap(req, 100);
+
+               assertEquals("h\u00e9llo", wrapped.getReader().readLine());
+       }
+
+       @Test void b03_getReader_cachedOnRepeatedCalls() throws IOException {
+               var req = mockRequest("abc".getBytes());
+               var wrapped = CachingHttpServletRequest.wrap(req, 10);
+               assertSame(wrapped.getReader(), wrapped.getReader());
+       }
+
+       @Test void b04_getReader_respectsCap() throws IOException {
+               var content = "0123456789".getBytes(StandardCharsets.UTF_8);
+               var req = mockRequest(content);
+               when(req.getCharacterEncoding()).thenReturn(null);
+               var wrapped = CachingHttpServletRequest.wrap(req, 4);
+
+               var line = wrapped.getReader().readLine();
+
+               assertEquals("0123456789", line, "downstream reader must see 
the full body");
+               assertArrayEquals("0123".getBytes(), wrapped.getContent(), 
"captured bytes must be capped");
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse_Test.java
new file mode 100644
index 0000000000..f70b43773e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/util/CachingHttpServletResponse_Test.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.*;
+import java.nio.charset.*;
+
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for {@link CachingHttpServletResponse} &mdash; lazy stream 
acquisition, capture-cap enforcement, the bulk
+ * {@code write(byte[],int,int)} override, and the teeing {@link 
CachingHttpServletResponse#getWriter()} path.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings("resource") // Mockito mocks / in-memory streams; nothing to 
close.
+class CachingHttpServletResponse_Test {
+
+       /** Minimal in-memory ServletOutputStream backed by a 
ByteArrayOutputStream for testing the tee path. */
+       private static final class FakeServletOutputStream extends 
ServletOutputStream {
+               final ByteArrayOutputStream sink = new ByteArrayOutputStream();
+               boolean closed;
+
+               @Override public void write(int b) { sink.write(b); }
+
+               @Override public void write(byte[] b, int off, int len) { 
sink.write(b, off, len); }
+
+               @Override public void close() { closed = true; }
+
+               @Override public boolean isReady() { return true; }
+
+               @Override public void setWriteListener(WriteListener listener) 
{ /* no-op */ }
+       }
+
+       private static HttpServletResponse mockResponse(FakeServletOutputStream 
sink) throws IOException {
+               var res = mock(HttpServletResponse.class);
+               when(res.getOutputStream()).thenReturn(sink);
+               return res;
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // a — wrap() / lazy stream acquisition / cap enforcement at capture 
time
+       // 
-----------------------------------------------------------------------------------------
+
+       @Test void a01_wrap_idempotent() throws IOException {
+               var res = mockResponse(new FakeServletOutputStream());
+               var wrapped = CachingHttpServletResponse.wrap(res, 10);
+               assertSame(wrapped, CachingHttpServletResponse.wrap(wrapped, 
10));
+       }
+
+       @Test void a02_constructor_doesNotEagerlyOpenStream() throws 
IOException {
+               var res = mockResponse(new FakeServletOutputStream());
+               CachingHttpServletResponse.wrap(res, 10);
+               verify(res, never()).getOutputStream();
+       }
+
+       @Test void a03_capLimitsCapturedBytes_downstreamStillGetsEverything() 
throws IOException {
+               var sink = new FakeServletOutputStream();
+               var res = mockResponse(sink);
+               var wrapped = CachingHttpServletResponse.wrap(res, 4);
+
+               wrapped.getOutputStream().write("0123456789".getBytes());
+
+               assertArrayEquals("0123456789".getBytes(), 
sink.sink.toByteArray());
+               assertArrayEquals("0123".getBytes(), wrapped.getContent());
+               assertEquals(10, wrapped.getTotalLength());
+       }
+
+       @Test void a04_bulkWrite_capturedInSingleCall() throws IOException {
+               var sink = new FakeServletOutputStream();
+               var res = mockResponse(sink);
+               var wrapped = CachingHttpServletResponse.wrap(res, 100);
+
+               wrapped.getOutputStream().write("abc".getBytes(), 0, 3);
+
+               assertArrayEquals("abc".getBytes(), wrapped.getContent());
+               assertEquals(3, wrapped.getTotalLength());
+       }
+
+       @Test void a05_getOutputStream_cachedOnRepeatedCalls() throws 
IOException {
+               var res = mockResponse(new FakeServletOutputStream());
+               var wrapped = CachingHttpServletResponse.wrap(res, 100);
+               assertSame(wrapped.getOutputStream(), 
wrapped.getOutputStream());
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // b — getWriter() teeing
+       // 
-----------------------------------------------------------------------------------------
+
+       @Test void b01_getWriter_teesThroughCapturedStream() throws IOException 
{
+               var sink = new FakeServletOutputStream();
+               var res = mockResponse(sink);
+               when(res.getCharacterEncoding()).thenReturn("UTF-8");
+               var wrapped = CachingHttpServletResponse.wrap(res, 100);
+
+               wrapped.getWriter().write("hello");
+               wrapped.getWriter().flush();
+
+               assertEquals("hello", 
sink.sink.toString(StandardCharsets.UTF_8));
+               assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), 
wrapped.getContent());
+       }
+
+       @Test void b02_getWriter_cachedOnRepeatedCalls() throws IOException {
+               var res = mockResponse(new FakeServletOutputStream());
+               var wrapped = CachingHttpServletResponse.wrap(res, 100);
+               assertSame(wrapped.getWriter(), wrapped.getWriter());
+       }
+
+       @Test void b03_getWriter_afterOutputStream_doesNotThrow() throws 
IOException {
+               // Real servlet containers throw IllegalStateException calling 
getWriter() after getOutputStream() on the
+               // SAME response — proving the tee's getWriter() routes through 
its OWN getOutputStream(), not the raw one.
+               var res = mockResponse(new FakeServletOutputStream());
+               var wrapped = CachingHttpServletResponse.wrap(res, 100);
+               wrapped.getOutputStream();
+               assertDoesNotThrow(wrapped::getWriter);
+       }
+
+       @Test void b04_getWriter_respectsCap() throws IOException {
+               var sink = new FakeServletOutputStream();
+               var res = mockResponse(sink);
+               when(res.getCharacterEncoding()).thenReturn("UTF-8");
+               var wrapped = CachingHttpServletResponse.wrap(res, 4);
+
+               wrapped.getWriter().write("0123456789");
+               wrapped.getWriter().flush();
+
+               assertEquals("0123456789", 
sink.sink.toString(StandardCharsets.UTF_8), "downstream must see the full 
body");
+               assertArrayEquals("0123".getBytes(), wrapped.getContent(), 
"captured bytes must be capped");
+       }
+}
diff --git a/src/test/resources/logging.properties 
b/src/test/resources/logging.properties
index d4883e22f6..0b712f7e47 100644
--- a/src/test/resources/logging.properties
+++ b/src/test/resources/logging.properties
@@ -18,8 +18,8 @@
 #
 # Policy: raise the console threshold to WARNING (NOT OFF) so genuine 
WARNING/SEVERE
 # still surface; only INFO/FINE chatter is suppressed.  Modules whose Juneau 
code logs
-# via JUL directly (e.g. the auth modules' 
JwksCache/BasicSwaggerProviderSession/CallLogger)
-# keep their real warnings visible under this threshold.
+# via JUL directly (e.g. the auth modules' 
JwksCache/BasicSwaggerProviderSession, and the
+# REST debug pipeline's per-resource loggers) keep their real warnings visible 
under this threshold.
 handlers=java.util.logging.ConsoleHandler
 .level=INFO
 

Reply via email to