classes/hirondelle/web4j/readconfig/ConfigReader.java
author Tomas Zeman <tzeman@volny.cz>
Wed, 04 Dec 2013 17:00:31 +0100
changeset 0 3060119b1292
permissions -rw-r--r--
Imported web4j 4.10.0

package hirondelle.web4j.readconfig;

import java.util.*;
import java.util.logging.*;
import java.io.*;
import java.util.regex.*;
import java.lang.reflect.*;
import javax.servlet.ServletContext;

import hirondelle.web4j.util.Args;
import hirondelle.web4j.util.Util;

/**
 (UNPUBLISHED) Reads text files (having specific formats) located under the <tt>WEB-INF</tt> directory, 
 and returns their contents as {@link Properties}.
 
 <P>In addition, this class returns {@link Set}s of {@link Class}es that are present under
 <tt>/WEB-INF/classes</tt>. This unusual policy allows the caller to use reflection upon 
 such classes, to extract what would otherwise be configuration information. For example, the 
 default implementation of {@link hirondelle.web4j.request.RequestParser} uses this technique to 
 automatically map request URIs to concrete implementations of {@link hirondelle.web4j.action.Action}.
 Thus, the action implementor can configure when the action is called simply by adding a 
 field satisfying some simple conventions.

<P><em>Design Note</em>: In addition to the configuration facilities present in 
 <tt>web.xml</tt>, it is sometimes useful to use properties files (see {@link Properties}), 
 or similar items. Such files may be placed in the same directory as the class 
 which uses them, but, in a web application, there is also the option of placing 
 such files in the <tt>WEB-INF</tt> directory. 
*/
public final class ConfigReader {
  
  /**  Must be called upon startup in a web container. */
  public static void init(ServletContext aContext){
    fContext = aContext;
  }
  
  /** Type-safe enumeration for the kinds of text file supported by {@link ConfigReader}.  */
  public enum FileType {
    
    /**
     Standard java <tt>.properties</tt> file. 
     See {@link Properties} for more information.
    */
    PROPERTIES_FILE,
    
