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><name> {</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);
}
}