You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by rm...@apache.org on 2019/08/08 09:47:43 UTC

[maven-shade-plugin] branch master updated: [MSHADE-322] - adding properties transformer (+ its microprofile and openwebbeans specific cases)

This is an automated email from the ASF dual-hosted git repository.

rmannibucau pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-shade-plugin.git


The following commit(s) were added to refs/heads/master by this push:
     new f122b47  [MSHADE-322] - adding properties transformer (+ its microprofile and openwebbeans specific cases)
f122b47 is described below

commit f122b4731993ca06cf8d5a052608be4dd5fc95d3
Author: Romain Manni-Bucau <rm...@apache.org>
AuthorDate: Fri Jul 12 20:13:04 2019 +0200

    [MSHADE-322] - adding properties transformer (+ its microprofile and openwebbeans specific cases)
---
 .../properties/MicroprofileConfigTransformer.java  |  33 +++
 .../OpenWebBeansPropertiesTransformer.java         |  33 +++
 .../resource/properties/PropertiesTransformer.java | 234 ++++++++++++++++++++
 .../properties/io/NoCloseOutputStream.java         |  67 ++++++
 .../io/SkipPropertiesDateLineWriter.java           |  97 +++++++++
 src/site/apt/examples/resource-transformers.apt.vm | 122 +++++++++++
 .../properties/PropertiesTransformerTest.java      | 143 +++++++++++++
 .../shade/resource/rule/TransformerTesterRule.java | 235 +++++++++++++++++++++
 8 files changed, 964 insertions(+)

diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/properties/MicroprofileConfigTransformer.java b/src/main/java/org/apache/maven/plugins/shade/resource/properties/MicroprofileConfigTransformer.java
new file mode 100644
index 0000000..ecc49b2
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/shade/resource/properties/MicroprofileConfigTransformer.java
@@ -0,0 +1,33 @@
+package org.apache.maven.plugins.shade.resource.properties;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Enables to merge Microprofile Config configuration files properly respecting their ordinal.
+ *
+ * @since 3.2.2
+ */
+public class MicroprofileConfigTransformer extends PropertiesTransformer
+{
+    protected MicroprofileConfigTransformer()
+    {
+        super( null, "config_ordinal", 1000, false );
+    }
+}
diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/properties/OpenWebBeansPropertiesTransformer.java b/src/main/java/org/apache/maven/plugins/shade/resource/properties/OpenWebBeansPropertiesTransformer.java
new file mode 100644
index 0000000..180ac54
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/shade/resource/properties/OpenWebBeansPropertiesTransformer.java
@@ -0,0 +1,33 @@
+package org.apache.maven.plugins.shade.resource.properties;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Enables to merge openwebbeans configuration files properly respecting their ordinal.
+ *
+ * @since 3.2.2
+ */
+public class OpenWebBeansPropertiesTransformer extends PropertiesTransformer
+{
+    protected OpenWebBeansPropertiesTransformer()
+    {
+        super( "META-INF/openwebbeans/openwebbeans.properties", "configuration.ordinal", 100, false );
+    }
+}
diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformer.java b/src/main/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformer.java
new file mode 100644
index 0000000..a6a7aff
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformer.java
@@ -0,0 +1,234 @@
+package org.apache.maven.plugins.shade.resource.properties;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import org.apache.maven.plugins.shade.relocation.Relocator;
+import org.apache.maven.plugins.shade.resource.ResourceTransformer;
+import org.apache.maven.plugins.shade.resource.properties.io.NoCloseOutputStream;
+import org.apache.maven.plugins.shade.resource.properties.io.SkipPropertiesDateLineWriter;
+
+/**
+ * Enables to merge a set of properties respecting priority between them.
+ *
+ * @since 3.2.2
+ */
+public class PropertiesTransformer implements ResourceTransformer
+{
+    private String resource;
+    private String alreadyMergedKey;
+    private String ordinalKey;
+    private int defaultOrdinal;
+    private boolean reverseOrder;
+
+    private final List<Properties> properties = new ArrayList<>();
+
+    public PropertiesTransformer()
+    {
+        // no-op
+    }
+
+    protected PropertiesTransformer( final String resource, final String ordinalKey,
+                                     final int defaultOrdinal, final boolean reversed )
+    {
+        this.resource = resource;
+        this.ordinalKey = ordinalKey;
+        this.defaultOrdinal = defaultOrdinal;
+        this.reverseOrder = reversed;
+    }
+
+    @Override
+    public boolean canTransformResource( final String resource )
+    {
+        return Objects.equals( resource, this.resource );
+    }
+
+    @Override
+    public void processResource( final String resource, final InputStream is, final List<Relocator> relocators )
+            throws IOException
+    {
+        final Properties p = new Properties();
+        p.load( is );
+        properties.add( p );
+    }
+
+    @Override
+    public boolean hasTransformedResource()
+    {
+        return !properties.isEmpty();
+    }
+
+    @Override
+    public void modifyOutputStream( final JarOutputStream os ) throws IOException
+    {
+        if ( properties.isEmpty() )
+        {
+            return;
+        }
+
+        final Properties out = mergeProperties( sortProperties() );
+        if ( ordinalKey != null )
+        {
+            out.remove( ordinalKey );
+        }
+        if ( alreadyMergedKey != null )
+        {
+            out.remove( alreadyMergedKey );
+        }
+        os.putNextEntry( new JarEntry( resource ) );
+        final BufferedWriter writer = new SkipPropertiesDateLineWriter(
+                new OutputStreamWriter( new NoCloseOutputStream( os ), StandardCharsets.ISO_8859_1 ) );
+        out.store( writer, " Merged by maven-shade-plugin (" + getClass().getName() + ")" );
+        writer.close();
+        os.closeEntry();
+    }
+
+    public void setReverseOrder( final boolean reverseOrder )
+    {
+        this.reverseOrder = reverseOrder;
+    }
+
+    public void setResource( final String resource )
+    {
+        this.resource = resource;
+    }
+
+    public void setOrdinalKey( final String ordinalKey )
+    {
+        this.ordinalKey = ordinalKey;
+    }
+
+    public void setDefaultOrdinal( final int defaultOrdinal )
+    {
+        this.defaultOrdinal = defaultOrdinal;
+    }
+
+    public void setAlreadyMergedKey( final String alreadyMergedKey )
+    {
+        this.alreadyMergedKey = alreadyMergedKey;
+    }
+
+    private List<Properties> sortProperties()
+    {
+        final List<Properties> sortedProperties = new ArrayList<>();
+        boolean foundMaster = false;
+        for ( final Properties current : properties )
+        {
+            if ( alreadyMergedKey != null )
+            {
+                final String master = current.getProperty( alreadyMergedKey );
+                if ( Boolean.parseBoolean( master ) )
+                {
+                    if ( foundMaster )
+                    {
+                        throw new IllegalStateException(
+                                "Ambiguous merged values: " + sortedProperties + ", " + current );
+                    }
+                    foundMaster = true;
+                    sortedProperties.clear();
+                    sortedProperties.add( current );
+                }
+            }
+            if ( !foundMaster )
+            {
+                final int configOrder = getConfigurationOrdinal( current );
+
+                int i;
+                for ( i = 0; i < sortedProperties.size(); i++ )
+                {
+                    int listConfigOrder = getConfigurationOrdinal( sortedProperties.get( i ) );
+                    if ( ( !reverseOrder && listConfigOrder > configOrder )
+                            || ( reverseOrder && listConfigOrder < configOrder ) )
+                    {
+                        break;
+                    }
+                }
+                sortedProperties.add( i, current );
+            }
+        }
+        return sortedProperties;
+    }
+
+    private int getConfigurationOrdinal( final Properties p )
+    {
+        if ( ordinalKey == null )
+        {
+            return defaultOrdinal;
+        }
+        final String configOrderString = p.getProperty( ordinalKey );
+        if ( configOrderString != null && configOrderString.length() > 0 )
+        {
+            return Integer.parseInt( configOrderString );
+        }
+        return defaultOrdinal;
+    }
+
+    private static Properties mergeProperties( final List<Properties> sortedProperties )
+    {
+        final Properties mergedProperties = new Properties()
+        {
+            @Override
+            public synchronized Enumeration<Object> keys() // ensure it is sorted to be deterministic
+            {
+                final List<String> keys = new LinkedList<>();
+                for ( Object k : super.keySet() )
+                {
+                    keys.add( (String) k );
+                }
+                Collections.sort( keys );
+                final Iterator<String> it = keys.iterator();
+                return new Enumeration<Object>()
+                {
+                    @Override
+                    public boolean hasMoreElements()
+                    {
+                        return it.hasNext();
+                    }
+
+                    @Override
+                    public Object nextElement()
+                    {
+                        return it.next();
+                    }
+                };
+            }
+        };
+        for ( final Properties p : sortedProperties )
+        {
+            mergedProperties.putAll( p );
+        }
+        return mergedProperties;
+    }
+}
diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/NoCloseOutputStream.java b/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/NoCloseOutputStream.java
new file mode 100644
index 0000000..b956044
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/NoCloseOutputStream.java
@@ -0,0 +1,67 @@
+package org.apache.maven.plugins.shade.resource.properties.io;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Simple output stream replacing close call by a simpe flush.
+ * Useful for output streams nesting streams (like jar output streams) and using a stream encoder.
+ */
+public class NoCloseOutputStream extends OutputStream
+{
+    private final OutputStream delegate;
+
+    public NoCloseOutputStream( OutputStream delegate )
+    {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public void write( int b ) throws IOException
+    {
+        delegate.write( b );
+    }
+
+    @Override
+    public void write( byte[] b ) throws IOException
+    {
+        delegate.write( b );
+    }
+
+    @Override
+    public void write( byte[] b, int off, int len ) throws IOException
+    {
+        delegate.write( b, off, len );
+    }
+
+    @Override
+    public void flush() throws IOException
+    {
+        delegate.flush();
+    }
+
+    @Override
+    public void close() throws IOException
+    {
+        delegate.flush();
+    }
+}
diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/SkipPropertiesDateLineWriter.java b/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/SkipPropertiesDateLineWriter.java
new file mode 100644
index 0000000..663c700
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/shade/resource/properties/io/SkipPropertiesDateLineWriter.java
@@ -0,0 +1,97 @@
+package org.apache.maven.plugins.shade.resource.properties.io;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * Simple buffered writer skipping its first write(String) call.
+ */
+public class SkipPropertiesDateLineWriter extends BufferedWriter
+{
+    private State currentState = State.MUST_SKIP_DATE_COMMENT;
+
+    public SkipPropertiesDateLineWriter( Writer out )
+    {
+        super( out );
+    }
+
+    @Override
+    public void write( String str ) throws IOException
+    {
+        if ( currentState.shouldSkip( str ) )
+        {
+            currentState = currentState.next();
+            return;
+        }
+        super.write( str );
+    }
+
+    private enum State
+    {
+        MUST_SKIP_DATE_COMMENT
+        {
+            @Override
+            boolean shouldSkip( String content )
+            {
+                return content.length() > 1 && content.startsWith( "#" ) && !content.startsWith( "# " );
+            }
+
+            @Override
+            State next()
+            {
+                return SKIPPED_DATE_COMMENT;
+            }
+        },
+        SKIPPED_DATE_COMMENT
+        {
+            @Override
+            boolean shouldSkip( String content )
+            {
+                return content.trim().isEmpty();
+            }
+
+            @Override
+            State next()
+            {
+                return DONE;
+            }
+        },
+        DONE
+        {
+            @Override
+            boolean shouldSkip( String content )
+            {
+                return false;
+            }
+
+            @Override
+            State next()
+            {
+                throw new UnsupportedOperationException( "done is a terminal state" );
+            }
+        };
+
+        abstract boolean shouldSkip( String content );
+        abstract State next();
+    }
+}
diff --git a/src/site/apt/examples/resource-transformers.apt.vm b/src/site/apt/examples/resource-transformers.apt.vm
index 1a09833..7248027 100644
--- a/src/site/apt/examples/resource-transformers.apt.vm
+++ b/src/site/apt/examples/resource-transformers.apt.vm
@@ -47,8 +47,14 @@ Resource Transformers
 *-----------------------------------------+------------------------------------------+
 | {{ManifestResourceTransformer}}         | Sets entries in the <<<MANIFEST>>>       |
 *-----------------------------------------+------------------------------------------+
+| {{MicroprofileConfigTransformer}}       | Merges conflicting Microprofile Config properties based on an ordinal |
+*-----------------------------------------+------------------------------------------+
+| {{OpenWebBeansPropertiesTransformer}}   | Merges Apache OpenWebBeans configuration files |
+*-----------------------------------------+------------------------------------------+
 | {{PluginXmlResourceTransformer}}        | Aggregates Mavens <<<plugin.xml>>>       |
 *-----------------------------------------+------------------------------------------+
+| {{PropertiesTransformer}}               | Merges properties files owning an ordinal to solve conflicts |
+*-----------------------------------------+------------------------------------------+
 | {{ResourceBundleAppendingTransformer}}  | Merges ResourceBundles                  | 
 *-----------------------------------------+------------------------------------------+
 | {{ServicesResourceTransformer}}         | Relocated class names in <<<META-INF/services>>> resources and merges them. |
@@ -538,3 +544,119 @@ Transformers in <<<org.apache.maven.plugins.shade.resource>>>
  ...
 </project>
 +-----
+
+* Merging properties files with {PropertiesTransformer}
+
+  The <<<PropertiesTransformer>>> allows a set of properties files to be merged and to resolve conflicts
+  based on an ordinal giving the priority of each file.
+  An optional <<<alreadyMergedKey>>> enables to have a boolean flag in the file which, if set to true,
+  request to use the file as it as the result of the merge. If two files are considered complete in the
+  merge process then the shade will fail.
+
++-----
+<project>
+  ...
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-shade-plugin</artifactId>
+        <version>${project.version}</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>shade</goal>
+            </goals>
+            <configuration>
+              <transformers>
+                <transformer implementation="org.apache.maven.plugins.shade.resource.properties.PropertiesTransformer">
+                  <!-- required configuration -->
+                  <resource>configuration/application.properties</resource>
+                  <ordinalKey>ordinal</ordinalKey>
+                  <!-- optional configuration -->
+                  <alreadyMergedKey>already_merged</alreadyMergedKey>
+                  <defaultOrdinal>0</defaultOrdinal>
+                  <reverseOrder>false</reverseOrder>
+                </transformer>
+              </transformers>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+  ...
+</project>
++-----
+
+* Merging Apache OpenWebBeans configuration with {OpenWebBeansPropertiesTransformer}
+
+  <<<OpenWebBeansPropertiesTransformer>>> preconfigure a <<<PropertiesTransformer>>>
+  for Apache OpenWebBeans configuration files.
+
+
++-----
+<project>
+  ...
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-shade-plugin</artifactId>
+        <version>${project.version}</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>shade</goal>
+            </goals>
+            <configuration>
+              <transformers>
+                <transformer implementation="org.apache.maven.plugins.shade.resource.properties.OpenWebBeansPropertiesTransformer" />
+              </transformers>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+  ...
+</project>
++-----
+
+* Merging Microprofile Config properties with {MicroprofileConfigTransformer}
+
+  <<<MicroprofileConfigTransformer>>> preconfigure a <<<PropertiesTransformer>>>
+  for Microprofile Config. The only required configuration is the ordinal.
+  The <<<alreadyMergedKey>>> is supported but is not defined by the specification.
+
+
++-----
+<project>
+  ...
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-shade-plugin</artifactId>
+        <version>${project.version}</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>shade</goal>
+            </goals>
+            <configuration>
+              <transformers>
+                <transformer implementation="org.apache.maven.plugins.shade.resource.properties.MicroprofileConfigTransformer">
+                  <resource>configuration/app.properties</resource>
+                </transformer>
+              </transformers>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+  ...
+</project>
++-----
+
diff --git a/src/test/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformerTest.java b/src/test/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformerTest.java
new file mode 100644
index 0000000..bd5f0e1
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/shade/resource/properties/PropertiesTransformerTest.java
@@ -0,0 +1,143 @@
+package org.apache.maven.plugins.shade.resource.properties;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.apache.maven.plugins.shade.resource.properties.io.NoCloseOutputStream;
+import org.apache.maven.plugins.shade.resource.properties.io.SkipPropertiesDateLineWriter;
+import org.apache.maven.plugins.shade.resource.rule.TransformerTesterRule;
+import org.apache.maven.plugins.shade.resource.rule.TransformerTesterRule.Property;
+import org.apache.maven.plugins.shade.resource.rule.TransformerTesterRule.Resource;
+import org.apache.maven.plugins.shade.resource.rule.TransformerTesterRule.TransformerTest;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+
+public class PropertiesTransformerTest
+{
+    @Rule
+    public final TestRule tester = new TransformerTesterRule();
+
+    @Test
+    public void propertiesRewritingIsStable() throws IOException
+    {
+        final Properties properties = new Properties();
+        properties.setProperty("a", "1");
+        properties.setProperty("b", "2");
+
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        final BufferedWriter writer = new SkipPropertiesDateLineWriter(
+                new OutputStreamWriter( new NoCloseOutputStream( os ), StandardCharsets.ISO_8859_1 ) );
+        properties.store( writer, " Merged by maven-shade-plugin" );
+        writer.close();
+        os.close();
+
+        assertEquals(
+            "# Merged by maven-shade-plugin\n" +
+            "b=2\n" +
+            "a=1\n", os.toString("UTF-8"));
+    }
+
+    @Test
+    public void canTransform()
+    {
+        final PropertiesTransformer transformer = new PropertiesTransformer();
+        transformer.setResource("foo/bar/my.properties");
+        assertTrue(transformer.canTransformResource("foo/bar/my.properties"));
+        assertFalse(transformer.canTransformResource("whatever"));
+    }
+
+    @Test
+    @TransformerTest(
+            transformer = PropertiesTransformer.class,
+            configuration = @Property(name = "resource", value = "foo/bar/my.properties"),
+            visited = {
+                    @Resource(path = "foo/bar/my.properties", content = "a=b"),
+                    @Resource(path = "foo/bar/my.properties", content = "c=d"),
+            },
+            expected = @Resource(path = "foo/bar/my.properties", content = "#.*\na=b\nc=d\n")
+    )
+    public void mergeWithoutOverlap()
+    {
+    }
+
+    @Test
+    @TransformerTest(
+            transformer = PropertiesTransformer.class,
+            configuration = {
+                    @Property(name = "resource", value = "foo/bar/my.properties"),
+                    @Property(name = "ordinalKey", value = "priority")
+            },
+            visited = {
+                    @Resource(path = "foo/bar/my.properties", content = "a=d\npriority=3"),
+                    @Resource(path = "foo/bar/my.properties", content = "a=b\npriority=1"),
+                    @Resource(path = "foo/bar/my.properties", content = "a=c\npriority=2"),
+            },
+            expected = @Resource(path = "foo/bar/my.properties", content = "#.*\na=d\n")
+    )
+    public void mergeWithOverlap()
+    {
+    }
+
+    @Test
+    @TransformerTest(
+            transformer = PropertiesTransformer.class,
+            configuration = {
+                    @Property(name = "resource", value = "foo/bar/my.properties"),
+                    @Property(name = "alreadyMergedKey", value = "complete")
+            },
+            visited = {
+                    @Resource(path = "foo/bar/my.properties", content = "a=b\ncomplete=true"),
+                    @Resource(path = "foo/bar/my.properties", content = "a=c\npriority=2"),
+            },
+            expected = @Resource(path = "foo/bar/my.properties", content = "#.*\na=b\n")
+    )
+    public void mergeWithAlreadyMerged()
+    {
+    }
+
+    @Test
+    @TransformerTest(
+            transformer = PropertiesTransformer.class,
+            configuration = {
+                    @Property(name = "resource", value = "foo/bar/my.properties"),
+                    @Property(name = "alreadyMergedKey", value = "complete")
+            },
+            visited = {
+                    @Resource(path = "foo/bar/my.properties", content = "a=b\ncomplete=true"),
+                    @Resource(path = "foo/bar/my.properties", content = "a=c\ncomplete=true"),
+            },
+            expected = {},
+            expectedException = IllegalStateException.class
+    )
+    public void alreadyMergeConflict()
+    {
+    }
+}
diff --git a/src/test/java/org/apache/maven/plugins/shade/resource/rule/TransformerTesterRule.java b/src/test/java/org/apache/maven/plugins/shade/resource/rule/TransformerTesterRule.java
new file mode 100644
index 0000000..88d3e93
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/shade/resource/rule/TransformerTesterRule.java
@@ -0,0 +1,235 @@
+package org.apache.maven.plugins.shade.resource.rule;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarInputStream;
+import java.util.jar.JarOutputStream;
+
+import org.apache.maven.plugins.shade.relocation.Relocator;
+import org.apache.maven.plugins.shade.resource.ResourceTransformer;
+import org.codehaus.plexus.component.configurator.ComponentConfigurationException;
+import org.codehaus.plexus.component.configurator.converters.ConfigurationConverter;
+import org.codehaus.plexus.component.configurator.converters.lookup.ConverterLookup;
+import org.codehaus.plexus.component.configurator.converters.lookup.DefaultConverterLookup;
+import org.codehaus.plexus.component.configurator.expression.DefaultExpressionEvaluator;
+import org.codehaus.plexus.configuration.DefaultPlexusConfiguration;
+import org.codehaus.plexus.configuration.PlexusConfiguration;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public class TransformerTesterRule implements TestRule
+{
+    @Override
+    public Statement apply( final Statement base, final Description description )
+    {
+        return new Statement()
+        {
+            @Override
+            public void evaluate() throws Throwable
+            {
+                final TransformerTest spec = description.getAnnotation( TransformerTest.class );
+                if ( spec == null )
+                {
+                    base.evaluate();
+                    return;
+                }
+
+                final Map<String, String> jar;
+                try
+                {
+                    final ResourceTransformer transformer = createTransformer(spec);
+                    visit(spec, transformer);
+                    jar = captureOutput(transformer);
+                }
+                catch ( final Exception ex )
+                {
+                    if ( Exception.class.isAssignableFrom( spec.expectedException() ) )
+                    {
+                        assertTrue(
+                                ex.getClass().getName(),
+                                spec.expectedException().isAssignableFrom( ex.getClass() ) );
+                        return;
+                    }
+                    else
+                    {
+                        throw ex;
+                    }
+                }
+                asserts(spec, jar);
+            }
+        };
+    }
+
+    private void asserts( final TransformerTest spec, final Map<String, String> jar)
+    {
+        if ( spec.strictMatch() && jar.size() != spec.expected().length )
+        {
+            fail( "Strict match test failed: " + jar );
+        }
+        for ( final Resource expected : spec.expected() )
+        {
+            final String content = jar.get( expected.path() );
+            assertNotNull( expected.path(), content );
+            assertTrue(
+                    expected.path() + ", expected=" + expected.content() + ", actual=" + content,
+                    content.matches( expected.content() ) );
+        }
+    }
+
+    private Map<String, String> captureOutput(final ResourceTransformer transformer ) throws IOException
+    {
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try ( final JarOutputStream jar = new JarOutputStream( out ) )
+        {
+            transformer.modifyOutputStream( jar );
+        }
+
+        final Map<String, String> created = new HashMap<>();
+        try ( final JarInputStream jar = new JarInputStream( new ByteArrayInputStream( out.toByteArray() ) ) )
+        {
+            JarEntry entry;
+            while ( ( entry = jar.getNextJarEntry() ) != null )
+            {
+                created.put( entry.getName(), read( jar ) );
+            }
+        }
+        return created;
+    }
+
+    private void visit( final TransformerTest spec, final ResourceTransformer transformer ) throws IOException
+    {
+        for ( final Resource resource : spec.visited() )
+        {
+            if ( transformer.canTransformResource( resource.path() ))
+            {
+                transformer.processResource(
+                        resource.path(),
+                        new ByteArrayInputStream( resource.content().getBytes(StandardCharsets.UTF_8) ),
+                        Collections.<Relocator>emptyList() );
+            }
+        }
+    }
+
+    private String read(final JarInputStream jar) throws IOException
+    {
+        final StringBuilder builder = new StringBuilder();
+        final byte[] buffer = new byte[512];
+        int read;
+        while ( (read = jar.read(buffer) ) >= 0 )
+        {
+            builder.append( new String( buffer, 0, read ) );
+        }
+        return builder.toString();
+    }
+
+    private ResourceTransformer createTransformer(final TransformerTest spec)
+    {
+        final ConverterLookup lookup = new DefaultConverterLookup();
+        try
+        {
+            final ConfigurationConverter converter = lookup.lookupConverterForType( spec.transformer() );
+            final PlexusConfiguration configuration = new DefaultPlexusConfiguration( "configuration" );
+            for ( final Property property : spec.configuration() )
+            {
+                configuration.addChild( property.name(), property.value() );
+            }
+            return ResourceTransformer.class.cast(
+                    converter.fromConfiguration( lookup, configuration,  spec.transformer(), spec.transformer(),
+                        Thread.currentThread().getContextClassLoader(),
+                        new DefaultExpressionEvaluator() ) );
+        }
+        catch (final ComponentConfigurationException e)
+        {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Enables to describe a test without having to implement the logic itself.
+     */
+    @Target(METHOD)
+    @Retention(RUNTIME)
+    public @interface TransformerTest
+    {
+        /**
+         * @return the list of resource the transformer will process.
+         */
+        Resource[] visited();
+
+        /**
+         * @return the expected output created by the transformer.
+         */
+        Resource[] expected();
+
+        /**
+         * @return true if only expected resources must be found.
+         */
+        boolean strictMatch() default true;
+
+        /**
+         * @return type of transformer to use.
+         */
+        Class<?> transformer();
+
+        /**
+         * @return transformer configuration.
+         */
+        Property[] configuration();
+
+        /**
+         * @return if set to an exception class it ensures it is thrown during the processing.
+         */
+        Class<?> expectedException() default Object.class;
+    }
+
+    @Target(METHOD)
+    @Retention(RUNTIME)
+    public @interface Property
+    {
+        String name();
+        String value();
+    }
+
+    @Target(METHOD)
+    @Retention(RUNTIME)
+    public @interface Resource
+    {
+        String path();
+        String content();
+    }
+}