001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.servlet.view.script; 018 019import java.io.IOException; 020import java.io.InputStreamReader; 021import java.nio.charset.Charset; 022import java.nio.charset.StandardCharsets; 023import java.util.Arrays; 024import java.util.HashMap; 025import java.util.Locale; 026import java.util.Map; 027import java.util.function.Function; 028import java.util.function.Supplier; 029 030import javax.script.Invocable; 031import javax.script.ScriptEngine; 032import javax.script.ScriptEngineManager; 033import javax.script.ScriptException; 034import javax.script.SimpleBindings; 035import javax.servlet.ServletException; 036import javax.servlet.http.HttpServletRequest; 037import javax.servlet.http.HttpServletResponse; 038 039import org.springframework.beans.BeansException; 040import org.springframework.beans.factory.BeanFactoryUtils; 041import org.springframework.beans.factory.NoSuchBeanDefinitionException; 042import org.springframework.context.ApplicationContext; 043import org.springframework.context.ApplicationContextException; 044import org.springframework.core.NamedThreadLocal; 045import org.springframework.core.io.Resource; 046import org.springframework.lang.Nullable; 047import org.springframework.scripting.support.StandardScriptEvalException; 048import org.springframework.scripting.support.StandardScriptUtils; 049import org.springframework.util.Assert; 050import org.springframework.util.FileCopyUtils; 051import org.springframework.util.ObjectUtils; 052import org.springframework.util.StringUtils; 053import org.springframework.web.servlet.support.RequestContextUtils; 054import org.springframework.web.servlet.view.AbstractUrlBasedView; 055 056/** 057 * An {@link AbstractUrlBasedView} subclass designed to run any template library 058 * based on a JSR-223 script engine. 059 * 060 * <p>If not set, each property is auto-detected by looking up a single 061 * {@link ScriptTemplateConfig} bean in the web application context and using 062 * it to obtain the configured properties. 063 * 064 * <p>The Nashorn JavaScript engine requires Java 8+ and may require setting the 065 * {@code sharedEngine} property to {@code false} in order to run properly. See 066 * {@link ScriptTemplateConfigurer#setSharedEngine(Boolean)} for more details. 067 * 068 * @author Sebastien Deleuze 069 * @author Juergen Hoeller 070 * @since 4.2 071 * @see ScriptTemplateConfigurer 072 * @see ScriptTemplateViewResolver 073 */ 074public class ScriptTemplateView extends AbstractUrlBasedView { 075 076 /** 077 * The default content type for the view. 078 */ 079 public static final String DEFAULT_CONTENT_TYPE = "text/html"; 080 081 private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 082 083 private static final String DEFAULT_RESOURCE_LOADER_PATH = "classpath:"; 084 085 086 private static final ThreadLocal<Map<Object, ScriptEngine>> enginesHolder = 087 new NamedThreadLocal<>("ScriptTemplateView engines"); 088 089 090 @Nullable 091 private ScriptEngine engine; 092 093 @Nullable 094 private Supplier<ScriptEngine> engineSupplier; 095 096 @Nullable 097 private String engineName; 098 099 @Nullable 100 private Boolean sharedEngine; 101 102 @Nullable 103 private String[] scripts; 104 105 @Nullable 106 private String renderObject; 107 108 @Nullable 109 private String renderFunction; 110 111 @Nullable 112 private Charset charset; 113 114 @Nullable 115 private String[] resourceLoaderPaths; 116 117 @Nullable 118 private volatile ScriptEngineManager scriptEngineManager; 119 120 121 /** 122 * Constructor for use as a bean. 123 * @see #setUrl 124 */ 125 public ScriptTemplateView() { 126 setContentType(null); 127 } 128 129 /** 130 * Create a new ScriptTemplateView with the given URL. 131 * @since 4.2.1 132 */ 133 public ScriptTemplateView(String url) { 134 super(url); 135 setContentType(null); 136 } 137 138 139 /** 140 * See {@link ScriptTemplateConfigurer#setEngine(ScriptEngine)} documentation. 141 */ 142 public void setEngine(ScriptEngine engine) { 143 this.engine = engine; 144 } 145 146 /** 147 * See {@link ScriptTemplateConfigurer#setEngineSupplier(Supplier)} documentation. 148 * @since 5.2 149 */ 150 public void setEngineSupplier(Supplier<ScriptEngine> engineSupplier) { 151 this.engineSupplier = engineSupplier; 152 } 153 154 /** 155 * See {@link ScriptTemplateConfigurer#setEngineName(String)} documentation. 156 */ 157 public void setEngineName(String engineName) { 158 this.engineName = engineName; 159 } 160 161 /** 162 * See {@link ScriptTemplateConfigurer#setSharedEngine(Boolean)} documentation. 163 */ 164 public void setSharedEngine(Boolean sharedEngine) { 165 this.sharedEngine = sharedEngine; 166 } 167 168 /** 169 * See {@link ScriptTemplateConfigurer#setScripts(String...)} documentation. 170 */ 171 public void setScripts(String... scripts) { 172 this.scripts = scripts; 173 } 174 175 /** 176 * See {@link ScriptTemplateConfigurer#setRenderObject(String)} documentation. 177 */ 178 public void setRenderObject(String renderObject) { 179 this.renderObject = renderObject; 180 } 181 182 /** 183 * See {@link ScriptTemplateConfigurer#setRenderFunction(String)} documentation. 184 */ 185 public void setRenderFunction(String functionName) { 186 this.renderFunction = functionName; 187 } 188 189 /** 190 * See {@link ScriptTemplateConfigurer#setCharset(Charset)} documentation. 191 */ 192 public void setCharset(Charset charset) { 193 this.charset = charset; 194 } 195 196 /** 197 * See {@link ScriptTemplateConfigurer#setResourceLoaderPath(String)} documentation. 198 */ 199 public void setResourceLoaderPath(String resourceLoaderPath) { 200 String[] paths = StringUtils.commaDelimitedListToStringArray(resourceLoaderPath); 201 this.resourceLoaderPaths = new String[paths.length + 1]; 202 this.resourceLoaderPaths[0] = ""; 203 for (int i = 0; i < paths.length; i++) { 204 String path = paths[i]; 205 if (!path.endsWith("/") && !path.endsWith(":")) { 206 path = path + "/"; 207 } 208 this.resourceLoaderPaths[i + 1] = path; 209 } 210 } 211 212 213 @Override 214 protected void initApplicationContext(ApplicationContext context) { 215 super.initApplicationContext(context); 216 217 ScriptTemplateConfig viewConfig = autodetectViewConfig(); 218 if (this.engine == null && viewConfig.getEngine() != null) { 219 this.engine = viewConfig.getEngine(); 220 } 221 if (this.engineSupplier == null && viewConfig.getEngineSupplier() != null) { 222 this.engineSupplier = viewConfig.getEngineSupplier(); 223 } 224 if (this.engineName == null && viewConfig.getEngineName() != null) { 225 this.engineName = viewConfig.getEngineName(); 226 } 227 if (this.scripts == null && viewConfig.getScripts() != null) { 228 this.scripts = viewConfig.getScripts(); 229 } 230 if (this.renderObject == null && viewConfig.getRenderObject() != null) { 231 this.renderObject = viewConfig.getRenderObject(); 232 } 233 if (this.renderFunction == null && viewConfig.getRenderFunction() != null) { 234 this.renderFunction = viewConfig.getRenderFunction(); 235 } 236 if (this.getContentType() == null) { 237 setContentType(viewConfig.getContentType() != null ? viewConfig.getContentType() : DEFAULT_CONTENT_TYPE); 238 } 239 if (this.charset == null) { 240 this.charset = (viewConfig.getCharset() != null ? viewConfig.getCharset() : DEFAULT_CHARSET); 241 } 242 if (this.resourceLoaderPaths == null) { 243 String resourceLoaderPath = viewConfig.getResourceLoaderPath(); 244 setResourceLoaderPath(resourceLoaderPath != null ? resourceLoaderPath : DEFAULT_RESOURCE_LOADER_PATH); 245 } 246 if (this.sharedEngine == null && viewConfig.isSharedEngine() != null) { 247 this.sharedEngine = viewConfig.isSharedEngine(); 248 } 249 250 int engineCount = 0; 251 if (this.engine != null) { 252 engineCount++; 253 } 254 if (this.engineSupplier != null) { 255 engineCount++; 256 } 257 if (this.engineName != null) { 258 engineCount++; 259 } 260 Assert.isTrue(engineCount == 1, 261 "You should define either 'engine', 'engineSupplier' or 'engineName'."); 262 263 if (Boolean.FALSE.equals(this.sharedEngine)) { 264 Assert.isTrue(this.engine == null, 265 "When 'sharedEngine' is set to false, you should specify the " + 266 "script engine using 'engineName' or 'engineSupplier' , not 'engine'."); 267 } 268 else if (this.engine != null) { 269 loadScripts(this.engine); 270 } 271 else if (this.engineName != null) { 272 setEngine(createEngineFromName(this.engineName)); 273 } 274 else { 275 setEngine(createEngineFromSupplier()); 276 } 277 278 if (this.renderFunction != null && this.engine != null) { 279 Assert.isInstanceOf(Invocable.class, this.engine, 280 "ScriptEngine must implement Invocable when 'renderFunction' is specified"); 281 } 282 } 283 284 protected ScriptEngine getEngine() { 285 if (Boolean.FALSE.equals(this.sharedEngine)) { 286 Map<Object, ScriptEngine> engines = enginesHolder.get(); 287 if (engines == null) { 288 engines = new HashMap<>(4); 289 enginesHolder.set(engines); 290 } 291 String name = (this.engineName != null ? this.engineName : ""); 292 Object engineKey = (!ObjectUtils.isEmpty(this.scripts) ? new EngineKey(name, this.scripts) : name); 293 ScriptEngine engine = engines.get(engineKey); 294 if (engine == null) { 295 if (this.engineName != null) { 296 engine = createEngineFromName(this.engineName); 297 } 298 else { 299 engine = createEngineFromSupplier(); 300 } 301 engines.put(engineKey, engine); 302 } 303 return engine; 304 } 305 else { 306 // Simply return the configured ScriptEngine... 307 Assert.state(this.engine != null, "No shared engine available"); 308 return this.engine; 309 } 310 } 311 312 protected ScriptEngine createEngineFromName(String engineName) { 313 ScriptEngineManager scriptEngineManager = this.scriptEngineManager; 314 if (scriptEngineManager == null) { 315 scriptEngineManager = new ScriptEngineManager(obtainApplicationContext().getClassLoader()); 316 this.scriptEngineManager = scriptEngineManager; 317 } 318 319 ScriptEngine engine = StandardScriptUtils.retrieveEngineByName(scriptEngineManager, engineName); 320 loadScripts(engine); 321 return engine; 322 } 323 324 private ScriptEngine createEngineFromSupplier() { 325 Assert.state(this.engineSupplier != null, "No engine supplier available"); 326 ScriptEngine engine = this.engineSupplier.get(); 327 if (this.renderFunction != null) { 328 Assert.isInstanceOf(Invocable.class, engine, 329 "ScriptEngine must implement Invocable when 'renderFunction' is specified"); 330 } 331 loadScripts(engine); 332 return engine; 333 } 334 335 protected void loadScripts(ScriptEngine engine) { 336 if (!ObjectUtils.isEmpty(this.scripts)) { 337 for (String script : this.scripts) { 338 Resource resource = getResource(script); 339 if (resource == null) { 340 throw new IllegalStateException("Script resource [" + script + "] not found"); 341 } 342 try { 343 engine.eval(new InputStreamReader(resource.getInputStream())); 344 } 345 catch (Throwable ex) { 346 throw new IllegalStateException("Failed to evaluate script [" + script + "]", ex); 347 } 348 } 349 } 350 } 351 352 @Nullable 353 protected Resource getResource(String location) { 354 if (this.resourceLoaderPaths != null) { 355 for (String path : this.resourceLoaderPaths) { 356 Resource resource = obtainApplicationContext().getResource(path + location); 357 if (resource.exists()) { 358 return resource; 359 } 360 } 361 } 362 return null; 363 } 364 365 protected ScriptTemplateConfig autodetectViewConfig() throws BeansException { 366 try { 367 return BeanFactoryUtils.beanOfTypeIncludingAncestors( 368 obtainApplicationContext(), ScriptTemplateConfig.class, true, false); 369 } 370 catch (NoSuchBeanDefinitionException ex) { 371 throw new ApplicationContextException("Expected a single ScriptTemplateConfig bean in the current " + 372 "Servlet web application context or the parent root context: ScriptTemplateConfigurer is " + 373 "the usual implementation. This bean may have any name.", ex); 374 } 375 } 376 377 378 @Override 379 public boolean checkResource(Locale locale) throws Exception { 380 String url = getUrl(); 381 Assert.state(url != null, "'url' not set"); 382 return (getResource(url) != null); 383 } 384 385 @Override 386 protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) { 387 super.prepareResponse(request, response); 388 389 setResponseContentType(request, response); 390 if (this.charset != null) { 391 response.setCharacterEncoding(this.charset.name()); 392 } 393 } 394 395 @Override 396 protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, 397 HttpServletResponse response) throws Exception { 398 399 try { 400 ScriptEngine engine = getEngine(); 401 String url = getUrl(); 402 Assert.state(url != null, "'url' not set"); 403 String template = getTemplate(url); 404 405 Function<String, String> templateLoader = path -> { 406 try { 407 return getTemplate(path); 408 } 409 catch (IOException ex) { 410 throw new IllegalStateException(ex); 411 } 412 }; 413 414 Locale locale = RequestContextUtils.getLocale(request); 415 RenderingContext context = new RenderingContext(obtainApplicationContext(), locale, templateLoader, url); 416 417 Object html; 418 if (this.renderFunction == null) { 419 SimpleBindings bindings = new SimpleBindings(); 420 bindings.putAll(model); 421 model.put("renderingContext", context); 422 html = engine.eval(template, bindings); 423 } 424 else if (this.renderObject != null) { 425 Object thiz = engine.eval(this.renderObject); 426 html = ((Invocable) engine).invokeMethod(thiz, this.renderFunction, template, model, context); 427 } 428 else { 429 html = ((Invocable) engine).invokeFunction(this.renderFunction, template, model, context); 430 } 431 432 response.getWriter().write(String.valueOf(html)); 433 } 434 catch (ScriptException ex) { 435 throw new ServletException("Failed to render script template", new StandardScriptEvalException(ex)); 436 } 437 } 438 439 protected String getTemplate(String path) throws IOException { 440 Resource resource = getResource(path); 441 if (resource == null) { 442 throw new IllegalStateException("Template resource [" + path + "] not found"); 443 } 444 InputStreamReader reader = (this.charset != null ? 445 new InputStreamReader(resource.getInputStream(), this.charset) : 446 new InputStreamReader(resource.getInputStream())); 447 return FileCopyUtils.copyToString(reader); 448 } 449 450 451 /** 452 * Key class for the {@code enginesHolder ThreadLocal}. 453 * Only used if scripts have been specified; otherwise, the 454 * {@code engineName String} will be used as cache key directly. 455 */ 456 private static class EngineKey { 457 458 private final String engineName; 459 460 private final String[] scripts; 461 462 public EngineKey(String engineName, String[] scripts) { 463 this.engineName = engineName; 464 this.scripts = scripts; 465 } 466 467 @Override 468 public boolean equals(@Nullable Object other) { 469 if (this == other) { 470 return true; 471 } 472 if (!(other instanceof EngineKey)) { 473 return false; 474 } 475 EngineKey otherKey = (EngineKey) other; 476 return (this.engineName.equals(otherKey.engineName) && Arrays.equals(this.scripts, otherKey.scripts)); 477 } 478 479 @Override 480 public int hashCode() { 481 return (this.engineName.hashCode() * 29 + Arrays.hashCode(this.scripts)); 482 } 483 } 484 485}