Spring Boot + Groovy Templates CRUD Example

Spring Boot officially provided starter to use Groovy Template for MVC and offline rendering.

In this tutorial, we will learn on how to build a simple CRUD Spring Boot application using Groovy Template as server side template engine. There are several template engines that included with Groovy. For this example, we will use MarkupTemplateEngine, a very complete, optimized, template engine.

Create a Spring Boot Project

To generate Spring Boot application we can use Spring Initializr (https://start.spring.io/ ) or Spring Boot CLI. Please refer to Scaffolding Spring Boot Application for details to create new Spring Boot application. For this tutorial, we need (at least) these five dependencies:

  • Spring Web
    Build web, including RESTful, applications using Spring MVC. Uses Apache Tomcat as the default embedded container.
  • Spring Data JPA
    Persist data in SQL stores with Java Persistence API using Spring Data and Hibernate.
  • Groovy Templates
    Groovy templating engine.
  • Lombok
    Java annotation library which helps to reduce boilerplate code.
  • PostgreSQL Driver
    A JDBC and R2DBC driver that allows Java programs to connect to a PostgreSQL database using standard, database independent Java code.

The starter for Groovy Templates is spring-boot-starter-groovy-templates. In our maven's pom.xml, the dependencies will be:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-groovy-templates</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

Prepare PostgreSQL

This tutorial will use PostgreSQL as data source. For this tutorial, we will create a simple CRUD to manage movies. The application should able to create, retrieve, update and delete a Movie. It's a simple Movie class, with this structure:

  1. ID (Primary key of this entity)
  2. Title
  3. Release Year
  4. Running time (in minutes)
  5. Tags (example: action, comedy, drama)
  6. Language
  7. Country

Yes, cannot compete with imdb, but should be good enough for our example. The databasename is demodb, here the DDL for table movie:

CREATE TABLE movie ( id bigserial NOT NULL, title character varying(255) NOT NULL, release_year integer, runtime_minutes integer, tags character varying(255), lang character varying(50), country character varying(50), CONSTRAINT movie_pkey PRIMARY KEY (id) );

To change ownership for this table to user barista:

ALTER TABLE movie OWNER TO barista;

And for demo, let's insert several records:

insert into movie (title, release_year, runtime_minutes, tags, lang, country) values ('Saving Private Ryan', 1998, 169, 'Drama, War', 'en', 'USA'), ('Gladiator', 2000, 155, 'Action, Adventure, Drama', 'en', 'USA'), ('Schindler''s List', 1993, 195, 'Biography, Drama, History', 'en', 'USA'), ('Blood Diamond', 2006, 143, 'Adventure, Drama, Thriller', 'en', 'USA'), ('Black Hawk Down', 2001, 144, 'Drama, History, War', 'en', 'USA'), ('Hidden Figures', 2016, 127, 'Biography, Drama, History', 'en', 'USA'), ('Patriots Day', 2016, 133, 'Action, Crime, Drama', 'en', 'USA'), ('Hacksaw Ridge', 2016, 139, 'Biography, Drama, History', 'en', 'USA');

Configure Datasource

Now, let’s configure Spring Boot for data source. Add PostgreSQL database url, username, and password in the src/main/resources/application.properties file:

spring.datasource.url = jdbc:postgresql://localhost/demodb # Username and password spring.datasource.username = barista spring.datasource.password = espresso # Allows Hibernate to generate SQL optimized for a particular DBMS spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQL82Dialect spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation = true

Spring Boot Application

MovieApplication is the main entry point of our Spring Boot application:

MovieApplication.java
package com.dariawan.moviedb;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MovieApplication {

    public static void main(String[] args) {
        SpringApplication.run(MovieApplication.class, args);
    }
}
                    

The main() method calls Spring Boot’s SpringApplication.run() method to launch the application.

Entity Class

Movie class is our entity class mapped to table movie:

package com.dariawan.moviedb.domain;

import java.io.Serializable;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import org.springframework.validation.annotation.Validated;

@Validated
@Entity
@Table(name = "movie")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Getter
@Setter
public class Movie implements Serializable {

    private static final long serialVersionUID = 6235798961366545815L;

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;
    
    @NotBlank
    @Size(max = 255)
    private String title;
    
    private Integer releaseYear;
    
    private Integer runtimeMinutes;
    
    @Size(max = 255)
    private String tags;
    
    @Size(max = 50)
    private String lang;
    
    @Size(max = 50)
    private String country; 
}
                    

Repository Class

MovieRepository extends PagingAndSortingRepository, an interface that provides generic CRUD operations and add methods like findAll(Pageable pageable) and findAll(Sort sort) methods for paging and sorting.

MovieRepository.java
package com.dariawan.moviedb.repository;

import com.dariawan.moviedb.domain.Movie;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.PagingAndSortingRepository;

public interface MovieRepository extends PagingAndSortingRepository<Movie, Long>, 
        JpaSpecificationExecutor<Movie> {
}
                    

Service Class

Service class is class that contains @Service annotation, used to write business logic. In our example, our service class is MovieService.

MovieService.java
package com.dariawan.moviedb.service;

import com.dariawan.moviedb.domain.Movie;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.dariawan.moviedb.repository.MovieRepository;

@Service
public class MovieService {
    
    @Autowired
    private MovieRepository movieRepository;
    
    private boolean existsById(Long id) {
        return movieRepository.existsById(id);
    }
    
    public Movie findById(Long id) throws Exception {
        Movie movie = movieRepository.findById(id).orElse(null);
        if (movie==null) {
            throw new Exception("Cannot find movie with id: " + id);
        }
        else return movie;
    }
    
    public List<Movie> findAll(int pageNumber, int rowPerPage) {
        List<Movie> movies = new ArrayList<>();
        Pageable sortedByIdAsc = PageRequest.of(pageNumber - 1, rowPerPage, 
                Sort.by("id").ascending());
        movieRepository.findAll(sortedByIdAsc).forEach(movies::add);
        return movies;
    }
    
    public Movie save(Movie movie) throws Exception {
        if (!StringUtils.isEmpty(movie.getTitle())) {
            if (movie.getId() != null && existsById(movie.getId())) { 
                throw new Exception("Movie with id: " + movie.getId() +
                        " already exists");
            }
            return movieRepository.save(movie);
        }
        else {
            throw new Exception("Title cannot be null or empty");
        }
    }
    
    public void update(Movie movie) throws Exception {
        if (!StringUtils.isEmpty(movie.getTitle())) {
            if (!existsById(movie.getId())) {
                throw new Exception("Cannot find movie with id: " + movie.getId());
            }
            movieRepository.save(movie);
        }
        else {
            throw new Exception("Title cannot be null or empty");
        }
    }
    
    public void deleteById(Long id) throws Exception {
        if (!existsById(id)) { 
            throw new Exception("Cannot find movie with id: " + id);
        }
        else {
            movieRepository.deleteById(id);
        }
    }
    
    public Long count() {
        return movieRepository.count();
    }
}
                    

Controller Class

Our controller class is MovieController. @Controller annotation indicates that the annotated class is a controller and used to handle requests coming from the client. Controller is typically used in combination with annotated handler methods based on the @RequestMapping annotation (such as: @GetMapping and @PostMapping).

MovieController.java
package com.dariawan.moviedb.controller;

import com.dariawan.moviedb.domain.Movie;
import com.dariawan.moviedb.service.MovieService;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class MovieController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private final int ROW_PER_PAGE = 5;

    @Autowired
    private MovieService movieService;

    @Value("${msg.title}")
    private String title;

    @GetMapping(value = {"/", "/index"})
    public String index(Model model) { ... }

    @GetMapping(value = "/movies")
    public String getMovies(Model model,
            @RequestParam(value = "page", defaultValue = "1") int pageNumber) { ... }

    @GetMapping(value = "/movies/{movieId}")
    public String getMovieById(Model model, @PathVariable long movieId) { ... }

    @GetMapping(value = {"/movies/add"})
    public String showAddMovie(Model model) {
        Movie movie = new Movie();
        model.addAttribute("add", true);
        model.addAttribute("movie", movie);
        model.addAttribute("actionUrl", "/movies/add");

        return "movie-edit";
    }

    @PostMapping(value = "/movies/add")
    public String addMovie(Model model,
            @ModelAttribute("movie") Movie movie) { ... }

    @GetMapping(value = {"/movies/{movieId}/edit"})
    public String showEditMovie(Model model, @PathVariable long movieId) { ... }

    @PostMapping(value = {"/movies/{movieId}/edit"})
    public String updateMovie(Model model,
            @PathVariable long movieId,
            @ModelAttribute("movie") Movie movie) { ... }

    @GetMapping(value = {"/movies/{movieId}/delete"})
    public String showDeleteMovieById(
            Model model, @PathVariable long movieId) { ... }

    @PostMapping(value = {"/movies/{movieId}/delete"})
    public String deleteMovieById(
            Model model, @PathVariable long movieId) { ... }
}
                    

