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.reactive.result.view.script; 018 019import java.io.IOException; 020import java.io.InputStreamReader; 021import java.nio.charset.StandardCharsets; 022import java.util.Locale; 023import java.util.Map; 024import java.util.function.Function; 025import java.util.function.Supplier; 026 027import javax.script.Invocable; 028import javax.script.ScriptEngine; 029import javax.script.ScriptEngineManager; 030import javax.script.ScriptException; 031import javax.script.SimpleBindings; 032 033import reactor.core.publisher.Mono; 034 035import org.springframework.beans.BeansException; 036import org.springframework.beans.factory.BeanFactoryUtils; 037import org.springframework.beans.factory.NoSuchBeanDefinitionException; 038import org.springframework.context.ApplicationContext; 039import org.springframework.context.ApplicationContextException; 040import org.springframework.context.i18n.LocaleContextHolder; 041import org.springframework.core.io.Resource; 042import org.springframework.http.MediaType; 043import org.springframework.lang.Nullable; 044import org.springframework.scripting.support.StandardScriptEvalException; 045import org.springframework.scripting.support.StandardScriptUtils; 046import org.springframework.util.Assert; 047import org.springframework.util.FileCopyUtils; 048import org.springframework.util.ObjectUtils; 049import org.springframework.util.StringUtils; 050import org.springframework.web.reactive.result.view.AbstractUrlBasedView; 051import org.springframework.web.server.ServerWebExchange; 052 053/** 054 * An {@link AbstractUrlBasedView} subclass designed to run any template library 055 * based on a JSR-223 script engine. 056 * 057 * <p>If not set, each property is auto-detected by looking up a single 058 * {@link ScriptTemplateConfig} bean in the web application context and using 059 * it to obtain the configured properties. 060 * 061 * <p>The Nashorn JavaScript engine requires Java 8+ and may require setting the 062 * {@code sharedEngine} property to {@code false} in order to run properly. See 063 * {@link ScriptTemplateConfigurer#setSharedEngine(Boolean)} for more details. 064 * 065 * @author Sebastien Deleuze 066 * @author Juergen Hoeller 067 * @since 5.0 068 * @see ScriptTemplateConfigurer 069 * @see ScriptTemplateViewResolver 070 */ 071public class ScriptTemplateView extends AbstractUrlBasedView { 072 073 private static final String DEFAULT_RESOURCE_LOADER_PATH = "classpath:"; 074 075 076 @Nullable 077 private ScriptEngine engine; 078 079 @Nullable 080 private Supplier<ScriptEngine> engineSupplier; 081 082 @Nullable 083 private String engineName; 084 085 @Nullable 086 private Boolean sharedEngine; 087 088 @Nullable 089 private String[] scripts; 090 091 @Nullable 092 private String renderObject; 093 094 @Nullable 095 private String renderFunction; 096 097 @Nullable 098 private String[] resourceLoaderPaths; 099 100 @Nullable 101 private volatile ScriptEngineManager scriptEngineManager; 102 103 104 /** 105 * Constructor for use as a bean. 106 * @see #setUrl 107 */ 108 public ScriptTemplateView() { 109 } 110 111 /** 112 * Create a new ScriptTemplateView with the given URL. 113 */ 114 public ScriptTemplateView(String url) { 115 super(url); 116 } 117 118 119 /** 120 * See {@link ScriptTemplateConfigurer#setEngine(ScriptEngine)} documentation. 121 */ 122 public void setEngine(ScriptEngine engine) { 123 this.engine = engine; 124 } 125 126 /** 127 * See {@link ScriptTemplateConfigurer#setEngineSupplier(Supplier)} documentation. 128 * @since 5.2 129 */ 130 public void setEngineSupplier(Supplier<ScriptEngine> engineSupplier) { 131 this.engineSupplier = engineSupplier; 132 } 133 134 /** 135 * See {@link ScriptTemplateConfigurer#setEngineName(String)} documentation. 136 */ 137 public void setEngineName(String engineName) { 138 this.engineName = engineName; 139 } 140 141 /** 142 * See {@link ScriptTemplateConfigurer#setSharedEngine(Boolean)} documentation. 143 */ 144 public void setSharedEngine(Boolean sharedEngine) { 145 this.sharedEngine = sharedEngine; 146 } 147 148 /** 149 * See {@link ScriptTemplateConfigurer#setScripts(String...)} documentation. 150 */ 151 public void setScripts(String... scripts) { 152 this.scripts = scripts; 153 } 154 155 /** 156 * See {@link ScriptTemplateConfigurer#setRenderObject(String)} documentation. 157 */ 158 public void setRenderObject(String renderObject) { 159 this.renderObject = renderObject; 160 } 161 162 /** 163 * See {@link ScriptTemplateConfigurer#setRenderFunction(String)} documentation. 164 */ 165 public void setRenderFunction(String functionName) { 166 this.renderFunction = functionName; 167 } 168 169 /** 170 * See {@link ScriptTemplateConfigurer#setResourceLoaderPath(String)} documentation. 171 */ 172 public void setResourceLoaderPath(String resourceLoaderPath) { 173 String[] paths = StringUtils.commaDelimitedListToStringArray(resourceLoaderPath); 174 this.resourceLoaderPaths = new String[paths.length + 1]; 175 this.resourceLoaderPaths[0] = ""; 176 for (int i = 0; i < paths.length; i++) { 177 String path = paths[i]; 178 if (!path.endsWith("/") && !path.endsWith(":")) { 179 path = path + "/"; 180 } 181 this.resourceLoaderPaths[i + 1] = path; 182 } 183 } 184 185 @Override 186 public void setApplicationContext(@Nullable ApplicationContext context) { 187 super.setApplicationContext(context); 188 189 ScriptTemplateConfig viewConfig = autodetectViewConfig(); 190 if (this.engine == null && viewConfig.getEngine() != null) { 191 this.engine = viewConfig.getEngine(); 192 } 193 if (this.engineSupplier == null && viewConfig.getEngineSupplier() != null) { 194 this.engineSupplier = viewConfig.getEngineSupplier(); 195 } 196 if (this.engineName == null && viewConfig.getEngineName() != null) { 197 this.engineName = viewConfig.getEngineName(); 198 } 199 if (this.scripts == null && viewConfig.getScripts() != null) { 200 this.scripts = viewConfig.getScripts(); 201 } 202 if (this.renderObject == null && viewConfig.getRenderObject() != null) { 203 this.renderObject = viewConfig.getRenderObject(); 204 } 205 if (this.renderFunction == null && viewConfig.getRenderFunction() != null) { 206 this.renderFunction = viewConfig.getRenderFunction(); 207 } 208 if (viewConfig.getCharset() != null) { 209 setDefaultCharset(viewConfig.getCharset()); 210 } 211 if (this.resourceLoaderPaths == null) { 212 String resourceLoaderPath = viewConfig.getResourceLoaderPath(); 213 setResourceLoaderPath(resourceLoaderPath != null ? resourceLoaderPath : DEFAULT_RESOURCE_LOADER_PATH); 214 } 215 if (this.sharedEngine == null && viewConfig.isSharedEngine() != null) { 216 this.sharedEngine = viewConfig.isSharedEngine(); 217 } 218 219 int engineCount = 0; 220 if (this.engine != null) { 221 engineCount++; 222 } 223 if (this.engineSupplier != null) { 224 engineCount++; 225 } 226 if (this.engineName != null) { 227 engineCount++; 228 } 229 Assert.isTrue(engineCount == 1, 230 "You should define either 'engine', 'engineSupplier' or 'engineName'."); 231 232 if (Boolean.FALSE.equals(this.sharedEngine)) { 233 Assert.isTrue(this.engine == null, 234 "When 'sharedEngine' is set to false, you should specify the " + 235 "script engine using 'engineName' or 'engineSupplier' , not 'engine'."); 236 } 237 else if (this.engine != null) { 238 loadScripts(this.engine); 239 } 240 else if (this.engineName != null) { 241 setEngine(createEngineFromName(this.engineName)); 242 } 243 else { 244 setEngine(createEngineFromSupplier()); 245 } 246 247 if (this.renderFunction != null && this.engine != null) { 248 Assert.isInstanceOf(Invocable.class, this.engine, 249 "ScriptEngine must implement Invocable when 'renderFunction' is specified"); 250 } 251 } 252 253 protected ScriptEngine getEngine() { 254 if (Boolean.FALSE.equals(this.sharedEngine)) { 255 if (this.engineName != null) { 256 return createEngineFromName(this.engineName); 257 } 258 else { 259 return createEngineFromSupplier(); 260 } 261 } 262 else { 263 Assert.state(this.engine != null, "No shared engine available"); 264 return this.engine; 265 } 266 } 267 268 protected ScriptEngine createEngineFromName(String engineName) { 269 ScriptEngineManager scriptEngineManager = this.scriptEngineManager; 270 if (scriptEngineManager == null) { 271 scriptEngineManager = new ScriptEngineManager(obtainApplicationContext().getClassLoader()); 272 this.scriptEngineManager = scriptEngineManager; 273 } 274 275 ScriptEngine engine = StandardScriptUtils.retrieveEngineByName(scriptEngineManager, engineName); 276 loadScripts(engine); 277 return engine; 278 } 279 280 private ScriptEngine createEngineFromSupplier() { 281 Assert.state(this.engineSupplier != null, "No engine supplier available"); 282 ScriptEngine engine = this.engineSupplier.get(); 283 if (this.renderFunction != null) { 284 Assert.isInstanceOf(Invocable.class, engine, 285 "ScriptEngine must implement Invocable when 'renderFunction' is specified"); 286 } 287 loadScripts(engine); 288 return engine; 289 } 290 291 protected void loadScripts(ScriptEngine engine) { 292 if (!ObjectUtils.isEmpty(this.scripts)) { 293 for (String script : this.scripts) { 294 Resource resource = getResource(script); 295 if (resource == null) { 296 throw new IllegalStateException("Script resource [" + script + "] not found"); 297 } 298 try { 299 engine.eval(new InputStreamReader(resource.getInputStream())); 300 } 301 catch (Throwable ex) { 302 throw new IllegalStateException("Failed to evaluate script [" + script + "]", ex); 303 } 304 } 305 } 306 } 307 308 @Nullable 309 protected Resource getResource(String location) { 310 if (this.resourceLoaderPaths != null) { 311 for (String path : this.resourceLoaderPaths) { 312 Resource resource = obtainApplicationContext().getResource(path + location); 313 if (resource.exists()) { 314 return resource; 315 } 316 } 317 } 318 return null; 319 } 320 321 protected ScriptTemplateConfig autodetectViewConfig() throws BeansException { 322 try { 323 return BeanFactoryUtils.beanOfTypeIncludingAncestors( 324 obtainApplicationContext(), ScriptTemplateConfig.class, true, false); 325 } 326 catch (NoSuchBeanDefinitionException ex) { 327 throw new ApplicationContextException("Expected a single ScriptTemplateConfig bean in the current " + 328 "web application context or the parent root context: ScriptTemplateConfigurer is " + 329 "the usual implementation. This bean may have any name.", ex); 330 } 331 } 332 333 @Override 334 public boolean checkResourceExists(Locale locale) throws Exception { 335 String url = getUrl(); 336 Assert.state(url != null, "'url' not set"); 337 return (getResource(url) != null); 338 } 339 340 @Override 341 protected Mono<Void> renderInternal( 342 Map<String, Object> model, @Nullable MediaType contentType, ServerWebExchange exchange) { 343 344 return exchange.getResponse().writeWith(Mono.fromCallable(() -> { 345 try { 346 ScriptEngine engine = getEngine(); 347 String url = getUrl(); 348 Assert.state(url != null, "'url' not set"); 349 String template = getTemplate(url); 350 351 Function<String, String> templateLoader = path -> { 352 try { 353 return getTemplate(path); 354 } 355 catch (IOException ex) { 356 throw new IllegalStateException(ex); 357 } 358 }; 359 360 Locale locale = LocaleContextHolder.getLocale(exchange.getLocaleContext()); 361 RenderingContext context = new RenderingContext( 362 obtainApplicationContext(), locale, templateLoader, url); 363 364 Object html; 365 if (this.renderFunction == null) { 366 SimpleBindings bindings = new SimpleBindings(); 367 bindings.putAll(model); 368 model.put("renderingContext", context); 369 html = engine.eval(template, bindings); 370 } 371 else if (this.renderObject != null) { 372 Object thiz = engine.eval(this.renderObject); 373 html = ((Invocable) engine).invokeMethod(thiz, this.renderFunction, template, model, context); 374 } 375 else { 376 html = ((Invocable) engine).invokeFunction(this.renderFunction, template, model, context); 377 } 378 379 byte[] bytes = String.valueOf(html).getBytes(StandardCharsets.UTF_8); 380 return exchange.getResponse().bufferFactory().wrap(bytes); // just wrapping, no allocation 381 } 382 catch (ScriptException ex) { 383 throw new IllegalStateException("Failed to render script template", new StandardScriptEvalException(ex)); 384 } 385 catch (Exception ex) { 386 throw new IllegalStateException("Failed to render script template", ex); 387 } 388 })); 389 } 390 391 protected String getTemplate(String path) throws IOException { 392 Resource resource = getResource(path); 393 if (resource == null) { 394 throw new IllegalStateException("Template resource [" + path + "] not found"); 395 } 396 InputStreamReader reader = new InputStreamReader(resource.getInputStream(), getDefaultCharset()); 397 return FileCopyUtils.copyToString(reader); 398 } 399 400}