The Problem
Using Springframework v6.2.6 (not spring boot) and Tomcat v11.0.6 (not embedded) I cannot use the ShallowEtagHeaderFiler
on a resource that is internally forwarded. I have several url paths that should result in the index.html page being served (single page application style), and I use server-side forwarding so the url displayed to the user remains the same. After the forward happens, the response content is missing (blank page), and the ETag header isn't present, despite a 200 status code. I also see this in the tomcat access log
org.apache.catalina.core.ApplicationDispatcher.forward Disabling the response for further output
Specifically, I think this happens when a HttpServletResponseWrapper (like what is used by ShallowEtagHeaderFilter) is active in the filter chain and I perform a RequestDispatcher.forward() (e.g., via Spring MVC's InternalResourceViewResolver) to a resource. After the forward, the output can't be written to for the Etag itself, and also for the contents that were cached.
Forwarding works as expected using Spring v5.3.27 and Tomcat 9.0.102
Sample Setup
TestController
@Controller
public class TestController {
@RequestMaping(value = {"/", "/admin/, "/admin", "/dev", "/dev/"})
public String handleRootRequest() {
return "/resources/index.html";
}
}
MvcConfiguration
@Configuration
@EnableWebMvc
public class TestMvcConfiguration implements WebMvcConfigurer
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**")
.addResourceLocations("/resources/")
.setCachePeriod(0);
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.favorParmeter(true)
.useRegisteredExtensionsOnly(true)
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("html", MediaType.TEXT_HTMNL);
}
// Have tried this instead of the controller endpoint, but same result
// @override
// public void addViewControllers(ViewControllerRegistry registry) {
// registry.addViewController("/").setViewName("forward:/resources/index.html");
// }
Snippet of applicable WebApplicationInitializer
public class WebApInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
try (AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
AnnotationConfigWebApplicationContext dispatcherContext = new AnnotationConfigWebApplicationContext(); ) {
rootContext.register(AppConfig.class);
servletContext.addListener(new ContextLoaderListener(rootContext));
// filters, but the etag filer is the last one
FilterRegistration.Dynamic etagFilter = servletContext.addFilter("shallowEtagHeaderFilter", new ShallowETagHeaderFilter());
etagFilter.setAsyncSupported(true);
etagFilter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ASYNC), true, "/*");
dispatcherContext.register(MvcConfig.class);
DispatcherServlet dispatcherSrv = new DispatcherServlet(dispatcherContext);
ServletRegistration.Dynamic dispatcher = servletContext.addServlet("dispatcher", dispatcherSrv);
dispatcher.addLoadOnStartup(1);
dispatcher.addMapping("/");
dispatcher.setAsyncSupported(true);
}
}
}
Create a generic ApplicationConfiguration Create a generic index.html file and put it in src/main/webapps/resources. Create a war file and deploy it to Tomcat. Attempt to go to localhost:8080/testApp (or whatever the root context of the war file is) You won't get the contents of the index.html, only a blank page.
Workaround
If I stream my index.html file directly from the endpoint in the Controller, thereby bypassing the forwarding by the mvc internal resource handler, the content of the index.html appears and the Etag header is present The following code in the Controller (the MvcConfiguration code is being bypassed) works
@Controller
public class TestController {
@RequestMaping(value = {"/", "/admin/, "/admin", "/dev", "/dev/"})
public void handleRootRequest(HttpServletRequest request, HttpServletResponse response) {
response.setContentType(MediaType.TEXT_HTML_VALUE);
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0");
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
ServletContext servletContext = request.getServletContext();
String indexPath = "/resources/index.html";
InputStream is = servletContext.getResourceAsStream(indexPath);
if (is != null) {
try (OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
} finally {
is.close();
}
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
}