    /**
     Text file containing blocks of text, constants, and comments. 
     <P>The {@link hirondelle.web4j.database.SqlStatement} class uses this style for 
     underlying SQL statements, but it may be used for any similar case,  
     where the format specified below is adequate.
    
     <P>The following terminology is used here :
     <ul>
     <li>Block - a multiline block of text with within braces, with an associated 
     identifier (similar to a typical Java block) 
     <li>Block Name - the identifier of a block, appearing on the first line, before the 
     opening brace
     <li>Block Body - the text appearing between the opening and closing braces. One
     advantage of using this file type, instead of a 
     {@link ConfigReader.FileType#PROPERTIES_FILE}, is 
     that no line continuation characters are needed. 
     </ul>
     
     <P>Example of a typical <tt>TEXT_BLOCK</tt> file (using  
     SQL statements for Block Bodies) : 
     <PRE>
    -- This is an example comment.
    -- This item exercises slight textual variations.
    -- The Block Name here is 'ADD_MESSAGE'.
     ADD_MESSAGE  {
     INSERT INTO MyMessage -- another example comment
      (LoginName, Body, CreationDate)
      -- this line and the following line are also commented out
      -- VALUES (?,?,?)
      VALUES (?,?,?)
     }
    
    -- Here, 'constants' is a reserved Block Name.
    -- Any number of 'constants' blocks can be defined, anywhere 
    -- in the file. Such constants must be defined before being
    -- referenced in a Block Body, however.
    constants {
      num_messages_to_view = 5
    }
    
    -- Example of referring to a constant defined above.
    FETCH_RECENT_MESSAGES {
     SELECT 
     LoginName, Body, CreationDate 
     FROM MyMessage 
     ORDER BY Id DESC LIMIT ${num_messages_to_view}
    }
    
    FETCH_NUM_MSGS_FOR_USER {
     SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
    }
    
    -- Browse Messages according to various criteria.
    -- Constants block used here just to demonstrate it 
    -- is possible.
    constants {
      -- Each constant must appear on a *single line* only, which
      -- is not the best for long SQL statements.
      -- Typically, if a previously defined *sub-query* is needed, 
      -- then a SQL statment may simply refer directly to that previously 
      -- defined SQL statement. (See oracle.sql in the example application.)
      base_query = SELECT Id, LoginName, Body, CreationDate FROM MyMessage 
    }
    
    BROWSE_MESSAGES {
     ${base_query}
     ORDER BY {0} {1}
    }
    
    BROWSE_MESSAGES_WITH_FILTER {
     ${base_query}
     WHERE {0} LIKE '{1}%' 
     ORDER BY {2} {3}
    }
     </PRE> 
     
     <P>The format details are as follows :
    <ul>
     <li>empty lines can appear only outside of blocks
     <li>the '<tt>--</tt>' character denotes a comment 
     <li>there are no multiline comments. (Such comments are easier for the writer, 
     but are much less clear for the reader.) 
     <li>the body of each item is placed in a named block, bounded by 
     '<tt>&lt;name&gt; {</tt>' on an initial line, and '<tt>}</tt>' on an end line.
     The name given to the the block (<tt>ADD_MESSAGE</tt> for example) is how 
     <tt>WEB4J</tt> identifies each block. The Block Name may correspond one to 
     one with some item in code. For SQL statements, the Block Name must correspond 
     to a <tt>public static final SqlId</tt> field. Upon startup, this allows verification 
     that all items defined in the text source have a corresponding item in code.
     <li>'<tt>--</tt>' comments can appear in the Block Body as well
     <li>if desired, the Block Body may be indented, to make the Block more legible
     <li>the Block Name of '<tt>constants</tt>' is reserved. A <tt>constants</tt> 
     block defines one or more simple textual substitution constants, as 
     <tt>name = value</tt> pairs, one per line, that may be 
     referenced later on in the file (see example). They are defined only to allow 
     such substitution to occur <em>later</em> in the file. Any number of <tt>constants</tt> 
     blocks can appear in a file. 
     <li>Block Names and the names of constants satisfy 
     {@link hirondelle.web4j.util.Regex#SIMPLE_SCOPED_IDENTIFIER}.
     <li>inside a Block Body, substitutions are denoted by the common 
     syntax '<tt>${blah}</tt>', where <tt>blah</tt> refers to an item appearing 
     earlier in the file, either the content 
     of a previously defined Block, or the value of a constant defined in a 
     <tt>constants</tt> Block 
     <li>an item must be defined before it can be used in a substitution ; that is, 
     it must appear earlier in the file
     <li>no substitutions are permitted in a <tt>constants</tt> Block
     <li>Block Names, and the names of constants, should be unique.
    </ul>
    */
    TEXT_BLOCK,
  }

  /**
   Fetch a single, specific file located in the <tt>WEB-INF</tt> 
   directory, and populate a corresponding <tt>Properties</tt> object.
  
   @param aConfigFileName has content and is the "simple" name (without path info)
   of a readable file in the <tt>WEB-INF</tt> directory (for example, <tt>"config.properties"</tt> or 
   <tt>"statements.sql"</tt>).
  */
  public static Properties fetch(String aConfigFileName, FileType aFileType){
    return basicFetch(fWEBINF + aConfigFileName, ! FOR_TESTING, aFileType);
  }
  
  /**
   Fetch all config files located anywhere under the <tt>WEB-INF</tt> directory whose 
   "simple" file name (without path info) matches <tt>aFileNamePattern</tt>, and 
   translate their content into a single {@link Properties} object.
  
   <P>If the caller needs only one file of a specific name, and that file is  
   located in the <tt>WEB-INF</tt> directory itself, then use {@link #fetch} instead.
  
   <P><span class="highlight">The keys in all of the matching files should be unique.</span>
   If a duplicate key exists, then the second instance will overwrite the first (as in
   {@link HashMap#put}), and a <tt>SEVERE</tt> warning is logged.
  
   <P><em>Design Note:</em><br>
   This method was initially created to reduce contention for the <tt>*.sql</tt> file, 
   on projects having more than one developer. See 
   {@link hirondelle.web4j.database.SqlStatement} and the example <tt>.sql</tt> files for 
   more information.
  
   @param aFileNamePattern regular expression for the desired file name, without path 
   information
   @param aConfigFileType identifies the layout style of the config file
  */
  public static Properties fetchMany(Pattern aFileNamePattern, FileType aConfigFileType){
    Properties result = new Properties();
    Set<String> allFilePaths = getFilePathsBelow(fWEBINF);
    Set<String> matchingFilePaths = matchTargetPattern(allFilePaths, aFileNamePattern);
    fLogger.config(
      "Desired configuration files under /WEB-INF/: " + Util.logOnePerLine(matchingFilePaths)
    );
    for (String matchingFilePath : matchingFilePaths){
      Properties properties = basicFetch(matchingFilePath, ! FOR_TESTING, aConfigFileType);
      addProperties(properties, result);
    }
    fLogger.config(
      "Total number of distinct keys in configuration files : " + result.keySet().size()
    );
    logKeysFromManyFiles(result);
    return result;
  }
  
