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