OpenRewrite enables large-scale distributed source code refactoring for framework migrations, vulnerability patches, and API migrations with an early focus on the Java language.

— Introduction to OpenRewrite
https://docs.openrewrite.org

To demonstrate OpenRewrite, this blogpost will walk through upgrading a Spring Boot 1.5 application to 2.5+. Along the way we will pick up JUnit 5, and migrate from Java 8 to 17, with minimal manual intervention.

We’ll start with the Spring PetClinic Sample Application, back as it was almost five years ago in 2017!

You can follow along by copy-pasting the commands and viewing the changes made in each step. You’ll need git, patch and SDKMAN!, to easily switch between Java versions. Be sure to commit your changes after each command to get a clean diff of changes at each step.

Checkout Spring PetClinic 1.5.x

Start by checking out the relevant branch of Spring PetClinic locally.

git clone https://github.com/spring-projects/spring-petclinic.git;
cd spring-petclinic;
git switch -c 2.5.x 1.5.x;

SDKMAN!

SDKMAN! is a tool for managing parallel versions of multiple Software Development Kits on most Unix based systems. It provides a convenient Command Line Interface (CLI) and API for installing, switching, removing and listing Candidates.

— The Software Development Kit Manager
https://sdkman.io

Through SDKMAN! we’ll install and switch between the Java versions used in this blogpost. Start by installing recent versions of Java 8 and 17, and activate Java 8.

# curl -s "https://get.sdkman.io" | bash
sdk install java 8.0.322-tem
sdk install java 17.0.2-tem
sdk use java 8.0.322-tem

Keep the terminal window open to stay on Java 8. Or add a .sdkmanrc file containing java=8.0.322-tem inside spring-petclinic/, to switch automatically each time you enter the project folder.

Apache Maven wrapper

Next up we’ll switch out the almost 7 years old Takari Maven Wrapper 3.3.3 for the most recent Apache Maven Wrapper version 3.8.5. This ensures there’s no incompatibilities running more recent plugins and Java versions.

./mvnw wrapper:wrapper -Dmaven=3.8.5

Verify

At any step in the migration we can verify our setup produces a working build by invoking the Maven verify goal.

./mvnw verify
We need neither clean nor install when using well behaved plugins.

This should produce a working build when run with Java 8. Runs with Java 17 fail for now; we’ll get to those later.

Upgrade to JUnit 5

To gain confidence with OpenRewrite, we’ll first try to migrate our application from JUnit 4 to 5. JUnit 5 was released in September 2017, yet it’s all too common to still see JUnit 4 on existing projects.

Take a moment to think about what would be needed for a JUnit 4 to 5 migration; we will need to add new dependencies, remove old dependencies, update imports, occasionally swap argument orders in assertions, before getting into more complicated patterns such as replacing @Rule usage. It should become apparent that while some steps could be achieved with a search-and-replace operation, others are not as clear cut.

Let’s get OpenRewrite set up to apply our first automated migration:

./mvnw org.openrewrite.maven:rewrite-maven-plugin:4.22.1:init \ (1)
  -Ddependencies=org.openrewrite.recipe:rewrite-testing-frameworks:1.20.1 \ (2)
  -DactiveRecipes=org.openrewrite.java.testing.junit5.JUnit5BestPractices (3)
git diff (4)
1 We run the recently added InitMojo to add the rewrite-maven-plugin to the pom.xml.
2 We add a dependency on rewrite-testing-frameworks to the plugin.
3 We activate the JUnit5BestPractices recipe.
4 The diff should show the plugin added to the project.
Listing 1. pom.xml build plugin added after init
diff --git a/pom.xml b/pom.xml
index 6fdc4d1..755b35a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -206,6 +206,23 @@
           </dependency>
         </dependencies>
       </plugin>
