001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.webconsole.base.freemarker;
020
021import freemarker.template.Configuration;
022import freemarker.template.SimpleScalar;
023import freemarker.template.Template;
024import freemarker.template.TemplateException;
025import freemarker.template.TemplateExceptionHandler;
026import freemarker.template.TemplateMethodModelEx;
027import freemarker.template.TemplateModel;
028import freemarker.template.TemplateModelException;
029import java.io.IOException;
030import java.io.OutputStream;
031import java.io.OutputStreamWriter;
032import java.io.Serializable;
033import java.io.StringWriter;
034import java.time.Instant;
035import java.util.Collections;
036import java.util.HashMap;
037import java.util.List;
038import java.util.Locale;
039import java.util.Map;
040import java.util.MissingResourceException;
041import java.util.ResourceBundle;
042import java.util.concurrent.Future;
043import java.util.regex.Pattern;
044import org.jdrupes.httpcodec.protocols.http.HttpResponse;
045import org.jgrapes.core.Channel;
046import org.jgrapes.core.Component;
047import org.jgrapes.core.Components;
048import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements;
049import org.jgrapes.http.Session;
050import org.jgrapes.io.IOSubchannel;
051import org.jgrapes.webconsole.base.AbstractConlet;
052import org.jgrapes.webconsole.base.AbstractConlet.ConletBaseModel;
053import org.jgrapes.webconsole.base.ConsoleSession;
054import org.jgrapes.webconsole.base.RenderSupport;
055import org.jgrapes.webconsole.base.ResourceByGenerator;
056import org.jgrapes.webconsole.base.events.ConletResourceRequest;
057import org.jgrapes.webconsole.base.events.RenderConlet;
058import org.jgrapes.webconsole.base.events.RenderConletRequest;
059import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
060import org.jgrapes.webconsole.base.freemarker.FreeMarkerConsoleWeblet.LanguageInfo;
061
062/**
063 * 
064 */
065public abstract class FreeMarkerConlet<S extends Serializable>
066        extends AbstractConlet<S> {
067
068    @SuppressWarnings("PMD.VariableNamingConventions")
069    private static final Pattern templatePattern
070        = Pattern.compile(".*\\.ftl\\.[a-z]+$");
071
072    private Configuration fmConfig;
073    private Map<String, Object> fmModel;
074
075    /**
076     * Creates a new component that listens for new events
077     * on the given channel.
078     * 
079     * @param componentChannel
080     */
081    public FreeMarkerConlet(Channel componentChannel) {
082        super(componentChannel);
083    }
084
085    /**
086     * Like {@link #FreeMarkerConlet(Channel)}, but supports
087     * the specification of channel replacements.
088     * 
089     * @param componentChannel
090     * @param channelReplacements the channel replacements (see
091     * {@link Component})
092     */
093    public FreeMarkerConlet(Channel componentChannel,
094            ChannelReplacements channelReplacements) {
095        super(componentChannel, channelReplacements);
096    }
097
098    /**
099     * Create the base freemarker configuration.
100     *
101     * @return the configuration
102     */
103    protected Configuration freemarkerConfig() {
104        if (fmConfig == null) {
105            fmConfig = new Configuration(Configuration.VERSION_2_3_26);
106            fmConfig.setClassLoaderForTemplateLoading(
107                getClass().getClassLoader(), getClass().getPackage()
108                    .getName().replace('.', '/'));
109            fmConfig.setDefaultEncoding("utf-8");
110            fmConfig.setTemplateExceptionHandler(
111                TemplateExceptionHandler.RETHROW_HANDLER);
112            fmConfig.setLogTemplateExceptions(false);
113        }
114        return fmConfig;
115    }
116
117    /**
118     * Creates the request independent part of the freemarker model. The
119     * result is cached as unmodifiable map as it can safely be assumed
120     * that the render support does not change for a given web console.
121     * 
122     * This model provides:
123     *  * The function `conletResource` that makes 
124     *    {@link RenderSupport#conletResource(String, java.net.URI)}
125     *    available in the template. The first argument is set to the name
126     *    of the web console component, only the second must be supplied 
127     *    when the function is invoked in a template.
128     * 
129     * @param renderSupport the render support from the web console
130     * @return the result
131     */
132    protected Map<String, Object> fmTypeModel(
133            RenderSupport renderSupport) {
134        if (fmModel == null) {
135            fmModel = new HashMap<>();
136            fmModel.put("conletResource", new TemplateMethodModelEx() {
137                @Override
138                public Object exec(@SuppressWarnings("rawtypes") List arguments)
139                        throws TemplateModelException {
140                    @SuppressWarnings("unchecked")
141                    List<TemplateModel> args = (List<TemplateModel>) arguments;
142                    if (!(args.get(0) instanceof SimpleScalar)) {
143                        throw new TemplateModelException("Not a string.");
144                    }
145                    return renderSupport.conletResource(
146                        FreeMarkerConlet.this.getClass().getName(),
147                        ((SimpleScalar) args.get(0)).getAsString())
148                        .getRawPath();
149                }
150            });
151            fmModel = Collections.unmodifiableMap(fmModel);
152        }
153        return fmModel;
154    }
155
156    /**
157     * Build a freemarker model holding the information associated 
158     * with the session.
159     * 
160     * This model provides:
161     *  * The `locale` (of type {@link Locale}).
162     *  * The `resourceBundle` (of type {@link ResourceBundle}).
163     *  * A function `_` that looks up the given key in the web console 
164     *    component's resource bundle.
165     *  * A function `supportedLanguages` that returns a {@link Map}
166     *    with language identifiers as keys and {@link LanguageInfo}
167     *    instances as values (derived from 
168     *    {@link AbstractConlet#supportedLocales()}).
169     *    
170     * @param session the session
171     * @return the model
172     */
173    protected Map<String, Object> fmSessionModel(Session session) {
174        @SuppressWarnings("PMD.UseConcurrentHashMap")
175        final Map<String, Object> model = new HashMap<>();
176        Locale locale = session.locale();
177        model.put("locale", locale);
178        final ResourceBundle resourceBundle = resourceBundle(locale);
179        model.put("resourceBundle", resourceBundle);
180        model.put("_", new TemplateMethodModelEx() {
181            @Override
182            public Object exec(@SuppressWarnings("rawtypes") List arguments)
183                    throws TemplateModelException {
184                @SuppressWarnings("unchecked")
185                List<TemplateModel> args = (List<TemplateModel>) arguments;
186                if (!(args.get(0) instanceof SimpleScalar)) {
187                    throw new TemplateModelException("Not a string.");
188                }
189                String key = ((SimpleScalar) args.get(0)).getAsString();
190                try {
191                    return resourceBundle.getString(key);
192                } catch (MissingResourceException e) { // NOPMD
193                    // no luck
194                }
195                return key;
196            }
197        });
198        // Add supported languages
199        model.put("supportedLanguages", new TemplateMethodModelEx() {
200            Object cachedResult;
201
202            @Override
203            public Object exec(@SuppressWarnings("rawtypes") List arguments)
204                    throws TemplateModelException {
205                if (cachedResult == null) {
206                    cachedResult = supportedLocales().entrySet().stream().map(
207                        entry -> new LanguageInfo(entry.getKey(),
208                            entry.getValue()))
209                        .toArray(size -> new LanguageInfo[size]);
210                }
211                return cachedResult;
212            }
213        });
214        return model;
215    }
216
217    /**
218     * Build a freemarker model for the current request.
219     * 
220     * This model provides:
221     *  * The `event` property (of type {@link RenderConletRequest}).
222     *  * The `conlet` property (of type {@link ConletBaseModel}).
223     *  * The function `_Id(String base)` that creates a unique
224     *    id for an HTML element by appending the web console component 
225     *    id to the provided base.
226     *    
227     * @param event the event
228     * @param channel the channel
229     * @param conletModel the web console component model
230     * @return the model
231     */
232    protected Map<String, Object> fmConletModel(
233            RenderConletRequestBase<?> event,
234            IOSubchannel channel, ConletBaseModel conletModel) {
235        @SuppressWarnings("PMD.UseConcurrentHashMap")
236        final Map<String, Object> model = new HashMap<>();
237        model.put("event", event);
238        model.put("conlet", conletModel);
239        model.put("_id", new TemplateMethodModelEx() {
240            @Override
241            public Object exec(@SuppressWarnings("rawtypes") List arguments)
242                    throws TemplateModelException {
243                @SuppressWarnings("unchecked")
244                List<TemplateModel> args = (List<TemplateModel>) arguments;
245                if (!(args.get(0) instanceof SimpleScalar)) {
246                    throw new TemplateModelException("Not a string.");
247                }
248                return ((SimpleScalar) args.get(0)).getAsString()
249                    + "-" + conletModel.getConletId();
250            }
251        });
252        return model;
253    }
254
255    /**
256     * Build a freemarker model that combines {@link #fmTypeModel},
257     * {@link #fmSessionModel} and {@link #fmConletModel}.
258     * 
259     * @param event the event
260     * @param channel the channel
261     * @param conletModel the web console component model
262     * @return the model
263     */
264    protected Map<String, Object> fmModel(RenderConletRequestBase<?> event,
265            ConsoleSession channel, ConletBaseModel conletModel) {
266        final Map<String, Object> model
267            = fmSessionModel(channel.browserSession());
268        model.put("locale", channel.locale());
269        model.putAll(fmTypeModel(event.renderSupport()));
270        model.putAll(fmConletModel(event, channel, conletModel));
271        return model;
272    }
273
274    /**
275     * Checks if the path of the requested resource ends with
276     * `*.ftl.*`. If so, processes the template with the
277     * {@link #fmTypeModel(RenderSupport)} and 
278     * {@link #fmSessionModel(Session)} and
279     * sends the result. Else, invoke the super class' method. 
280     * 
281     * @param event the event. The result will be set to
282     * `true` on success
283     * @param channel the channel
284     */
285    @Override
286    protected void doGetResource(ConletResourceRequest event,
287            IOSubchannel channel) {
288        if (!templatePattern.matcher(event.resourceUri().getPath()).matches()) {
289            super.doGetResource(event, channel);
290            return;
291        }
292        try {
293            // Prepare template
294            final Template tpl = freemarkerConfig().getTemplate(
295                event.resourceUri().getPath());
296            Map<String, Object> model = fmSessionModel(event.session());
297            model.putAll(fmTypeModel(event.renderSupport()));
298
299            // Everything successfully prepared
300            event.setResult(new ResourceByGenerator(event,
301                new ResourceByGenerator.Generator() {
302                    @Override
303                    public void write(OutputStream stream) throws IOException {
304                        try {
305                            tpl.process(model, new OutputStreamWriter(
306                                stream, "utf-8"));
307                        } catch (TemplateException e) {
308                            throw new IOException(e);
309                        }
310                    }
311                },
312                HttpResponse.contentType(event.resourceUri()),
313                Instant.now(), 0));
314            event.stop();
315        } catch (IOException e) { // NOPMD
316            throw new IllegalArgumentException(e);
317        }
318    }
319
320    /**
321     * Specifies how to render web console component content using a template.
322     */
323    public static class RenderConletFromTemplate extends RenderConlet {
324
325        private final Future<String> content;
326
327        /**
328         * Instantiates a new event.
329         *
330         * @param request the request
331         * @param conletType the conlet type
332         * @param conletId the web console component id
333         * @param template the template
334         * @param dataModel the data model
335         */
336        public RenderConletFromTemplate(RenderConletRequestBase<?> request,
337                String conletType, String conletId, Template template,
338                Object dataModel) {
339            super(conletType, conletId);
340            // Start to prepare the content immediately and concurrently.
341            content
342                = request.processedBy().map(procBy -> procBy.executorService())
343                    .orElse(Components.defaultExecutorService()).submit(() -> {
344                        StringWriter out = new StringWriter();
345                        try {
346                            template.process(dataModel, out);
347                        } catch (TemplateException | IOException e) {
348                            throw new IllegalArgumentException(e);
349                        }
350                        return out.toString();
351
352                    });
353        }
354
355        @Override
356        public Future<String> content() {
357            return content;
358        }
359    }
360}