From 721dc273f41f72c8df382c8450c3d7a34de1dbd0 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 20:48:22 +0300 Subject: [PATCH 1/4] test: Pin merged class-level prefixes of composed controller annotations (#672) --- .../java/ComposedMappingDefaultPathTest.kt | 159 +++++++++++++++++- .../kotlin/ComposedControllerPrefixTest.kt | 124 ++++++++++++++ 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt index 44c5769f7..8a5fb4634 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt @@ -5,17 +5,20 @@ package com.explyt.spring.web.loader.java +import com.explyt.spring.core.service.MetaAnnotationsHolder import com.explyt.spring.test.ExplytJavaLightTestCase import com.explyt.spring.test.TestLibrary import com.explyt.spring.web.SpringWebClasses import com.explyt.spring.web.loader.EndpointElement import com.explyt.spring.web.loader.EndpointType import com.explyt.spring.web.service.SpringWebEndpointsSearcher +import com.explyt.spring.web.util.HandlerMethods import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass import com.intellij.psi.PsiMethod import com.intellij.psi.search.GlobalSearchScope -class ComposedMappingDefaultPathTest : ExplytJavaLightTestCase() { +class ComposedMappingDefaultPathTest : ComposedControllerPrefixJavaTestCase() { override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) @@ -37,6 +40,41 @@ class ComposedMappingDefaultPathTest : ExplytJavaLightTestCase() { assertEquals(listOf("GET"), endpoint.requestMethods) } + fun testAliasDefaultPrefixOfComposedControllerIsServedOnPlainUse() = + assertItemsPaths(ALIAS_TO_PATH, "@ApiController", listOf("/api/items")) + + fun testPrefixSetAtUseSiteOverridesTheControllerAliasDefault() = + assertItemsPaths(ALIAS_TO_PATH, "@ApiController(path = \"/v2\")", listOf("/v2/items")) + + fun testTwoPrefixesSetAtUseSiteServeTheHandlerUnderEach() = + assertItemsPaths(ALIAS_TO_PATH, "@ApiController(path = {\"/x\", \"/y\"})", listOf("/x/items", "/y/items")) + + fun testAliasDefaultOfValueAttributeIsTheControllerPrefix() = + assertItemsPaths(ALIAS_TO_VALUE, "@ApiController", listOf("/dval/items")) + + fun testAliasDefaultReachesThroughTwoComposedLevels() = + assertItemsPaths(ALIAS_TO_PATH, "@ApiNested", listOf("/api/items")) + + fun testPrefixFixedOnTheMetaAnnotationIsTheControllerPrefix() = + assertItemsPaths(FIXED_ON_META, "@ApiController", listOf("/fixed/items")) + + fun testPlainRequestMappingPrefixBesideComposedAnnotation() = + assertItemsPaths(ALIAS_TO_PATH, "@RestController @RequestMapping(\"/p\")", listOf("/p/items")) + + fun testConventionPathDefaultIsTheControllerPrefixBeforeSpring7() = + assertItemsPaths(CONVENTION_PATH, "@ApiController", listOf("/conv/items")) + + fun testHandlerMethodsReadTheAliasDefaultPrefixOfComposedController() { + addItemsController(ALIAS_TO_PATH, "@ApiController") + assertFalse("Precondition: the loader finds the controller", itemsPaths().isEmpty()) + + val prefixes = HandlerMethods.requestMappingPrefixes( + itemsController(), MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + ) + + assertEquals(listOf("/api"), prefixes) + } + private fun addController(usage: String) { myFixture.addFileToProject( "com/example/ReportController.java", """ @@ -84,3 +122,122 @@ class ComposedMappingDefaultPathTest : ExplytJavaLightTestCase() { .single { (it.psiElement as PsiMethod).name == "report" } } } + +class ComposedControllerPrefixSpring7Test : ComposedControllerPrefixJavaTestCase() { + + override val libraries: Array = arrayOf(TestLibrary("org.springframework:spring-webmvc:7.0.9", true)) + + fun testConventionPathDefaultIsIgnoredInSpring7() = + assertItemsPaths(CONVENTION_PATH, "@ApiController", listOf("/items")) + + fun testAliasDefaultPrefixOfComposedControllerIsServedInSpring7() = + assertItemsPaths(ALIAS_TO_PATH, "@ApiController", listOf("/api/items")) +} + +abstract class ComposedControllerPrefixJavaTestCase : ExplytJavaLightTestCase() { + + protected class Composition(val metaMapping: String, val members: String) + + protected fun assertItemsPaths(composition: Composition, usage: String, expected: List) { + addItemsController(composition, usage) + + assertEquals(expected, itemsPaths()) + } + + protected fun addItemsController(composition: Composition, usage: String) { + myFixture.addFileToProject( + "com/example/ApiController.java", """ + package com.example; + + import org.springframework.core.annotation.AliasFor; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @RestController + ${composition.metaMapping} + public @interface ApiController { + ${composition.members} + } + """.trimIndent() + ) + myFixture.addFileToProject( + "com/example/ApiNested.java", """ + package com.example; + + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @ApiController + public @interface ApiNested { + } + """.trimIndent() + ) + myFixture.addFileToProject( + "com/example/ItemsController.java", """ + package com.example; + + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + + $usage + public class ItemsController { + @GetMapping("/items") + public String items() { + return "items"; + } + } + """.trimIndent() + ) + } + + protected fun itemsController(): PsiClass { + val facade = JavaPsiFacade.getInstance(project) + assertNotNull( + "Precondition: @RequestMapping resolves", + facade.findClass(SpringWebClasses.REQUEST_MAPPING, GlobalSearchScope.allScope(project)) + ) + val composed = facade.findClass("com.example.ApiController", GlobalSearchScope.projectScope(project)) + assertNotNull("Precondition: the composed controller annotation is in the fixture", composed) + assertTrue( + "Precondition: the composed controller annotation is meta-annotated with @RequestMapping", + composed!!.hasAnnotation(SpringWebClasses.REQUEST_MAPPING) + ) + val controller = facade.findClass("com.example.ItemsController", GlobalSearchScope.projectScope(project)) + assertNotNull("Precondition: the controller is in the fixture", controller) + return controller!! + } + + protected fun itemsPaths(): List { + itemsController() + val paths = SpringWebEndpointsSearcher.getInstance(project).getAllEndpoints(module) + .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == "ItemsController" } + .map { it.path } + .sorted() + assertFalse("Precondition: the loader finds the controller", paths.isEmpty()) + return paths + } + + protected companion object { + val ALIAS_TO_PATH = Composition( + "@RequestMapping", + "@AliasFor(annotation = RequestMapping.class, attribute = \"path\") String[] path() default \"/api\";" + ) + val ALIAS_TO_VALUE = Composition( + "@RequestMapping", + "@AliasFor(annotation = RequestMapping.class, attribute = \"value\") String[] value() default \"/dval\";" + ) + val CONVENTION_PATH = Composition("@RequestMapping", "String[] path() default \"/conv\";") + val FIXED_ON_META = Composition("@RequestMapping(\"/fixed\")", "String name() default \"\";") + } +} diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt new file mode 100644 index 000000000..87ab06c64 --- /dev/null +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.web.loader.kotlin + +import com.explyt.spring.test.ExplytKotlinLightTestCase +import com.explyt.spring.test.TestLibrary +import com.explyt.spring.web.SpringWebClasses +import com.explyt.spring.web.loader.EndpointType +import com.explyt.spring.web.service.SpringWebEndpointsSearcher +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.search.GlobalSearchScope + +class ComposedControllerPrefixTest : ExplytKotlinLightTestCase() { + + override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) + + fun testAliasDefaultPrefixOfComposedControllerIsServedOnPlainUse() = + assertItemsPaths("@ApiController", listOf("/api/items")) + + fun testPrefixSetAtUseSiteOverridesTheControllerAliasDefault() = + assertItemsPaths("@ApiController(path = [\"/v2\"])", listOf("/v2/items")) + + fun testTwoPrefixesSetAtUseSiteServeTheHandlerUnderEach() = + assertItemsPaths("@ApiController(path = [\"/x\", \"/y\"])", listOf("/x/items", "/y/items")) + + fun testAliasDefaultOfValueAttributeIsTheControllerPrefix() = + assertItemsPaths("@ValueController", listOf("/dval/items")) + + fun testAliasDefaultReachesThroughTwoComposedLevels() = + assertItemsPaths("@ApiNested", listOf("/api/items")) + + fun testPrefixFixedOnTheMetaAnnotationIsTheControllerPrefix() = + assertItemsPaths("@FixedController", listOf("/fixed/items")) + + private fun assertItemsPaths(usage: String, expected: List) { + addSources(usage) + assertComposedAnnotationsAreMetaAnnotated() + + val paths = SpringWebEndpointsSearcher.getInstance(project).getAllEndpoints(module) + .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == "ItemsController" } + .map { it.path } + .sorted() + + assertFalse("Precondition: the loader finds the controller", paths.isEmpty()) + assertEquals(expected, paths) + } + + private fun assertComposedAnnotationsAreMetaAnnotated() { + val facade = JavaPsiFacade.getInstance(project) + assertNotNull( + "Precondition: @RequestMapping resolves", + facade.findClass(SpringWebClasses.REQUEST_MAPPING, GlobalSearchScope.allScope(project)) + ) + for (name in listOf("ApiController", "ValueController", "FixedController")) { + val composed = facade.findClass("com.example.$name", GlobalSearchScope.projectScope(project)) + assertNotNull("Precondition: $name is in the fixture", composed) + assertTrue( + "Precondition: $name is meta-annotated with @RequestMapping", + composed!!.hasAnnotation(SpringWebClasses.REQUEST_MAPPING) + ) + } + assertNotNull( + "Precondition: ApiNested is in the fixture", + facade.findClass("com.example.ApiNested", GlobalSearchScope.projectScope(project)) + ) + } + + private fun addSources(usage: String) { + myFixture.addFileToProject( + "com/example/ComposedControllers.kt", """ + package com.example + + import org.springframework.core.annotation.AliasFor + import org.springframework.web.bind.annotation.RequestMapping + import org.springframework.web.bind.annotation.RestController + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + @RestController + @RequestMapping + annotation class ApiController( + @get:AliasFor(annotation = RequestMapping::class, attribute = "path") + val path: Array = ["/api"], + ) + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + @RestController + @RequestMapping + annotation class ValueController( + @get:AliasFor(annotation = RequestMapping::class, attribute = "value") + val value: Array = ["/dval"], + ) + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + @RestController + @RequestMapping("/fixed") + annotation class FixedController(val name: String = "") + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + @ApiController + annotation class ApiNested + """.trimIndent() + ) + myFixture.addFileToProject( + "com/example/ItemsController.kt", """ + package com.example + + import org.springframework.web.bind.annotation.GetMapping + + $usage + class ItemsController { + @GetMapping("/items") + fun items(): String = "items" + } + """.trimIndent() + ) + } +} From 79ba9907b24fc8a850c4944cff435703570c7a47 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 20:55:34 +0300 Subject: [PATCH 2/4] fix: Read class-level prefixes of composed controller annotations as Spring merges them (#672) --- CHANGELOG.md | 1 + .../web/loader/SpringWebControllerLoader.kt | 12 ++---------- .../explyt/spring/web/util/HandlerMethods.kt | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcbf3346..34b76bfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ - fix: Make `gradlew` and `mvnw` executable in a project generated through Spring Initializr, so the first `./gradlew` in a terminal no longer fails with "permission denied" (#60) ### Spring Web +- fix: Read the class-level prefix of a controller annotated with a composed annotation the way Spring merges it: an `@AliasFor` default, such as `String[] path() default "/api"` on an annotation meta-annotated with `@RequestMapping`, also through a second composed level, is now the prefix of the controller's endpoints in the Endpoints tool window, the Spring MCP endpoint tools, the endpoint gutters and "Generate OpenApi Specification", and an attribute named like a `@RequestMapping` path attribute without `@AliasFor` sets the prefix only before Spring 7, as Spring's convention mapping does (#672) - fix: Show the composed path, including enclosing path() and nest() prefixes, in the gutter of a route declared in a Java RouterFunctions builder, and no longer give a request predicate passed as an argument of a builder route its own marker (#609) - fix: Join Kotlin router DSL nest prefixes and inner paths the way Spring does, and stop listing more than 100 prefix × path combinations (#627) - fix: List the operations of a project `@EndpointWebExtension` under its Actuator endpoint, replacing an endpoint operation with the same request predicate (an extension in test sources applies only to an endpoint in test sources), and stop listing an Actuator endpoint that declares no operation, which Boot does not serve (#654) diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/loader/SpringWebControllerLoader.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/loader/SpringWebControllerLoader.kt index 7f0f0954f..326e614ee 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/loader/SpringWebControllerLoader.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/loader/SpringWebControllerLoader.kt @@ -13,7 +13,6 @@ import com.explyt.spring.web.util.MappingPathPlaceholders import com.explyt.spring.web.util.SpringWebUtil import com.explyt.spring.web.util.WebApplicationStack import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedBy -import com.intellij.codeInsight.AnnotationUtil import com.intellij.codeInsight.MetaAnnotationUtil import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project @@ -74,10 +73,7 @@ class SpringWebControllerLoader(private val project: Project) : SpringWebEndpoin module: Module, endpointType: EndpointType, ): List { - val prefixes = HandlerMethods.mappedType(controllerPsiClass) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } - ?.let { requestMappingMah.getAnnotationMemberValues(it, TARGET_VALUE) }.orEmpty() - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - .ifEmpty { listOf("") } + val prefixes = HandlerMethods.requestMappingPrefixes(controllerPsiClass, requestMappingMah).ifEmpty { listOf("") } val result = mutableListOf() @@ -104,8 +100,4 @@ class SpringWebControllerLoader(private val project: Project) : SpringWebEndpoin } return result } - - companion object { - private val TARGET_VALUE = setOf("value") - } -} \ No newline at end of file +} diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt index 3218cb08a..a7e016467 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt @@ -21,10 +21,14 @@ import com.intellij.psi.util.CachedValueProvider import com.intellij.psi.util.CachedValuesManager import com.intellij.psi.util.MethodSignatureUtil import com.intellij.psi.util.PsiModificationTracker +import org.jetbrains.uast.UAnnotated +import org.jetbrains.uast.evaluateString +import org.jetbrains.uast.toUElement object HandlerMethods { private val parameterMethodsKey = Key.create>>("explyt.web.parameterMethods") + private val MAPPING_PATH_ATTRIBUTES = setOf("path", "value") enum class HierarchyOrder { INTERFACES_FIRST, SUPERCLASS_FIRST } @@ -48,10 +52,15 @@ object HandlerMethods { isMapped: (PsiClass) -> Boolean, ): PsiClass? = typeHierarchy(controller, order).firstOrNull(isMapped) - fun requestMappingPrefixes(controller: PsiClass, requestMappingMah: MetaAnnotationsHolder): List = - mappedType(controller) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } - ?.let { requestMappingMah.getAnnotationMemberValues(it, setOf("path", "value")) }.orEmpty() - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } + fun requestMappingPrefixes(controller: PsiClass, requestMappingMah: MetaAnnotationsHolder): List { + val mappedType = mappedType(controller) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } + ?: return emptyList() + val annotated = mappedType.toUElement() as? UAnnotated + ?: return requestMappingMah.getAnnotationMemberValues(mappedType, MAPPING_PATH_ATTRIBUTES) + .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } + return requestMappingMah.getAnnotationValues(annotated, MAPPING_PATH_ATTRIBUTES) + .mapNotNull { it.evaluateString() } + } fun mostSpecificMethod(method: PsiMethod, controller: PsiClass): PsiMethod { val overrides = controller.findMethodsByName(method.name, true) From e716b940f450c1a7f1b712e8d94bfd049abcbcd4 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 21:12:59 +0300 Subject: [PATCH 3/4] test: Pin the first @RequestMapping of a controller as its only prefix (#672) --- .../java/ComposedMappingDefaultPathTest.kt | 43 +++++++++++++ .../kotlin/ComposedControllerPrefixTest.kt | 63 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt index 8a5fb4634..eac4d0275 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMappingDefaultPathTest.kt @@ -75,6 +75,49 @@ class ComposedMappingDefaultPathTest : ComposedControllerPrefixJavaTestCase() { assertEquals(listOf("/api"), prefixes) } + fun testDirectRequestMappingPrefixWinsOverComposedControllerPrefix() = + assertOnlyDirectPrefixIsServed("@ApiController @RequestMapping(\"/p\")") + + fun testDirectRequestMappingPrefixDeclaredFirstWinsOverComposedControllerPrefix() = + assertOnlyDirectPrefixIsServed("@RequestMapping(\"/p\") @ApiController") + + fun testHandlerMethodsReadOnlyTheDirectPrefixBesideComposedController() = + assertHandlerMethodsReadOnlyTheDirectPrefix("@ApiController @RequestMapping(\"/p\")") + + fun testHandlerMethodsReadOnlyTheDirectPrefixDeclaredFirstBesideComposedController() = + assertHandlerMethodsReadOnlyTheDirectPrefix("@RequestMapping(\"/p\") @ApiController") + + private fun assertOnlyDirectPrefixIsServed(usage: String) { + addItemsController(ALIAS_TO_PATH, usage) + assertControllerCarriesDirectAndComposedMapping() + + assertEquals(listOf("/p/items"), itemsPaths()) + } + + private fun assertHandlerMethodsReadOnlyTheDirectPrefix(usage: String) { + addItemsController(ALIAS_TO_PATH, usage) + assertControllerCarriesDirectAndComposedMapping() + assertFalse("Precondition: the loader finds the controller", itemsPaths().isEmpty()) + + val prefixes = HandlerMethods.requestMappingPrefixes( + itemsController(), MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + ) + + assertEquals(listOf("/p"), prefixes) + } + + private fun assertControllerCarriesDirectAndComposedMapping() { + val controller = itemsController() + assertTrue( + "Precondition: the controller carries a direct @RequestMapping", + controller.hasAnnotation(SpringWebClasses.REQUEST_MAPPING) + ) + assertTrue( + "Precondition: the controller carries the composed @ApiController", + controller.hasAnnotation("com.example.ApiController") + ) + } + private fun addController(usage: String) { myFixture.addFileToProject( "com/example/ReportController.java", """ diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt index 87ab06c64..4c5416aaa 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt @@ -5,12 +5,15 @@ package com.explyt.spring.web.loader.kotlin +import com.explyt.spring.core.service.MetaAnnotationsHolder import com.explyt.spring.test.ExplytKotlinLightTestCase import com.explyt.spring.test.TestLibrary import com.explyt.spring.web.SpringWebClasses import com.explyt.spring.web.loader.EndpointType import com.explyt.spring.web.service.SpringWebEndpointsSearcher +import com.explyt.spring.web.util.HandlerMethods import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass import com.intellij.psi.search.GlobalSearchScope class ComposedControllerPrefixTest : ExplytKotlinLightTestCase() { @@ -35,17 +38,72 @@ class ComposedControllerPrefixTest : ExplytKotlinLightTestCase() { fun testPrefixFixedOnTheMetaAnnotationIsTheControllerPrefix() = assertItemsPaths("@FixedController", listOf("/fixed/items")) + fun testDirectRequestMappingPrefixWinsOverComposedControllerPrefix() = + assertOnlyDirectPrefixIsServed("@ApiController @RequestMapping(\"/p\")") + + fun testDirectRequestMappingPrefixDeclaredFirstWinsOverComposedControllerPrefix() = + assertOnlyDirectPrefixIsServed("@RequestMapping(\"/p\") @ApiController") + + fun testHandlerMethodsReadOnlyTheDirectPrefixBesideComposedController() = + assertHandlerMethodsReadOnlyTheDirectPrefix("@ApiController @RequestMapping(\"/p\")") + + fun testHandlerMethodsReadOnlyTheDirectPrefixDeclaredFirstBesideComposedController() = + assertHandlerMethodsReadOnlyTheDirectPrefix("@RequestMapping(\"/p\") @ApiController") + private fun assertItemsPaths(usage: String, expected: List) { addSources(usage) assertComposedAnnotationsAreMetaAnnotated() + assertEquals(expected, itemsPaths()) + } + + private fun assertOnlyDirectPrefixIsServed(usage: String) { + addSources(usage) + assertComposedAnnotationsAreMetaAnnotated() + assertControllerCarriesDirectAndComposedMapping() + + assertEquals(listOf("/p/items"), itemsPaths()) + } + + private fun assertHandlerMethodsReadOnlyTheDirectPrefix(usage: String) { + addSources(usage) + assertComposedAnnotationsAreMetaAnnotated() + assertControllerCarriesDirectAndComposedMapping() + assertFalse("Precondition: the loader finds the controller", itemsPaths().isEmpty()) + + val prefixes = HandlerMethods.requestMappingPrefixes( + itemsController(), MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + ) + + assertEquals(listOf("/p"), prefixes) + } + + private fun itemsPaths(): List { val paths = SpringWebEndpointsSearcher.getInstance(project).getAllEndpoints(module) .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == "ItemsController" } .map { it.path } .sorted() - assertFalse("Precondition: the loader finds the controller", paths.isEmpty()) - assertEquals(expected, paths) + return paths + } + + private fun itemsController(): PsiClass { + val controller = JavaPsiFacade.getInstance(project) + .findClass("com.example.ItemsController", GlobalSearchScope.projectScope(project)) + assertNotNull("Precondition: the controller is in the fixture", controller) + return controller!! + } + + private fun assertControllerCarriesDirectAndComposedMapping() { + val controller = itemsController() + assertTrue( + "Precondition: the controller carries a direct @RequestMapping", + controller.hasAnnotation(SpringWebClasses.REQUEST_MAPPING) + ) + assertTrue( + "Precondition: the controller carries the composed @ApiController", + controller.hasAnnotation("com.example.ApiController") + ) } private fun assertComposedAnnotationsAreMetaAnnotated() { @@ -112,6 +170,7 @@ class ComposedControllerPrefixTest : ExplytKotlinLightTestCase() { package com.example import org.springframework.web.bind.annotation.GetMapping + import org.springframework.web.bind.annotation.RequestMapping $usage class ItemsController { From 7d44c43d39ebe5627f9b5905aa6fed835c887957 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 21:20:13 +0300 Subject: [PATCH 4/4] fix: Use only the nearest @RequestMapping of a controller as its prefix (#672) --- .../service/MergedAnnotationAttributes.kt | 2 ++ .../core/service/MetaAnnotationsHolder.kt | 24 +++++++++++++++---- .../explyt/spring/web/util/HandlerMethods.kt | 6 +++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MergedAnnotationAttributes.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MergedAnnotationAttributes.kt index 3ba809665..482a94cc5 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MergedAnnotationAttributes.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MergedAnnotationAttributes.kt @@ -45,6 +45,8 @@ class MergedAnnotationAttributes( return attributeGroups(path.last().type, targetAttributes).mapNotNull { sourceOf(path, it) } } + fun metaDistanceOf(useSite: PsiAnnotation): Int? = metaPathOf(useSite)?.lastIndex + fun conventionOverrideOf(useSite: PsiAnnotation, targetAttribute: String): ConventionOverride? { if (!conventionMapping) return null val path = metaPathOf(useSite)?.takeIf { it.size > 1 } ?: return null diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MetaAnnotationsHolder.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MetaAnnotationsHolder.kt index 5bc2b1fa2..14db94543 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MetaAnnotationsHolder.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/MetaAnnotationsHolder.kt @@ -129,14 +129,28 @@ class MetaAnnotationsHolder private constructor( val useSiteFqn = useSite.qualifiedName if (useSiteFqn !in annotationByFqn) return emptySet() if (useSiteFqn == rootFqn) return getDeclaredAnnotationMemberValues(annotation, targetMethods) - val sources = MergedAnnotationAttributes( - rootFqn, - { it.qualifiedName in annotationByFqn }, - lazy { MergedAnnotationAttributes.conventionMappingApplies(MergedAnnotationAttributes.moduleOf(useSite)) }, - ).resolve(useSite, targetMethods) ?: return getDeclaredAnnotationMemberValues(annotation, targetMethods) + val sources = mergedAttributesOf(useSite).resolve(useSite, targetMethods) + ?: return getDeclaredAnnotationMemberValues(annotation, targetMethods) return sources.flatMapTo(linkedSetOf()) { getValues(expressionOf(it, annotation)) } } + fun nearestAnnotation(uAnnotated: UAnnotated): UAnnotation? = + uAnnotated.uAnnotations + .filter { contains(it) } + .minByOrNull { annotation -> annotation.javaPsi?.let(::metaDistanceOf) ?: Int.MAX_VALUE } + + fun nearestAnnotation(owner: PsiModifierListOwner): PsiAnnotation? = + owner.annotations.filter { contains(it) }.minByOrNull(::metaDistanceOf) + + private fun metaDistanceOf(useSite: PsiAnnotation): Int = + mergedAttributesOf(useSite).metaDistanceOf(useSite) ?: Int.MAX_VALUE + + private fun mergedAttributesOf(useSite: PsiAnnotation) = MergedAnnotationAttributes( + rootFqn, + { it.qualifiedName in annotationByFqn }, + lazy { MergedAnnotationAttributes.conventionMappingApplies(MergedAnnotationAttributes.moduleOf(useSite)) }, + ) + private fun expressionOf(source: MergedAnnotationAttributes.Source, useSite: UAnnotation): UExpression? = when (source) { is MergedAnnotationAttributes.Source.Default -> MergedAnnotationAttributes.defaultValueOf(source.attribute) diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt index a7e016467..12b762120 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/HandlerMethods.kt @@ -56,9 +56,11 @@ object HandlerMethods { val mappedType = mappedType(controller) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } ?: return emptyList() val annotated = mappedType.toUElement() as? UAnnotated - ?: return requestMappingMah.getAnnotationMemberValues(mappedType, MAPPING_PATH_ATTRIBUTES) + ?: return requestMappingMah.nearestAnnotation(mappedType) + ?.let { requestMappingMah.getAnnotationMemberValues(it, MAPPING_PATH_ATTRIBUTES) }.orEmpty() .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - return requestMappingMah.getAnnotationValues(annotated, MAPPING_PATH_ATTRIBUTES) + return requestMappingMah.nearestAnnotation(annotated) + ?.let { requestMappingMah.getAnnotationMemberValues(it, MAPPING_PATH_ATTRIBUTES) }.orEmpty() .mapNotNull { it.evaluateString() } }