Autoconfigure MustacheServletWebConfiguration conditional on ViewResolver

Fixes problem where if Mustache is present, but spring-webmvc isn't present, then Spring Boot Autoconfigure will fail with ClassNotFoundException:

java.lang.IllegalStateException: Error processing condition on org.springframework.boot.autoconfigure.mustache.MustacheServletWebConfiguration.mustacheViewResolver
    at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:60)
    at org.springframework.context.annotation.ConditionEvaluator.shouldSkip(ConditionEvaluator.java:108)
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForBeanMethod(ConfigurationClassBeanDefinitionReader.java:193)
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForConfigurationClass(ConfigurationClassBeanDefinitionReader.java:153)
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitions(ConfigurationClassBeanDefinitionReader.java:129)
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:343)
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:247)
    at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:311)
    at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:112)
    at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:746)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:564)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:740)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:415)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:303)
    at org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:136)
    at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:99)
    at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:124)
    at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:124)
    at org.springframework.test.context.junit.jupiter.SpringExtension.getApplicationContext(SpringExtension.java:283)
    at mil.af.kesselrun.adcp.adcpzerotrustclient.extension.RestAssuredExtension.beforeAll(RestAssuredExtension.java:29)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$invokeBeforeAllCallbacks$10(ClassBasedTestDescriptor.java:381)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.invokeBeforeAllCallbacks(ClassBasedTestDescriptor.java:381)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.before(ClassBasedTestDescriptor.java:205)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.before(ClassBasedTestDescriptor.java:80)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:148)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:95)
    at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:91)
    at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:60)
    at org.eclipse.jdt.internal.junit5.runner.JUnit5TestReference.run(JUnit5TestReference.java:98)
    at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:40)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:529)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:756)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:452)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:210)
Caused by: java.lang.IllegalStateException: @ConditionalOnMissingBean did not specify a bean using type, name or annotation and the attempt to deduce the bean's type failed
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.validate(OnBeanCondition.java:494)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.<init>(OnBeanCondition.java:443)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition.getMatchOutcome(OnBeanCondition.java:154)
    at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:47)
    ... 60 common frames omitted
Caused by: org.springframework.boot.autoconfigure.condition.OnBeanCondition$BeanTypeDeductionException: Failed to deduce bean type for org.springframework.boot.autoconfigure.mustache.MustacheServletWebConfiguration.mustacheViewResolver
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanTypeForBeanMethod(OnBeanCondition.java:524)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanType(OnBeanCondition.java:513)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.<init>(OnBeanCondition.java:436)
    ... 62 common frames omitted
Caused by: java.lang.NoClassDefFoundError: org/springframework/web/servlet/view/AbstractTemplateViewResolver
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1016)
    at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:151)
    at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:825)
    at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:723)
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:646)
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:604)
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:168)
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
    at java.base/java.lang.Class.forName0(Native Method)
    at java.base/java.lang.Class.forName(Class.java:468)
    at org.springframework.boot.autoconfigure.condition.FilteringSpringBootCondition.resolve(FilteringSpringBootCondition.java:108)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.getReturnType(OnBeanCondition.java:532)
    at org.springframework.boot.autoconfigure.condition.OnBeanCondition$Spec.deducedBeanTypeForBeanMethod(OnBeanCondition.java:520)
    ... 64 common frames omitted
Caused by: java.lang.ClassNotFoundException: org.springframework.web.servlet.view.AbstractTemplateViewResolver
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:606)
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:168)
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
    ... 78 common frames omitted

Comment From: bclozel

I'm confused. I cannot reproduce the issue you're describing with Spring Boot 2.6.5. Could you provide a sample application that shows the problem?

Also, checking for the presence of ViewResolver classes on the classpath looks strange to me. The @ConditionalOnWebApplication condition already performs classpath checks on classes that are in spring-webmvc or spring-webflux JARs.

Comment From: candrews

Here are two examples - the first is the one I experienced in the "real world" and the second is a minimally reproducing test case.

In both cases, run ./gradlew test to see the failure as described in this issue's description.

First, the "real word" is to add a dependency on org.mock-server:mockserver-spring-test-listener:5.13.0 which is how I discovered this issue. Here's an example build.gradle:

