DTOs conversion with reflection example - applied on Thymeleaf (or how to get read of Pojo's conversion boilerplate)

I was playing around with Thymeleaf the other day and I wanted to pass all values of my ModelAttribute POJO (Person.class in the example) to Spring's UI Model so I can use the values to the template. (Example to follow).


A form is filled, and the values are passed to a controller that will render them to a new view (html) page. Nothing complex or useful, but just an alternative to hello world if you will.

The ModelAttribute Person.class. Ignore the @ModelElement annotations for now.

package com.multipartyloops.thymeleaf.entities;
import com.multipartyloops.thymeleaf.entities.model.converters.ModelElement;
import java.util.Objects;
public class Person {
@ModelElement(key = "name")
private String name;
@ModelElement(key = "sex")
private Sex sex;
@ModelElement(key = "age")
private int age;
public Person(String name, Sex sex, int age) {
this.name = name;
this.sex = sex;
this.age = age;
}
//Getters and Setters
}
view raw Person.java hosted with ❤ by GitHub
The main page that will render a simple form with 1 textfield (name),  one numeric field (age), one drop down menu to restrict the input values of a java enum (sex) and of course the submit button.

<!DOCTYPE HTML>
<html>
<head>
<title>A simple form to test Thymeleaf</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<form method="post" action="/">
<label for="name">name:</label><br>
<input type="text" id="name" name="name"><br>
<label for="sex">sex:</label><br>
<select id="sex" name="sex">
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="NON_BINARY">Non Binary</option>
</select><br>
<label for="age">age:</label><br>
<input type="number" id="age" name="age"><br><br>
<input type="submit" value="Submit">
</form>
</body>
</html>
view raw form-index.html hosted with ❤ by GitHub
When we click the submit button the following controller will be called, passing the values via the Person object. We then are passing them to Spring's model attribute so we can use them to our template. Lines 4-6 should belong in a different class but for simplicity I am putting them in the controller.

@RequestMapping(value = "/", method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String home(@ModelAttribute Person body, Model model) {
model.addAttribute("name", body.getName());
model.addAttribute("age", body.getAge());
model.addAttribute("sex", body.getSex());
return "index";
}
The above illustrates the problem. There is a lot of unnecessary code written here. Passing every POJO's field into the model is boring. It generates a lot of boilerplate and its not fun.

Bellow is the Thymeleaf template that will render the attributes of the Person object.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Serving Web Content</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="${name}"></p>
<p th:text="${age}"></p>
<p th:text="${sex}"></p>
</body>
</html>
view raw index.html hosted with ❤ by GitHub
So how do we increase the fun factor here? If we don't mind Reflection (making a conscious choice here), we are going to create a custom annotation (you have already seen it in the Person.class above). We will use it to annotate the fields that we want to pass to the model for rendering in our html template.

The Annotation:
package com.multipartyloops.thymeleaf.entities.model.converters;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ModelElement {
String key() default "";
}
Now we will need a "utility" to take an Object with annotated field and a Model and populate the Model without having to specify every field manually added.
We will use reflection to get all the field of the object (it wont take fields of a super class though), we will use reflection magic to make all accessible and we will check the annotation. If the field is annotated with a key value we will take the key value from the annotation parameter otherwise we will use the field's name as the key.

package com.multipartyloops.thymeleaf.entities.model.converters;
import org.springframework.ui.Model;
import java.lang.reflect.Field;
public class ModelPopulator {
public void populateModelsAttributesWithObjectsValues(Object object, Model model) {
Class<?> objectType = object.getClass();
Field[] fields = objectType.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
if(field.isAnnotationPresent(ModelElement.class)){
String fieldName = field.getAnnotation(ModelElement.class).key();
Object value = getFieldsValue(object, field);
if("".equals(fieldName)){
fieldName = field.getName();
}
model.addAttribute(fieldName, value);
}
}
}
private Object getFieldsValue(Object object, Field field){
try {
return field.get(object);
} catch (IllegalAccessException e) {
throw new RuntimeException("Could not read value of private property", e);
}
}
}
now we can simply change our controller to something like:

@RequestMapping(value = "/", method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String home(@ModelAttribute Person body, Model model) {
modelPopulator.populateModelsAttributesWithObjectsValues(body, model);
return "index";
}
Once again, it is not controller's responsibility to do the parsing (line 3). If we wanted to be SOLID we would have to use a service class, or delegate the parsing to a different object.

Bonus - testing the ModelPopulator (a compact version):

package com.multipartyloops.thymeleaf.entities.model.converters;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ui.ConcurrentModel;
import org.springframework.ui.Model;
import java.util.ArrayList;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertNull;
class ModelPopulatorTest {
private ModelPopulator modelPopulator;
@BeforeEach
void setup(){
modelPopulator = new ModelPopulator();
}
@Test
void populatesTheValuesOfAModelFromARandomObject() {
Model model = new ConcurrentModel();
List<String> aList = new ArrayList<>();
aList.add("testValue1");
aList.add("testValue2");
ARandomObject aRandomObject = new ARandomObject("a string", 42, aList);
modelPopulator.populateModelsAttributesWithObjectsValues(aRandomObject, model);
assertThat(model.getAttribute("privateString"), equalTo("a string"));
assertThat(model.getAttribute("privateInt"), equalTo(42));
assertThat(model.getAttribute("aListIsAList"), equalTo(aList));
assertNull(model.getAttribute("privateList"));
assertNull(model.getAttribute("notToBeIncluded"));
}
private static final class ARandomObject {
@ModelElement
private String privateString;
@ModelElement
private int privateInt;
@ModelElement(key = "aListIsAList")
private List<String> privateList;
private String notToBeIncluded;
public ARandomObject(String privateString, int privateInt, List<String> privateList) {
this.privateString = privateString;
this.privateInt = privateInt;
this.privateList = new ArrayList<>(privateList);
this.notToBeIncluded = "DoesNotMatter!";
}
}
}

Comments

Popular posts from this blog

Make your bash script runnable from every directory on the terminal

How to create a Cassandra container for testing with Keyspace and the latest schema with a single script call