Thymeleaf template layouts in Spring MVC application with no extensions

After some years with JSP/JSTL and Apache Tiles I started discovering Thymeleaf for my Spring MVC applications. Thymeleaf is a really great view engine and it simplifies and speeds up the development despite that lack of good IntelliJ (vote here: http://youtrack.jetbrains.com/issue/IDEABKL-6713) support at the moment (there is an Eclipse plugin though). While learning how to use Thymeleaf I investigated different possibilities of working with layouts.

Apart from the native fragment inclusion mechanism there are at least two options to work with layouts: Thymeleaf integration with Apache Tile and Thymeleaf Layout Dialect. Both seem to work fine, but inspired by this comment about a simple and custom option, I gave it a try. In this post I will show I created the solution.

Change Log

  • 17/01/2017 - Source code updated. Fixed bug with casting in the interceptor. Library upgrade.
  • 04/12/2016 - Source code updated. Thymeleaf 3.0.2
  • 26/07/2016 - Source code updated. No Layout option introduced (@Layout(Layout.NONE)), integration tests, POM improvements and updates.

Create a Spring MVC project with Thymeleaf support

To get started quickly I used my Spring MVC Archetype with Thymeleaf 2.1 support. I created a project by simply invoking the archetype and then imported it to IntellJ.

Creating the layout file

In WEB-INF/views directory I created a layouts folder where I placed the my first layout file called default.html:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>...</head>
<body>
<div th:raplace="fragments/header :: header">
    Header
</div>
<div th:replace="${view} :: content">
    Content
</div>
<div th:replace="fragments/footer :: footer">
    Footer
</div>
</body>
</html>

The ${view} variable will contain the view name returned by the @Controller and the content fragment from ${view} file will be placed here.

Creating the view file

I edited WEB-INF/views/homeNotSignedIn.html and I defined the content fragment like this:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>...</head>
<body>
<div class="container" th:fragment="content">
    <!-- /* Handle the flash message */-->
    <th:block th:if="${message != null}">
        <div th:replace="fragments/alert :: alert (type=${#strings.toLowerCase(message.type)}, message=${message.message})">&nbsp;</div>
    </th:block>
    <p>
        Hello <span th:text="${#authentication.name}">User</span>!
        Welcome to the Spring MVC Quickstart application!
    </p>
</div>
</body>
</html>

So the only change was defining the fragment named content and removing duplicated fragment inclusions. No additional changes are required. The @Controller returns the original view name, as it was before:

@Controller
class HomeController {
 
 @RequestMapping(value = "/", method = RequestMethod.GET)
 String index(Principal principal) {
  return principal != null ? "home/homeSignedIn" : "home/homeNotSignedIn";
 }
}

I changed other views accordingly.

Creating the interceptor and integrating with Spring MVC

To finish the "new layout framework" I created a handler interceptor that will do the work:


public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        modelAndView.setViewName(DEFAULT_LAYOUT);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    }    
}

ThymeleafLayoutInterceptor gets the original view name returned from the handler's method and replaces it with the layout name (that is defined in WEB-INF/views/layouts/default.html). The original view is placed in the model as a view variable, so it can be used in the layout file. I overrode the postHandle method, as it is executed just before rendering the view.
Adding the interceptor is easy:

@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ThymeleafLayoutInterceptor());
    }
}
And that's it for the basic configuration. Not a rocket since. The result after going to localhost:8080. This is what I expected. Works like a charm. So I try to signup for an account and what I see after submitting a form:
500 returned for /signup with message Error resolving template "redirect:/", template might not exist or might not be accessible by any of the configured Template Resolvers
Of course, redirect:/ after the form submission. I needed to modify the interceptor like this:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        modelAndView.setViewName(DEFAULT_LAYOUT);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    } 
    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }   
}

And it worked as expected. But I realized that I need to define and additional layout because Signup and Signin used this before, but not after applying the above changes.

Creating additional layouts

I created a new layout called blank.html and placed it to WEB-INF/views/layouts folder. But how to use select the layout? Probably there are many ways to do this. One of the easiest I though is to return the layout name from the @Controller by simply adding a model attribute named layout. If no layout is given, default one is used, otherwise the given one. Simple. But I wanted a more robust solution. So I thought maybe an annotation that I could use like this:


@Controller
class SigninController {

    @Layout(value = "layouts/blank")
    @RequestMapping(value = "signin")
    String signin() {
        return "signin/signin";
    }
}

To me it sounded like a good solution. So I implemented it.

Selecting the layout

I created a method level @Layout annotation that I placed in org.thymeleaf.spring.support package (together with ThymeleafLayoutInterceptor):


package org.thymeleaf.spring.support;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}


I changed the interceptor as follows:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        String layoutName = getLayoutName(handler);
        modelAndView.setViewName(layoutName);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    }

    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }

    private String getLayoutName(Object handler) {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Layout layout = handlerMethod.getMethodAnnotation(Layout.class);
        if (layout == null) {
            return DEFAULT_LAYOUT;
        } else {
            return layout.value();
        }
    }
}

Now, when the handler method is annotated with @Layout annotation, it get its value attribute. Works great. But when I started to change SignupController I realized I need to annotate both methods. It would be better if my annotation can be used for all methods at once, by annotating the @Controller class:

@Controller
@Layout(value = "layouts/blank")
class SignupController {

}

So I did.

Final touches

Firstly, I changed the annotation so it can be targeted at the type level:


@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}

And the interceptor:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    private String defaultLayout = DEFAULT_LAYOUT;
    private String viewAttributeName = DEFAULT_VIEW_ATTRIBUTE_NAME;

    public void setDefaultLayout(String defaultLayout) {
        Assert.hasLength(defaultLayout);
        this.defaultLayout = defaultLayout;
    }

    public void setViewAttributeName(String viewAttributeName) {
        Assert.hasLength(defaultLayout);
        this.viewAttributeName = viewAttributeName;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        String layoutName = getLayoutName(handler);
        modelAndView.setViewName(layoutName);
        modelAndView.addObject(this.viewAttributeName, originalViewName);
    }

    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }

    private String getLayoutName(Object handler) {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Layout layout = getMethodOrTypeAnnotation(handlerMethod);
            if (layout != null) {
                return layout.value();
            }
        }
        return this.defaultLayout;
    }

    private Layout getMethodOrTypeAnnotation(HandlerMethod handlerMethod) {
        Layout layout = handlerMethod.getMethodAnnotation(Layout.class);
        if (layout == null) {
            return handlerMethod.getBeanType().getAnnotation(Layout.class);
        }
        return layout;
    }
}

As you can see method level annotation is more important than type level annotation, which gives some flexibility. In addition, I added a possibility to configure the interceptor. I thought, that setting the default layout name and view attribute name may be useful.

Summary

The presented solution may need some polishing in order to use it in production, but it shows how simply we can build template layouts without adding extra libraries to our project and utilizing only core Thymeleaf features. Please share you comments and opinions about the solution.

Please find the source code on GitHub: https://github.com/kolorobot/thymeleaf-custom-layout

Popular posts from this blog

Parameterized tests in JavaScript with Jest

macOS: Insert current date shortcut with `Shortcuts.app`