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