plugins {
    id 'org.springframework.boot' version '2.6.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

ext {
    mockserverVersion = '5.13.0'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation("org.mock-server:mockserver-spring-test-listener:${mockserverVersion}"){
        exclude group: 'jakarta.validation', module: 'jakarta.validation-api'
        exclude group: 'javax.validation', module: 'validation-api'
        exclude group: 'org.slf4j', module: 'slf4j-ext' // slf4j-ext isn't used - it shouldn't be a dependency. See https://github.com/swagger-api/swagger-parser/pull/1560
    }

}

tasks.named('test') {
    useJUnitPlatform()
}

configurations {
    all {
        resolutionStrategy.dependencySubstitution {
            substitute module('org.mock-server:mockserver-netty') using module("org.mock-server:mockserver-netty:${mockserverVersion}") withoutClassifier() because('org.mock-server:mockserver-spring-test-listener depends on org.mock-server:mockserver-netty with the "shaded" classifier. We want the non-shaded dependency. See: https://github.com/mock-server/mockserver/issues/1244')
        }
    }
}

Full project: demo.zip

And here's the minimal test case's build.gradle:

plugins {
    id 'org.springframework.boot' version '2.6.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation("org.springframework:spring-web")
    implementation("com.samskivert:jmustache:1.15")
    implementation("javax.servlet:javax.servlet-api")

}

tasks.named('test') {
    useJUnitPlatform()
}

Full project: demo.zip

Comment From: candrews

The @ConditionalOnWebApplication condition already performs classpath checks on classes that are in spring-webmvc or spring-webflux JARs.

Looking at https://github.com/spring-projects/spring-boot/blob/v2.6.5/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnWebApplicationCondition.java, @ConditionalOnWebApplication checks for org.springframework.web.context.support.GenericWebApplicationContext, which is in spring-web. It doesn't check for anything in spring-webmvc - and that's the problem.

I don't think it makes sense to have @ConditionalOnWebApplication(type=SERVLET) require spring-webmvc which is why I added these @ConditionalOnClass annotations. That seems like it would be a significant break in backwards compatibility and break the expectations users have based on the name of that annotation.

However, maybe it makes sense to add a couple of new conditional annotations? * @ConditionalOnWebMvc that's a composition of @ConditionalOnClass(org.springframework.web.servlet.ViewResolver.class) @ConditionalOnWebApplication(type=SERVLET) * @ConditionalOnWebFlux that's a composition of @ConditionalOnClass(org.springframework.web.reactive.result.view.ViewResolver.class) @ConditionalOnWebApplication(type=REACTIVE)

Comment From: wilkinsona

Thanks, @candrews.

As far as I can tell, the problem is Servlet-specific. @ConditionalOnWebApplication(type = Type.SERVLET) can match if spring-web is on the classpath but spring-webmvc is not. The latter contains AbstractTemplateViewResolver so the problem you've seen can arise.

On the reactive side, @ConditionalOnWebApplication(type = Type.REACTIVE) will only match if org.springframework.web.reactive.HandlerResult is on the classpath. If HandlerResult is present then org.springframework.web.reactive.result.view.UrlBasedViewResolver most also be present as they are both in the spring-webflux module.

Comment From: candrews

As far as I can tell, the problem is Servlet-specific.

That logic makes sense to me - thank you. I've updated this PR accordingly.

Comment From: candrews

I think this problem also exists for FreeMarker:

https://github.com/spring-projects/spring-boot/blob/bb7811e7f87c38da6cd86e0d3ed6dcdc97384f7b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/freemarker/FreeMarkerServletWebConfiguration.java#L46

Comment From: wilkinsona

FreeMarker won't be affected as FreeMarkerConfigurer is in spring-webmvc.

Comment From: candrews

FreeMarker won't be affected as FreeMarkerConfigurer is in spring-webmvc.

:+1:

Perhaps this MR is now mergeable? Please let me know if there's anything more I can do.

Comment From: wilkinsona

Thanks very much, @candrews.

Comment From: candrews

Will this be backported to 2.6.x?

I'm hoping this change will be included in 2.6.6 :-)

Comment From: wilkinsona

It's already there: https://github.com/spring-projects/spring-boot/issues/30475.