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-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/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..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 @@ -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,17 @@ 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.nearestAnnotation(mappedType) + ?.let { requestMappingMah.getAnnotationMemberValues(it, MAPPING_PATH_ATTRIBUTES) }.orEmpty() + .mapNotNull { AnnotationUtil.getStringAttributeValue(it) } + return requestMappingMah.nearestAnnotation(annotated) + ?.let { requestMappingMah.getAnnotationMemberValues(it, MAPPING_PATH_ATTRIBUTES) }.orEmpty() + .mapNotNull { it.evaluateString() } + } fun mostSpecificMethod(method: PsiMethod, controller: PsiClass): PsiMethod { val overrides = controller.findMethodsByName(method.name, true) 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..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 @@ -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,84 @@ 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) + } + + 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", """ @@ -84,3 +165,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..4c5416aaa --- /dev/null +++ b/modules/spring-web/src/test/kotlin/com/explyt/spring/web/loader/kotlin/ComposedControllerPrefixTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 Explyt Ltd + * SPDX-License-Identifier: Apache-2.0 + */ + +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() { + + 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")) + + 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()) + 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() { + 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 + import org.springframework.web.bind.annotation.RequestMapping + + $usage + class ItemsController { + @GetMapping("/items") + fun items(): String = "items" + } + """.trimIndent() + ) + } +}