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.webconsole.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; 029import java.io.IOException; 030import java.io.StringWriter; 031import java.time.Instant; 032import java.util.Collections; 033import java.util.HashMap; 034import java.util.List; 035import java.util.Locale; 036import java.util.Map; 037import java.util.MissingResourceException; 038import java.util.ResourceBundle; 039import java.util.concurrent.ExecutorService; 040import java.util.concurrent.Future; 041import java.util.regex.Pattern; 042import org.jdrupes.httpcodec.protocols.http.HttpResponse; 043import org.jgrapes.core.Channel; 044import org.jgrapes.core.Component; 045import org.jgrapes.core.Components; 046import org.jgrapes.core.Event; 047import org.jgrapes.core.EventPipeline; 048import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements; 049import org.jgrapes.http.Session; 050import org.jgrapes.io.IOSubchannel; 051import org.jgrapes.io.util.ByteBufferWriter; 052import org.jgrapes.webconsole.base.AbstractConlet; 053import org.jgrapes.webconsole.base.ConsoleConnection; 054import org.jgrapes.webconsole.base.RenderSupport; 055import org.jgrapes.webconsole.base.ResourceByProducer; 056import org.jgrapes.webconsole.base.events.AddConletRequest; 057import org.jgrapes.webconsole.base.events.ConletResourceRequest; 058import org.jgrapes.webconsole.base.events.NotifyConletModel; 059import org.jgrapes.webconsole.base.events.RenderConletRequest; 060import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 061 062/** 063 * 064 */ 065public abstract class FreeMarkerConlet<S> extends AbstractConlet<S> { 066 067 @SuppressWarnings({ "PMD.FieldNamingConventions" }) 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 */ 080 public FreeMarkerConlet(Channel componentChannel) { 081 super(componentChannel); 082 } 083 084 /** 085 * Like {@link #FreeMarkerConlet(Channel)}, but supports 086 * the specification of channel replacements. 087 * 088 * @param componentChannel 089 * @param channelReplacements the channel replacements (see 090 * {@link Component}) 091 */ 092 @SuppressWarnings("PMD.LooseCoupling") 093 public FreeMarkerConlet(Channel componentChannel, 094 ChannelReplacements channelReplacements) { 095 super(componentChannel, channelReplacements); 096 } 097 098 /** 099 * Create the base freemarker configuration. 100 * 101 * @return the configuration 102 */ 103 protected Configuration freemarkerConfig() { 104 if (fmConfig == null) { 105 fmConfig = new Configuration(Configuration.VERSION_2_3_26); 106 fmConfig.setClassLoaderForTemplateLoading( 107 getClass().getClassLoader(), getClass().getPackage() 108 .getName().replace('.', '/')); 109 fmConfig.setDefaultEncoding("utf-8"); 110 fmConfig.setTemplateExceptionHandler( 111 TemplateExceptionHandler.RETHROW_HANDLER); 112 fmConfig.setLogTemplateExceptions(false); 113 } 114 return fmConfig; 115 } 116 117 /** 118 * Creates the request independent part of the freemarker model. The 119 * result is cached as unmodifiable map as it can safely be assumed 120 * that the render support does not change for a given web console. 121 * 122 * This model provides: 123 * * The function `conletResource` that makes 124 * {@link RenderSupport#conletResource(String, java.net.URI)} 125 * available in the template. The first argument is set to the name 126 * of the web console component, only the second must be supplied 127 * when the function is invoked in a template. 128 * 129 * @param renderSupport the render support from the web console 130 * @return the result 131 */ 132 protected Map<String, Object> fmTypeModel(RenderSupport renderSupport) { 133 if (fmModel == null) { 134 fmModel = new HashMap<>(); 135 fmModel.put("conletResource", new TemplateMethodModelEx() { 136 @Override 137 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 138 public Object exec(@SuppressWarnings("rawtypes") List arguments) 139 throws TemplateModelException { 140 @SuppressWarnings("unchecked") 141 List<TemplateModel> args = (List<TemplateModel>) arguments; 142 if (!(args.get(0) instanceof SimpleScalar)) { 143 throw new TemplateModelException("Not a string."); 144 } 145 return renderSupport.conletResource( 146 FreeMarkerConlet.this.getClass().getName(), 147 ((SimpleScalar) args.get(0)).getAsString()) 148 .getRawPath(); 149 } 150 }); 151 fmModel = Collections.unmodifiableMap(fmModel); 152 } 153 return fmModel; 154 } 155 156 /** 157 * Build a freemarker model holding the information associated 158 * with the session. 159 * 160 * This model provides: 161 * * The `locale` (of type {@link Locale}). 162 * * The `resourceBundle` (of type {@link ResourceBundle}). 163 * * A function `_` that looks up the given key in the web console 164 * component's resource bundle. 165 * * A function `supportedLanguages` that returns a {@link Map} 166 * with language identifiers as keys and {@link LanguageInfo} 167 * instances as values (derived from 168 * {@link AbstractConlet#supportedLocales()}). 169 * 170 * @param session the session 171 * @return the model 172 */ 173 protected Map<String, Object> fmSessionModel(Session session) { 174 @SuppressWarnings("PMD.UseConcurrentHashMap") 175 final Map<String, Object> model = new HashMap<>(); 176 Locale locale = session.locale(); 177 model.put("locale", locale); 178 final ResourceBundle resourceBundle = resourceBundle(locale); 179 model.put("resourceBundle", resourceBundle); 180 model.put("_", new TemplateMethodModelEx() { 181 @Override 182 public Object exec(@SuppressWarnings("rawtypes") List arguments) 183 throws TemplateModelException { 184 @SuppressWarnings("unchecked") 185 List<TemplateModel> args = (List<TemplateModel>) arguments; 186 if (!(args.get(0) instanceof SimpleScalar)) { 187 throw new TemplateModelException("Not a string."); 188 } 189 String key = ((SimpleScalar) args.get(0)).getAsString(); 190 try { 191 return resourceBundle.getString(key); 192 } catch (MissingResourceException e) { // NOPMD 193 // no luck 194 } 195 return key; 196 } 197 }); 198 // Add supported languages 199 model.put("supportedLanguages", new TemplateMethodModelEx() { 200 private Object cachedResult; 201 202 @Override 203 public Object exec(@SuppressWarnings("rawtypes") List arguments) 204 throws TemplateModelException { 205 if (cachedResult == null) { 206 cachedResult = supportedLocales().entrySet().stream().map( 207 entry -> new LanguageInfo(entry.getKey(), 208 entry.getValue())) 209 .toArray(size -> new LanguageInfo[size]); 210 } 211 return cachedResult; 212 } 213 }); 214 return model; 215 } 216 217 /** 218 * Build a freemarker model for the current request. 219 * 220 * This model provides: 221 * * The `event` property (of type {@link RenderConletRequest}). 222 * * The `conletType` property (of type {@link String}). 223 * * The `conletId` property (of type {@link String}). 224 * * The `conlet` property with the conlet's state (if not `null`). 225 * * The function `_Id(String base)` that creates a unique 226 * id for an HTML element by appending the web console component 227 * id to the provided base. 228 * * The `conletProperties` which are the properties from 229 * an {@link AddConletRequest}, or an empty map. 230 * 231 * @param event the event 232 * @param channel the channel 233 * @param conletId the conlet id 234 * @param conletState the conlet's state information 235 * @return the model 236 */ 237 protected Map<String, Object> fmConletModel(Event<?> event, 238 IOSubchannel channel, String conletId, Object conletState) { 239 @SuppressWarnings("PMD.UseConcurrentHashMap") 240 final Map<String, Object> model = new HashMap<>(); 241 model.put("event", event); 242 model.put("conletType", type()); 243 model.put("conletId", conletId); 244 if (conletState != null) { 245 model.put("conlet", conletState); 246 } 247 model.put("_id", new TemplateMethodModelEx() { 248 @Override 249 public Object exec(@SuppressWarnings("rawtypes") List arguments) 250 throws TemplateModelException { 251 @SuppressWarnings("unchecked") 252 List<TemplateModel> args = (List<TemplateModel>) arguments; 253 if (!(args.get(0) instanceof SimpleScalar)) { 254 throw new TemplateModelException("Not a string."); 255 } 256 return ((SimpleScalar) args.get(0)).getAsString() 257 + "-" + conletId; 258 } 259 }); 260 if (event instanceof AddConletRequest) { 261 model.put("conletProperties", 262 ((AddConletRequest) event).properties()); 263 } else { 264 model.put("conletProperties", Collections.emptyMap()); 265 } 266 return model; 267 } 268 269 /** 270 * Build a freemarker model that combines {@link #fmTypeModel}, 271 * {@link #fmSessionModel} and {@link #fmConletModel}. 272 * 273 * @param event the event 274 * @param channel the channel 275 * @param conletId the conlet id 276 * @param conletState the conlet's state information 277 * @return the model 278 */ 279 protected Map<String, Object> fmModel(RenderConletRequestBase<?> event, 280 ConsoleConnection channel, String conletId, Object conletState) { 281 return fmModel(event, event.renderSupport(), channel, conletId, 282 conletState); 283 } 284 285 /** 286 * Build a freemarker model that combines {@link #fmTypeModel}, 287 * {@link #fmSessionModel} and {@link #fmConletModel}. 288 * 289 * @param event the event 290 * @param channel the channel 291 * @param conletId the conlet id 292 * @param conletState the conlet's state information 293 * @return the model 294 */ 295 protected Map<String, Object> fmModel(NotifyConletModel event, 296 ConsoleConnection channel, String conletId, Object conletState) { 297 return fmModel(event, event.renderSupport(), channel, conletId, 298 conletState); 299 } 300 301 private Map<String, Object> fmModel(Event<?> event, 302 RenderSupport renderSupport, ConsoleConnection channel, 303 String conletId, Object conletState) { 304 final Map<String, Object> model 305 = fmSessionModel(channel.session()); 306 model.put("locale", channel.locale()); 307 model.putAll(fmTypeModel(renderSupport)); 308 model.putAll(fmConletModel(event, channel, conletId, conletState)); 309 return model; 310 } 311 312 /** 313 * Checks if the path of the requested resource ends with 314 * `*.ftl.*`. If so, processes the template with the 315 * {@link #fmTypeModel(RenderSupport)} and 316 * {@link #fmSessionModel(Session)} and 317 * sends the result. Else, invoke the super class' method. 318 * 319 * @param event the event. The result will be set to 320 * `true` on success 321 * @param channel the channel 322 */ 323 @Override 324 protected void doGetResource(ConletResourceRequest event, 325 IOSubchannel channel) { 326 if (!templatePattern.matcher(event.resourceUri().getPath()).matches()) { 327 super.doGetResource(event, channel); 328 return; 329 } 330 try { 331 // Prepare template 332 final Template tpl = freemarkerConfig().getTemplate( 333 event.resourceUri().getPath()); 334 Map<String, Object> model = fmSessionModel(event.session()); 335 model.putAll(fmTypeModel(event.renderSupport())); 336 337 // Everything successfully prepared 338 event.setResult(new ResourceByProducer(event, 339 c -> { 340 try { 341 tpl.process(model, new ByteBufferWriter(c)); 342 } catch (TemplateException e) { 343 throw new IOException(e); 344 } 345 }, HttpResponse.contentType(event.resourceUri()), 346 Instant.now(), 0)); 347 event.stop(); 348 } catch (IOException e) { // NOPMD 349 throw new IllegalArgumentException(e); 350 } 351 } 352 353 /** 354 * Returns a future string providing the result 355 * from processing the given template with the given data. 356 * 357 * The method delegates to 358 * {@link #processTemplate(Event, Template, Object)}. The version with 359 * this signature is kept for backward compatibility. 360 * 361 * @param request the request, used to obtain the 362 * {@link ExecutorService} service related with the request being 363 * processed 364 * @param template the template 365 * @param dataModel the data model 366 * @return the future 367 */ 368 public Future<String> processTemplate(RenderConletRequestBase<?> request, 369 Template template, Object dataModel) { 370 return processTemplate((Event<?>) request, template, dataModel); 371 } 372 373 /** 374 * Returns a future string providing the result 375 * from processing the given template with the given data. 376 * 377 * @param request the request, used to obtain the 378 * {@link ExecutorService} service related with the request being 379 * processed 380 * @param template the template 381 * @param dataModel the data model 382 * @return the future 383 */ 384 public Future<String> processTemplate(Event<?> request, Template template, 385 Object dataModel) { 386 return request.processedBy().map(EventPipeline::executorService) 387 .orElse(Components.defaultExecutorService()).submit(() -> { 388 StringWriter out = new StringWriter(); 389 try { 390 template.process(dataModel, out); 391 } catch (TemplateException | IOException e) { 392 throw new IllegalArgumentException(e); 393 } 394 return out.toString(); 395 }); 396 } 397}