View Javadoc

1   package org.apache.velocity.tools.view;
2   
3   /*
4    * Licensed to the Apache Software Foundation (ASF) under one
5    * or more contributor license agreements.  See the NOTICE file
6    * distributed with this work for additional information
7    * regarding copyright ownership.  The ASF licenses this file
8    * to you under the Apache License, Version 2.0 (the
9    * "License"); you may not use this file except in compliance
10   * with the License.  You may obtain a copy of the License at
11   *
12   *  http://www.apache.org/licenses/LICENSE-2.0
13   *
14   * Unless required by applicable law or agreed to in writing,
15   * software distributed under the License is distributed on an
16   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17   * KIND, either express or implied.  See the License for the
18   * specific language governing permissions and limitations
19   * under the License.    
20   */
21  
22  import java.io.InputStream;
23  import java.io.IOException;
24  import java.net.URL;
25  import java.util.ArrayList;
26  import java.util.Collections;
27  import java.util.LinkedHashMap;
28  import java.util.List;
29  import java.util.Map;
30  import javax.servlet.ServletContext;
31  import javax.servlet.http.HttpServletRequest;
32  import org.xml.sax.Attributes;
33  import org.xml.sax.SAXException;
34  import org.apache.commons.digester.Digester;
35  import org.apache.commons.digester.Rule;
36  import org.apache.velocity.tools.ClassUtils;
37  import org.apache.velocity.tools.view.ViewContext;
38  import org.apache.velocity.runtime.log.Log;
39  import org.apache.velocity.tools.Scope;
40  import org.apache.velocity.tools.ToolContext;
41  import org.apache.velocity.tools.config.DefaultKey;
42  import org.apache.velocity.tools.config.ValidScope;
43  
44  /**
45   * <b>NOTE: This tool is considered "beta" quality due to lack of public testing
46   * and is not automatically provided via the default tools.xml file.
47   * </b>
48   *
49   * Tool to make it easier to manage usage of client-side dependencies.
50   * This is essentially a simple dependency system for javascript and css.
51   * This could be cleaned up to use fewer maps, use more classes,
52   * and cache formatted values, but this is good enough for now.
53   *
54   * To use it, create a ui.xml file at the root of the classpath.
55   * Follow the example below.  By default, it prepends the request context path
56   * and then "css/" to every stylesheet file and the request context path
57   * and "js/" to every javascript file path.  You can
58   * alter those defaults by changing the type definition. In the example
59   * below, the file path for the style type is changed to "/styles/", leaving out
60   * the {context}.
61   *
62   * This is safe in request scope, but the group info (from ui.xml)
63   * should only be read once.  It is not re-parsed on every request.
64   * <p>
65   * Example of use:
66   * <pre>
67   *  Template
68   *  ---
69   *  &lt;html&gt;
70   *    &lt;head&gt;
71   *      $depends.on('profile').print('
72   *      ')
73   *    &lt;/head&gt;
74   *  ...
75   *
76   *  Output
77   *  ------
78   *  &lt;html&gt;
79   *    &lt;head&gt;
80   *      &lt;style rel="stylesheet" type="text/css" href="css/globals.css"/&gt;
81   *      &lt;script type="text/javascript" src="js/jquery.js"&gt;&lt;/script&gt;
82   *      &lt;script type="text/javascript" src="js/profile.js"&gt;&lt;/script&gt;
83   *    &lt;/head&gt;
84   *  ...
85   *
86   * Example tools.xml:
87   * &lt;tools&gt;
88   *   &lt;toolbox scope="request"&gt;
89   *     &lt;tool class="org.apache.velocity.tools.view.beta.UiDependencyTool"/&gt;
90   *   &lt;/toolbox&gt;
91   * &lt;/tools&gt;
92   *
93   * Example ui.xml:
94   * &lt;ui&gt;
95   *   &lt;type name="style"&gt;&lt;![CDATA[&lt;link rel="stylesheet" type="text/css" href="/styles/{file}"&gt;]]&gt;&lt;/type&gt;
96   *   &lt;group name="globals"&gt;
97   *     &lt;file type="style"&gt;css/globals.css&lt;file/&gt;
98   *   &lt;/group&gt;
99   *   &lt;group name="jquery"&gt;
100  *     &lt;file type="script"&gt;js/jquery.js&lt;file/&gt;
101  *   &lt;/group&gt;
102  *   &lt;group name="profile"&gt;
103  *     &lt;needs&gt;globals&lt;/needs&gt;
104  *     &lt;needs&gt;jquery&lt;/needs&gt;
105  *     &lt;file type="script"&gt;js/profile.js&lt;file/&gt;
106  *   &lt;/group&gt;
107  * &lt;/ui&gt;
108  * </pre>
109  * </p>
110  *
111  * @author Nathan Bubna
112  * @version $Revision: 16660 $
113  */
114 @DefaultKey("depends")
115 @ValidScope(Scope.REQUEST)
116 public class UiDependencyTool {
117 
118     public static final String GROUPS_KEY_SPACE = UiDependencyTool.class.getName() + ":";
119     public static final String TYPES_KEY_SPACE = UiDependencyTool.class.getName() + ":types:";
120     public static final String SOURCE_FILE_KEY = "file";
121     public static final String DEFAULT_SOURCE_FILE = "ui.xml";
122     private static final List<Type> DEFAULT_TYPES;
123     static {
124         List<Type> types = new ArrayList<Type>();
125         // start out with these two types
126         types.add(new Type("style", "<link rel=\"stylesheet\" type=\"text/css\" href=\"{context}/css/{file}\"/>"));
127         types.add(new Type("script", "<script type=\"text/javascript\" src=\"{context}/js/{file}\"></script>"));
128         DEFAULT_TYPES = Collections.unmodifiableList(types);
129     }
130 
131     private Map<String,Group> groups = null;
132     private List<Type> types = DEFAULT_TYPES;
133     private Map<String,List<String>> dependencies;
134     private Log LOG;
135     private String context = "";
136 
137     private void debug(String msg, Object... args) {
138         if (LOG.isDebugEnabled()) {
139             LOG.debug(String.format("UiDependencyTool: "+msg, args));
140         }
141     }
142 
143     protected static final void trace(Log log, String msg, Object... args) {
144         if (log.isTraceEnabled()) {
145             log.trace(String.format("UiDependencyTool: "+msg, args));
146         }
147     }
148 
149     public void configure(Map params) {
150         ServletContext app = (ServletContext)params.get(ViewContext.SERVLET_CONTEXT_KEY);
151         LOG = (Log)params.get(ToolContext.LOG_KEY);
152 
153         HttpServletRequest request = (HttpServletRequest)params.get(ViewContext.REQUEST);
154         context = request.getContextPath();
155 
156         String file = (String)params.get(SOURCE_FILE_KEY);
157         if (file == null) {
158             file = DEFAULT_SOURCE_FILE;
159         } else {
160             debug("Loading file: %s", file);
161         }
162 
163         synchronized (app) {
164             // first, see if we've already read this file
165             groups = (Map<String,Group>)app.getAttribute(GROUPS_KEY_SPACE+file);
166             if (groups == null) {
167                 groups = new LinkedHashMap<String,Group>();
168                 // only require file presence, if one is specified
169                 read(file, (file != DEFAULT_SOURCE_FILE));
170                 app.setAttribute(GROUPS_KEY_SPACE+file, groups);
171                 if (types != DEFAULT_TYPES) {
172                     app.setAttribute(TYPES_KEY_SPACE+file, types);
173                 }
174             } else {
175                 // load any custom types too
176                 List<Type> alt = (List<Type>)app.getAttribute(TYPES_KEY_SPACE+file);
177                 if (alt != null) {
178                     types = alt;
179                 }
180             }
181         }
182     }
183 
184     /**
185      * Adds all the files required for the specified group, then returns
186      * this instance.  If the group name is null or no such group exists,
187      * this will return null to indicate the error.
188      */
189     public UiDependencyTool on(String name) {
190         Map<String,List<String>> groupDeps = getGroupDependencies(name);
191         if (groupDeps == null) {
192             return null;
193         } else {
194             addDependencies(groupDeps);
195             return this;
196         }
197     }
198 
199     /**
200      * Adds the specified file to this instance's list of dependencies
201      * of the specified type, then returns this instance.  If either the
202      * type or file are null, this will return null to indicate the error.
203      */
204     public UiDependencyTool on(String type, String file) {
205         if (type == null || file == null) {
206             return null;
207         } else {
208             addFile(type, file);
209             return this;
210         }
211     }
212 
213     /**
214      * Formats and prints all the current dependencies of this tool,
215      * using a new line in between the printed/formatted files.
216      */
217     public String print() {
218         return printAll("\n");
219     }
220 
221     /**
222      * If the parameter value is a known type, then this will
223      * format and print all of this instance's current dependencies of the
224      * specified type, using a new line in between the printed/formatted files.
225      * If the parameter value is NOT a known type, then this will treat it
226      * as a delimiter and print all of this instance's dependencies of all
227      * types, using the specified value as the delimiter in between the
228      * printed/formatted files.
229      * @see #print(String,String)
230      * @see #printAll(String)
231      */
232     public String print(String typeOrDelim) {
233         if (getType(typeOrDelim) == null) {
234             // then it's a delimiter
235             return printAll(typeOrDelim);
236         } else {
237             // then it's obviously a type
238             return print(typeOrDelim, "\n");
239         }
240     }
241 
242     /**
243      * Formats and prints all of this instance's current dependencies of the
244      * specified type, using the specified delimiter in between the
245      * printed/formatted files.
246      */
247     public String print(String type, String delim) {
248         List<String> files = getDependencies(type);
249         if (files == null) {
250             return null;
251         }
252 
253         String format = getFormat(type);
254         StringBuilder out = new StringBuilder();
255         for (String file : files) {
256             out.append(format(format, file));
257             out.append(delim);
258         }
259         return out.toString();
260     }
261 
262     /**
263      * Formats and prints all the current dependencies of this tool,
264      * using the specified delimiter in between the printed/formatted files.
265      */
266     public String printAll(String delim) {
267         if (dependencies == null) {
268             return null;
269         }
270 
271         StringBuilder out = new StringBuilder();
272         for (Type type : types) {
273             if (out.length() > 0) {
274                 out.append(delim);
275             }
276             List<String> files = dependencies.get(type.name);
277             if (files != null) {
278                 for (int i=0; i < files.size(); i++) {
279                     if (i > 0) {
280                         out.append(delim);
281                     }
282                     out.append(format(type.format, files.get(i)));
283                 }
284             }
285         }
286         return out.toString();
287     }
288 
289     /**
290      * Sets a custom {context} variable for the formats to use.
291      */
292     public UiDependencyTool context(String path)
293     {
294         this.context = path;
295         return this;
296     }
297 
298     /**
299      * Retrieves the configured format string for the specified file type.
300      */
301     public String getFormat(String type) {
302         Type t = getType(type);
303         if (t == null) {
304             return null;
305         }
306         return t.format;
307     }
308 
309     /**
310      * Sets the format string for the specified file type.
311      */
312     public void setFormat(String type, String format) {
313         if (format == null || type == null) {
314             throw new NullPointerException("Type name and format must not be null");
315         }
316         // do NOT alter the defaults, just copy them
317         if (types == DEFAULT_TYPES) {
318             types = new ArrayList<Type>();
319             for (Type t : DEFAULT_TYPES) {
320                 types.add(new Type(t.name, t.format));
321             }
322         }
323         Type t = getType(type);
324         if (t == null) {
325             types.add(new Type(type, format));
326         } else {
327             t.format = format;
328         }
329     }
330 
331     /**
332      * Returns the current dependencies of this instance, organized
333      * as an ordered map of file types to lists of the required files
334      * of that type.
335      */
336     public Map<String,List<String>> getDependencies() {
337         return dependencies;
338     }
339 
340     /**
341      * Returns the {@link List} of files for the specified file type, if any.
342      */
343     public List<String> getDependencies(String type) {
344         if (dependencies == null) {
345             return null;
346         }
347         return dependencies.get(type);
348     }
349 
350     /**
351      * Returns the dependencies of the specified group, organized
352      * as an ordered map of file types to lists of the required files
353      * of that type.
354      */
355     public Map<String,List<String>> getGroupDependencies(String name) {
356         Group group = getGroup(name);
357         if (group == null) {
358             return null;
359         }
360         return group.getDependencies(this);
361     }
362 
363     /**
364      * Returns an empty String to avoid polluting the template output after a
365      * successful call to {@link #on(String)} or {@link #on(String,String)}.
366      */
367     @Override
368     public String toString() {
369         return "";
370     }
371 
372 
373     /**
374      * Reads group info out of the specified file and into this instance.
375      * If the file cannot be found and required is true, then this will throw
376      * an IllegalArgumentException.  Otherwise, it will simply do nothing. Any
377      * checked exceptions during the actual reading of the file are caught and
378      * wrapped as {@link RuntimeException}s.
379      */
380     protected void read(String file, boolean required) {
381         debug("UiDependencyTool: Reading file from %s", file);
382         URL url = toURL(file);
383         if (url == null) {
384             String msg = "UiDependencyTool: Could not read file from '"+file+"'";
385             if (required) {
386                 LOG.error(msg);
387                 throw new IllegalArgumentException(msg);
388             } else {
389                 LOG.debug(msg);
390             }
391         } else {
392             Digester digester = createDigester();
393             try
394             {
395                 digester.parse(url.openStream());
396             }
397             catch (SAXException saxe)
398             {
399                 LOG.error("UiDependencyTool: Failed to parse '"+file+"'", saxe);
400                 throw new RuntimeException("While parsing the InputStream", saxe);
401             }
402             catch (IOException ioe)
403             {
404                 LOG.error("UiDependencyTool: Failed to read '"+file+"'", ioe);
405                 throw new RuntimeException("While handling the InputStream", ioe);
406             }
407         }
408     }
409 
410     /**
411      * Creates the {@link Digester} used by {@link #read} to create
412      * the group info for this instance out of the specified XML file.
413      */
414     protected Digester createDigester() {
415         Digester digester = new Digester();
416         digester.setValidating(false);
417         digester.setUseContextClassLoader(true);
418         digester.addRule("ui/type", new TypeRule());
419         digester.addRule("ui/group", new GroupRule());
420         digester.addRule("ui/group/file", new FileRule());
421         digester.addRule("ui/group/needs", new NeedsRule());
422         digester.push(this);
423         return digester;
424     }
425 
426     /**
427      * Applies the format string to the given value.  Currently,
428      * this simply replaces '{file}' with the value.  If you
429      * want to handle more complicated formats, override this method.
430      */
431     protected String format(String format, String value) {
432         if (format == null) {
433             return value;
434         }
435         return format.replace("{file}", value).replace("{context}", this.context);
436     }
437 
438     /**
439      * NOTE: This method may change or disappear w/o warning; don't depend
440      * on it unless you're willing to update your code whenever this changes.
441      */
442     protected Group getGroup(String name) {
443         if (groups == null) {
444             return null;
445         }
446         return groups.get(name);
447     }
448 
449     /**
450      * NOTE: This method may change or disappear w/o warning; don't depend
451      * on it unless you're willing to update your code whenever this changes.
452      */
453     protected Group makeGroup(String name) {
454         trace(LOG, "Creating group '%s'", name);
455         Group group = new Group(name, LOG);
456         groups.put(name, group);
457         return group;
458     }
459 
460     /**
461      * Adds the specified files organized by type to this instance's
462      * current dependencies.
463      */
464     protected void addDependencies(Map<String,List<String>> fbt) {
465         if (this.dependencies == null) {
466             dependencies = new LinkedHashMap<String,List<String>>(fbt.size());
467         }
468         for (Map.Entry<String,List<String>> entry : fbt.entrySet()) {
469             String type = entry.getKey();
470             if (getType(type) == null) {
471                 LOG.error("UiDependencyTool: Type '"+type+"' is unknown and will not be printed unless defined.");
472             }
473             List<String> existing = dependencies.get(type);
474             if (existing == null) {
475                 existing =  new ArrayList<String>(entry.getValue().size());
476                 dependencies.put(type, existing);
477             }
478             for (String file : entry.getValue()) {
479                 if (!existing.contains(file)) {
480                     trace(LOG, "Adding %s: %s", type, file);
481                     existing.add(file);
482                 }
483             }
484         }
485     }
486 
487     /**
488      * Adds a file to this instance's dependencies under the specified type.
489      */
490     protected void addFile(String type, String file) {
491         List<String> files = null;
492         if (dependencies == null) {
493             dependencies = new LinkedHashMap<String,List<String>>(types.size());
494         } else {
495             files = dependencies.get(type);
496         }
497         if (files == null) {
498             files = new ArrayList<String>();
499             dependencies.put(type, files);
500         }
501         if (!files.contains(file)) {
502             trace(LOG, "Adding %s: %s", type, file);
503             files.add(file);
504         }
505     }
506 
507 
508     /**
509      * For internal use only. Use/override get/setFormat instead.
510      */
511     private Type getType(String type) {
512         for (Type t : types) {
513             if (t.name.equals(type)) {
514                 return t;
515             }
516         }
517         return null;
518     }
519 
520     //TODO: replace this method with ConversionUtils.toURL(file, this)
521     //      once VelocityTools 2.0-beta3 or 2.0 final is released.
522     private URL toURL(String file) {
523         try
524         {
525             return ClassUtils.getResource(file, this);
526         }
527         catch (Exception e) {
528             return null;
529         }
530     }
531 
532 
533     /**
534      * NOTE: This class may change or disappear w/o warning; don't depend
535      * on it unless you're willing to update your code whenever this changes.
536      */
537     protected static class Group {
538 
539         private volatile boolean resolved = true;
540         private String name;
541         private Map<String,Integer> typeCounts = new LinkedHashMap<String,Integer>();
542         private Map<String,List<String>> dependencies = new LinkedHashMap<String,List<String>>();
543         private List<String> groups;
544         private Log LOG;
545 
546         public Group(String name, Log log) {
547             this.name = name;
548             this.LOG = log;
549         }
550 
551         private void trace(String msg, Object... args) {
552             if (LOG.isTraceEnabled()) {
553                 UiDependencyTool.trace(LOG, "Group "+name+": "+msg, args);
554             }
555         }
556 
557         public void addFile(String type, String value) {
558             List<String> files = dependencies.get(type);
559             if (files == null) {
560                 files = new ArrayList<String>();
561                 dependencies.put(type, files);
562             }
563             if (!files.contains(value)) {
564                 trace("Adding %s: %s", type, value);
565                 files.add(value);
566             }
567         }
568 
569         public void addGroup(String group) {
570             if (this.groups == null) {
571                 this.resolved = false;
572                 this.groups = new ArrayList<String>();
573             }
574             if (!this.groups.contains(group)) {
575                 trace("Adding group %s", group, name);
576                 this.groups.add(group);
577             }
578         }
579 
580         public Map<String,List<String>> getDependencies(UiDependencyTool parent) {
581             resolve(parent);
582             return this.dependencies;
583         }
584 
585         protected void resolve(UiDependencyTool parent) {
586             if (!resolved)  {
587                 // mark first to keep circular from becoming infinite
588                 resolved = true;
589                 trace("resolving...");
590                 for (String name : groups) {
591                     Group group = parent.getGroup(name);
592                     if (group == null) {
593                         throw new NullPointerException("No group named '"+name+"'");
594                     }
595                     Map<String,List<String>> dependencies = group.getDependencies(parent);
596                     for (Map.Entry<String,List<String>> type : dependencies.entrySet()) {
597                         for (String value : type.getValue()) {
598                             addFileFromGroup(type.getKey(), value);
599                         }
600                     }
601                 }
602                 trace(" is resolved.");
603             }
604         }
605 
606         private void addFileFromGroup(String type, String value) {
607             List<String> files = dependencies.get(type);
608             if (files == null) {
609                 files = new ArrayList<String>();
610                 files.add(value);
611                 trace("adding %s '%s' first", type, value);
612                 dependencies.put(type, files);
613                 typeCounts.put(type, 1);
614             } else if (!files.contains(value)) {
615                 Integer count = typeCounts.get(type);
616                 if (count == null) {
617                     count = 0;
618                 }
619                 files.add(count, value);
620                 trace("adding %s '%s' at %s", type, value, count);
621                 typeCounts.put(type, ++count);
622             }
623         }
624     }
625 
626     /**
627      * NOTE: This class may change or disappear w/o warning; don't depend
628      * on it unless you're willing to update your code whenever this changes.
629      */
630     protected static class TypeRule extends Rule {
631 
632         private UiDependencyTool parent;
633 
634         public void begin(String ns, String el, Attributes attributes) throws Exception {
635             parent = (UiDependencyTool)digester.peek();
636 
637             for (int i=0; i < attributes.getLength(); i++) {
638                 String name = attributes.getLocalName(i);
639                 if ("".equals(name)) {
640                     name = attributes.getQName(i);
641                 }
642                 if ("name".equals(name)) {
643                     digester.push(attributes.getValue(i));
644                 }
645             }
646         }
647 
648         public void body(String ns, String el, String typeFormat) throws Exception {
649             String typeName = (String)digester.pop();
650             parent.setFormat(typeName, typeFormat);
651         }
652     }
653 
654     /**
655      * NOTE: This class may change or disappear w/o warning; don't depend
656      * on it unless you're willing to update your code whenever this changes.
657      */
658     protected static class GroupRule extends Rule {
659 
660         private UiDependencyTool parent;
661 
662         public void begin(String ns, String el, Attributes attributes) throws Exception {
663             parent = (UiDependencyTool)digester.peek();
664 
665             for (int i=0; i < attributes.getLength(); i++) {
666                 String name = attributes.getLocalName(i);
667                 if ("".equals(name)) {
668                     name = attributes.getQName(i);
669                 }
670                 if ("name".equals(name)) {
671                     digester.push(parent.makeGroup(attributes.getValue(i)));
672                 }
673             }
674         }
675 
676         public void end(String ns, String el) throws Exception {
677             digester.pop();
678         }
679     }
680 
681     /**
682      * NOTE: This class may change or disappear w/o warning; don't depend
683      * on it unless you're willing to update your code whenever this changes.
684      */
685     protected static class FileRule extends Rule {
686 
687         public void begin(String ns, String el, Attributes attributes) throws Exception {
688             for (int i=0; i < attributes.getLength(); i++) {
689                 String name = attributes.getLocalName(i);
690                 if ("".equals(name)) {
691                     name = attributes.getQName(i);
692                 }
693                 if ("type".equals(name)) {
694                     digester.push(attributes.getValue(i));
695                 }
696             }
697         }
698 
699         public void body(String ns, String el, String value) throws Exception {
700             String type = (String)digester.pop();
701             Group group = (Group)digester.peek();
702             group.addFile(type, value);
703         }
704     }
705 
706     /**
707      * NOTE: This class may change or disappear w/o warning; don't depend
708      * on it unless you're willing to update your code whenever this changes.
709      */
710     protected static class NeedsRule extends Rule {
711 
712         public void body(String ns, String el, String otherGroup) throws Exception {
713             Group group = (Group)digester.peek();
714             group.addGroup(otherGroup);
715         }
716     }
717 
718 
719     private static final class Type {
720 
721         protected String name;
722         protected String format;
723 
724         Type(String n, String f) {
725             name = n;
726             format = f;
727         }
728     }
729 
730 }