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