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 —
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");
+ }
+}