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.
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.
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.
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.
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:
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.
now we can simply change our controller to something like:
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):
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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"; | |
} |
Bellow is the Thymeleaf template that will render the attributes of the Person object.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
The Annotation:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ""; | |
} |
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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"; | |
} |
Bonus - testing the ModelPopulator (a compact version):
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
Post a Comment