We will go through every pages and groovy templates this controller associated with.

Index Page

The index page is a simple page with the title of application and link to Movie List page.

@Value("${msg.title}")
private String title;

@GetMapping(value = {"/", "/index"})
public String index(Model model) {
    model.addAttribute("title", title);
    return "index";
}
                    

The function returning String, which is the template name which will be used to render the response. The default suffix for Grrovy Templates in Spring Boot is .tpl. The template that will be rendered in this function is index.tpl which is available in default templates location in src/main/resources/templates/.

index.tpl
yieldUnescaped '<!DOCTYPE html>'
html(lang:'en') {
    head {
        meta('http-equiv':'"Content-Type" content="text/html; charset=utf-8"')
        title("$title")
        link(rel: "stylesheet", type: "text/css", href: "/css/style.css")
    }
    body {
        h1 ("$title")
        a(href: "/movies", "Movie List")
        br()
        br()
        div ("Copyright &copy; dariawan.com")
    }
}
                    

A quick introduction to Groovy Templates, method yieldUnescaped used to renders raw contents. The argument is rendered as is, without escaping. So, it will render (print): <!DOCTYPE html>

Another method that we will find later on is yield method, used to renders contents, but escapes it before rendering.

The title attribute is extracted from application.properties with the @Value annotation. The value of msg.title:

