diff --git a/src/main/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClient.java b/src/main/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClient.java new file mode 100644 index 000000000..5a8101328 --- /dev/null +++ b/src/main/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClient.java @@ -0,0 +1,131 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.spring.cloud2022; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Preconditions; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.AddOrUpdateAnnotationAttribute; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.RemoveAnnotation; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.TypeUtils; + +public class MigrateRequestMappingOnFeignClient extends Recipe { + + private static final String FEIGN_CLIENT = "org.springframework.cloud.openfeign.FeignClient"; + + private static final String REQUEST_MAPPING = "org.springframework.web.bind.annotation.RequestMapping"; + + @Override + public String getDisplayName() { + return "Migrate `@RequestMapping` on `FeignClient` to `@FeignClient` path attribute"; + } + + @Override + public String getDescription() { + return "Support for `@RequestMapping` over a `FeignClient` interface was removed in Spring Cloud OpenFeign 2.2.10.RELEASE."; + } + + @Override + public TreeVisitor getVisitor() { + return Preconditions.check(Preconditions.and( + new UsesType<>(FEIGN_CLIENT, false), + new UsesType<>(REQUEST_MAPPING, false)), + new JavaIsoVisitor() { + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) { + J.Annotation requestMapping = classDecl.getLeadingAnnotations().stream() + .filter(a -> TypeUtils.isOfClassType(a.getType(), REQUEST_MAPPING)) + .findFirst().orElse(null); + J.Annotation feignClient = classDecl.getLeadingAnnotations().stream() + .filter(a -> TypeUtils.isOfClassType(a.getType(), FEIGN_CLIENT)) + .findFirst().orElse(null); + + if (requestMapping != null && feignClient != null) { + J.ClassDeclaration cd = classDecl; + if (requestMapping.getArguments() == null || requestMapping.getArguments().isEmpty()) { + cd = removeRequestMapping(cd, ctx); + } else if (requestMapping.getArguments().size() == 1) { + String pathValueFromRequestMapping = getPathValue(requestMapping.getArguments().get(0)); + if (pathValueFromRequestMapping != null && !hasPathAttribute(feignClient)) { + cd = removeRequestMapping(cd, ctx); + cd = addAttributeToFeignClient(cd, ctx, pathValueFromRequestMapping); + } + } + return cd; + } + return super.visitClassDeclaration(classDecl, ctx); + } + + private boolean hasPathAttribute(J.Annotation annotation) { + if (annotation.getArguments() == null || annotation.getArguments().isEmpty()) { + return false; + } + return annotation.getArguments().stream().anyMatch(arg -> { + if (arg instanceof J.Assignment) { + J.Assignment assignment = (J.Assignment) arg; + if (assignment.getVariable() instanceof J.Identifier) { + J.Identifier variable = (J.Identifier) assignment.getVariable(); + return "path".equals(variable.getSimpleName()); + } + } + return false; + }); + } + + private J.ClassDeclaration addAttributeToFeignClient(J.ClassDeclaration cd, ExecutionContext ctx, String path) { + return cd.withLeadingAnnotations( + ListUtils.map(cd.getLeadingAnnotations(), a -> (J.Annotation) + new AddOrUpdateAnnotationAttribute(FEIGN_CLIENT, "path", + path, true, false).getVisitor() + .visit(a, ctx, getCursor().getParentOrThrow()))); + } + + private J.ClassDeclaration removeRequestMapping(J.ClassDeclaration classDecl, ExecutionContext ctx) { + maybeRemoveImport(REQUEST_MAPPING); + return classDecl.withLeadingAnnotations(ListUtils.map(classDecl.getLeadingAnnotations(), + a -> (J.Annotation) new RemoveAnnotation(REQUEST_MAPPING).getVisitor() + .visit(a, ctx, getCursor().getParentOrThrow()))); + } + + private String getPathValue(Expression arg) { + if (arg instanceof J.Literal) { + J.Literal literal = (J.Literal) arg; + return (String) literal.getValue(); + } else if (arg instanceof J.Assignment) { + J.Assignment assignment = (J.Assignment) arg; + if (assignment.getVariable() instanceof J.Identifier) { + J.Identifier variable = (J.Identifier) assignment.getVariable(); + if ("path".equals(variable.getSimpleName()) || "value".equals(variable.getSimpleName())) { + Expression expression = assignment.getAssignment(); + if (expression instanceof J.Literal) { + J.Literal value = (J.Literal) expression; + return (String) value.getValue(); + } + } + } + } + return null; + } + }); + } + +} diff --git a/src/main/resources/META-INF/rewrite/classpath/spring-cloud-openfeign-core-4.1.0.jar b/src/main/resources/META-INF/rewrite/classpath/spring-cloud-openfeign-core-4.1.0.jar new file mode 100644 index 000000000..b678ecf38 Binary files /dev/null and b/src/main/resources/META-INF/rewrite/classpath/spring-cloud-openfeign-core-4.1.0.jar differ diff --git a/src/main/resources/META-INF/rewrite/spring-cloud-2022.yml b/src/main/resources/META-INF/rewrite/spring-cloud-2022.yml index b4fec0fc1..e2a733e33 100644 --- a/src/main/resources/META-INF/rewrite/spring-cloud-2022.yml +++ b/src/main/resources/META-INF/rewrite/spring-cloud-2022.yml @@ -25,6 +25,7 @@ tags: recipeList: - org.openrewrite.java.spring.cloud2022.DependencyUpgrades - org.openrewrite.java.spring.cloud2022.MigrateCloudSleuthToMicrometerTracing + - org.openrewrite.java.spring.cloud2022.MigrateRequestMappingOnFeignClient --- type: specs.openrewrite.org/v1beta/recipe diff --git a/src/test/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClientTest.java b/src/test/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClientTest.java new file mode 100644 index 000000000..b2b8711cc --- /dev/null +++ b/src/test/java/org/openrewrite/java/spring/cloud2022/MigrateRequestMappingOnFeignClientTest.java @@ -0,0 +1,313 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.openrewrite.java.spring.cloud2022; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class MigrateRequestMappingOnFeignClientTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec + .recipe(new MigrateRequestMappingOnFeignClient()) + .parser(JavaParser.fromJavaVersion() + .classpathFromResources(new InMemoryExecutionContext(), + "spring-web", "spring-cloud-openfeign-core")); + } + + @Test + @DocumentExample + void migrateRequestMappingAnnotation() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping(path = "/posts") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """, + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(path = "/posts", name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void requestMappingWithDefaultAttributeName() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping("/posts") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """, + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(path = "/posts", name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void requestMappingWithValueAttributeName() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping(value = "/posts") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """, + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.GetMapping; + + @FeignClient(path = "/posts", name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @GetMapping(value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void withRequestMappingAnnotationOnMethod() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping(path = "/posts") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """, + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(path = "/posts", name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void withNonRequestMappingOnFeignClientButWithOneOnMethod() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void requestMappingAnnotationWithMultipleAttributes() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping(headers = "X-My-Header=MyValue", path = "/posts") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void requestMappingAnnotationWithNonAttributes() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """, + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void requestMappingAnnotationWithOnlyHeadersAttribute() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080") + @RequestMapping(headers = "X-My-Header=MyValue") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + + @Test + void feignClientAnnotationAlreadyHasPathAttribute() { + rewriteRun( + //language=java + java( + """ + import org.springframework.cloud.openfeign.FeignClient; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import org.springframework.web.bind.annotation.PathVariable; + + @FeignClient(name = "myService", url = "http://localhost:8080", path = "/api/v1") + @RequestMapping(path = "/posts") + public interface MyServiceClient { + + @RequestMapping(method = RequestMethod.GET, value = "/{postId}", produces = "application/json") + String getPostById(@PathVariable("postId") Long postId); + } + """ + ) + ); + } + +}