001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.view.thymeleaf;
018
019import java.io.*;
020
021import org.apache.juneau.commons.utils.*;
022import org.apache.juneau.http.response.*;
023import org.apache.juneau.rest.server.*;
024import org.apache.juneau.rest.server.processor.*;
025import org.apache.juneau.rest.server.view.*;
026import org.thymeleaf.*;
027import org.thymeleaf.context.*;
028
029/**
030 * {@link ResponseProcessor} that detects {@link ThymeleafView}-typed return values and asks the
031 * configured {@link TemplateEngine} to render them directly onto the response writer.
032 *
033 * <p>
034 * Auto-registered by {@link ThymeleafMixin} via
035 * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
036 * &mdash; callers who add the mixin don't need to wire up this class explicitly. Callers who want
037 * to handle {@code ThymeleafView} returns <i>without</i> adopting the mixin can add this class to
038 * their own
039 * {@link Rest#responseProcessors() responseProcessors} list.
040 *
041 * <h5 class='section'>Behavior:</h5>
042 *
043 * <ol class='spaced-list'>
044 *    <li>Inspect the response content. If the value is not a {@link ThymeleafView}, return
045 *       {@link ResponseProcessor#NEXT NEXT} so the rest of the chain runs.
046 *    <li>Read the active {@link ThymeleafMixin} from the {@code RestContext} bean store to
047 *       discover the {@link TemplateEngine} (lazy default if no engine bean is registered).
048 *    <li>Build a Thymeleaf {@link Context} populated with the view's attribute map.
049 *    <li>Apply every entry from {@link ThymeleafView#getResponseHeaders()} via
050 *       {@link jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
051 *       res.setHeader(...)}.
052 *    <li>Default {@code Content-Type} to {@code text/html;charset=UTF-8} if the caller did not set
053 *       one explicitly (Thymeleaf's HTML template mode emits HTML; an explicit caller header wins).
054 *    <li>Call {@code templateEngine.process(view.getTemplateName(), context, res.getWriter())} to
055 *       stream the rendered output.
056 *    <li>When no Thymeleaf engine is on the classpath, the {@code TemplateEngine}-typed import here
057 *       fails to load at first use and surfaces {@link #NO_ENGINE_DIAGNOSTIC} naming the missing
058 *       dependency.
059 * </ol>
060 *
061 * <h5 class='section'>See Also:</h5><ul>
062 *    <li class='jc'>{@link ThymeleafMixin}
063 *    <li class='jc'>{@link ThymeleafView}
064 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response Processors</a>
065 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ThymeleafViewSupport">Thymeleaf View Support</a>
066 * </ul>
067 *
068 * @since 10.0.0
069 */
070@SuppressWarnings({
071   "resource" // Closeables here are framework-managed and not owned/closed by this class; not a real leak.
072})
073public class ThymeleafViewRenderer implements ViewRenderer {
074
075   /** Default {@code Content-Type} applied when the view does not specify one explicitly. */
076   public static final String DEFAULT_CONTENT_TYPE = "text/html;charset=UTF-8";
077
078   /**
079    * Diagnostic message emitted when no Thymeleaf engine can be found on the classpath at
080    * render-time.
081    *
082    * <p>
083    * Public so tests and consumer apps can pattern-match against the message text without
084    * depending on internal string literals.
085    */
086   public static final String NO_ENGINE_DIAGNOSTIC = """
087      No Thymeleaf engine is available on the classpath. Add one of:
088        - org.springframework.boot:spring-boot-starter-thymeleaf  (Spring Boot autoconfig)
089        - org.thymeleaf:thymeleaf                                  (Juneau microservice / Jetty)
090      Or register a custom @Bean TemplateEngine that picks up your preferred resolvers.
091      See https://juneau.apache.org/docs/topics/ThymeleafViewSupport for the full matrix.""";
092
093   // Lazily-created fallback used when no ThymeleafMixin bean is registered.  Cached (double-checked) so
094   // the bridge-default TemplateEngine and resolver are built once instead of rebuilt per render.  Created
095   // lazily (not eagerly) so a missing Thymeleaf engine still surfaces at render time as
096   // NO_ENGINE_DIAGNOSTIC rather than during RestContext construction.
097   @SuppressWarnings({
098      "java:S3077" // volatile is required here for correct double-checked-locking safe-publication of the lazily-created fallbackMixin in fallbackMixin(); the reference is publish-once (fully constructed before assignment) and never compound-mutated.
099   })
100   private volatile ThymeleafMixin fallbackMixin;
101
102   @Override /* Overridden from ResponseProcessor */
103   public int process(RestOpSession opSession) throws IOException, BasicHttpException {
104      var req = opSession.getRequest();
105      var res = opSession.getResponse();
106
107      var content = res.getContent(Object.class);
108      if (! (content instanceof ThymeleafView content2))
109         return NEXT;
110
111      // Resolve the bridge resource (carries TemplateEngine + cached default). Fall back to a
112      // cached ThymeleafMixin when the renderer is used standalone without the mixin.
113      var bridge = req.getContext().getBeanStore()
114         .getBean(ThymeleafMixin.class)
115         .orElseGet(this::fallbackMixin);
116
117      // Apply caller-supplied response headers first so a caller-provided Content-Type wins
118      // over the bridge's default below.
119      content2.getResponseHeaders().forEach(res::setHeader);
120      if (! res.containsHeader("Content-Type"))
121         res.setHeader("Content-Type", DEFAULT_CONTENT_TYPE);
122
123      // Apply the same traversal gate the raw /thymeleaf/* mount uses so a typed-View template name
124      // assembled from user input (e.g. ThymeleafView.of(userInput)) cannot escape the configured base
125      // path via '../' segments.  Mirrors the JSP bridge's typed-View gate.
126      String safeName;
127      try {
128         safeName = gateTemplateName(bridge.getBasePath(), content2.getTemplateName());
129      } catch (IllegalArgumentException ex) {
130         throw new InternalServerError(ex, "Thymeleaf template name escapes configured base path: '%s'", content2.getTemplateName());
131      }
132
133      try {
134         var engine = bridge.resolveTemplateEngine(req);
135         var ctx = newContext(req, content2);
136         engine.process(safeName, ctx, res.getWriter());
137         return FINISHED;
138      } catch (LinkageError ex) {
139         throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
140      } catch (IOException ex) {
141         throw ex;
142      } catch (Exception ex) {
143         throw new InternalServerError(ex, "Thymeleaf render failed for '%s'",
144            content2.getTemplateName());
145      }
146   }
147
148   /**
149    * Builds a fresh Thymeleaf {@link Context} for the current request, populated with every
150    * entry from {@link ThymeleafView#getAttributes()}.
151    *
152    * <p>
153    * The {@code Context} inherits the request's {@link java.util.Locale Locale} so Thymeleaf's
154    * i18n machinery (`#messages`, `MessageSource`) picks it up automatically.
155    *
156    * @param req The current REST request.
157    * @param view The view being rendered.
158    * @return A new {@link Context} carrying the view's attributes.
159    */
160   static Context newContext(RestRequest req, ThymeleafView view) {
161      var ctx = new Context(req.getLocale());
162      view.getAttributes().forEach(ctx::setVariable);
163      return ctx;
164   }
165
166   // Rejects '../'-style traversal in a typed-View template name using the same virtual-path gate the raw
167   // mount applies, returning the engine-relative template name.  Delegates to the shared
168   // FileUtils.resolveVirtualPathSafely + ThymeleafDispatcher.stripBasePath used by the raw render path.
169   static String gateTemplateName(String basePath, String templateName) {
170      var resolved = FileUtils.resolveVirtualPathSafely(basePath, templateName);
171      return ThymeleafDispatcher.stripBasePath(basePath, resolved);
172   }
173
174   // Returns the cached fallback mixin, creating it once (double-checked) on first standalone render.
175   private ThymeleafMixin fallbackMixin() {
176      var local = fallbackMixin;
177      if (local == null) {
178         synchronized (this) {
179            local = fallbackMixin;
180            if (local == null) {
181               local = new ThymeleafMixin();
182               fallbackMixin = local;
183            }
184         }
185      }
186      return local;
187   }
188}