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