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>@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>@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>@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> ->
<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> -> <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>, () -> spyLogger);
+ * .override(RichLogger.<jk>class</jk>, () -> 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 — 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 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 — 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 — 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 — 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 —
+ * rather than the raw resource class name — 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> — not the mixin class
— 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> — not the mixin class — 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>}) —
+ * 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 — not just the wrapper's own 8 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 — 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 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 — 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 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 — 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} — 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} — 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