Monday, April 28, 2014

Spring Thymeleaf CRUD User Maintenance - Part 2

This post builds on the code from part 1 of this series, Spring Thymeleaf CRUD User Maintenance - Part 1.  In this post, I had intended to create the controller class to handle CRUD operations on the User object, as well as the Thymeleaf pages.  Since there are so many interesting topics to cover in these two areas, I've decided to split this post into two parts.  We will create the associated Thymeleaf CRUD pages, updates to the menus, and updates to Twitter Bootstrap functionality (associated with alerts and actions menus) in part 3.


1. The controller for the User pages, and the Thymeleaf User pages themselves will use the message file for labels and messages. The updated message file is shown below.  Pay particular attention to the sections labeled:
  • "# USER THYMELEAF LABELS"
  • "# CONTROLLER MESSAGES"
# HOME PAGE TITLES
admin.page.title=Admin Home Page
trader.page.title=Trader Home Page
user.page.title=User Home Page
login.page.title=Login Page

# GENERIC BUTTON LABELS
button.label.update=Update
button.label.edit=Edit
button.label.add=Add
button.label.save=Save
button.label.delete=Delete
button.label.cancel=Cancel

button.action.stage=stage
button.action.save=save
button.action.delete=delete
button.action.cancel=cancel

# STRATEGY THYMELEAF LABELS
strategy.list.page.title=Strategy List
strategy.list.head.title=Strategy List
strategy.list.body.title=Strategy List
strategy.list.table.title=Strategy List
strategy.list.id.label=Id
strategy.list.type.label=Strategy Type
strategy.list.name.label=Strategy Name
strategy.list.actions.label=Action
strategy.list.add.title=Add

strategy.delete.page.title=Delete Strategy
strategy.delete.head.title=Delete Strategy

strategy.edit.page.title=Edit Strategy
strategy.edit.head.title=Edit Strategy

# USER THYMELEAF LABELS
user.list.page.title=User List
user.list.table.title=User List
user.list.id.label=Id
user.list.user.label=Username
user.list.pass.label=Password
user.list.role.label=Role
user.list.enabled.label=Enabled
user.list.actions.label=Action
user.list.add.title=Add User

user.edit.page.title=Edit User
user.edit.head.title=Edit User

user.delete.page.title=Delete User
user.delete.head.title=Delete User

# CONTROLLER MESSAGES
ctrl.message.error.duplicate=The {0} ''{1}'' already exists in the system.
ctrl.message.error.duplicate.strategy=The {0} of type ''{1}'' with name ''{2}'' already exists in the system.
ctrl.message.error.notfound=The {0} ''{1}'' does not exist in the system.
ctrl.message.success.add=The {0} ''{1}'' was successfully added to the system.
ctrl.message.success.update=The {0} ''{1}'' was successfully updated in the system.
ctrl.message.success.cancel={0} of {1} ''{2}'' was cancelled.
ctrl.message.success.delete=The {0} ''{1}'' was successfully deleted from the system.
Notice the controller messages section. The numbers in brackets represent strings in an Object array that is passed to the Spring MessageSource.getMessage() method.  We will look at this method in more detail when we review the controller code.

Also, to display single quotes in the message displayed to the end user, we use another single quote to escape the second single quote.  So, to display one single quote we include two single quotes in the message text in the property file.  Here are two articles that discuss escaping quotes in property files:


2. The controller for the User pages is shown below, with comments following this code.
package com.dtr.oas.controller;
import java.util.List;
import java.util.Locale;
import javax.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.dtr.oas.exception.DuplicateUserException;
import com.dtr.oas.exception.RoleNotFoundException;
import com.dtr.oas.exception.UserNotFoundException;
import com.dtr.oas.model.Role;
import com.dtr.oas.model.User;
import com.dtr.oas.service.RoleService;
import com.dtr.oas.service.UserService;

@Controller
@RequestMapping(value = "/user")
@PreAuthorize("denyAll")
public class UserController {
    static Logger logger = LoggerFactory.getLogger(UserController.class);
    static String businessObject = "user"; //used in RedirectAttributes messages 

    @Autowired
    private RoleService roleService;

    @Autowired
    private UserService userService;

    @Autowired
    private MessageSource messageSource;

