diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..940fbf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.* +!.gitignore +/build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d615a37 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## v1.0 / 2014-10-01 +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3214bb8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014 Chiang Seng Chang + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc27b27 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# slurper-configuration + +Application configuration backed by [ConfigSlurper](http://groovy.codehaus.org/ConfigSlurper), plus support for [Spring Framework](http://projects.spring.io/spring-framework/) placeholders, and [@Value](http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/beans/factory/annotation/Value.html) annotations. + +## Features + +#### More than Strings +Configure using actual typed values, not just Strings. +```groovy +day = 'Monday' // String, duh! +count = 123 // a real int, no quotes! +vowels = [ 'a', 'e','i', 'o', 'u'] // a real List +now = new Date() // any Object! +// should be able to configure any type Groovy can muster. +``` + +#### Levels +Grouped and organized! +```groovy +// instead of +myService.foo = 1 +myService.bar = 2 + +// do this +myService { + foo = 1 + bar = 2 +} +``` + +#### Profiles Support +Either by direct setting on the Config object, or automatically by Spring's [Environment](http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/core/env/Environment.html) bean. +```groovy +target = 'http://default/' + +environments { + dev { + target = 'http://localhost/' + } + qa { + target = 'http://qa/' + } +} +``` + +#### Local Overrides +By setting the locations to load on the Config object. Latter locations will overrides values set by ealier locations. +Locations are Spring's resource strings such as `classpath:x/y/z`, and `file:/x/y/z`. +With an additional type `class:fully.qualified.Classname` which loads a compiled config groovy class. +```java +Config config = new Config(); +config.setLocations( + "class:conf.MyConfig", + "file:/usr/local/etc/my-config.groovy" +); +``` + +#### ConfigPlaceholderConfigurer for Spring +Just like Spring's [PropertySourcesPlaceholderConfigurer](http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/context/support/PropertySourcesPlaceholderConfigurer.html), +it resolves `${...}` placeholders within bean definition property values and `@Value` annotations against the current Spring Environment, and the Config bean. +```java +public class MyBean { + + @Value("${day}") + String day; + + @Value("${count}") + int count; + + @Value("#{config.get("vowels")}") + List vowels; + + @Value("#{config.get('now')}") + Date now; +} +``` + +## Setup and Usage + +### Standalone Setup +```java +// setup +Config config = new Config(); +config.setProfiles('dev'); // optional +config.setLocations('class:conf/MyConfig'); +config.load(); +``` + +### Spring Context Setup +```java +@Configuration +public class AppConfig { + + @Bean + public static Config config() { + Config config = new Config(); + // no need to setProfiles(), typically set by Environment, + // perhaps via the -Dspring.profiles.active parameter. + config.setLocations( + "class:conf/MyConfig", + "file:/usr/local/etc/my-local-config.groovy" + ); + return config; + } + + @Bean + public static ConfigPlaceholderConfigurer configPlaceholderConfigurer(Config config) { + return new ConfigPlaceholderConfigurer(config); + } + +} +``` + +### An example `conf/MyConfig.groovy` +```groovy +package conf + +who = 'McGann' + +life = 42 + +lorem { + ipsum = """ +Lorem ipsum dolor sit amet, nec primis argumentum an, nec integre eruditi laoreet eu. Eam illum nulla id, mea ea sonet alterum. +You can inject other var here like, the answer is ${life}. +""" +} + +nemesis = [ + 'Sherlock Holmes': 'James Moriarty', + 'Peter Pan': 'Captain Hook', + 'John McClane': 'Hans Gruber' +] +``` + +### Usage + +#### Getters +```java +// get(key) will throw NoSuchKeyException if key is absent + +String doctor = config.get("who"); +int answer = config.get("life"); +String gibbish = config.get("lorem.ipsum"); +Map archenimies = config.get("nemesis"); + +// get(key, default) will return the default if key is absent + +int i = config.get("no.such.key", 7); // i == 7 +``` + +#### Spring [@Value](http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/beans/factory/annotation/Value.html) Annotation +```java +public class MyBean { + + // ${} may be used simple types + + @Value("${who}") + String doctor; + + @Value("${life}") + int answer; + + // #{} SpEL can be used for all types + + @Value("#{config.get('nemesis')}") + Map archenimies +} +``` + +## build + + ./gradlew clean build javadoc + +## More Features + +#### Default Locations +Say you wish to use this in developing a library (jar), and you have some default configuration values, e.g. in your com.acme.AcmeConfig class, instead of asking your user to add that location, you can set it in `META-INF/slurper-configuration.properties` of your jar (ironic, I know). Here is an example: +```properties +# locations is comma separated and they are PREPENDED, i.e. loaded first. + +locations=class:com.acme.AcmeConfig +``` + +#### Script Value +Latter locations always overwrites the same keyed values of earlier locations. +But you may use a script value to manipuate the current (a.k.a. the so-far) value instead. +Script values are strings that starts with `groovy::` and the current/so-far value is passed in as variable `x`, for example: +```groovy +// Base.groovy + +some.number = 123 +fruits = [ 'Apple', 'Orange' ] +``` +```groovy +// Override.groovy + +// double it! +some.number = 'groovy:: x * 2' + +// more elaborate script +fruits = '''groovy:: + // I like Mango, let's add it + x << 'Mango' + // remember to return x + return x +''' +``` +Script values only works across locations, not within different profiles of the same file, for example, assume profile is `dev`, `foo` is finalized to `50`, not `100`: +```groovy +// Base.groovy + +foo = 5 +``` +```groovy +// Override.groovy + +foo = 10 + +environments { + dev { + foo = 'groovy:: x * 10' + } +} +``` +The reason is script values are only resolved after a config location is loaded, +and before merging into the current/so-far config values. diff --git a/assets/reportng-custom.css b/assets/reportng-custom.css new file mode 100644 index 0000000..b87dcdd --- /dev/null +++ b/assets/reportng-custom.css @@ -0,0 +1,9 @@ +/* Make assert failures look better */ + +.result { + font-family: Lucida Console, Monaco, Courier New, monospace; +} + +a { + text-decoration: none; +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..678a03e --- /dev/null +++ b/build.gradle @@ -0,0 +1,211 @@ +//================================================== +// slurper-configuration build file +//================================================== + +group = 'com.ctzen' +version = '1.0' + +defaultTasks 'clean', 'build' + +apply plugin: 'groovy' + +//================================================== +// dependencies +//================================================== + +repositories { + mavenCentral() + flatDir { + dirs 'lib' + } +} + +dependencies { + // groovy + compile 'org.codehaus.groovy:groovy-all:2.3.6' // version matches eclipse plug-in + // spring + compile 'org.springframework:spring-context:4.1.0.RELEASE' + // must haves + compile 'com.google.guava:guava:18.0' + // logging + compile 'org.slf4j:slf4j-api:1.7.7' + runtime 'ch.qos.logback:logback-classic:1.1.2' + runtime 'org.slf4j:jcl-over-slf4j:1.7.7' // bridge commons-logging to slf4j + runtime 'org.slf4j:log4j-over-slf4j:1.7.7' // bridge log4j to slf4j + // testing + testCompile 'org.slf4j:jcl-over-slf4j:1.7.7' // need this to compile groovy tests + testCompile 'org.testng:testng:6.8.8' + testRuntime 'org.uncommons:reportng:1.1.4' // nicer testng reports + testRuntime 'com.google.inject:guice:2.0' // required by reportng (no major version due to bad meta on repo) + testCompile 'org.springframework:spring-test:4.1.0.RELEASE' + testRuntime name: 'slurper-configuration-test' // for testing META-INF/slurper-configuration.properties +} + +configurations { + all*.exclude group: 'commons-logging' // exclude to use slf4j + all*.exclude group: 'log4j' // exclude to use slf4j +} + +//================================================== +// java / groovy +//================================================== + +ext { + // CANNOT be full path or eclipse plugin may misbehave! + srcDir = 'src' // main sources and resources + classesDir = 'build/classes' // main classes + testSrcDir = 'src-test' // test sources and resources + testClassesDir = 'build/classes-test' // test classes + assetsDir = 'assets' // e.g. reportng css +} + +tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + options.deprecation = true + options.compilerArgs << '-Xlint:unchecked' +} + +tasks.withType(GroovyCompile) { + options.encoding = 'UTF-8' + options.deprecation = true + options.compilerArgs << '-Xlint:unchecked' +} + +sourceSets { + main { + java { + srcDirs = [] + } + groovy { + srcDirs = ["${srcDir}"] + } + resources { + srcDirs = ["${srcDir}"] // note same as main source + } + output.classesDir = "${classesDir}" + output.resourcesDir = "${classesDir}" // note same as main classes + } + test { + java { + srcDirs = [] + } + groovy { + srcDirs = ["${testSrcDir}"] + } + resources { + srcDirs = ["${testSrcDir}"] // note same as test source + } + output.classesDir = "${testClassesDir}" + output.resourcesDir = "${testClassesDir}" // note same as test classes + } +} + +// workaround dup entries in jar until GRADLE-2213 is fixed +jar.doFirst { + sourceSets.main.output.resourcesDir = "/does/not/exist" +} +jar.doLast { + sourceSets.main.output.resourcesDir = sourceSets.main.output.classesDir +} + +//================================================== +// unit test +//================================================== + +apply plugin: 'jacoco' + +test { + useTestNG() { + parallel 'tests' + threadCount 5 + } + systemProperty 'org.uncommons.reportng.stylesheet', "${assetsDir}/reportng-custom.css" + options { + listeners << 'org.uncommons.reportng.HTMLReporter' // use reportng + listeners << 'org.uncommons.reportng.JUnitXMLReporter' // also produce junit xml reports for + // downstream build step such as + // jenkins' unit test report + } + jacoco { + append = false + destinationFile = file("${reporting.baseDir}/jacoco/jacoco.exec") + } + doLast { + jacocoTestReport.execute() + } +} + +jacocoTestReport { + group = 'Reporting' + description = 'Generate Jacoco unit tests coverage report.' + dependsOn test + reports { + xml.enabled = false + csv.enabled false + html.enabled true + html.destination "${reporting.baseDir}/jacoco/html" + } +} + +//================================================== +// eclipse integration +//================================================== + +apply plugin: 'eclipse' + +eclipse { + classpath { + downloadSources = true + // setup separate output classes folders. + defaultOutputDir = file("${classesDir}") + file { + // Classpath entry for Eclipse which changes the order of classpathentries + // otherwise no sources for 3rd party jars are shown + withXml { xml -> + def node = xml.asNode() + def j2eeNode = node.find { it.@path == 'org.eclipse.jst.j2ee.internal.web.container' } + if (j2eeNode) { + node.remove(j2eeNode) + node.appendNode( 'classpathentry', [ kind: 'con', path: 'org.eclipse.jst.j2ee.internal.web.container', exported: 'true']) + } + } + // switch test classes output folder + whenMerged { cp -> + cp.entries.findAll { + it.kind == 'src' && it.path == "${testSrcDir}" + }*.output = "${testClassesDir}" + } + } + } +} + +// always clean when generating eclipse project files +// because the generated files without cleaning could be bad +tasks.eclipse.dependsOn cleanEclipse + +//================================================== +// publish +//================================================== + +apply plugin: 'maven-publish' + +task sourceJar(type: Jar) { + from sourceSets.main.allSource +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourceJar { + classifier "sources" + } + } + } +} + +task publishLocal { + group = 'Publishing' + description = 'Alias for publishToMavenLocal' + dependsOn publishToMavenLocal +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b761216 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9b8c07c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Sep 29 10:11:17 EDT 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.0-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..91a7e26 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/slurper-configuration-test.jar b/lib/slurper-configuration-test.jar new file mode 100644 index 0000000..09fc5f5 Binary files /dev/null and b/lib/slurper-configuration-test.jar differ diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..dce42b6 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,12 @@ +sonar.host.url=http://localhost:9088 +# required metadata +sonar.projectKey=com.ctzen:slurper-configuration +sonar.projectName=slurper-configuration +sonar.projectVersion=1.0 +sonar.sources=src +sonar.tests=src-test +sonar.binaries=build/classes +# jacoco +sonar.dynamicAnalysis=reuseReports +sonar.java.coveragePlugin=jacoco +sonar.jacoco.reportPath=build/reports/jacoco/jacoco.exec diff --git a/src-test/com/ctzen/config/GetTests.groovy b/src-test/com/ctzen/config/GetTests.groovy new file mode 100644 index 0000000..21813b2 --- /dev/null +++ b/src-test/com/ctzen/config/GetTests.groovy @@ -0,0 +1,73 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.testng.annotations.BeforeClass +import org.testng.annotations.DataProvider +import org.testng.annotations.Test + +import com.ctzen.config.exception.NoSuchKeyException + +/** + * @author cchang + */ +@CompileStatic +@Test +class GetTests { + + @BeforeClass + void setup() { + config = new Config() + config.setLocations('class:com.ctzen.config.GetTestsConfig') + config.load() + assert !config.empty + } + + private Config config + + @Test(expectedExceptions = NoSuchKeyException) + void noSuchKey() { + try { + config.get('no.such.key') + } + catch (NoSuchKeyException e) { + assert 'no.such.key' == e.key + throw e + } + } + + void noSuchKeyDefault() { + assert 123 == config.get('no.such.key', 123) + } + + void nullValue() { + assert null == config.get('aNull', 'Not me!') + } + + @DataProvider(name = 'simpleGetData') + private Object[][] simpleGetData() { + [ + [ 'foo', 'I am foo' ], + [ 'bar', 123 ], + [ 'foo2', 'I am foo too' ], + [ 'qux', 0 ], + [ 'l1.qux', 1 ], + [ 'l1.l2.qux', 2 ], + [ 'l1.l2.l3.qux', 3 ], + [ 'aNull', null ], + ] as Object[][] + } + + @Test(dataProvider = 'simpleGetData') + void simpleGet(final String key, final Object expected) { + assert config.containsKey(key) + assert expected == config.get(key) + } + + void pojo() { + final TestPojo pojo = (TestPojo)config.get('pojo') + assert null != pojo + assert 'I am pojo!' == pojo.name + } + +} diff --git a/src-test/com/ctzen/config/GetTestsConfig.groovy b/src-test/com/ctzen/config/GetTestsConfig.groovy new file mode 100644 index 0000000..b85df88 --- /dev/null +++ b/src-test/com/ctzen/config/GetTestsConfig.groovy @@ -0,0 +1,22 @@ +package com.ctzen.config + +foo = 'I am foo' + +bar = 123 + +foo2 = "${foo} too" + +qux = 0 +l1 { + qux = 1 + l2 { + qux = 2 + l3 { + qux = 3 + } + } +} + +aNull = null + +pojo = new TestPojo(name: 'I am pojo!') \ No newline at end of file diff --git a/src-test/com/ctzen/config/LocationsLoadingTests.groovy b/src-test/com/ctzen/config/LocationsLoadingTests.groovy new file mode 100644 index 0000000..cafd41e --- /dev/null +++ b/src-test/com/ctzen/config/LocationsLoadingTests.groovy @@ -0,0 +1,58 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class LocationsLoadingTests { + + void noLocation() { + final Config config = new Config() + config.load() + assert 'I am meta-inf' == config.get('foo') + assert !config.keySet().empty + assert 0 < config.size() + } + + void badLocations() { + final Config config = new Config() + config.setLocations( + 'class:no.such.Config', + 'classpath:no/such/config.gy' + ) + config.load() + assert 'I am meta-inf' == config.get('foo') + } + + void loadFromClass() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.LocationsLoadingTestsConfig') + config.load() + assert 'I am class' == config.get('foo') + } + + void loadFromScript() { + final Config config = new Config() + config.setLocations('classpath:com/ctzen/config/locations-loading-tests-config.gy') + config.load() + assert 'I am script' == config.get('foo') + } + + void locationsOverrides() { + final Config config = new Config() + config.setLocations( + 'class:com.ctzen.config.LocationsLoadingTestsConfig', + 'classpath:com/ctzen/config/locations-loading-tests-config.gy' + ) + config.load() + assert 'I am script' == config.get('foo') + assert 'bar from class' == config.get('bar') + assert 'qux from script' == config.get('qux') + } + +} diff --git a/src-test/com/ctzen/config/LocationsLoadingTestsConfig.groovy b/src-test/com/ctzen/config/LocationsLoadingTestsConfig.groovy new file mode 100644 index 0000000..304713a --- /dev/null +++ b/src-test/com/ctzen/config/LocationsLoadingTestsConfig.groovy @@ -0,0 +1,5 @@ +package com.ctzen.config + +foo = 'I am class' + +bar = 'bar from class' diff --git a/src-test/com/ctzen/config/LocationsTests.groovy b/src-test/com/ctzen/config/LocationsTests.groovy new file mode 100644 index 0000000..5bb2aff --- /dev/null +++ b/src-test/com/ctzen/config/LocationsTests.groovy @@ -0,0 +1,66 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.springframework.core.env.AbstractEnvironment +import org.springframework.core.env.Environment +import org.testng.Reporter +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class LocationsTests { + + void noLocation() { + final Config config = new Config() + Reporter.log('locations=' + config.locations) + assert [] == config.locations + } + + @DataProvider(name = 'setLocationsData') + private Object[][] setLocationsData() { + [ + [ [] ], + [ ['foo'] ], + [ ['foo', 'bar'] ], + [ ['foo', 'bar', 'qux' ] ] + ] as Object[][] + } + + @Test(dataProvider = 'setLocationsData') + void setLocationsStrings(final List locations) { + final Config config = new Config() + // test initial locations is replaced + config.setLocations('initial') + assert ['initial'] == config.locations + config.setLocations(locations as String[]) + Reporter.log('locations=' + config.locations) + assert locations == config.locations + } + + @Test(dataProvider = 'setLocationsData') + void setLocationsCollection(final List locations) { + final Config config = new Config() + // test initial locations is replaced + config.setLocations('initial') + assert ['initial'] == config.locations + config.setLocations(locations) + Reporter.log('locations=' + config.locations) + assert locations == config.locations + } + + void addLocations() { + final Config config = new Config() + // test initial locations is replaced + config.setLocations('initial') + assert ['initial'] == config.locations + config.addLocations('foo', 'bar') + Reporter.log('locations=' + config.locations) + assert ['initial', 'foo', 'bar'] == config.locations + } + +} diff --git a/src-test/com/ctzen/config/ProfilesLoadingTests.groovy b/src-test/com/ctzen/config/ProfilesLoadingTests.groovy new file mode 100644 index 0000000..75a1653 --- /dev/null +++ b/src-test/com/ctzen/config/ProfilesLoadingTests.groovy @@ -0,0 +1,59 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class ProfilesLoadingTests { + + void noProfile() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.ProfilesLoadingTestsConfig') + config.load() + assert 'I am default' == config.get('foo') + assert 'bar@root' == config.get('bar') + } + + void devProfile() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.ProfilesLoadingTestsConfig') + config.setProfiles(ConfigProfile.DEV) + config.load() + assert 'I am dev' == config.get('foo') + assert 'bar@root' == config.get('bar') + + } + + void prodProfile() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.ProfilesLoadingTestsConfig') + config.setProfiles(ConfigProfile.PROD) + config.load() + assert 'I am prod' == config.get('foo') + assert 'bar@root' == config.get('bar') + } + + void devProdProfiles() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.ProfilesLoadingTestsConfig') + config.setProfiles(ConfigProfile.DEV, ConfigProfile.PROD) + config.load() + assert 'I am prod' == config.get('foo') + assert 'bar@root' == config.get('bar') + } + + void prodDevProfiles() { + final Config config = new Config() + config.setLocations('class:com.ctzen.config.ProfilesLoadingTestsConfig') + config.setProfiles(ConfigProfile.PROD, ConfigProfile.DEV) + config.load() + assert 'I am dev' == config.get('foo') + assert 'bar@root' == config.get('bar') + } + +} diff --git a/src-test/com/ctzen/config/ProfilesLoadingTestsConfig.groovy b/src-test/com/ctzen/config/ProfilesLoadingTestsConfig.groovy new file mode 100644 index 0000000..504fc43 --- /dev/null +++ b/src-test/com/ctzen/config/ProfilesLoadingTestsConfig.groovy @@ -0,0 +1,17 @@ +package com.ctzen.config + +foo = 'I am default' + +bar = 'bar@root' + +environments { + + dev { + foo = 'I am dev' + } + + prod { + foo = 'I am prod' + } + +} \ No newline at end of file diff --git a/src-test/com/ctzen/config/ProfilesTests.groovy b/src-test/com/ctzen/config/ProfilesTests.groovy new file mode 100644 index 0000000..d20989c --- /dev/null +++ b/src-test/com/ctzen/config/ProfilesTests.groovy @@ -0,0 +1,125 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.springframework.core.env.AbstractEnvironment +import org.springframework.core.env.Environment +import org.testng.Reporter +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class ProfilesTests { + + void noProfile() { + final Config config = new Config() + Reporter.log('effectiveProfiles=' + config.effectiveProfiles) + assert [] == config.effectiveProfiles + } + + @DataProvider(name = 'setProfilesData') + private Object[][] setProfilesData() { + [ + [ [] ], + [ ['foo'] ], + [ ['foo', 'bar'] ], + [ ['foo', 'bar', 'qux' ] ] + ] as Object[][] + } + + @Test(dataProvider = 'setProfilesData') + void setProfilesStrings(final List profiles) { + final Config config = new Config() + // test initial profiles is replaced + config.setProfiles('initial') + assert ['initial'] == config.effectiveProfiles + config.setProfiles(profiles as String[]) + Reporter.log('effectiveProfiles=' + config.effectiveProfiles) + assert profiles == config.effectiveProfiles + } + + @Test(dataProvider = 'setProfilesData') + void setProfilesCollection(final List profiles) { + final Config config = new Config() + // test initial profiles is replaced + config.setProfiles('initial') + assert ['initial'] == config.effectiveProfiles + config.setProfiles(profiles) + Reporter.log('effectiveProfiles=' + config.effectiveProfiles) + assert profiles == config.effectiveProfiles + } + + @Test(dataProvider = 'setProfilesData') + void setEnvironment(final List profiles) { + final Config config = new Config() + // test initial profiles is replaced + final Environment initialEnv = new AbstractEnvironment() { + @Override + String[] getActiveProfiles() { + ['initial'] as String[] + } + } + config.setEnvironment(initialEnv) + assert ['initial'] == config.effectiveProfiles + final Environment env = new AbstractEnvironment() { + @Override + String[] getActiveProfiles() { + profiles as String[] + } + } + config.setEnvironment(env) + Reporter.log('effectiveProfiles=' + config.effectiveProfiles) + assert profiles == config.effectiveProfiles + } + + void environmentProfilesTrumps() { + final Config config = new Config() + config.setProfiles('non-env') + assert ['non-env'] == config.effectiveProfiles + final Environment env = new AbstractEnvironment() { + @Override + String[] getActiveProfiles() { + ['env'] as String[] + } + } + // env trumps + config.setEnvironment(env) + assert ['env'] == config.effectiveProfiles + // and back + config.setEnvironment(null) + assert ['non-env'] == config.effectiveProfiles + } + + void environmentEmptyProfile() { + final Config config = new Config() + config.setProfiles('non-env') + assert ['non-env'] == config.effectiveProfiles + final Environment env = new AbstractEnvironment() { + @Override + String[] getActiveProfiles() { + new String[0] + } + } + config.setEnvironment(env) + assert ['non-env'] == config.effectiveProfiles + } + + void environmentNullProfile() { + final Config config = new Config() + config.setProfiles('non-env') + assert ['non-env'] == config.effectiveProfiles + final Environment env = new AbstractEnvironment() { + @Override + String[] getActiveProfiles() { + null + } + } + config.setEnvironment(env) + assert ['non-env'] == config.effectiveProfiles + } + +} diff --git a/src-test/com/ctzen/config/ReloadTests.groovy b/src-test/com/ctzen/config/ReloadTests.groovy new file mode 100644 index 0000000..b2a15fe --- /dev/null +++ b/src-test/com/ctzen/config/ReloadTests.groovy @@ -0,0 +1,37 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import java.nio.file.Files + +import org.testng.Reporter +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class ReloadTests { + + void loadFromClass() { + final File f = File.createTempFile('slurper-configuration-test-', '.groovy') + Reporter.log(f.canonicalPath) + Files.write(f.toPath(), ''' +foo = 'foo-initial' +bar = 'bar-initial' +'''.bytes) + final Config config = new Config() + config.setLocations("file:${f.canonicalPath}") + config.load() + assert 'foo-initial' == config.get('foo') + assert 'bar-initial' == config.get('bar') + Files.write(f.toPath(), ''' +foo = 'foo-reloaded' +'''.bytes) + config.load() + assert 'foo-reloaded' == config.get('foo') + assert !config.containsKey('bar') + } + +} diff --git a/src-test/com/ctzen/config/ScriptValueTests.groovy b/src-test/com/ctzen/config/ScriptValueTests.groovy new file mode 100644 index 0000000..9226edb --- /dev/null +++ b/src-test/com/ctzen/config/ScriptValueTests.groovy @@ -0,0 +1,25 @@ +package com.ctzen.config + +import groovy.transform.CompileStatic + +import org.testng.annotations.Test + +/** + * @author cchang + */ +@CompileStatic +@Test +class ScriptValueTests { + + void scriptValues() { + final Config config = new Config() + config.setLocations( + 'classpath:com/ctzen/config/script-value-tests-config.gy', + 'class:com.ctzen.config.ScriptValueTestsConfig' + ) + config.load() + assert [ 1, 3, 4, 5, 6 ] == config.get('foo') + assert 'An orange a day' == config.get('ina.bar') + } + +} diff --git a/src-test/com/ctzen/config/ScriptValueTestsConfig.groovy b/src-test/com/ctzen/config/ScriptValueTestsConfig.groovy new file mode 100644 index 0000000..423c156 --- /dev/null +++ b/src-test/com/ctzen/config/ScriptValueTestsConfig.groovy @@ -0,0 +1,10 @@ +package com.ctzen.config + +foo = 'groovy:: x + [ 4, 5, 6 ] - [ 2 ]' + +ina { + bar = '''groovy:: +// long way to overrides... +x.replace('apple', 'orange') +''' +} \ No newline at end of file diff --git a/src-test/com/ctzen/config/TestMetaInfConfig.groovy b/src-test/com/ctzen/config/TestMetaInfConfig.groovy new file mode 100644 index 0000000..6db7a39 --- /dev/null +++ b/src-test/com/ctzen/config/TestMetaInfConfig.groovy @@ -0,0 +1,3 @@ +package com.ctzen.config + +foo = 'I am meta-inf' \ No newline at end of file diff --git a/src-test/com/ctzen/config/TestPojo.groovy b/src-test/com/ctzen/config/TestPojo.groovy new file mode 100644 index 0000000..0d249ed --- /dev/null +++ b/src-test/com/ctzen/config/TestPojo.groovy @@ -0,0 +1,10 @@ +package com.ctzen.config + +/** + * @author cchang + */ +class TestPojo { + + String name + +} diff --git a/src-test/com/ctzen/config/locations-loading-tests-config.gy b/src-test/com/ctzen/config/locations-loading-tests-config.gy new file mode 100644 index 0000000..1e5a7c5 --- /dev/null +++ b/src-test/com/ctzen/config/locations-loading-tests-config.gy @@ -0,0 +1,5 @@ +package com.ctzen.config + +foo = 'I am script' + +qux = 'qux from script' diff --git a/src-test/com/ctzen/config/script-value-tests-config.gy b/src-test/com/ctzen/config/script-value-tests-config.gy new file mode 100644 index 0000000..e3a0588 --- /dev/null +++ b/src-test/com/ctzen/config/script-value-tests-config.gy @@ -0,0 +1,8 @@ +package com.ctzen.config + + +foo = [ 1, 2, 3 ] + +ina { + bar = 'An apple a day' +} \ No newline at end of file diff --git a/src-test/com/ctzen/config/spring/AppConfig.groovy b/src-test/com/ctzen/config/spring/AppConfig.groovy new file mode 100644 index 0000000..fba5c4e --- /dev/null +++ b/src-test/com/ctzen/config/spring/AppConfig.groovy @@ -0,0 +1,37 @@ +package com.ctzen.config.spring + +import groovy.transform.CompileStatic + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +import com.ctzen.config.Config +import com.ctzen.config.ConfigProfile + +/** + * @author cchang + */ +@CompileStatic +@Configuration +@Profile(ConfigProfile.UNIT_TEST) +class AppConfig { + + @Bean + static Config config() { + final Config ret = new Config() + ret.setLocations('class:com.ctzen.config.spring.SpringTestsConfig') + return ret + } + + @Bean + static ConfigPlaceholderConfigurer configPlaceholderConfigurer(final Config config) { + new ConfigPlaceholderConfigurer(config) + } + + @Bean + Placeheld placeheld() { + new Placeheld(); + } + +} diff --git a/src-test/com/ctzen/config/spring/Placeheld.groovy b/src-test/com/ctzen/config/spring/Placeheld.groovy new file mode 100644 index 0000000..4ef693f --- /dev/null +++ b/src-test/com/ctzen/config/spring/Placeheld.groovy @@ -0,0 +1,25 @@ +package com.ctzen.config.spring + +import org.springframework.beans.factory.annotation.Value + + +/** + * For testing @Value annotations. + * + * @author cchang + */ +class Placeheld { + + @Value('${placeheld.name}') + String name + + @Value('${placeheld.meaning}') + int meaning + + @Value("#{config.get('placeheld.likes')}") + Set likes + + @Value('${placeheld.noSuchKey:default}') + String defa + +} diff --git a/src-test/com/ctzen/config/spring/SpringTests.groovy b/src-test/com/ctzen/config/spring/SpringTests.groovy new file mode 100644 index 0000000..514a44d --- /dev/null +++ b/src-test/com/ctzen/config/spring/SpringTests.groovy @@ -0,0 +1,47 @@ +package com.ctzen.config.spring + +import groovy.transform.CompileStatic + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.env.Environment +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests +import org.testng.Reporter +import org.testng.annotations.BeforeClass +import org.testng.annotations.Test + +import com.ctzen.config.Config +import com.ctzen.config.ConfigProfile + +/** + * @author cchang + */ +@CompileStatic +@Test +@ContextConfiguration(classes = AppConfig) +@ActiveProfiles(ConfigProfile.UNIT_TEST) +class SpringTests extends AbstractTestNGSpringContextTests { + + @Autowired + private Config config + + void simpleGet() { + assert 'I am spring' == config.get('foo') + } + + void profileOverrides() { + assert 'bar@unittest' == config.get('bar') + } + + @Autowired + private Placeheld placeheld + + void placeheld() { + assert 'John' == placeheld.name + assert 42 == placeheld.meaning + assert ['movies', 'comics'] as Set == placeheld.likes + assert 'default' == placeheld.defa + } + +} diff --git a/src-test/com/ctzen/config/spring/SpringTestsConfig.groovy b/src-test/com/ctzen/config/spring/SpringTestsConfig.groovy new file mode 100644 index 0000000..6cd146a --- /dev/null +++ b/src-test/com/ctzen/config/spring/SpringTestsConfig.groovy @@ -0,0 +1,29 @@ +package com.ctzen.config.spring + +foo = 'I am spring' + +bar = 'bar@root' + +environments { + + unittest { + + bar = 'bar@unittest' + + } + +} + +// this is a comment + +placeheld { + + aNull = null + + name = 'John' + + meaning = 42 + + likes = [ 'comics', 'movies' ] as Set + +} \ No newline at end of file diff --git a/src-test/logback-test.xml b/src-test/logback-test.xml new file mode 100644 index 0000000..c72580c --- /dev/null +++ b/src-test/logback-test.xml @@ -0,0 +1,15 @@ + + + build/reports/test.log + false + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + true + + diff --git a/src/com/ctzen/config/Config.java b/src/com/ctzen/config/Config.java new file mode 100644 index 0000000..ddd76f7 --- /dev/null +++ b/src/com/ctzen/config/Config.java @@ -0,0 +1,412 @@ +package com.ctzen.config; + +import groovy.lang.Binding; +import groovy.lang.GroovyShell; +import groovy.util.ConfigObject; +import groovy.util.ConfigSlurper; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; + +import org.codehaus.groovy.runtime.GStringImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.EnvironmentAware; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.env.Environment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +import com.ctzen.config.exception.ConfigException; +import com.ctzen.config.exception.NoSuchKeyException; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +/** + * {@link ConfigSlurper} backed configuration. + * + * @author cchang + */ +public class Config implements InitializingBean, EnvironmentAware, ResourceLoaderAware { + + private static final Logger LOG = LoggerFactory.getLogger(Config.class); + + public static final String LOCATION_PREFIX_CLASS = "class:"; + + public static final String GROOVY_SCRIPT_VALUE_PREFIX = "groovy::"; + + public static final String GROOVY_SCRIPT_BASE_VALUE_VAR = "x"; + + private final Set profiles = new LinkedHashSet<>(); + + public void setProfiles(final String... profiles) { + this.profiles.clear(); + for (final String profile: profiles) { + this.profiles.add(profile); + } + } + + public void setProfiles(final Collection profiles) { + this.profiles.clear(); + this.profiles.addAll(profiles); + } + + private Environment environment; + + @Override + public void setEnvironment(final Environment environment) { + this.environment = environment; + } + + /** + * The final list of profiles depending on {@link #setProfiles(String...)}, {@link #setProfiles(Collection)}, and {@link #setEnvironment(Environment)} + * + *

