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}