+      <plugin>
+        <groupId>org.openrewrite.maven</groupId>
+        <artifactId>rewrite-maven-plugin</artifactId>
+        <version>4.22.1</version>
+        <configuration>
+          <activeRecipes>
+            <recipe>org.openrewrite.java.testing.junit5.JUnit5BestPractices</recipe>
+          </activeRecipes>
+        </configuration>
+        <dependencies>
+          <dependency>
+            <groupId>org.openrewrite.recipe</groupId>
+            <artifactId>rewrite-testing-frameworks</artifactId>
+            <version>1.20.1</version>
+          </dependency>
+        </dependencies>
+      </plugin>
     </plugins>
   </build>
   <reporting>

If you take a closer look at the added JUnit5BestPractices recipe, you’ll find it’s composed of a recipeList, with references defined further down. Note how the recipeList under JUnit4to5Migration matches some of the migration steps we identified above. This is a common pattern with OpenRewrite recipes; complex migrations are composed of more fine grained recipes, each of which applies a small step in the full migration.

Run JUnit 5 migration

With the plugin configured, next comes running OpenRewrite. Notice how we can use the shorthand rewrite to invoke Maven plugin goals once the plugin is added to the project pom.xml.

./mvnw rewrite:run

This command should complete in about a minute, and migrate all tests to JUnit 5, as well as updating the pom.xml. While most tests in the Spring PetClinic are fairly straightforward, the changes in PetTypeFormatterTests highlight a few strengths of OpenRewrite. Notice how it updates not just the imports and @Test annotations, but also method visibility, applies MockitoExtension and adopts assertThrows.

diff --git a/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java b/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
index f332257..1d5e072 100644
--- a/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
@@ -1,19 +1,20 @@
 package org.springframework.samples.petclinic.owner;

-import static org.junit.Assert.assertEquals;
-
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;

-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.Mockito;
-import org.mockito.runners.MockitoJUnitRunner;
+import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.samples.petclinic.owner.PetRepository;
 import org.springframework.samples.petclinic.owner.PetType;
 import org.springframework.samples.petclinic.owner.PetTypeFormatter;
@@ -23,7 +24,7 @@ import org.springframework.samples.petclinic.owner.PetTypeFormatter;
  *
  * @author Colin But
  */
