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 * — 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}