001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 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.http.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.OutputStreamWriter;
032import java.io.Writer;
033import java.net.URI;
034import java.text.ParseException;
035import java.time.Instant;
036import java.util.HashMap;
037import java.util.List;
038import java.util.Locale;
039import java.util.Map;
040import java.util.MissingResourceException;
041import java.util.Optional;
042import java.util.ResourceBundle;
043import java.util.regex.Pattern;
044
045import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
046import org.jdrupes.httpcodec.protocols.http.HttpField;
047import org.jdrupes.httpcodec.protocols.http.HttpRequest;
048import org.jdrupes.httpcodec.protocols.http.HttpResponse;
049import org.jdrupes.httpcodec.types.MediaType;
050import org.jgrapes.core.Channel;
051import org.jgrapes.core.Component;
052import org.jgrapes.core.annotation.Handler;
053import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements;
054import org.jgrapes.core.events.Error;
055import org.jgrapes.http.ResourcePattern;
056import org.jgrapes.http.ResponseCreationSupport;
057import org.jgrapes.http.ResponseCreationSupport.MaxAgeCalculator;
058import org.jgrapes.http.Session;
059import org.jgrapes.http.events.Request;
060import org.jgrapes.http.events.Response;
061import org.jgrapes.io.IOSubchannel;
062import org.jgrapes.io.events.Close;
063import org.jgrapes.io.events.Output;
064import org.jgrapes.io.util.ByteBufferOutputStream;
065
066/**
067 * A base class for components that generate responses to
068 * HTTP requests which are based on a FreeMarker template.
069 */
070@SuppressWarnings("PMD.DataClass")
071public class FreeMarkerRequestHandler extends Component {
072    public static final Pattern TEMPLATE_PATTERN
073        = Pattern.compile(".*\\.ftl\\.[a-z]+$");
074
075    private ClassLoader contentLoader;
076    private String contentPath;
077    private URI prefix;
078    private ResourcePattern prefixPattern;
079    private Configuration fmConfig;
080    private MaxAgeCalculator maxAgeCalculator;
081
082    /**
083     * Instantiates a new free marker request handler.
084     * 
085     * The prefix path is removed from the request paths before resolving
086     * them against the content root. A prefix path must start with a
087     * slash and must end with a slash. If the request handler
088     * should respond to top-level requests, the prefix must be
089     * a single slash.
090     *
091     * @param componentChannel the component channel
092     * @param channelReplacements the channel replacements to apply
093     * to the `channels` elements of the {@link Handler} annotations
094     * @param contentLoader the content loader
095     * @param contentPath the content path
096     * @param prefix the prefix used in requests
097     */
098    public FreeMarkerRequestHandler(Channel componentChannel,
099            ChannelReplacements channelReplacements,
100            ClassLoader contentLoader, String contentPath, URI prefix) {
101        super(componentChannel, channelReplacements);
102        String prefixPath = prefix.getPath();
103        if (!prefixPath.startsWith("/") || !prefixPath.endsWith("/")) {
104            throw new IllegalArgumentException("Illegal prefix: " + prefix);
105        }
106        this.prefix = prefix;
107        this.contentLoader = contentLoader;
108        if (contentPath.startsWith("/")) {
109            contentPath = contentPath.substring(1);
110        }
111        if (contentPath.endsWith("/")) {
112            contentPath = contentPath.substring(0, contentPath.length() - 1);
113        }
114        this.contentPath = contentPath;
115        try {
116            this.prefixPattern = new ResourcePattern(
117                prefixPath.substring(0, prefixPath.length() - 1) + "|**");
118        } catch (ParseException e) {
119            throw new IllegalArgumentException(e);
120        }
121    }
122
123    /**
124     * Instantiates a new free marker request handler.
125     * 
126     * The prefix path is removed from the request paths before resolving
127     * them against the content root. A prefix path must start with a
128     * slash and must end with a slash. If the request handler
129     * should respond to top-level requests, the prefix must be
130     * a single slash.
131     *
132     * @param componentChannel the component channel
133     * @param contentLoader the content loader
134     * @param contentPath the content path
135     * @param prefix the prefix used in requests
136     */
137    public FreeMarkerRequestHandler(Channel componentChannel,
138            ClassLoader contentLoader, String contentPath, URI prefix) {
139        this(componentChannel, null, contentLoader, contentPath, prefix);
140    }
141
142    /**
143     * Returns the prefix passed to the constructor.
144     *
145     * @return the prefix
146     */
147    public URI prefix() {
148        return prefix;
149    }
150
151    /**
152     * Gets the prefix pattern. 
153     *
154     * @return the prefixPattern
155     */
156    public ResourcePattern prefixPattern() {
157        return prefixPattern;
158    }
159
160    /**
161     * Updates the prefix pattern. The contructor initializes the
162     * prefix pattern to the prefix with "|**" appended
163     * (see {@link ResourcePattern}.
164     *
165     * @param prefixPattern the prefixPattern to set
166     */
167    protected void updatePrefixPattern(ResourcePattern prefixPattern) {
168        this.prefixPattern = prefixPattern;
169    }
170
171    /**
172     * @return the maxAgeCalculator
173     */
174    public MaxAgeCalculator maxAgeCalculator() {
175        return maxAgeCalculator;
176    }
177
178    /**
179     * Sets the {@link MaxAgeCalculator} for generating the `Cache-Control` 
180     * (`max-age`) header of the response. The default is `null`. This
181     * causes 0 to be provided for responses generated from templates and the 
182     * {@link ResponseCreationSupport#DEFAULT_MAX_AGE_CALCULATOR} to be
183     * used for static content.
184     * 
185     * @param maxAgeCalculator the maxAgeCalculator to set
186     */
187    public void setMaxAgeCalculator(MaxAgeCalculator maxAgeCalculator) {
188        this.maxAgeCalculator = maxAgeCalculator;
189    }
190
191    /**
192     * Removes the prefix specified in the constructor from the
193     * path in the request. Checks if the resulting path  
194     * ends with `*.ftl.*`. If so, processes the template with the
195     * {@link #sendProcessedTemplate(Request.In, IOSubchannel, String)} (which 
196     * uses {@link #fmSessionModel(Optional)}) and sends the result. 
197     * Else, tries to serve static content with the optionally 
198     * shortened path.
199     * 
200     * @param event the event
201     * @param channel the channel
202     * @throws ParseException the parse exception
203     */
204    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
205    protected void doRespond(Request.In event, IOSubchannel channel)
206            throws ParseException {
207        final HttpRequest request = event.httpRequest();
208        prefixPattern.pathRemainder(request.requestUri()).ifPresent(path -> {
209            boolean success = false;
210            if (TEMPLATE_PATTERN.matcher(path).matches()) {
211                success = sendProcessedTemplate(event, channel, path);
212            } else {
213                success = ResponseCreationSupport.sendStaticContent(
214                    request, channel, requestPath -> contentLoader
215                        .getResource(contentPath + "/" + path),
216                    maxAgeCalculator);
217            }
218            event.setResult(true);
219            event.stop();
220            if (!success) {
221                channel.respond(new Close());
222            }
223        });
224    }
225
226    /**
227     * Render a response using the given template. Send the 
228     * {@link Response} and at least one {@link Output} event.
229     *
230     * If the method does not return `true`, the invoker should close
231     * the connection because it is most likely in an undefined state.
232     *
233     * @param event the request event
234     * @param channel the channel
235     * @param tpl the template
236     * @return false if an error occurred
237     */
238    protected boolean sendProcessedTemplate(
239            Request.In event, IOSubchannel channel, Template tpl) {
240        // Prepare response
241        HttpResponse response = event.httpRequest().response().get();
242        MediaType mediaType = contentType(
243            ResponseCreationSupport.uriFromPath(tpl.getSourceName()));
244        response.setContentType(mediaType);
245
246        // Send response
247        response.setStatus(HttpStatus.OK);
248        response.setField(HttpField.LAST_MODIFIED, Instant.now());
249        if (maxAgeCalculator == null) {
250            ResponseCreationSupport.setMaxAge(response, 0);
251        } else {
252            ResponseCreationSupport.setMaxAge(response,
253                maxAgeCalculator.maxAge(event.httpRequest(), mediaType));
254        }
255        channel.respond(new Response(response));
256
257        // Send content
258        try (ByteBufferOutputStream bbos = new ByteBufferOutputStream(
259            channel, channel.responsePipeline());
260                Writer out = new OutputStreamWriter(
261                    bbos.suppressClose(), "utf-8")) {
262            Map<String, Object> model = fmSessionModel(
263                event.associated(Session.class));
264            tpl.setLocale((Locale) model.get("locale"));
265            tpl.process(model, out);
266            return true;
267        } catch (IOException | TemplateException e) {
268            // Too late to do anything about this (header was sent).
269            fire(new Error(event, e), channel);
270        }
271        return false;
272    }
273
274    /**
275     * Render a response using the template obtained from the config with
276     * {@link Configuration#getTemplate(String)} and the given path.
277     *
278     * @param event
279     *            the event
280     * @param channel
281     *            the channel
282     * @param path
283     *            the path
284     */
285    protected boolean sendProcessedTemplate(
286            Request.In event, IOSubchannel channel, String path) {
287        try {
288            // Get template (no need to continue if this fails).
289            Template tpl = freemarkerConfig().getTemplate(path);
290            return sendProcessedTemplate(event, channel, tpl);
291        } catch (IOException e) {
292            fire(new Error(event, e), channel);
293            return false;
294        }
295    }
296
297    /**
298     * Creates the configuration for freemarker template processing.
299     * 
300     * @return the configuration
301     */
302    protected Configuration freemarkerConfig() {
303        if (fmConfig == null) {
304            fmConfig = new Configuration(Configuration.VERSION_2_3_26);
305            fmConfig.setClassLoaderForTemplateLoading(
306                contentLoader, contentPath);
307            fmConfig.setDefaultEncoding("utf-8");
308            fmConfig.setTemplateExceptionHandler(
309                TemplateExceptionHandler.RETHROW_HANDLER);
310            fmConfig.setLogTemplateExceptions(false);
311        }
312        return fmConfig;
313    }
314
315    /**
316     * Build a freemarker model holding the information associated with the
317     * session.
318     * 
319     * This model provides: 
320     * 
321     * * The `locale` (of type {@link Locale}). 
322     * 
323     * * The `resourceBundle` (of type {@link ResourceBundle}). 
324     * 
325     * * A function "`_`" that looks up the given key in the 
326     *   resource bundle.
327     * 
328     * @param session
329     *            the session
330     * @return the model
331     */
332    protected Map<String, Object> fmSessionModel(Optional<Session> session) {
333        @SuppressWarnings("PMD.UseConcurrentHashMap")
334        final Map<String, Object> model = new HashMap<>();
335        Locale locale = session.map(
336            sess -> sess.locale()).orElse(Locale.getDefault());
337        model.put("locale", locale);
338        final ResourceBundle resourceBundle = resourceBundle(locale);
339        model.put("resourceBundle", resourceBundle);
340        model.put("_", new TemplateMethodModelEx() {
341            @Override
342            @SuppressWarnings("PMD.EmptyCatchBlock")
343            public Object exec(@SuppressWarnings("rawtypes") List arguments)
344                    throws TemplateModelException {
345                @SuppressWarnings("unchecked")
346                List<TemplateModel> args = (List<TemplateModel>) arguments;
347                if (!(args.get(0) instanceof SimpleScalar)) {
348                    throw new TemplateModelException("Not a string.");
349                }
350                String key = ((SimpleScalar) args.get(0)).getAsString();
351                try {
352                    return resourceBundle.getString(key);
353                } catch (MissingResourceException e) {
354                    // no luck
355                }
356                return key;
357            }
358        });
359        return model;
360    }
361
362    /**
363     * Used to get the content type when generating a response with
364     * {@link #sendProcessedTemplate(Request.In, IOSubchannel, Template)}. 
365     * May be overridden by derived classes. This implementation simply invokes
366     * {@link HttpResponse#contentType(URI)}.
367     *
368     * @param request the request
369     * @return the content type
370     */
371    protected MediaType contentType(URI request) {
372        return HttpResponse.contentType(request);
373    }
374
375    /**
376     * Provides a resource bundle for localization.
377     * The default implementation looks up a bundle using the
378     * package name plus "l10n" as base name.
379     * 
380     * @return the resource bundle
381     */
382    protected ResourceBundle resourceBundle(Locale locale) {
383        return ResourceBundle.getBundle(
384            contentPath.replace('/', '.') + ".l10n", locale,
385            contentLoader, ResourceBundle.Control.getNoFallbackControl(
386                ResourceBundle.Control.FORMAT_DEFAULT));
387    }
388}