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