/** * Copyright (C) 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package ninja.template; import java.io.File; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import javax.inject.Singleton; import ninja.Context; import ninja.Result; import ninja.exceptions.RenderingException; import ninja.i18n.Lang; import ninja.i18n.Messages; import ninja.template.directives.TemplateEngineFreemarkerAuthenticityFormDirective; import ninja.template.directives.TemplateEngineFreemarkerAuthenticityTokenDirective; import ninja.utils.NinjaConstant; import ninja.utils.NinjaProperties; import ninja.utils.ResponseStreams; import org.slf4j.Logger; import com.google.common.base.CaseFormat; import com.google.common.collect.Maps; import com.google.inject.Inject; import freemarker.cache.ClassTemplateLoader; import freemarker.cache.FileTemplateLoader; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.TemplateLoader; import freemarker.core.ParseException; import freemarker.ext.beans.BeansWrapper; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapper; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.Template; import freemarker.template.TemplateException; import freemarker.template.TemplateNotFoundException; import freemarker.template.Version; @Singleton public class TemplateEngineFreemarker implements TemplateEngine { public final static String FREEMARKER_CONFIGURATION_FILE_SUFFIX = "freemarker.suffix"; // Selection of logging library has to be done manually until Freemarker 2.4 // more: http://freemarker.org/docs/api/freemarker/log/Logger.html static { try { freemarker.log.Logger.selectLoggerLibrary(freemarker.log.Logger.LIBRARY_SLF4J); } catch (Exception e) { throw new RuntimeException(e); } } // end private final Version INCOMPATIBLE_IMPROVEMENTS_VERSION = new Version(2, 3, 22); private final String FILE_SUFFIX = ".ftl.html"; private final Configuration cfg; private final NinjaProperties ninjaProperties; private final Messages messages; private final Lang lang; private final TemplateEngineHelper templateEngineHelper; private final Logger logger; private final TemplateEngineFreemarkerReverseRouteMethod templateEngineFreemarkerReverseRouteMethod; private final TemplateEngineFreemarkerAssetsAtMethod templateEngineFreemarkerAssetsAtMethod; private final TemplateEngineFreemarkerWebJarsAtMethod templateEngineFreemarkerWebJarsAtMethod; private final String fileSuffix; @Inject public TemplateEngineFreemarker(Messages messages, Lang lang, Logger logger, TemplateEngineHelper templateEngineHelper, TemplateEngineManager templateEngineManager, TemplateEngineFreemarkerReverseRouteMethod templateEngineFreemarkerReverseRouteMethod, TemplateEngineFreemarkerAssetsAtMethod templateEngineFreemarkerAssetsAtMethod, TemplateEngineFreemarkerWebJarsAtMethod templateEngineFreemarkerWebJarsAtMethod, NinjaProperties ninjaProperties) throws Exception { this.messages = messages; this.lang = lang; this.logger = logger; this.ninjaProperties = ninjaProperties; this.templateEngineHelper = templateEngineHelper; this.templateEngineFreemarkerReverseRouteMethod = templateEngineFreemarkerReverseRouteMethod; this.templateEngineFreemarkerAssetsAtMethod = templateEngineFreemarkerAssetsAtMethod; this.templateEngineFreemarkerWebJarsAtMethod = templateEngineFreemarkerWebJarsAtMethod; this.fileSuffix = ninjaProperties.getWithDefault(FREEMARKER_CONFIGURATION_FILE_SUFFIX, FILE_SUFFIX); cfg = new Configuration(INCOMPATIBLE_IMPROVEMENTS_VERSION); // Set your preferred charset template files are stored in. UTF-8 is // a good choice in most applications: cfg.setDefaultEncoding(NinjaConstant.UTF_8); // Set the charset of the output. This is actually just a hint, that // templates may require for URL encoding and for generating META element // that uses http-equiv="Content-type". cfg.setOutputEncoding(NinjaConstant.UTF_8); // Ninja does the localization itself - lookup is not needed. cfg.setLocalizedLookup(false); /////////////////////////////////////////////////////////////////////// // 1) In dev we load templates from src/java/main first, then from the // classpath. // Therefore Freemarker can handle reloading of changed templates without // the need to restart the server (e.g automatic reload of jetty:run) // 2) In test and prod we never refresh templates and load them // from the classpath /////////////////////////////////////////////////////////////////////// String srcDir = System.getProperty("user.dir") + File.separator + "src" + File.separator + "main" + File.separator + "java"; if (ninjaProperties.isDev() && new File(srcDir).exists()) { try { // the src dir of user's project. FileTemplateLoader fileTemplateLoader = new FileTemplateLoader(new File(srcDir)); // then ftl.html files from the classpath (eg. from inherited modules // or the ninja core module) ClassTemplateLoader classTemplateLoader = new ClassTemplateLoader(this.getClass(), "/"); TemplateLoader [] templateLoader = new TemplateLoader[] { fileTemplateLoader, classTemplateLoader }; MultiTemplateLoader multiTemplateLoader = new MultiTemplateLoader(templateLoader); cfg.setTemplateLoader(multiTemplateLoader); } catch (IOException e) { logger.error("Error Loading Freemarker Template " +srcDir , e); } // check for updates each second cfg.setTemplateUpdateDelay(1); } else { // load templates from classpath cfg.setClassForTemplateLoading(this.getClass(), "/"); // never update the templates in production or while testing... cfg.setTemplateUpdateDelay(Integer.MAX_VALUE); // Hold 20 templates as strong references as recommended by: // http://freemarker.sourceforge.net/docs/pgui_config_templateloading.html cfg.setCacheStorage(new freemarker.cache.MruCacheStorage(20, Integer.MAX_VALUE)); } // we are going to enable html escaping by default using this template // loader: cfg.setTemplateLoader(new TemplateEngineFreemarkerEscapedLoader(cfg .getTemplateLoader())); // We also do not want Freemarker to chose a platform dependent // number formatting. Eg "1000" could be printed out by FTL as "1,000" // on some platforms. This is not "least astonishemnt". It will also // break stuff really badly sometimes. // See also: http://freemarker.sourceforge.net/docs/app_faq.html#faq_number_grouping cfg.setNumberFormat("0.######"); // now it will print 1000000 cfg.setObjectWrapper(createBeansWrapperWithExposedFields()); } @Override public void invoke(Context context, Result result) { Object object = result.getRenderable(); Map map; // if the object is null we simply render an empty map... if (object == null) { map = Maps.newHashMap(); } else if (object instanceof Map) { map = (Map) object; } else { // We are getting an arbitrary Object and put that into // the root of freemarker // If you are rendering something like Results.ok().render(new MyObject()) // Assume MyObject has a public String name field. // You can then access the fields in the template like that: // ${myObject.publicField} String realClassNameLowerCamelCase = CaseFormat.UPPER_CAMEL.to( CaseFormat.LOWER_CAMEL, object.getClass().getSimpleName()); map = Maps.newHashMap(); map.put(realClassNameLowerCamelCase, object); } // set language from framework. You can access // it in the templates as ${lang} Optional<String> language = lang.getLanguage(context, Optional.of(result)); if (language.isPresent()) { map.put("lang", language.get()); } // put all entries of the session cookie to the map. // You can access the values by their key in the cookie if (!context.getSession().isEmpty()) { map.put("session", context.getSession().getData()); } map.put("contextPath", context.getContextPath()); map.put("validation", context.getValidation()); ////////////////////////////////////////////////////////////////////// // A method that renders i18n messages and can also render messages with // placeholders directly in your template: // E.g.: ${i18n("mykey", myPlaceholderVariable)} ////////////////////////////////////////////////////////////////////// map.put("i18n", new TemplateEngineFreemarkerI18nMethod(messages, context, result)); Optional<String> requestLang = lang.getLanguage(context, Optional.of(result)); Locale locale = lang.getLocaleFromStringOrDefault(requestLang); map.put("prettyTime", new TemplateEngineFreemarkerPrettyTimeMethod(locale)); map.put("reverseRoute", templateEngineFreemarkerReverseRouteMethod); map.put("assetsAt", templateEngineFreemarkerAssetsAtMethod); map.put("webJarsAt", templateEngineFreemarkerWebJarsAtMethod); map.put("authenticityToken", new TemplateEngineFreemarkerAuthenticityTokenDirective(context)); map.put("authenticityForm", new TemplateEngineFreemarkerAuthenticityFormDirective(context)); /////////////////////////////////////////////////////////////////////// // Convenience method to translate possible flash scope keys. // !!! If you want to set messages with placeholders please do that // !!! in your controller. We only can set simple messages. // Eg. A message like "errorMessage=my name is: {0}" => translate in controller and pass directly. // A message like " errorMessage=An error occurred" => use that as errorMessage. // // get keys via ${flash.KEYNAME} ////////////////////////////////////////////////////////////////////// Map<String, String> translatedFlashCookieMap = Maps.newHashMap(); for (Entry<String, String> entry : context.getFlashScope().getCurrentFlashCookieData().entrySet()) { String messageValue = null; Optional<String> messageValueOptional = messages.get(entry.getValue(), context, Optional.of(result)); if (!messageValueOptional.isPresent()) { messageValue = entry.getValue(); } else { messageValue = messageValueOptional.get(); } // new way translatedFlashCookieMap.put(entry.getKey(), messageValue); } // now we can retrieve flash cookie messages via ${flash.MESSAGE_KEY} map.put("flash", translatedFlashCookieMap); // Specify the data source where the template files come from. // Here I set a file directory for it: String templateName = templateEngineHelper.getTemplateForResult( context.getRoute(), result, this.fileSuffix); Template freemarkerTemplate = null; try { freemarkerTemplate = cfg.getTemplate(templateName); // Fully buffer the response so in the case of a template error we can // return the applications 500 error message. Without fully buffering // we can't guarantee we haven't flushed part of the response to the // client. StringWriter buffer = new StringWriter(64 * 1024); freemarkerTemplate.process(map, buffer); ResponseStreams responseStreams = context.finalizeHeaders(result); try (Writer writer = responseStreams.getWriter()) { writer.write(buffer.toString()); } } catch (Exception cause) { // delegate rendering exception handling back to Ninja throwRenderingException(context, result, cause, templateName); } } public void throwRenderingException( Context context, Result result, Exception cause, String knownTemplateSourcePath) { // parse method above may throw an IOException whose cause is really // a more useful ParseException if (cause instanceof IOException && cause.getCause() != null && cause.getCause() instanceof ParseException) { cause = (ParseException)cause.getCause(); } if (cause instanceof TemplateNotFoundException) { // inner cause will be better to display throw new RenderingException(cause.getMessage(), cause, result, "FreeMarker template not found", knownTemplateSourcePath, -1); } else if (cause instanceof TemplateException) { TemplateException te = (TemplateException)cause; String templateSourcePath = te.getTemplateSourceName(); if (templateSourcePath == null) { templateSourcePath = knownTemplateSourcePath; } throw new RenderingException(cause.getMessage(), cause, result, "FreeMarker render exception", templateSourcePath, te.getLineNumber()); } else if (cause instanceof ParseException) { ParseException pe = (ParseException)cause; String templateSourcePath = pe.getTemplateName(); if (templateSourcePath == null) { templateSourcePath = knownTemplateSourcePath; } throw new RenderingException(cause.getMessage(), cause, result, "FreeMarker parser exception", templateSourcePath, pe.getLineNumber()); } // fallback to throwing generic rendering exception throw new RenderingException(cause.getMessage(), cause, result, knownTemplateSourcePath, -1); } @Override public String getContentType() { return "text/html"; } @Override public String getSuffixOfTemplatingEngine() { return this.fileSuffix; } /** * Allows to modify the FreeMarker configuration. According to the FreeMarker documentation, the configuration will be thread-safe once * all settings have been set via a safe publication technique. Therefore, consider modifying this configuration only within the configure() * method of your application Module singleton. * * @return the freemarker configuration object */ public Configuration getConfiguration() { return cfg; } private BeansWrapper createBeansWrapperWithExposedFields() { DefaultObjectWrapperBuilder defaultObjectWrapperBuilder = new DefaultObjectWrapperBuilder(INCOMPATIBLE_IMPROVEMENTS_VERSION); defaultObjectWrapperBuilder.setExposeFields(true); DefaultObjectWrapper defaultObjectWrapper = defaultObjectWrapperBuilder.build(); return defaultObjectWrapper; } }