Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,10 +73,7 @@ class SpringWebControllerLoader(private val project: Project) : SpringWebEndpoin
module: Module,
endpointType: EndpointType,
): List<EndpointElement> {
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<EndpointElement>()

Expand All @@ -104,8 +100,4 @@ class SpringWebControllerLoader(private val project: Project) : SpringWebEndpoin
}
return result
}

companion object {
private val TARGET_VALUE = setOf("value")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CachedValue<List<PsiMethod>>>("explyt.web.parameterMethods")
private val MAPPING_PATH_ATTRIBUTES = setOf("path", "value")

enum class HierarchyOrder { INTERFACES_FIRST, SUPERCLASS_FIRST }

Expand All @@ -48,10 +52,17 @@ object HandlerMethods {
isMapped: (PsiClass) -> Boolean,
): PsiClass? = typeHierarchy(controller, order).firstOrNull(isMapped)

fun requestMappingPrefixes(controller: PsiClass, requestMappingMah: MetaAnnotationsHolder): List<String> =
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<String> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestLibrary> = arrayOf(TestLibrary.springWebMvc_6_0_7)

Expand All @@ -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", """
Expand Down Expand Up @@ -84,3 +165,122 @@ class ComposedMappingDefaultPathTest : ExplytJavaLightTestCase() {
.single { (it.psiElement as PsiMethod).name == "report" }
}
}

class ComposedControllerPrefixSpring7Test : ComposedControllerPrefixJavaTestCase() {

override val libraries: Array<TestLibrary> = 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<String>) {
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<String> {
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 \"\";")
}
}
Loading
Loading