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