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.freemarker;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022
023import org.apache.juneau.commons.inject.*;
024import org.apache.juneau.commons.utils.*;
025import org.apache.juneau.http.response.*;
026import org.apache.juneau.rest.server.*;
027import org.apache.juneau.rest.server.processor.*;
028import org.apache.juneau.rest.server.view.*;
029
030import freemarker.template.*;
031
032/**
033 * {@link ResponseProcessor} that detects {@link FreemarkerView}-typed return values and asks the
034 * configured {@link freemarker.template.Configuration} to render them directly onto the response writer.
035 *
036 * <p>
037 * Auto-registered by {@link FreemarkerMixin} via
038 * {@link Rest#responseProcessors() @Rest(responseProcessors=...)}
039 * &mdash; callers who add the mixin don't need to wire up this class explicitly. Callers who want
040 * to handle {@code FreemarkerView} returns <i>without</i> adopting the mixin can add this class
041 * to their own
042 * {@link Rest#responseProcessors() responseProcessors} list.
043 *
044 * <h5 class='section'>Behavior:</h5>
045 *
046 * <ol class='spaced-list'>
047 *    <li>Inspect the response content. If the value is not a {@link FreemarkerView}, return
048 *       {@link ResponseProcessor#NEXT NEXT} so the rest of the chain runs.
049 *    <li>Read the active {@link FreemarkerMixin} from the {@code RestContext} bean store
050 *       to discover the {@link freemarker.template.Configuration} (lazy default if no configuration bean is
051 *       registered) and the optional template-suffix knob.
052 *    <li>Apply every entry from {@link FreemarkerView#getResponseHeaders()} via
053 *       {@link jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
054 *       res.setHeader(...)}.
055 *    <li>Default {@code Content-Type} to {@code text/html;charset=UTF-8} if the caller did not set
056 *       one explicitly. FreeMarker's bridge-default {@code Configuration} uses
057 *       {@code HTMLOutputFormat} so HTML is the natural target; an explicit caller header wins.
058 *    <li>Call {@code configuration.getTemplate(templateName).process(view.getAttributes(),
059 *       res.getWriter())} to stream the rendered output.
060 *    <li>When no FreeMarker engine is on the classpath, the {@link freemarker.template.Configuration}-typed import
061 *       here fails to load at first use and surfaces {@link #NO_ENGINE_DIAGNOSTIC} naming the
062 *       missing dependency.
063 * </ol>
064 *
065 * <h5 class='section'>See Also:</h5><ul>
066 *    <li class='jc'>{@link FreemarkerMixin}
067 *    <li class='jc'>{@link FreemarkerView}
068 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response Processors</a>
069 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport">FreeMarker View Support</a>
070 * </ul>
071 *
072 * @since 10.0.0
073 */
074@SuppressWarnings({
075   "resource" // Closeables here are framework-managed and not owned/closed by this class; not a real leak.
076})
077public class FreemarkerViewRenderer implements ViewRenderer {
078
079   /** Default {@code Content-Type} applied when the view does not specify one explicitly. */
080   public static final String DEFAULT_CONTENT_TYPE = "text/html;charset=UTF-8";
081
082   /**
083    * Diagnostic message emitted when no FreeMarker engine can be found on the classpath at
084    * render-time.
085    *
086    * <p>
087    * Public so tests and consumer apps can pattern-match against the message text without
088    * depending on internal string literals.
089    */
090   public static final String NO_ENGINE_DIAGNOSTIC = """
091      No Apache FreeMarker engine is available on the classpath. Add:
092        - org.freemarker:freemarker                       (FreeMarker engine core)
093      Or, for Spring Boot:
094        - org.springframework.boot:spring-boot-starter-freemarker
095          (autoconfigures a freemarker.template.Configuration bean the bridge picks up).
096      Or register a custom @Bean freemarker.template.Configuration with whatever loaders /
097      encodings / output formats you need.
098      See https://juneau.apache.org/docs/topics/FreemarkerViewSupport for the full matrix.""";
099
100   // Lazily-created fallback used when no FreemarkerMixin bean is registered.  Cached (double-checked) so
101   // the bridge-default Configuration and its template cache are built once instead of rebuilt per render.
102   // Created lazily (not eagerly) so a missing FreeMarker engine still surfaces at render time as
103   // NO_ENGINE_DIAGNOSTIC rather than during RestContext construction.
104   @SuppressWarnings({
105      "java:S3077" // volatile is required here for correct double-checked-locking safe-publication of the lazily-built fallback FreemarkerMixin in fallbackMixin(); the reference is publish-once (fully constructed before assignment, immutable thereafter) and never compound-mutated.
106   })
107   private volatile FreemarkerMixin fallbackMixin;
108
109   @Override /* Overridden from ResponseProcessor */
110   public int process(RestOpSession opSession) throws IOException, BasicHttpException {
111      var req = opSession.getRequest();
112      var res = opSession.getResponse();
113
114      var content = res.getContent(Object.class);
115      if (! (content instanceof FreemarkerView content2))
116         return NEXT;
117
118      // Resolve the bridge resource (carries Configuration + cached default + templateSuffix).
119      // Fall back to a cached FreemarkerMixin when the renderer is used standalone
120      // without the mixin.
121      var bridge = findMixin(req.getContext().getBeanStore());
122
123      // Apply caller-supplied response headers first so a caller-provided Content-Type wins
124      // over the bridge's default below.
125      content2.getResponseHeaders().forEach(res::setHeader);
126      if (! res.containsHeader("Content-Type"))
127         res.setHeader("Content-Type", DEFAULT_CONTENT_TYPE);
128
129      // Apply the same traversal gate the raw /freemarker/* mount uses so a typed-View template name
130      // assembled from user input (e.g. FreemarkerView.of(userInput)) cannot escape the configured base
131      // path via '../' segments.  Mirrors the JSP bridge's typed-View gate.
132      String safeName;
133      try {
134         safeName = gateTemplateName(bridge.getBasePath(), content2.getTemplateName());
135      } catch (IllegalArgumentException ex) {
136         throw new InternalServerError(ex, "FreeMarker template name escapes configured base path: '%s'", content2.getTemplateName());
137      }
138
139      var templateName = bridge.applyTemplateSuffix(safeName);
140      try {
141         var cfg = bridge.resolveConfiguration(req);
142         var template = cfg.getTemplate(templateName);
143         // Expose the in-flight request to FreeMarker TemplateDirectiveModels (e.g. <@page toolkit=...>)
144         // for the duration of the render; always cleared so open() never leaks on the thread.
145         try {
146            FreemarkerRenderScope.open(req);
147            if (hasMustConsume(content2)) {
148               // Buffer first, so a failed adoption check can never follow output that is already committed.
149               var buffer = new StringWriter();
150               template.process(content2.getAttributes(), buffer);
151               checkConsumed(content2, templateName);
152               res.getWriter().write(buffer.toString());
153            } else {
154               template.process(content2.getAttributes(), res.getWriter());
155            }
156            res.getWriter().flush();
157            return FINISHED;
158         } finally {
159            FreemarkerRenderScope.close();
160         }
161      } catch (LinkageError ex) {
162         throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
163      } catch (IOException ex) {
164         throw ex;
165      } catch (TemplateException | RuntimeException ex) {
166         // Surface the deepest cause's message in the response body.  FreeMarker's DEBUG exception handler only
167         // writes a diagnostic to the *output writer* it was rendering into; a directive that throws while a
168         // parent directive (e.g. <@page>) is capturing its body into a throwaway buffer therefore leaves no
169         // trace in the response.  Appending the root-cause message makes the diagnostic deterministic
170         // regardless of which buffer FreeMarker happened to be writing to.
171         throw new InternalServerError(ex, "FreeMarker render failed for '%s': %s", templateName, rootMessage(ex));
172      }
173   }
174
175   private static boolean hasMustConsume(FreemarkerView view) {
176      for (var v : view.getAttributes().values())
177         if (v instanceof FreemarkerView.MustConsume)
178            return true;
179      return false;
180   }
181
182   // A MustConsume attribute that was never adopted means the template never reached the directive meant to consume
183   // it, which would otherwise drop the attribute silently.  The message is carried as the deepest cause so it
184   // reaches the response body undecorated, as rootMessage() unwraps it.
185   private static void checkConsumed(FreemarkerView view, String templateName) throws TemplateModelException {
186      for (var v : view.getAttributes().values()) {
187         if (v instanceof FreemarkerView.MustConsume mc && ! mc.consumed()) {
188            var msg = mc.notConsumedMessage(templateName);
189            throw new TemplateModelException(msg, new IllegalArgumentException(msg));
190         }
191      }
192   }
193
194   // Walks to the deepest cause and returns its message (falling back to the simple class name when the message
195   // is null), so a nested-directive diagnostic reaches the HTTP response body even when FreeMarker's exception
196   // handler wrote it into a discarded capture buffer.
197   static String rootMessage(Throwable t) {
198      var c = t;
199      while (c.getCause() != null && c.getCause() != c)
200         c = c.getCause();
201      var m = c.getMessage();
202      return m == null ? c.getClass().getSimpleName() : m;
203   }
204
205   // Rejects '../'-style traversal in a typed-View template name using the same virtual-path gate the raw
206   // mount applies, returning the configuration-relative template name.  Delegates to the shared
207   // FileUtils.resolveVirtualPathSafely + FreemarkerDispatcher.stripBasePath used by the raw render path.
208   static String gateTemplateName(String basePath, String templateName) {
209      var resolved = FileUtils.resolveVirtualPathSafely(basePath, templateName);
210      return FreemarkerDispatcher.stripBasePath(basePath, resolved);
211   }
212
213   // The exact FreemarkerMixin bean, else a bean of any registered subtype; two different beans fail loud.
214   private FreemarkerMixin findMixin(WritableBeanStore store) {
215      FreemarkerMixin found = store.getBean(FreemarkerMixin.class).orElse(null);
216      for (var t : FreemarkerMixin.registeredSubtypes()) {
217         var b = store.getBean(t).orElse(null);
218         if (b == null || b == found)
219            continue;
220         if (found != null)
221            throw new IllegalStateException(f(
222               "Found more than one FreemarkerMixin bean ('%s' and '%s'); declare exactly one.",
223               found.getClass().getSimpleName(), b.getClass().getSimpleName()));
224         found = b;
225      }
226      return found != null ? found : fallbackMixin();
227   }
228
229   // Returns the cached fallback mixin, creating it once (double-checked) on first standalone render.
230   private FreemarkerMixin fallbackMixin() {
231      var local = fallbackMixin;
232      if (local == null) {
233         synchronized (this) {
234            local = fallbackMixin;
235            if (local == null) {
236               local = new FreemarkerMixin();
237               fallbackMixin = local;
238            }
239         }
240      }
241      return local;
242   }
243}