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.portal.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;
029
030import java.io.IOException;
031import java.io.OutputStream;
032import java.io.OutputStreamWriter;
033import java.io.Serializable;
034import java.io.StringWriter;
035import java.time.Instant;
036import java.util.Collections;
037import java.util.HashMap;
038import java.util.List;
039import java.util.Locale;
040import java.util.Map;
041import java.util.MissingResourceException;
042import java.util.ResourceBundle;
043import java.util.concurrent.Future;
044import java.util.regex.Pattern;
045
046import org.jdrupes.httpcodec.protocols.http.HttpResponse;
047import org.jgrapes.core.Channel;
048import org.jgrapes.core.Component;
049import org.jgrapes.core.Components;
050import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements;
051import org.jgrapes.http.Session;
052import org.jgrapes.io.IOSubchannel;
053import org.jgrapes.portal.base.AbstractPortlet;
054import org.jgrapes.portal.base.AbstractPortlet.PortletBaseModel;
055import org.jgrapes.portal.base.PortalSession;
056import org.jgrapes.portal.base.RenderSupport;
057import org.jgrapes.portal.base.ResourceByGenerator;
058import org.jgrapes.portal.base.events.PortletResourceRequest;
059import org.jgrapes.portal.base.events.RenderPortlet;
060import org.jgrapes.portal.base.events.RenderPortletRequest;
061import org.jgrapes.portal.base.events.RenderPortletRequestBase;
062
063/**
064 * 
065 */
066public abstract class FreeMarkerPortlet<S extends Serializable>
067        extends AbstractPortlet<S> {
068
069    @SuppressWarnings("PMD.VariableNamingConventions")
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 FreeMarkerPortlet(Channel componentChannel) {
083        super(componentChannel);
084    }
085
086    /**
087     * Like {@link #FreeMarkerPortlet(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    public FreeMarkerPortlet(Channel componentChannel,
095            ChannelReplacements channelReplacements) {
096        super(componentChannel, channelReplacements);
097    }
098
099    /**
100     * Create the base freemarker configuration.
101     *
102     * @return the configuration
103     */
104    protected Configuration freemarkerConfig() {
105        if (fmConfig == null) {
106            fmConfig = new Configuration(Configuration.VERSION_2_3_26);
107            fmConfig.setClassLoaderForTemplateLoading(
108                getClass().getClassLoader(), getClass().getPackage()
109                    .getName().replace('.', '/'));
110            fmConfig.setDefaultEncoding("utf-8");
111            fmConfig.setTemplateExceptionHandler(
112                TemplateExceptionHandler.RETHROW_HANDLER);
113            fmConfig.setLogTemplateExceptions(false);
114        }
115        return fmConfig;
116    }
117
118    /**
119     * Creates the request independent part of the freemarker model. The
120     * result is cached as unmodifiable map as it can safely be assumed
121     * that the render support does not change for a given portal.
122     * 
123     * This model provides:
124     *  * The function `portletResource` that makes 
125     *    {@link RenderSupport#portletResource(String, java.net.URI)}
126     *    available in the template. The first argument is set to the name
127     *    of the portlet, only the second must be supplied when the function
128     *    is invoked in a template.
129     * 
130     * @param renderSupport the render support from the portal
131     * @return the result
132     */
133    protected Map<String, Object> fmTypeModel(
134            RenderSupport renderSupport) {
135        if (fmModel == null) {
136            fmModel = new HashMap<>();
137            fmModel.put("portletResource", new TemplateMethodModelEx() {
138                @Override
139                public Object exec(@SuppressWarnings("rawtypes") List arguments)
140                        throws TemplateModelException {
141                    @SuppressWarnings("unchecked")
142                    List<TemplateModel> args = (List<TemplateModel>) arguments;
143                    if (!(args.get(0) instanceof SimpleScalar)) {
144                        throw new TemplateModelException("Not a string.");
145                    }
146                    return renderSupport.portletResource(
147                        FreeMarkerPortlet.this.getClass().getName(),
148                        ((SimpleScalar) args.get(0)).getAsString())
149                        .getRawPath();
150                }
151            });
152            fmModel = Collections.unmodifiableMap(fmModel);
153        }
154        return fmModel;
155    }
156
157    /**
158     * Build a freemarker model holding the information associated 
159     * with the session.
160     * 
161     * This model provides:
162     *  * The `locale` (of type {@link Locale}).
163     *  * The `resourceBundle` (of type {@link ResourceBundle}).
164     *  * A function `_` that looks up the given key in the portlet's
165     *    resource bundle.
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        return model;
196    }
197
198    /**
199     * Build a freemarker model for the current request.
200     * 
201     * This model provides:
202     *  * The `event` property (of type {@link RenderPortletRequest}).
203     *  * The `portlet` property (of type {@link PortletBaseModel}).
204     *  * The function `_Id(String base)` that creates a unique
205     *    id for an HTML element by appending the portlet id to the 
206     *    provided base.
207     *    
208     * @param event the event
209     * @param channel the channel
210     * @param portletModel the portlet model
211     * @return the model
212     */
213    protected Map<String, Object> fmPortletModel(
214            RenderPortletRequestBase<?> event,
215            IOSubchannel channel, PortletBaseModel portletModel) {
216        @SuppressWarnings("PMD.UseConcurrentHashMap")
217        final Map<String, Object> model = new HashMap<>();
218        model.put("event", event);
219        model.put("portlet", portletModel);
220        model.put("_id", new TemplateMethodModelEx() {
221            @Override
222            public Object exec(@SuppressWarnings("rawtypes") List arguments)
223                    throws TemplateModelException {
224                @SuppressWarnings("unchecked")
225                List<TemplateModel> args = (List<TemplateModel>) arguments;
226                if (!(args.get(0) instanceof SimpleScalar)) {
227                    throw new TemplateModelException("Not a string.");
228                }
229                return ((SimpleScalar) args.get(0)).getAsString()
230                    + "-" + portletModel.getPortletId();
231            }
232        });
233        return model;
234    }
235
236    /**
237     * Build a freemarker model that combines {@link #fmTypeModel},
238     * {@link #fmSessionModel} and {@link #fmPortletModel}.
239     * 
240     * @param event the event
241     * @param channel the channel
242     * @param portletModel the portlet model
243     * @return the model
244     */
245    protected Map<String, Object> fmModel(RenderPortletRequestBase<?> event,
246            PortalSession channel, PortletBaseModel portletModel) {
247        final Map<String, Object> model
248            = fmSessionModel(channel.browserSession());
249        model.put("locale", channel.locale());
250        model.putAll(fmTypeModel(event.renderSupport()));
251        model.putAll(fmPortletModel(event, channel, portletModel));
252        return model;
253    }
254
255    /**
256     * Checks if the path of the requested resource ends with
257     * `*.ftl.*`. If so, processes the template with the
258     * {@link #fmTypeModel(RenderSupport)} and 
259     * {@link #fmSessionModel(Session)} and
260     * sends the result. Else, invoke the super class' method. 
261     * 
262     * @param event the event. The result will be set to
263     * `true` on success
264     * @param channel the channel
265     */
266    @Override
267    protected void doGetResource(PortletResourceRequest event,
268            IOSubchannel channel) {
269        if (!templatePattern.matcher(event.resourceUri().getPath()).matches()) {
270            super.doGetResource(event, channel);
271            return;
272        }
273        try {
274            // Prepare template
275            final Template tpl = freemarkerConfig().getTemplate(
276                event.resourceUri().getPath());
277            Map<String, Object> model = fmSessionModel(event.session());
278            model.putAll(fmTypeModel(event.renderSupport()));
279
280            // Everything successfully prepared
281            event.setResult(new ResourceByGenerator(event,
282                new ResourceByGenerator.Generator() {
283                    @Override
284                    public void write(OutputStream stream) throws IOException {
285                        try {
286                            tpl.process(model, new OutputStreamWriter(
287                                stream, "utf-8"));
288                        } catch (TemplateException e) {
289                            throw new IOException(e);
290                        }
291                    }
292                },
293                HttpResponse.contentType(event.resourceUri()),
294                Instant.now(), 0));
295            event.stop();
296        } catch (IOException e) { // NOPMD
297            throw new IllegalArgumentException(e);
298        }
299    }
300
301    /**
302     * Specifies how to render portlet content using a template.
303     */
304    public static class RenderPortletFromTemplate extends RenderPortlet {
305
306        private final Future<String> content;
307
308        /**
309         * Instantiates a new event.
310         *
311         * @param request the request
312         * @param portletClass the portlet class
313         * @param portletId the portlet id
314         * @param template the template
315         * @param dataModel the data model
316         */
317        public RenderPortletFromTemplate(RenderPortletRequestBase<?> request,
318                Class<?> portletClass, String portletId, Template template,
319                Object dataModel) {
320            super(portletClass, portletId);
321            // Start to prepare the content immediately and concurrently.
322            content
323                = request.processedBy().map(procBy -> procBy.executorService())
324                    .orElse(Components.defaultExecutorService()).submit(() -> {
325                        StringWriter out = new StringWriter();
326                        try {
327                            template.process(dataModel, out);
328                        } catch (TemplateException | IOException e) {
329                            throw new IllegalArgumentException(e);
330                        }
331                        return out.toString();
332
333                    });
334        }
335
336        @Override
337        public Future<String> content() {
338            return content;
339        }
340    }
341}