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}