I've been struggling with a rather absurd problem for a few days now: The project I'm on is using Spring MVC with FreeMarker for it's templating.
This is running atop a Tomcat container (testing locally using Cargo).
The issue I'm working has the brief of implementing uniform behaviour in a standardised error page but covering covering the various types of errors that may be encountered. (Exceptions bubbling up from back-end services, inadequate permissions, http errors, etc)
So far, the results are as follows (Graphic included):
Currently we're using Spring to configure the servlet handling so the web.xml is beautifully sparse:
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<!--
This application uses the config of the mapping by Spring MVC
This is why you will not see servlet declarations here
The web app is defined in
- butler.SpringWebInit
- butler.SpringWebConfig
-->
<context-param>
<description>Escape HTML form data by default when using Spring tags</description>
<param-name>defaultHtmlEscape</param-name>
<param-value>true</param-value>
</context-param>
<!-- Disabling welcome list file for Tomcat, handling it in Spring MVC -->
<welcome-file-list>
<welcome-file/>
</welcome-file-list>
<!-- Generic Error redirection, allows for handling in Spring MVC -->
<error-page>
<location>/http-error</location>
<!-- Was originally just "/error" it was changed for internal forwarding/proxying/redirection attempts -->
</error-page>
</web-app>
The Configuration is handled by SpringWebInit.java to which I have not made any modifications:
SpringWebInit.java
/**
* Automatically loaded by class org.springframework.web.SpringServletContainerInitializer
*
* @see http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#mvc-container-config
*
* According to {@link AbstractSecurityWebApplicationInitializer}, this class should be
* annotated with a Order so that it is loaded before {@link SpringSecurityInit}
*/
@Order(0)
public class SpringWebInit extends AbstractAnnotationConfigDispatcherServletInitializer implements InitializingBean {
private final Logger LOG = LoggerFactory.getLogger(getClass());
@Override
public void afterPropertiesSet() throws Exception {
LOG.info("DispatcherServlet loaded");
}
@Override
protected Class<?>[] getServletConfigClasses() {
return null; // returning null, getRootConfigClasses() will handle this as well
}
@Override
protected String[] getServletMappings() {
return new String[] {"/**"}; // Spring MVC should handle everything
}
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[] {SpringWebConfig.class, SpringSecurityConfig.class};
}
@Override
protected Filter[] getServletFilters() {
CharacterEncodingFilter characterEncodingFilter =
new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true);
return new Filter[] {characterEncodingFilter, new SiteMeshFilter()};
}
}
Which in turn loads The various config for Freemarker and Sitemesh:
SpringWebConfig.java
@EnableWebMvc
@Configuration
@PropertySource("classpath:/butler-init.properties")
@ComponentScan({"butler"})
class SpringWebConfig extends WebMvcConfigurerAdapter implements InitializingBean {
private final Logger LOG = LoggerFactory.getLogger(getClass());
@Autowired
LoggedInUserService loggedInUserService;
@Override
public void afterPropertiesSet() throws Exception {
LOG.info("Web Mvc Configurer loaded");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userHeaderInterceptor());
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("/static/").setCacheControl(
CacheControl.maxAge(30, TimeUnit.MINUTES).noTransform().cachePublic().mustRevalidate());
}
@Bean
FreeMarkerViewResolver viewResolver() throws TemplateException {
FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
resolver.setCache(/*true*/false); // Set to false for debugging
resolver.setPrefix("");
resolver.setSuffix(".ftlh");
resolver.setRequestContextAttribute("rContext");
resolver.setContentType("text/html;charset=UTF-8");
DefaultObjectWrapper wrapper =
new DefaultObjectWrapperBuilder(freemarker.template.Configuration.getVersion()).build();
Map<String, Object> attrs = new HashMap<>();
attrs.put("loggedInUserService", wrapper.wrap(loggedInUserService));
resolver.setAttributesMap(attrs);
return resolver;
}
@Bean
FreeMarkerConfigurer freeMarkerConfig() {
Properties freeMarkerVariables = new Properties();
// http://freemarker.org/docs/pgui_config_incompatible_improvements.html
// http://freemarker.org/docs/pgui_config_outputformatsautoesc.html
freeMarkerVariables.put(freemarker.template.Configuration.INCOMPATIBLE_IMPROVEMENTS_KEY,
freemarker.template.Configuration.getVersion().toString());
FreeMarkerConfigurer freeMarkerConfigurer = new FreeMarkerConfigurer();
freeMarkerConfigurer.setDefaultEncoding("UTF-8");
freeMarkerConfigurer.setTemplateLoaderPath("/WEB-INF/mvc/view/ftl/");
freeMarkerConfigurer.setFreemarkerSettings(freeMarkerVariables);
return freeMarkerConfigurer;
}
@Bean
UserHeaderInterceptor userHeaderInterceptor() {
return new UserHeaderInterceptor();
}
@Bean
static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
}
SiteMeshFilter.java
public class SiteMeshFilter extends ConfigurableSiteMeshFilter {
@Override
protected void applyCustomConfiguration(SiteMeshFilterBuilder builder) {
// Don't use decorator REST api pages
builder.addExcludedPath("/api/*");
builder.addDecoratorPath("/*", Views.DECORATOR_HEADER_FOOTER);
builder.setIncludeErrorPages(true);
}
}
Finally, onto the meat of the problem, the error handling is being handled via a combination of DefaultControllerAdvice.java, which provides the rules for intercepting exceptions and ErrorController.java itself, which handles the mappings and eventually, the message handling (displaying information about the error, adapting according to the type of error, etc)
DefaultControllerAdvice.java
@ControllerAdvice(annotations = Controller.class)
class DefaultControllerAdvice {
private static String EXCEPTION = "butlerexception";
@ExceptionHandler(ServiceException.class)
public String exceptionHandler(ServiceException se, Model model) {
model.addAttribute(EXCEPTION, se.getMessage());
return Views.ERROR;
}
@ExceptionHandler(PermissionException.class)
public String exceptionHandler(PermissionException pe, Model model) {
model.addAttribute(EXCEPTION, "Incorrect Permissions");
return Views.ERROR;
}
/*@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(IOException.class)
public String exceptionHandler(Model model) { // Trying another way of intercepting 404 errors
model.addAttribute(EXCEPTION, "HTTP Error: 404");
return Views.ERROR;
}*/
}
ErrorController.java
@Controller
class ErrorController extends AbstractController {
@Autowired
private LoggedInUserService loggedInUserService;
@RequestMapping(path="error",method = {GET,POST}) // Normal Error Controller, Returns fully decorated page without issue for Exceptions and normal requests.
public String error(RedirectAttributes redirectAttributes, HttpServletResponse response,Model model) {
//if (redirectAttributes.containsAttribute("errorCode")) { // Trying to invisibly use redirection
// Map<String, ?> redirAttribs = redirectAttributes.getFlashAttributes();
// model.addAttribute("butlerexception", "HTTP Error: "+redirAttribs.get("errorCode"));
//} else {
model.addAttribute("butlerexception", "Error");
//}
return ERROR;
}
@RequestMapping("/http-error") // Created to test HTTP requests being proxied via ServiceExceptions, Redirections, etc...
public String httpError(/*RedirectAttributes redirectAttributes,*/ HttpServletResponse response, HttpServletRequest request, Model model){
model.addAttribute("butlerexception", "HTTP Error: " + response.getStatus());
//throw new ServiceException("HTTP Error: " + response.getStatus()); // Trying to piggyback off Exception handling
//redirectAttributes.addFlashAttribute("errorCode", response.getStatus()); // Trying to invisibly use redirection
//redirectAttributes.addFlashAttribute("originalURL",request.getRequestURL());
return /*"redirect:"+*/ERROR;
}
}
So Far, I have tried:
Additionally, from the debugging logs, I can see that the filters from Spring Security are triggered normally but the ones involved with decorating the site (for both logged in and anonymous requests) fail to trigger for HTTP errors only.
One of the limiting factors currently is that I cannot gut the system and define it all in the web.xml (as many of the solutions here and in the Spring documentation seem to call for) without causing excessive disruption to development at this stage. (nor do I have the authority to effect such a change (Junior rank))
For Convenience sake, a few of the solutions I've tried so far:
At this point I'm really not sure what else to try, what on earth am I missing here?
Edit: it turned out to be a bug in SiteMesh to do with the triggering of .setContentType(...)
that was solved via setting the contentType again after sitemesh in order to trigger decoration: Bug report with description and solution
This turned out to a two-part issue, firstly SiteMesh3's handling of error pages means that it believes it has processed all the filters even when an error causes decorators to be skipped. (expanded upon in this issue on github)
The second part was that SiteMesh3 appears to only buffer pages for decoration when SpringMVC calls .setContentType(...)
.
This was tripping up since Spring will only trigger this on elements with undefined content type whereas errors have already had their content type defined before they even reach Spring. (expanded upon by my lead in this issue)
My lead managed to solve this by adding a filter after SiteMesh that triggered .setContentType(...)
and forced SiteMesh to buffer the page for decoration.
It's a little heavy, since it means that the content type is set twice per request, but it works.
Edit: Originally had a note here asking not to upvote to avoid receiving rep for a solution my lead found, but found a blog post explaining that self-answers don't earn rep - huzzah!