  /**
   <em>Intended for testing only</em>, outside of a web container environment.
  
   <P>Fetches properties not from a file in the WEB-INF directory, 
   but from a hard-coded absolute file name.
   
   @param aConfigFileName absolute file name, including path information
  */
  public static Properties fetchForTesting(String aConfigFileName, FileType aFileType){
    return basicFetch(aConfigFileName, FOR_TESTING, aFileType);
  }
  
  /** 
   Return a possibly-empty {@link Set} of {@link Class} objects, for all concrete classes 
   under <tt>/WEB-INF/classes/</tt> that implement <tt>aInterface</tt>.
   
   <P>More specifically, the classes returned here are 
   not <tt>abstract</tt>, and not an <tt>interface</tt>. Only such classes 
   should be of interest to the caller, since only such classes can be instantiated.
   It is expected that almost all classes in an application will be concrete.
  */
  public static <T> Set<Class<T>> fetchConcreteClassesThatImplement(Class<T> aInterface){
    if(aInterface != null){
      fLogger.config("Fetching concrete classes that implement " + aInterface);
    }
    else {
      fLogger.config("Fetching all concrete classes.");
    }
    Set<Class<T>> result = new LinkedHashSet<Class<T>>();
    Set<String> allFilePaths = getFilePathsBelow(fWEBINF_CLASSES);
    Set<String> classFilePaths = matchTargetPattern(allFilePaths, CLASS_FILES);
    for (String classFilePath: classFilePaths){
      String className = classFilePath.replace('/','.');
      if( className.endsWith("package-info.class")){
        fLogger.finest("Ignoring package-info.class.");
      }
      else {
        className = className.substring(fWEBINF_CLASSES.length());
        className = className.substring(0,className.lastIndexOf(fDOT_CLASS));
        try {
          Class<T> thisClass = (Class<T>)Class.forName(className); //cast OK
          if ( isConcrete(thisClass) ){
            if(aInterface == null){
              result.add(thisClass);
            }
            else {
              if(aInterface.isAssignableFrom(thisClass)){
                result.add(thisClass);
              }
            }
          }
        }
        catch(ClassNotFoundException ex){
          fLogger.severe("Cannot load class using name : " + className);
        }
      }
    }
    return result;
  }
  
  /**
   As in {@link #fetchConcreteClassesThatImplement(Class)}, but with no test for implementing 
   a specific interface. 
  */
  public static Set fetchConcreteClasses(){
    return fetchConcreteClassesThatImplement(null);
  }
  
  /**
   Return all <tt>public static final</tt> fields declared in an application of type <tt>aFieldClass</tt>.
   
   <P>Restrict the search to concrete classes that implement an interface <tt>aContainingClassInterface</tt>,
   as in {@link #fetchConcreteClassesThatImplement(Class)}. If <tt>aContainingClassInterface</tt> is 
   <tt>null</tt>, then scan all classes for such fields.
   
   <P>Return a possibly-empty {@link Map} having :
   <br>KEY - Class object of class from which the field is visible; see {@link Class#getFields()}. 
   <br>VALUE - {@link Set} of objects of class aFieldClass
  */
  public static /*T(Contain),V(field)*/ Map/*<Class<T>, Set<V>>*/ fetchPublicStaticFinalFields(Class aContainingClassInterface, Class aFieldClass){
    //see AppFirewallImpl for example
    if(aContainingClassInterface == null){
      fLogger.config("Fetching public static final fields of " + aFieldClass + ", from all concrete classes.");
    }
    else {
      fLogger.config("Fetching public static final fields of " + aFieldClass + ", from concrete classes that implement " + aContainingClassInterface);
    }
    Map result = new LinkedHashMap();
    Set classesToScan = null;
    if(aContainingClassInterface == null){
      classesToScan = fetchConcreteClasses();
    }
    else {
      classesToScan = fetchConcreteClassesThatImplement(aContainingClassInterface);
    }
    Iterator iter = classesToScan.iterator();
    while (iter.hasNext()){
      Class thisClass = (Class)iter.next();
      result.put(thisClass, getFields(thisClass, aFieldClass));
    }
    return result;
  }