+ * If {@link Environment} is set and {@link Environment#getActiveProfiles()} is not empty, use that.
+ * Otherwise, use the local profiles set by the {@code setProfiles(...)} methods. + *

+ * + * @return effective profile names + */ + public List getEffectiveProfiles() { + Set ret = null; + if (environment != null) { + final String[] envProfiles = environment.getActiveProfiles(); + if (envProfiles != null && envProfiles.length > 0) { + ret = new LinkedHashSet<>(); + for (final String envProfile: envProfiles) { + ret.add(envProfile); + } + } + } + if (ret == null) { + ret = profiles; + } + return ImmutableList.copyOf(ret); + } + + private final List locations = new ArrayList<>(); + + public List getLocations() { + return ImmutableList.copyOf(locations); + } + + /** + * Set where to load the config from. + * + *

+ * Supports the spring-style resource strings, with an additional type of + * {@code "class:fully.qualified.Classname"} which loads from a {@link Class}. + *

+ * + * @param locations + */ + public void setLocations(final String... locations) { + this.locations.clear(); + addLocations(locations); + } + + /** + * @see #setLocations(String...) + */ + public void setLocations(final Collection locations) { + this.locations.clear(); + this.locations.addAll(locations); + } + + public void addLocations(final String... locations) { + for (final String location: locations) { + this.locations.add(location); + } + } + + private static final String SLURPER_CONFIG_PROPERTIES_PATTERN = "classpath*:META-INF/slurper-configuration.properties"; + + private List getEffectiveLocations() { + final List ret = new ArrayList<>(); + // process META-INF/slurper-configuration.properties + final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(resourceLoader); + final Resource[] resources; + try { + resources = resolver.getResources(SLURPER_CONFIG_PROPERTIES_PATTERN); + } + catch (IOException e) { + throw new ConfigException("Error finding resources: " + SLURPER_CONFIG_PROPERTIES_PATTERN, e); + } + if (resources != null) { + for (final Resource r: resources) { + Properties props; + try { + props = PropertiesLoaderUtils.loadProperties(r); + } + catch (IOException e) { + throw new ConfigException("Error loading resource: " + r, e); + } + final String propLocations = props.getProperty("locations"); + if (propLocations != null) { + for (String propLocation: propLocations.split(",")) { // NOSONAR: Refactor this code to not nest more than 3 if/for/while/switch/try statements. + // It's not that deep... + propLocation = propLocation.trim(); + if (!propLocation.isEmpty()) { + ret.add(propLocation); + } + } + } + } + } + ret.addAll(locations); + return ret; + } + + private ResourceLoader resourceLoader; + + @Override + public void setResourceLoader(final ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + /** + * For spring to finalize this bean. Non spring users should call {@link #load()} instead. + */ + // @PostConstruct does not work for static @Bean + @Override + public void afterPropertiesSet() { + load(); + } + + /** + * Load up this config using the configured locations and profiles. + */ + public void load() { + final long start = System.currentTimeMillis(); + if (resourceLoader == null) { + resourceLoader = new DefaultResourceLoader(Thread.currentThread().getContextClassLoader()); + } + values.clear(); + List effectiveProfiles = getEffectiveProfiles(); + LOG.info("Load using profiles: {}", effectiveProfiles); + final List effectiveLocations = getEffectiveLocations(); + if (effectiveLocations.isEmpty()) { + LOG.warn("No location to load!"); + } + else { + final ConfigObject configObject = new ConfigObject(); + for (final String location: effectiveLocations) { + loadFromLocation(configObject, location, effectiveProfiles); + } + if (configObject.isEmpty()) { + LOG.warn("Nothing was loaded!"); + } + else { + loadValues("", configObject); + } + } + logValues(); + LOG.info("Loading took {}ms", System.currentTimeMillis() - start); + } + + private void loadFromLocation(final ConfigObject configObject, final String location, final List profiles) { + if (profiles.isEmpty()) { + final ConfigSlurper slurper = new ConfigSlurper(); + loadFromLocation(slurper, configObject, location); + } + else { + for (final String profile: profiles) { + final ConfigSlurper slurper = new ConfigSlurper(profile); + loadFromLocation(slurper, configObject, location); + } + } + } + + private void loadFromLocation(final ConfigSlurper slurper, final ConfigObject configObject, final String location) { + LOG.info("Load from: {}", location); + final ConfigObject cobj; + if (location.startsWith(LOCATION_PREFIX_CLASS)) { + cobj = loadFromClass(slurper, location.substring(LOCATION_PREFIX_CLASS.length())); + } + else { + cobj = loadFromResource(slurper, location); + } + if (cobj != null) { + resolveScriptValues(configObject, cobj); + configObject.merge(cobj); + } + } + + private ConfigObject loadFromClass(final ConfigSlurper slurper, final String classname) { + final Class scriptClass; + try { + scriptClass = resourceLoader.getClassLoader().loadClass(classname); + } + catch (final ClassNotFoundException e) { + LOG.warn("Class not found: {}", classname); + return null; + } + return slurper.parse(scriptClass); + } + + private ConfigObject loadFromResource(final ConfigSlurper slurper, final String location) { + final Resource res = resourceLoader.getResource(location); + if (!res.isReadable()) { + LOG.warn("Resource not readable: {}", res); + return null; + } + final URL url; + try { + url = res.getURL(); + } + catch (IOException e) { + throw new ConfigException("Not expecting a bad URL from a redable resource: " + res, e); + } + return slurper.parse(url); + } + + /** + * Resolve any inline groovy script values before merge. + */ + private void resolveScriptValues(final ConfigObject base, final ConfigObject src) { + @SuppressWarnings("unchecked") + final Set> srcEntries = src.entrySet(); + for (final Entry srcEntry: srcEntries) { + final String srcKey = srcEntry.getKey(); + final Object srcValue = srcEntry.getValue(); + if (srcValue instanceof ConfigObject) { + final Object baseValue = base.get(srcKey); + if (baseValue instanceof ConfigObject) { + resolveScriptValues((ConfigObject)baseValue, (ConfigObject)srcValue); + } + } + else if (srcValue instanceof CharSequence) { + final String sv = ((CharSequence)srcValue).toString(); + if (sv.startsWith(GROOVY_SCRIPT_VALUE_PREFIX)) { + final Binding binding = new Binding(); + final Object baseValue = base.get(srcKey); + binding.setVariable(GROOVY_SCRIPT_BASE_VALUE_VAR, baseValue); + final GroovyShell shell = new GroovyShell(binding); + final String script = sv.substring(GROOVY_SCRIPT_VALUE_PREFIX.length()); + final Object resolvedValue = shell.evaluate(script); + srcEntry.setValue(resolvedValue); + } + } + } + } + + /** + * Flattens and finalizes the config values. + */ + private void loadValues(final String keyPrefix, final ConfigObject configObject) { + @SuppressWarnings("unchecked") + final Set> entries = configObject.entrySet(); + for (final Map.Entry entry: entries) { + final String key = keyPrefix + entry.getKey(); + Object value = entry.getValue(); + if (value instanceof ConfigObject) { + loadValues(key + ".", (ConfigObject)value); + } + else { + if (value instanceof GStringImpl) { + value = value.toString(); + } + values.put(key, value); + } + } + } + + private void logValues() { + final StringBuilder msg = new StringBuilder(values.size() + " config values:"); + for (final Map.Entry entry: values.entrySet()) { + msg.append("\n ") + .append(entry.getKey()) + .append(" ("); + final Object value = entry.getValue(); + if (value == null) { + msg.append("null"); + } + else { + msg.append(value.getClass().getSimpleName()); + } + msg.append(") = ") + .append(entry.getValue()); + } + LOG.info(msg.toString()); + } + + private final Map values = new TreeMap<>(); + + /** + * @return {@code true} if there is no config entry + */ + public boolean isEmpty() { + return values.isEmpty(); + } + + /** + * @return number of config entries + */ + public int size() { + return values.size(); + } + + /** + * @param key config key to search + * @return {@code true} if there is a config entry of the {@code key} + */ + public boolean containsKey(final String key) { + return values.containsKey(key); + } + + /** + * @return all config keys + */ + public Set keySet() { + return ImmutableSet.copyOf(values.keySet()); + } + + /** + * Gets a config value. + * + * @param key config key to search + * @return config value associated with the {@code key} + * @throws NoSuchKeyException if the {@code key} does not exists + */ + @SuppressWarnings("unchecked") + public T get(final String key) { + if (!containsKey(key)) { + throw new NoSuchKeyException(key); + } + return (T)values.get(key); + } + + /** + * Gets a config value, return the {@code defaultValue} if {@code key} does not exists. + * + * @param key config key to search + * @param defaultValue return this if {@code key} does not exists + * @return config value associated with the {@code key}, or {@code defaultValue} if {@code key} does not exists + */ + @SuppressWarnings("unchecked") + public T get(final String key, final T defaultValue) { + final T ret; + if (containsKey(key)) { + ret = (T)values.get(key); + } + else { + ret = defaultValue; + } + return ret; + } + +} diff --git a/src/com/ctzen/config/ConfigProfile.java b/src/com/ctzen/config/ConfigProfile.java new file mode 100644 index 0000000..26f6727 --- /dev/null +++ b/src/com/ctzen/config/ConfigProfile.java @@ -0,0 +1,17 @@ +package com.ctzen.config; + +/** + * Common profile names. + * + * @author cchang + */ +public interface ConfigProfile { // NOSONAR: Move constants to a class or enum. + // This allows the constants to be used as annotation parameters. + + static final String DEV = "dev"; + static final String UNIT_TEST = "unittest"; + static final String INTEGRATION_TEST = "integrationtest"; + static final String QA = "qa"; + static final String PROD = "prod"; + +} diff --git a/src/com/ctzen/config/exception/ConfigException.java b/src/com/ctzen/config/exception/ConfigException.java new file mode 100644 index 0000000..21b2b0e --- /dev/null +++ b/src/com/ctzen/config/exception/ConfigException.java @@ -0,0 +1,22 @@ +package com.ctzen.config.exception; + +import com.ctzen.config.Config; + +/** + * Generic {@link Config} exception. + * + * @author cchang + */ +public class ConfigException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ConfigException(String message) { + super(message); + } + + public ConfigException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/com/ctzen/config/exception/NoSuchKeyException.java b/src/com/ctzen/config/exception/NoSuchKeyException.java new file mode 100644 index 0000000..09c5a32 --- /dev/null +++ b/src/com/ctzen/config/exception/NoSuchKeyException.java @@ -0,0 +1,28 @@ +package com.ctzen.config.exception; + +import com.ctzen.config.Config; + +/** + * No such key in the {@link Config} object. + * + * @author cchang + */ +public class NoSuchKeyException extends ConfigException { + + private static final long serialVersionUID = 1L; + + public NoSuchKeyException(String key) { + super("Missing config key '" + key + "'"); + this.key = key; + } + + private final String key; + + /** + * @return the missing config key + */ + public String getKey() { + return key; + } + +} diff --git a/src/com/ctzen/config/spring/ConfigPlaceholderConfigurer.java b/src/com/ctzen/config/spring/ConfigPlaceholderConfigurer.java new file mode 100644 index 0000000..7c932c0 --- /dev/null +++ b/src/com/ctzen/config/spring/ConfigPlaceholderConfigurer.java @@ -0,0 +1,111 @@ +package com.ctzen.config.spring; + +import java.util.Properties; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.PlaceholderConfigurerSupport; +import org.springframework.context.EnvironmentAware; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.core.env.ConfigurablePropertyResolver; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.PropertySources; +import org.springframework.core.env.PropertySourcesPropertyResolver; +import org.springframework.util.Assert; +import org.springframework.util.StringValueResolver; + +import com.ctzen.config.Config; + +/** + * Specialization of {@link PlaceholderConfigurerSupport} that resolves ${...} placeholders + * within bean definition property values and {@code @Value} annotations against the current Spring + * {@link Environment} and its {@link Config}. + * + * @author cchang + */ +public class ConfigPlaceholderConfigurer extends PlaceholderConfigurerSupport implements EnvironmentAware { + + public static final String CONFIG_PROPERTY_SOURCE_NAME = "configProperties"; + + public ConfigPlaceholderConfigurer(final Config config) { + this.config = config; + } + + private final Config config; + + private Environment environment; + + @Override + public void setEnvironment(final Environment environment) { + this.environment = environment; + } + + private MutablePropertySources propertySources; + + private PropertySources appliedPropertySources; + + @Override + public void postProcessBeanFactory(final ConfigurableListableBeanFactory beanFactory) { + if (propertySources == null) { + propertySources = new MutablePropertySources(); + if (environment != null) { + propertySources.addLast( + new PropertySource(PropertySourcesPlaceholderConfigurer.ENVIRONMENT_PROPERTIES_PROPERTY_SOURCE_NAME, environment) { + @Override + public String getProperty(final String key) { + return source.getProperty(key); + } + } + ); + } + ConfigPropertySource configPropertySource = new ConfigPropertySource(CONFIG_PROPERTY_SOURCE_NAME, config); + if (localOverride) { + propertySources.addFirst(configPropertySource); + } + else { + propertySources.addLast(configPropertySource); + } + } + processProperties(beanFactory, new PropertySourcesPropertyResolver(propertySources)); + appliedPropertySources = propertySources; + } + + protected void processProperties(final ConfigurableListableBeanFactory beanFactoryToProcess, final ConfigurablePropertyResolver propertyResolver) { + propertyResolver.setPlaceholderPrefix(placeholderPrefix); + propertyResolver.setPlaceholderSuffix(placeholderSuffix); + propertyResolver.setValueSeparator(valueSeparator); + StringValueResolver valueResolver = new StringValueResolver() { + @Override + public String resolveStringValue(final String strVal) { + String resolved = ignoreUnresolvablePlaceholders ? propertyResolver.resolvePlaceholders(strVal) : propertyResolver.resolveRequiredPlaceholders(strVal); + return resolved.equals(nullValue) ? null : resolved; + } + }; + doProcessProperties(beanFactoryToProcess, valueResolver); + } + + /** + * Implemented for compatibility with {@link org.springframework.beans.factory.config.PlaceholderConfigurerSupport}. + * @deprecated in favor of {@link #processProperties(ConfigurableListableBeanFactory, ConfigurablePropertyResolver)} + * @throws UnsupportedOperationException + */ + @Override + @Deprecated + protected void processProperties(final ConfigurableListableBeanFactory beanFactory, final Properties props) { + throw new UnsupportedOperationException("Call processProperties(ConfigurableListableBeanFactory, ConfigurablePropertyResolver) instead"); + } + + /** + * Returns the property sources that were actually applied during + * {@link #postProcessBeanFactory(ConfigurableListableBeanFactory) post-processing}. + * @return the property sources that were applied + * @throws IllegalStateException if the property sources have not yet been applied + * @since 4.0 + */ + public PropertySources getAppliedPropertySources() { + Assert.state(appliedPropertySources != null, "PropertySources have not get been applied"); + return appliedPropertySources; + } + +} diff --git a/src/com/ctzen/config/spring/ConfigPropertySource.java b/src/com/ctzen/config/spring/ConfigPropertySource.java new file mode 100644 index 0000000..8a1e763 --- /dev/null +++ b/src/com/ctzen/config/spring/ConfigPropertySource.java @@ -0,0 +1,31 @@ +package com.ctzen.config.spring; + +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.util.StringUtils; + +import com.ctzen.config.Config; + +/** + * Spring {@link Config} {@link PropertySource} for {@link ConfigPlaceholderConfigurer}. + * + * @author cchang + */ +public class ConfigPropertySource extends EnumerablePropertySource { + + public ConfigPropertySource(final String name, final Config source) { + super(name, source); + } + + @Override + public String[] getPropertyNames() { + return StringUtils.toStringArray(source.keySet()); + } + + @Override + public Object getProperty(final String name) { + // defaultValue null allows @Value annotation to handle missing config + return source.get(name, null); + } + +}