From be09f49e9d31cf7a53371a3b1c154a7f68c058cb Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Wed, 14 Oct 2026 23:35:31 +0300 Subject: [PATCH 1/4] test: Pin method-level composed request mappings as Spring registers them (#672) --- .../RequestMappingDuplicateInspectionTest.kt | 97 ++++++++- .../RequestMappingDuplicateInspectionTest.kt | 73 ++++++- .../loader/ComposedMethodMappingFixture.kt | 131 +++++++++++++ .../loader/java/ComposedMethodMappingTest.kt | 184 ++++++++++++++++++ .../kotlin/ComposedMethodMappingTest.kt | 179 +++++++++++++++++ 5 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt create mode 100644 modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt create mode 100644 modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/java/RequestMappingDuplicateInspectionTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/java/RequestMappingDuplicateInspectionTest.kt index 84c951113..ca5bbc816 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/java/RequestMappingDuplicateInspectionTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/java/RequestMappingDuplicateInspectionTest.kt @@ -8,6 +8,8 @@ package com.explyt.spring.web.inspections.java import com.explyt.spring.test.ExplytInspectionJavaTestCase import com.explyt.spring.test.TestLibrary import com.explyt.spring.web.inspections.RequestMappingDuplicateInspection +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.search.GlobalSearchScope import org.intellij.lang.annotations.Language class RequestMappingDuplicateInspectionTest : ExplytInspectionJavaTestCase() { @@ -73,4 +75,97 @@ class RequestMappingDuplicateInspectionTest : ExplytInspectionJavaTestCase() { myFixture.configureByText("DuplicatedEndpointController.java", trimIndent) myFixture.testHighlighting("DuplicatedEndpointController.java") } -} \ No newline at end of file + + fun testComposedMappingWithAliasDefaultPathDuplicatesAGetMapping() { + addComposedMapping() + @Language("JAVA") val controller = """ + import com.example.ApiGet; + import org.springframework.web.bind.annotation.*; + + @RestController + public class ComposedDuplicateController { + + @ApiGet + public String bare() { + return "bare"; + } + + @GetMapping("/items") + public String listed() { + return "listed"; + } + } + """.trimIndent() + myFixture.configureByText("ComposedDuplicateController.java", controller) + myFixture.testHighlighting("ComposedDuplicateController.java") + } + + fun testComposedMappingWithPathSetAtUseSiteDoesNotDuplicateTheAliasDefault() { + addComposedMapping() + @Language("JAVA") val controller = """ + import com.example.ApiGet; + import org.springframework.web.bind.annotation.*; + + @RestController + public class ComposedDistinctController { + + @ApiGet(path = "/v2") + public String useSite() { + return "useSite"; + } + + @GetMapping("/items") + public String listed() { + return "listed"; + } + + @PostMapping("/same") + public String firstPost() { + return "firstPost"; + } + + @PostMapping("/same") + public String secondPost() { + return "secondPost"; + } + } + """.trimIndent() + myFixture.configureByText("ComposedDistinctController.java", controller) + myFixture.testHighlighting("ComposedDistinctController.java") + } + + private fun addComposedMapping() { + val composedFile = myFixture.addFileToProject( + "com/example/ApiGet.java", """ + package com.example; + + import org.springframework.core.annotation.AliasFor; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) + @RequestMapping(method = RequestMethod.GET) + public @interface ApiGet { + @AliasFor(annotation = RequestMapping.class, attribute = "path") + String[] path() default "/items"; + } + """.trimIndent() + ) + myFixture.allowTreeAccessForFile(composedFile.virtualFile) + val composed = myFixture.findClass("com.example.ApiGet") + assertTrue( + "Precondition: @ApiGet is meta-annotated with @RequestMapping", + composed.hasAnnotation("org.springframework.web.bind.annotation.RequestMapping") + ) + assertNotNull( + "Precondition: @RequestMapping resolves", + JavaPsiFacade.getInstance(project) + .findClass("org.springframework.web.bind.annotation.RequestMapping", GlobalSearchScope.allScope(project)) + ) + } +} diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/kotlin/RequestMappingDuplicateInspectionTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/kotlin/RequestMappingDuplicateInspectionTest.kt index fc600f9d3..5683aa28e 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/kotlin/RequestMappingDuplicateInspectionTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/inspections/kotlin/RequestMappingDuplicateInspectionTest.kt @@ -59,4 +59,75 @@ class RequestMappingDuplicateInspectionTest : ExplytInspectionKotlinTestCase() { myFixture.configureByText("DuplicatedEndpointController.kt", trimIndent) myFixture.testHighlighting("DuplicatedEndpointController.kt") } -} \ No newline at end of file + + fun testComposedMappingWithAliasDefaultPathDuplicatesAGetMapping() { + addComposedMapping() + @Language("kotlin") val controller = """ + import com.example.ApiGet + import org.springframework.web.bind.annotation.* + + @RestController + class ComposedDuplicateController { + + @ApiGet + fun bare() = "bare" + + @GetMapping("/items") + fun listed() = "listed" + } + """.trimIndent() + myFixture.configureByText("ComposedDuplicateController.kt", controller) + myFixture.testHighlighting("ComposedDuplicateController.kt") + } + + fun testComposedMappingWithPathSetAtUseSiteDoesNotDuplicateTheAliasDefault() { + addComposedMapping() + @Language("kotlin") val controller = """ + import com.example.ApiGet + import org.springframework.web.bind.annotation.* + + @RestController + class ComposedDistinctController { + + @ApiGet(path = ["/v2"]) + fun useSite() = "useSite" + + @GetMapping("/items") + fun listed() = "listed" + + @PostMapping("/same") + fun firstPost() = "firstPost" + + @PostMapping("/same") + fun secondPost() = "secondPost" + } + """.trimIndent() + myFixture.configureByText("ComposedDistinctController.kt", controller) + myFixture.testHighlighting("ComposedDistinctController.kt") + } + + private fun addComposedMapping() { + myFixture.addFileToProject( + "com/example/ApiGet.kt", """ + package com.example + + import org.springframework.core.annotation.AliasFor + import org.springframework.web.bind.annotation.RequestMapping + import org.springframework.web.bind.annotation.RequestMethod + + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) + @RequestMapping(method = [RequestMethod.GET]) + annotation class ApiGet( + @get:AliasFor(annotation = RequestMapping::class, attribute = "path") + val path: Array = ["/items"], + ) + """.trimIndent() + ) + val composed = myFixture.findClass("com.example.ApiGet") + assertTrue( + "Precondition: @ApiGet is meta-annotated with @RequestMapping", + composed.hasAnnotation("org.springframework.web.bind.annotation.RequestMapping") + ) + } +} diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt new file mode 100644 index 000000000..900df4a76 --- /dev/null +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.web.loader + +import com.explyt.spring.core.service.MetaAnnotationsHolder +import com.explyt.spring.web.SpringWebClasses +import com.explyt.spring.web.inspections.quickfix.AddEndpointToOpenApiIntention.EndpointInfo +import com.explyt.spring.web.providers.ControllerEndpointActionsLineMarkerProvider +import com.explyt.spring.web.providers.EndpointIconGutterHandler +import com.explyt.spring.web.providers.EndpointRunLineMarkerProvider +import com.explyt.spring.web.providers.RunInSwaggerAction +import com.explyt.spring.web.service.SpringWebEndpointsSearcher +import com.explyt.spring.web.util.HandlerMethods +import com.explyt.spring.web.util.SpringWebUtil +import com.intellij.codeInsight.daemon.LineMarkerInfo +import com.intellij.openapi.module.Module +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.toUElement + +abstract class ComposedMethodMappingFixture( + private val codeInsightFixture: CodeInsightTestFixture, + private val module: Module, +) { + + data class MappingShape( + val path: String, + val methods: List, + val produces: List = emptyList(), + val consumes: List = emptyList(), + ) + + abstract fun identifierOf(method: PsiMethod): PsiElement + + fun endpointRows(methodName: String): List>> { + val rows = controllerEndpoints() + assertTrue( + "Precondition: $methodName is a handler of the fixture", + handlerNames().contains(methodName) + ) + return rows.filter { it.first == methodName }.map { it.second to it.third }.sortedBy { it.first } + } + + fun gutterShape(methodName: String): MappingShape { + assertComposedMappingsAreMetaAnnotated() + val markers = mutableListOf>() + ControllerEndpointActionsLineMarkerProvider() + .collectSlowLineMarkers(mutableListOf(identifierOf(handler(methodName))), markers) + assertEquals("Precondition: one Endpoint Actions marker on $methodName", 1, markers.size) + return (markers.single().navigationHandler as EndpointIconGutterHandler).endpointInfo.shape() + } + + fun runInSwaggerShape(methodName: String): MappingShape { + assertComposedMappingsAreMetaAnnotated() + val actions = EndpointRunLineMarkerProvider().getInfo(identifierOf(handler(methodName)))?.actions + ?.filterIsInstance().orEmpty() + assertEquals("Precondition: one Run in Swagger action on $methodName", 1, actions.size) + return actions.single().endpoints().single().shape() + } + + fun openApiShape(methodName: String): MappingShape { + assertComposedMappingsAreMetaAnnotated() + val controller = controller() + val prefix = HandlerMethods.requestMappingPrefixes( + controller, MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + ).single() + val uMethod = handler(methodName).toUElement() as UMethod + val endpoint = SpringWebUtil.getEndpointInfo(uMethod, prefix) + assertNotNull("Precondition: $methodName is described for OpenAPI", endpoint) + return endpoint!!.shape() + } + + private fun EndpointInfo.shape() = + MappingShape(path, requestMethods, produces.toList(), consumes.toList()) + + private fun controllerEndpoints(): List>> { + assertComposedMappingsAreMetaAnnotated() + val rows = SpringWebEndpointsSearcher.getInstance(codeInsightFixture.project).getAllEndpoints(module) + .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == "ItemsController" } + .map { Triple((it.psiElement.toUElement() as? UMethod)?.name, it.path, it.requestMethods) } + assertFalse("Precondition: the loader finds the controller", rows.isEmpty()) + return rows + } + + private fun handlerNames(): Set = controller().methods.mapTo(mutableSetOf()) { it.name } + + private fun handler(methodName: String): PsiMethod { + val methods = controller().findMethodsByName(methodName, false) + assertEquals("Precondition: $methodName is a handler of the fixture", 1, methods.size) + return methods.single() + } + + private fun controller(): PsiClass { + val controller = facade().findClass("com.example.ItemsController", GlobalSearchScope.projectScope(codeInsightFixture.project)) + assertNotNull("Precondition: the controller is in the fixture", controller) + return controller!! + } + + private fun assertComposedMappingsAreMetaAnnotated() { + val projectScope = GlobalSearchScope.projectScope(codeInsightFixture.project) + assertNotNull( + "Precondition: @RequestMapping resolves", + facade().findClass(SpringWebClasses.REQUEST_MAPPING, GlobalSearchScope.allScope(codeInsightFixture.project)) + ) + for (name in listOf("ApiGet", "ApiConv")) { + val composed = facade().findClass("com.example.$name", projectScope) + assertNotNull("Precondition: $name is in the fixture", composed) + assertTrue( + "Precondition: $name is meta-annotated with @RequestMapping", + composed!!.hasAnnotation(SpringWebClasses.REQUEST_MAPPING) + ) + } + val twoLevel = facade().findClass("com.example.ApiGetV", projectScope) + assertNotNull("Precondition: ApiGetV is in the fixture", twoLevel) + assertTrue("Precondition: ApiGetV is meta-annotated with @ApiGet", twoLevel!!.hasAnnotation("com.example.ApiGet")) + } + + private fun facade() = JavaPsiFacade.getInstance(codeInsightFixture.project) +} diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt new file mode 100644 index 000000000..bb4c4036b --- /dev/null +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.explyt.spring.web.loader.java + +import com.explyt.spring.test.ExplytJavaLightTestCase +import com.explyt.spring.test.TestLibrary +import com.explyt.spring.web.loader.ComposedMethodMappingFixture +import com.explyt.spring.web.loader.ComposedMethodMappingFixture.MappingShape +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod + +class ComposedMethodMappingTest : ComposedMethodMappingJavaTestCase() { + + override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) + + fun testBareComposedMappingServesItsAliasDefaultPath() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) + + fun testPathSetAtUseSiteOverridesTheAliasDefault() = + assertEquals(listOf("/api/v2" to listOf("GET")), fixture.endpointRows("useSite")) + + fun testOnlyTheFirstOfTwoComposedMappingsIsServed() = + assertEquals(listOf("/api/a" to listOf("GET")), fixture.endpointRows("getThenPost")) + + fun testOnlyTheFirstOfTwoComposedMappingsIsServedInReversedOrder() = + assertEquals(listOf("/api/b" to listOf("POST")), fixture.endpointRows("postThenGet")) + + fun testDirectRequestMappingBeatsComposedMappingDeclaredAfterIt() = + assertEquals(listOf("/api/d" to emptyList()), fixture.endpointRows("directThenComposed")) + + fun testDirectRequestMappingBeatsComposedMappingDeclaredBeforeIt() = + assertEquals(listOf("/api/d" to emptyList()), fixture.endpointRows("composedThenDirect")) + + fun testAliasDefaultReachesThroughTwoComposedLevels() = + assertEquals(listOf("/api/lvl" to listOf("GET")), fixture.endpointRows("twoLevel")) + + fun testConventionPathOfComposedMappingIsServedBeforeSpring7() = + assertEquals(listOf("/api/conv" to listOf("GET")), fixture.endpointRows("convention")) + + fun testEndpointActionsGutterReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.gutterShape("bare")) + + fun testEndpointActionsGutterReadsThePathSetAtUseSite() = + assertEquals(MappingShape("/api/v2", listOf("GET")), fixture.gutterShape("useSite")) + + fun testEndpointActionsGutterReadsOnlyTheFirstComposedMapping() = + assertEquals(GET_A, fixture.gutterShape("getThenPost")) + + fun testEndpointActionsGutterReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.gutterShape("postThenGet")) + + fun testEndpointActionsGutterLetsTheDirectRequestMappingDeclaredFirstWin() = + assertEquals(MappingShape("/api/d", emptyList()), fixture.gutterShape("directThenComposed")) + + fun testEndpointActionsGutterLetsTheDirectRequestMappingDeclaredLastWin() = + assertEquals(MappingShape("/api/d", emptyList()), fixture.gutterShape("composedThenDirect")) + + fun testRunInSwaggerReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.runInSwaggerShape("bare")) + + fun testRunInSwaggerReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.runInSwaggerShape("postThenGet")) + + fun testOpenApiEndpointInfoReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.openApiShape("bare")) + + fun testOpenApiEndpointInfoReadsOnlyTheFirstComposedMapping() = + assertEquals(GET_A, fixture.openApiShape("getThenPost")) + + fun testOpenApiEndpointInfoReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.openApiShape("postThenGet")) + + private companion object { + val GET_A = MappingShape("/api/a", listOf("GET"), produces = listOf("text/plain")) + val POST_B = MappingShape("/api/b", listOf("POST"), consumes = listOf("application/json")) + } +} + +class ComposedMethodMappingSpring7Test : ComposedMethodMappingJavaTestCase() { + + override val libraries: Array = arrayOf(TestLibrary("org.springframework:spring-webmvc:7.0.9", true)) + + fun testConventionPathOfComposedMappingIsIgnoredInSpring7() = + assertEquals(listOf("/api" to listOf("GET")), fixture.endpointRows("convention")) + + fun testBareComposedMappingServesItsAliasDefaultPathInSpring7() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) +} + +abstract class ComposedMethodMappingJavaTestCase : ExplytJavaLightTestCase() { + + protected val fixture by lazy { + object : ComposedMethodMappingFixture(myFixture, module) { + override fun identifierOf(method: PsiMethod): PsiElement = method.nameIdentifier!! + } + } + + override fun setUp() { + super.setUp() + addSources() + } + + private fun addSources() { + myFixture.addFileToProject( + "com/example/ComposedMappings.java", """ + package com.example; + + import org.springframework.core.annotation.AliasFor; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) + @RequestMapping(method = RequestMethod.GET) + @interface ApiGet { + @AliasFor(annotation = RequestMapping.class, attribute = "path") + String[] path() default "/items"; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.METHOD) + @RequestMapping(method = RequestMethod.GET) + @interface ApiConv { + String[] path() default "/conv"; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.METHOD) + @ApiGet(path = "/lvl") + @interface ApiGetV { + } + """.trimIndent() + ) + myFixture.addFileToProject( + "com/example/ItemsController.java", """ + package com.example; + + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.PostMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + + @RestController + @RequestMapping("/api") + public class ItemsController { + @ApiGet + public String bare() { return ""; } + + @ApiGet(path = "/v2") + public String useSite() { return ""; } + + @GetMapping(path = "/a", produces = "text/plain") + @PostMapping(path = "/b", consumes = "application/json") + public String getThenPost() { return ""; } + + @PostMapping(path = "/b", consumes = "application/json") + @GetMapping(path = "/a", produces = "text/plain") + public String postThenGet() { return ""; } + + @RequestMapping("/d") + @ApiGet + public String directThenComposed() { return ""; } + + @ApiGet + @RequestMapping("/d") + public String composedThenDirect() { return ""; } + + @ApiGetV + public String twoLevel() { return ""; } + + @ApiConv + public String convention() { return ""; } + } + """.trimIndent() + ) + } +} diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt new file mode 100644 index 000000000..502dfcfb6 --- /dev/null +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt @@ -0,0 +1,179 @@ +/* + * 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.loader.ComposedMethodMappingFixture +import com.explyt.spring.web.loader.ComposedMethodMappingFixture.MappingShape +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import org.jetbrains.kotlin.psi.KtNamedFunction + +class ComposedMethodMappingTest : ComposedMethodMappingKotlinTestCase() { + + override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) + + fun testBareComposedMappingServesItsAliasDefaultPath() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) + + fun testPathSetAtUseSiteOverridesTheAliasDefault() = + assertEquals(listOf("/api/v2" to listOf("GET")), fixture.endpointRows("useSite")) + + fun testOnlyTheFirstOfTwoComposedMappingsIsServed() = + assertEquals(listOf("/api/a" to listOf("GET")), fixture.endpointRows("getThenPost")) + + fun testOnlyTheFirstOfTwoComposedMappingsIsServedInReversedOrder() = + assertEquals(listOf("/api/b" to listOf("POST")), fixture.endpointRows("postThenGet")) + + fun testDirectRequestMappingBeatsComposedMappingDeclaredAfterIt() = + assertEquals(listOf("/api/d" to emptyList()), fixture.endpointRows("directThenComposed")) + + fun testDirectRequestMappingBeatsComposedMappingDeclaredBeforeIt() = + assertEquals(listOf("/api/d" to emptyList()), fixture.endpointRows("composedThenDirect")) + + fun testAliasDefaultReachesThroughTwoComposedLevels() = + assertEquals(listOf("/api/lvl" to listOf("GET")), fixture.endpointRows("twoLevel")) + + fun testConventionPathOfComposedMappingIsServedBeforeSpring7() = + assertEquals(listOf("/api/conv" to listOf("GET")), fixture.endpointRows("convention")) + + fun testEndpointActionsGutterReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.gutterShape("bare")) + + fun testEndpointActionsGutterReadsThePathSetAtUseSite() = + assertEquals(MappingShape("/api/v2", listOf("GET")), fixture.gutterShape("useSite")) + + fun testEndpointActionsGutterReadsOnlyTheFirstComposedMapping() = + assertEquals(GET_A, fixture.gutterShape("getThenPost")) + + fun testEndpointActionsGutterReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.gutterShape("postThenGet")) + + fun testEndpointActionsGutterLetsTheDirectRequestMappingDeclaredFirstWin() = + assertEquals(MappingShape("/api/d", emptyList()), fixture.gutterShape("directThenComposed")) + + fun testEndpointActionsGutterLetsTheDirectRequestMappingDeclaredLastWin() = + assertEquals(MappingShape("/api/d", emptyList()), fixture.gutterShape("composedThenDirect")) + + fun testRunInSwaggerReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.runInSwaggerShape("bare")) + + fun testRunInSwaggerReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.runInSwaggerShape("postThenGet")) + + fun testOpenApiEndpointInfoReadsTheAliasDefaultPath() = + assertEquals(MappingShape("/api/items", listOf("GET")), fixture.openApiShape("bare")) + + fun testOpenApiEndpointInfoReadsOnlyTheFirstComposedMapping() = + assertEquals(GET_A, fixture.openApiShape("getThenPost")) + + fun testOpenApiEndpointInfoReadsOnlyTheFirstComposedMappingInReversedOrder() = + assertEquals(POST_B, fixture.openApiShape("postThenGet")) + + private companion object { + val GET_A = MappingShape("/api/a", listOf("GET"), produces = listOf("text/plain")) + val POST_B = MappingShape("/api/b", listOf("POST"), consumes = listOf("application/json")) + } +} + +class ComposedMethodMappingSpring7Test : ComposedMethodMappingKotlinTestCase() { + + override val libraries: Array = arrayOf(TestLibrary("org.springframework:spring-webmvc:7.0.9", true)) + + fun testConventionPathOfComposedMappingIsIgnoredInSpring7() = + assertEquals(listOf("/api" to listOf("GET")), fixture.endpointRows("convention")) + + fun testBareComposedMappingServesItsAliasDefaultPathInSpring7() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) +} + +abstract class ComposedMethodMappingKotlinTestCase : ExplytKotlinLightTestCase() { + + protected val fixture by lazy { + object : ComposedMethodMappingFixture(myFixture, module) { + override fun identifierOf(method: PsiMethod): PsiElement = + (method.navigationElement as KtNamedFunction).nameIdentifier!! + } + } + + override fun setUp() { + super.setUp() + addSources() + } + + private fun addSources() { + myFixture.addFileToProject( + "com/example/ComposedMappings.kt", """ + package com.example + + import org.springframework.core.annotation.AliasFor + import org.springframework.web.bind.annotation.RequestMapping + import org.springframework.web.bind.annotation.RequestMethod + + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) + @RequestMapping(method = [RequestMethod.GET]) + annotation class ApiGet( + @get:AliasFor(annotation = RequestMapping::class, attribute = "path") + val path: Array = ["/items"], + ) + + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.FUNCTION) + @RequestMapping(method = [RequestMethod.GET]) + annotation class ApiConv(val path: Array = ["/conv"]) + + @Retention(AnnotationRetention.RUNTIME) + @Target(AnnotationTarget.FUNCTION) + @ApiGet(path = ["/lvl"]) + annotation class ApiGetV + """.trimIndent() + ) + myFixture.addFileToProject( + "com/example/ItemsController.kt", """ + package com.example + + import org.springframework.web.bind.annotation.GetMapping + import org.springframework.web.bind.annotation.PostMapping + import org.springframework.web.bind.annotation.RequestMapping + import org.springframework.web.bind.annotation.RestController + + @RestController + @RequestMapping("/api") + class ItemsController { + @ApiGet + fun bare(): String = "" + + @ApiGet(path = ["/v2"]) + fun useSite(): String = "" + + @GetMapping(path = ["/a"], produces = ["text/plain"]) + @PostMapping(path = ["/b"], consumes = ["application/json"]) + fun getThenPost(): String = "" + + @PostMapping(path = ["/b"], consumes = ["application/json"]) + @GetMapping(path = ["/a"], produces = ["text/plain"]) + fun postThenGet(): String = "" + + @RequestMapping("/d") + @ApiGet + fun directThenComposed(): String = "" + + @ApiGet + @RequestMapping("/d") + fun composedThenDirect(): String = "" + + @ApiGetV + fun twoLevel(): String = "" + + @ApiConv + fun convention(): String = "" + } + """.trimIndent() + ) + } +} From c18c7871ea108327b4764b5215bbc41028d56578 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Thu, 15 Oct 2026 02:29:17 +0300 Subject: [PATCH 2/4] fix: Read method request mappings from the single annotation Spring registers (#672) --- CHANGELOG.md | 1 + .../explyt/spring/ai/mcp/SpringMcpProvider.kt | 12 ++- .../RequestMappingDuplicateInspection.kt | 78 +++++-------------- ...rollerEndpointActionsLineMarkerProvider.kt | 20 ++--- .../EndpointRunLineMarkerProvider.kt | 24 ++---- .../explyt/spring/web/util/HandlerMethods.kt | 58 ++++++++++++++ .../explyt/spring/web/util/SpringWebUtil.kt | 32 ++------ 7 files changed, 105 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3102cc363..db3ad85b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,7 @@ - fix: Inject the regular expression of a `@RequestMapping` path built by concatenation into the literal that actually contains it - fix: Keep an Actuator endpoint's write operation next to a web extension's write operation that differs only by whether it reads a request body, as Boot routes them by the consumed media types (#655) - fix: Read the endpoint a project Actuator web extension extends the way Boot discovers it: through a composed annotation, with an `@AliasFor` value or default taking precedence over the value fixed on the meta-annotation, and from an annotation declared on a superclass of the extension; a same-named attribute without `@AliasFor` counts only up to Spring Framework 6. An extension whose bean condition does not match no longer replaces the endpoint's operations, and one whose condition cannot be decided is applied while the endpoint's bean condition reads undecided, naming the extension (#655) +- fix: Read a method's request mapping from the single annotation Spring registers: a composed mapping contributes its merged path, verbs and media types to the endpoint gutters, Run in Swagger, OpenAPI generation, the duplicate-endpoint inspection and the MCP contract, and of several mapping annotations on one method only the one Spring uses is kept (a direct `@RequestMapping`, else the first in source order), so the Endpoints window and the MCP tools no longer list the others (#672) ### Spring MCP diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt index 5d672df3f..5a26a6412 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt @@ -79,7 +79,7 @@ import org.jetbrains.uast.UMethod import org.jetbrains.uast.UParameter import org.jetbrains.uast.UResolvable import org.jetbrains.uast.visitor.AbstractUastVisitor -import org.jetbrains.uast.evaluateString + import org.jetbrains.uast.getParentOfType import org.jetbrains.uast.getUastParentOfType import org.jetbrains.uast.toUElement @@ -1344,12 +1344,10 @@ class SpringBootApplicationMcpToolset : McpToolset { if (uHandler == null || module == null) return MediaTypes(emptyList(), emptyList()) val mah = SpringSearchService.getInstance(project) .getMetaAnnotations(module, SpringWebClasses.REQUEST_MAPPING) - val mapping = HandlerMethods.mappingSourceOf(uHandler.javaPsi) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } - ?.toUElement() as? UMethod ?: uHandler - return MediaTypes( - produces = mah.getAnnotationValues(mapping, setOf("produces")).mapNotNull { it.evaluateString() }, - consumes = mah.getAnnotationValues(mapping, setOf("consumes")).mapNotNull { it.evaluateString() }, - ) + val mappingSource = HandlerMethods.mappingSourceOf(uHandler.javaPsi) { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } + ?: uHandler.javaPsi + val mapping = HandlerMethods.methodMapping(mappingSource, mah) + return MediaTypes(produces = mapping.produces, consumes = mapping.consumes) } /** diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt index d79d6c514..bd9421e28 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt @@ -8,17 +8,17 @@ package com.explyt.spring.web.inspections import com.explyt.spring.core.service.MetaAnnotationsHolder import com.explyt.spring.web.SpringWebBundle import com.explyt.spring.web.SpringWebClasses +import com.explyt.spring.web.util.HandlerMethods import com.explyt.util.ExplytPsiUtil.getHighlightRange import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedBy import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedByOrSelf import com.explyt.util.ExplytPsiUtil.isPublic -import com.intellij.codeInsight.AnnotationUtil import com.intellij.codeInspection.InspectionManager import com.intellij.codeInspection.ProblemDescriptor import com.intellij.codeInspection.ProblemHighlightType import com.intellij.codeInspection.ProblemsHolder import com.intellij.openapi.module.ModuleUtilCore -import com.intellij.psi.PsiAnnotationMemberValue +import com.intellij.psi.PsiElement import com.intellij.psi.PsiMethod import io.ktor.http.* import org.jetbrains.kotlin.build.joinToReadableString @@ -57,7 +57,7 @@ class RequestMappingDuplicateInspection : SpringWebBaseUastLocalInspectionTool() methodsByUrlMap.forEach { (_, duplicates) -> if (duplicates.size < 2) return@forEach for (duplicate in duplicates) { - val psiElement = duplicate.psiAnnotationMemberValue.toUElement()?.sourcePsi ?: continue + val psiElement = duplicate.anchor ?: continue holder.registerProblem( manager.createProblemDescriptor( psiElement, psiElement.getHighlightRange(), @@ -77,47 +77,25 @@ class RequestMappingDuplicateInspection : SpringWebBaseUastLocalInspectionTool() private fun toUrlMethodPair( requestMappingMah: MetaAnnotationsHolder, psiMethod: PsiMethod ): List { - return requestMappingMah.getAnnotationMemberValues(psiMethod, TARGET_VALUE) - .flatMap { toDuplicateUrlInfo(it, psiMethod, requestMappingMah) } - } - - private fun toDuplicateUrlInfo( - psiValue: PsiAnnotationMemberValue, psiMethod: PsiMethod, requestMappingMah: MetaAnnotationsHolder - ): List { - val urlString = AnnotationUtil.getStringAttributeValue(psiValue) ?: return emptyList() - val httpMethods = requestMappingMah.getHttpMethods(psiMethod) - return httpMethods.mapNotNull { getDuplicateUrlInfo(urlString, it, psiMethod, psiValue) } - } - - private fun getDuplicateUrlInfo( - urlString: String, - string: String, - psiMethod: PsiMethod, - psiValue: PsiAnnotationMemberValue - ): DuplicateUrlInfo? { - val url = try { - Url(urlString) - } catch (_: Exception) { - return null + val mapping = HandlerMethods.methodMapping(psiMethod, requestMappingMah) + val httpMethods = mapping.methods.toSet() + return mapping.paths.flatMap { (path, anchor) -> + val url = urlOf(path) ?: return@flatMap emptyList() + httpMethods.map { DuplicateUrlInfo(url, it, psiMethod, anchor) } } - return DuplicateUrlInfo(url, string, psiMethod, psiValue) } - private fun MetaAnnotationsHolder.getHttpMethods( - psiMethod: PsiMethod - ) = getAnnotationMemberValues(psiMethod, TARGET_METHOD) - .mapTo(mutableSetOf()) { it.text.split('.').last() } - - companion object { - private val TARGET_VALUE = setOf("value") - private val TARGET_METHOD = setOf("method") + private fun urlOf(urlString: String): Url? = try { + Url(urlString) + } catch (_: Exception) { + null } private data class DuplicateUrlInfo( val url: Url, val methodType: String, val psiMethod: PsiMethod, - val psiAnnotationMemberValue: PsiAnnotationMemberValue, + val anchor: PsiElement?, ) } @@ -138,21 +116,16 @@ private class RequestMappingVisitor( val psiMethod = uAnnotation.getContainingUMethod()?.javaPsi ?: return true val psiClass = psiMethod.containingClass ?: return true if (!psiClass.isMetaAnnotatedBy(SpringWebClasses.CONTROLLER)) return true - val httpMethods = requestMappingMah.getHttpMethods(psiMethod) + val httpMethods = HandlerMethods.methodMapping(psiMethod, requestMappingMah).methods.toSet() val conflictingEndpoints = psiClass.methods.asSequence() .filter { it != psiMethod } .filter { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } - .filter { - requestMappingMah.getAnnotationMemberValues(it, TARGET_VALUE).any { anotherEndpoint -> - AnnotationUtil.getStringAttributeValue(anotherEndpoint) == endpoint - } - } - .filter { - requestMappingMah.getHttpMethods(it) - .intersect(httpMethods).isNotEmpty() - }.mapTo(mutableListOf()) { - it.name + .map { it to HandlerMethods.methodMapping(it, requestMappingMah) } + .filter { (_, mapping) -> mapping.paths.any { it.path == endpoint } } + .filter { (_, mapping) -> mapping.methods.intersect(httpMethods).isNotEmpty() } + .mapTo(mutableListOf()) { (method, _) -> + method.name } if (conflictingEndpoints.isEmpty()) return true @@ -173,15 +146,4 @@ private class RequestMappingVisitor( return true } - - private fun MetaAnnotationsHolder.getHttpMethods( - psiMethod: PsiMethod - ) = getAnnotationMemberValues(psiMethod, TARGET_METHOD) - .mapTo(mutableSetOf()) { it.text.split('.').last() } - - companion object { - private val TARGET_VALUE = setOf("value") - private val TARGET_METHOD = setOf("method") - } - -} \ No newline at end of file +} diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/ControllerEndpointActionsLineMarkerProvider.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/ControllerEndpointActionsLineMarkerProvider.kt index cb5763261..4b12a01c7 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/ControllerEndpointActionsLineMarkerProvider.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/ControllerEndpointActionsLineMarkerProvider.kt @@ -16,7 +16,7 @@ import com.explyt.spring.web.util.MappingPathPlaceholders import com.explyt.spring.web.util.SpringWebUtil import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedBy import com.explyt.util.ExplytUastUtil.getCommentText -import com.intellij.codeInsight.AnnotationUtil + import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProviderDescriptor import com.intellij.openapi.editor.markup.GutterIconRenderer @@ -53,9 +53,8 @@ class ControllerEndpointActionsLineMarkerProvider : LineMarkerProviderDescriptor val controllerName = psiClass.name ?: return null val requestMappingMah = MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) - val path = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("path", "value")).asSequence() - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - .firstOrNull() ?: "" + val mapping = HandlerMethods.methodMapping(mappingSource, requestMappingMah) + val path = mapping.paths.firstOrNull()?.path ?: "" if (OpenApiUtils.isAbsolutePath(path)) return null val prefix = HandlerMethods.requestMappingPrefixes(psiClass, requestMappingMah).firstOrNull() ?: "" @@ -63,13 +62,6 @@ class ControllerEndpointActionsLineMarkerProvider : LineMarkerProviderDescriptor val fullPath = SpringWebUtil.simplifyUrl(MappingPathPlaceholders.resolve(module, "$prefix/$path")) - val requestMethods = - requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("method")) - .map { it.text.split('.').last() } - val produces = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("produces")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - val consumes = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("consumes")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } val description = uMethod.comments.firstOrNull()?.getCommentText() ?: "" val returnType = uMethod.returnType @@ -77,7 +69,7 @@ class ControllerEndpointActionsLineMarkerProvider : LineMarkerProviderDescriptor val endpointElement = EndpointInfo( fullPath, - requestMethods, + mapping.methods, psiElement, uMethod.name, controllerName.replace("controller", "", true) @@ -88,8 +80,8 @@ class ControllerEndpointActionsLineMarkerProvider : LineMarkerProviderDescriptor SpringWebUtil.collectRequestParameters(psiMethod), SpringWebUtil.getRequestBodyInfo(psiMethod), SpringWebUtil.collectRequestHeaders(psiMethod), - produces, - consumes + mapping.produces, + mapping.consumes ) return LineMarkerInfo( diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/EndpointRunLineMarkerProvider.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/EndpointRunLineMarkerProvider.kt index 4ea059082..69923778d 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/EndpointRunLineMarkerProvider.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/providers/EndpointRunLineMarkerProvider.kt @@ -22,7 +22,7 @@ import com.explyt.spring.web.util.SpringWebUtil import com.explyt.spring.web.util.SpringWebUtil.removeParams import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedBy import com.explyt.util.ExplytUastUtil.getCommentText -import com.intellij.codeInsight.AnnotationUtil + import com.intellij.execution.lineMarker.RunLineMarkerContributor import com.intellij.icons.AllIcons import com.intellij.openapi.module.ModuleUtilCore @@ -71,9 +71,7 @@ class EndpointRunLineMarkerProvider : RunLineMarkerContributor() { psiMethod: PsiMethod, mappingSource: PsiMethod, ): String { - var path = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("path", "value")).asSequence() - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - .firstOrNull() ?: "" + var path = HandlerMethods.methodMapping(mappingSource, requestMappingMah).paths.firstOrNull()?.path ?: "" if (isAbsolutePath(path)) return path val containingClass = psiMethod.containingClass ?: return path @@ -93,24 +91,18 @@ class EndpointRunLineMarkerProvider : RunLineMarkerContributor() { val module = ModuleUtilCore.findModuleForPsiElement(psiMethod) ?: return null - val requestMappingMah = MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) - val produces = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("produces")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - val consumes = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("consumes")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } + val mapping = HandlerMethods.methodMapping( + mappingSource, MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + ) val fullPath = SpringWebUtil.simplifyUrl(removeParams(apiPath)) - val requestMethods = - requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("method")) - .map { it.text.split('.').last() } - val description = uMethod.comments.firstOrNull()?.getCommentText() ?: "" val returnTypeFqn = SpringWebUtil.getTypeFqn(HandlerSignature.declaredReturnType(psiMethod), psiMethod.language) return EndpointInfo( fullPath, - requestMethods, + mapping.methods, psiMethod, uMethod.name, "default", @@ -120,8 +112,8 @@ class EndpointRunLineMarkerProvider : RunLineMarkerContributor() { SpringWebUtil.collectRequestParameters(psiMethod), SpringWebUtil.getRequestBodyInfo(psiMethod), SpringWebUtil.collectRequestHeaders(psiMethod), - produces, - consumes + mapping.produces, + mapping.consumes ) } 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 12b762120..9d68bfa34 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 @@ -14,14 +14,20 @@ import com.intellij.openapi.util.Key import com.intellij.psi.CommonClassNames import com.intellij.psi.PsiAnnotation import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiEnumConstant import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifierListOwner import com.intellij.psi.PsiParameter import com.intellij.psi.util.CachedValue 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 com.intellij.psi.util.PsiTreeUtil import org.jetbrains.uast.UAnnotated +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UResolvable import org.jetbrains.uast.evaluateString import org.jetbrains.uast.toUElement @@ -29,11 +35,24 @@ object HandlerMethods { private val parameterMethodsKey = Key.create>>("explyt.web.parameterMethods") private val MAPPING_PATH_ATTRIBUTES = setOf("path", "value") + private val MAPPING_METHOD_ATTRIBUTES = setOf("method") + private val MAPPING_PRODUCES_ATTRIBUTES = setOf("produces") + private val MAPPING_CONSUMES_ATTRIBUTES = setOf("consumes") + private val NO_MAPPING = MethodMapping(emptyList(), emptyList(), emptyList(), emptyList()) enum class HierarchyOrder { INTERFACES_FIRST, SUPERCLASS_FIRST } data class MappedMethod(val handler: PsiMethod, val mappingSource: PsiMethod) + data class MappedPath(val path: String, val anchor: PsiElement?) + + data class MethodMapping( + val paths: List, + val methods: List, + val produces: List, + val consumes: List, + ) + fun mappedMethods( controller: PsiClass, order: HierarchyOrder = HierarchyOrder.INTERFACES_FIRST, @@ -64,6 +83,45 @@ object HandlerMethods { .mapNotNull { it.evaluateString() } } + fun methodMapping(member: PsiModifierListOwner, requestMappingMah: MetaAnnotationsHolder): MethodMapping { + val annotated = member.toUElement() as? UAnnotated ?: return declaredMethodMapping(member, requestMappingMah) + val annotation = requestMappingMah.nearestAnnotation(annotated) ?: return NO_MAPPING + fun values(attributes: Set) = requestMappingMah.getAnnotationMemberValues(annotation, attributes) + val nameAnchor = annotation.uastAnchor?.sourcePsi ?: annotation.sourcePsi + return MethodMapping( + paths = values(MAPPING_PATH_ATTRIBUTES).mapNotNull { value -> + value.evaluateString()?.let { MappedPath(it, anchorOf(value.sourcePsi, annotation.sourcePsi, nameAnchor)) } + }, + methods = values(MAPPING_METHOD_ATTRIBUTES).mapNotNull(::requestMethodName), + produces = values(MAPPING_PRODUCES_ATTRIBUTES).mapNotNull { it.evaluateString() }, + consumes = values(MAPPING_CONSUMES_ATTRIBUTES).mapNotNull { it.evaluateString() }, + ) + } + + private fun declaredMethodMapping( + member: PsiModifierListOwner, + requestMappingMah: MetaAnnotationsHolder, + ): MethodMapping { + val annotation = requestMappingMah.nearestAnnotation(member) ?: return NO_MAPPING + fun values(attributes: Set) = requestMappingMah.getAnnotationMemberValues(annotation, attributes) + return MethodMapping( + paths = values(MAPPING_PATH_ATTRIBUTES).mapNotNull { value -> + AnnotationUtil.getStringAttributeValue(value) + ?.let { MappedPath(it, anchorOf(value, annotation, annotation.nameReferenceElement)) } + }, + methods = values(MAPPING_METHOD_ATTRIBUTES).map { it.text.split('.').last() }, + produces = values(MAPPING_PRODUCES_ATTRIBUTES).mapNotNull { AnnotationUtil.getStringAttributeValue(it) }, + consumes = values(MAPPING_CONSUMES_ATTRIBUTES).mapNotNull { AnnotationUtil.getStringAttributeValue(it) }, + ) + } + + private fun anchorOf(value: PsiElement?, useSite: PsiElement?, name: PsiElement?): PsiElement? = + value?.takeIf { useSite != null && PsiTreeUtil.isAncestor(useSite, it, false) } ?: name + + private fun requestMethodName(value: UExpression): String? = + ((value as? UResolvable)?.resolve() as? PsiEnumConstant)?.name + ?: value.sourcePsi?.text?.split('.')?.last() + fun mostSpecificMethod(method: PsiMethod, controller: PsiClass): PsiMethod { val overrides = controller.findMethodsByName(method.name, true) .filter { MethodSignatureUtil.isSuperMethod(method, it) } diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt index 8b116bddf..8da4106f3 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt @@ -345,28 +345,18 @@ object SpringWebUtil { val psiClass = psiMethod.containingClass ?: return null val controllerName = psiClass.name ?: return null - val requestMappingMah = MetaAnnotationsHolder.of(module, REQUEST_MAPPING) - val path = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("path", "value")).asSequence() - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - .firstOrNull() ?: "" + val mapping = HandlerMethods.methodMapping(mappingSource, MetaAnnotationsHolder.of(module, REQUEST_MAPPING)) + val path = mapping.paths.firstOrNull()?.path ?: "" if (isAbsolutePath(path)) return null - val produces = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("produces")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } - val consumes = requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("consumes")) - .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } val fullPath = simplifyUrl(MappingPathPlaceholders.resolve(module, "$prefix/${removeParams(path)}")) - val requestMethods = - requestMappingMah.getAnnotationMemberValues(mappingSource, setOf("method")) - .map { it.text.split('.').last() } - val description = uMethod.comments.firstOrNull()?.getCommentText() ?: "" val returnTypeFqn = getTypeFqn(HandlerSignature.declaredReturnType(psiMethod), psiMethod.language) return EndpointInfo( fullPath, - requestMethods, + mapping.methods, psiMethod, uMethod.name, controllerName.replace("controller", "", true) @@ -377,8 +367,8 @@ object SpringWebUtil { collectRequestParameters(psiMethod), getRequestBodyInfo(psiMethod), collectRequestHeaders(psiMethod), - produces, - consumes + mapping.produces, + mapping.consumes ) } @@ -388,18 +378,10 @@ object SpringWebUtil { * verbs, empty for a mapping that restricts none. */ fun requestMappingOf(member: PsiMember, requestMappingMah: MetaAnnotationsHolder): RequestMapping { - val annotated = member.toUElement() as? UAnnotated ?: return RequestMapping(listOf(""), emptyList()) - val values = requestMappingMah.getAnnotationValues(annotated, setOf("value")) - val paths = if (values.isEmpty()) listOf("") else values.mapNotNull { it.evaluateString() } - val methods = requestMappingMah.getAnnotationValues(annotated, setOf("method")) - .mapNotNull { requestMethodName(it) } - return RequestMapping(paths, methods) + val mapping = HandlerMethods.methodMapping(member, requestMappingMah) + return RequestMapping(mapping.paths.map { it.path }.ifEmpty { listOf("") }, mapping.methods) } - private fun requestMethodName(value: UExpression): String? = - ((value as? UResolvable)?.resolve() as? PsiEnumConstant)?.name - ?: value.sourcePsi?.text?.split('.')?.last() - data class RequestMapping(val paths: List, val methods: List) fun removeParams(url: String): String { From 24564f614d2453603941801e9ef1198955bd4204 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Thu, 15 Oct 2026 02:44:39 +0300 Subject: [PATCH 3/4] test: Pin that an unreadable mapping path yields no endpoint row (#672) --- .../loader/ComposedMethodMappingFixture.kt | 18 ++++++++- .../loader/java/ComposedMethodMappingTest.kt | 39 +++++++++++++++++++ .../kotlin/ComposedMethodMappingTest.kt | 39 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt index 900df4a76..694f18d1c 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/ComposedMethodMappingFixture.kt @@ -26,13 +26,17 @@ import com.intellij.testFramework.fixtures.CodeInsightTestFixture import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue +import org.jetbrains.uast.UAnnotated import org.jetbrains.uast.UMethod +import org.jetbrains.uast.evaluateString import org.jetbrains.uast.toUElement abstract class ComposedMethodMappingFixture( private val codeInsightFixture: CodeInsightTestFixture, private val module: Module, + private val controllerName: String = "ItemsController", ) { data class MappingShape( @@ -53,6 +57,16 @@ abstract class ComposedMethodMappingFixture( return rows.filter { it.first == methodName }.map { it.second to it.third }.sortedBy { it.first } } + fun assertMappingPathCannotBeEvaluated(methodName: String) { + val method = handler(methodName).toUElement() as UMethod + val mappings = MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) + val annotation = mappings.nearestAnnotation(method as UAnnotated) + assertNotNull("Precondition: $methodName declares a mapping", annotation) + val values = mappings.getAnnotationMemberValues(annotation!!, setOf("path", "value")) + assertEquals("Precondition: $methodName declares one path value", 1, values.size) + assertNull("Precondition: the declared path cannot be evaluated", values.single().evaluateString()) + } + fun gutterShape(methodName: String): MappingShape { assertComposedMappingsAreMetaAnnotated() val markers = mutableListOf>() @@ -88,7 +102,7 @@ abstract class ComposedMethodMappingFixture( private fun controllerEndpoints(): List>> { assertComposedMappingsAreMetaAnnotated() val rows = SpringWebEndpointsSearcher.getInstance(codeInsightFixture.project).getAllEndpoints(module) - .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == "ItemsController" } + .filter { it.type == EndpointType.SPRING_MVC && it.containingClass?.name == controllerName } .map { Triple((it.psiElement.toUElement() as? UMethod)?.name, it.path, it.requestMethods) } assertFalse("Precondition: the loader finds the controller", rows.isEmpty()) return rows @@ -103,7 +117,7 @@ abstract class ComposedMethodMappingFixture( } private fun controller(): PsiClass { - val controller = facade().findClass("com.example.ItemsController", GlobalSearchScope.projectScope(codeInsightFixture.project)) + val controller = facade().findClass("com.example.$controllerName", GlobalSearchScope.projectScope(codeInsightFixture.project)) assertNotNull("Precondition: the controller is in the fixture", controller) return controller!! } diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt index bb4c4036b..1c60eef73 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/java/ComposedMethodMappingTest.kt @@ -16,6 +16,39 @@ class ComposedMethodMappingTest : ComposedMethodMappingJavaTestCase() { override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) + fun testUnreadableMappingPathProducesNoEndpointRow() { + myFixture.addFileToProject( + "com/example/UnreadableController.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; + + @RestController + @RequestMapping("/api") + public class UnreadableController { + @GetMapping(PathHolder.UNRESOLVED) + public String unreadable() { return ""; } + + @GetMapping("/known") + public String known() { return ""; } + } + """.trimIndent() + ) + val unreadableFixture = object : ComposedMethodMappingFixture(myFixture, module, "UnreadableController") { + override fun identifierOf(method: PsiMethod): PsiElement = method.nameIdentifier!! + } + unreadableFixture.assertMappingPathCannotBeEvaluated("unreadable") + assertEquals(listOf("/api/known" to listOf("GET")), unreadableFixture.endpointRows("known")) + assertEquals(emptyList>>(), unreadableFixture.endpointRows("unreadable")) + } + + fun testBareDirectMappingServesTheClassPrefix() = + assertEquals(listOf("/api" to listOf("GET")), fixture.endpointRows("directNoPath")) + + fun testLiteralDirectMappingServesTheClassPrefixAndPath() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("normalLiteralPath")) + fun testBareComposedMappingServesItsAliasDefaultPath() = assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) @@ -175,6 +208,12 @@ abstract class ComposedMethodMappingJavaTestCase : ExplytJavaLightTestCase() { @ApiGetV public String twoLevel() { return ""; } + @GetMapping + public String directNoPath() { return ""; } + + @GetMapping("/items") + public String normalLiteralPath() { return ""; } + @ApiConv public String convention() { return ""; } } diff --git a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt index 502dfcfb6..20d37cf84 100644 --- a/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedMethodMappingTest.kt @@ -17,6 +17,39 @@ class ComposedMethodMappingTest : ComposedMethodMappingKotlinTestCase() { override val libraries: Array = arrayOf(TestLibrary.springWebMvc_6_0_7) + fun testUnreadableMappingPathProducesNoEndpointRow() { + myFixture.addFileToProject( + "com/example/UnreadableController.kt", """ + package com.example + import org.springframework.web.bind.annotation.GetMapping + import org.springframework.web.bind.annotation.RequestMapping + import org.springframework.web.bind.annotation.RestController + + @RestController + @RequestMapping("/api") + class UnreadableController { + @GetMapping(PathHolder.UNRESOLVED) + fun unreadable(): String = "" + + @GetMapping("/known") + fun known(): String = "" + } + """.trimIndent() + ) + val unreadableFixture = object : ComposedMethodMappingFixture(myFixture, module, "UnreadableController") { + override fun identifierOf(method: PsiMethod): PsiElement = (method.navigationElement as KtNamedFunction).nameIdentifier!! + } + unreadableFixture.assertMappingPathCannotBeEvaluated("unreadable") + assertEquals(listOf("/api/known" to listOf("GET")), unreadableFixture.endpointRows("known")) + assertEquals(emptyList>>(), unreadableFixture.endpointRows("unreadable")) + } + + fun testBareDirectMappingServesTheClassPrefix() = + assertEquals(listOf("/api" to listOf("GET")), fixture.endpointRows("directNoPath")) + + fun testLiteralDirectMappingServesTheClassPrefixAndPath() = + assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("normalLiteralPath")) + fun testBareComposedMappingServesItsAliasDefaultPath() = assertEquals(listOf("/api/items" to listOf("GET")), fixture.endpointRows("bare")) @@ -170,6 +203,12 @@ abstract class ComposedMethodMappingKotlinTestCase : ExplytKotlinLightTestCase() @ApiGetV fun twoLevel(): String = "" + @GetMapping + fun directNoPath(): String = "" + + @GetMapping("/items") + fun normalLiteralPath(): String = "" + @ApiConv fun convention(): String = "" } From fc6bc5d862270c565258c4521f2d2acbe493f8eb Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Thu, 15 Oct 2026 03:05:34 +0300 Subject: [PATCH 4/4] fix: Keep an unreadable mapping path out of the endpoint list and drop the unused duplicate-mapping visitor (#672) --- .../RequestMappingDuplicateInspection.kt | 53 +------------------ .../explyt/spring/web/util/HandlerMethods.kt | 11 ++-- .../explyt/spring/web/util/SpringWebUtil.kt | 3 +- 3 files changed, 12 insertions(+), 55 deletions(-) diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt index bd9421e28..ea0dc18c5 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/inspections/RequestMappingDuplicateInspection.kt @@ -11,7 +11,7 @@ import com.explyt.spring.web.SpringWebClasses import com.explyt.spring.web.util.HandlerMethods import com.explyt.util.ExplytPsiUtil.getHighlightRange import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedBy -import com.explyt.util.ExplytPsiUtil.isMetaAnnotatedByOrSelf + import com.explyt.util.ExplytPsiUtil.isPublic import com.intellij.codeInspection.InspectionManager import com.intellij.codeInspection.ProblemDescriptor @@ -23,7 +23,7 @@ import com.intellij.psi.PsiMethod import io.ktor.http.* import org.jetbrains.kotlin.build.joinToReadableString import org.jetbrains.uast.* -import org.jetbrains.uast.visitor.AbstractUastNonRecursiveVisitor + class RequestMappingDuplicateInspection : SpringWebBaseUastLocalInspectionTool() { @@ -98,52 +98,3 @@ class RequestMappingDuplicateInspection : SpringWebBaseUastLocalInspectionTool() val anchor: PsiElement?, ) } - -private class RequestMappingVisitor( - private val problemsHolder: ProblemsHolder, private val isOnTheFly: Boolean -) : AbstractUastNonRecursiveVisitor() { - - override fun visitLiteralExpression(node: ULiteralExpression): Boolean { - val psiExpression = node.sourcePsi ?: return true - val module = ModuleUtilCore.findModuleForPsiElement(psiExpression) ?: return true - val requestMappingMah = MetaAnnotationsHolder.of(module, SpringWebClasses.REQUEST_MAPPING) - val endpoint = node.evaluateString() ?: return true - val uAnnotation = node.getParentOfType() ?: return true - val psiAnnotation = uAnnotation.javaPsi ?: return true - if (!psiAnnotation.isMetaAnnotatedByOrSelf(SpringWebClasses.REQUEST_MAPPING)) return true - if (psiAnnotation.isMetaAnnotatedByOrSelf(SpringWebClasses.CONTROLLER)) return true - - val psiMethod = uAnnotation.getContainingUMethod()?.javaPsi ?: return true - val psiClass = psiMethod.containingClass ?: return true - if (!psiClass.isMetaAnnotatedBy(SpringWebClasses.CONTROLLER)) return true - val httpMethods = HandlerMethods.methodMapping(psiMethod, requestMappingMah).methods.toSet() - - val conflictingEndpoints = psiClass.methods.asSequence() - .filter { it != psiMethod } - .filter { it.isMetaAnnotatedBy(SpringWebClasses.REQUEST_MAPPING) } - .map { it to HandlerMethods.methodMapping(it, requestMappingMah) } - .filter { (_, mapping) -> mapping.paths.any { it.path == endpoint } } - .filter { (_, mapping) -> mapping.methods.intersect(httpMethods).isNotEmpty() } - .mapTo(mutableListOf()) { (method, _) -> - method.name - } - - if (conflictingEndpoints.isEmpty()) return true - - problemsHolder.registerProblem( - problemsHolder.manager.createProblemDescriptor( - psiExpression, - psiExpression.getHighlightRange(), - SpringWebBundle.message( - "explyt.spring.web.inspection.requestMapping.duplicate.methods", - conflictingEndpoints.joinToReadableString() - ), - ProblemHighlightType.GENERIC_ERROR, - isOnTheFly - - ) - ) - - return true - } -} 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 9d68bfa34..f678cde5e 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 @@ -38,7 +38,7 @@ object HandlerMethods { private val MAPPING_METHOD_ATTRIBUTES = setOf("method") private val MAPPING_PRODUCES_ATTRIBUTES = setOf("produces") private val MAPPING_CONSUMES_ATTRIBUTES = setOf("consumes") - private val NO_MAPPING = MethodMapping(emptyList(), emptyList(), emptyList(), emptyList()) + private val NO_MAPPING = MethodMapping(emptyList(), false, emptyList(), emptyList(), emptyList()) enum class HierarchyOrder { INTERFACES_FIRST, SUPERCLASS_FIRST } @@ -48,6 +48,7 @@ object HandlerMethods { data class MethodMapping( val paths: List, + val pathsDeclared: Boolean, val methods: List, val produces: List, val consumes: List, @@ -88,10 +89,12 @@ object HandlerMethods { val annotation = requestMappingMah.nearestAnnotation(annotated) ?: return NO_MAPPING fun values(attributes: Set) = requestMappingMah.getAnnotationMemberValues(annotation, attributes) val nameAnchor = annotation.uastAnchor?.sourcePsi ?: annotation.sourcePsi + val pathValues = values(MAPPING_PATH_ATTRIBUTES) return MethodMapping( - paths = values(MAPPING_PATH_ATTRIBUTES).mapNotNull { value -> + paths = pathValues.mapNotNull { value -> value.evaluateString()?.let { MappedPath(it, anchorOf(value.sourcePsi, annotation.sourcePsi, nameAnchor)) } }, + pathsDeclared = pathValues.isNotEmpty(), methods = values(MAPPING_METHOD_ATTRIBUTES).mapNotNull(::requestMethodName), produces = values(MAPPING_PRODUCES_ATTRIBUTES).mapNotNull { it.evaluateString() }, consumes = values(MAPPING_CONSUMES_ATTRIBUTES).mapNotNull { it.evaluateString() }, @@ -104,11 +107,13 @@ object HandlerMethods { ): MethodMapping { val annotation = requestMappingMah.nearestAnnotation(member) ?: return NO_MAPPING fun values(attributes: Set) = requestMappingMah.getAnnotationMemberValues(annotation, attributes) + val pathValues = values(MAPPING_PATH_ATTRIBUTES) return MethodMapping( - paths = values(MAPPING_PATH_ATTRIBUTES).mapNotNull { value -> + paths = pathValues.mapNotNull { value -> AnnotationUtil.getStringAttributeValue(value) ?.let { MappedPath(it, anchorOf(value, annotation, annotation.nameReferenceElement)) } }, + pathsDeclared = pathValues.isNotEmpty(), methods = values(MAPPING_METHOD_ATTRIBUTES).map { it.text.split('.').last() }, produces = values(MAPPING_PRODUCES_ATTRIBUTES).mapNotNull { AnnotationUtil.getStringAttributeValue(it) }, consumes = values(MAPPING_CONSUMES_ATTRIBUTES).mapNotNull { AnnotationUtil.getStringAttributeValue(it) }, diff --git a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt index 8da4106f3..902b869d9 100644 --- a/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt +++ b/modules/spring-web/src/main/kotlin/com/explyt/spring/web/util/SpringWebUtil.kt @@ -379,7 +379,8 @@ object SpringWebUtil { */ fun requestMappingOf(member: PsiMember, requestMappingMah: MetaAnnotationsHolder): RequestMapping { val mapping = HandlerMethods.methodMapping(member, requestMappingMah) - return RequestMapping(mapping.paths.map { it.path }.ifEmpty { listOf("") }, mapping.methods) + val paths = if (mapping.pathsDeclared) mapping.paths.map { it.path } else listOf("") + return RequestMapping(paths, mapping.methods) } data class RequestMapping(val paths: List, val methods: List)