  /**
   As in {@link #fetchPublicStaticFinalFields(Class, Class)}, but with <tt>null</tt> value 
   for <tt>aContainingClassInterface</tt>.
  */
  public static Map fetchPublicStaticFinalFields(Class aFieldClass){
    return fetchPublicStaticFinalFields(null, aFieldClass);
  }
  
  /**
   Process all the raw <tt>.sql</tt> text data into a consolidated Map of SqlId to SQL statements.
   @param aRawSql - the raw, unprocessed content of each <tt>.sql</tt> file (or data)
  */
  public static Map<String, String> processRawSql(List<String> aRawSql){
    Map<String, String> result = new LinkedHashMap<String, String>();
    for(String rawSqlFile : aRawSql){
      result.putAll(processedSql(rawSqlFile));
    }
    return result;
  }
  
  // PRIVATE
  
  /** This field requires the servlet jar to be present. It will be init-ed when this class is loaded (?). */
  private static ServletContext fContext;
  
  private static final boolean FOR_TESTING = true;
  
  private static final String fWEBINF = "/WEB-INF/";
  private static final String fWEBINF_CLASSES = "/WEB-INF/classes/";
  
  private static final Pattern CLASS_FILES = Pattern.compile("(?:.)*\\.class");
  private static final String fSLASH = "/";
  private static final String fDOT_CLASS = ".class";
  private static final Logger fLogger = Util.getLogger(ConfigReader.class);
  
  private ConfigReader (){
    //prevent construction
  } 

  /**
   Return a <tt>Properties</tt> which reflects the contents of the 
   given config file located under WEB-INF.
  */
  private static Properties basicFetch(String aConfigFilePath, boolean aIsTesting, FileType aFileType){
    Args.checkForContent(aConfigFilePath);
    if (  aIsTesting ) {
      checkIsAbsolute(aConfigFilePath);
    }
    Properties result = new Properties();
    InputStream input = null;
    try {
      if ( ! aIsTesting ) {
        input = fContext.getResourceAsStream(aConfigFilePath);
      }
      else {
        input = new FileInputStream(aConfigFilePath);
      }
      if (FileType.PROPERTIES_FILE == aFileType) {
        result.load(input);
      }
      else {
        result = loadTextBlockFile(input, aConfigFilePath);
      }
    }
    catch (IOException ex){
      vomit(aConfigFilePath, aIsTesting);
    }
    finally {
      shutdown(input);
    }
    fLogger.finest("Number of keys in properties object : " + result.keySet().size());
    return result;
  }
  
  private static void checkIsAbsolute(String aConfigFileName){
    if ( !isAbsolute(aConfigFileName) ) {
      throw new IllegalArgumentException(
        "Configuration file name is not absolute, "+ aConfigFileName
      );
    }
  }
  
  private static boolean isAbsolute(String aFileName){
    File file = new File(aFileName);
    return file.isAbsolute();
  }
  
  private static void shutdown(InputStream aInput){
    try {
      if (aInput != null) aInput.close();
    }
    catch (IOException ex ){
      throw new IllegalStateException("Cannot close config file in WEB-INF directory.");
    }
  }
  
  private static void vomit(String aConfigFileName, boolean aIsTesting){
    String message = null;
    if ( ! aIsTesting ){
      message = (
        "Cannot open and load configuration file from WEB-INF directory: " + 
        Util.quote(aConfigFileName)
      );
    }
    else {
      message = (
        "Cannot open and load configuration file named " + 
        Util.quote(aConfigFileName)
      );
    }
    throw new IllegalStateException(message);
  }
  
