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