Forms
Programs create posts through the API, but people need an ordinary HTML form. Thymeleaf links form fields to a Java object: it shows the object's values in the fields, and after submitting Spring builds the object back from the fields. @Valid works the same as in the API, only the errors appear right in the form.
Showing the form
// src/main/java/com/example/blog/PageController.java
@GetMapping("/posts/new")
public String newPost(Model model) {
model.addAttribute("post", new NewPostRequest("", "", null));
model.addAttribute("users", users.findAll());
return "posts/form";
}We put an empty NewPostRequest record into the model — the form is filled from its values — and a list of users to choose the author from. Add UserRepository users to the PageController constructor.
The form template
<!-- src/main/resources/templates/posts/form.html -->
<form th:action="@{/posts}" th:object="${post}" method="post">
<label>Title
<input type="text" th:field="*{title}">
</label>
<p class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Error</p>
<label>Text
<textarea th:field="*{body}" rows="8"></textarea>
</label>
<p class="error" th:if="${#fields.hasErrors('body')}" th:errors="*{body}">Error</p>
<label>Author
<select th:field="*{authorId}">
<option th:each="user : ${users}" th:value="${user.id}" th:text="${user.username}">anna</option>
</select>
</label>
<button type="submit">Publish</button>
</form>th:object="${post}" selects the form object, and th:field="*{title}" links a field to its component: Thymeleaf sets id, name and value itself. th:errors prints the field's error messages. The template is happy with record components — no getters needed.
Handling the submit
// src/main/java/com/example/blog/PageController.java
@PostMapping("/posts")
public String create(@Valid @ModelAttribute("post") NewPostRequest form,
BindingResult result, Model model) {
if (result.hasErrors()) {
model.addAttribute("users", users.findAll());
return "posts/form";
}
PostResponse post = postService.create(form);
return "redirect:/posts/" + post.id();
}@ModelAttribute("post") builds the record from the form fields. BindingResult holds the validation result: on errors we show the form again — the entered values and the messages stay in place. On success we return redirect: — the browser goes to the new post's page. This way refreshing the page does not submit the form a second time.
Checking it
Open http://localhost:8080/posts/new and click "Publish" with empty fields — "must not be blank" appears under the fields. The same is visible with curl:
curl -i -X POST http://localhost:8080/posts -d "title=From the form&body=Written in the browser&authorId=2"HTTP/1.1 302
Location: http://localhost:8080/posts/2Code 302 and the Location header are the redirect after a successful submit.
Common mistake
BindingResult must come right after the validated object in the parameters. If you put another parameter between them, for example Model, Spring decides you do not intend to handle errors: the method is not called, and instead of the form with hints the user sees a 400 error page.