msg.title=Spring Boot + Groovy Templates

And result in http://localhost:8080:

http://localhost:8080

http://localhost:8080 (Index Page)

Movies Page

Movies page will show list of movies in paged mode ─ for every five records.

@GetMapping(value = "/movies")
public String getMovies(Model model,
        @RequestParam(value = "page", defaultValue = "1") int pageNumber) {
    List<Movie> movies = movieService.findAll(pageNumber, ROW_PER_PAGE);

    long count = movieService.count();
    boolean hasPrev = pageNumber > 1;
    boolean hasNext = (pageNumber * ROW_PER_PAGE) < count;
    model.addAttribute("movies", movies);
    model.addAttribute("hasPrev", hasPrev);
    model.addAttribute("prev", pageNumber - 1);
    model.addAttribute("hasNext", hasNext);
    model.addAttribute("next", pageNumber + 1);
    return "movie-list";
}
                    

The controller then will render movie-list.tpl

movie-list.tpl
yieldUnescaped '<!DOCTYPE html>'
html(lang:'en') {
    head {
        meta('http-equiv':'"Content-Type" content="text/html; charset=utf-8"')
        title("Movie List")
        link(rel: "stylesheet", type: "text/css", href: "/css/style.css")
    }
    body {
        h1("Movie List")
        div {
            nobr {
                a(href: "/movies/add", "Add Movie")
                yield ' | '
                a(href: "/", "Back to Index")
            }
        }
        br()
        br()
        div {
            table(border: "1") {
                tr {
                    th("Id")
                    th("Title")
                    th("Year")
                    th("Running Time")
                    th("Tags")
                    th("Edit")
                }
                movies.each { movie ->
                    tr {
                        td {
                            a(href:"/movies/$movie.id", "$movie.id")
                        }
                        td {
                            a(href:"/movies/$movie.id", "$movie.title")
                        }
                        td("$movie.releaseYear")
                        td("$movie.runtimeMinutes")
                        td("$movie.tags")
                        td {
                            a(href:"/movies/$movie.id/edit", "Edit")
                        }
                    }
                }
            }
        }
        br()
        br()
        div {
            nobr {
                span {
                    if (hasPrev) {
                        a(href:"/movies?page=$prev", "Prev")
                        yield '   '
                    }
                }
                span {
                    if (hasNext) {
                        a(href:"/movies?page=$next", "Next")
                    }
                }
            }
        }
    }
}
                    

Result of http://localhost:8080/movies:

http://localhost:8080/movies

http://localhost:8080/movies (Movie List Page)

Movie Page

Movie Page will show movie's data in read-only mode. From this page, user can decide to "Edit" or "Delete" movie.

@GetMapping(value = "/movies/{movieId}")
public String getMovieById(Model model, @PathVariable long movieId) {
    Movie movie = null;
    String errorMessage = null;
    try {
        movie = movieService.findById(movieId);
    } catch (Exception ex) {
        errorMessage = "Movie not found";
    }

    model.addAttribute("movie", movie);
    model.addAttribute("allowDelete", false);
    model.addAttribute("errorMessage", errorMessage);
    return "movie-view";
}
                    

movie-view.tpl returned (and rendered) by function getMovieById:

