spring-bootspring-securitythymeleafhtmx

Using SpringBoot, thymeleaf and htmx specifically with hx-put in a html form th:object not being passed in request


I am attempting to use hx-put for processing an update on a form. My project uses Spring 3.3.2, thymeleaf-extras-springsecurity6, and htmx 2.0.3 (webjars).

Here is my thymeleaf fragment for processing an update:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<div th:fragment="email-form(email, personId)">
    <h4 th:text="${edit} ? 'Edit Email' : 'Add Email'">add or edit Email</h4>
    <form th:action="@{/person/{personId}/email/save(personId=${personId})}"
          th:object="${email}"
          method="post">
        <input type="hidden" th:field="*{emailId}"/>

        <div class="mb-3">
            <label for="emailType" class="form-label">Type</label>
            <select id="emailType" th:field="*{type}" class="form-select">
                <option value="PERSONAL">Personal</option>
                <option value="WORK">Work</option>
                <option value="HOME">Home</option>
                <option value="SCHOOL">School</option>
                <option value="OTHER">Other</option>
            </select>
        </div>
        <div class="mb-3">
            <label for="email" class="form-label">Email Address</label>
            <input id="email" type="email" th:field="*{emailAddress}" class="form-control"
                   placeholder="email@example.com" required>
        </div>
        <div class="d-flex justify-content-end">
            <button type="button" class="btn btn-success me-2"
                    th:if="${edit}"
                    th:hx-put="@{/person/{personId}/email/update(personId=${personId})}"
                    hx-target="#email-parent"
                    hx-swap="innerHTML transition:true"
                    th:attr="data-csrf-header=${_csrf.headerName}, data-csrf-token=${_csrf.token}, data-email-id=${email.emailId"
                    hx-trigger="click">
                Update Email
            </button>

            <button type="button" class="btn btn-success me-2"
                    th:if="${!edit}"
                    th:hx-post="@{/person/{personId}/email/save(personId=${personId})}"
                    hx-target="#email-parent"
                    hx-swap="innerHTML transition:true"
                    hx-trigger="click">
                Save
            </button>
            <button type="button" class="btn btn-secondary"
                    th:hx-get="@{/person/view/{personId}(personId=${personId})}"
                    hx-target="body" hx-swap="outerHTML transition:true">Cancel
            </button>
        </div>
    </form>
</div>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<div th:fragment="email-form(email, personId)">
    <h4 th:text="${edit} ? 'Edit Email' : 'Add Email'">add or edit Email</h4>
    <form th:action="@{/person/{personId}/email/save(personId=${personId})}"
          th:object="${email}"
          method="post">
        <input type="hidden" th:field="*{emailId}"/>

        <div class="mb-3">
            <label for="emailType" class="form-label">Type</label>
            <select id="emailType" th:field="*{type}" class="form-select">
                <option value="PERSONAL">Personal</option>
                <option value="WORK">Work</option>
                <option value="HOME">Home</option>
                <option value="SCHOOL">School</option>
                <option value="OTHER">Other</option>
            </select>
        </div>
        <div class="mb-3">
            <label for="email" class="form-label">Email Address</label>
            <input id="email" type="email" th:field="*{emailAddress}" class="form-control"
                   placeholder="email@example.com" required>
        </div>
        <div class="d-flex justify-content-end">
            <button type="button" class="btn btn-success me-2"
                    th:if="${edit}"
                    th:hx-put="@{/person/{personId}/email/update(personId=${personId})}"
                    hx-target="#email-parent"
                    hx-swap="innerHTML transition:true"
                    th:attr="data-csrf-header=${_csrf.headerName}, data-csrf-token=${_csrf.token}"
                    hx-trigger="click">
                Update Email
            </button>

            <button type="button" class="btn btn-success me-2"
                    th:if="${!edit}"
                    th:hx-post="@{/person/{personId}/email/save(personId=${personId})}"
                    hx-target="#email-parent"
                    hx-swap="innerHTML transition:true"
                    hx-trigger="click">
                Save
            </button>
            <button type="button" class="btn btn-secondary"
                    th:hx-get="@{/person/view/{personId}(personId=${personId})}"
                    hx-target="body" hx-swap="outerHTML transition:true">Cancel
            </button>
        </div>
    </form>
</div>
</html>

This form is used for both adding new emails and updating existing. Updates are being attempted with hx-put.

In my user-layout.html, I include a htmx.configRequest call:

<!DOCTYPE html>
<html lang="en"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
    <meta charset="UTF-8">
    <title>Records Management - User Dashboard</title>
    <link rel="stylesheet" th:href="@{/css/style.css}"/>
    <link rel="stylesheet" th:href="@{/webjars/bootstrap/5.3.3/css/bootstrap.min.css}"/>
    <link rel="stylesheet" th:href="@{/webjars/font-awesome/6.5.2/css/all.min.css}"/>
</head>
<body class="gradient-custom" hx-boost="true">
<header th:insert="~{fragments/header :: userHeader}"></header> <!-- User-specific header -->

<div class="container">
    <div th:insert="~{fragments/messages :: messages}"></div>
    <div layout:fragment="content"></div> <!-- Content will be injected here -->
</div>

<footer th:insert="~{fragments/footer :: userFooter}"></footer>

<script th:src="@{/webjars/bootstrap/5.3.3/js/bootstrap.bundle.min.js}"></script>
<script type="text/javascript" th:src="@{/webjars/htmx.org/2.0.3/dist/htmx.min.js}"></script>
<script>
    document.body.addEventListener('htmx:configRequest', function(event) {
        const target = event.target;
        const csrfHeader = target.getAttribute('data-csrf-header');
        const csrfToken = target.getAttribute('data-csrf-token');

        // If CSRF attributes are found, add them to the request headers
        if (csrfHeader && csrfToken) {
            event.detail.headers[csrfHeader] = csrfToken;
        }
    });
