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 b3d9b94308 feat: ViewRenderer/CatchAllResponseProcessor SPI with 
auto-prepend partition pass in ResponseProcessorList (TODO-96)
b3d9b94308 is described below

commit b3d9b943087316662c0e28e894b9bbac3701e533
Author: James Bognar <[email protected]>
AuthorDate: Wed May 27 12:54:15 2026 -0400

    feat: ViewRenderer/CatchAllResponseProcessor SPI with auto-prepend 
partition pass in ResponseProcessorList (TODO-96)
---
 .../view/freemarker/FreemarkerViewRenderer.java    |   3 +-
 .../juneau/rest/view/jsp/JspViewRenderer.java      |   3 +-
 .../rest/view/mustache/MustacheViewRenderer.java   |   3 +-
 .../rest/view/thymeleaf/ThymeleafViewRenderer.java |   3 +-
 .../rest/processor/CatchAllResponseProcessor.java  |  45 ++++
 .../rest/processor/ResponseProcessorList.java      |  78 ++++++-
 .../rest/processor/SerializedPojoProcessor.java    |   2 +-
 .../org/apache/juneau/rest/view/ViewRenderer.java  |  51 +++++
 .../org/apache/juneau/rest/view/package-info.java  |  26 ++-
 .../processor/CatchAllResponseProcessor_Test.java  | 100 +++++++++
 .../rest/processor/ResponseProcessorList_Test.java | 232 +++++++++++++++++++++
 .../apache/juneau/rest/view/ViewRenderer_Test.java | 140 +++++++++++++
 12 files changed, 666 insertions(+), 20 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
index 89ff1a16b0..af2f21f02a 100644
--- 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/view/freemarker/FreemarkerViewRenderer.java
@@ -23,6 +23,7 @@ import freemarker.template.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.view.*;
 
 /**
  * {@link ResponseProcessor} that detects {@link FreemarkerView}-typed return 
values and asks the
@@ -66,7 +67,7 @@ import org.apache.juneau.rest.processor.*;
  *
  * @since 9.5.0
  */
