'Spring Boot Upgrade from 2.3.x -> 2.6.x Breaks mvc forward:

After upgrading From Spring Boot 2.3.x to 2.6.x Forwarding MVC REST Calls doesn't work.

We have a controller for /health that returns forward:/actuator/health/. forward:/actuator/health/ fails in the controller, but localhost:8080/actuator/health/ works just fine in my browser when hit directly.

We have the following controller:

@Controller
public class HealthRestController implements HealthAPI
{
   @Value("forward:/actuator/health/")
   String forwardString;

   @CrossOrigin
   @GetMapping(value = {"/health"}, produces = "application/json")
   public String getHealth() { return forwardString; }

   @GetMapping(value = {"/health_secure"}, produces = "application/json")
   public String getHealthSecure() { return forwardString; }

}

/health does not require authentication.

      @Override
      public void configure(WebSecurity web) throws Exception
      {  //other urls removed, for simplicity
         web.ignoring()
                 .antMatchers("/health",
                              "/actuator/health",
                              "/actuator/prometheus",
                              "/cloudfoundryapplication",
                              "/actuator/cloudfoundryapplication",
                              "/cloudfoundryapplication/**");
      }

But when I hit /health in My Web Browser, I get the following errors:

May 10, 2022 10:44:28 AM org.apache.catalina.core.ApplicationContext log
INFO: Initializing Spring DispatcherServlet 'dispatcherServlet'
2022-05-10 10:44:28 INFO  DispatcherServlet:525 -   - Initializing Servlet 'dispatcherServlet'
2022-05-10 10:44:28 INFO  DispatcherServlet:547 -   - Completed initialization in 2 ms
May 10, 2022 10:44:28 AM org.apache.catalina.core.ApplicationDispatcher invoke
SEVERE: Servlet.service() for servlet [dispatcherServlet] threw exception
javax.servlet.ServletException: Could not resolve view with name 'forward:/actuator/health/' in servlet with name 'dispatcherServlet'
    at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1380)
    at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1145)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1084)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:655)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:764)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:711)
    at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:459)
    at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:385)
    at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:313)
    at com.allstate.d3.sh.commons.config.CloudMetricsConfig$2.service(CloudMetricsConfig.java:76)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
    at java.lang.Thread.run(Thread.java:748)

May 10, 2022 10:44:28 AM org.apache.catalina.core.StandardWrapperValve invoke
SEVERE: Servlet.service() for servlet [health] in context with path [/health] threw exception [Could not resolve view with name 'forward:/actuator/health/' in servlet with name 'dispatcherServlet'] with root cause
javax.servlet.ServletException: Could not resolve view with name 'forward:/actuator/health/' in servlet with name 'dispatcherServlet'
    at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1380)
    at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1145)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1084)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:655)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:764)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:711)
    at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:459)
    at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:385)
    at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:313)
    at com.allstate.d3.sh.commons.config.CloudMetricsConfig$2.service(CloudMetricsConfig.java:76)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
    at java.lang.Thread.run(Thread.java:748)

So what are we missing? why doesn't forward: work?

UPDATE

Dependency snippet from build.gradle with Spring dependencies


    buildscript
    {
       ext { springBootVersion = '2.6.7' }
       //other stuff here

       //skip repositories

       dependencies
       {
          classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
          //other non-spring dependencies here
       }       
    }


    //dependencies

    implementation('org.springframework.boot:spring-boot-starter-web')

    implementation('org.springframework:spring-context')
    implementation('org.springframework:spring-expression')
    implementation('org.springframework:spring-beans')
    implementation('org.springframework:spring-core')

    implementation('org.springframework.boot:spring-boot-starter-webflux')

    implementation("org.springframework.cloud:spring-cloud-starter-vault-config:3.1.0")

    implementation('org.springframework.security:spring-security-web')
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    implementation("org.springframework.boot:spring-boot-starter-aop")

    implementation("org.springframework.kafka:spring-kafka")
    
    implementation("org.springframework.security.oauth:spring-security-oauth2:${springSecurityOauth2Version}")

    implementation "org.springframework.cloud:spring-cloud-spring-service-connector:${springCloudConnectorVersion}"
    implementation "org.springframework.cloud:spring-cloud-cloudfoundry-connector:${springCloudConnectorVersion}"

    //websocket
    implementation("org.springframework.boot:spring-boot-starter-websocket")

    // cache
    implementation 'org.springframework:spring-context'
    implementation 'org.springframework:spring-context-support'

    implementation 'org.springdoc:springdoc-openapi-ui:1.6.1'

    implementation("org.springframework.security:spring-security-jwt:${springSecurityJwtVersion}")           

application context path is set in application.yml

server:
  servlet:
    contextPath: /exe/v2

Paths are exposed outside of the context path like so:

@Configuration
public class CloudMetricsConfig
{
    @Bean
    public TomcatServletWebServerFactory servletWebServerFactory()
    {
        return new TomcatServletWebServerFactory()
        {
            @Override
            protected void prepareContext(Host host,
                                          ServletContextInitializer[] initializers)
            {
               super.prepareContext(host, initializers);

               addContext(host, "/cloudfoundryapplication", getContextPath(),
                          "cloudfoundry");
               addContext(host, "/actuator/prometheus", getContextPath(),
                          "prometheus");
               addContext(host, "/actuator/health", getContextPath(),
                          "actuatorHealth");
               addContext(host, "/health", getContextPath(),
                          "health");
            }

        };
    }

    private void addContext(Host host, String path, String contextPath,
                            String servletName)
    {
        StandardContext child = new StandardContext();
        child.addLifecycleListener(new Tomcat.FixContextListener());
        child.setPath(path);
        ServletContainerInitializer initializer =
               getServletContextInitializer(contextPath, servletName, path);
        child.addServletContainerInitializer(initializer, Collections.emptySet());
        child.setCrossContext(true);
        host.addChild(child);
    }

    private ServletContainerInitializer getServletContextInitializer(String contextPath,
                                                                     String servletName,
                                                                     String path)
    {
       return (c, context) ->
       {
          Servlet servlet = new GenericServlet()
          {
             @Override
             public void service(ServletRequest req, ServletResponse res)
                    throws ServletException, IOException
             {
                ServletContext context = req.getServletContext().getContext(contextPath);
                context.getRequestDispatcher(path).forward(req, res);
             }
          };
          context.addServlet(servletName, servlet)//.addMapping(path);
                 .addMapping("/*");
       };
    }


Solution 1:[1]

Just checked with Spring Boot 2.6.7 - works as expected.

org.springframework.web.servlet.view.UrlBasedViewResolver#FORWARD_URL_PREFIX (which is the string "forward:") is the part of spring-webmvc module. Check that you have this in your project's dependencies, with the version that corresponds to the Spring/Boot version you use.

Solution 2:[2]

Per @dekkard 's answer: https://stackoverflow.com/a/72192299/659354
I upgraded from Spring Boot 2.6.2 -> 2.6.7 to minimize version overrides for more secure versions. And verified the presence of spring-mvc

The final piece of the puzzle came from following the link in @RAHULBHOITE 's answer
Spring boot single page application - forward every request to index.html

I removed @EnableWebMvc from one of my configuration classes.
Now the /health works. All my integration tests still pass, and manual postman testing for the REST endpoints still works.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 dekkard
Solution 2 Raystorm