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 f2932362a6 Measure MCP schema-safety compute budget as thread CPU
time; add view-mixin responseProcessor auto-fold opt-in; FreeMarker
object-wrapper + fluent ConfigItem.setValue
f2932362a6 is described below
commit f2932362a6158e2d9fb2ed109d35866c4f0720e9
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 13 18:03:16 2026 -0400
Measure MCP schema-safety compute budget as thread CPU time; add view-mixin
responseProcessor auto-fold opt-in; FreeMarker object-wrapper + fluent
ConfigItem.setValue
McpSchemaSafety (mcp-v20260728): the tools/call input-schema-validation DoS
budget is now charged against the validating thread's actual CPU time
(ThreadMXBean.getThreadCpuTime) instead of wall-clock, so OS
preemption/scheduling no longer counts against the ~100ms budget. Includes a
support-check with a verbatim wall-clock fallback when thread-CPU timing is
unavailable, correct ns/-1-sentinel handling, and sleep-vs-CPU-burn regression
tests. Fixes an intermittent CI false-trip (-32602 [...]
TODO-358 (juneau-rest-server view mixins): (1) FreemarkerDispatcher's
bridge-default Configuration now installs a
DefaultObjectWrapper(exposeFields=true) so public-field DTOs render instead of
silently resolving to null, with new
exposeFields(boolean)/objectWrapper(ObjectWrapper) builder knobs. (2) New
non-silent, response-processor-scoped
@Rest(mergeResponseProcessorsIntoHost=true) opt-in: a plain
@Rest(mixins=<ViewMixin>.class) now folds the mixin's renderer into the host
chain so t [...]
TODO-320 (juneau-sc-server): ConfigItem.setValue(String) is now a fluent
self-returning setter, matching the repo-wide convention (the lone void-setter
outlier); source-compatible, no reflective contract broken.
Docs: release notes for both behavioral changes, plus FreeMarker/JSP
view-support and mixin-subcontexts topic updates.
---
.../rest/server/mcp/v20260728/McpSchemaSafety.java | 169 +++++++++++++++++----
.../server/mcp/v20260728/McpSchemaSafety_Test.java | 96 +++++++++++-
.../view/freemarker/FreemarkerDispatcher.java | 107 ++++++++++++-
.../server/view/freemarker/FreemarkerMixin.java | 142 ++++++++++++++++-
.../view/freemarker/FreemarkerDispatcher_Test.java | 53 +++++++
.../freemarker/FreemarkerMixin_Builder_Test.java | 30 ++++
.../juneau/rest/server/view/jsp/JspMixin.java | 39 +++--
.../jsp/JspViewRenderer_ForwardPaths_Test.java | 79 +++++++---
.../rest/server/view/mustache/MustacheMixin.java | 13 +-
.../rest/server/view/thymeleaf/ThymeleafMixin.java | 13 +-
.../java/org/apache/juneau/rest/server/Rest.java | 31 ++++
.../apache/juneau/rest/server/RestAnnotation.java | 19 +++
.../org/apache/juneau/rest/server/RestContext.java | 51 ++++++-
.../server/MixinResponseProcessorFold_Test.java | 139 +++++++++++++++++
.../server/config/repository/ConfigItem.java | 3 +-
15 files changed, 888 insertions(+), 96 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
index d3c8dee7a1..c17065ab14 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static java.util.concurrent.TimeUnit.*;
+import java.lang.management.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
@@ -49,12 +50,28 @@ import org.apache.juneau.rest.server.mcp.McpSchema;
* <p>
* Validation itself runs on a fixed-size pool of daemon threads and is
bounded by the same overall budget: if
* a pathological schema (for example a catastrophically-backtracking {@code
pattern}) fails to complete in
- * time, the task is cancelled and a {@code -32602} error is raised instead of
hanging the request thread. That
- * compute budget is measured from the moment the task actually starts
running, not from the moment it is
- * submitted: time spent waiting for a free thread in {@link #VALIDATION_POOL}
(for example under heavy
- * concurrent build/test load) is scheduling latency, not validation cost, so
it is never charged against
- * {@link #MAX_VALIDATION_MILLIS}. A separate, much more generous {@link
#MAX_SCHEDULING_MILLIS} backstop still
- * bounds the scheduling wait itself, purely so a wedged or saturated pool
cannot block the caller forever.
+ * time, the task is cancelled and a {@code -32602} error is raised instead of
hanging the request thread.
+ *
+ * <p>
+ * <b>The compute budget is charged against the validating thread's actual CPU
time, not wall-clock time.</b>
+ * The DoS threat being defended against is a schema that burns CPU
(catastrophic backtracking, quadratic
+ * blowups); the amount of CPU a validation consumes is exactly what that
budget should cap. Measuring
+ * wall-clock instead would wrongly count time the validating thread was
<i>not</i> running - time it spent
+ * preempted by the OS scheduler, or queued behind sibling work - as if it
were validation cost. Under heavy
+ * concurrent load (for example {@code juneau-integration-tests} running
Surefire with {@code forkCount=8}) that
+ * false accounting can trip the budget on trivial input, and the same
false-positive can bite a saturated
+ * production deployment. Sampling {@link ThreadMXBean#getThreadCpuTime(long)
thread CPU time} on the thread
+ * that actually does the work means only real compute counts: preemption and
scheduling latency no longer
+ * shrink the budget, while a genuinely expensive validation still trips it
and returns the same {@code -32602}
+ * error. On a JVM where per-thread CPU timing is unavailable, {@link
#awaitBounded} transparently falls back to
+ * the original wall-clock measurement so the guard still functions everywhere.
+ *
+ * <p>
+ * Independently of the compute budget, time spent waiting for a free thread
in {@link #VALIDATION_POOL} is
+ * scheduling latency, not validation cost, so it is never charged against
{@link #MAX_VALIDATION_MILLIS}: the
+ * budget clock (CPU or wall-clock) only starts once the task is actually
running. A separate, much more
+ * generous {@link #MAX_SCHEDULING_MILLIS} backstop bounds the scheduling wait
itself, purely so a wedged or
+ * saturated pool cannot block the caller forever.
*/
final class McpSchemaSafety {
@@ -65,10 +82,10 @@ final class McpSchemaSafety {
static final int MAX_NODES = 10_000;
/**
- * Maximum compute time permitted for a single schema validation, in
milliseconds, measured from the
- * moment the validation task actually starts running on {@link
#VALIDATION_POOL} (not from submission).
+ * Maximum compute time permitted for a single schema validation, in
milliseconds, measured as the
+ * validating thread's actual CPU time (see class-level notes) - not
from submission, and not as wall-clock.
* This is the DoS bound: it caps how much CPU a pathological schema
can burn, and is deliberately
- * unaffected by how long the task had to wait for a free thread.
+ * unaffected both by how long the task had to wait for a free thread
and by any OS preemption while it runs.
*/
static final long MAX_VALIDATION_MILLIS = 100;
@@ -80,6 +97,36 @@ final class McpSchemaSafety {
*/
static final long MAX_SCHEDULING_MILLIS = 2_000;
+ private static final ThreadMXBean THREAD_MX =
ManagementFactory.getThreadMXBean();
+
+ /**
+ * Whether per-thread CPU-time measurement is available (and enabled)
on this JVM. When <jk>true</jk>, the
+ * compute budget is charged against the validating thread's actual CPU
time; when <jk>false</jk> (a JVM that
+ * cannot report per-thread CPU time), {@link #awaitBounded} falls back
to the original wall-clock measurement
+ * so the DoS guard still functions everywhere.
+ */
+ private static final boolean CPU_TIME_SUPPORTED = initCpuTimeSupport();
+
+ /**
+ * How often the waiting thread re-samples the validating thread's
accumulated CPU time while a validation is
+ * still running. Small enough that a runaway validation is stopped
promptly once it burns past the budget,
+ * large enough that the sampling overhead is negligible.
+ */
+ private static final long CPU_POLL_INTERVAL_NANOS =
MILLISECONDS.toNanos(5);
+
+ private static boolean initCpuTimeSupport() {
+ if (! THREAD_MX.isThreadCpuTimeSupported())
+ return false; // HTT: CI JVMs support per-thread CPU
time; this fallback path isn't reachable there.
+ if (! THREAD_MX.isThreadCpuTimeEnabled()) {
+ try {
+ THREAD_MX.setThreadCpuTimeEnabled(true); //
HTT: HotSpot enables thread CPU time by default; not reachable in CI.
+ } catch (@SuppressWarnings("unused") SecurityException
| UnsupportedOperationException e) {
+ return false; // HTT: enabling is rejected
only under a restrictive SecurityManager; not reachable in CI.
+ }
+ }
+ return true;
+ }
+
private static final ExecutorService VALIDATION_POOL =
newValidationPool();
private static ExecutorService newValidationPool() {
@@ -125,56 +172,79 @@ final class McpSchemaSafety {
/**
* Runs {@link JsonSchemaValidator} on a daemon thread and enforces the
remaining share of the shared
- * {@link JsonValueSafety} deadline against the task's own compute time
- never against however long it
- * had to wait in {@link #VALIDATION_POOL} for a free thread.
+ * {@link JsonValueSafety} deadline against the task's own compute time
- never against however long it had
+ * to wait in {@link #VALIDATION_POOL} for a free thread, and (when CPU
timing is available) never against
+ * wall-clock time it spent preempted rather than running.
*/
private static void validateBounded(JsonSchema<?> schema, Object value,
long deadlineNanos) {
var remaining = JsonValueSafety.remainingNanos(deadlineNanos);
if (remaining == 0)
throw validationTimeoutException();
var started = new CountDownLatch(1);
- var startedAtNanos = new AtomicLong();
+ var taskStart = new AtomicReference<TaskStart>();
var future = VALIDATION_POOL.submit(() -> {
- startedAtNanos.set(System.nanoTime());
+ taskStart.set(TaskStart.capture());
started.countDown();
JsonSchemaValidator.of(schema).validate(value);
return null;
});
- awaitBounded(future, started, startedAtNanos, remaining);
+ awaitBounded(future, started, taskStart, remaining);
}
/**
- * Waits for a submitted validation task to complete, charging only its
own compute time - measured from
- * {@code startedAtNanos}, which the task sets as its first instruction
- against {@code remaining}, the
- * caller's share of the shared {@link JsonValueSafety} deadline.
+ * Snapshot of when a validation task actually began running, captured
on the validating thread itself as its
+ * first instruction.
+ *
+ * @param threadId The validating thread's id, so the waiting thread
can sample that same thread's CPU time.
+ * @param wallNanos The {@link System#nanoTime()} baseline, used by the
wall-clock fallback.
+ * @param cpuNanos The validating thread's CPU-time baseline in
nanoseconds ({@link ThreadMXBean#getThreadCpuTime(long)}),
+ * or {@code -1} if per-thread CPU timing is unavailable on this
JVM.
+ */
+ record TaskStart(long threadId, long wallNanos, long cpuNanos) {
+
+ /** Captures the current thread's start snapshot; must be
called on the thread that runs the validation. */
+ static TaskStart capture() {
+ var id = Thread.currentThread().getId();
+ return new TaskStart(id, System.nanoTime(),
CPU_TIME_SUPPORTED ? THREAD_MX.getThreadCpuTime(id) : -1L);
+ }
+ }
+
+ /**
+ * Waits for a submitted validation task to complete, charging only its
own compute time against
+ * {@code remaining} (the caller's share of the shared {@link
JsonValueSafety} deadline) - never however long
+ * it had to wait in {@link #VALIDATION_POOL} for a free thread.
*
* <p>
- * This waits in two phases. First, {@code started} is awaited so the
calling thread learns exactly when
- * the task begins running; this wait is bounded only by the generous
{@link #MAX_SCHEDULING_MILLIS}
- * backstop, since scheduling delay under load (queueing behind other
work in {@link #VALIDATION_POOL})
- * is not the thing being defended against. Second, once running, the
task is given the full
- * {@code remaining} share of the deadline as its own fresh compute
window - anchored to its actual start
- * time rather than to submission time - so a busy pool cannot eat into
the budget that is meant to cap
- * the validator's own work.
+ * This waits in two phases. First, {@code started} is awaited so the
calling thread learns exactly when the
+ * task begins running; this wait is bounded only by the generous
{@link #MAX_SCHEDULING_MILLIS} backstop,
+ * since scheduling delay under load (queueing behind other work in
{@link #VALIDATION_POOL}) is not the thing
+ * being defended against. Second, once the task is running, its
compute is bounded: when per-thread CPU
+ * timing is available (the normal case) the waiting thread polls the
validating thread's accumulated
+ * <i>CPU</i> time and trips only if that exceeds {@code remaining}, so
preemption and scheduling latency
+ * never shrink the budget - only real CPU work does. When CPU timing
is unavailable, it falls back to the
+ * original wall-clock window anchored to the task's actual start time.
*
* <p>
* Package-private (rather than private) purely so this can be
exercised directly against a test-local
- * {@link Future}/latch pair - deterministically simulating scheduling
delay - without needing to saturate
- * the shared {@link #VALIDATION_POOL}.
+ * {@link Future}/latch pair - deterministically simulating scheduling
delay, a CPU burn, or a sleep that
+ * elapses wall-clock without consuming CPU - without needing to
saturate the shared {@link #VALIDATION_POOL}.
*
* @param future The in-flight (or already-complete) validation task.
* @param started Counted down by the task as its first instruction,
once it actually begins running.
- * @param startedAtNanos Set by the task (before counting down {@code
started}) to {@link System#nanoTime()}.
+ * @param taskStart Set by the task (before counting down {@code
started}) to its {@link TaskStart} snapshot.
* @param remaining The caller's remaining share of the shared
deadline, in nanoseconds, captured before submission.
*/
- static void awaitBounded(Future<?> future, CountDownLatch started,
AtomicLong startedAtNanos, long remaining) {
+ static void awaitBounded(Future<?> future, CountDownLatch started,
AtomicReference<TaskStart> taskStart, long remaining) {
try {
if (! started.await(MAX_SCHEDULING_MILLIS,
MILLISECONDS)) {
future.cancel(true);
throw new
McpException(McpRevision.CODE_INVALID_PARAMS, "Tool input schema validation
could not be scheduled within " + MAX_SCHEDULING_MILLIS + " ms");
}
- var computeRemainingNanos = remaining -
(System.nanoTime() - startedAtNanos.get());
- future.get(Math.max(0, computeRemainingNanos),
NANOSECONDS);
+ var start = taskStart.get();
+ if (CPU_TIME_SUPPORTED && start.cpuNanos() >= 0)
+ awaitByCpuTime(future, start.threadId(),
start.cpuNanos(), remaining);
+ else
+ future.get(Math.max(0, remaining -
(System.nanoTime() - start.wallNanos())), NANOSECONDS); // HTT: wall-clock
fallback; CI JVMs use the CPU-time path.
} catch (TimeoutException e) {
future.cancel(true);
throw validationTimeoutException();
@@ -190,6 +260,45 @@ final class McpSchemaSafety {
}
}
+ /**
+ * Bounds a running validation by the validating thread's actual CPU
time.
+ *
+ * <p>
+ * The waiting thread polls at {@link #CPU_POLL_INTERVAL_NANOS}
intervals: each time the task hasn't finished
+ * yet, it re-samples thread CPU time and keeps waiting as long as the
CPU consumed since {@code baseCpuNanos}
+ * is within {@code budgetNanos}. Wall-clock elapsed while the thread
was preempted (or deliberately sleeping)
+ * accrues no CPU, so it never trips the budget; a schema that
genuinely burns CPU does. Validation is pure
+ * in-memory work with no I/O or blocking, so CPU accrual is a
sufficient bound and no wall-clock ceiling is
+ * needed here - a task that never returns would necessarily be burning
CPU and will trip the budget.
+ *
+ * @param future The running validation task.
+ * @param threadId The validating thread's id.
+ * @param baseCpuNanos The validating thread's CPU-time baseline
captured at task start.
+ * @param budgetNanos The maximum CPU time, in nanoseconds, the
validation may consume.
+ * @throws TimeoutException If the CPU budget is exceeded while the
task is still running.
+ */
+ private static void awaitByCpuTime(Future<?> future, long threadId,
long baseCpuNanos, long budgetNanos)
+ throws InterruptedException, ExecutionException,
TimeoutException {
+ while (true) {
+ try {
+ future.get(CPU_POLL_INTERVAL_NANOS,
NANOSECONDS);
+ return;
+ } catch (TimeoutException poll) {
+ var cpuNow =
THREAD_MX.getThreadCpuTime(threadId);
+ if (cpuNow >= 0 && cpuNow - baseCpuNanos <=
budgetNanos)
+ continue; // real CPU work still
within budget; preemption/scheduling doesn't count against it
+ if (future.isDone())
+ continue; // finished inside the
sampling race window; the next get() harvests its result/exception
+ throw poll; // CPU work exceeded the DoS
budget (or CPU timing was lost) while the task is still running
+ }
+ }
+ }
+
+ /** Whether the compute budget is charged against thread CPU time (vs.
the wall-clock fallback); for tests. */
+ static boolean cpuTimeBudgetEnabled() {
+ return CPU_TIME_SUPPORTED;
+ }
+
private static McpException validationTimeoutException() {
return new McpException(McpRevision.CODE_INVALID_PARAMS, "Tool
input schema validation exceeded " + MAX_VALIDATION_MILLIS + " ms");
}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
index 22af41ae8f..fd4855c23f 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
@@ -41,7 +41,7 @@ import org.junit.jupiter.api.*;
/**
* Coverage for {@link McpSchemaSafety}: no-fetch handling of external {@code
$ref}s and bounded
- * (depth / node / wall-clock) schema validation (Resolution B2).
+ * (depth / node / CPU-time) schema validation (Resolution B2).
*/
class McpSchemaSafety_Test {
@@ -159,7 +159,7 @@ class McpSchemaSafety_Test {
assertContains("node count", e.getMessage());
}
- // -------- bounded wall-clock ---------
+ // -------- bounded compute (thread CPU time, with wall-clock fallback)
---------
@Test
void d01_adversarialValidation_terminatesWithinDeadline() {
@@ -190,23 +190,107 @@ class McpSchemaSafety_Test {
// deliberately delays counting down `started` well past
MAX_VALIDATION_MILLIS before doing its
// (instantaneous) "work". If scheduling latency were - the
regression this guards against - counted
// against the compute budget, awaitBounded() would throw a
timeout error despite the task's own
- // work costing nothing; with the root-cause fix, only compute
time (measured from `started`) counts.
+ // work costing nothing; with the root-cause fix, only compute
time (measured from the task's start
+ // snapshot) counts.
var schedulingDelayMillis = 3 *
McpSchemaSafety.MAX_VALIDATION_MILLIS;
assertTrue(schedulingDelayMillis <
McpSchemaSafety.MAX_SCHEDULING_MILLIS, "test fixture assumption");
var started = new CountDownLatch(1);
- var startedAtNanos = new AtomicLong();
+ var taskStart = new
AtomicReference<McpSchemaSafety.TaskStart>();
var executor = Executors.newSingleThreadExecutor();
try {
var future = executor.submit(() -> {
Thread.sleep(schedulingDelayMillis); //
simulates thread-pool queueing/scheduling delay
- startedAtNanos.set(System.nanoTime());
+
taskStart.set(McpSchemaSafety.TaskStart.capture());
started.countDown();
return null; // the "validation" work itself
is instantaneous
});
assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
- future, started, startedAtNanos,
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+ future, started, taskStart,
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @SuppressWarnings({
+ "java:S2925" // Thread.sleep here deterministically models a
validation that elapses wall-clock without consuming CPU, not a wait-and-hope
delay.
+ })
+ @Test
+ void d03_sleepingValidation_doesNotTripCpuBudget() {
+ // The core of the wall-clock->CPU-time fix: a "validation"
that lets a lot of wall-clock elapse but
+ // consumes ~no CPU (here, by sleeping *after* its start
snapshot) must NOT trip the compute budget,
+ // because only real CPU work is the DoS threat. Under the old
wall-clock measurement this would have
+ // tripped MAX_VALIDATION_MILLIS. Only meaningful when CPU
timing is active (otherwise awaitBounded
+ // legitimately falls back to wall-clock, under which a sleep
does count).
+ Assumptions.assumeTrue(McpSchemaSafety.cpuTimeBudgetEnabled(),
"per-thread CPU timing unavailable on this JVM");
+ var sleepMillis = 4 * McpSchemaSafety.MAX_VALIDATION_MILLIS;
// far past the budget in wall-clock terms
+ assertTrue(sleepMillis < McpSchemaSafety.MAX_SCHEDULING_MILLIS,
"test fixture assumption");
+
+ var started = new CountDownLatch(1);
+ var taskStart = new
AtomicReference<McpSchemaSafety.TaskStart>();
+ var executor = Executors.newSingleThreadExecutor();
+ try {
+ var future = executor.submit(() -> {
+
taskStart.set(McpSchemaSafety.TaskStart.capture());
+ started.countDown();
+ Thread.sleep(sleepMillis); // wall-clock
elapses well past the budget, but burns ~no CPU
+ return null;
+ });
+
+ assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
+ future, started, taskStart,
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void d04_cpuBurningValidation_tripsBudget() {
+ // The complement of d03: a "validation" that actually burns
CPU past the budget MUST still trip it and
+ // raise the same -32602 error. A generous CPU-burn margin
(many multiples of the budget) keeps this
+ // robust rather than racing a tight wall-clock bound.
+ var started = new CountDownLatch(1);
+ var taskStart = new
AtomicReference<McpSchemaSafety.TaskStart>();
+ var executor = Executors.newSingleThreadExecutor();
+ try {
+ var future = executor.submit(() -> {
+
taskStart.set(McpSchemaSafety.TaskStart.capture());
+ started.countDown();
+ var sink = 0L;
+ while (!
Thread.currentThread().isInterrupted()) // spins until awaitBounded trips the
budget and cancels us
+ for (var i = 1; i < 5_000_000; i++)
+ sink += (long) Math.sqrt(i) * i;
+ return sink; // returned only so the JIT can't
elide the loop
+ });
+
+ var e = assertThrows(McpException.class, () ->
McpSchemaSafety.awaitBounded(
+ future, started, taskStart,
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+ assertEquals(-32602, e.getCode());
+ assertContains("exceeded " +
McpSchemaSafety.MAX_VALIDATION_MILLIS + " ms", e.getMessage());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void d05_cpuTimeUnavailable_fallsBackToWallClock() {
+ // When per-thread CPU timing is unavailable (cpuNanos == -1 in
the start snapshot), awaitBounded must
+ // fall back to the original wall-clock budget so the guard
still functions on such JVMs. Simulated here
+ // by handing awaitBounded a snapshot with cpuNanos == -1; the
instantaneous task completes well within
+ // the wall-clock window.
+ var started = new CountDownLatch(1);
+ var taskStart = new
AtomicReference<McpSchemaSafety.TaskStart>();
+ var executor = Executors.newSingleThreadExecutor();
+ try {
+ var future = executor.submit(() -> {
+ taskStart.set(new
McpSchemaSafety.TaskStart(Thread.currentThread().getId(), System.nanoTime(),
-1L));
+ started.countDown();
+ return null; // instantaneous work, well
within the wall-clock window
+ });
+
+ assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
+ future, started, taskStart,
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
} finally {
executor.shutdownNow();
}
diff --git
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
index e01c7976b0..fcbaa8bcca 100644
---
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
+++
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
@@ -66,9 +66,14 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
/** Default template-cache flag — {@code true} (production-safe).
*/
public static final boolean DEFAULT_CACHE_TEMPLATES = true;
+ /** Default field-exposure flag — {@code true} (public-field DTOs
render out of the box). */
+ public static final boolean DEFAULT_EXPOSE_FIELDS = true;
+
private final String basePath;
private final String templateSuffix;
private final boolean cacheTemplates;
+ private final boolean exposeFields;
+ private final ObjectWrapper objectWrapper;
// Lazy bridge-default configuration. Built on first call to
resolveConfiguration(...) when
// no Configuration bean is registered in the request's BeanStore.
Volatile so the
@@ -96,6 +101,8 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
basePath = builder.basePath;
templateSuffix = builder.templateSuffix;
cacheTemplates = builder.cacheTemplates;
+ exposeFields = builder.exposeFields;
+ objectWrapper = builder.objectWrapper;
}
/**
@@ -125,6 +132,25 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
return cacheTemplates;
}
+ /**
+ * Returns the field-exposure flag applied to the bridge-default object
wrapper.
+ *
+ * @return The field-exposure flag. Ignored once {@link
#getObjectWrapper()} returns non-{@code null}.
+ */
+ public boolean isExposeFields() {
+ return exposeFields;
+ }
+
+ /**
+ * Returns the user-supplied {@link ObjectWrapper} override, if any.
+ *
+ * @return The object wrapper override, or {@code null} if the bridge
builds its own default
+ * (a {@link DefaultObjectWrapper} configured per {@link
#isExposeFields() isExposeFields()}).
+ */
+ public ObjectWrapper getObjectWrapper() {
+ return objectWrapper;
+ }
+
/**
* Appends {@link #getTemplateSuffix()} to {@code name} if not already
present (idempotent).
*
@@ -183,9 +209,16 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
* pins {@code IncompatibleImprovements} to {@link
Configuration#VERSION_2_3_34} so behavior is
* stable across consumer upgrades of {@code
org.freemarker:freemarker}; sets
* {@code DefaultEncoding} to {@code UTF-8} and {@code OutputFormat} to
- * {@link HTMLOutputFormat#INSTANCE} so HTML escaping is the natural
target; and applies
+ * {@link HTMLOutputFormat#INSTANCE} so HTML escaping is the natural
target; applies
* {@code TemplateUpdateDelayMilliseconds} per the configured
- * {@link #isCacheTemplates() cacheTemplates} flag.
+ * {@link #isCacheTemplates() cacheTemplates} flag; and sets an {@code
ObjectWrapper} —
+ * either the user-supplied {@link #getObjectWrapper() objectWrapper}
override, or (by default)
+ * a {@link DefaultObjectWrapper} built with {@code exposeFields} set to
+ * {@link #isExposeFields() isExposeFields()}. Field exposure defaults
to {@code true} (unlike
+ * FreeMarker's own version-default wrapper, which is getter-only):
Juneau's own marshalling is
+ * comfortable with public-field beans elsewhere, so a view-model DTO
written with public fields
+ * and no getters renders its field values here too, instead of
silently resolving to
+ * {@code null}/missing.
*
* <p>
* Subclasses may override to plug in custom loaders / encodings /
output formats without
@@ -199,9 +232,23 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
cfg.setDefaultEncoding("UTF-8");
cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);
cfg.setTemplateUpdateDelayMilliseconds(cacheTemplates ?
Long.MAX_VALUE : 0L);
+ cfg.setObjectWrapper(objectWrapper != null ? objectWrapper :
buildDefaultObjectWrapper());
return cfg;
}
+ /**
+ * Builds the bridge-default {@link ObjectWrapper} — a {@link
DefaultObjectWrapper} with
+ * {@code exposeFields} set per {@link #isExposeFields()
isExposeFields()}. Only called when no
+ * {@link #getObjectWrapper() objectWrapper} override has been supplied.
+ *
+ * @return A new {@link DefaultObjectWrapper} instance.
+ */
+ private ObjectWrapper buildDefaultObjectWrapper() {
+ var b = new
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34);
+ b.setExposeFields(exposeFields);
+ return b.build();
+ }
+
/**
* Translates a virtual base path (e.g. {@code "/templates/"}) into a
FreeMarker
* {@code ClassLoaderTemplateLoader} resource root (e.g. {@code
"/templates"}).
@@ -314,6 +361,8 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
String basePath = DEFAULT_BASE_PATH;
String templateSuffix = DEFAULT_TEMPLATE_SUFFIX;
boolean cacheTemplates = DEFAULT_CACHE_TEMPLATES;
+ boolean exposeFields = DEFAULT_EXPOSE_FIELDS;
+ ObjectWrapper objectWrapper;
/** Constructor — package access for {@link
FreemarkerDispatcher#create()}. */
protected Builder() {}
@@ -354,6 +403,42 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
return this;
}
+ /**
+ * Sets whether the bridge-default {@code ObjectWrapper}
exposes public fields (not just
+ * JavaBean getters) to templates.
+ *
+ * <p>
+ * Defaults to {@link
FreemarkerDispatcher#DEFAULT_EXPOSE_FIELDS true} so simple view-model
+ * DTOs written with public fields and no getters render their
field values instead of
+ * silently resolving to {@code null}/missing. Has no effect
once {@link #objectWrapper}
+ * has been called with a non-{@code null} value — the
explicit wrapper always wins.
+ *
+ * @param value The field-exposure flag.
+ * @return This object.
+ */
+ public Builder exposeFields(boolean value) {
+ exposeFields = value;
+ return this;
+ }
+
+ /**
+ * Sets a fully custom {@code ObjectWrapper} for the
bridge-default configuration,
+ * overriding {@link #exposeFields(boolean)} entirely.
+ *
+ * <p>
+ * Escape hatch for consumers who need full control (e.g. a
{@code BeansWrapper} with
+ * custom method/property exposure, or a third-party wrapper)
without hand-rolling and
+ * registering a whole replacement {@code Configuration} bean.
+ *
+ * @param value The object wrapper. {@code null} reverts to the
bridge-built
+ * {@link DefaultObjectWrapper} (per {@link
#exposeFields(boolean)}).
+ * @return This object.
+ */
+ public Builder objectWrapper(ObjectWrapper value) {
+ objectWrapper = value;
+ return this;
+ }
+
/**
* Reads the current base path setting (test/inspection helper).
*
@@ -381,6 +466,24 @@ public class FreemarkerDispatcher implements
RawTemplateDispatcher {
return cacheTemplates;
}
+ /**
+ * Reads the current field-exposure setting (test/inspection
helper).
+ *
+ * @return The field-exposure flag.
+ */
+ public boolean isExposeFields() {
+ return exposeFields;
+ }
+
+ /**
+ * Reads the current object-wrapper override (test/inspection
helper).
+ *
+ * @return The object wrapper override, or {@code null} if none
has been set.
+ */
+ public ObjectWrapper getObjectWrapper() {
+ return objectWrapper;
+ }
+
/**
* Builds the {@link FreemarkerDispatcher}.
*
diff --git
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
index b4d0de7c16..20291fd129 100644
---
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
+++
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
@@ -37,12 +37,46 @@ import freemarker.template.*;
* the importer's classpath by asking the configured {@link
Configuration} for the named
* template and rendering it with an empty data model (raw render
path; callers who want
* attributes use {@link FreemarkerView} from a typed handler
instead).
- * <li>Picks up {@link FreemarkerViewRenderer} automatically via the
mixin's
- * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
declaration, so
- * {@code @RestOp}-method return values of type {@link
FreemarkerView} render through the
- * FreeMarker engine without any additional wiring.
+ * <li>Registers {@link FreemarkerViewRenderer} for the mixin's <b>own</b>
endpoints (e.g. the
+ * {@code /freemarker/*} route above) via the mixin's own
+ * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
declaration.
* </ol>
*
+ * <h5 class='section'>Auto-wiring the renderer into the host's own
endpoints:</h5>
+ *
+ * <p>
+ * A bare {@code @Rest(mixins=FreemarkerMixin.class)} is enough — a host
whose own {@code @RestOp} method
+ * returns {@link FreemarkerView} renders it through the FreeMarker engine
with no extra wiring. That works
+ * because {@code FreemarkerMixin} declares
+ * {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)} on its own class,
+ * so the framework folds {@code FreemarkerMixin}'s {@link
FreemarkerViewRenderer} into the host's own
+ * response-processor chain automatically.
+ *
+ * <p>
+ * This fold is per-mixin, opt-in, and response-processor-scoped: it happens
only because
+ * {@code FreemarkerMixin} declares the opt-in (a mixin that does not is still
scoped to its own endpoints, the
+ * standard {@link Rest#mixins() @Rest(mixins=...)} rule), and it folds only
the renderer — never
+ * {@code guards}, {@code serializers}, or any other list-shaped attribute.
+ *
+ * <p>
+ * Two explicit wirings remain supported — useful when you want to be
explicit, or to fold more than the
+ * renderer:
+ *
+ * <ol class='spaced-list'>
+ * <li><b>{@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)}</b> via
the rich {@link Rest#mixinDefs()
+ * mixinDefs} form — folds <b>all</b> of {@code
FreemarkerMixin}'s list-shaped {@code @Rest}
+ * attributes into the host's own chain, not just the renderer.
+ * <li><b>List {@link FreemarkerViewRenderer FreemarkerViewRenderer.class}
explicitly</b> in the host's own
+ * {@code @Rest(responseProcessors=...)} — the fully manual
equivalent.
+ * </ol>
+ *
+ * <p>
+ * The {@link org.apache.juneau.rest.server.processor.ResponseProcessorList}
partition pass repositions
+ * {@link FreemarkerViewRenderer} (a
+ * {@link org.apache.juneau.rest.server.view.ViewRenderer ViewRenderer}) ahead
of
+ * {@code SerializedPojoProcessor} in every case, so the {@link
FreemarkerView} bean is dispatched to the
+ * FreeMarker engine rather than bean-serialized.
+ *
* <h5 class='figure'>Composition example (microservice):</h5>
*
* <p class='bjava'>
@@ -102,16 +136,33 @@ import freemarker.template.*;
* {@code ClassLoaderTemplateResolver}-equivalent prefix {@code
"/templates"}). The default
* pins {@code IncompatibleImprovements} to the bridge-tested
minor version
* ({@code Configuration.VERSION_2_3_34}), sets {@code
DefaultEncoding} to {@code UTF-8},
- * uses {@code HTMLOutputFormat} so HTML escaping is the natural
target, and applies
+ * uses {@code HTMLOutputFormat} so HTML escaping is the natural
target, applies
* {@code TemplateUpdateDelayMilliseconds} per the {@link
#isCacheTemplates() cacheTemplates}
* flag (production-safe by default; users opt into hot-reload via
- * {@link Builder#cacheTemplates(boolean) cacheTemplates(false)}).
+ * {@link Builder#cacheTemplates(boolean) cacheTemplates(false)}),
and sets an
+ * {@code ObjectWrapper} with {@code exposeFields=true} (see
below) unless overridden.
* </ul>
*
* <p>
* When no FreeMarker engine is on the classpath, the renderer surfaces
* {@link FreemarkerViewRenderer#NO_ENGINE_DIAGNOSTIC} naming the missing
dependency.
*
+ * <h5 class='section'>Public-field DTOs (getter-only default trap):</h5>
+ *
+ * <p>
+ * FreeMarker's own version-default {@code ObjectWrapper} exposes only
JavaBean getters — a
+ * view-model bean written as a simple DTO with public fields and no getters
resolves every
+ * {@code ${bean.field}} reference to {@code null}/missing, <b>silently</b> (a
template using
+ * {@code !'default'} fallbacks renders the default with no error at all; a
bare {@code ${bean.field}}
+ * with no fallback throws at render time). This bridge avoids that trap by
default: the bridge-built
+ * {@link Configuration} sets a {@link DefaultObjectWrapper} with {@code
exposeFields=true}
+ * ({@link #DEFAULT_EXPOSE_FIELDS}), so public-field DTOs render their field
values out of the
+ * box — matching how Juneau's own marshalling is comfortable with
public-field beans elsewhere.
+ * Use {@link Builder#exposeFields(boolean) exposeFields(false)} to restore
FreeMarker's getter-only
+ * behavior, or {@link Builder#objectWrapper(ObjectWrapper)
objectWrapper(...)} for full control
+ * (e.g. a {@code BeansWrapper} with custom exposure rules). Both knobs only
affect the
+ * bridge-default {@link Configuration}; a user-supplied {@code @Bean
Configuration} is used as-is.
+ *
* <h5 class='section'>Template suffix:</h5>
*
* <p>
@@ -155,7 +206,8 @@ import freemarker.template.*;
*/
// @formatter:off
@Rest(
- responseProcessors={FreemarkerViewRenderer.class}
+ responseProcessors={FreemarkerViewRenderer.class},
+ mergeResponseProcessorsIntoHost=true
)
public class FreemarkerMixin {
@@ -168,6 +220,9 @@ public class FreemarkerMixin {
/** Default template-cache flag — {@code true} (production-safe).
*/
public static final boolean DEFAULT_CACHE_TEMPLATES =
FreemarkerDispatcher.DEFAULT_CACHE_TEMPLATES;
+ /** Default field-exposure flag — {@code true} (public-field DTOs
render out of the box). */
+ public static final boolean DEFAULT_EXPOSE_FIELDS =
FreemarkerDispatcher.DEFAULT_EXPOSE_FIELDS;
+
private final FreemarkerDispatcher worker;
/**
@@ -229,6 +284,24 @@ public class FreemarkerMixin {
return worker.isCacheTemplates();
}
+ /**
+ * Returns the field-exposure flag applied to the bridge-default object
wrapper.
+ *
+ * @return The field-exposure flag. Ignored once {@link
#getObjectWrapper()} returns non-{@code null}.
+ */
+ public boolean isExposeFields() {
+ return worker.isExposeFields();
+ }
+
+ /**
+ * Returns the user-supplied {@link ObjectWrapper} override, if any.
+ *
+ * @return The object wrapper override, or {@code null} if the bridge
builds its own default.
+ */
+ public ObjectWrapper getObjectWrapper() {
+ return worker.getObjectWrapper();
+ }
+
/**
* Appends {@link #getTemplateSuffix()} to {@code name} if not already
present (idempotent).
*
@@ -357,6 +430,43 @@ public class FreemarkerMixin {
return this;
}
+ /**
+ * Sets whether the bridge-default {@code ObjectWrapper}
exposes public fields (not just
+ * JavaBean getters) to templates.
+ *
+ * <p>
+ * Defaults to {@link FreemarkerMixin#DEFAULT_EXPOSE_FIELDS
true} so simple view-model
+ * DTOs written with public fields and no getters render their
field values instead of
+ * silently resolving to {@code null}/missing (see the
class-level
+ * "Public-field DTOs" section). Has no effect once {@link
#objectWrapper(ObjectWrapper)}
+ * has been called with a non-{@code null} value — the
explicit wrapper always wins.
+ *
+ * @param value The field-exposure flag.
+ * @return This object.
+ */
+ public Builder exposeFields(boolean value) {
+ worker.exposeFields(value);
+ return this;
+ }
+
+ /**
+ * Sets a fully custom {@code ObjectWrapper} for the
bridge-default configuration,
+ * overriding {@link #exposeFields(boolean)} entirely.
+ *
+ * <p>
+ * Escape hatch for consumers who need full control (e.g. a
{@code BeansWrapper} with
+ * custom method/property exposure, or a third-party wrapper)
without hand-rolling and
+ * registering a whole replacement {@code Configuration} bean.
+ *
+ * @param value The object wrapper. {@code null} reverts to the
bridge-built
+ * {@link DefaultObjectWrapper} (per {@link
#exposeFields(boolean)}).
+ * @return This object.
+ */
+ public Builder objectWrapper(ObjectWrapper value) {
+ worker.objectWrapper(value);
+ return this;
+ }
+
/**
* Reads the current base path setting (test/inspection helper).
*
@@ -384,6 +494,24 @@ public class FreemarkerMixin {
return worker.isCacheTemplates();
}
+ /**
+ * Reads the current field-exposure setting (test/inspection
helper).
+ *
+ * @return The field-exposure flag.
+ */
+ public boolean isExposeFields() {
+ return worker.isExposeFields();
+ }
+
+ /**
+ * Reads the current object-wrapper override (test/inspection
helper).
+ *
+ * @return The object wrapper override, or {@code null} if none
has been set.
+ */
+ public ObjectWrapper getObjectWrapper() {
+ return worker.getObjectWrapper();
+ }
+
/**
* Builds the {@link FreemarkerMixin}.
*
diff --git
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
index 11aeafab91..a80458fa3a 100644
---
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
+++
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.*;
+import java.util.*;
import org.apache.juneau.*;
import org.apache.juneau.commons.inject.*;
@@ -29,6 +30,7 @@ import org.junit.jupiter.api.*;
import freemarker.cache.*;
import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapperBuilder;
/**
* Unit tests for {@link FreemarkerDispatcher#render(String, RestRequest,
RestResponse) render(...)} and
@@ -178,4 +180,55 @@ class FreemarkerDispatcher_Test extends TestBase {
var cfg = dispatcher.buildDefaultConfiguration();
assertEquals(Long.MAX_VALUE,
cfg.getTemplateUpdateDelayMilliseconds());
}
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section D: buildDefaultConfiguration object-wrapper / exposeFields
behavior
+ *
----------------------------------------------------------------------------------------
*/
+
+ /** View-model DTO with a public field and no getter — the shape that
bit the dogfooded consumer. */
+ public static class D_FieldOnlyBean {
+ public String name = "Alice";
+ }
+
+ private static String renderToString(Configuration cfg, String
template, Object bean) throws Exception {
+ var loader = new StringTemplateLoader();
+ loader.putTemplate("t", template);
+ cfg.setTemplateLoader(loader);
+ var sw = new StringWriter();
+ cfg.getTemplate("t").process(Map.of("bean", bean), sw);
+ return sw.toString();
+ }
+
+ @Test void
d01_buildDefaultConfiguration_exposeFieldsDefaultTrue_rendersPublicFieldValue()
throws Exception {
+ var dispatcher = FreemarkerDispatcher.create().build();
+ var cfg = dispatcher.buildDefaultConfiguration();
+
+ assertEquals("Alice", renderToString(cfg, "${bean.name}", new
D_FieldOnlyBean()));
+ }
+
+ @Test void d02_exposeFieldsFalse_publicFieldIsInvisibleToTemplates()
throws Exception {
+ var dispatcher =
FreemarkerDispatcher.create().exposeFields(false).build();
+ var cfg = dispatcher.buildDefaultConfiguration();
+
+ // With exposeFields=false (FreeMarker's own version-default
behavior), the public field
+ // isn't visible as a bean property, so a defaulted reference
falls through silently.
+ assertEquals("MISSING", renderToString(cfg,
"${bean.name!'MISSING'}", new D_FieldOnlyBean()));
+ }
+
+ @Test void d03_objectWrapperOverride_takesPrecedenceOverExposeFields() {
+ var b = new
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34);
+ b.setExposeFields(false);
+ var custom = b.build();
+ var dispatcher =
FreemarkerDispatcher.create().exposeFields(true).objectWrapper(custom).build();
+
+ var cfg = dispatcher.buildDefaultConfiguration();
+
+ assertSame(custom, cfg.getObjectWrapper());
+ }
+
+ @Test void d04_exposeFieldsAndObjectWrapperDefaultToTrueAndNull() {
+ var dispatcher = FreemarkerDispatcher.create().build();
+ assertTrue(dispatcher.isExposeFields());
+ assertNull(dispatcher.getObjectWrapper());
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
index 13bc53438c..b0a41f350c 100644
---
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
+++
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
@@ -138,6 +138,36 @@ class FreemarkerMixin_Builder_Test extends TestBase {
assertNotNull(c1.getTemplateLoader());
}
+ @Test void a14_exposeFieldsDefaultsTrue() {
+ var r = FreemarkerMixin.create().build();
+ assertTrue(r.isExposeFields());
+ }
+
+ @Test void a15_exposeFieldsSetterRoundTrips() {
+ var r = FreemarkerMixin.create().exposeFields(false).build();
+ assertFalse(r.isExposeFields());
+ }
+
+ @Test void a16_objectWrapperDefaultsNull() {
+ var r = FreemarkerMixin.create().build();
+ assertNull(r.getObjectWrapper());
+ }
+
+ @Test void a17_objectWrapperSetterRoundTrips() {
+ var wrapper = new
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34).build();
+ var r = FreemarkerMixin.create().objectWrapper(wrapper).build();
+ assertSame(wrapper, r.getObjectWrapper());
+ }
+
+ @Test void
a18_builderReadersReflectExposeFieldsAndObjectWrapperMutations() {
+ var wrapper = new
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34).build();
+ var b = FreemarkerMixin.create()
+ .exposeFields(false)
+ .objectWrapper(wrapper);
+ assertFalse(b.isExposeFields());
+ assertSame(wrapper, b.getObjectWrapper());
+ }
+
/*
----------------------------------------------------------------------------------------
*
* Section B: applyTemplateSuffix helper (idempotent appender)
*
----------------------------------------------------------------------------------------
*/
diff --git
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
index 4ef847187a..79ad89e3d6 100644
---
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
+++
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
@@ -42,35 +42,41 @@ import org.apache.juneau.rest.server.view.*;
* <h5 class='section'>Auto-wiring the renderer into the host's own
endpoints:</h5>
*
* <p>
- * By default a mixin's {@code responseProcessors} apply only to the mixin's
own endpoints — a host's own
- * {@code @RestOp} methods see only the host's chain (the {@link Rest#mixins()
@Rest(mixins=...)} rule "host's
- * chain runs first, then the mixin's appended; host endpoints see only the
host's chain"). So with a bare
- * {@code @Rest(mixins=JspMixin.class)}, a host whose own {@code @RestOp}
returns {@link JspView} would have the
- * framework's default {@code SerializedPojoProcessor} bean-serialize it
instead of dispatching it to the JSP
- * engine. Two ways to make the host's own {@code JspView} returns reach
{@link JspViewRenderer}:
+ * A bare {@code @Rest(mixins=JspMixin.class)} is enough — a host whose
own {@code @RestOp} method returns
+ * {@link JspView} renders it through the JSP engine with no extra wiring.
That works because {@code JspMixin}
+ * declares {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)} on its own
+ * class, so the framework folds {@code JspMixin}'s {@link JspViewRenderer}
into the host's own response-processor
+ * chain automatically.
+ *
+ * <p>
+ * This fold is per-mixin, opt-in, and response-processor-scoped: it happens
only because {@code JspMixin}
+ * declares the opt-in (a mixin that does not is still scoped to its own
endpoints, the standard
+ * {@link Rest#mixins() @Rest(mixins=...)} rule), and it folds only the
renderer — never {@code guards},
+ * {@code serializers}, or any other list-shaped attribute.
+ *
+ * <p>
+ * Two explicit wirings remain supported — useful when you want to be
explicit, or to fold more than the
+ * renderer:
*
* <ol class='spaced-list'>
- * <li><b>Opt in via {@link Mixin#mergeIntoHost()
@Mixin(mergeIntoHost=true)}</b> (recommended) — the
- * host declares the mixin through the rich {@link
Rest#mixinDefs() mixinDefs} form with
- * {@code mergeIntoHost=true}, which folds {@code JspMixin}'s
{@code @Rest(responseProcessors=...)} (and any
- * other list-shaped attributes) into the host's own chain. No
need to repeat
- * {@link JspViewRenderer JspViewRenderer.class} on the host.
+ * <li><b>{@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)}</b> via
the rich {@link Rest#mixinDefs()
+ * mixinDefs} form — folds <b>all</b> of {@code JspMixin}'s
list-shaped {@code @Rest} attributes into
+ * the host's own chain, not just the renderer.
* <li><b>List {@link JspViewRenderer JspViewRenderer.class}
explicitly</b> in the host's own
- * {@code @Rest(responseProcessors=...)} — the manual
equivalent, useful when the host does not
- * declare the mixin via {@code mixinDefs}.
+ * {@code @Rest(responseProcessors=...)} — the fully manual
equivalent.
* </ol>
*
* <p>
* The {@link org.apache.juneau.rest.server.processor.ResponseProcessorList}
partition pass repositions
* {@link JspViewRenderer} (a
* {@link org.apache.juneau.rest.server.view.ViewRenderer ViewRenderer}) ahead
of
- * {@code SerializedPojoProcessor} in either case, so the {@link JspView} bean
is dispatched to the JSP engine
+ * {@code SerializedPojoProcessor} in every case, so the {@link JspView} bean
is dispatched to the JSP engine
* rather than bean-serialized.
*
* <h5 class='figure'>Composition example (microservice):</h5>
*
* <p class='bjava'>
- * <ja>@Rest</ja>(path=<js>"/app"</js>,
mixinDefs=<ja>@Mixin</ja>(type=JspMixin.<jk>class</jk>,
mergeIntoHost=<jk>true</jk>))
+ * <ja>@Rest</ja>(path=<js>"/app"</js>, mixins=JspMixin.<jk>class</jk>)
* <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
*
* <ja>@Bean</ja> JspMixin jsp() {
@@ -162,7 +168,8 @@ import org.apache.juneau.rest.server.view.*;
*/
// @formatter:off
@Rest(
- responseProcessors={JspViewRenderer.class}
+ responseProcessors={JspViewRenderer.class},
+ mergeResponseProcessorsIntoHost=true
)
public class JspMixin {
diff --git
a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
index 768857a114..678f755422 100644
---
a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
+++
b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
@@ -40,8 +40,10 @@ import org.junit.jupiter.api.*;
*
* <p>
* Fixture {@code A} declares {@code
@Rest(responseProcessors=JspViewRenderer.class)} directly
- * (the pattern proven to work by {@code JspView_TypedHandler_Test} in {@code
juneau-integration-tests}),
- * rather than relying on {@code @Rest(mixins=JspMixin.class)} alone — see
{@code z01} below for why.
+ * (the pattern proven to work by {@code JspView_TypedHandler_Test} in {@code
juneau-integration-tests}).
+ * A bare {@code @Rest(mixins=JspMixin.class)} now reaches the same code path
too, because {@code JspMixin}
+ * declares {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)} on its
+ * own class, folding {@link JspViewRenderer} into the host's own chain — see
{@code z01} below.
*
* @since 10.0.0
*/
@@ -71,19 +73,13 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
private static final MockRestClient c =
MockRestClient.buildLax(A.class);
- // A BARE @Rest(mixins=JspMixin.class) does NOT route a HOST-defined
@RestGet method's
- // JspView return value through JspViewRenderer -- this is the default,
deliberately-isolated behavior.
- // RestContext#getRestAnnotationsForProperty resolves a HOST context's
own responseProcessors chain from the
- // host's OWN @Rest annotation chain (ancestor classes); a mixin
class's @Rest(responseProcessors=...) is a
- // property of the MIXIN's own sub-context (consulted only for ops
declared directly on the mixin class
- // itself, e.g. JspMixin#render), and is NOT folded into the host's
list unless the host opts in. See
- // Rest#mixins() javadoc ("host's chain runs first, then the mixin's
appended. Host endpoints see only the
- // host's chain") and MixinInheritance_ResponseProcessors_Test#a02 in
juneau-integration-tests, which pins
- // this default isolation. To have a host's own JspView returns reach
JspViewRenderer, the host opts in via
- // @Mixin(mergeIntoHost=true) (see MergeIntoHost fixture + z02 below),
which folds the mixin's list-shaped
- // @Rest attributes (including responseProcessors) into the host's own
chain; the manual equivalent is
- // listing JspViewRenderer.class directly in the host's own
@Rest(responseProcessors=...) -- exactly like
- // this class's own fixture A above.
+ // A bare @Rest(mixins=JspMixin.class) DOES route a HOST-defined
@RestGet method's JspView return value
+ // through JspViewRenderer, because JspMixin declares
@Rest(mergeResponseProcessorsIntoHost=true) on its own
+ // class. That opt-in folds JspMixin's own
@Rest(responseProcessors=JspViewRenderer.class) into the host's
+ // own response-processor chain (response-processor-scoped only), so a
host op returning a JspView reaches
+ // the renderer with no extra wiring. See z01 below, JspMixin's class
javadoc, and
+ // MixinResponseProcessorFold_Test (rest-server) /
MixinInheritance_ResponseProcessors_Test
+ // (juneau-integration-tests), which pin the fold at the RestContext
and mock-client levels respectively.
@Rest(mixins=JspMixin.class)
public static class MixinOnly extends BasicRestServlet {
private static final long serialVersionUID = 1L;
@@ -95,6 +91,29 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
private static final MockRestClient cMixinOnly =
MockRestClient.buildLax(MixinOnly.class);
+ // Non-silent regression guard: a mixin that registers JspViewRenderer
but does NOT declare the
+ // mergeResponseProcessorsIntoHost opt-in stays scoped to its own
endpoints -- its responseProcessors are
+ // NOT folded into the host's chain, so a host op returning a JspView
falls back to bean-serialization.
+ // This is the deliberately-isolated default that JspMixin overrides by
opting in (see z03 vs z01).
+ @Rest(responseProcessors=JspViewRenderer.class)
+ public static class NoFoldMixin {
+ @RestGet(path="/mixin-only-endpoint")
+ public String noop() {
+ return "noop";
+ }
+ }
+
+ @Rest(mixins=NoFoldMixin.class)
+ public static class HostWithNoFoldMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/view")
+ public View view() {
+ return JspView.of("hello.jsp");
+ }
+ }
+
+ private static final MockRestClient cNoFoldMixin =
MockRestClient.buildLax(HostWithNoFoldMixin.class);
+
// Opt-in host: adopts JspMixin via the rich mixinDefs form with
mergeIntoHost=true, so the mixin's
// @Rest(responseProcessors=JspViewRenderer.class) folds into THIS
host's own chain.
@Rest(mixinDefs=@Mixin(type=JspMixin.class, mergeIntoHost=true))
@@ -108,10 +127,30 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
private static final MockRestClient cMergeIntoHost =
MockRestClient.buildLax(MergeIntoHost.class);
- @Test void z01_mixinAlone_doesNotRouteHostViewReturnThroughRenderer()
throws Exception {
- // Bean-serialized fallback (SerializedPojoProcessor won), NOT
JSP-dispatched -- the body contains the
- // JspView bean's own "templateName" field, which
JspViewRenderer's actual dispatch path never produces.
- var res =
cMixinOnly.get("/view").accept("application/json").run();
+ @Test void z01_mixinAlone_routesHostViewReturnThroughRenderer() throws
Exception {
+ // JspMixin declares
@Rest(mergeResponseProcessorsIntoHost=true), so a bare
@Rest(mixins=JspMixin.class)
+ // folds JspViewRenderer into the host's own chain: a host
@RestGet returning a JspView is CLAIMED by the
+ // renderer (not bean-serialized). With a well-behaved
dispatcher that commits the response, the render
+ // succeeds end-to-end (200) -- deterministic proof the fold
routes AND renders, not merely that it was
+ // intercepted. The companion null-dispatcher assertion below
pins that it reached the renderer rather than
+ // the default SerializedPojoProcessor (which would have
produced a 200 "templateName" body, as in z03).
+ var okCtx = fakeServletContext(new FakeDispatcher(() -> { /*
well-behaved engine committed the response */ }));
+
cMixinOnly.get("/view").servletContext(okCtx).run().assertStatus(200);
+
+ var noEngineCtx = fakeServletContext(null);
+ var res =
cMixinOnly.get("/view").servletContext(noEngineCtx).run();
+ res.assertStatus(500);
+ res.assertContent().asString().isContains("Could not resolve
RequestDispatcher");
+ res.assertContent().asString().isContains("No JSP engine is
available on the classpath");
+ }
+
+ @Test void
z03_nonOptedInMixin_doesNotRouteHostViewReturnThroughRenderer() throws
Exception {
+ // Regression guard for the non-silent contract: NoFoldMixin
registers JspViewRenderer but does NOT opt in
+ // via mergeResponseProcessorsIntoHost, so the renderer stays
scoped to the mixin's own endpoints and is
+ // NOT folded into the host's chain. The host's JspView return
therefore falls back to bean-serialization
+ // (SerializedPojoProcessor won) -- a 200 whose body contains
the JspView bean's own "templateName" field,
+ // which JspViewRenderer's dispatch path never produces.
Contrast with z01 (opted-in JspMixin routes).
+ var res =
cNoFoldMixin.get("/view").accept("application/json").run();
res.assertStatus(200);
res.assertContent().asString().isContains("templateName");
}
@@ -121,7 +160,7 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
// into the HOST's own chain, so a host @RestGet returning a
JspView is now CLAIMED by JspViewRenderer
// (not bean-serialized). With a ServletContext that resolves
no dispatcher, the renderer surfaces its
// NO_ENGINE_DIAGNOSTIC 500 -- deterministic proof the JspView
reached JspViewRenderer rather than the
- // default SerializedPojoProcessor (which would have produced a
200 "templateName" body as in z01).
+ // default SerializedPojoProcessor (which would have produced a
200 "templateName" body as in z03).
var ctx = fakeServletContext(null);
var res = cMergeIntoHost.get("/view").servletContext(ctx).run();
res.assertStatus(500);
diff --git
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
index 095810dc04..c46c5d8fdd 100644
---
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
+++
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
@@ -37,10 +37,12 @@ import com.github.mustachejava.*;
* importer's classpath by asking the configured {@link
MustacheFactory} to compile and
* render them with no scope (raw render path; callers who want
attributes use
* {@link MustacheView} from a typed handler instead).
- * <li>Picks up {@link MustacheViewRenderer} automatically via the mixin's
- * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
declaration, so
- * {@code @RestOp}-method return values of type {@link
MustacheView} render through the
- * Mustache engine without any additional wiring.
+ * <li>Routes the host's own {@code @RestOp}-method return values of type
{@link MustacheView} through the
+ * Mustache engine automatically. {@code MustacheMixin} declares
+ * {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)}, so a plain
+ * {@code @Rest(mixins=MustacheMixin.class)} folds its {@link
MustacheViewRenderer} into the host's own
+ * response-processor chain — no {@code mergeIntoHost} or
explicit {@code responseProcessors=} on the
+ * host needed.
* </ol>
*
* <h5 class='figure'>Composition example (microservice):</h5>
@@ -150,7 +152,8 @@ import com.github.mustachejava.*;
*/
// @formatter:off
@Rest(
- responseProcessors={MustacheViewRenderer.class}
+ responseProcessors={MustacheViewRenderer.class},
+ mergeResponseProcessorsIntoHost=true
)
public class MustacheMixin {
diff --git
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
index 42d9364f83..c7d53a4d2b 100644
---
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
+++
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
@@ -37,10 +37,12 @@ import org.thymeleaf.templatemode.*;
* the importer's classpath by asking the configured
* {@link org.thymeleaf.TemplateEngine TemplateEngine} to render
them with the current
* request's locale and attributes available to the template.
- * <li>Picks up {@link ThymeleafViewRenderer} automatically via the mixin's
- * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
declaration, so
- * {@code @RestOp}-method return values of type {@link
ThymeleafView} render through the
- * Thymeleaf engine without any additional wiring.
+ * <li>Routes the host's own {@code @RestOp}-method return values of type
{@link ThymeleafView} through the
+ * Thymeleaf engine automatically. {@code ThymeleafMixin} declares
+ * {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)}, so a plain
+ * {@code @Rest(mixins=ThymeleafMixin.class)} folds its {@link
ThymeleafViewRenderer} into the host's own
+ * response-processor chain — no {@code mergeIntoHost} or
explicit {@code responseProcessors=} on the
+ * host needed.
* </ol>
*
* <h5 class='figure'>Composition example (microservice):</h5>
@@ -134,7 +136,8 @@ import org.thymeleaf.templatemode.*;
*/
// @formatter:off
@Rest(
- responseProcessors={ThymeleafViewRenderer.class}
+ responseProcessors={ThymeleafViewRenderer.class},
+ mergeResponseProcessorsIntoHost=true
)
@SuppressWarnings({
"java:S1192" // Duplicate string literals are Thymeleaf MIME type
strings and template attribute keys; intentional
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
index a0c8de9f61..16fb24b791 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
@@ -363,6 +363,37 @@ public @interface Rest {
*/
Mixin[] mixinDefs() default {};
+ /**
+ * Mixin-declared opt-in: fold this class's own {@link
#responseProcessors() responseProcessors} into the
+ * <b>host</b> resource's chain whenever this class is composed as a
mixin via a plain
+ * {@link #mixins() @Rest(mixins=...)} reference.
+ *
+ * <p>
+ * By default ({@code false}) a mixin's {@code responseProcessors}
apply only to the mixin's own endpoints
+ * — a host's own {@code @RestOp} methods see only the host's
chain (the standard {@link #mixins() mixin}
+ * inheritance rule "host's chain runs first, then the mixin's
appended; host endpoints see only the host's
+ * chain"). Setting this to {@code true} <b>on the mixin class
itself</b> makes a plain
+ * {@code @Rest(mixins=ThisClass.class)} reference additionally append
this class's own
+ * {@code responseProcessors} to the end of the host's own chain, so
the host's own endpoints pick them up too.
+ *
+ * <p>
+ * This is the mixin-declared, response-processor-scoped counterpart to
+ * {@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)} (which is
host-declared and folds <i>all</i>
+ * list-shaped attributes). Unlike {@code mergeIntoHost}, this
directive lives with the mixin and folds
+ * <b>only</b> {@code responseProcessors} — never {@code guards},
{@code serializers}, or any other
+ * list-shaped attribute. It is intended for view-renderer mixins
(e.g. {@code FreemarkerMixin}) so that
+ * declaring the mixin is enough to route the host's own {@code View}
returns through the renderer.
+ *
+ * <p>
+ * Strictly opt-in and non-silent: it has no effect for a mixin that
leaves it {@code false}, so existing
+ * mixins keep today's scoping exactly. Same-class de-duplication
applies, so a response processor the host
+ * already declares is not added twice.
+ *
+ * @return The annotation value.
+ * @since 10.0.0
+ */
+ boolean mergeResponseProcessorsIntoHost() default false;
+
/**
* Client version header.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
index b654d6f7c1..1be09c9ca3 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
@@ -80,6 +80,7 @@ public class RestAnnotation {
private Child[] childrenDefs = {};
private Class<?>[] mixins = {};
private Mixin[] mixinDefs = {};
+ private boolean mergeResponseProcessorsIntoHost;
private Class<?>[] parsers = {};
private Swagger swagger = SwaggerAnnotation.DEFAULT;
private String disableContentParam = "";
@@ -250,6 +251,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#mergeResponseProcessorsIntoHost()}
property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder mergeResponseProcessorsIntoHost(boolean value) {
+ mergeResponseProcessorsIntoHost = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#clientVersionHeader()} property on this
annotation.
*
@@ -821,6 +833,7 @@ public class RestAnnotation {
private final Child[] childrenDefs;
private final Class<?>[] mixins;
private final Mixin[] mixinDefs;
+ private final boolean mergeResponseProcessorsIntoHost;
private final Class<?>[] parsers;
private final Swagger swagger;
private final String disableContentParam;
@@ -878,6 +891,7 @@ public class RestAnnotation {
childrenDefs = cp(b.childrenDefs);
mixins = cp(b.mixins);
mixinDefs = cp(b.mixinDefs);
+ mergeResponseProcessorsIntoHost =
b.mergeResponseProcessorsIntoHost;
clientVersionHeader = b.clientVersionHeader;
config = b.config;
eagerInit = b.eagerInit;
@@ -976,6 +990,11 @@ public class RestAnnotation {
return mixinDefs;
}
+ @Override /* Overridden from Rest */
+ public boolean mergeResponseProcessorsIntoHost() {
+ return mergeResponseProcessorsIntoHost;
+ }
+
@Override /* Overridden from Rest */
public String clientVersionHeader() {
return clientVersionHeader;
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 a3a74d4d52..9a99f51e7c 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
@@ -3558,10 +3558,19 @@ public class RestContext extends Context {
// Opt-in @Mixin(mergeIntoHost=true) directive: for a host
context and a list-shaped property, append the
// adopted mixin(s)' own @Rest contributions AFTER the host's
chain (parent-to-child), so the host's own
// endpoints pick up the mixin's list-shaped attributes.
Default behavior (no opted-in mixin) is unchanged.
- if (! isMixinContextField() &&
MERGEABLE_LIST_PROPERTIES.contains(name)) {
- var merged = mergeIntoHostMixinAnnotations.get();
- if (! merged.isEmpty())
- return Stream.concat(hostStream,
merged.stream());
+ if (! isMixinContextField()) {
+ if (MERGEABLE_LIST_PROPERTIES.contains(name)) {
+ var merged =
mergeIntoHostMixinAnnotations.get();
+ if (! merged.isEmpty())
+ hostStream = Stream.concat(hostStream,
merged.stream());
+ }
+ // Mixin-declared
@Rest(mergeResponseProcessorsIntoHost=true) opt-in: fold the opted-in mixin's
OWN response
+ // processors into the host's chain —
response-processor-scoped only (never other list-shaped attributes).
+ if (PROPERTY_responseProcessors.equals(name)) {
+ var mergedRp =
mergeResponseProcessorsIntoHostMixinAnnotations.get();
+ if (! mergedRp.isEmpty())
+ hostStream = Stream.concat(hostStream,
mergedRp.stream());
+ }
}
return hostStream;
}
@@ -3592,6 +3601,40 @@ public class RestContext extends Context {
return u(out);
});
+ /**
+ * The OWN {@code @Rest} annotation chains (parent-to-child, framework
{@code DefaultConfig} entries excluded) of any
+ * mixin that opts into folding its response processors into the host
via
+ * {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)} on its own class,
+ * appended by {@link #getRestAnnotationsForProperty(String)} for the
{@code responseProcessors} property only so the
+ * mixin's {@code responseProcessors} fold into the host's own chain
under a plain {@link Rest#mixins() mixins=}
+ * reference.
+ *
+ * <p>
+ * This is the mixin-declared, response-processor-scoped counterpart to
the host-declared
+ * {@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)} directive
handled by
+ * {@link #mergeIntoHostMixinAnnotations}: it folds <b>only</b> {@code
responseProcessors}, never other list-shaped
+ * attributes. Empty for mixin sub-contexts and for hosts with no
opted-in mixins — keeping the default (drop)
+ * behavior for every mixin that does not declare the opt-in. Built
once at memoizer-init time (zero per-request
+ * cost).
+ */
+ private final Memoizer<List<AnnotationInfo<Rest>>>
mergeResponseProcessorsIntoHostMixinAnnotations = memoizer(() -> {
+ if (isMixinContextField())
+ return List.of();
+ var out = new ArrayList<AnnotationInfo<Rest>>();
+ for (var rm : getResolvedMixins()) {
+ if (rm.type() == resourceClass())
+ continue;
+ // Fold in the mixin class's OWN @Rest chain
(parent-to-child), excluding the framework DefaultConfig
+ // entries, but only when the mixin's own @Rest
declares the mergeResponseProcessorsIntoHost opt-in.
+ var mixinRest =
rstream(getAnnotationProvider().find(Rest.class, ClassInfo.of(rm.type())))
+ .filter(ai -> ! (ai.getAnnotatable() instanceof
ClassInfo ci && DefaultConfig.class.equals(ci.inner())))
+ .toList();
+ if (mixinRest.stream().anyMatch(ai ->
ai.inner().mergeResponseProcessorsIntoHost()))
+ out.addAll(mixinRest);
+ }
+ return u(out);
+ });
+
/**
* For a {@linkplain #isMixinContext() mixin sub-context}, returns the
<b>host</b> resource class's
* class-level annotation infos (host class chain, in parent-to-child
order) so a mixin operation can
diff --git
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
new file mode 100644
index 0000000000..700daf70fe
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
@@ -0,0 +1,139 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.server.processor.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the mixin-declared {@link Rest#mergeResponseProcessorsIntoHost()
@Rest(mergeResponseProcessorsIntoHost=true)}
+ * opt-in: a mixin that declares it on its own class has its {@code
responseProcessors} folded into the host's own chain
+ * under a plain {@link Rest#mixins() @Rest(mixins=...)} reference, while a
mixin that does NOT declare it keeps today's
+ * isolated scoping (the non-silent contract).
+ *
+ * <p>
+ * This is the mixin-side, response-processor-scoped counterpart to
+ * {@code MixinInheritance_ResponseProcessors_Test} (a02/a04) in {@code
juneau-integration-tests}, exercised here at the
+ * {@link RestContext} level without a mock client so it runs inside {@code
juneau-rest-server}'s own test suite.
+ *
+ * @since 10.0.0
+ */
+class MixinResponseProcessorFold_Test extends org.apache.juneau.TestBase {
+
+
//-----------------------------------------------------------------------------------------------------------
+ // Fixtures
+
//-----------------------------------------------------------------------------------------------------------
+
+ public static class HostRp1 implements ResponseProcessor {
+ @Override public int process(RestOpSession s) throws
IOException, NotAcceptable, BasicHttpException { return NEXT; }
+ }
+
+ public static class MixinRp1 implements ResponseProcessor {
+ @Override public int process(RestOpSession s) throws
IOException, NotAcceptable, BasicHttpException { return NEXT; }
+ }
+
+ /** Opted-in mixin: declares the response-processor fold on its own
class. */
+ @Rest(responseProcessors={MixinRp1.class},
mergeResponseProcessorsIntoHost=true)
+ public static class M_OptedIn {
+ @RestGet(path="/my") public String my() { return "my"; }
+ }
+
+ /** Non-opted-in mixin: same response processor, but no fold opt-in
(regression guard for the non-silent contract). */
+ @Rest(responseProcessors={MixinRp1.class})
+ public static class M_NotOptedIn {
+ @RestGet(path="/my") public String my() { return "my"; }
+ }
+
+ @Rest(responseProcessors={HostRp1.class}, mixins={M_OptedIn.class})
+ public static class HostWithOptedInMixin {
+ @RestGet(path="/h") public String h() { return "h"; }
+ }
+
+ @Rest(responseProcessors={HostRp1.class}, mixins={M_NotOptedIn.class})
+ public static class HostWithNonOptedInMixin {
+ @RestGet(path="/h") public String h() { return "h"; }
+ }
+
+ static RestContext.Args argsOf(Class<?> resourceClass,
java.util.function.Supplier<?> supplier) {
+ return new RestContext.Args(resourceClass, null, null,
supplier, null, null, null, null, null, null);
+ }
+
+ private static List<Class<?>> classesOf(ResponseProcessor[] rps) {
+ var l = new ArrayList<Class<?>>(rps.length);
+ for (var rp : rps) l.add(rp.getClass());
+ return l;
+ }
+
+
//-----------------------------------------------------------------------------------------------------------
+ // a - opt-in folds the mixin's response processor into the host chain
+
//-----------------------------------------------------------------------------------------------------------
+
+ @Test void a01_optedInMixin_foldsResponseProcessorIntoHostChain()
throws Exception {
+ var hostCtx = new
RestContext(argsOf(HostWithOptedInMixin.class, HostWithOptedInMixin::new));
+ var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+ assertTrue(hostRps.contains(MixinRp1.class),
+ "Host must fold in the opted-in mixin's MixinRp1 under
a plain mixins= reference");
+ assertTrue(hostRps.contains(HostRp1.class),
+ "Host must still register its own HostRp1");
+
+ var mixinCtx = hostCtx.getMixinContexts().get(M_OptedIn.class);
+ assertNotNull(mixinCtx);
+
assertTrue(classesOf(mixinCtx.getResponseProcessors()).contains(MixinRp1.class),
+ "Mixin endpoint must still have its own MixinRp1");
+ }
+
+
//-----------------------------------------------------------------------------------------------------------
+ // b - non-opted-in mixin keeps today's isolated scoping (non-silent
contract)
+
//-----------------------------------------------------------------------------------------------------------
+
+ @Test void b01_nonOptedInMixin_keepsIsolatedScoping() throws Exception {
+ var hostCtx = new
RestContext(argsOf(HostWithNonOptedInMixin.class,
HostWithNonOptedInMixin::new));
+ var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+ assertFalse(hostRps.contains(MixinRp1.class),
+ "Host must NOT have MixinRp1 — a mixin without the
opt-in stays scoped to its own endpoints");
+ assertTrue(hostRps.contains(HostRp1.class),
+ "Host must still register its own HostRp1");
+
+ var mixinCtx =
hostCtx.getMixinContexts().get(M_NotOptedIn.class);
+ assertNotNull(mixinCtx);
+
assertTrue(classesOf(mixinCtx.getResponseProcessors()).contains(MixinRp1.class),
+ "Mixin endpoint must still have its own MixinRp1 via
its own @Rest(responseProcessors=)");
+ }
+
+
//-----------------------------------------------------------------------------------------------------------
+ // c - ordering / precedence: the host's own processor precedes the
folded mixin processor
+
//-----------------------------------------------------------------------------------------------------------
+
+ @Test void c01_foldedProcessorAppendedAfterHostChain() throws Exception
{
+ var hostCtx = new
RestContext(argsOf(HostWithOptedInMixin.class, HostWithOptedInMixin::new));
+ var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+ var hostIdx = hostRps.indexOf(HostRp1.class);
+ var mixinIdx = hostRps.indexOf(MixinRp1.class);
+ assertTrue(hostIdx >= 0 && mixinIdx >= 0, "Both processors must
be present");
+ assertTrue(hostIdx < mixinIdx,
+ "Host's own HostRp1 must precede the folded MixinRp1
(append semantics)");
+ }
+}
diff --git
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
index 61762ba263..0d5eafc5e9 100644
---
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
+++
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
@@ -46,6 +46,7 @@ public class ConfigItem {
* Sets the configuration value.
*
* @param value The value to set.
+ * @return This object.
*/
- public void setValue(String value) { this.value = value; }
+ public ConfigItem setValue(String value) { this.value = value; return
this; }
}
\ No newline at end of file