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.StringWriter;
031import java.time.Instant;
032import java.util.Collections;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Locale;
036import java.util.Map;
037import java.util.MissingResourceException;
038import java.util.ResourceBundle;
039import java.util.concurrent.ExecutorService;
040import java.util.concurrent.Future;
041import java.util.regex.Pattern;
042import org.jdrupes.httpcodec.protocols.http.HttpResponse;
043import org.jgrapes.core.Channel;
044import org.jgrapes.core.Component;
045import org.jgrapes.core.Components;
046import org.jgrapes.core.Event;
047import org.jgrapes.core.EventPipeline;
048import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements;
049import org.jgrapes.http.Session;
050import org.jgrapes.io.IOSubchannel;
051import org.jgrapes.io.util.ByteBufferWriter;
052import org.jgrapes.webconsole.base.AbstractConlet;
053import org.jgrapes.webconsole.base.ConsoleConnection;
054import org.jgrapes.webconsole.base.RenderSupport;
055import org.jgrapes.webconsole.base.ResourceByProducer;
056import org.jgrapes.webconsole.base.events.AddConletRequest;
057import org.jgrapes.webconsole.base.events.ConletResourceRequest;
058import org.jgrapes.webconsole.base.events.NotifyConletModel;
059import org.jgrapes.webconsole.base.events.RenderConletRequest;
060import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
061
062/**
063 * 
064 */
065@SuppressWarnings("PMD.CouplingBetweenObjects")
066public abstract class FreeMarkerConlet<S> extends AbstractConlet<S> {
067
068    @SuppressWarnings({ "PMD.VariableNamingConventions",
069        "PMD.FieldNamingConventions" })
070    private static final Pattern templatePattern
071        = Pattern.compile(".*\\.ftl\\.[a-z]+$");
072
073    private Configuration fmConfig;
074    private Map<String, Object> fmModel;
075
076    /**
077     * Creates a new component that listens for new events
078     * on the given channel.
079     * 
080     * @param componentChannel
081     */
082    public FreeMarkerConlet(Channel componentChannel) {
083        super(componentChannel);
084    }
085
086    /**
087     * Like {@link #FreeMarkerConlet(Channel)}, but supports
088     * the specification of channel replacements.
089     * 
090     * @param componentChannel
091     * @param channelReplacements the channel replacements (see
092     * {@link Component})
093     */
094    @SuppressWarnings("PMD.LooseCoupling")
095    public FreeMarkerConlet(Channel componentChannel,
096            ChannelReplacements channelReplacements) {
097        super(componentChannel, channelReplacements);
098    }
099
100    /**
101     * Create the base freemarker configuration.
102     *
103     * @return the configuration
104     */
105    protected Configuration freemarkerConfig() {
106        if (fmConfig == null) {
107            fmConfig = new Configuration(Configuration.VERSION_2_3_26);
108            fmConfig.setClassLoaderForTemplateLoading(
109                getClass().getClassLoader(), getClass().getPackage()
110                    .getName().replace('.', '/'));
111            fmConfig.setDefaultEncoding("utf-8");
112            fmConfig.setTemplateExceptionHandler(
113                TemplateExceptionHandler.RETHROW_HANDLER);
114            fmConfig.setLogTemplateExceptions(false);
115        }
116        return fmConfig;
117    }
118
119    /**
120     * Creates the request independent part of the freemarker model. The
121     * result is cached as unmodifiable map as it can safely be assumed
122     * that the render support does not change for a given web console.
123     * 
124     * This model provides:
125     *  * The function `conletResource` that makes 
126     *    {@link RenderSupport#conletResource(String, java.net.URI)}
127     *    available in the template. The first argument is set to the name
128     *    of the web console component, only the second must be supplied 
129     *    when the function is invoked in a template.
130     * 
131     * @param renderSupport the render support from the web console
132     * @return the result
133     */
134    protected Map<String, Object> fmTypeModel(RenderSupport renderSupport) {
135        if (fmModel == null) {
136            fmModel = new HashMap<>();
137            fmModel.put("conletResource", new TemplateMethodModelEx() {
138                @Override
139                @SuppressWarnings("PMD.AvoidDuplicateLiterals")
140                public Object exec(@SuppressWarnings("rawtypes") List arguments)
141                        throws TemplateModelException {
142                    @SuppressWarnings("unchecked")
143                    List<TemplateModel> args = (List<TemplateModel>) arguments;
144                    if (!(args.get(0) instanceof SimpleScalar)) {
145                        throw new TemplateModelException("Not a string.");
146                    }
147                    return renderSupport.conletResource(
148                        FreeMarkerConlet.this.getClass().getName(),
149                        ((SimpleScalar) args.get(0)).getAsString())
150                        .getRawPath();
151                }
152            });
153            fmModel = Collections.unmodifiableMap(fmModel);
154        }
155        return fmModel;
156    }
157
158    /**
159     * Build a freemarker model holding the information associated 
160     * with the session.
161     * 
162     * This model provides:
163     *  * The `locale` (of type {@link Locale}).
164     *  * The `resourceBundle` (of type {@link ResourceBundle}).
165     *  * A function `_` that looks up the given key in the web console 
166     *    component's resource bundle.
167     *  * A function `supportedLanguages` that returns a {@link Map}
168     *    with language identifiers as keys and {@link LanguageInfo}
169     *    instances as values (derived from 
170     *    {@link AbstractConlet#supportedLocales()}).
171     *    
172     * @param session the session
173     * @return the model
174     */
175    protected Map<String, Object> fmSessionModel(Session session) {
176        @SuppressWarnings("PMD.UseConcurrentHashMap")
177        final Map<String, Object> model = new HashMap<>();
178        Locale locale = session.locale();
179        model.put("locale", locale);
180        final ResourceBundle resourceBundle = resourceBundle(locale);
181        model.put("resourceBundle", resourceBundle);
182        model.put("_", new TemplateMethodModelEx() {
183            @Override
184            public Object exec(@SuppressWarnings("rawtypes") List arguments)
185                    throws TemplateModelException {
186                @SuppressWarnings("unchecked")
187                List<TemplateModel> args = (List<TemplateModel>) arguments;
188                if (!(args.get(0) instanceof SimpleScalar)) {
189                    throw new TemplateModelException("Not a string.");
190                }
191                String key = ((SimpleScalar) args.get(0)).getAsString();
192                try {
193                    return resourceBundle.getString(key);
194                } catch (MissingResourceException e) { // NOPMD
195                    // no luck
196                }
197                return key;
198            }
199        });
200        // Add supported languages
201        model.put("supportedLanguages", new TemplateMethodModelEx() {
202            private Object cachedResult;
203
204            @Override
205            public Object exec(@SuppressWarnings("rawtypes") List arguments)
206                    throws TemplateModelException {
207                if (cachedResult == null) {
208                    cachedResult = supportedLocales().entrySet().stream().map(
209                        entry -> new LanguageInfo(entry.getKey(),
210                            entry.getValue()))
211                        .toArray(size -> new LanguageInfo[size]);
212                }
213                return cachedResult;
214            }
215        });
216        return model;
217    }
218
219    /**
220     * Build a freemarker model for the current request.
221     * 
222     * This model provides:
223     *  * The `event` property (of type {@link RenderConletRequest}).
224     *  * The `conletType` property (of type {@link String}).
225     *  * The `conletId` property (of type {@link String}).
226     *  * The `conlet` property with the conlet's state (if not `null`).
227     *  * The function `_Id(String base)` that creates a unique
228     *    id for an HTML element by appending the web console component 
229     *    id to the provided base.
230     *  * The `conletProperties` which are the properties from 
231     *    an {@link AddConletRequest}, or an empty map.
232     *
233     * @param event the event
234     * @param channel the channel
235     * @param conletId the conlet id
236     * @param conletState the conlet's state information
237     * @return the model
238     */
239    protected Map<String, Object> fmConletModel(Event<?> event,
240            IOSubchannel channel, String conletId, Object conletState) {
241        @SuppressWarnings("PMD.UseConcurrentHashMap")
242        final Map<String, Object> model = new HashMap<>();
243        model.put("event", event);
244        model.put("conletType", type());
245        model.put("conletId", conletId);
246        if (conletState != null) {
247            model.put("conlet", conletState);
248        }
249        model.put("_id", new TemplateMethodModelEx() {
250            @Override
251            public Object exec(@SuppressWarnings("rawtypes") List arguments)
252                    throws TemplateModelException {
253                @SuppressWarnings("unchecked")
254                List<TemplateModel> args = (List<TemplateModel>) arguments;
255                if (!(args.get(0) instanceof SimpleScalar)) {
256                    throw new TemplateModelException("Not a string.");
257                }
258                return ((SimpleScalar) args.get(0)).getAsString()
259                    + "-" + conletId;
260            }
261        });
262        if (event instanceof AddConletRequest) {
263            model.put("conletProperties",
264                ((AddConletRequest) event).properties());
265        } else {
266            model.put("conletProperties", Collections.emptyMap());
267        }
268        return model;
269    }
270
271    /**
272     * Build a freemarker model that combines {@link #fmTypeModel},
273     * {@link #fmSessionModel} and {@link #fmConletModel}. 
274     *
275     * @param event the event
276     * @param channel the channel
277     * @param conletId the conlet id
278     * @param conletState the conlet's state information
279     * @return the model
280     */
281    protected Map<String, Object> fmModel(RenderConletRequestBase<?> event,
282            ConsoleConnection channel, String conletId, Object conletState) {
283        return fmModel(event, event.renderSupport(), channel, conletId,
284            conletState);
285    }
286
287    /**
288     * Build a freemarker model that combines {@link #fmTypeModel},
289     * {@link #fmSessionModel} and {@link #fmConletModel}. 
290     *
291     * @param event the event
292     * @param channel the channel
293     * @param conletId the conlet id
294     * @param conletState the conlet's state information
295     * @return the model
296     */
297    protected Map<String, Object> fmModel(NotifyConletModel event,
298            ConsoleConnection channel, String conletId, Object conletState) {
299        return fmModel(event, event.renderSupport(), channel, conletId,
300            conletState);
301    }
302
303    private Map<String, Object> fmModel(Event<?> event,
304            RenderSupport renderSupport, ConsoleConnection channel,
305            String conletId, Object conletState) {
306        final Map<String, Object> model
307            = fmSessionModel(channel.session());
308        model.put("locale", channel.locale());
309        model.putAll(fmTypeModel(renderSupport));
310        model.putAll(fmConletModel(event, channel, conletId, conletState));
311        return model;
312    }
313
314    /**
315     * Checks if the path of the requested resource ends with
316     * `*.ftl.*`. If so, processes the template with the
317     * {@link #fmTypeModel(RenderSupport)} and 
318     * {@link #fmSessionModel(Session)} and
319     * sends the result. Else, invoke the super class' method. 
320     * 
321     * @param event the event. The result will be set to
322     * `true` on success
323     * @param channel the channel
324     */
325    @Override
326    protected void doGetResource(ConletResourceRequest event,
327            IOSubchannel channel) {
328        if (!templatePattern.matcher(event.resourceUri().getPath()).matches()) {
329            super.doGetResource(event, channel);
330            return;
331        }
332        try {
333            // Prepare template
334            final Template tpl = freemarkerConfig().getTemplate(
335                event.resourceUri().getPath());
336            Map<String, Object> model = fmSessionModel(event.session());
337            model.putAll(fmTypeModel(event.renderSupport()));
338
339            // Everything successfully prepared
340            event.setResult(new ResourceByProducer(event,
341                c -> {
342                    try {
343                        tpl.process(model, new ByteBufferWriter(c));
344                    } catch (TemplateException e) {
345                        throw new IOException(e);
346                    }
347                }, HttpResponse.contentType(event.resourceUri()),
348                Instant.now(), 0));
349            event.stop();
350        } catch (IOException e) { // NOPMD
351            throw new IllegalArgumentException(e);
352        }
353    }
354
355    /**
356     * Returns a future string providing the result
357     * from processing the given template with the given data.
358     * 
359     * The method delegates to
360     * {@link #processTemplate(Event, Template, Object)}. The version with
361     * this signature is kept for backward compatibility.
362     *
363     * @param request the request, used to obtain the
364     * {@link ExecutorService} service related with the request being
365     * processed
366     * @param template the template
367     * @param dataModel the data model
368     * @return the future
369     */
370    public Future<String> processTemplate(RenderConletRequestBase<?> request,
371            Template template, Object dataModel) {
372        return processTemplate((Event<?>) request, template, dataModel);
373    }
374
375    /**
376     * Returns a future string providing the result
377     * from processing the given template with the given data. 
378     *
379     * @param request the request, used to obtain the
380     * {@link ExecutorService} service related with the request being
381     * processed
382     * @param template the template
383     * @param dataModel the data model
384     * @return the future
385     */
386    public Future<String> processTemplate(Event<?> request, Template template,
387            Object dataModel) {
388        return request.processedBy().map(EventPipeline::executorService)
389            .orElse(Components.defaultExecutorService()).submit(() -> {
390                StringWriter out = new StringWriter();
391                try {
392                    template.process(dataModel, out);
393                } catch (TemplateException | IOException e) {
394                    throw new IllegalArgumentException(e);
395                }
396                return out.toString();
397            });
398    }
399}