001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2018,2022 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.cache.ClassTemplateLoader;
022import freemarker.cache.MultiTemplateLoader;
023import freemarker.cache.TemplateLoader;
024import freemarker.template.Configuration;
025import freemarker.template.SimpleScalar;
026import freemarker.template.Template;
027import freemarker.template.TemplateException;
028import freemarker.template.TemplateExceptionHandler;
029import freemarker.template.TemplateMethodModelEx;
030import freemarker.template.TemplateModel;
031import freemarker.template.TemplateModelException;
032import java.io.IOException;
033import java.io.Writer;
034import java.net.URI;
035import java.text.Collator;
036import java.util.ArrayList;
037import java.util.Comparator;
038import java.util.HashMap;
039import java.util.List;
040import java.util.Locale;
041import java.util.Map;
042import java.util.MissingResourceException;
043import java.util.ResourceBundle;
044import java.util.UUID;
045import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
046import org.jdrupes.httpcodec.protocols.http.HttpField;
047import org.jdrupes.httpcodec.protocols.http.HttpResponse;
048import org.jdrupes.httpcodec.types.MediaType;
049import org.jgrapes.core.Channel;
050import org.jgrapes.http.LanguageSelector.Selection;
051import org.jgrapes.http.events.Request;
052import org.jgrapes.http.events.Response;
053import org.jgrapes.io.IOSubchannel;
054import org.jgrapes.io.util.ByteBufferWriter;
055import org.jgrapes.webconsole.base.ConsoleWeblet;
056
057/**
058 * A console weblet that uses a freemarker template to generate
059 * the HTML source for the console page.  
060 */
061public abstract class FreeMarkerConsoleWeblet extends ConsoleWeblet {
062
063    public static final String UTF_8 = "utf-8";
064    /**
065     * Initialized with a base FreeMarker configuration.
066     */
067    protected Configuration freeMarkerConfig;
068
069    /**
070     * Instantiates a new free marker console weblet.
071     *
072     * @param webletChannel the weblet channel
073     * @param consoleChannel the console channel
074     * @param consolePrefix the console prefix
075     */
076    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
077    public FreeMarkerConsoleWeblet(Channel webletChannel,
078            Channel consoleChannel, URI consolePrefix) {
079        super(webletChannel, consoleChannel, consolePrefix);
080        freeMarkerConfig = new Configuration(Configuration.VERSION_2_3_26);
081        List<TemplateLoader> loaders = new ArrayList<>();
082        Class<?> clazz = getClass();
083        while (!clazz.equals(FreeMarkerConsoleWeblet.class)) {
084            loaders.add(new ClassTemplateLoader(clazz.getClassLoader(),
085                clazz.getPackage().getName().replace('.', '/')));
086            clazz = clazz.getSuperclass();
087        }
088        freeMarkerConfig.setTemplateLoader(
089            new MultiTemplateLoader(loaders.toArray(new TemplateLoader[0])));
090        freeMarkerConfig.setDefaultEncoding(UTF_8);
091        freeMarkerConfig.setTemplateExceptionHandler(
092            TemplateExceptionHandler.RETHROW_HANDLER);
093        freeMarkerConfig.setLogTemplateExceptions(false);
094    }
095
096    /**
097     * Prepend a class template loader to the list of loaders 
098     * derived from the class hierarchy.
099     *
100     * @param classloader the class loader
101     * @param path the path
102     * @return the free marker console weblet
103     */
104    public FreeMarkerConsoleWeblet
105            prependClassTemplateLoader(ClassLoader classloader, String path) {
106        List<TemplateLoader> loaders = new ArrayList<>();
107        loaders.add(new ClassTemplateLoader(classloader, path));
108        MultiTemplateLoader oldLoader
109            = (MultiTemplateLoader) freeMarkerConfig.getTemplateLoader();
110        for (int i = 0; i < oldLoader.getTemplateLoaderCount(); i++) {
111            loaders.add(oldLoader.getTemplateLoader(i));
112        }
113        freeMarkerConfig.setTemplateLoader(
114            new MultiTemplateLoader(loaders.toArray(new TemplateLoader[0])));
115        return this;
116    }
117
118    /**
119     * Convenience version of 
120     * {@link #prependClassTemplateLoader(ClassLoader, String)} that derives
121     * the path from the class's package name.
122     *
123     * @param clazz the clazz
124     * @return the free marker console weblet
125     */
126    public FreeMarkerConsoleWeblet prependClassTemplateLoader(Class<?> clazz) {
127        return prependClassTemplateLoader(clazz.getClassLoader(),
128            clazz.getPackage().getName().replace('.', '/'));
129    }
130
131    /**
132     * Creates the console base model.
133     *
134     * @return the base model
135     */
136    @SuppressWarnings("PMD.UseConcurrentHashMap")
137    protected Map<String, Object> createConsoleBaseModel() {
138        // Create console model
139        Map<String, Object> consoleModel = new HashMap<>();
140        consoleModel.put("consoleType", getClass().getName());
141        consoleModel.put("renderSupport", renderSupport());
142        consoleModel.put("useMinifiedResources", useMinifiedResources());
143        consoleModel.put("minifiedExtension",
144            useMinifiedResources() ? ".min" : "");
145        consoleModel.put("connectionRefreshInterval",
146            connectionRefreshInterval());
147        consoleModel.put("connectionInactivityTimeout",
148            connectionInactivityTimeout());
149        return consoleModel;
150    }
151
152    /**
153     * Invoked by {@link #renderConsole renderConsole} 
154     * to expand the {@link #createConsoleBaseModel()
155     * base model} with information from the current event.
156     *
157     * @param model the model
158     * @param event the event
159     * @param consoleConnectionId the console connection id
160     * @return the map
161     */
162    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
163    protected Map<String, Object> expandConsoleModel(
164            Map<String, Object> model, Request.In.Get event,
165            UUID consoleConnectionId) {
166        // WebConsole Connection UUID
167        model.put("consoleConnectionId", consoleConnectionId.toString());
168
169        // Add locale
170        final Locale locale = event.associated(Selection.class).map(
171            sel -> sel.get()[0]).orElse(Locale.getDefault());
172        model.put("locale", locale);
173
174        // Add supported languages
175        model.put("supportedLanguages", new TemplateMethodModelEx() {
176            private Object cachedResult;
177
178            @Override
179            public Object exec(@SuppressWarnings("rawtypes") List arguments)
180                    throws TemplateModelException {
181                if (cachedResult != null) {
182                    return cachedResult;
183                }
184                final Collator coll = Collator.getInstance(locale);
185                final Comparator<LanguageInfo> comp
186                    = new Comparator<LanguageInfo>() {
187                        @Override
188                        public int compare(LanguageInfo o1, LanguageInfo o2) { // NOPMD
189                            return coll.compare(o1.getLabel(), o2.getLabel());
190                        }
191                    };
192                return cachedResult = supportedLocales().entrySet().stream()
193                    .map(entry -> new LanguageInfo(entry.getKey(),
194                        entry.getValue()))
195                    .sorted(comp).toArray(size -> new LanguageInfo[size]);
196            }
197        });
198
199        final ResourceBundle baseResources = consoleResourceBundle(locale);
200        model.put("_", new TemplateMethodModelEx() {
201            @Override
202            public Object exec(@SuppressWarnings("rawtypes") List arguments)
203                    throws TemplateModelException {
204                @SuppressWarnings("unchecked")
205                List<TemplateModel> args = (List<TemplateModel>) arguments;
206                if (!(args.get(0) instanceof SimpleScalar)) {
207                    throw new TemplateModelException("Not a string.");
208                }
209                String key = ((SimpleScalar) args.get(0)).getAsString();
210                try {
211                    return baseResources.getString(key);
212                } catch (MissingResourceException e) { // NOPMD
213                    // no luck
214                }
215                return key;
216            }
217        });
218        return model;
219    }
220
221    /**
222     * Render the console page using the freemarker template 
223     * "console.ftl.html". The template for the console page
224     * (and all included templates) are loaded using the 
225     * a list of template class loaders created as follows:
226     * 
227     *  1. Start with the actual type of the console conlet.
228     *  
229     *  2. Using the current type, add a freemarker class template loader
230     *    {@link ClassTemplateLoader#ClassTemplateLoader(ClassLoader, String)}
231     *    that uses the package name as path (all dots replaced with
232     *    slashes).
233     *    
234     *  3. Repeat step 2 with the super class of the current type
235     *     until type {@link FreeMarkerConsoleWeblet} is reached.
236     *     
237     *  This approach allows a (base) console weblet to provide a
238     *  console page template that includes another template
239     *  e.g. "footer.ftl.html" to provide a specific part of the 
240     *  console page. A derived console weblet can then provide its
241     *  own "footer.ftl.html" and thus override the version from the
242     *  base class(es).
243     * 
244     * @param event the event
245     * @param channel the channel
246     * @throws IOException Signals that an I/O exception has occurred.
247     * @throws InterruptedException the interrupted exception
248     */
249    @Override
250    protected void renderConsole(Request.In.Get event, IOSubchannel channel,
251            UUID consoleConnectionId) throws IOException, InterruptedException {
252        event.setResult(true);
253        event.stop();
254
255        // Prepare response
256        HttpResponse response = event.httpRequest().response().get();
257        MediaType mediaType = MediaType.builder().setType("text", "html")
258            .setParameter("charset", UTF_8).build();
259        response.setField(HttpField.CONTENT_TYPE, mediaType);
260        response.setStatus(HttpStatus.OK);
261        response.setHasPayload(true);
262        channel.respond(new Response(response));
263        try (@SuppressWarnings("resource")
264        Writer out = new ByteBufferWriter(channel).suppressClose()) {
265            Template tpl = freeMarkerConfig.getTemplate("console.ftl.html");
266            Map<String, Object> consoleModel = expandConsoleModel(
267                createConsoleBaseModel(), event, consoleConnectionId);
268            tpl.process(consoleModel, out);
269        } catch (TemplateException e) {
270            throw new IOException(e);
271        }
272    }
273
274}