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