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 */
065public abstract class FreeMarkerConlet<S> extends AbstractConlet<S> {
066
067    @SuppressWarnings({ "PMD.FieldNamingConventions" })
068    private static final Pattern templatePattern
069        = Pattern.compile(".*\\.ftl\\.[a-z]+$");
070
071    private Configuration fmConfig;
072    private Map<String, Object> fmModel;
073
074    /**
075     * Creates a new component that listens for new events
076     * on the given channel.
077     * 
078     * @param componentChannel
079     */
080    public FreeMarkerConlet(Channel componentChannel) {
081        super(componentChannel);
082    }
083
084    /**
085     * Like {@link #FreeMarkerConlet(Channel)}, but supports
086     * the specification of channel replacements.
087     * 
088     * @param componentChannel
089     * @param channelReplacements the channel replacements (see
090     * {@link Component})
091     */
092    @SuppressWarnings("PMD.LooseCoupling")
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(RenderSupport renderSupport) {
133        if (fmModel == null) {
134            fmModel = new HashMap<>();
135            fmModel.put("conletResource", new TemplateMethodModelEx() {
136                @Override
137                @SuppressWarnings("PMD.AvoidDuplicateLiterals")
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            private 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 `conletType` property (of type {@link String}).
223     *  * The `conletId` property (of type {@link String}).
224     *  * The `conlet` property with the conlet's state (if not `null`).
225     *  * The function `_Id(String base)` that creates a unique
226     *    id for an HTML element by appending the web console component 
227     *    id to the provided base.
228     *  * The `conletProperties` which are the properties from 
229     *    an {@link AddConletRequest}, or an empty map.
230     *
231     * @param event the event
232     * @param channel the channel
233     * @param conletId the conlet id
234     * @param conletState the conlet's state information
235     * @return the model
236     */
237    protected Map<String, Object> fmConletModel(Event<?> event,
238            IOSubchannel channel, String conletId, Object conletState) {
239        @SuppressWarnings("PMD.UseConcurrentHashMap")
240        final Map<String, Object> model = new HashMap<>();
241        model.put("event", event);
242        model.put("conletType", type());
243        model.put("conletId", conletId);
244        if (conletState != null) {
245            model.put("conlet", conletState);
246        }
247        model.put("_id", new TemplateMethodModelEx() {
248            @Override
249            public Object exec(@SuppressWarnings("rawtypes") List arguments)
250                    throws TemplateModelException {
251                @SuppressWarnings("unchecked")
252                List<TemplateModel> args = (List<TemplateModel>) arguments;
253                if (!(args.get(0) instanceof SimpleScalar)) {
254                    throw new TemplateModelException("Not a string.");
255                }
256                return ((SimpleScalar) args.get(0)).getAsString()
257                    + "-" + conletId;
258            }
259        });
260        if (event instanceof AddConletRequest) {
261            model.put("conletProperties",
262                ((AddConletRequest) event).properties());
263        } else {
264            model.put("conletProperties", Collections.emptyMap());
265        }
266        return model;
267    }
268
269    /**
270     * Build a freemarker model that combines {@link #fmTypeModel},
271     * {@link #fmSessionModel} and {@link #fmConletModel}. 
272     *
273     * @param event the event
274     * @param channel the channel
275     * @param conletId the conlet id
276     * @param conletState the conlet's state information
277     * @return the model
278     */
279    protected Map<String, Object> fmModel(RenderConletRequestBase<?> event,
280            ConsoleConnection channel, String conletId, Object conletState) {
281        return fmModel(event, event.renderSupport(), channel, conletId,
282            conletState);
283    }
284
285    /**
286     * Build a freemarker model that combines {@link #fmTypeModel},
287     * {@link #fmSessionModel} and {@link #fmConletModel}. 
288     *
289     * @param event the event
290     * @param channel the channel
291     * @param conletId the conlet id
292     * @param conletState the conlet's state information
293     * @return the model
294     */
295    protected Map<String, Object> fmModel(NotifyConletModel event,
296            ConsoleConnection channel, String conletId, Object conletState) {
297        return fmModel(event, event.renderSupport(), channel, conletId,
298            conletState);
299    }
300
301    private Map<String, Object> fmModel(Event<?> event,
302            RenderSupport renderSupport, ConsoleConnection channel,
303            String conletId, Object conletState) {
304        final Map<String, Object> model
305            = fmSessionModel(channel.session());
306        model.put("locale", channel.locale());
307        model.putAll(fmTypeModel(renderSupport));
308        model.putAll(fmConletModel(event, channel, conletId, conletState));
309        return model;
310    }
311
312    /**
313     * Checks if the path of the requested resource ends with
314     * `*.ftl.*`. If so, processes the template with the
315     * {@link #fmTypeModel(RenderSupport)} and 
316     * {@link #fmSessionModel(Session)} and
317     * sends the result. Else, invoke the super class' method. 
318     * 
319     * @param event the event. The result will be set to
320     * `true` on success
321     * @param channel the channel
322     */
323    @Override
324    protected void doGetResource(ConletResourceRequest event,
325            IOSubchannel channel) {
326        if (!templatePattern.matcher(event.resourceUri().getPath()).matches()) {
327            super.doGetResource(event, channel);
328            return;
329        }
330        try {
331            // Prepare template
332            final Template tpl = freemarkerConfig().getTemplate(
333                event.resourceUri().getPath());
334            Map<String, Object> model = fmSessionModel(event.session());
335            model.putAll(fmTypeModel(event.renderSupport()));
336
337            // Everything successfully prepared
338            event.setResult(new ResourceByProducer(event,
339                c -> {
340                    try {
341                        tpl.process(model, new ByteBufferWriter(c));
342                    } catch (TemplateException e) {
343                        throw new IOException(e);
344                    }
345                }, HttpResponse.contentType(event.resourceUri()),
346                Instant.now(), 0));
347            event.stop();
348        } catch (IOException e) { // NOPMD
349            throw new IllegalArgumentException(e);
350        }
351    }
352
353    /**
354     * Returns a future string providing the result
355     * from processing the given template with the given data.
356     * 
357     * The method delegates to
358     * {@link #processTemplate(Event, Template, Object)}. The version with
359     * this signature is kept for backward compatibility.
360     *
361     * @param request the request, used to obtain the
362     * {@link ExecutorService} service related with the request being
363     * processed
364     * @param template the template
365     * @param dataModel the data model
366     * @return the future
367     */
368    public Future<String> processTemplate(RenderConletRequestBase<?> request,
369            Template template, Object dataModel) {
370        return processTemplate((Event<?>) request, template, dataModel);
371    }
372
373    /**
374     * Returns a future string providing the result
375     * from processing the given template with the given data. 
376     *
377     * @param request the request, used to obtain the
378     * {@link ExecutorService} service related with the request being
379     * processed
380     * @param template the template
381     * @param dataModel the data model
382     * @return the future
383     */
384    public Future<String> processTemplate(Event<?> request, Template template,
385            Object dataModel) {
386        return request.processedBy().map(EventPipeline::executorService)
387            .orElse(Components.defaultExecutorService()).submit(() -> {
388                StringWriter out = new StringWriter();
389                try {
390                    template.process(dataModel, out);
391                } catch (TemplateException | IOException e) {
392                    throw new IllegalArgumentException(e);
393                }
394                return out.toString();
395            });
396    }
397}