  /**
   Return a Set of Strings starting with <tt>aStartDirectory</tt> and containing the full 
   file path for all files under <tt>aStartDirectory</tt>. No sorting is performed.
   
   @param aStartDirectory starts with <tt>/WEB-INF/</tt>
  */
  private static Set<String> getFilePathsBelow(String aStartDirectory){
    Set<String> result = new LinkedHashSet<String>();
    Set<String> paths = fContext.getResourcePaths(aStartDirectory);
    for ( String path : paths) {
      if ( isDirectory(path) ) {
        //recursive call !!!
        result.addAll(getFilePathsBelow(path));
      }
      else {
        result.add(path);
      }
    }
    return result;
  }
  
  /**
   Return a Set of paths (as Strings), starting with "/WEB-INF/", for files under 
   <tt>WEB-INF</tt> whose simple file name (<em>without</em> the path info) 
   matches <tt>aFileNamePattern</tt>.
  
   @param aFullFilePaths Set of Strings starting with "<tt>/WEB-INF/</tt>", which 
   denote paths to <b>all</b> files under the <tt>WEB-INF</tt> directory (recursive) 
  */  
  private static Set<String> matchTargetPattern(Set<String> aFullFilePaths, Pattern aFileNamePattern) {
    Set<String> result = new LinkedHashSet<String>();
    for ( String fullFilePath : aFullFilePaths){
      int lastSlash = fullFilePath.lastIndexOf(fSLASH);
      assert(lastSlash != -1);
      if ( ! isDirectory(fullFilePath) ){
        String simpleFileName = fullFilePath.substring(lastSlash + 1);
        Matcher matcher = aFileNamePattern.matcher(simpleFileName);
        if ( matcher.matches() ) {
          result.add(fullFilePath);
        }
      }
    }
    return result;
  }
  
  private static boolean isDirectory(String aFullFilePath){
    return aFullFilePath.endsWith(fSLASH); 
  }
  
  /**
   Add all key-value pairs in <tt>aProperties</tt> to <tt>aResult</tt>.
  
   <P>If duplicate key is found, replaces old with new, and log the occurrence.
  */
  private static void addProperties(Properties aProperties, Properties aResult){
    Enumeration keys = aProperties.propertyNames();
    while ( keys.hasMoreElements() ) {
      String key = (String)keys.nextElement();
      if ( aResult.containsKey(key) ) {
        fLogger.severe(
          "WARNING. Same key found in more than one configuration file: " +Util.quote(key)+
          "This condition almost always indicates an error. " +
          "Overwriting old key-value pair with new key-value pair."
        );
      }
      String value = aProperties.getProperty(key);
      aResult.setProperty(key, value);
    }  
  }
  
  /**
   Log all key names in <tt>aProperties</tt>, in alphabetical order.
  */
  private static void logKeysFromManyFiles(Properties aProperties){
    SortedSet sortedKeys = new TreeSet(aProperties.keySet());
    fLogger.config(Util.logOnePerLine(sortedKeys));
  }
  
  private static Properties loadTextBlockFile(InputStream aInput,String aSqlFileName) throws IOException {
    TextBlockReader sqlReader = new TextBlockReader(aInput, aSqlFileName);
    return sqlReader.read(); 
  }
  
  private static boolean isConcrete(Class aClass){
    int modifiers = aClass.getModifiers();
    return 
      ! Modifier.isInterface(modifiers) && 
      ! Modifier.isAbstract(modifiers) 
    ;  
  }
  
  private static Set getFields(Class aContainingClass, Class aFieldClass){
    Set result = new LinkedHashSet();
    List fields = Arrays.asList(aContainingClass.getFields());
    Iterator fieldsIter = fields.iterator();
    while (fieldsIter.hasNext()){
      Field field = (Field)fieldsIter.next();
      if( field.getType() == aFieldClass ){
        int modifiers = field.getModifiers();
        if(Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers)){
          try {
            result.add(field.get(null));
          }
          catch (IllegalAccessException ex){
            fLogger.severe("Cannot get value of public static final field in " + aContainingClass);
          }
        }
      }
    }
    return result;
  }
  
  private static Map<String, String> processedSql(String aRawSql){
    TextBlockReader textBlockReader = new TextBlockReader(aRawSql);
    Properties props = null;
    try {
      props = textBlockReader.read();
    }
    catch (IOException ex) {
      ex.printStackTrace();
    }
    return new LinkedHashMap(props);
  }
}