movie-view.tpl
yieldUnescaped '<!DOCTYPE html>'
html(lang:'en') {
    head {
        meta('http-equiv':'"Content-Type" content="text/html; charset=utf-8"')
        title("View Movie")
        link(rel: "stylesheet", type: "text/css", href: "/css/style.css")
    }
    body {
        h1("View Movie")
        a(href: "/movies", "Back to Movie List")
        br()
        br()
        div {
            table(border: "0") {
                tr {
                    td("Id")
                    td(":")
                    td(movie.id ?: '')
                }
                tr {
                    td("Title")
                    td(":")
                    td(movie.title ?: '')
                }
                tr {
                    td("Release Year")
                    td(":")
                    td(movie.releaseYear ?: '')
                }
                tr {
                    td("Runtime (Minutes)")
                    td(":")
                    td(movie.runtimeMinutes ?: '')
                }
                tr {
                    td("Tags")
                    td(":")
                    td(movie.tags ?: '')
                }
                tr {
                    td("Language")
                    td(":")
                    td(movie.lang ?: '')
                }
                tr {
                    td("Country")
                    td(":")
                    td(movie.country ?: '')
                }
            }
        }
        br()
        br()
        if (allowDelete) {
            form (id:"deleteForm", action:"/movies/$movie.id/delete", method:"POST") {
                yield 'Delete this movie? '
                input(type: 'submit', value: 'Yes')
            }
        }
        else {
            div {
                a(href: "/movies/$movie.id/edit", "Edit")
                yield ' | '
                a(href: "/movies/$movie.id/delete", "Delete")
            }
        }
        if (errorMessage!=null) {
            div(class: "error", "$errorMessage")
        }
    }
}
                    

http://localhost:8080/movies/4 rendered in browser:

http://localhost:8080/movies/4

http://localhost:8080/movies/4 (Movie Page)

If user choose Edit, it will go to Edit Movie Page. If user choose Delete, it will lead to Delete Movie Page.

Edit and Add Movie Page

For Add and Edit Movie Page, we will use following flow:

  1. GET request to show/render the page, represented by functions showAddMovie(...) and showEditMovie(...)
  2. POST request to save the user data to the server, represented by functions addMovie(...) and updateMovie(...)
@GetMapping(value = {"/movies/add"})
public String showAddMovie(Model model) {
    Movie movie = new Movie();
    model.addAttribute("add", true);
    model.addAttribute("movie", movie);
    model.addAttribute("actionUrl", "/movies/add");

    return "movie-edit";
}

@PostMapping(value = "/movies/add")
public String addMovie(Model model,
        @ModelAttribute("movie") Movie movie) {        
    try {
        Movie newMovie = movieService.save(movie);
        return "redirect:/movies/" + String.valueOf(newMovie.getId());
    } catch (Exception ex) {
        // log exception first, 
        // then show error
        String errorMessage = ex.getMessage();
        logger.error(errorMessage);
        model.addAttribute("errorMessage", errorMessage);

        //model.addAttribute("movie", movie);
        model.addAttribute("add", true);
        return "movie-edit";
    }        
}

@GetMapping(value = {"/movies/{movieId}/edit"})
public String showEditMovie(Model model, @PathVariable long movieId) {
    Movie movie = null;
    String errorMessage = null;
    try {
        movie = movieService.findById(movieId);
    } catch (Exception ex) {
        errorMessage = "Movie not found";            
    }
    model.addAttribute("add", false);
    model.addAttribute("movie", movie);
    model.addAttribute("errorMessage", errorMessage);
    model.addAttribute("actionUrl", 
            "/movies/" + (movie == null ? 0 : movie.getId()) + "/edit");
    return "movie-edit";
}

@PostMapping(value = {"/movies/{movieId}/edit"})
public String updateMovie(Model model,
        @PathVariable long movieId,
        @ModelAttribute("movie") Movie movie) {        
    try {
        movie.setId(movieId);
        movieService.update(movie);
        return "redirect:/movies/" + String.valueOf(movie.getId());
    } catch (Exception ex) {
        // log exception first, 
        // then show error
        String errorMessage = ex.getMessage();
        logger.error(errorMessage);
        model.addAttribute("errorMessage", errorMessage);

        model.addAttribute("add", false);
        return "movie-edit";
    }
}
                    

For GET request, both functions will render movie-edit.tpl:

