diff --git a/migration/src/main/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfiguration.java b/migration/src/main/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfiguration.java new file mode 100644 index 0000000000..b0994802c8 --- /dev/null +++ b/migration/src/main/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfiguration.java @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2010-2026. Axon Framework + * + * Licensed 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. + */ + +package org.axonframework.migration; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.SourceFile; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.Statement; +import org.openrewrite.java.tree.TypeUtils; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * In Spring Boot applications that use {@code MessageOriginProvider}, creates (or updates) a + * {@code CorrelationDataProviderConfiguration} {@code @Configuration} class that registers the + * provider as a Spring bean with Axon Framework 4-compatible metadata key names. + *

+ * Spring Boot applications typically rely on bean discovery to register + * {@link org.axonframework.messaging.core.correlation.CorrelationDataProvider} implementations. + * After the AF4 → AF5 migration the default constructor of {@code MessageOriginProvider} writes + * metadata under new key names: AF4's {@code "traceId"} (originating-message id, propagated) + * became AF5's default {@code "correlationId"}, and AF4's {@code "correlationId"} (current-message + * id, the direct cause) became AF5's default {@code "causationId"}. This recipe wires the provider + * as a Spring bean with explicit {@code correlationKey="traceId"} / + * {@code causationKey="correlationId"}, preserving the AF4-compatible key names in message + * metadata so existing consumers are not broken. + *

+ * Behavior: + *

+ *

