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 e36e6b824d feat(rest): new juneau-rest-server-view-mustache module
(TODO-83)
e36e6b824d is described below
commit e36e6b824d75255671409880db417f124c55b803
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 12:50:08 2026 -0400
feat(rest): new juneau-rest-server-view-mustache module (TODO-83)
---
juneau-distrib/pom.xml | 25 +
.../juneau-rest-server-view-mustache/pom.xml | 126 ++++++
.../rest/view/mustache/BasicMustacheResource.java | 502 +++++++++++++++++++++
.../juneau/rest/view/mustache/MustacheView.java | 212 +++++++++
.../rest/view/mustache/MustacheViewRenderer.java | 127 ++++++
.../juneau/rest/view/mustache/package-info.java | 100 ++++
juneau-rest/pom.xml | 1 +
juneau-utest/pom.xml | 21 +
.../BasicMustacheResource_Builder_Test.java | 222 +++++++++
.../BasicMustacheResource_MockRest_Test.java | 148 ++++++
.../BasicMustacheResource_PathTraversal_Test.java | 109 +++++
...BasicMustacheResource_SvlPathOverride_Test.java | 66 +++
.../view/mustache/MustacheViewRenderer_Test.java | 60 +++
.../rest/view/mustache/MustacheView_Test.java | 227 ++++++++++
.../resources/mustache-templates/about.mustache | 22 +
.../resources/mustache-templates/hello.mustache | 22 +
.../mustache-templates/nested/inner.mustache | 22 +
17 files changed, 2012 insertions(+)
diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index b9b024be6f..3b6ca2ddcc 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -558,6 +558,31 @@
<destFileName>org.apache.juneau.rest.server.view.thymeleaf_${project.version}.jar</destFileName>
</artifactItem>
+ <!--
juneau-rest-server-view-mustache -->
+ <artifactItem>
+
<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
+
<groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-view-mustache</artifactId>
+
<version>${project.version}</version>
+
<type>jar</type>
+
<classifier>sources</classifier>
+
<destFileName>apache-juneau-rest-server-view-mustache-${project.version}-sources.jar</destFileName>
+ </artifactItem>
+ <artifactItem>
+
<outputDirectory>${project.build.directory}/bin/lib</outputDirectory>
+
<groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-view-mustache</artifactId>
+
<version>${project.version}</version>
+
<destFileName>apache-juneau-rest-server-view-mustache-${project.version}.jar</destFileName>
+ </artifactItem>
+ <artifactItem>
+
<outputDirectory>${project.build.directory}/bin/osgi</outputDirectory>
+
<groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-view-mustache</artifactId>
+
<version>${project.version}</version>
+
<destFileName>org.apache.juneau.rest.server.view.mustache_${project.version}.jar</destFileName>
+ </artifactItem>
+
<!--
juneau-rest-server-springboot -->
<artifactItem>
<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
diff --git a/juneau-rest/juneau-rest-server-view-mustache/pom.xml
b/juneau-rest/juneau-rest-server-view-mustache/pom.xml
new file mode 100644
index 0000000000..89f9803c02
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-view-mustache/pom.xml
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest</artifactId>
+ <version>9.5.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-rest-server-view-mustache</artifactId>
+ <name>Apache Juneau REST Server View — Mustache</name>
+ <description>Apache Juneau REST Server - Mustache view-rendering bridge
(BasicMustacheResource mixin + MustacheViewRenderer).</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <mustache.version>0.9.14</mustache.version>
+ </properties>
+
+ <!--
+ Engine-agnostic POM stance (mirrors juneau-rest-server-view-jsp
resolved decision #2 and
+ juneau-rest-server-view-thymeleaf resolved decision #2).
+
+ The bridge module declares ONLY the mustache.java compiler API
in `provided` scope. NO
+ concrete Mustache engine dependency leaks into consumer
projects through this module.
+ Consumers add the engine matching their deployment:
+ - Juneau microservice / Jetty / Spring Boot: add
+ `com.github.spullara.mustache.java:compiler`
directly; the bridge builds a default
+ `MustacheFactory` lazily at first use if no bean is
registered.
+ - Custom: user supplies their own `MustacheFactory`
`@Bean` with whatever
+ resolvers / object handlers / encoders they want.
+
+ Note: Spring Boot's `spring-boot-starter-mustache` ships
`com.samskivert:jmustache`, NOT
+ mustache.java; the bridge's renderer is mustache.java-specific.
See "Choosing a
+ MustacheFactory" matrix in MustacheViewSupport.md for the full
guidance.
+ -->
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.github.spullara.mustache.java</groupId>
+ <artifactId>compiler</artifactId>
+ <version>${mustache.version}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+
<supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+
<goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource.java
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource.java
new file mode 100644
index 0000000000..c8571dd051
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource.java
@@ -0,0 +1,502 @@
+/*
+ * 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.mustache;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.io.*;
+import java.util.*;
+
+import com.github.mustachejava.*;
+
+import org.apache.juneau.commons.utils.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that wires Mustache view-rendering onto any Juneau REST resource.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicMustacheResource.class)}; the host
then:
+ *
+ * <ol class='spaced-list'>
+ * <li>Gains a default mount at {@code /mustache/*} that serves raw
Mustache templates from the
+ * 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.
+ * </ol>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/app"</js>,
mixins=BasicMustacheResource.<jk>class</jk>)
+ * <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ * <ja>@Bean</ja> BasicMustacheResource mustache() {
+ * <jk>return</jk>
BasicMustacheResource.<jsm>create</jsm>()
+ * .basePath(<js>"/templates/"</js>)
+ * .templateSuffix(<js>".mustache"</js>)
+ * .build();
+ * }
+ *
+ * <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ * <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>)
{
+ * <jk>return</jk>
MustacheView.<jsm>of</jsm>(<js>"hello"</js>).attr(<js>"name"</js>,
<jv>name</jv>);
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Configurable mount path:</h5>
+ *
+ * <p>
+ * The default mount {@code /mustache/*} can be overridden via the SVL variable
+ * {@code ${juneau.mustache.path:mustache}} — set via system property
+ * ({@code -Djuneau.mustache.path=views}), environment variable
+ * ({@code JUNEAU_MUSTACHE_PATH=views}), or {@code Config} key
+ * ({@code juneau.mustache.path = views}) to change the runtime mount without
subclassing.
+ * Resolution happens once at {@link RestContext} construction time; see
+ * {@code FINISHED-99-svl-in-op-paths.md} for the full resolution chain.
+ *
+ * <h5 class='section'>Mixin-only deployment:</h5>
+ *
+ * <p>
+ * This resource is designed for composition via {@code @Rest(mixins=...)}.
The mount path is
+ * pinned at the op level by
+ * {@link RestGet @RestGet(path="/${juneau.mustache.path:mustache}/*")} on
{@link #render};
+ * a class-level {@code @Rest(paths=...)} declaration would be silently
ignored under the mixin
+ * pattern (see {@link Rest#paths() @Rest(paths)} Javadoc).
+ *
+ * <h5 class='section'>Choosing a MustacheFactory:</h5>
+ *
+ * <p>
+ * The {@code juneau-rest-server-view-mustache} module ships <b>only</b> the
mustache.java
+ * compiler API in {@code provided} scope. <b>No factory bean is bundled.</b>
The bridge resolves
+ * a {@link MustacheFactory} at first use from three sources, in order:
+ *
+ * <ul class='spaced-list'>
+ * <li><b>User-supplied bean:</b> register a {@code @Bean MustacheFactory}
(Spring) or
+ * {@code BasicBeanStore.put(MustacheFactory.class, factory)}
(microservice). Allows full
+ * control of resolvers, object handlers, and encoders.
+ * <li><b>Bridge default:</b> when nothing is registered, the bridge
constructs a default
+ * {@link DefaultMustacheFactory} anchored on a resource root
derived from
+ * {@link #getBasePath() basePath} (leading + trailing slashes
trimmed; a {@code "/"} base
+ * yields a no-prefix factory). With {@code
basePath("/templates/")}, the factory resolves
+ * template names like {@code "hello.mustache"} as classpath
resource
+ * {@code templates/hello.mustache}.
+ * </ul>
+ *
+ * <p>
+ * mustache.java's {@code DefaultMustacheFactory} caches compiled templates
internally; the
+ * bridge does not currently expose a cache-disable knob (consumers who want
hot-reload in
+ * development supply their own {@code @Bean MustacheFactory} configured as
needed).
+ *
+ * <p>
+ * When no Mustache engine is on the classpath, the renderer surfaces
+ * {@link MustacheViewRenderer#NO_ENGINE_DIAGNOSTIC} naming the missing
dependency.
+ *
+ * <h5 class='section'>Template suffix:</h5>
+ *
+ * <p>
+ * Unlike the Thymeleaf bridge (which always appends {@code .html} via the
engine resolver's
+ * suffix setting), mustache.java has no resolver-suffix concept — the
literal template name
+ * is what {@link MustacheFactory#compile(String) factory.compile(...)} sees.
The
+ * {@link Builder#templateSuffix(String) templateSuffix(...)} builder knob
fills the gap: when
+ * non-blank, the bridge appends the configured suffix to template names that
don't already end
+ * with it (idempotent), so callers can write {@code MustacheView.of("hello")}
or request
+ * {@code /mustache/hello} and have it resolve to {@code hello.mustache} on
the classpath.
+ *
+ * <h5 class='section'>Multiple base paths:</h5>
+ *
+ * <p>
+ * A host with {@code /templates/} and {@code /admin/templates/} template
trees registers two
+ * {@code BasicMustacheResource} beans, each mounted via a subclass declaring
its own op-level
+ * {@code @RestGet(path=...)} override and its own {@code basePath}. See the
+ * {@code BasicMustacheResource_MockRest_Test} test in {@code juneau-utest}
for the canonical
+ * pattern.
+ *
+ * <h5 class='section'>OpenAPI surface:</h5>
+ *
+ * <p>
+ * The greedy {@code /*} handler is not API-meaningful and is excluded from
generated
+ * Swagger / OpenAPI specs via {@link OpSwagger#ignore()
@OpSwagger(ignore=true)}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link MustacheView}
+ * <li class='jc'>{@link MustacheViewRenderer}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/MustacheViewSupport">Mustache View
Support</a>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(
+ responseProcessors={MustacheViewRenderer.class}
+)
+public class BasicMustacheResource {
+
+ /** Default base path applied when no {@link Builder#basePath(String)}
call has been made. */
+ public static final String DEFAULT_BASE_PATH = "/";
+
+ /** Default template suffix — empty (literal template names, no
implicit suffix). */
+ public static final String DEFAULT_TEMPLATE_SUFFIX = "";
+
+ private final String basePath;
+ private final String templateSuffix;
+
+ // Lazy bridge-default factory. Built on first call to
resolveMustacheFactory(...) when no
+ // MustacheFactory bean is registered in the request's BeanStore.
Volatile so the
+ // double-checked-locking idiom is safe under concurrent first-request
load.
+ private volatile MustacheFactory defaultFactory;
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * No-arg constructor — uses {@link #DEFAULT_BASE_PATH} as the
base path and
+ * {@link #DEFAULT_TEMPLATE_SUFFIX} for the template suffix.
+ *
+ * <p>
+ * The mixin walk falls back to this constructor when the
+ * {@link org.apache.juneau.commons.inject.BeanStore BeanStore} does
not have a registered
+ * {@code BasicMustacheResource} bean.
+ */
+ public BasicMustacheResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder. Must not be {@code null}.
+ */
+ protected BasicMustacheResource(Builder builder) {
+ basePath = builder.basePath;
+ templateSuffix = builder.templateSuffix;
+ }
+
+ /**
+ * Returns the base path under which template resources are resolved.
+ *
+ * <p>
+ * Used to derive the bridge-default factory's resource root, and as
the boundary for the
+ * path-traversal check in {@link #render render(...)}.
+ *
+ * @return The base path. Never {@code null}.
+ */
+ public String getBasePath() {
+ return basePath;
+ }
+
+ /**
+ * Returns the configured template-name suffix.
+ *
+ * <p>
+ * When non-blank, the bridge appends this suffix to any template name
that does not already
+ * end with it (idempotent). Defaults to {@link
#DEFAULT_TEMPLATE_SUFFIX} (empty —
+ * literal names).
+ *
+ * @return The template suffix. Never {@code null}.
+ */
+ public String getTemplateSuffix() {
+ return templateSuffix;
+ }
+
+ /**
+ * Appends {@link #getTemplateSuffix()} to {@code name} if not already
present (idempotent).
+ *
+ * @param name The template name. Must not be {@code null}.
+ * @return The template name with the configured suffix appended (if
applicable).
+ */
+ public String applyTemplateSuffix(String name) {
+ if (templateSuffix == null || templateSuffix.isEmpty())
+ return name;
+ if (name.endsWith(templateSuffix))
+ return name;
+ return name + templateSuffix;
+ }
+
+ /**
+ * Resolves the active {@link MustacheFactory}.
+ *
+ * <p>
+ * Lookup order:
+ * <ol class='spaced-list'>
+ * <li>{@code
req.getContext().getBeanStore().getBean(MustacheFactory.class)} — any
+ * user-supplied bean (Spring {@code @Bean}, microservice
{@code BasicBeanStore.put},
+ * etc.).
+ * <li>Lazy bridge default — constructed on first call when
no factory bean is
+ * registered. Carries a {@link DefaultMustacheFactory}
anchored on a resource root
+ * derived from {@link #basePath} (leading + trailing
slashes trimmed).
+ * </ol>
+ *
+ * @param req The current REST request.
+ * @return The active Mustache factory. Never {@code null}.
+ */
+ public MustacheFactory resolveMustacheFactory(RestRequest req) {
+ var bean =
req.getContext().getBeanStore().getBean(MustacheFactory.class);
+ if (bean.isPresent())
+ return bean.get();
+ var local = defaultFactory;
+ if (local == null) {
+ synchronized (this) {
+ local = defaultFactory;
+ if (local == null) {
+ local = buildDefaultFactory();
+ defaultFactory = local;
+ }
+ }
+ }
+ return local;
+ }
+
+ /**
+ * Constructs the bridge-default {@link MustacheFactory}.
+ *
+ * <p>
+ * {@link DefaultMustacheFactory} anchored on a classpath resource root
derived from
+ * {@link #basePath} (leading + trailing slashes trimmed). A {@code
"/"} base yields a
+ * no-prefix factory. Subclasses may override to plug in custom
resolvers / object handlers
+ * without registering a separate {@code @Bean MustacheFactory}.
+ *
+ * @return A new {@link MustacheFactory} instance.
+ */
+ protected MustacheFactory buildDefaultFactory() {
+ var root = toResourceRoot(basePath);
+ return root.isEmpty() ? new DefaultMustacheFactory() : new
DefaultMustacheFactory(root);
+ }
+
+ /**
+ * Translates a virtual base path (e.g. {@code "/templates/"}) into a
mustache.java resource
+ * root (e.g. {@code "templates"}).
+ *
+ * <p>
+ * mustache.java's {@link
DefaultMustacheFactory#DefaultMustacheFactory(String)} expects a
+ * classpath-relative root without a leading slash and treats a
trailing slash as part of the
+ * resolved path, so we trim both ends. A {@code null} / blank / {@code
"/"} base yields an
+ * empty string — meaning "no prefix" (factory resolves names
directly).
+ *
+ * @param base The virtual base path.
+ * @return The mustache.java resource root (never {@code null}).
+ */
+ static String toResourceRoot(String base) {
+ if (base == null || base.isBlank())
+ return "";
+ var s = base;
+ while (s.startsWith("/"))
+ s = s.substring(1);
+ while (s.endsWith("/"))
+ s = s.substring(0, s.length() - 1);
+ return s;
+ }
+
+ /**
+ * [GET /mustache/*] — render a raw template through the Mustache
engine.
+ *
+ * <p>
+ * The {@code @Path("/*") String path} captures the multi-segment
trailing remainder (e.g. a
+ * request for {@code /mustache/about.mustache} matches the mount with
+ * {@code path = "about.mustache"}; a request for {@code
/mustache/admin/dashboard.mustache}
+ * matches with {@code path = "admin/dashboard.mustache"}). Behavior:
+ *
+ * <ol class='spaced-list'>
+ * <li>Validate the resolved {@code <basePath><path>} via
+ * {@link FileUtils#resolveVirtualPathSafely(String,
String)} — reject any
+ * {@code ..} traversal with HTTP 403.
+ * <li>Apply the configured template suffix idempotently (e.g. with
+ * {@code templateSuffix(".mustache")}, a request for
{@code /mustache/about} resolves
+ * template {@code "about.mustache"} via {@link
#applyTemplateSuffix}).
+ * <li>Ask the active {@link MustacheFactory} to compile the
template and execute it with
+ * an empty scope directly onto the response writer;
request attributes and parameters
+ * are not auto-bound (callers who want attributes use
{@link MustacheView} from a typed
+ * handler instead).
+ * </ol>
+ *
+ * <p>
+ * Missing template surfaces as the engine's own {@link
MustacheNotFoundException}
+ * (HTTP 500 from the renderer's catch-all); missing engine surfaces as
+ * {@link MustacheViewRenderer#NO_ENGINE_DIAGNOSTIC}.
+ *
+ * @param path The trailing path segment after {@code /mustache/} (the
template name relative
+ * to the configured {@link #getBasePath() base path}; configured
suffix appended if
+ * missing).
+ * @param req The current REST request.
+ * @param res The current REST response.
+ * @throws IOException If the underlying response writer fails.
+ * @throws BasicHttpException On boundary violation (403), missing
engine (500), or render
+ * failure (500).
+ */
+ @RestGet(
+ path="/${juneau.mustache.path:mustache}/*",
+ summary="Mustache view",
+ description="Render a raw Mustache template under the
configured base path.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void render(@Path("/*") String path, RestRequest req,
RestResponse res)
+ throws IOException, BasicHttpException {
+
+ var template = (path == null) ? "" : path;
+
+ // resolveVirtualPathSafely validates that the resolved virtual
path stays inside the
+ // configured basePath. The traversal check operates on
basePath + template so a template
+ // name like "../../etc/passwd" or "a/b/../../../secret" is
rejected before reaching the
+ // engine. We re-derive the factory-relative template name from
the safe result by
+ // stripping the (already-normalized) basePath prefix.
+ String safeTemplate;
+ try {
+ var resolved =
FileUtils.resolveVirtualPathSafely(basePath, template);
+ safeTemplate = stripBasePath(basePath, resolved);
+ } catch (@SuppressWarnings("unused") IllegalArgumentException
ex) {
+ throw new Forbidden("Path escapes configured base
path.");
+ }
+
+ // Apply suffix idempotently so /mustache/about resolves to
about.mustache (when
+ // templateSuffix is configured), while
/mustache/about.mustache stays as-is.
+ safeTemplate = applyTemplateSuffix(safeTemplate);
+
+ try {
+ var factory = resolveMustacheFactory(req);
+ if (! res.containsHeader("Content-Type"))
+ res.setHeader("Content-Type",
MustacheViewRenderer.DEFAULT_CONTENT_TYPE);
+ var mustache = factory.compile(safeTemplate);
+ mustache.execute(res.getWriter(), Map.of()).flush();
+ } catch (LinkageError ex) {
+ throw new InternalServerError(ex,
MustacheViewRenderer.NO_ENGINE_DIAGNOSTIC);
+ } catch (IOException ex) {
+ throw ex;
+ } catch (BasicHttpException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ throw new InternalServerError(ex, "Mustache render
failed for ''{0}''", safeTemplate);
+ }
+ }
+
+ /**
+ * Strips the normalized {@code basePath} prefix from a {@code
resolveVirtualPathSafely}
+ * result so the leftover string is factory-relative (the bridge's
default factory re-adds
+ * the configured resource-root prefix).
+ *
+ * <p>
+ * Normalization mirrors {@code FileUtils.resolveVirtualPathSafely}: a
{@code null} / empty
+ * base normalizes to {@code "/"}; otherwise the base is guaranteed to
start with {@code "/"}
+ * and end with {@code "/"} once the helper has normalized it.
+ *
+ * @param base The configured base path (typically {@code
"/templates/"}).
+ * @param resolved The output of {@code resolveVirtualPathSafely}
(always starts with the
+ * normalized base).
+ * @return The factory-relative template name (e.g. {@code
"hello.mustache"} for base
+ * {@code "/templates/"} and resolved {@code
"/templates/hello.mustache"}).
+ */
+ static String stripBasePath(String base, String resolved) {
+ var bp = (base == null || base.isEmpty()) ? "/" : base;
+ if (! bp.endsWith("/"))
+ bp = bp + "/";
+ if (! bp.startsWith("/"))
+ bp = "/" + bp;
+ if (resolved.startsWith(bp))
+ return resolved.substring(bp.length());
+ throw illegalArg("Resolved path ''{0}'' does not start with
base ''{1}''", resolved, bp);
+ }
+
+ /**
+ * Builder for {@link BasicMustacheResource}.
+ */
+ public static class Builder {
+
+ private String basePath = DEFAULT_BASE_PATH;
+ private String templateSuffix = DEFAULT_TEMPLATE_SUFFIX;
+
+ /** Constructor — package access for {@link
BasicMustacheResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Sets the classpath base path under which template resources
are resolved.
+ *
+ * <p>
+ * Defaults to {@link BasicMustacheResource#DEFAULT_BASE_PATH
"/"}. A typical configured
+ * value is {@code "/templates/"} — the same
Spring-Boot-compatible layout
+ * convention the sibling JSP / Thymeleaf bridges use.
+ *
+ * @param value The base path. {@code null} or blank values
reset to the default
+ * {@link BasicMustacheResource#DEFAULT_BASE_PATH}.
+ * @return This object.
+ */
+ public Builder basePath(String value) {
+ basePath = (value == null || value.isBlank()) ?
DEFAULT_BASE_PATH : value;
+ return this;
+ }
+
+ /**
+ * Sets the suffix to append to template names that don't
already end with it (idempotent).
+ *
+ * <p>
+ * Defaults to {@link
BasicMustacheResource#DEFAULT_TEMPLATE_SUFFIX ""} (no implicit
+ * suffix — literal template names). Typical configured
value is
+ * {@code ".mustache"}, the mustache.java convention.
+ *
+ * @param value The new suffix. {@code null} resets to
+ * {@link BasicMustacheResource#DEFAULT_TEMPLATE_SUFFIX}.
+ * @return This object.
+ */
+ public Builder templateSuffix(String value) {
+ templateSuffix = (value == null) ?
DEFAULT_TEMPLATE_SUFFIX : value;
+ return this;
+ }
+
+ /**
+ * Reads the current base path setting (test/inspection helper).
+ *
+ * @return The base path. Never {@code null}.
+ */
+ public String getBasePath() {
+ return basePath;
+ }
+
+ /**
+ * Reads the current template-suffix setting (test/inspection
helper).
+ *
+ * @return The template suffix. Never {@code null}.
+ */
+ public String getTemplateSuffix() {
+ return templateSuffix;
+ }
+
+ /**
+ * Builds the {@link BasicMustacheResource}.
+ *
+ * @return A new {@link BasicMustacheResource} instance.
+ */
+ public BasicMustacheResource build() {
+ if (basePath == null)
+ throw illegalArg("basePath must not be null");
+ return new BasicMustacheResource(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheView.java
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheView.java
new file mode 100644
index 0000000000..4a431d5e9f
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheView.java
@@ -0,0 +1,212 @@
+/*
+ * 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.mustache;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.view.*;
+
+/**
+ * Immutable value class returned from {@code @RestOp}-annotated methods to
ask the framework to
+ * render a Mustache template.
+ *
+ * <p>
+ * Companion to {@link BasicMustacheResource} and {@link
MustacheViewRenderer}: the mixin sets up
+ * the {@code /mustache/*} mount and registers the renderer; the renderer
detects
+ * {@code MustacheView} returns in the response-processor chain and asks the
configured
+ * {@code com.github.mustachejava.MustacheFactory} to {@code
compile(templateName)} and then
+ * {@code execute(writer, scope)} directly onto the response writer.
+ *
+ * <h5 class='figure'>Usage:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ * <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) {
+ * <jk>return</jk>
MustacheView.<jsm>of</jsm>(<js>"hello.mustache"</js>)
+ * .attr(<js>"name"</js>, <jv>name</jv>)
+ * .attr(<js>"ts"</js>, Instant.<jsm>now</jsm>());
+ * }
+ * </p>
+ *
+ * <p>
+ * The template name is factory-relative — the configured
+ * {@code com.github.mustachejava.MustacheFactory} resource resolver prepends
its own resource
+ * root. With the bridge's default factory + {@code basePath("/templates/")},
+ * {@code MustacheView.of("hello.mustache")} resolves to {@code
templates/hello.mustache} on the
+ * classpath.
+ *
+ * <h5 class='section'>Template suffix:</h5>
+ *
+ * <p>
+ * Unlike the Thymeleaf bridge (which always appends {@code .html} via the
engine resolver's
+ * suffix setting), mustache.java does not have a built-in resolver-suffix
concept — the
+ * literal template name is what {@code factory.compile(...)} sees. The
+ * {@link BasicMustacheResource.Builder#templateSuffix(String)
templateSuffix(...)} builder knob
+ * fills the gap: when set, the bridge appends the suffix to template names
that don't already
+ * end with it (idempotent), so the caller can write {@code
MustacheView.of("hello")} and the
+ * bridge resolves it as {@code "hello.mustache"} on the classpath.
+ *
+ * <h5 class='section'>Immutability:</h5>
+ *
+ * <p>
+ * Each {@code attr(...)} / {@code attrs(...)} / {@code header(...)} call
returns a <b>new</b>
+ * {@code MustacheView} carrying the additional binding; the original instance
is unchanged. This
+ * keeps {@code MustacheView} a value type safe to share across requests (e.g.
as a static
+ * singleton built once and returned from many handlers).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicMustacheResource}
+ * <li class='jc'>{@link MustacheViewRenderer}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/MustacheViewSupport">Mustache View
Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public final class MustacheView implements View {
+
+ private final String templateName;
+ private final Map<String, Object> attributes;
+ private final Map<String, String> responseHeaders;
+
+ /**
+ * Creates a new {@code MustacheView} carrying the given template name
and no attributes.
+ *
+ * <p>
+ * The template name is factory-relative; the configured
+ * {@code com.github.mustachejava.MustacheFactory} resource resolver
supplies the prefix
+ * (resource root). With the bridge's default factory + {@code
basePath("/templates/")}
+ * and {@code templateSuffix(".mustache")}, {@code "hello"} resolves to
+ * {@code templates/hello.mustache}; without {@code templateSuffix},
the literal name is
+ * used as-is.
+ *
+ * @param templateName The template name (factory-relative, suffix
optional). Must not be
+ * {@code null} or blank.
+ * @return A new {@code MustacheView} instance.
+ * @throws IllegalArgumentException If {@code templateName} is {@code
null} or blank.
+ */
+ public static MustacheView of(String templateName) {
+ if (templateName == null || templateName.isBlank())
+ throw illegalArg("templateName must not be null or
blank");
+ return new MustacheView(templateName, Map.of(), Map.of());
+ }
+
+ private MustacheView(String templateName, Map<String, Object>
attributes, Map<String, String> responseHeaders) {
+ this.templateName = templateName;
+ this.attributes = attributes;
+ this.responseHeaders = responseHeaders;
+ }
+
+ /**
+ * Returns a copy of this view with the given attribute added (or
replaced if a binding for
+ * {@code key} already exists).
+ *
+ * <p>
+ * The full attribute map is passed to mustache.java as the rendering
scope, so each entry
+ * is accessible inside the template as {@code {{key}}}. {@code null}
values are rejected at
+ * build-time because a {@code null} scope binding in mustache.java
renders as an empty
+ * string, which silently masks what is typically a caller bug;
rejecting up-front surfaces
+ * the mistake at the call site (matches the Thymeleaf-bridge
precedent).
+ *
+ * @param key The attribute key (exposed inside the template as {@code
{{key}}}). Must not be
+ * {@code null} or blank.
+ * @param value The attribute value. Must not be {@code null}.
+ * @return A new {@code MustacheView} carrying the additional attribute.
+ * @throws IllegalArgumentException If {@code key} is {@code null} or
blank, or {@code value}
+ * is {@code null}.
+ */
+ public MustacheView attr(String key, Object value) {
+ if (key == null || key.isBlank())
+ throw illegalArg("attribute key must not be null or
blank");
+ if (value == null)
+ throw illegalArg("attribute value must not be null
(attribute ''{0}'')", key);
+ var copy = new LinkedHashMap<>(attributes);
+ copy.put(key, value);
+ return new MustacheView(templateName, Map.copyOf(copy),
responseHeaders);
+ }
+
+ /**
+ * Returns a copy of this view with all entries from the given map
added (or replaced if a
+ * binding for any key already exists).
+ *
+ * @param values The attributes to add. {@code null} is treated as an
empty map (the result is
+ * the same instance with no entries added).
+ * @return A new {@code MustacheView} carrying the additional
attributes.
+ * @throws IllegalArgumentException If any entry has a {@code
null}/blank key or a {@code null}
+ * value.
+ */
+ public MustacheView attrs(Map<String, ?> values) {
+ if (values == null || values.isEmpty())
+ return this;
+ var copy = new LinkedHashMap<>(attributes);
+ values.forEach((k, v) -> {
+ if (k == null || k.isBlank())
+ throw illegalArg("attribute key must not be
null or blank");
+ if (v == null)
+ throw illegalArg("attribute value must not be
null (attribute ''{0}'')", k);
+ copy.put(k, v);
+ });
+ return new MustacheView(templateName, Map.copyOf(copy),
responseHeaders);
+ }
+
+ /**
+ * Returns a copy of this view with the given response header set on
the rendered response.
+ *
+ * <p>
+ * Typical usage: {@code .header("Content-Type", "text/html;
charset=UTF-8")} or
+ * {@code .header("Cache-Control", "no-store")}. The renderer applies
these headers via
+ * {@link jakarta.servlet.http.HttpServletResponse#setHeader(String,
String)
+ * HttpServletResponse.setHeader(...)} before writing the rendered
output.
+ *
+ * @param name The header name. Must not be {@code null} or blank.
+ * @param value The header value. Must not be {@code null}.
+ * @return A new {@code MustacheView} carrying the additional response
header.
+ * @throws IllegalArgumentException If {@code name} is {@code null} or
blank, or {@code value}
+ * is {@code null}.
+ */
+ public MustacheView header(String name, String value) {
+ if (name == null || name.isBlank())
+ throw illegalArg("header name must not be null or
blank");
+ if (value == null)
+ throw illegalArg("header value must not be null (header
''{0}'')", name);
+ var copy = new LinkedHashMap<>(responseHeaders);
+ copy.put(name, value);
+ return new MustacheView(templateName, attributes,
Map.copyOf(copy));
+ }
+
+ @Override /* Overridden from View */
+ public String getTemplateName() {
+ return templateName;
+ }
+
+ @Override /* Overridden from View */
+ public Map<String, Object> getAttributes() {
+ return attributes;
+ }
+
+ @Override /* Overridden from View */
+ public Map<String, String> getResponseHeaders() {
+ return responseHeaders;
+ }
+
+ @Override /* Overridden from Object */
+ public String toString() {
+ return "MustacheView[template=" + templateName + ", attrs=" +
attributes.keySet()
+ + ", headers=" + responseHeaders.keySet() + "]";
+ }
+}
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
new file mode 100644
index 0000000000..b7de1b72b5
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer.java
@@ -0,0 +1,127 @@
+/*
+ * 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.mustache;
+
+import java.io.*;
+
+import com.github.mustachejava.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+
+/**
+ * {@link ResponseProcessor} that detects {@link MustacheView}-typed return
values and asks the
+ * configured {@link MustacheFactory} to render them directly onto the
response writer.
+ *
+ * <p>
+ * Auto-registered by {@link BasicMustacheResource} via
+ * {@link org.apache.juneau.rest.annotation.Rest#responseProcessors()
@Rest(responseProcessors=...)}
+ * — callers who add the mixin don't need to wire up this class
explicitly. Callers who want
+ * to handle {@code MustacheView} returns <i>without</i> adopting the mixin
can add this class to
+ * their own
+ * {@link org.apache.juneau.rest.annotation.Rest#responseProcessors()
responseProcessors} list.
+ *
+ * <h5 class='section'>Behavior:</h5>
+ *
+ * <ol class='spaced-list'>
+ * <li>Inspect the response content. If the value is not a {@link
MustacheView}, return
+ * {@link ResponseProcessor#NEXT NEXT} so the rest of the chain
runs.
+ * <li>Read the active {@link BasicMustacheResource} from the {@code
RestContext} bean store to
+ * discover the {@link MustacheFactory} (lazy default if no
factory bean is registered) and
+ * the optional template-suffix knob.
+ * <li>Apply every entry from {@link MustacheView#getResponseHeaders()} via
+ * {@link
jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
+ * res.setHeader(...)}.
+ * <li>Default {@code Content-Type} to {@code text/html;charset=UTF-8} if
the caller did not set
+ * one explicitly (Mustache is content-neutral, but HTML is
overwhelmingly the most common
+ * output target; an explicit caller header wins).
+ * <li>Call {@code factory.compile(templateName).execute(res.getWriter(),
view.getAttributes())}
+ * to stream the rendered output.
+ * <li>When no Mustache engine is on the classpath, the {@link
MustacheFactory}-typed import
+ * here fails to load at first use and surfaces {@link
#NO_ENGINE_DIAGNOSTIC} naming the
+ * missing dependency.
+ * </ol>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicMustacheResource}
+ * <li class='jc'>{@link MustacheView}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response
Processors</a>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/MustacheViewSupport">Mustache View
Support</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class MustacheViewRenderer implements ResponseProcessor {
+
+ /** 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";
+
+ /**
+ * Diagnostic message emitted when no Mustache engine can be found on
the classpath at
+ * render-time.
+ *
+ * <p>
+ * Public so tests and consumer apps can pattern-match against the
message text without
+ * depending on internal string literals.
+ */
+ public static final String NO_ENGINE_DIAGNOSTIC =
+ "No Mustache engine is available on the classpath. Add:\n"
+ + " - com.github.spullara.mustache.java:compiler
(mustache.java — the engine the bridge targets)\n"
+ + "Or register a custom @Bean MustacheFactory that picks up
your preferred resolvers.\n"
+ + "Note: Spring Boot's spring-boot-starter-mustache ships
com.samskivert:jmustache, NOT\n"
+ + "mustache.java; the bridge is mustache.java-specific. To use
jmustache, supply your own\n"
+ + "ResponseProcessor instead of MustacheViewRenderer.\n"
+ + "See
https://juneau.apache.org/docs/topics/MustacheViewSupport for the full matrix.";
+
+ @Override /* Overridden from ResponseProcessor */
+ public int process(RestOpSession opSession) throws IOException,
BasicHttpException {
+ var req = opSession.getRequest();
+ var res = opSession.getResponse();
+
+ var content = res.getContent(Object.class);
+ if (! (content instanceof MustacheView view))
+ return NEXT;
+
+ // Resolve the bridge resource (carries MustacheFactory +
cached default + templateSuffix).
+ // Fall back to a fresh BasicMustacheResource when the renderer
is used standalone without
+ // the mixin.
+ var bridge = req.getContext().getBeanStore()
+ .getBean(BasicMustacheResource.class)
+ .orElseGet(BasicMustacheResource::new);
+
+ // Apply caller-supplied response headers first so a
caller-provided Content-Type wins
+ // over the bridge's default below.
+ view.getResponseHeaders().forEach(res::setHeader);
+ if (! res.containsHeader("Content-Type"))
+ res.setHeader("Content-Type", DEFAULT_CONTENT_TYPE);
+
+ var templateName =
bridge.applyTemplateSuffix(view.getTemplateName());
+ try {
+ var factory = bridge.resolveMustacheFactory(req);
+ var mustache = factory.compile(templateName);
+ mustache.execute(res.getWriter(),
view.getAttributes()).flush();
+ return FINISHED;
+ } catch (LinkageError ex) {
+ throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
+ } catch (IOException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ throw new InternalServerError(ex, "Mustache render
failed for ''{0}''", templateName);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/package-info.java
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/package-info.java
new file mode 100644
index 0000000000..858131def7
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/view/mustache/package-info.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.
+ */
+/**
+ * Mustache view-rendering bridge — opt-in {@code
juneau-rest-server-view-mustache} module.
+ *
+ * <p>
+ * Provides three sibling pieces that together let a Juneau REST resource
render Mustache
+ * templates via <a
href="https://github.com/spullara/mustache.java">mustache.java</a>:
+ *
+ * <ul class='javatreec'>
+ * <li class='jc'>{@link
org.apache.juneau.rest.view.mustache.BasicMustacheResource} —
+ * mixin that renders raw Mustache templates from the importer's
classpath under
+ * {@code /mustache/*} and auto-registers
+ * {@link
org.apache.juneau.rest.view.mustache.MustacheViewRenderer}.
+ * <li class='jc'>{@link
org.apache.juneau.rest.view.mustache.MustacheView} — immutable
+ * value class (implements the core {@link
org.apache.juneau.rest.view.View View} interface)
+ * returned from {@code @RestOp} methods to ask the framework to
render a Mustache template.
+ * <li class='jc'>{@link
org.apache.juneau.rest.view.mustache.MustacheViewRenderer} —
+ * {@link org.apache.juneau.rest.processor.ResponseProcessor
ResponseProcessor} that detects
+ * {@link org.apache.juneau.rest.view.mustache.MustacheView
MustacheView} returns and
+ * dispatches them to the configured
+ * {@link com.github.mustachejava.MustacheFactory MustacheFactory}
via
+ * {@code factory.compile(templateName).execute(writer, scope)}.
+ * </ul>
+ *
+ * <h5 class='figure'>Composition example (microservice):</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/app"</js>,
mixins=BasicMustacheResource.<jk>class</jk>)
+ * <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
+ *
+ * <ja>@Bean</ja> BasicMustacheResource mustache() {
+ * <jk>return</jk>
BasicMustacheResource.<jsm>create</jsm>()
+ * .basePath(<js>"/templates/"</js>)
+ * .templateSuffix(<js>".mustache"</js>)
+ * .build();
+ * }
+ *
+ * <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
+ * <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>)
{
+ * <jk>return</jk>
MustacheView.<jsm>of</jsm>(<js>"hello"</js>).attr(<js>"name"</js>,
<jv>name</jv>);
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Engine-agnostic packaging:</h5>
+ *
+ * <p>
+ * The bridge module's main POM declares <b>only</b>
+ * {@code com.github.spullara.mustache.java:compiler} in {@code provided}
scope — <b>no
+ * concrete Mustache transitive dep</b> ships here. Consumers add the engine
matching their
+ * deployment:
+ *
+ * <ul class='spaced-list'>
+ * <li><b>Juneau microservice / Jetty / Spring Boot:</b> add
+ * {@code com.github.spullara.mustache.java:compiler}. With no
user-supplied factory bean,
+ * the bridge builds a default {@link
com.github.mustachejava.MustacheFactory
+ * MustacheFactory} on first request anchored on the importer's
classpath under the
+ * configured base path.
+ * <li><b>Custom:</b> register your own {@code @Bean MustacheFactory} with
whatever resolvers,
+ * object handlers, and encoders you need.
+ * </ul>
+ *
+ * <p>
+ * When no Mustache engine is on the classpath, the renderer surfaces a clear
diagnostic naming
+ * the missing dependency and linking to the "Choosing a MustacheFactory"
matrix in
+ * {@code MustacheViewSupport.md}.
+ *
+ * <h5 class='section'>Note on Spring Boot's mustache starter:</h5>
+ *
+ * <p>
+ * Spring Boot's {@code spring-boot-starter-mustache} pulls in {@code
com.samskivert:jmustache}
+ * (a separate Mustache implementation), <b>not</b> mustache.java. The bridge
is mustache.java-
+ * specific; consumers who want to use jmustache instead supply their own
+ * {@link org.apache.juneau.rest.processor.ResponseProcessor
ResponseProcessor} rather than
+ * {@link org.apache.juneau.rest.view.mustache.MustacheViewRenderer
MustacheViewRenderer}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.view.View}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/MustacheViewSupport">Mustache View
Support</a>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.view.mustache;
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index b4ff80d0eb..37fa14e046 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -39,6 +39,7 @@
<module>juneau-rest-server-jwt</module>
<module>juneau-rest-server-view-jsp</module>
<module>juneau-rest-server-view-thymeleaf</module>
+ <module>juneau-rest-server-view-mustache</module>
<module>juneau-rest-server-springboot</module>
<module>juneau-rest-client</module>
<module>juneau-rest-client-classic</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index e33c607e81..94dba56875 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -226,6 +226,27 @@
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
+ <!--
+ Mustache view bridge + mustache.java engine for the
BasicMustacheResource /
+ MustacheView test matrix. Engine-agnostic POM stance
(TODO-83 resolved decision #2 /
+ mirrored from TODO-78 #2 + TODO-82 #2): the bridge
module itself has no concrete
+ engine dep, so juneau-utest supplies mustache.java's
compiler in `test` scope. The
+ bridge is mustache.java-specific (Spring Boot's
spring-boot-starter-mustache ships
+ com.samskivert:jmustache instead), so there is no
Spring-Boot-starter flavor in this
+ test surface.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-view-mustache</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.github.spullara.mustache.java</groupId>
+ <artifactId>compiler</artifactId>
+ <version>0.9.14</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_Builder_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_Builder_Test.java
new file mode 100644
index 0000000000..0e69ad50f1
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_Builder_Test.java
@@ -0,0 +1,222 @@
+/*
+ * 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.mustache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+import com.github.mustachejava.*;
+
+/**
+ * Unit tests for the {@link BasicMustacheResource.Builder} contract, the
+ * {@link BasicMustacheResource#applyTemplateSuffix applyTemplateSuffix}
helper,
+ * the {@link BasicMustacheResource#toResourceRoot toResourceRoot} helper, and
the
+ * {@link BasicMustacheResource#stripBasePath stripBasePath} helper.
+ *
+ * <p>
+ * Mirrors the {@code BasicJspResource_Builder_Test} / {@code
BasicThymeleafResource_Builder_Test}
+ * shape so sibling view modules can follow the same template.
+ *
+ * @since 9.5.0
+ */
+class BasicMustacheResource_Builder_Test extends TestBase {
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section A: builder surface
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void a01_defaultBasePathIsRoot() {
+ var r = BasicMustacheResource.create().build();
+ assertEquals(BasicMustacheResource.DEFAULT_BASE_PATH,
r.getBasePath());
+ assertEquals("/", r.getBasePath());
+ }
+
+ @Test void a02_basePathSetterRoundTrips() {
+ var r =
BasicMustacheResource.create().basePath("/templates/").build();
+ assertEquals("/templates/", r.getBasePath());
+ }
+
+ @Test void a03_basePathNullResetsToDefault() {
+ var r = BasicMustacheResource.create()
+ .basePath("/templates/")
+ .basePath(null)
+ .build();
+ assertEquals(BasicMustacheResource.DEFAULT_BASE_PATH,
r.getBasePath());
+ }
+
+ @Test void a04_basePathBlankResetsToDefault() {
+ var r = BasicMustacheResource.create()
+ .basePath("/templates/")
+ .basePath(" ")
+ .build();
+ assertEquals(BasicMustacheResource.DEFAULT_BASE_PATH,
r.getBasePath());
+ }
+
+ @Test void a05_builderGetBasePathReadsBeforeBuild() {
+ var b = BasicMustacheResource.create();
+ assertEquals(BasicMustacheResource.DEFAULT_BASE_PATH,
b.getBasePath());
+ b.basePath("/templates/");
+ assertEquals("/templates/", b.getBasePath());
+ }
+
+ @Test void a06_noArgConstructorUsesDefaults() {
+ var r = new BasicMustacheResource();
+ assertEquals(BasicMustacheResource.DEFAULT_BASE_PATH,
r.getBasePath());
+ assertEquals(BasicMustacheResource.DEFAULT_TEMPLATE_SUFFIX,
r.getTemplateSuffix());
+ }
+
+ @Test void a07_templateSuffixDefaultsEmpty() {
+ var r = BasicMustacheResource.create().build();
+ assertEquals("", r.getTemplateSuffix());
+ }
+
+ @Test void a08_templateSuffixSetterRoundTrips() {
+ var r =
BasicMustacheResource.create().templateSuffix(".mustache").build();
+ assertEquals(".mustache", r.getTemplateSuffix());
+ }
+
+ @Test void a09_templateSuffixNullResetsToDefault() {
+ var r = BasicMustacheResource.create()
+ .templateSuffix(".mustache")
+ .templateSuffix(null)
+ .build();
+ assertEquals(BasicMustacheResource.DEFAULT_TEMPLATE_SUFFIX,
r.getTemplateSuffix());
+ }
+
+ @Test void a10_builderReadersReflectMutations() {
+ var b = BasicMustacheResource.create()
+ .basePath("/views/")
+ .templateSuffix(".mst");
+ assertEquals("/views/", b.getBasePath());
+ assertEquals(".mst", b.getTemplateSuffix());
+ }
+
+ @Test void a11_defaultFactoryIsLazyButReused() {
+ // buildDefaultFactory returns a fresh instance each call; the
caching is in
+ // resolveMustacheFactory itself. This test pins the contract
that the constructor is
+ // idempotent (each call returns a non-null factory of the
bridge default type).
+ var r =
BasicMustacheResource.create().basePath("/mustache-templates/").build();
+ var f1 = r.buildDefaultFactory();
+ var f2 = r.buildDefaultFactory();
+ assertNotNull(f1);
+ assertNotNull(f2);
+ assertNotSame(f1, f2,
+ "buildDefaultFactory is the constructor — the caching
lives in resolveMustacheFactory");
+ assertInstanceOf(DefaultMustacheFactory.class, f1);
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section B: applyTemplateSuffix helper (idempotent appender)
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void b01_applyTemplateSuffixAppendsWhenMissing() {
+ var r =
BasicMustacheResource.create().templateSuffix(".mustache").build();
+ assertEquals("hello.mustache", r.applyTemplateSuffix("hello"));
+ }
+
+ @Test void b02_applyTemplateSuffixIdempotentWhenPresent() {
+ var r =
BasicMustacheResource.create().templateSuffix(".mustache").build();
+ assertEquals("hello.mustache",
r.applyTemplateSuffix("hello.mustache"));
+ }
+
+ @Test void b03_applyTemplateSuffixNoOpWhenSuffixEmpty() {
+ var r = BasicMustacheResource.create().build();
+ assertEquals("hello", r.applyTemplateSuffix("hello"));
+ assertEquals("hello.mustache",
r.applyTemplateSuffix("hello.mustache"));
+ }
+
+ @Test void b04_applyTemplateSuffixHandlesMultiSegment() {
+ var r =
BasicMustacheResource.create().templateSuffix(".mustache").build();
+ assertEquals("admin/dashboard.mustache",
r.applyTemplateSuffix("admin/dashboard"));
+ assertEquals("admin/dashboard.mustache",
r.applyTemplateSuffix("admin/dashboard.mustache"));
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section C: toResourceRoot helper (basePath → mustache.java resource
root)
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void c01_toResourceRootTrimsLeadingAndTrailingSlashes() {
+ assertEquals("templates",
BasicMustacheResource.toResourceRoot("/templates/"));
+ }
+
+ @Test void c02_toResourceRootHandlesNoLeadingSlash() {
+ assertEquals("templates",
BasicMustacheResource.toResourceRoot("templates/"));
+ }
+
+ @Test void c03_toResourceRootHandlesNoTrailingSlash() {
+ assertEquals("templates",
BasicMustacheResource.toResourceRoot("/templates"));
+ }
+
+ @Test void c04_toResourceRootHandlesRootBase() {
+ assertEquals("", BasicMustacheResource.toResourceRoot("/"));
+ }
+
+ @Test void c05_toResourceRootHandlesNullBase() {
+ assertEquals("", BasicMustacheResource.toResourceRoot(null));
+ }
+
+ @Test void c06_toResourceRootHandlesBlankBase() {
+ assertEquals("", BasicMustacheResource.toResourceRoot(""));
+ assertEquals("", BasicMustacheResource.toResourceRoot(" "));
+ }
+
+ @Test void c07_toResourceRootHandlesMultiSegmentBase() {
+ assertEquals("a/b/c",
BasicMustacheResource.toResourceRoot("/a/b/c/"));
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section D: stripBasePath helper
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void d01_stripBasePathRemovesPrefix() {
+ assertEquals("hello",
+ BasicMustacheResource.stripBasePath("/templates/",
"/templates/hello"));
+ }
+
+ @Test void d02_stripBasePathHandlesMissingTrailingSlash() {
+ assertEquals("hello",
+ BasicMustacheResource.stripBasePath("/templates",
"/templates/hello"));
+ }
+
+ @Test void d03_stripBasePathHandlesRootBase() {
+ assertEquals("hello",
+ BasicMustacheResource.stripBasePath("/", "/hello"));
+ }
+
+ @Test void d04_stripBasePathHandlesNullBase() {
+ // null base normalizes to "/" (matches the helper's contract).
+ assertEquals("hello",
+ BasicMustacheResource.stripBasePath(null, "/hello"));
+ }
+
+ @Test void d05_stripBasePathHandlesEmptyBase() {
+ assertEquals("hello",
+ BasicMustacheResource.stripBasePath("", "/hello"));
+ }
+
+ @Test void d06_stripBasePathHandlesMultiSegment() {
+ assertEquals("admin/dashboard",
+ BasicMustacheResource.stripBasePath("/templates/",
"/templates/admin/dashboard"));
+ }
+
+ @Test void d07_stripBasePathThrowsWhenResolvedOutsideBase() {
+ assertThrows(IllegalArgumentException.class,
+ () ->
BasicMustacheResource.stripBasePath("/templates/", "/other/hello"));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_MockRest_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_MockRest_Test.java
new file mode 100644
index 0000000000..2462ecbc76
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_MockRest_Test.java
@@ -0,0 +1,148 @@
+/*
+ * 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.mustache;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * MockRest-level assertions for {@link BasicMustacheResource} composition +
route wiring.
+ *
+ * <p>
+ * Like Thymeleaf and unlike JSP, mustache.java has <b>zero servlet-container
dependencies</b>
+ * — it compiles a template into a {@code Mustache} object and asks it to
+ * {@code execute(Writer, scope)} directly. That makes the <i>raw-render
path</i> (the mixin's
+ * own {@code /mustache/*} handler that calls {@code
factory.compile(...).execute(...)} directly
+ * onto the response writer) fully exerciseable under MockRest, which is what
this test covers.
+ *
+ * <h5 class='section'>Deferred — typed {@link
org.apache.juneau.rest.view.View View} return
+ * path:</h5>
+ *
+ * <p>
+ * Verifying that an {@code @RestOp} method returning a {@link MustacheView}
renders through
+ * {@link MustacheViewRenderer} requires the renderer to run <b>before</b> the
default
+ * HTML/JSON/… serializer processors in the response chain. Today the
mixin's
+ * {@link Rest#responseProcessors() @Rest(responseProcessors=...)} declaration
appends to the
+ * chain, so the default HTML serializer wins under MockRest and the {@code
MustacheView} is
+ * serialized as a Juneau-bean HTML table instead of routed through Mustache.
The response-
+ * processor "prepend" mechanism that fixes this is tracked separately
(TODO-96) and blocks the
+ * typed-handler integration matrix for all three sibling view modules (JSP /
Thymeleaf /
+ * Mustache). The typed-handler test surface lights up alongside the
real-container coverage
+ * once TODO-96 lands — tracked as the Mustache analog of TODO-97 (JSP)
and TODO-107
+ * (Thymeleaf).
+ *
+ * <h5 class='figure'>Test resource layout (classpath {@code
src/test/resources}):</h5>
+ *
+ * <pre>
+ * /mustache-templates/hello.mustache ← greets "Hello, {{name}}!"
(used by future typed-handler tests)
+ * /mustache-templates/about.mustache ← static "About Juneau"
+ * /mustache-templates/nested/inner.mustache ← validates multi-segment paths
+ * </pre>
+ *
+ * @since 9.5.0
+ */
+class BasicMustacheResource_MockRest_Test extends TestBase {
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Resource A: mixin only — default base path "/" + default factory +
no implicit suffix
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Rest(mixins=BasicMustacheResource.class)
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_hostEndpointStillReachable() throws Exception {
+ ca.get("/items")
+ .accept("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("items");
+ }
+
+ @Test void a02_mustacheMountInstalled_rendersTemplate() throws
Exception {
+ // Default basePath="/" + no templateSuffix → request path is
taken as-is, mustache.java's
+ // no-prefix factory resolves it directly from classpath.
+ ca.get("/mustache/mustache-templates/about.mustache")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("About Juneau");
+ }
+
+ @Test void a04_nonMixinPathFallsThrough() throws Exception {
+ ca.get("/does-not-exist")
+ .run()
+ .assertStatus(404);
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Resource B: configured base path "/mustache-templates/" +
templateSuffix ".mustache"
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Rest(mixins=BasicMustacheResource.class)
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean public BasicMustacheResource mustache() {
+ return BasicMustacheResource.create()
+ .basePath("/mustache-templates/")
+ .templateSuffix(".mustache")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_renderResolvesUnderConfiguredBasePath() throws Exception
{
+ // basePath = /mustache-templates/, suffix = .mustache.
+ // /mustache/about → factory.compile("about.mustache") →
resolves
+ // mustache-templates/about.mustache on the classpath.
+ cb.get("/mustache/about")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("About Juneau");
+ }
+
+ @Test void b02_renderResolvesWithExplicitSuffix() throws Exception {
+ // Same template via the explicit-suffix request form; suffix
is idempotent.
+ cb.get("/mustache/about.mustache")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("About Juneau");
+ }
+
+ @Test void b03_renderHandlesMultiSegmentPath() throws Exception {
+ cb.get("/mustache/nested/inner")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("inner
template");
+ }
+
+ @Test void b04_contentTypeDefaultsToHtml() throws Exception {
+ cb.get("/mustache/about")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/html");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_PathTraversal_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_PathTraversal_Test.java
new file mode 100644
index 0000000000..49ccfe767c
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_PathTraversal_Test.java
@@ -0,0 +1,109 @@
+/*
+ * 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.mustache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Regression tests for path-traversal hardening in
+ * {@link BasicMustacheResource#render render(...)}.
+ *
+ * <p>
+ * Mirrors {@code BasicJspResource_PathTraversal_Test} and
+ * {@code BasicThymeleafResource_PathTraversal_Test}: the handler must reject
any user-supplied
+ * {@code @Path("/*") String path} that resolves outside the configured {@code
basePath} with HTTP
+ * 403, via {@link
org.apache.juneau.commons.utils.FileUtils#resolveVirtualPathSafely
+ * FileUtils.resolveVirtualPathSafely}.
+ *
+ * <p>
+ * MockRest does not URL-normalize request paths the way a real servlet
container does, so
+ * {@code @Path("/*") String path} receives the raw {@code ..} segments
— which is what the
+ * handler-layer boundary check is designed to catch. (Real-container coverage
is deferred to the
+ * Mustache analog of TODO-97 / TODO-107 that lights up Jetty/Spring Boot
end-to-end alongside
+ * the JSP and Thymeleaf modules.)
+ *
+ * @since 9.5.0
+ */
+class BasicMustacheResource_PathTraversal_Test extends TestBase {
+
+ @Rest(mixins=BasicMustacheResource.class)
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ // Register a non-default base path so the boundary check has
somewhere to stay inside of.
+ @Bean
+ public BasicMustacheResource mustache() {
+ return BasicMustacheResource.create()
+ .basePath("/mustache-templates/")
+ .templateSuffix(".mustache")
+ .build();
+ }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // Baseline — well-formed requests reach the engine (real template
renders since mustache.java
+ // works under MockRest, so we expect 200, not 403).
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void t01_normalAccess_passesBoundaryCheck() throws Exception {
+ var status = c.get("/mustache/about").run().getStatusCode();
+ assertNotEquals(403, status, "Well-formed request must not be
rejected by boundary check");
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // CWE-22: direct .. traversal of basePath returns 403
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void t02_directTraversal_returns403() throws Exception {
+ var status = c.get("/mustache/../secret").run().getStatusCode();
+ assertEquals(403, status, "GET /mustache/../secret must be
rejected (escapes /mustache-templates/)");
+ }
+
+ @Test void t03_nestedTraversal_returns403() throws Exception {
+ var status =
c.get("/mustache/a/b/../../../secret").run().getStatusCode();
+ assertEquals(403, status, "GET /mustache/a/b/../../../secret
must be rejected");
+ }
+
+ @Test void t04_traversalToSibling_returns403() throws Exception {
+ // /mustache/../views2/foo → after join:
/mustache-templates/../views2/foo →
+ // normalized: /views2/foo → does NOT start with
/mustache-templates/ → 403.
+ var status =
c.get("/mustache/../views2/foo").run().getStatusCode();
+ assertEquals(403, status, "Traversal to a sibling
base-path-prefix must be rejected");
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // URL-encoded traversal handling
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void t05_urlEncodedTraversal_doesNotEscape() throws Exception {
+ // Behavior depends on whether HttpClient / MockRest
URL-decodes %2e%2e before the
+ // handler sees the path. Either way, the response MUST NOT be
200 — that would mean the
+ // outside file got rendered.
+ var status =
c.get("/mustache/%2e%2e/secret").run().getStatusCode();
+ assertNotEquals(200, status,
+ "URL-encoded traversal must not return 200. Status was:
" + status);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_SvlPathOverride_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_SvlPathOverride_Test.java
new file mode 100644
index 0000000000..a0f48d6af0
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/BasicMustacheResource_SvlPathOverride_Test.java
@@ -0,0 +1,66 @@
+/*
+ * 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.mustache;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates the {@code ${juneau.mustache.path:mustache}} SVL override on
+ * {@link BasicMustacheResource}.
+ *
+ * <p>
+ * mustache.java renders directly to the response writer under MockRest (no
servlet-container
+ * deps), so we can verify the SVL substitution moved the mount point by
asserting that the
+ * default {@code /mustache/*} no longer matches (404) while the overridden
prefix does (200 with
+ * the rendered template body).
+ *
+ * <p>
+ * Uses fresh inner-class resources because {@link MockRestClient} caches
{@link RestContext} per
+ * resource class — SVL substitution is captured at context-construction
time.
+ *
+ * @since 9.5.0
+ */
+class BasicMustacheResource_SvlPathOverride_Test extends TestBase {
+
+ @Rest(mixins=BasicMustacheResource.class)
+ public static class A01_OverridePath extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test void a01_svlOverrideChangesPath() throws Exception {
+ var key = "juneau.mustache.path";
+ var prev = System.getProperty(key);
+ System.setProperty(key, "views");
+ try {
+ var c = MockRestClient.buildLax(A01_OverridePath.class);
+
+
c.get("/mustache/mustache-templates/about.mustache").run().assertStatus(404);
+
+ c.get("/views/mustache-templates/about.mustache")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("About
Juneau");
+ } finally {
+ if (prev == null) System.clearProperty(key);
+ else System.setProperty(key, prev);
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer_Test.java
new file mode 100644
index 0000000000..7d20df1764
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheViewRenderer_Test.java
@@ -0,0 +1,60 @@
+/*
+ * 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.mustache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link MustacheViewRenderer}.
+ *
+ * <p>
+ * Covers the diagnostic-message constant + default content-type. The full
+ * {@link MustacheViewRenderer#process process(...)} dispatch path is
exercised end-to-end by the
+ * MockRest-flavored tests in this package (full Jetty / Spring Boot
real-container coverage is
+ * deferred to a follow-on TODO, the Mustache analog of TODO-97 for JSP and
TODO-107 for
+ * Thymeleaf).
+ *
+ * @since 9.5.0
+ */
+class MustacheViewRenderer_Test extends TestBase {
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section A: NO_ENGINE_DIAGNOSTIC text
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void a01_diagnosticNamesMustacheJavaCompiler() {
+
assertTrue(MustacheViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("com.github.spullara.mustache.java:compiler"),
+ "Diagnostic must name the mustache.java compiler
dependency");
+ }
+
+ @Test void a02_diagnosticCallsOutJmustacheGotcha() {
+
assertTrue(MustacheViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("jmustache"),
+ "Diagnostic must call out the Spring Boot starter /
jmustache gotcha");
+ }
+
+ @Test void a03_diagnosticLinksToDocs() {
+
assertTrue(MustacheViewRenderer.NO_ENGINE_DIAGNOSTIC.contains("MustacheViewSupport"),
+ "Diagnostic must link to the Mustache topic page");
+ }
+
+ @Test void a04_defaultContentTypeIsHtmlUtf8() {
+ assertEquals("text/html;charset=UTF-8",
MustacheViewRenderer.DEFAULT_CONTENT_TYPE);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheView_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheView_Test.java
new file mode 100644
index 0000000000..214b2ce559
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/view/mustache/MustacheView_Test.java
@@ -0,0 +1,227 @@
+/*
+ * 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.mustache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.view.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link MustacheView}.
+ *
+ * <p>
+ * Covers the immutable-value-class contract: {@link MustacheView#of(String)
of(...)} validation,
+ * fluent {@link MustacheView#attr(String, Object) attr(...)} /
+ * {@link MustacheView#attrs(Map) attrs(...)} /
+ * {@link MustacheView#header(String, String) header(...)} chaining, and the
{@link View}-interface
+ * surface ({@code getTemplateName} / {@code getAttributes} / {@code
getResponseHeaders}).
+ *
+ * @since 9.5.0
+ */
+class MustacheView_Test extends TestBase {
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section A: factory + invariants
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void a01_ofProducesViewWithTemplateAndEmptyMaps() {
+ var v = MustacheView.of("hello.mustache");
+ assertEquals("hello.mustache", v.getTemplateName());
+ assertTrue(v.getAttributes().isEmpty(), "attributes must be
empty");
+ assertTrue(v.getResponseHeaders().isEmpty(), "responseHeaders
must be empty");
+ }
+
+ @Test void a02_ofRejectsNullTemplate() {
+ assertThrows(IllegalArgumentException.class, () ->
MustacheView.of(null));
+ }
+
+ @Test void a03_ofRejectsBlankTemplate() {
+ assertThrows(IllegalArgumentException.class, () ->
MustacheView.of(""));
+ assertThrows(IllegalArgumentException.class, () ->
MustacheView.of(" "));
+ }
+
+ @Test void a04_isViewInstance() {
+ // Verifies the type relationship that ResponseProcessor
pattern-matches on.
+ View v = MustacheView.of("x");
+ assertNotNull(v);
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section B: attr(...)
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void b01_attrAddsBindingOnNewInstance() {
+ var v0 = MustacheView.of("hello");
+ var v1 = v0.attr("name", "Bob");
+
+ assertNotSame(v0, v1, "attr() must return a new instance");
+ assertTrue(v0.getAttributes().isEmpty(), "original must remain
unchanged");
+ assertEquals("Bob", v1.getAttributes().get("name"));
+ }
+
+ @Test void b02_attrChainsMultipleBindings() {
+ var v = MustacheView.of("hello")
+ .attr("name", "Bob")
+ .attr("age", 42)
+ .attr("active", Boolean.TRUE);
+
+ assertEquals("Bob", v.getAttributes().get("name"));
+ assertEquals(42, v.getAttributes().get("age"));
+ assertEquals(Boolean.TRUE, v.getAttributes().get("active"));
+ assertEquals(3, v.getAttributes().size());
+ }
+
+ @Test void b03_attrReplacesExistingBinding() {
+ var v = MustacheView.of("hello")
+ .attr("name", "Bob")
+ .attr("name", "Alice");
+
+ assertEquals("Alice", v.getAttributes().get("name"));
+ assertEquals(1, v.getAttributes().size());
+ }
+
+ @Test void b04_attrRejectsNullValue() {
+ // mustache.java renders null scope bindings as empty strings;
reject at build-time so
+ // the mistake surfaces at the call site rather than as a
silently empty template
+ // variable downstream (matches the Thymeleaf-bridge precedent).
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attr("missing", null));
+ }
+
+ @Test void b05_attrRejectsNullKey() {
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attr(null, "v"));
+ }
+
+ @Test void b06_attrRejectsBlankKey() {
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attr("", "v"));
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attr(" ", "v"));
+ }
+
+ @Test void b07_attributesMapIsImmutable() {
+ var v = MustacheView.of("hello").attr("name", "Bob");
+ assertThrows(UnsupportedOperationException.class,
+ () -> v.getAttributes().put("name", "Alice"));
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section C: attrs(...)
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void c01_attrsMergesMap() {
+ var v = MustacheView.of("hello")
+ .attrs(Map.of("a", 1, "b", 2));
+ assertEquals(1, v.getAttributes().get("a"));
+ assertEquals(2, v.getAttributes().get("b"));
+ }
+
+ @Test void c02_attrsNullReturnsSameInstance() {
+ var v0 = MustacheView.of("hello");
+ var v1 = v0.attrs(null);
+ assertSame(v0, v1, "attrs(null) is a no-op");
+ }
+
+ @Test void c03_attrsEmptyReturnsSameInstance() {
+ var v0 = MustacheView.of("hello");
+ var v1 = v0.attrs(Map.of());
+ assertSame(v0, v1, "attrs(emptyMap) is a no-op");
+ }
+
+ @Test void c04_attrsRejectsBlankKey() {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("ok", 1);
+ m.put("", 2);
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attrs(m));
+ }
+
+ @Test void c05_attrsRejectsNullValue() {
+ var m = new LinkedHashMap<String, Object>();
+ m.put("ok", 1);
+ m.put("bad", null);
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").attrs(m));
+ }
+
+ @Test void c06_attrsOverridesExistingBindings() {
+ var v = MustacheView.of("hello")
+ .attr("a", 1)
+ .attrs(Map.of("a", 99, "b", 2));
+ assertEquals(99, v.getAttributes().get("a"));
+ assertEquals(2, v.getAttributes().get("b"));
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section D: header(...)
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void d01_headerAddsResponseHeader() {
+ var v = MustacheView.of("hello")
+ .header("Content-Type", "text/html; charset=UTF-8");
+ assertEquals("text/html; charset=UTF-8",
v.getResponseHeaders().get("Content-Type"));
+ }
+
+ @Test void d02_headerChainsMultipleHeaders() {
+ var v = MustacheView.of("hello")
+ .header("Content-Type", "text/html; charset=UTF-8")
+ .header("Cache-Control", "no-store");
+ assertEquals(2, v.getResponseHeaders().size());
+ }
+
+ @Test void d03_headerRejectsBlankName() {
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").header("", "v"));
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").header(null, "v"));
+ }
+
+ @Test void d04_headerRejectsNullValue() {
+ assertThrows(IllegalArgumentException.class,
+ () -> MustacheView.of("hello").header("X", null));
+ }
+
+ @Test void d05_headerReturnsNewInstance() {
+ var v0 = MustacheView.of("hello");
+ var v1 = v0.header("X", "1");
+ assertNotSame(v0, v1);
+ assertTrue(v0.getResponseHeaders().isEmpty());
+ }
+
+ @Test void d06_responseHeadersMapIsImmutable() {
+ var v = MustacheView.of("hello").header("X", "1");
+ assertThrows(UnsupportedOperationException.class,
+ () -> v.getResponseHeaders().put("Y", "2"));
+ }
+
+ /*
----------------------------------------------------------------------------------------
*
+ * Section E: toString + misc
+ *
----------------------------------------------------------------------------------------
*/
+
+ @Test void e01_toStringIncludesTemplateName() {
+ var v = MustacheView.of("hello").attr("k", "v").header("H",
"1");
+ var s = v.toString();
+ assertTrue(s.contains("hello"), "toString must include template
name: " + s);
+ assertTrue(s.contains("k"), "toString must include attribute
keys: " + s);
+ assertTrue(s.contains("H"), "toString must include header keys:
" + s);
+ }
+}
diff --git a/juneau-utest/src/test/resources/mustache-templates/about.mustache
b/juneau-utest/src/test/resources/mustache-templates/about.mustache
new file mode 100644
index 0000000000..d61c52c26c
--- /dev/null
+++ b/juneau-utest/src/test/resources/mustache-templates/about.mustache
@@ -0,0 +1,22 @@
+{{!
+ 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.
+}}<!DOCTYPE html>
+<html>
+<head><title>About</title></head>
+<body>
+<p>About Juneau</p>
+</body>
+</html>
diff --git a/juneau-utest/src/test/resources/mustache-templates/hello.mustache
b/juneau-utest/src/test/resources/mustache-templates/hello.mustache
new file mode 100644
index 0000000000..67c9dd6959
--- /dev/null
+++ b/juneau-utest/src/test/resources/mustache-templates/hello.mustache
@@ -0,0 +1,22 @@
+{{!
+ 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.
+}}<!DOCTYPE html>
+<html>
+<head><title>Hello</title></head>
+<body>
+<p>Hello, {{name}}!</p>
+</body>
+</html>
diff --git
a/juneau-utest/src/test/resources/mustache-templates/nested/inner.mustache
b/juneau-utest/src/test/resources/mustache-templates/nested/inner.mustache
new file mode 100644
index 0000000000..d1910c928e
--- /dev/null
+++ b/juneau-utest/src/test/resources/mustache-templates/nested/inner.mustache
@@ -0,0 +1,22 @@
+{{!
+ 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.
+}}<!DOCTYPE html>
+<html>
+<head><title>Inner</title></head>
+<body>
+<p>This is the inner template.</p>
+</body>
+</html>