Module 2: Declarative YAML recipes & preconditions
As a best practice, if your recipe can be declarative (meaning it can be built out of other recipes), then you should make it declarative. You can make some truly powerful migration recipes by combining many tiny recipes together (which have been vetted to handle specific tasks correctly, such as only adding dependencies as needed).
If you completed the Introduction to OpenRewrite workshop, you've already built a declarative recipe in Module 2 when you used the Recipe Builder to combine existing recipes using Moderne. The YAML file you downloaded is a declarative recipe. Now you'll learn how to write or modify one yourself, and then scope it with preconditions.
If you get stuck, you can reference the workshop-solutions branch of the starter repo for completed examples (and you’ll also see code embedded inline throughout the steps).
Exercise 2-1: Write a declarative YAML recipe
In this exercise, you'll build upon a custom migration recipe that replaces Spring's StringUtils with Apache Commons StringUtils.
Goals for this exercise
- Write a declarative YAML recipe that ties together existing recipes.
- Learn how to configure a recipe with options.
- Gain an understanding of the order that recipes are executed and what that means for your recipe options.
Steps
Step 1: Add the ChangeMethodName recipe
A declarative YAML recipe consists of [at least] metadata fields (type, name, displayName, description) and a recipeList field that lists the fully qualified class names of recipes to include, along with their options (if any exist).
The recipe starter project already contains a migration recipe for replacing Spring string utilities with Apache string utilities, but it's just a start and is missing some cases that still need to be covered. For one, you'll want to change from Spring's trimWhitespace(String) to Apache Common's StringUtils.strip(String).
- Open src/main/resources/META-INF/rewrite/stringutils.yml from your project in IntelliJ.
- Add
org.openrewrite.java.ChangeMethodNameto the end ofrecipeList. - Set the options for this recipe as follows:
methodPattern: org.apache.commons.lang3.StringUtils trimWhitespace(java.lang.String)newMethodName: strip
Reference example: stringutils.yml
---
type: specs.openrewrite.org/v1beta/recipe
name: com.yourorg.UseApacheStringUtils
displayName: Use Apache `StringUtils`
description: Replace Spring string utilities with Apache string utilities.
recipeList:
- org.openrewrite.java.dependencies.AddDependency:
groupId: org.apache.commons
artifactId: commons-lang3
version: latest.release
onlyIfUsing: org.springframework.util.StringUtils
configuration: implementation
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: org.springframework.util.StringUtils
newFullyQualifiedTypeName: org.apache.commons.lang3.StringUtils
- org.openrewrite.java.ChangeMethodName:
methodPattern: org.apache.commons.lang3.StringUtils trimWhitespace(java.lang.String)
newMethodName: strip
You may notice that the method pattern actually refers to a method that does not exist. Apache Commons does not have a trimWhitespace method, but Spring does. However, because recipes in the recipeList are executed in order and the ChangeType recipe comes before the new ChangeMethodName recipe, when ChangeMethodName runs, the type will already be Apache Commons and there will no longer be a Spring trimWhitespace method. This is an important point to keep in mind when chaining recipes together.
IntelliJ can suggest recipe options. Place your cursor between description and recipeList, then trigger auto-complete (Ctrl/Cmd + Space) to see optional fields that may be missing (like estimatedEffortPerOccurrence in this example).
You can also click through in IntelliJ on a recipe name (like AddDependency or ChangeType) to open its definition using Ctrl/Cmd + Click.
Step 2: Add a unit test
Even for declarative recipes, you should always write tests. Make sure you expand the tests to cover each case as you add functionality.
- Open src/test/java/com/yourorg/UseApacheStringUtilsTest.java. There are some important things to note in this file that will help you understand how to write effective tests for OpenRewrite:
- This class implements
RewriteTest, overridesdefaults(RecipeSpec)to run the recipe, and configures a classpath that includesspring-core. - The tests only need dependencies required to compile the "before" code, so
spring-coreis enough andcommons-lang3is not needed in the test classpath. - The existing
replacesStringEqualstest usesrewriteRun(SourceSpecs...)with a singlejava(String, String)before/after text block, which asserts the recipe transforms the "before" code into the "after" code. - The
noChangeWhenAlreadyUsingCommonsLang3test only includes a before block, which as the comment mentions, indicates that the after code block should be the same as the before code block. - The
//language=javainjection on the text blocks enables IntelliJ syntax highlighting and code completion.
- This class implements
- Now run the existing
replacesStringEqualstest (use the green play icon to the left of the test method) to confirm it passes. This takes care of that particular case, but now you need to cover the method name change that you just implemented. - Add a unit test that validates
trimWhitespaceis converted tostrip.
Reference example: trimWhitespace test
@Test
void useTrimWhitespace() {
rewriteRun(
//language=java
java(
"""
import org.springframework.util.StringUtils;
class A {
boolean test(String s) {
return StringUtils.trimWhitespace(s);
}
}
""",
"""
import org.apache.commons.lang3.StringUtils;
class A {
boolean test(String s) {
return StringUtils.strip(s);
}
}
"""
)
);
}
- Run the tests and verify that they pass.
Step 3: Reinstall the YAML recipe and validate run
Now that the recipe has been modified, you'll need to reinstall before running it:
mod config recipes yaml install stringutils.yml
cd ~/moderne-workshop
mod run . --recipe=com.yourorg.UseApacheStringUtils
Exercise 2-2: Add preconditions to the declarative recipe
You may not necessarily always want recipes to affect every file in a codebase. For example, a recipe intended for test code should only run on files that are tests, and a recipe that updates ArrayList usage should only run where ArrayList appears. Preconditions are recipes themselves, used to narrow the scope of another recipe so it only runs where it makes sense. This keeps runs focused and fast while also making the recipe easier to understand, debug, and maintain. For additional guidance, check out the Use preconditions section of the recipe conventions guide.
In this exercise, you will update the stringutils.yml recipe to only run on sources that are likely tests by adding a precondition that uses the org.openrewrite.java.search.IsLikelyTest recipe.
Goals for this exercise
- Discover common preconditions, and learn how to combine those with recipes.
Steps
Step 1: Add a precondition
- In src/main/resources/META-INF/rewrite/stringutils.yml, add a
preconditionsfield betweendescriptionandrecipeList. - Under the new
preconditionsfield, add a list with a singleorg.openrewrite.java.search.IsLikelyTestrecipe. You don't need to provide any options for this recipe.
Reference example: stringutils.yml with preconditions
---
type: specs.openrewrite.org/v1beta/recipe
name: com.yourorg.UseApacheStringUtils
displayName: Use Apache `StringUtils`
description: Replace Spring string utilities with Apache string utilities.
preconditions:
- org.openrewrite.java.search.IsLikelyTest
recipeList:
- org.openrewrite.java.dependencies.AddDependency:
groupId: org.apache.commons
artifactId: commons-lang3
version: latest.release
onlyIfUsing: org.springframework.util.StringUtils
configuration: implementation
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: org.springframework.util.StringUtils
newFullyQualifiedTypeName: org.apache.commons.lang3.StringUtils
- org.openrewrite.java.ChangeMethodName:
methodPattern: org.apache.commons.lang3.StringUtils trimWhitespace(java.lang.String)
newMethodName: strip
A precondition is considered "met" for a file if it would make a change to that file. When you list multiple preconditions, all must match.
- From the src/test/java/com/yourorg/UseApacheStringUtilsTest.java file, run the tests again as you did in the previous exercise. Though they passed before, now you should notice that they fail and don't make any changes. This is because of the precondition you added. It has not been met because the sources are not identified as tests.
Step 2: Mark sources as test code
To satisfy the test-only precondition, you need to explicitly mark the test inputs in your RewriteTest sources as test code:
- In src/test/java/com/yourorg/UseApacheStringUtilsTest.java, add a static import for
org.openrewrite.java.Assertions.srcTestJava. - Now, wrap all
java(String, String)sources withsrcTestJava(). This explicitly identifies them as tests. - Re-run the tests and confirm they now pass.
Reference example: UseApacheStringUtilsTest.java
package com.yourorg;
import org.junit.jupiter.api.Test;
import org.openrewrite.DocumentExample;
import org.openrewrite.java.JavaParser;
import org.openrewrite.test.RecipeSpec;
import org.openrewrite.test.RewriteTest;
import static org.openrewrite.java.Assertions.java;
import static org.openrewrite.java.Assertions.srcTestJava;
class UseApacheStringUtilsTest implements RewriteTest {
@Override
public void defaults(RecipeSpec spec) {
spec.recipeFromResources("com.yourorg.UseApacheStringUtils")
.parser(JavaParser.fromJavaVersion().classpath("commons-lang3", "spring-core"));
}
@DocumentExample
@Test
void replacesStringEquals() {
rewriteRun(
//language=java
srcTestJava(
java(
"""
import org.springframework.util.StringUtils;
class A {
boolean test(String s) {
return StringUtils.containsWhitespace(s);
}
}
""",
"""
import org.apache.commons.lang3.StringUtils;
class A {
boolean test(String s) {
return StringUtils.containsWhitespace(s);
}
}
"""
)
)
);
}
@Test
void trimWhitespace() {
rewriteRun(
//language=java
srcTestJava(
java(
"""
import org.springframework.util.StringUtils;
class A {
boolean test(String s) {
return StringUtils.trimWhitespace(s);
}
}
""",
"""
import org.apache.commons.lang3.StringUtils;
class A {
boolean test(String s) {
return StringUtils.strip(s);
}
}
"""
)
)
);
}
}
Step 3: (Optional) Experiment with other preconditions
You may be interested in exploring other Find recipes in the OpenRewrite recipe catalog. These are often used as preconditions for recipes:
- org.openrewrite.FindSourceFiles, to match specific files or directories.
- org.openrewrite.java.migrate.search.FindJavaVersion, to match specific Java versions.
- org.openrewrite.java.search.FindTypes, to find type references by name.
Takeaways
- Declarative recipes are the simplest to write, and are the most common type of recipe.
- Common building blocks can be configured and combined to compose more complex migrations.
- Recipes can be chained together, to make multiple changes to your code in a single run.
- When changing types, keep in mind the order of recipes as subsequent recipes in the
recipeListwill need to use the new type. - Unit tests are a great way to ensure your recipe behaves as expected.
- Preconditions can be used to limit which source files a recipe is run on.