</script>
</body>
</html>

This code is needed to address CORS requirement of spring security.

My controller code method is:

@PutMapping(value = "/person/{personId}/email/update")
    public String updateEmail(@ModelAttribute Email email,
                              @PathVariable("personId") Long personId,
                              Model model) {

        // Verify that 'email' here contains the ID correctly and not the email string.
        LOGGER.info("Updating email for personId: {}, emailId: {}", personId, email.getEmailId());
        Person person = personService.getPersonById(personId);

        emailService.saveOrUpdateEmail(person, email);
        model.addAttribute("emails", person.getEmails());
        model.addAttribute("personId", personId);

        return "person/emails-info :: emails-info";
//        return "person/email-item :: email-item";
    }

What I am seeing on submission of the form is that the @ModelAttribute Email email in the controller is instantiated but all the properties in the Object are empty. When I look at the browser traffic in Network tab, I see the payload:

_csrf: aemCvGmY8p2U6i0t2sysF7dEPI5eZPLq-NAbPY6WC-bNKYycD4zghFCgyq25iEsbuOGYc9UmEbduVpTHyuAuW7f0b97-HLyu
emailId: 2
type: WORK
emailAddress: john.jacobs.work@mail.com

However, in logs the emailId is empty, type is empty, and emailaddress is empty. I am beginning to think that hx-put does not work with Spring boot/thymeleaf. Any ideas?


Solution

  • I found out three things about using htmx (with hx-put, hx-patch, and hx-delete), thymeleaf and Spring:

    1. I found out that by default, HTMX uses application/x-www-form-urlencoded for POST requests, but for other HTTP methods like PUT, it may default to text/plain. Not sure why but just observed it does.
    2. To address the issue I added hx-encoding="application/x-www-form-urlencoded" to my my button. This ensured that the content type did not switch on me for my PUT request.
      https://htmx.org/attributes/hx-encoding/
    3. I also discovered that I had to add FormContentFilter to my WebConfig.java:
    @Bean
        public FormContentFilter formContentFilter() { //added with hx-pu
            return new FormContentFilter();
        }
    

    Drilling into the documentation on the FormContentFilter I found out:

    Filter that parses form data for HTTP PUT, PATCH, and DELETE requests and exposes it as Servlet request parameters. By default the Servlet spec only requires this for HTTP POST.

    https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/FormContentFilter.html

    My final state of the thymeleaf fragment looks like this:

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org" lang="en">
    <div th:fragment="email-form(email, personId)">
        <h4 th:text="${edit} ? 'Edit Email' : 'Add Email'">add or edit Email</h4>
        <form th:action="@{/person/{personId}/email/save(personId=${personId})}"
              th:object="${email}"
              method="post">
            <input type="hidden" th:field="*{emailId}"/>
            <div class="mb-3">
                <label for="emailType" class="form-label">Type</label>
                <select id="emailType" th:field="*{type}" class="form-select">
                    <option value="PERSONAL">Personal</option>
                    <option value="WORK">Work</option>
                    <option value="HOME">Home</option>
                    <option value="SCHOOL">School</option>
                    <option value="OTHER">Other</option>
                </select>
            </div>
            <div class="mb-3">
                <label for="email" class="form-label">Email Address</label>
                <input id="email" type="email" th:field="*{emailAddress}" class="form-control"
                       placeholder="email@example.com" required>
            </div>
            <div class="d-flex justify-content-end">
                <button type="button" class="btn btn-success me-2"
                        th:if="${edit}"
                        th:hx-put="@{/person/{personId}/email/update(personId=${personId})}"
                        hx-target="#email-parent"
                        hx-swap="innerHTML transition:true"
                        th:attr="data-csrf-header=${_csrf.headerName}, data-csrf-token=${_csrf.token}"
                        hx-trigger="click"
                        hx-encoding="application/x-www-form-urlencoded">
                    Update Email
                </button>
    
                <button type="button" class="btn btn-success me-2"
                        th:if="${!edit}"
                        th:hx-post="@{/person/{personId}/email/save(personId=${personId})}"
                        hx-target="#email-parent"
                        hx-swap="innerHTML transition:true"
                        hx-trigger="click">
                    Save
                </button>
                <button type="button" class="btn btn-secondary"
                        th:hx-get="@{/person/view/{personId}(personId=${personId})}"
                        hx-target="body" hx-swap="outerHTML transition:true">Cancel
                </button>
            </div>
        </form>
    </div>
    </html>
    

    And my controller method:

    @PutMapping(value = "/person/{personId}/email/update")
        public String updateEmail(@ModelAttribute Email email,
                                  @PathVariable("personId") Long personId,
                                  Model model) {
    
            // Verify that 'email' here contains the ID correctly and not the email string.
            LOGGER.info("Updating email for personId: {}, emailId: {}", personId, email.getEmailId());
            LOGGER.info("email address = {}", email.getEmailAddress());
    
            Person person = personService.getPersonById(personId);
    
            emailService.saveOrUpdateEmail(person, email);
            model.addAttribute("emails", person.getEmails());
            model.addAttribute("personId", personId);
    
            return "person/emails-info :: emails-info";
    //        return "person/email-item :: email-item";
        }
    

    So, ensuring the encoding stays as application/x-www-form-urlencoded when using hx-put and having the FormContentFilter bean added to the WebConfig.java allowed my Email ModelAttribute to be converted and passed to the controller method.

    Has taken many days to figure this out. Hope it helps someone else!