-public class FreemarkerViewRenderer implements ResponseProcessor {
+public class FreemarkerViewRenderer implements ViewRenderer {
 
        /** Default {@code Content-Type} applied when the view does not specify 
one explicitly. */
        public static final String DEFAULT_CONTENT_TYPE = 
"text/html;charset=UTF-8";
diff --git 
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/view/jsp/JspViewRenderer.java
 
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/view/jsp/JspViewRenderer.java
index 4c83adb2e5..e03090d13d 100644
--- 
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/view/jsp/JspViewRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/view/jsp/JspViewRenderer.java
@@ -22,6 +22,7 @@ import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.view.*;
 
 /**
  * {@link ResponseProcessor} that detects {@link JspView}-typed return values 
and dispatches them
@@ -77,7 +78,7 @@ import org.apache.juneau.rest.processor.*;
  *
  * @since 9.5.0
  */
-public class JspViewRenderer implements ResponseProcessor {
+public class JspViewRenderer implements ViewRenderer {
 
        /**
         * Diagnostic message emitted when no JSP engine can be found on the 
classpath at forward-time.
diff --git 
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer.java
 
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer.java
index b7de1b72b5..bbe8ea1011 100644
--- 
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer.java
@@ -23,6 +23,7 @@ import com.github.mustachejava.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.view.*;
 
 /**
  * {@link ResponseProcessor} that detects {@link MustacheView}-typed return 
values and asks the
@@ -66,7 +67,7 @@ import org.apache.juneau.rest.processor.*;
  *
  * @since 9.5.0
  */
-public class MustacheViewRenderer implements ResponseProcessor {
+public class MustacheViewRenderer implements ViewRenderer {
 
        /** Default {@code Content-Type} applied when the view does not specify 
one explicitly. */
        public static final String DEFAULT_CONTENT_TYPE = 
"text/html;charset=UTF-8";
diff --git 
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
 
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
index 29759e9cc0..79b0467e59 100644
--- 
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/view/thymeleaf/ThymeleafViewRenderer.java
@@ -21,6 +21,7 @@ import java.io.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.view.*;
 import org.thymeleaf.*;
 import org.thymeleaf.context.*;
 
@@ -65,7 +66,7 @@ import org.thymeleaf.context.*;
  *
  * @since 9.5.0
  */
-public class ThymeleafViewRenderer implements ResponseProcessor {
+public class ThymeleafViewRenderer implements ViewRenderer {
 
        /** Default {@code Content-Type} applied when the view does not specify 
one explicitly. */
        public static final String DEFAULT_CONTENT_TYPE = 
"text/html;charset=UTF-8";
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor.java
new file mode 100644
index 0000000000..30369ae89b
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor.java
@@ -0,0 +1,45 @@
+/*
+ * 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.processor;
+
+import org.apache.juneau.rest.view.*;
+
+/**
+ * Marker interface for response processors that act as catch-alls — i.e. 
processors that
+ * accept any non-null return value when no more-specific processor matches.
+ *
+ * <p>
+ * During {@link ResponseProcessorList} construction, any processor 
implementing
+ * {@link ViewRenderer} is automatically repositioned to run before the first
+ * {@code CatchAllResponseProcessor} in the chain.  This ensures that
+ * {@code View}-returning {@code @RestOp} methods reach the appropriate 
renderer rather than
+ * falling through to the catch-all serializer.
+ *
+ * <p>
+ * {@link SerializedPojoProcessor} is the canonical catch-all and implements 
this interface.
+ * Future catch-all processors should also implement this interface to 
preserve the ordering
+ * invariant for any registered {@link ViewRenderer}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link ViewRenderer}
+ *     <li class='jc'>{@link ResponseProcessorList}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/ResponseProcessors";>Response 
Processors</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public interface CatchAllResponseProcessor extends ResponseProcessor {}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/ResponseProcessorList.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/ResponseProcessorList.java
index 189bcf57bb..bd0bb0232e 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/ResponseProcessorList.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/ResponseProcessorList.java
@@ -24,6 +24,7 @@ import java.util.*;
 import org.apache.juneau.ConfigException;
 import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.rest.view.*;
 
 /**
  * A list of {@link ResponseProcessor} objects.
@@ -64,26 +65,50 @@ public class ResponseProcessorList {
                /**
                 * Appends the specified rest response processor classes to the 
list.
                 *
+                * <p>
+                * Same-class deduplication is applied: if an entry of the same 
{@link Class} already exists
+                * in the list, the duplicate is silently skipped.
+                *
                 * @param values The values to add.
                 * @return This object.
                 * @throws IllegalArgumentException if any class does not 
extend from {@link ResponseProcessor}.
                 */
                public Builder add(Class<?>...values) {
-                       addAll(entries, 
(Object[])assertClassArrayArgIsType("values", ResponseProcessor.class, values));
+                       for (var v : assertClassArrayArgIsType("values", 
ResponseProcessor.class, values))
+                               if (!entryClassExists(v))
+                                       entries.add(v);
                        return this;
                }
 
                /**
                 * Appends the specified rest response processor objects to the 
list.
                 *
+                * <p>
+                * Same-class deduplication is applied: if an entry of the same 
{@link Class} already exists
+                * in the list, the duplicate is silently skipped.
+                *
                 * @param values The values to add.
                 * @return This object.
                 */
                public Builder add(ResponseProcessor...values) {
-                       addAll(entries, (Object[])values);
+                       for (var v : values)
+                               if (!entryClassExists(v.getClass()))
+                                       entries.add(v);
                        return this;
                }
 
+               // Returns true if an entry with the given class is already 
present (either as a Class token
+               // or as an instantiated ResponseProcessor of that class).
+               private boolean entryClassExists(Class<?> cls) {
+                       for (var e : entries) {
+                               if (e instanceof Class<?> e2 && e2 == cls)
+                                       return true;
+                               if (e instanceof ResponseProcessor e2 && 
e2.getClass() == cls)
+                                       return true;
+                       }
+                       return false;
+               }
+
                /**
                 * Builds the list.
                 *
@@ -120,18 +145,51 @@ public class ResponseProcessorList {
        /**
         * Constructor.
         *
+        * <p>
+        * After instantiating all processors, a partition pass runs once at 
build time (O(N)):
+        * any processor implementing {@link ViewRenderer} is repositioned to 
run immediately before
+        * the first {@link CatchAllResponseProcessor} in the chain.  If no 
{@link ViewRenderer} or
+        * no {@link CatchAllResponseProcessor} is present, the chain is left 
unchanged.
+        *
         * @param builder The builder containing the contents for this list.
         */
        protected ResponseProcessorList(Builder builder) {
                var bs = builder.beanStore();
-               // @formatter:off
-               entries =
-                       builder
-                               .entries
-                               .stream()
-                               .map(x -> instantiate(x, bs))
-                               .toArray(ResponseProcessor[]::new);
-               // @formatter:on
+               // Instantiate all entries first.
+               var list = new 
ArrayList<ResponseProcessor>(builder.entries.size());
+               for (var x : builder.entries)
+                       list.add(instantiate(x, bs));
+
+               // Partition pass: reposition ViewRenderers before the first 
CatchAllResponseProcessor.
+               var viewRenderers = new ArrayList<ResponseProcessor>();
+               for (var p : list)
+                       if (p instanceof ViewRenderer)
+                               viewRenderers.add(p);
+
+               var hasCatchAll = list.stream().anyMatch(p -> p instanceof 
CatchAllResponseProcessor);
+
+               if (!viewRenderers.isEmpty() && hasCatchAll) {
+                       // Build new list: non-ViewRenderer entries in original 
order, then insert all
+                       // ViewRenderers immediately before the first 
CatchAllResponseProcessor.
+                       var reordered = new 
ArrayList<ResponseProcessor>(list.size());
+                       for (var p : list)
+                               if (!(p instanceof ViewRenderer))
+                                       reordered.add(p);
+
+                       var insertAt = -1;
+                       for (var i = 0; i < reordered.size(); i++) {
+                               if (reordered.get(i) instanceof 
CatchAllResponseProcessor) {
+                                       insertAt = i;
+                                       break;
+                               }
+                       }
+                       if (insertAt == -1)
+                               insertAt = reordered.size();
+                       reordered.addAll(insertAt, viewRenderers);
+                       list = reordered;
+               }
+
+               entries = list.toArray(ResponseProcessor[]::new);
        }
 
        /**
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java
index 6bb487bc3b..a3cd9027d9 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java
@@ -39,7 +39,7 @@ import org.apache.juneau.serializer.*;
 @SuppressWarnings({
        "resource" // SerializedPojoProcessor manages Closeable resources
 })
-public class SerializedPojoProcessor implements ResponseProcessor {
+public class SerializedPojoProcessor implements CatchAllResponseProcessor {
 
        @Override /* Overridden from ResponseProcessor */
        @SuppressWarnings({
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/ViewRenderer.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/ViewRenderer.java
new file mode 100644
index 0000000000..126fe2909e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/ViewRenderer.java
@@ -0,0 +1,51 @@
+/*
+ * 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.view;
+
+import org.apache.juneau.rest.processor.*;
+
+/**
+ * Marker interface for response processors that handle {@link View}-typed 
return values.
+ *
+ * <p>
+ * During {@link ResponseProcessorList} construction, any processor 
implementing this interface
+ * is automatically repositioned to run before any {@link 
CatchAllResponseProcessor} in the chain.
+ * This ensures view renderers are consulted before the catch-all serializer 
for
+ * {@code View}-returning {@code @RestOp} methods.
+ *
+ * <p>
+ * The four built-in renderers automatically implement this interface:
+ * <ul>
+ *     <li class='jc'>{@link org.apache.juneau.rest.view.jsp.JspViewRenderer}
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.thymeleaf.ThymeleafViewRenderer}
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.mustache.MustacheViewRenderer}
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.view.freemarker.FreemarkerViewRenderer}
+ * </ul>
+ *
+ * <p>
+ * Third-party view renderers should implement this interface to inherit the 
same ordering
+ * guarantee.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link CatchAllResponseProcessor}
+ *     <li class='jc'>{@link ResponseProcessorList}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/ResponseProcessors";>Response 
Processors</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public interface ViewRenderer extends ResponseProcessor {}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/package-info.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/package-info.java
index c5a339ecf7..f4a13358c0 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/package-info.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/view/package-info.java
@@ -19,11 +19,11 @@
  *
  * <p>
  * Hosts the {@link org.apache.juneau.rest.view.View View} interface &mdash; 
the stable contract
- * that per-engine bridge modules ({@code juneau-rest-server-view-jsp}, future
- * {@code -thymeleaf} / {@code -mustache} / {@code -freemarker}) implement and 
pair with a
- * {@link org.apache.juneau.rest.processor.ResponseProcessor 
ResponseProcessor} that detects their
- * own {@code View} subtype on {@code @RestOp}-method return values and 
dispatches to the
- * underlying templating engine.
+ * that per-engine bridge modules ({@code juneau-rest-server-view-jsp},
+ * {@code -thymeleaf}, {@code -mustache}, {@code -freemarker}) implement and 
pair with a
+ * {@link org.apache.juneau.rest.view.ViewRenderer ViewRenderer} that detects 
their own
+ * {@code View} subtype on {@code @RestOp}-method return values and dispatches 
to the underlying
+ * templating engine.
  *
  * <p>
  * Keeping the interface in {@code juneau-rest-server}'s core (rather than in 
any one engine
@@ -31,6 +31,22 @@
  * compile-time dependency on a specific engine, and lets host code 
interoperate with multiple
  * bridge modules in the same application.
  *
+ * <h5 class='topic'>Renderer auto-prepend</h5>
+ *
+ * <p>
+ * Any {@link org.apache.juneau.rest.processor.ResponseProcessor 
ResponseProcessor} implementing
+ * {@link ViewRenderer} is automatically repositioned to run before the first
+ * {@link org.apache.juneau.rest.processor.CatchAllResponseProcessor 
CatchAllResponseProcessor}
+ * (i.e. {@link org.apache.juneau.rest.processor.SerializedPojoProcessor 
SerializedPojoProcessor})
+ * during {@link org.apache.juneau.rest.processor.ResponseProcessorList 
ResponseProcessorList}
+ * construction.  This ensures that a {@code @RestOp} method returning a typed 
{@code View}
+ * subclass ({@link org.apache.juneau.rest.view.jsp.JspView JspView},
+ * {@link org.apache.juneau.rest.view.thymeleaf.ThymeleafView ThymeleafView}, 
etc.) reaches the
+ * matching renderer without the user having to enumerate a full custom 
processor chain.
+ *
+ * <p>
+ * Third-party renderers gain the same ordering guarantee by implementing 
{@link ViewRenderer}.
+ *
  * @since 9.5.0
  */
 package org.apache.juneau.rest.view;
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor_Test.java
new file mode 100644
index 0000000000..f6456b65e7
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/CatchAllResponseProcessor_Test.java
@@ -0,0 +1,100 @@
+/*
+ * 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.processor;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Marker-conformance tests for {@link CatchAllResponseProcessor} (TODO-96).
+ *
+ * @since 9.5.0
+ */
+class CatchAllResponseProcessor_Test extends TestBase {
+
+       /** {@link SerializedPojoProcessor} must implement {@link 
CatchAllResponseProcessor}. */
+       @Test void a01_serializedPojoProcessor_isCatchAll() {
+               // Instantiate via builder so BeanStore wiring is exercised.
+               var chain = ResponseProcessorList.create(new 
BasicBeanStore(null))
+                       .add(SerializedPojoProcessor.class)
+                       .build()
+                       .toArray();
+               assertEquals(1, chain.length);
+               assertInstanceOf(CatchAllResponseProcessor.class, chain[0]);
+       }
+
+       /** {@link SerializedPojoProcessor} must also implement {@link 
ResponseProcessor}. */
+       @Test void a02_serializedPojoProcessor_isResponseProcessor() {
+               assertInstanceOf(ResponseProcessor.class, new 
SerializedPojoProcessor());
+       }
+
+       /** Default-chain scan: exactly one processor implements {@link 
CatchAllResponseProcessor}
+        * in the baseline default configuration (only {@link 
SerializedPojoProcessor}). */
+       @Test void a03_defaultChain_exactlyOneCatchAll() {
+               var chain = ResponseProcessorList.create(new 
BasicBeanStore(null))
+                       .add(
+                               AsyncResponseProcessor.class,
+                               ReaderProcessor.class,
+                               InputStreamProcessor.class,
+                               ThrowableProcessor.class,
+                               ProblemDetailsProcessor.class,
+                               HttpResponseProcessor.class,
+                               HttpResourceProcessor.class,
+                               HttpBodyProcessor.class,
+                               ResponseBeanProcessor.class,
+                               PlainTextPojoProcessor.class,
+                               SerializedPojoProcessor.class
+                       )
+                       .build()
+                       .toArray();
+
+               var catchAllCount = 0;
+               for (var p : chain)
+                       if (p instanceof CatchAllResponseProcessor)
+                               catchAllCount++;
+
+               assertEquals(1, catchAllCount,
+                       "Default chain should have exactly 1 
CatchAllResponseProcessor (SerializedPojoProcessor)");
+       }
+
+       /** {@link SerializedPojoProcessor} is the last entry in the default 
chain
+        * (all other processors are type-guarded; only SerializedPojoProcessor 
accepts any POJO). */
+       @Test void a04_serializedPojoProcessor_isLastInDefaultChain() {
+               var chain = ResponseProcessorList.create(new 
BasicBeanStore(null))
+                       .add(
+                               AsyncResponseProcessor.class,
+                               ReaderProcessor.class,
+                               InputStreamProcessor.class,
+                               ThrowableProcessor.class,
+                               ProblemDetailsProcessor.class,
+                               HttpResponseProcessor.class,
+                               HttpResourceProcessor.class,
+                               HttpBodyProcessor.class,
+                               ResponseBeanProcessor.class,
+                               PlainTextPojoProcessor.class,
+                               SerializedPojoProcessor.class
+                       )
+                       .build()
+                       .toArray();
+
+               assertInstanceOf(SerializedPojoProcessor.class, 
chain[chain.length - 1],
+                       "SerializedPojoProcessor should be the last entry in 
the default chain");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/processor/ResponseProcessorList_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/ResponseProcessorList_Test.java
new file mode 100644
index 0000000000..d069ffcad2
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/ResponseProcessorList_Test.java
@@ -0,0 +1,232 @@
+/*
+ * 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.processor;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.view.*;
+import org.apache.juneau.rest.view.freemarker.*;
+import org.apache.juneau.rest.view.jsp.*;
+import org.apache.juneau.rest.view.mustache.*;
+import org.apache.juneau.rest.view.thymeleaf.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the {@link ResponseProcessorList} partition pass (TODO-96) that 
repositions
+ * {@link ViewRenderer} processors before the first {@link 
CatchAllResponseProcessor}.
+ *
+ * @since 9.5.0
+ */
+class ResponseProcessorList_Test extends TestBase {
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Stub processors used across tests.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       static class OtherA implements ResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       static class OtherB implements ResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       static class OtherC implements ResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       static class ViewRendererA implements ViewRenderer {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       static class ViewRendererB implements ViewRenderer {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       static class CatchAllA implements CatchAllResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, BasicHttpException { return NEXT; }
+       }
+
+       // Convenience factory to avoid repeating new BasicBeanStore(null) 
everywhere.
+       private static ResponseProcessorList.Builder builder() {
+               return ResponseProcessorList.create(new BasicBeanStore(null));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // a: Partition-pass no-op cases.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** No ViewRenderer present — chain must be unchanged. */
+       @Test void a01_noViewRenderer_chainUnchanged() {
+               var a = new OtherA();
+               var b = new OtherB();
+               var chain = builder().add(a, b).build().toArray();
+               assertArrayEquals(new ResponseProcessor[]{a, b}, chain);
+       }
+
+       /** ViewRenderer present but no CatchAllResponseProcessor — no reorder, 
no crash. */
+       @Test void a02_noCatchAll_chainUnchanged() {
+               var a = new OtherA();
+               var vr = new ViewRendererA();
+               var chain = builder().add(a, vr).build().toArray();
+               assertArrayEquals(new ResponseProcessor[]{a, vr}, chain);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // b: Partition-pass reorder cases.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** [OtherA, ViewRendererA, OtherB, CatchAllA, OtherC] → [OtherA, 
OtherB, ViewRendererA, CatchAllA, OtherC]. */
+       @Test void b01_singleViewRenderer_prependsBeforeCatchAll() {
+               var a  = new OtherA();
+               var vr = new ViewRendererA();
+               var b  = new OtherB();
+               var ca = new CatchAllA();
+               var c  = new OtherC();
+               var chain = builder().add(a, vr, b, ca, c).build().toArray();
+               assertArrayEquals(new ResponseProcessor[]{a, b, vr, ca, c}, 
chain);
+       }
+
+       /** [CatchAllA, ViewRendererB, ViewRendererA] → [ViewRendererB, 
ViewRendererA, CatchAllA].
+        * Registration order among renderers is preserved. */
+       @Test void b02_multipleViewRenderers_allPrependInOrder() {
+               var ca  = new CatchAllA();
+               var vrb = new ViewRendererB();
+               var vra = new ViewRendererA();
+               var chain = builder().add(ca, vrb, vra).build().toArray();
+               assertArrayEquals(new ResponseProcessor[]{vrb, vra, ca}, chain);
+       }
+
+       /** ViewRenderer already before CatchAll — chain preserved (no 
double-move). */
+       @Test void b03_viewRendererAlreadyBeforeCatchAll_unchanged() {
+               var vr = new ViewRendererA();
+               var a  = new OtherA();
+               var ca = new CatchAllA();
+               var chain = builder().add(vr, a, ca).build().toArray();
+               // vr is already a ViewRenderer; after reorder: 
non-viewrenderers first (a), then renderers (vr), then catch-all (ca).
+               assertArrayEquals(new ResponseProcessor[]{a, vr, ca}, chain);
+       }
+
+       /** [SerializedPojoProcessor, JspViewRenderer] → JspViewRenderer runs 
before SerializedPojoProcessor. */
+       @Test void b04_viewRendererAfterCatchAll_movedBefore() {
+               var chain = builder()
+                       .add(SerializedPojoProcessor.class, 
JspViewRenderer.class)
+                       .build()
+                       .toArray();
+               assertEquals(2, chain.length);
+               assertInstanceOf(JspViewRenderer.class, chain[0]);
+               assertInstanceOf(SerializedPojoProcessor.class, chain[1]);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // c: Deduplication.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** Adding JspViewRenderer.class twice yields only one instance. */
+       @Test void c01_sameClassDedup_classToken() {
+               var chain = builder()
+                       .add(JspViewRenderer.class, 
SerializedPojoProcessor.class)
+                       .add(JspViewRenderer.class)   // duplicate — should be 
silently ignored
+                       .build()
+                       .toArray();
+               var jspCount = 0;
+               for (var p : chain)
+                       if (p instanceof JspViewRenderer)
+                               jspCount++;
+               assertEquals(1, jspCount, "JspViewRenderer should appear 
exactly once");
+       }
+
+       /** Adding a JspViewRenderer instance twice yields only one instance. */
+       @Test void c02_sameClassDedup_instances() {
+               var jspA = new JspViewRenderer();
+               var jspB = new JspViewRenderer();  // different instance, same 
class
+               var chain = builder()
+                       .add(SerializedPojoProcessor.class)
+                       .add(jspA)
+                       .add(jspB)   // duplicate by class — should be silently 
ignored
+                       .build()
+                       .toArray();
+               var jspCount = 0;
+               for (var p : chain)
+                       if (p instanceof JspViewRenderer)
+                               jspCount++;
+               assertEquals(1, jspCount, "JspViewRenderer should appear 
exactly once");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // d: Default-chain ordering invariant.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       /** All four built-in renderers, when mixed into the default chain 
alongside SerializedPojoProcessor,
+        * appear before SerializedPojoProcessor in the final list. */
+       @Test void d01_defaultChain_renderersPrecedeCatchAll() {
+               // Simulate the default chain (from DefaultConfig), adding all 
four renderers as
+               // well. ViewRenderers are appended at the end; the partition 
pass must hoist them.
+               var chain = builder()
+                       .add(
+                               AsyncResponseProcessor.class,
+                               ReaderProcessor.class,
+                               InputStreamProcessor.class,
+                               ThrowableProcessor.class,
+                               ProblemDetailsProcessor.class,
+                               HttpResponseProcessor.class,
+                               HttpResourceProcessor.class,
+                               HttpBodyProcessor.class,
+                               ResponseBeanProcessor.class,
+                               PlainTextPojoProcessor.class,
+                               SerializedPojoProcessor.class,  // catch-all
+                               JspViewRenderer.class,
+                               ThymeleafViewRenderer.class,
+                               MustacheViewRenderer.class,
+                               FreemarkerViewRenderer.class
+                       )
+                       .build()
+                       .toArray();
+
+               // Find the index of the first CatchAllResponseProcessor.
+               var catchAllIdx = -1;
+               for (var i = 0; i < chain.length; i++) {
+                       if (chain[i] instanceof CatchAllResponseProcessor) {
+                               catchAllIdx = i;
+                               break;
+                       }
+               }
+               assertNotEquals(-1, catchAllIdx, "Chain must contain a 
CatchAllResponseProcessor");
+
+               // Every ViewRenderer must appear before the catch-all.
+               for (var i = 0; i < chain.length; i++) {
+                       if (chain[i] instanceof ViewRenderer)
+                               assertTrue(i < catchAllIdx,
+                                       chain[i].getClass().getSimpleName() + " 
at index " + i + " must be before catch-all at " + catchAllIdx);
+               }
+
+               // The four built-in renderers must all be present.
+               var types = new java.util.HashSet<Class<?>>();
+               for (var p : chain)
+                       types.add(p.getClass());
+               assertTrue(types.contains(JspViewRenderer.class),       
"JspViewRenderer must be in chain");
+               assertTrue(types.contains(ThymeleafViewRenderer.class), 
"ThymeleafViewRenderer must be in chain");
+               assertTrue(types.contains(MustacheViewRenderer.class),  
"MustacheViewRenderer must be in chain");
+               
assertTrue(types.contains(FreemarkerViewRenderer.class),"FreemarkerViewRenderer 
must be in chain");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/ViewRenderer_Test.java 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/ViewRenderer_Test.java
new file mode 100644
index 0000000000..75efdf9d40
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/ViewRenderer_Test.java
@@ -0,0 +1,140 @@
+/*
+ * 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.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.view.freemarker.*;
+import org.apache.juneau.rest.view.jsp.*;
+import org.apache.juneau.rest.view.mustache.*;
+import org.apache.juneau.rest.view.thymeleaf.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Marker-conformance and end-to-end ordering tests for {@link ViewRenderer} 
(TODO-96).
+ *
+ * <p>
+ * The marker-conformance assertions verify that all four built-in renderers 
implement
+ * {@link ViewRenderer}, making them eligible for the partition pass in
+ * {@link ResponseProcessorList}.
+ *
+ * <p>
+ * The end-to-end assertions use {@link MockRestClient} to verify that a 
{@code @RestGet} method
+ * returning a typed {@code View} subclass reaches the corresponding renderer 
rather than being
+ * serialized by {@link SerializedPojoProcessor}.  MockRest does not provide a 
JSP engine or
+ * real template files for the renderer to dispatch to, so the renderer fails 
with a 500 — but
+ * a 500 from the renderer is categorically different from a 200 with 
Juneau-bean HTML content
+ * that would appear if the catch-all serializer ran first.
+ *
+ * @since 9.5.0
+ */
+class ViewRenderer_Test extends TestBase {
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // a: Marker-conformance — all four built-in renderers implement 
ViewRenderer.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_jspViewRenderer_implementsViewRenderer() {
+               assertInstanceOf(ViewRenderer.class, new JspViewRenderer());
+       }
+
+       @Test void a02_thymeleafViewRenderer_implementsViewRenderer() {
+               assertInstanceOf(ViewRenderer.class, new 
ThymeleafViewRenderer());
+       }
+
+       @Test void a03_mustacheViewRenderer_implementsViewRenderer() {
+               assertInstanceOf(ViewRenderer.class, new 
MustacheViewRenderer());
+       }
+
+       @Test void a04_freemarkerViewRenderer_implementsViewRenderer() {
+               assertInstanceOf(ViewRenderer.class, new 
FreemarkerViewRenderer());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // b: ViewRenderer also extends ResponseProcessor (contract via marker 
chain).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_viewRenderer_extendsResponseProcessor() {
+               assertInstanceOf(ResponseProcessor.class, new 
JspViewRenderer());
+               assertInstanceOf(ResponseProcessor.class, new 
ThymeleafViewRenderer());
+               assertInstanceOf(ResponseProcessor.class, new 
MustacheViewRenderer());
+               assertInstanceOf(ResponseProcessor.class, new 
FreemarkerViewRenderer());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // c: End-to-end ordering: JspView-returning @RestGet reaches 
JspViewRenderer (not SerializedPojoProcessor).
+       //
+       // The partition pass in ResponseProcessorList reorders any 
ViewRenderer before any
+       // CatchAllResponseProcessor in the same list.  In C01_JspViewResource 
the host resource explicitly
+       // adds JspViewRenderer.class to responseProcessors — without the 
partition pass it would land after
+       // SerializedPojoProcessor (which is contributed by DefaultConfig) and 
the JspView object would be
+       // bean-serialized to {"templateName":"hello.jsp",...}.  With the 
partition pass, JspViewRenderer runs
+       // first.  In MockRest the dispatcher may silently complete (200 empty 
body) or throw (5xx), but
+       // either way the response body must NOT contain "templateName" as a 
serialized-bean field.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(responseProcessors={JspViewRenderer.class})
+       public static class C01_JspViewResource extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/hello")
+               public View hello() {
+                       return JspView.of("hello.jsp").attr("greeting", 
"Hello");
+               }
+       }
+
+       /** JspView-returning @RestGet reaches JspViewRenderer first (partition 
pass) — response body is NOT
+        * a JSON bean dump of JspView (which would contain "templateName" if 
SerializedPojoProcessor ran). */
+       @Test void c01_jspView_reachesRenderer_notBeanHtml() throws Exception {
+               var c = MockRestClient.buildLax(C01_JspViewResource.class);
+               c.get("/hello")
+                       .accept("application/json")
+                       .run()
+                       
.assertContent().asString().isNotContains("templateName");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // d: End-to-end ordering for ThymeleafView.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(responseProcessors={ThymeleafViewRenderer.class})
+       public static class D01_ThymeleafViewResource extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/hello")
+               public View hello() {
+                       // Use a template name that doesn't exist on the 
classpath so the renderer fails.
+                       return 
ThymeleafView.of("__nonexistent_todo96_template__").attr("greeting", "Hello");
+               }
+       }
+
+       /** ThymeleafView-returning @RestGet reaches ThymeleafViewRenderer 
first (partition pass) — response
+        * body is NOT a JSON bean dump of ThymeleafView (which would contain 
"templateName" if
+        * SerializedPojoProcessor ran). */
+       @Test void d01_thymeleafView_reachesRenderer_notBeanHtml() throws 
Exception {
+               var c = 
MockRestClient.buildLax(D01_ThymeleafViewResource.class);
+               c.get("/hello")
+                       .accept("application/json")
+                       .run()
+                       
.assertContent().asString().isNotContains("templateName");
+       }
+}


Reply via email to