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}