+ * Intended to run as part of the {@code Axon4ToAxon5SpringBootExtension} recipe so that it has + * access to the Spring Boot classpath. Must run after {@code Axon4ToAxon5Messaging} so that + * package renames have already produced the AF5 FQNs in the imports. + * + * @author Mateusz Nowak + * @since 5.1.1 + */ +public class AddMessageOriginProviderSpringBeanConfiguration + extends ScanningRecipe { + + private static final String MOP_AF4 = + "org.axonframework.messaging.correlation.MessageOriginProvider"; + private static final String MOP_AF5 = + "org.axonframework.messaging.core.correlation.MessageOriginProvider"; + private static final String CDP_AF5 = + "org.axonframework.messaging.core.correlation.CorrelationDataProvider"; + private static final String SPRING_BOOT_APP_FQN = + "org.springframework.boot.autoconfigure.SpringBootApplication"; + private static final String BEAN_FQN = + "org.springframework.context.annotation.Bean"; + private static final String CONFIGURATION_FQN = + "org.springframework.context.annotation.Configuration"; + private static final String CONFIG_CLASS_NAME = "CorrelationDataProviderConfiguration"; + private static final String BEAN_METHOD_NAME = "messageOriginProvider"; + + public static class Accumulator { + + boolean messageOriginProviderUsed; + boolean configClassExists; + /** True when the {@code messageOriginProvider} @Bean already returns an explicit-args constructor. */ + boolean configClassHasCustomArgs; + String rootPackage; + } + + @Override + public String getDisplayName() { + return "Create CorrelationDataProviderConfiguration Spring bean for MessageOriginProvider"; + } + + @Override + public String getDescription() { + return "In Spring Boot applications using MessageOriginProvider, creates (or updates) a " + + "`CorrelationDataProviderConfiguration` @Configuration class with a @Bean method " + + "returning `new MessageOriginProvider(\"traceId\", \"correlationId\")` to register " + + "the provider as a Spring bean with Axon Framework 4-compatible metadata key names. " + + "AF5 renamed the keys: AF4's `traceId` (propagated originating-message id) became " + + "AF5's `correlationKey`, and AF4's `correlationId` (direct-cause id) became " + + "AF5's `causationKey`. This bean preserves the old key names in output metadata."; + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new JavaIsoVisitor<>() { + + @Override + public J.NewClass visitNewClass(J.NewClass nc, ExecutionContext ctx) { + if (isMessageOriginProviderNoArgs(nc)) { + acc.messageOriginProviderUsed = true; + } + return super.visitNewClass(nc, ctx); + } + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, + ExecutionContext ctx) { + if (CONFIG_CLASS_NAME.equals(cd.getSimpleName())) { + acc.configClassExists = true; + } + if (acc.rootPackage == null && isSpringBootApplication(cd)) { + if (cd.getType() != null) { + acc.rootPackage = cd.getType().getPackageName(); + } + } + return super.visitClassDeclaration(cd, ctx); + } + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, + ExecutionContext ctx) { + // Detect whether the existing @Bean method already uses explicit constructor args. + // If it does, the developer chose their own keys and we must not override them. + J.ClassDeclaration enclosingClass = getCursor().firstEnclosing(J.ClassDeclaration.class); + if (enclosingClass != null + && CONFIG_CLASS_NAME.equals(enclosingClass.getSimpleName()) + && BEAN_METHOD_NAME.equals(method.getName().getSimpleName()) + && method.getBody() != null) { + for (Statement stmt : method.getBody().getStatements()) { + if (stmt instanceof J.Return ret + && ret.getExpression() instanceof J.NewClass nc + && isMessageOriginProviderClass(nc) + && !isNoArgs(nc)) { + acc.configClassHasCustomArgs = true; + } + } + } + return super.visitMethodDeclaration(method, ctx); + } + + private boolean isMessageOriginProviderClass(J.NewClass nc) { + if (TypeUtils.isOfClassType(nc.getType(), MOP_AF4) + || TypeUtils.isOfClassType(nc.getType(), MOP_AF5)) { + return true; + } + if (nc.getClazz() instanceof J.Identifier) { + return "MessageOriginProvider".equals( + ((J.Identifier) nc.getClazz()).getSimpleName()); + } + return false; + } + + private boolean isNoArgs(J.NewClass nc) { + List args = nc.getArguments(); + return args == null + || args.isEmpty() + || (args.size() == 1 && args.get(0) instanceof J.Empty); + } + + private boolean isMessageOriginProviderNoArgs(J.NewClass nc) { + return isNoArgs(nc) && isMessageOriginProviderClass(nc); + } + + private boolean isSpringBootApplication(J.ClassDeclaration cd) { + for (J.Annotation ann : cd.getLeadingAnnotations()) { + if (TypeUtils.isOfClassType(ann.getType(), SPRING_BOOT_APP_FQN)) { + return true; + } + if (ann.getAnnotationType() instanceof J.Identifier) { + String simpleName = ((J.Identifier) ann.getAnnotationType()).getSimpleName(); + if ("SpringBootApplication".equals(simpleName)) { + return true; + } + } + } + return false; + } + }; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + if (!acc.messageOriginProviderUsed || !acc.configClassExists + || acc.configClassHasCustomArgs) { + // configClassHasCustomArgs: the @Bean already returns an explicit-args constructor — + // the developer intentionally chose their own key names, so we must not override them. + return TreeVisitor.noop(); + } + return new JavaIsoVisitor<>() { + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, + ExecutionContext ctx) { + J.ClassDeclaration result = super.visitClassDeclaration(cd, ctx); + if (!CONFIG_CLASS_NAME.equals(cd.getSimpleName())) { + return result; + } + if (hasBeanMethod(result)) { + // Method already exists (with no-args constructor). + // MigrateMessageOriginProviderDefaultKeys handles updating the args. + return result; + } + result = JavaTemplate.builder( + "@Bean\n" + + "public CorrelationDataProvider messageOriginProvider() {\n" + + " return new MessageOriginProvider(\"traceId\", \"correlationId\");\n" + + "}\n") + .imports(CDP_AF5, MOP_AF5, BEAN_FQN) + .javaParser(JavaParser.fromJavaVersion().classpath(JavaParser.runtimeClasspath())) + .build() + .apply(getCursor(), result.getBody().getCoordinates().lastStatement()); + maybeAddImport(CDP_AF5, null, false); + maybeAddImport(MOP_AF5, null, false); + maybeAddImport(BEAN_FQN, null, false); + return result; + } + + private boolean hasBeanMethod(J.ClassDeclaration cd) { + for (Statement stmt : cd.getBody().getStatements()) { + if (stmt instanceof J.MethodDeclaration method) { + if (BEAN_METHOD_NAME.equals(method.getName().getSimpleName())) { + return true; + } + } + } + return false; + } + }; + } + + @Override + public Collection generate(Accumulator acc, + Collection generatedInThisCycle, + ExecutionContext ctx) { + if (!acc.messageOriginProviderUsed || acc.configClassExists || acc.rootPackage == null) { + return Collections.emptyList(); + } + String pkg = acc.rootPackage; + String source = buildConfigClassSource(pkg); + Path sourcePath = Paths.get( + "src/main/java/" + pkg.replace('.', '/') + "/" + CONFIG_CLASS_NAME + ".java"); + List result = new ArrayList<>(); + JavaParser.fromJavaVersion() + .build() + .parse(source) + .forEach(sf -> result.add(sf.withSourcePath(sourcePath))); + return result; + } + + private static String buildConfigClassSource(String pkg) { + return "package " + pkg + ";\n\n" + + "import " + CDP_AF5 + ";\n" + + "import " + MOP_AF5 + ";\n" + + "import " + BEAN_FQN + ";\n" + + "import " + CONFIGURATION_FQN + ";\n\n" + + "@Configuration\n" + + "public class " + CONFIG_CLASS_NAME + " {\n\n" + + " @Bean\n" + + " public CorrelationDataProvider messageOriginProvider() {\n" + + " return new MessageOriginProvider(\"traceId\", \"correlationId\");\n" + + " }\n" + + "}\n"; + } +} diff --git a/migration/src/main/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeys.java b/migration/src/main/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeys.java new file mode 100644 index 0000000000..fd050add38 --- /dev/null +++ b/migration/src/main/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeys.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2010-2026. Axon Framework + * + * Licensed 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. + */ + +package org.axonframework.migration; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.Markers; + +import java.util.ArrayList; +import java.util.List; + +/** + * Replaces no-args {@code new MessageOriginProvider()} with + * {@code new MessageOriginProvider("traceId", "correlationId")} to preserve the Axon Framework 4 + * default metadata key names after migrating to AF5. + *

+ * In AF4, {@code MessageOriginProvider} put two keys into message metadata: + *

+ * AF5 renamed the keys to better reflect their semantics: + * + * A no-args {@code new MessageOriginProvider()} in AF5 therefore writes metadata under the + * new key names. Downstream systems that still read the old AF4 keys + * ({@code "correlationId"} and {@code "traceId"}) would silently stop receiving those values. + * This recipe pins the constructor to + * {@code new MessageOriginProvider("traceId", "correlationId")} so that the provider continues + * to write metadata under the AF4-compatible key names. + *

+ * Idempotent — constructor calls that already supply explicit arguments are left unchanged. + * Handles both the AF4 FQN ({@code org.axonframework.messaging.correlation.MessageOriginProvider}) + * and the AF5 FQN ({@code org.axonframework.messaging.core.correlation.MessageOriginProvider}), + * so it is order-independent with respect to the package-rename recipe in + * {@code Axon4ToAxon5Messaging}. + * + * @author Mateusz Nowak + * @since 5.1.1 + */ +public class MigrateMessageOriginProviderDefaultKeys extends Recipe { + + private static final String MOP_AF4 = + "org.axonframework.messaging.correlation.MessageOriginProvider"; + private static final String MOP_AF5 = + "org.axonframework.messaging.core.correlation.MessageOriginProvider"; + + @Override + public String getDisplayName() { + return "Pin MessageOriginProvider to Axon Framework 4 default metadata keys"; + } + + @Override + public String getDescription() { + return "Replaces no-args `new MessageOriginProvider()` with " + + "`new MessageOriginProvider(\"traceId\", \"correlationId\")` to preserve the " + + "Axon Framework 4 metadata key names. AF5 renamed the keys: AF4's `traceId` " + + "(originating message, propagated) became AF5's default `correlationId`, and " + + "AF4's `correlationId` (current message) became AF5's default `causationId`. " + + "This recipe pins the AF4-compatible key names so downstream consumers are not broken."; + } + + @Override + public TreeVisitor getVisitor() { + return new JavaIsoVisitor<>() { + + @Override + public J.NewClass visitNewClass(J.NewClass newClass, ExecutionContext ctx) { + J.NewClass nc = super.visitNewClass(newClass, ctx); + if (!isMessageOriginProviderNoArgs(nc)) { + return nc; + } + // Replace the empty argument list with explicit AF4-compatible key names. + // AF5 constructor: MessageOriginProvider(String correlationKey, String causationKey) + // To emit AF4 key names in metadata we pass: + // correlationKey = "traceId" (AF4's "traceId" → now called "correlationKey" in AF5) + // causationKey = "correlationId" (AF4's "correlationId" → now called "causationKey" in AF5) + // The first argument gets no leading space; the second gets a single space so + // the output reads `new MessageOriginProvider("traceId", "correlationId")`. + J.Literal traceIdAsCorrelation = new J.Literal( + Tree.randomId(), Space.EMPTY, Markers.EMPTY, + "traceId", "\"traceId\"", null, JavaType.Primitive.String); + J.Literal correlationIdAsCausation = new J.Literal( + Tree.randomId(), Space.format(" "), Markers.EMPTY, + "correlationId", "\"correlationId\"", null, JavaType.Primitive.String); + List newArgs = new ArrayList<>(2); + newArgs.add(traceIdAsCorrelation); + newArgs.add(correlationIdAsCausation); + return nc.withArguments(newArgs); + } + + private boolean isMessageOriginProviderNoArgs(J.NewClass nc) { + List args = nc.getArguments(); + boolean noArgs = args == null + || args.isEmpty() + || (args.size() == 1 && args.get(0) instanceof J.Empty); + if (!noArgs) { + return false; + } + if (TypeUtils.isOfClassType(nc.getType(), MOP_AF4) + || TypeUtils.isOfClassType(nc.getType(), MOP_AF5)) { + return true; + } + // Fallback for sources whose imports haven't been resolved yet (e.g., when this + // recipe runs before the package-rename recipe within the same cycle). + if (nc.getClazz() instanceof J.Identifier) { + return "MessageOriginProvider".equals( + ((J.Identifier) nc.getClazz()).getSimpleName()); + } + return false; + } + }; + } +} diff --git a/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-extension-spring.yml b/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-extension-spring.yml index 2f02c4104f..355d92249a 100644 --- a/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-extension-spring.yml +++ b/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-extension-spring.yml @@ -137,6 +137,17 @@ recipeList: # developer and the next-running LLM-driven skill see it immediately. - org.axonframework.migration.AnnotateObsoleteSequencingPolicyProperty + # ── MessageOriginProvider: create Spring @Bean with AF4-compatible keys ─ + # Spring Boot applications rely on bean discovery to register + # CorrelationDataProvider implementations. This recipe creates (or updates) + # a `CorrelationDataProviderConfiguration` @Configuration class that exposes + # MessageOriginProvider as a bean with explicit "correlationId" / "traceId" + # keys, preserving the AF4-compatible key names that the surrounding system + # may already depend on. + # Runs after MigrateMessageOriginProviderDefaultKeys (in Axon4ToAxon5Messaging) + # which replaces no-args constructors; this recipe adds the Spring wiring on top. + - org.axonframework.migration.AddMessageOriginProviderSpringBeanConfiguration + --- type: specs.openrewrite.org/v1beta/recipe name: org.axonframework.migration.Axon4ToAxon5SpringBootActuatorExtension diff --git a/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-messaging.yml b/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-messaging.yml index 62a2788f9f..2ca4222764 100644 --- a/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-messaging.yml +++ b/migration/src/main/resources/META-INF/rewrite/axon4-to-axon5-messaging.yml @@ -361,3 +361,14 @@ recipeList: # `CommandGateway`. Anything outside the supported body shapes (single dispatch / try/catch with one # dispatch per branch) is left untouched so the build still surfaces those call sites for the human. - org.axonframework.migration.MigrateCommandGatewayInEventHandler + + # ── MessageOriginProvider: pin AF4-compatible default metadata keys ────── + # AF5 renamed MessageOriginProvider's default metadata keys: + # AF4 `traceId` (propagated originating-message id) → AF5 `correlationId` + # AF4 `correlationId` (current-message id, direct cause) → AF5 `causationId` + # A no-args `new MessageOriginProvider()` in AF5 therefore writes metadata + # under the NEW key names, silently breaking downstream consumers that read + # the old AF4 keys. This recipe replaces every no-args constructor call with + # `new MessageOriginProvider("traceId", "correlationId")` so the provider + # continues to emit metadata under the AF4-compatible key names. + - org.axonframework.migration.MigrateMessageOriginProviderDefaultKeys diff --git a/migration/src/test/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfigurationTest.java b/migration/src/test/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfigurationTest.java new file mode 100644 index 0000000000..483e50976f --- /dev/null +++ b/migration/src/test/java/org/axonframework/migration/AddMessageOriginProviderSpringBeanConfigurationTest.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2010-2026. Axon Framework + * + * Licensed 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. + */ + +package org.axonframework.migration; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.test.TypeValidation; + +import static org.openrewrite.java.Assertions.java; + +/** + * Verifies the {@link AddMessageOriginProviderSpringBeanConfiguration} recipe creates (or updates) + * a {@code CorrelationDataProviderConfiguration} Spring {@code @Configuration} class. + *

+ * The recipe triggers only on a no-args {@code new MessageOriginProvider()} + * constructor call — explicit-arg forms such as + * {@code new MessageOriginProvider("myKey", "myOther")} are left untouched so that developers who + * intentionally chose custom key names are not overridden. + *

+ * The generated / injected bean uses {@code new MessageOriginProvider("traceId", "correlationId")}: + * {@code correlationKey="traceId"} preserves the AF4 trace-id key name, and + * {@code causationKey="correlationId"} preserves the AF4 correlation-id key name. + */ +class AddMessageOriginProviderSpringBeanConfigurationTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AddMessageOriginProviderSpringBeanConfiguration()) + .typeValidationOptions(TypeValidation.none()); + } + + @Test + void generatesConfigClassWhenNoneExistsInSpringBootApp() { + // when: @SpringBootApplication class present + no-args MessageOriginProvider usage + // then: a new CorrelationDataProviderConfiguration is generated in the root package. + // Note: JavaParser preserves the blank lines from the generated source template — + // blank line between `package` and imports, blank line inside the class body. + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = new MessageOriginProvider(); + } + """ + ), + java( + null, + """ + package com.example; + + import org.axonframework.messaging.core.correlation.CorrelationDataProvider; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + + @Configuration + public class CorrelationDataProviderConfiguration { + + @Bean + public CorrelationDataProvider messageOriginProvider() { + return new MessageOriginProvider("traceId", "correlationId"); + } + } + """, + spec -> spec.path("src/main/java/com/example/CorrelationDataProviderConfiguration.java") + ) + ); + } + + @Test + void addsBeanToExistingConfigClassWhenBeanAbsent() { + // when: CorrelationDataProviderConfiguration exists but lacks the @Bean method + // then: the @Bean method is injected by JavaTemplate (no blank line after `package` + // because the original file didn't have one; no blank line inside {} because the + // original body was empty). + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = new MessageOriginProvider(); + } + """ + ), + java( + """ + package com.example; + import org.springframework.context.annotation.Configuration; + + @Configuration + public class CorrelationDataProviderConfiguration { + } + """, + """ + package com.example; + import org.axonframework.messaging.core.correlation.CorrelationDataProvider; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + + @Configuration + public class CorrelationDataProviderConfiguration { + @Bean + public CorrelationDataProvider messageOriginProvider() { + return new MessageOriginProvider("traceId", "correlationId"); + } + } + """ + ) + ); + } + + @Test + void leavesExistingBeanMethodUntouched() { + // Idempotency: if the @Bean already exists, re-running should produce no change. + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = new MessageOriginProvider(); + } + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.CorrelationDataProvider; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + + @Configuration + public class CorrelationDataProviderConfiguration { + + @Bean + public CorrelationDataProvider messageOriginProvider() { + return new MessageOriginProvider("traceId", "correlationId"); + } + } + """ + ) + ); + } + + @Test + void doesNotTriggerOnExplicitConstructorArgs() { + // Developer used custom key names intentionally — recipe must NOT override them. + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = + new MessageOriginProvider("myCorrelation", "myCausation"); + } + """ + ) + // no generated file expected — explicit args prevent the trigger + ); + } + + @Test + void doesNotOverrideBeanWithExplicitArgs() { + // Developer already set custom key names in the @Bean — do not touch. + // The scanner detects explicit args in the return statement and sets the + // "do not override" flag so neither the visitor nor the generator runs. + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ), + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = new MessageOriginProvider(); + } + """ + ), + // config class exists with custom args in the @Bean → must remain unchanged + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.CorrelationDataProvider; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + + @Configuration + public class CorrelationDataProviderConfiguration { + + @Bean + public CorrelationDataProvider messageOriginProvider() { + return new MessageOriginProvider("myCustomCorrelation", "myCustomCausation"); + } + } + """ + ) + ); + } + + @Test + void doesNothingWhenNoMessageOriginProviderUsage() { + // No MessageOriginProvider in the project → no config class generated. + rewriteRun( + java( + """ + package com.example; + import org.springframework.boot.autoconfigure.SpringBootApplication; + + @SpringBootApplication + class Application {} + """ + ) + ); + } + + @Test + void doesNothingWithoutSpringBootApplication() { + // Non-Spring-Boot project: no @SpringBootApplication → can't determine package, + // so no file is generated even when no-args MessageOriginProvider is used. + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class SomeHandler { + private MessageOriginProvider provider = new MessageOriginProvider(); + } + """ + ) + ); + } +} diff --git a/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysKotlinTest.java b/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysKotlinTest.java new file mode 100644 index 0000000000..3f7512b5c0 --- /dev/null +++ b/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysKotlinTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2010-2026. Axon Framework + * + * Licensed 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. + */ + +package org.axonframework.migration; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.test.TypeValidation; + +import static org.openrewrite.kotlin.Assertions.kotlin; + +/** + * Validates {@link MigrateMessageOriginProviderDefaultKeys} on Kotlin sources, mirroring the + * real-world Spring {@code @Bean} shape from the Cinema sample app. + *

+ * A type stub for {@code MessageOriginProvider} is supplied so the Kotlin parser resolves the + * call as a constructor invocation ({@code J.NewClass}). This mirrors a real migration run, where + * the project's compile classpath provides the Axon types — without resolution Kotlin parses + * {@code MessageOriginProvider()} as an ambiguous {@code J.MethodInvocation} instead. + */ +class MigrateMessageOriginProviderDefaultKeysKotlinTest implements RewriteTest { + + private static final String MESSAGE_ORIGIN_PROVIDER_STUB = + """ + package org.axonframework.messaging.core.correlation + interface CorrelationDataProvider + class MessageOriginProvider() : CorrelationDataProvider { + constructor(correlationKey: String, causationKey: String) : this() + } + """; + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new MigrateMessageOriginProviderDefaultKeys()) + .typeValidationOptions(TypeValidation.none()); + } + + @Test + void replacesNoArgsConstructorInKotlinSpringBean() { + rewriteRun( + kotlin(MESSAGE_ORIGIN_PROVIDER_STUB), + kotlin( + """ + package com.example + import org.axonframework.messaging.core.correlation.CorrelationDataProvider + import org.axonframework.messaging.core.correlation.MessageOriginProvider + import org.springframework.context.annotation.Bean + import org.springframework.context.annotation.Configuration + + @Configuration + internal class AxonFrameworkConfiguration { + + @Bean + fun messageOriginProvider(): CorrelationDataProvider { + return MessageOriginProvider() + } + } + """, + """ + package com.example + import org.axonframework.messaging.core.correlation.CorrelationDataProvider + import org.axonframework.messaging.core.correlation.MessageOriginProvider + import org.springframework.context.annotation.Bean + import org.springframework.context.annotation.Configuration + + @Configuration + internal class AxonFrameworkConfiguration { + + @Bean + fun messageOriginProvider(): CorrelationDataProvider { + return MessageOriginProvider("traceId", "correlationId") + } + } + """ + ) + ); + } + + @Test + void leavesConstructorWithExplicitArgsUntouched() { + rewriteRun( + kotlin(MESSAGE_ORIGIN_PROVIDER_STUB), + kotlin( + """ + package com.example + import org.axonframework.messaging.core.correlation.CorrelationDataProvider + import org.axonframework.messaging.core.correlation.MessageOriginProvider + import org.springframework.context.annotation.Bean + import org.springframework.context.annotation.Configuration + + @Configuration + internal class AxonFrameworkConfiguration { + + @Bean + fun messageOriginProvider(): CorrelationDataProvider { + return MessageOriginProvider("myCorrelation", "myCausation") + } + } + """ + ) + ); + } +} diff --git a/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysTest.java b/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysTest.java new file mode 100644 index 0000000000..97d21293b0 --- /dev/null +++ b/migration/src/test/java/org/axonframework/migration/MigrateMessageOriginProviderDefaultKeysTest.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2010-2026. Axon Framework + * + * Licensed 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. + */ + +package org.axonframework.migration; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.test.TypeValidation; + +import static org.openrewrite.java.Assertions.java; + +/** + * Verifies the {@link MigrateMessageOriginProviderDefaultKeys} recipe replaces no-args + * {@code new MessageOriginProvider()} with {@code new MessageOriginProvider("traceId", "correlationId")} + * to preserve AF4-compatible metadata key names. + *

+ * Key mapping (AF4 → AF5 rename, what this recipe preserves): + *

+ */ +class MigrateMessageOriginProviderDefaultKeysTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new MigrateMessageOriginProviderDefaultKeys()) + .typeValidationOptions(TypeValidation.none()); + } + + @Test + void replacesNoArgsConstructorWithAf4Keys() { + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider(); + } + """, + """ + package com.example; + import org.axonframework.messaging.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider("traceId", "correlationId"); + } + """ + ) + ); + } + + @Test + void replacesNoArgsConstructorOnAf5FqnType() { + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider(); + } + """, + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider("traceId", "correlationId"); + } + """ + ) + ); + } + + @Test + void leavesConstructorWithExplicitArgsUntouched() { + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider("myCorrelation", "myCausation"); + } + """ + ) + ); + } + + @Test + void leavesAlreadyMigratedConstructorUntouched() { + // Idempotency: if the recipe already ran, re-running should produce no change. + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.core.correlation.MessageOriginProvider; + + class MyConfiguration { + MessageOriginProvider provider = new MessageOriginProvider("traceId", "correlationId"); + } + """ + ) + ); + } + + @Test + void leavesOtherClassConstructorsUntouched() { + rewriteRun( + java( + """ + package com.example; + + class MyConfiguration { + Object obj = new Object(); + } + """ + ) + ); + } + + @Test + void replacesInMethodBody() { + rewriteRun( + java( + """ + package com.example; + import org.axonframework.messaging.correlation.MessageOriginProvider; + import org.axonframework.messaging.correlation.CorrelationDataProvider; + + class Config { + CorrelationDataProvider provider() { + return new MessageOriginProvider(); + } + } + """, + """ + package com.example; + import org.axonframework.messaging.correlation.MessageOriginProvider; + import org.axonframework.messaging.correlation.CorrelationDataProvider; + + class Config { + CorrelationDataProvider provider() { + return new MessageOriginProvider("traceId", "correlationId"); + } + } + """ + ) + ); + } +}