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