movie-edit.tpl
yieldUnescaped '<!DOCTYPE html>'
html(lang:'en') {
    head {
        meta('http-equiv':'"Content-Type" content="text/html; charset=utf-8"')        
        if (add) {
            title("Create a Movie")
        }
        else {
            title("Edit Movie")
        }
        link(rel: "stylesheet", type: "text/css", href: "/css/style.css")
    }
    body {
        if (add) {
            h1("Create a Movie:")
        }
        else {
            h1("Edit Movie")
        }
        a(href: "/movies", "Back to Movie List")
        br()
        br()
        form (id:"editForm", action:"$actionUrl", method:"POST") {
            table(border: "0") {
                if (!add) {
                    tr {
                        td("Id")
                        td(":")
                        td(movie.id ?: '')
                    }
                }
                tr {
                    td("Title")
                    td(":")
                    td {
                        input(name: 'title', type: 'text', value: movie.title ?: '')
                    }
                }
                tr {
                    td("Release Year")
                    td(":")
                    td {
                        input(name: 'releaseYear', type: 'number', value: movie.releaseYear ?: '')
                    }
                }
                tr {
                    td("Runtime (Minutes)")
                    td(":")
                    td {
                        input(name: 'runtimeMinutes', type: 'number', value: movie.runtimeMinutes ?: '')
                    }
                }
                tr {
                    td("Tags")
                    td(":")
                    td {
                        input(name: 'tags', type: 'text', value: movie.tags ?: '')
                    }
                }
                tr {
                    td("Language")
                    td(":")
                    td {
                        input(name: 'lang', type: 'text', value: movie.lang ?: '')
                    }
                }
                tr {
                    td("Country")
                    td(":")
                    td {
                        input(name: 'country', type: 'text', value: movie.country ?: '')
                    }
                }
            }
            br()
            if (add) {
                input(type: 'submit', value: 'Create')
            }
            else {
                input(type: 'submit', value: 'Update')
            }
        }
        
        br()
        if (errorMessage!=null) {
            div(class: "error", "$errorMessage")
        }
    }
}
                    

Add Movie Page in http://localhost:8080/movies/add :

http://localhost:8080/movies/add

http://localhost:8080/movies/add (Add Movie Page)

Upon successful add, the controller will redirect to Movie Page to view new created movie info.

Following screenshot is the example of Edit Movie Page (for movie with id=8) which is available in http://localhost:8080/movies/8/edit:

http://localhost:8080/movies/8/edit

http://localhost:8080/movies/8/edit (Edit User Page)

Upon successful update, the controller also will redirect to Movie Page to view updated movie data.

Delete Movie Page

Delete Movie Page using same scenario as Add/Edit Movie Page:

  1. GET request to show/render the page, represented by functions showDeleteMovieById(...) to confirm deletion
  2. POST request to delete movie data from server, represented by functions deleteMovieById(...)
@GetMapping(value = {"/movies/{movieId}/delete"})
public String showDeleteMovieById(
        Model model, @PathVariable long movieId) {
    Movie movie = null;
    String errorMessage = null;
    try {
        movie = movieService.findById(movieId);
    } catch (Exception ex) {
        errorMessage = "Movie not found";

    }
    model.addAttribute("allowDelete", true);
    model.addAttribute("movie", movie);
    model.addAttribute("errorMessage", errorMessage);
    return "movie-view";
}

@PostMapping(value = {"/movies/{movieId}/delete"})
public String deleteMovieById(
        Model model, @PathVariable long movieId) {
    try {
        movieService.deleteById(movieId);
        return "redirect:/movies";
    } catch (Exception ex) {
        String errorMessage = ex.getMessage();
        logger.error(errorMessage);
        model.addAttribute("errorMessage", errorMessage);
        return "movie-view";
    }
}
                    

Confirm deletion when trying to delete movie with id 8 (http://localhost:8080/users/4/delete):

http://localhost:8080/movies/8/delete

http://localhost:8080/movies/8/delete (Delete Movie Page)

Spring Boot's static resources default folder is in \src\main\resources\static\. The css used for this example is available in css\style.css

Final Project Structure

At the end, our project structure will be similar like this:

spring-boot-groovy-templates-example │ .gitignore │ mvnw │ mvnw.cmd │ pom.xml │ ├───.mvn │ └───wrapper │ maven-wrapper.jar │ maven-wrapper.properties │ MavenWrapperDownloader.java │ └───src ├───main │ ├───java │ │ └───com │ │ └───dariawan │ │ └───moviedb │ │ │ MovieApplication.java │ │ │ │ │ ├───controller │ │ │ MovieController.java │ │ │ │ │ ├───domain │ │ │ Movie.java │ │ │ │ │ ├───repository │ │ │ MovieRepository.java │ │ │ │ │ └───service │ │ MovieService.java │ │ │ └───resources │ │ application.properties │ │ │ ├───static │ │ └───css │ │ style.css │ │ │ └───templates │ index.tpl │ movie-edit.tpl │ movie-list.tpl │ movie-view.tpl │ ├───sql │ movies.sql │ └───test └───java └───com └───dariawan └───moviedb MovieApplicationTests.java