    @ModelAttribute("allRoles")
    @PreAuthorize("hasAnyRole('CTRL_USER_LIST_GET','CTRL_USER_EDIT_GET')")
    public List<Role> getAllRoles() {
        return roleService.getRoles();
    }

    @ModelAttribute("enabledOptions")
    @PreAuthorize("hasAnyRole('CTRL_USER_LIST_GET','CTRL_USER_EDIT_GET')")
    public boolean[] getEnabledOptions() {
        boolean[] array = new boolean[2];
        array[0] = true;
        array[1] = false;
        return array;
    }

    @RequestMapping(value = {"/", "/list"}, method = RequestMethod.GET)
    @PreAuthorize("hasRole('CTRL_USER_LIST_GET')")
    public String listUsers(Model model) {
        logger.info("IN: User/list-GET");

        List<User> users = userService.getUsers();
        model.addAttribute("users", users);

        // if there was an error in /add, we do not want to overwrite
        // the existing user object containing the errors.
        if (!model.containsAttribute("userDTO")) {
            logger.info("Adding UserDTO object to model");
            UserDTO userDTO = new UserDTO();
            model.addAttribute("userDTO", userDTO);
        }
        return "user-list";
    }

    @RequestMapping(value = "/add", method = RequestMethod.POST)
    @PreAuthorize("hasRole('CTRL_USER_ADD_POST')")
    public String addUser(@Valid @ModelAttribute UserDTO userDTO,
            BindingResult result, RedirectAttributes redirectAttrs) {
        
        logger.info("IN: User/add-POST");

        if (result.hasErrors()) {
            logger.info("UserDTO add error: " + result.toString());
            redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.userDTO", result);
            redirectAttrs.addFlashAttribute("userDTO", userDTO);
            return "redirect:/user/list";
        } else {
            User user = new User();

            try {
                user = getUser(userDTO);
                userService.addUser(user);
                String message = messageSource.getMessage("ctrl.message.success.add", new Object[] {businessObject, user.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("message", message);
                return "redirect:/user/list";
            } catch (DuplicateUserException e) {
                String message = messageSource.getMessage("ctrl.message.error.duplicate", new Object[] {businessObject, userDTO.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("error", message);
                return "redirect:/user/list";
            } catch (RoleNotFoundException rnf) {
                String message = messageSource.getMessage("ctrl.message.error.notfound", new Object[] {"role", userDTO.getRoleId()}, Locale.US);
                result.rejectValue("roleId", "0", message);
                redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.userDTO", result);
                redirectAttrs.addFlashAttribute("userDTO", userDTO);
                return "redirect:/user/list";
            }
        }
    }

    @RequestMapping(value = "/edit", method = RequestMethod.GET)
    @PreAuthorize("hasRole('CTRL_USER_EDIT_GET')")
    public String editUserPage(@RequestParam(value = "id", required = true)
            Integer id, Model model, RedirectAttributes redirectAttrs) {

        logger.info("IN: User/edit-GET:  ID to query = " + id);

        try {
            if (!model.containsAttribute("userDTO")) {
                logger.info("Adding userDTO object to model");
                User user = userService.getUser(id);
                UserDTO userDTO = getUserDTO(user);
                logger.info("User/edit-GET:  " + userDTO.toString());
                model.addAttribute("userDTO", userDTO);
            }
            return "user-edit";
        } catch (UserNotFoundException e) {
            String message = messageSource.getMessage("ctrl.message.error.notfound", new Object[] {"user id", id}, Locale.US);
            model.addAttribute("error", message);
            return "redirect:/user/list";
        }
    }

    @RequestMapping(value = "/edit", method = RequestMethod.POST)
    @PreAuthorize("hasRole('CTRL_USER_EDIT_POST')")
    public String editUser(@Valid @ModelAttribute UserDTO userDTO,
            BindingResult result, RedirectAttributes redirectAttrs,
            @RequestParam(value = "action", required = true) String action) {

        logger.info("IN: User/edit-POST: " + action);

        if (action.equals(messageSource.getMessage("button.action.cancel", null, Locale.US))) {
            String message = messageSource.getMessage("ctrl.message.success.cancel", new Object[] {"Edit", businessObject, userDTO.getUsername()}, Locale.US);
            redirectAttrs.addFlashAttribute("message", message);
        } else if (result.hasErrors()) {
            logger.info("User-edit error: " + result.toString());
            redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.userDTO", result);
            redirectAttrs.addFlashAttribute("userDTO", userDTO);
            return "redirect:/user/edit?id=" + userDTO.getId();
        } else if (action.equals(messageSource.getMessage("button.action.save",  null, Locale.US))) {
            logger.info("User/edit-POST:  " + userDTO.toString());
            try {
                User user = getUser(userDTO);
                userService.updateUser(user);
                String message = messageSource.getMessage("ctrl.message.success.update", new Object[] {businessObject, userDTO.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("message", message);
            } catch (DuplicateUserException unf) {
                String message = messageSource.getMessage("ctrl.message.error.duplicate", new Object[] {businessObject, userDTO.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("error", message);
                return "redirect:/user/list";
            } catch (UserNotFoundException unf) {
                String message = messageSource.getMessage("ctrl.message.error.notfound", new Object[] {businessObject, userDTO.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("error", message);
                return "redirect:/user/list";
            } catch (RoleNotFoundException rnf) {
                String message = messageSource.getMessage("ctrl.message.error.notfound", new Object[] {"role", userDTO.getRoleId()}, Locale.US);
                redirectAttrs.addFlashAttribute("error", message);
                return "redirect:/user/list";
            }
        }
        return "redirect:/user/list";
    }

    @RequestMapping(value = "/delete", method = RequestMethod.GET)
    @PreAuthorize("hasRole('CTRL_USER_DELETE_GET')")
    public String deleteUser(
            @RequestParam(value = "id", required = true) Integer id,
            @RequestParam(value = "phase", required = true) String phase,
            Model model, RedirectAttributes redirectAttrs) {

        User user;
        try {
            user = userService.getUser(id);
        } catch (UserNotFoundException e) {
            String message = "User number [" + id + "] does not exist in the system";
            redirectAttrs.addFlashAttribute("error", message);
            return "redirect:/user/list";
        }

        logger.info("IN: User/delete-GET | id = " + id + " | phase = " + phase + " | " + user.toString());

        if (phase.equals(messageSource.getMessage("button.action.cancel", null, Locale.US))) {
            String message = messageSource.getMessage("ctrl.message.success.cancel", new Object[] {"Delete", businessObject, user.getUsername()}, Locale.US);
            redirectAttrs.addFlashAttribute("message", message);
            return "redirect:/user/list";
        } else if (phase.equals(messageSource.getMessage("button.action.stage", null, Locale.US))) {
            logger.info("     adding user: " + user.toString());
            model.addAttribute("user", user);
            return "user-delete";
        } else if (phase.equals(messageSource.getMessage("button.action.delete", null, Locale.US))) {
            try {
                userService.deleteUser(user.getId());
                String message = messageSource.getMessage("ctrl.message.success.delete", new Object[] {businessObject, user.getUsername()}, Locale.US);
                redirectAttrs.addFlashAttribute("message", message);
                return "redirect:/user/list";
            } catch (UserNotFoundException e) {
                String message = messageSource.getMessage("ctrl.message.error.notfound", new Object[] {"user id", id}, Locale.US);
               redirectAttrs.addFlashAttribute("error", message);
                return "redirect:/user/list";
           }
        }

        return "redirect:/user/list";
    }

    @PreAuthorize("hasAnyRole('CTRL_USER_EDIT_GET','CTRL_USER_DELETE_GET')")
    public UserDTO getUserDTO(User user) {
        UserDTO userDTO = new UserDTO();
        userDTO.setId(user.getId());
        userDTO.setUsername(user.getUsername());
        userDTO.setPassword(user.getPassword());
        userDTO.setEnabled(user.getEnabled());
        userDTO.setRoleId(user.getRole().getId());
        return userDTO;
    }

    @PreAuthorize("hasAnyRole('CTRL_USER_ADD_POST','CTRL_USER_EDIT_POST')")
    public User getUser(UserDTO userDTO) throws RoleNotFoundException {
        User user = new User();
        user.setId(userDTO.getId());

        Role role = roleService.getRole(userDTO.getRoleId());
        user.setRole(role);

        user.setUsername(userDTO.getUsername());
        user.setPassword(userDTO.getPassword());
        user.setEnabled(userDTO.getEnabled());
        return user;
    }
}
There are several points that I will discuss below.  The associated lines are highlighted in the code above.
  • There is an instance variable named businessObject.  This string is used in the messages returned from the controller and this variable allows us to reuse messages in the messages_en.properties file across controllers.
  • The @Autowired MessageSource variable allows us to access messages in the messages_en.properties file, and is used throughout the controller.
  • All of the methods in the controller are annotated with @PreAuthorize.  These tags contain permission values for fine grained authorization.  We set up the infrastructure to use permissions rather than roles for authorization in the last series of posts.
  • There are two methods that are annotated with @ModelAttributes.  These methods will populate the response with lists used to populate dropdown lists on the User Thymeleaf pages.
    • Also notice the call to the roleService to get all of the roles in the system.
  • Some of the methods in the controller that are annotated with @RequestMapping get/set a User DTO rather than a User Entity.  We have to use this DTO object to pass roleIds to/from a form.  Unfortunately, we can't assign Role objects in a form...as far as I know, so we need to use a new object for this purpose.  The list page uses Entities for the list, but a DTO for the add section.  The edit page uses a DTO, and the delete page uses an Entity.  You will see Thymeleaf examples in the next post.
  • In the addUser() method you will notice the call to messageSource.getMessage(), that we touched on in bullet one in this post.  The parameters are:
    • The key from the messages_en.properties file.
    • An object array containing the arguments that will be substituted for the {#} placeholders in the messages in the messages_en.properties file.
    • The Locale.
  • I add the message as a flash attribute with the identifier of either "message" or "error".  These attributes will be displayed on the Thymeleaf pages in a dedicated message display section.
  • Finally, note the two helper methods at the bottom of the controller that convert from DTO to Entity, and from Entity to DTO.


3. The User DTO that we created contains the same validations contained in the User Entity.  I don't like the redundancy, but I couldn't find an elegant solution.  Also, you will notice that the DTO contains a roleId instance variable of type int, rather than the role instance variable of type Role contained in the Entity.  The DTO code is shown below.
package com.dtr.oas.controller;
import java.io.Serializable;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.hibernate.validator.constraints.NotEmpty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Objects;

public class UserDTO implements Serializable {

    private static final long serialVersionUID = -1631797874369735181L;
    static Logger logger = LoggerFactory.getLogger(UserDTO.class);
    
    private int id;
    
    @NotNull(message = "{error.user.username.null}")
    @NotEmpty(message = "{error.user.username.empty}")
    @Size(max = 50, message = "{error.user.username.max}")
    private String username;

    @NotNull(message = "{error.user.password.null}")
    @NotEmpty(message = "{error.user.password.empty}")
    @Size(max = 50, message = "{error.user.password.max}")
    private String password;
    
    private boolean enabled;
    private int roleId;
    
    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public String getUsername() {
        return username;
    }
    public void setUsername(String username) {
        this.username = username;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
    public boolean getEnabled() {
        return enabled;
    }
    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
    public int getRoleId() {
        return roleId;
    }
    public void setRoleId(int roleId) {
        this.roleId = roleId;
    }
    
    @Override
    public String toString() {
        return String.format("%s(id=%d, username=%s, password=%s, enabled=%b, roleID=%d)", 
                this.getClass().getSimpleName(), 
                this.getId(), 
                this.getUsername(), 
                this.getPassword(), 
                this.getEnabled(),
                this.getRoleId());
    }

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (o == null)
            return false;

        if (o instanceof UserDTO) {
            final UserDTO other = (UserDTO) o;
            return Objects.equal(getId(), other.getId())
                    && Objects.equal(getUsername(), other.getUsername())
                    && Objects.equal(getPassword(), other.getPassword())
                    && Objects.equal(getEnabled(), other.getEnabled())
                    && Objects.equal(getRoleId(), other.getRoleId());
        }
        return false;
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(getId(), getUsername(), getPassword(), getEnabled(), getRoleId());
    }
}

In the next post we will create the Thymeleaf CRUD pages, update the menus, and update the Twitter Bootstrap functionality (associated with alerts and actions menus).

1 comment:

Unknown said...
This comment has been removed by the author.

Post a Comment