diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/ConfigReader.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/ConfigReader.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,551 @@ +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 WEB-INF directory, + and returns their contents as {@link Properties}. + +

In addition, this class returns {@link Set}s of {@link Class}es that are present under + /WEB-INF/classes. 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. + +

Design Note: In addition to the configuration facilities present in + web.xml, 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 WEB-INF 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 .properties file. + See {@link Properties} for more information. + */ + PROPERTIES_FILE, + + /** + Text file containing blocks of text, constants, and comments. +

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. + +

The following terminology is used here : +

+ +

Example of a typical TEXT_BLOCK file (using + SQL statements for Block Bodies) : +

+    -- 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}
+    }
+     
+ +

The format details are as follows : +

+ */ + TEXT_BLOCK, + } + + /** + Fetch a single, specific file located in the WEB-INF + directory, and populate a corresponding Properties object. + + @param aConfigFileName has content and is the "simple" name (without path info) + of a readable file in the WEB-INF directory (for example, "config.properties" or + "statements.sql"). + */ + public static Properties fetch(String aConfigFileName, FileType aFileType){ + return basicFetch(fWEBINF + aConfigFileName, ! FOR_TESTING, aFileType); + } + + /** + Fetch all config files located anywhere under the WEB-INF directory whose + "simple" file name (without path info) matches aFileNamePattern, and + translate their content into a single {@link Properties} object. + +

If the caller needs only one file of a specific name, and that file is + located in the WEB-INF directory itself, then use {@link #fetch} instead. + +

The keys in all of the matching files should be unique. + If a duplicate key exists, then the second instance will overwrite the first (as in + {@link HashMap#put}), and a SEVERE warning is logged. + +

Design Note:
+ This method was initially created to reduce contention for the *.sql file, + on projects having more than one developer. See + {@link hirondelle.web4j.database.SqlStatement} and the example .sql 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 allFilePaths = getFilePathsBelow(fWEBINF); + Set 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; + } + + /** + Intended for testing only, outside of a web container environment. + +

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 /WEB-INF/classes/ that implement aInterface. + +

More specifically, the classes returned here are + not abstract, and not an interface. 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 Set> fetchConcreteClassesThatImplement(Class aInterface){ + if(aInterface != null){ + fLogger.config("Fetching concrete classes that implement " + aInterface); + } + else { + fLogger.config("Fetching all concrete classes."); + } + Set> result = new LinkedHashSet>(); + Set allFilePaths = getFilePathsBelow(fWEBINF_CLASSES); + Set 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 thisClass = (Class)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 public static final fields declared in an application of type aFieldClass. + +

Restrict the search to concrete classes that implement an interface aContainingClassInterface, + as in {@link #fetchConcreteClassesThatImplement(Class)}. If aContainingClassInterface is + null, then scan all classes for such fields. + +

Return a possibly-empty {@link Map} having : +
KEY - Class object of class from which the field is visible; see {@link Class#getFields()}. +
VALUE - {@link Set} of objects of class aFieldClass + */ + public static /*T(Contain),V(field)*/ Map/*, Set>*/ 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 null value + for aContainingClassInterface. + */ + public static Map fetchPublicStaticFinalFields(Class aFieldClass){ + return fetchPublicStaticFinalFields(null, aFieldClass); + } + + /** + Process all the raw .sql text data into a consolidated Map of SqlId to SQL statements. + @param aRawSql - the raw, unprocessed content of each .sql file (or data) + */ + public static Map processRawSql(List aRawSql){ + Map result = new LinkedHashMap(); + 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 Properties 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 aStartDirectory and containing the full + file path for all files under aStartDirectory. No sorting is performed. + + @param aStartDirectory starts with /WEB-INF/ + */ + private static Set getFilePathsBelow(String aStartDirectory){ + Set result = new LinkedHashSet(); + Set 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 + WEB-INF whose simple file name (without the path info) + matches aFileNamePattern. + + @param aFullFilePaths Set of Strings starting with "/WEB-INF/", which + denote paths to all files under the WEB-INF directory (recursive) + */ + private static Set matchTargetPattern(Set aFullFilePaths, Pattern aFileNamePattern) { + Set result = new LinkedHashSet(); + 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 aProperties to aResult. + +

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 aProperties, 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 processedSql(String aRawSql){ + TextBlockReader textBlockReader = new TextBlockReader(aRawSql); + Properties props = null; + try { + props = textBlockReader.read(); + } + catch (IOException ex) { + ex.printStackTrace(); + } + return new LinkedHashMap(props); + } +}