-@RunWith(MockitoJUnitRunner.class)
+@ExtendWith(MockitoExtension.class)
 public class PetTypeFormatterTests {

     @Mock
@@ -31,13 +32,13 @@ public class PetTypeFormatterTests {

     private PetTypeFormatter petTypeFormatter;

-    @Before
+    @BeforeEach
     public void setup() {
         this.petTypeFormatter = new PetTypeFormatter(pets);
     }

     @Test
-    public void testPrint() {
+    void testPrint() {
         PetType petType = new PetType();
         petType.setName("Hamster");
         String petTypeName = this.petTypeFormatter.print(petType, Locale.ENGLISH);
@@ -45,16 +46,18 @@ public class PetTypeFormatterTests {
     }

     @Test
-    public void shouldParse() throws ParseException {
+    void shouldParse() throws ParseException {
         Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
         PetType petType = petTypeFormatter.parse("Bird", Locale.ENGLISH);
         assertEquals("Bird", petType.getName());
     }

-    @Test(expected = ParseException.class)
-    public void shouldThrowParseException() throws ParseException {
-        Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
-        petTypeFormatter.parse("Fish", Locale.ENGLISH);
+    @Test
+    void shouldThrowParseException() throws ParseException {
+        assertThrows(ParseException.class, () -> {
+            Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
+            petTypeFormatter.parse("Fish", Locale.ENGLISH);
+        });
     }

     /**

Now unfortunately this migration does not yet lead to a working build, due to a Spring Boot incompatibility. JUnit 5 support was added in Spring Boot 2.2, while JUnit 5’s Vintage Engine was dropped in Spring Boot 2.4.

So let’s revert this partial JUnit 5 migration for now, as it will be picked up (correctly) as part of the Spring Boot migration.

git reset --hard

Upgrade to Spring Boot 2.x

To upgrade Spring Boot applications we need a different dependency and recipe. We invoke the rewrite plugin configure goal for the plugin to update it’s own configuration. Neat!

./mvnw rewrite:configure \
  -Ddependencies=org.openrewrite.recipe:rewrite-spring:4.19.2 \
  -DactiveRecipes=org.openrewrite.java.spring.boot2.SpringBoot1To2Migration
The configure goal only works if the plugin is already present; if not replace rewrite:configure with org.openrewrite.maven:rewrite-maven-plugin:4.22.1:init.

We can again see how the SpringBoot1To2Migration is composed of finer grained recipes. If you look closely these migration takes us first to 2.0.x, then 2.1.x, 2.2.x, all the way through to 2.5.x at present, with 2.6.x still in development. As indicated above a JUnit 5 migration will be executed as part of the Spring Boot 2.4.x migration.

We again run OpenRewrite to upgrade our application.

./mvnw rewrite:run

This results in a large changeset, with notable changes to src/main/resources/application.properties and pom.xml, and relatively small changes in src/main/java. The same test changes that we saw before with the isolated migration to JUnit 5 are again present.

Verify & fix tests

The big change now is that we can once again run our build, with a small caveat.

./mvnw verify

Two tests fail after the migration to Spring Boot 2.5.x.

[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR]   ValidatorTests.shouldNotValidateWhenFirstNameEmpty:42
expected: "may not be empty"
but was : "must not be empty"
[ERROR]   VetControllerTests.testShowResourcesVetList:67 Content type
expected: <application/json;charset=UTF-8>
but was: <application/json>
[INFO]
[ERROR] Tests run: 41, Failures: 2, Errors: 0, Skipped: 1

The ValidatorTests.shouldNotValidateWhenFirstNameEmpty failure is caused by a change in the validation implementation. The VetControllerTests.testShowResourcesVetList failure is caused by a change in the returned media type. Both tests are easily fixed with small patch command.

Listing 2. patch ValidatorTests & VetControllerTests
patch -p1 << EOF
diff --git a/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java b/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
index b623330..b5294f4 100644
--- a/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
@@ -39,7 +39,7 @@
         assertThat(constraintViolations.size()).isEqualTo(1);
         ConstraintViolation<Person> violation = constraintViolations.iterator().next();
         assertThat(violation.getPropertyPath().toString()).isEqualTo("firstName");
-        assertThat(violation.getMessage()).isEqualTo("may not be empty");
+        assertThat(violation.getMessage()).isEqualTo("must not be empty");
     }

 }
diff --git a/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java b/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
index 5fd6598..ccb5d78 100644
--- a/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
@@ -64,7 +64,7 @@
     void testShowResourcesVetList() throws Exception {
         ResultActions actions = mockMvc.perform(get("/vets.json").accept(MediaType.APPLICATION_JSON))
                 .andExpect(status().isOk());
-        actions.andExpect(content().contentType("application/json;charset=UTF-8"))
+        actions.andExpect(content().contentTypeCompatibleWith("application/json;charset=UTF-8"))
                 .andExpect(jsonPath("$.vetList[0].id").value(1));
     }
EOF

When we now run the tests again, all tests will pass. That means our migration to Spring Boot 2.5 and JUnit 5 worked!

Upgrade to Java 17

Next we want to adopt the Java 17 runtime.

Replace Cobertura with JaCoCo

To prepare for our Java 17 migration, we first need fix the outdated Cobertura code coverage plugin, which we can best replace with the more modern JaCoCo. We cheat just a little bit by cherry picking that change out of the main branch.

git cherry-pick 60105d5d9a8b64d29927b98cd06d6d811fd4bb52

Java8toJava11 recipe

To upgrade our application itself we need yet another dependency and recipe. We will run the Java8toJava11 recipe, which comprises a number of fixes to be compatible with Java 11 runtime. There’s no need for a recipe yet to explicitly upgrade to Java 17.

./mvnw rewrite:configure \
  -Ddependencies=org.openrewrite.recipe:rewrite-migrate-java:1.4.2 \
  -DactiveRecipes=org.openrewrite.java.migrate.Java8toJava11
./mvnw rewrite:run

On our project the Java8toJava11 recipe produces the following relevant changes.

diff --git a/pom.xml b/pom.xml
index b058c6c..49d7e34 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
   <properties>

     <!-- Generic properties -->
-    <java.version>1.8</java.version>
+    <java.version>11</java.version>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

@@ -25,9 +25,9 @@
     <webjars-bootstrap.version>3.3.6</webjars-bootstrap.version>
     <webjars-jquery-ui.version>1.11.4</webjars-jquery-ui.version>
     <webjars-jquery.version>2.2.4</webjars-jquery.version>
-    <wro4j.version>1.8.0</wro4j.version>
+    <wro4j.version>1.10.1</wro4j.version>

-    <jacoco.version>0.8.1</jacoco.version>
+    <jacoco.version>0.8.7</jacoco.version>

   </properties>

@@ -122,6 +122,12 @@
       <artifactId>jquery-ui</artifactId>
       <version>${webjars-jquery-ui.version}</version>
     </dependency>
+    <dependency>
+      <groupId>com.sun.xml.bind</groupId>
+      <artifactId>jaxb-impl</artifactId>
+      <version>2.3.2</version>
+      <scope>provided</scope>
+    </dependency>
     <dependency>
       <groupId>org.webjars</groupId>
       <artifactId>bootstrap</artifactId>
@@ -245,6 +251,14 @@
           </dependency>
         </dependencies>
       </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jdeprscan-plugin</artifactId>
+        <version>3.0.0-alpha-1</version>
+        <configuration>
+          <release>11</release>
+        </configuration>
+      </plugin>
     </plugins>
   </build>

Notice how the java.version Maven property is changed to 11, which sets maven.compiler.source and maven.compiler.target. This unlocks Java 11 language features such as var.

Java 11 removed the Java EE modules, which is why we need jaxb-impl when running on Java 11+.

The maven-jdeprscan-plugin surfaces any incompatibilities when running the jdeprscan goal on Java 11+.

Along the way Wro4j and JaCoCo are updated to their latest versions as well.

When we run our tests on Java 17 we can see we are now compatible with the Java 17 runtime.

sdk use java 17.0.2-tem
./mvnw verify

Our source level is still at 11 though, so we can not yet use any Java 17 language feautures.

Patch java.version

To update the source level we override the java.version property defined in the Spring Boot parent pom.xml. That sets both maven.compiler.source and maven.compiler.target to use Java 17 as well, unlocking new language features such as text blocks and records.

patch -p1 << EOF
diff --git a/pom.xml b/pom.xml
index 9701237..818e20d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
   <properties>

     <!-- Generic properties -->
-    <java.version>11</java.version>
+    <java.version>17</java.version>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
EOF

Now we are able to start using Java 17 language features in our updated Spring PetClinic application.

Cleanup

When we take a closer look through the application source code, we notice there’s quite a few unnecessary imports. Luckily OpenRewrite also contains a large number of clean up recipes that we can use. The RemoveUnusedImports recipe in particular comes in handy here.

Be aware that OpenRewrite presently requires access to Java compiler internals to function. On JDK 17+ you can opt-in to access these internals via --add-exports in a number of ways. Adding --add-exports statements to .mvn/jvm.config is by far the easiest way to set these parameters.

Reconfigure the plugin to activate and run this new recipe; This recipe does not need any additional dependencies.

./mvnw rewrite:configure \
  -DactiveRecipes=org.openrewrite.java.RemoveUnusedImports
./mvnw rewrite:run

That concludes the Java recipes we will run against the Spring PetClinic for now. But there’s a host of other recipes to explore still; look at the Java collection of recipes to see which might be applicable to your projects.

Also know OpenRewrite is not limited to Java; you can also explore recipes for related technology such as Maven, Gradle, XML, YAML, JSON, GitHub Actions, Kubernetes and more.

Remove plugin

Once we’re done migrating our application, we can have the OpenRewrite Maven plugin remove itself from our pom.xml.

./mvnw rewrite:remove

That concludes our first look at OpenRewrite. Be sure to reach out if you encounter any issues; I’ve found the folks behind OpenRewrite are very responsive with any issues.

Versions used

shadow-left