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