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 :
+
+ - Block - a multiline block of text with within braces, with an associated
+ identifier (similar to a typical Java block)
+
- Block Name - the identifier of a block, appearing on the first line, before the
+ opening brace
+
- 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.
+
+
+ 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 :
+
+ - empty lines can appear only outside of blocks
+
- the '--' character denotes a comment
+
- there are no multiline comments. (Such comments are easier for the writer,
+ but are much less clear for the reader.)
+
- the body of each item is placed in a named block, bounded by
+ '<name> {' on an initial line, and '}' on an end line.
+ The name given to the the block (ADD_MESSAGE for example) is how
+ WEB4J 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 public static final SqlId field. Upon startup, this allows verification
+ that all items defined in the text source have a corresponding item in code.
+
- '--' comments can appear in the Block Body as well
+
- if desired, the Block Body may be indented, to make the Block more legible
+
- the Block Name of 'constants' is reserved. A constants
+ block defines one or more simple textual substitution constants, as
+ name = value 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 later in the file. Any number of constants
+ blocks can appear in a file.
+
- Block Names and the names of constants satisfy
+ {@link hirondelle.web4j.util.Regex#SIMPLE_SCOPED_IDENTIFIER}.
+
- inside a Block Body, substitutions are denoted by the common
+ syntax '${blah}', where blah 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
+ constants Block
+
- an item must be defined before it can be used in a substitution ; that is,
+ it must appear earlier in the file
+
- no substitutions are permitted in a constants Block
+
- Block Names, and the names of constants, should be unique.
+
+ */
+ 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);
+ }
+}