Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spring Boot Form Validation with JTE #284

Closed
don41382 opened this issue Oct 7, 2023 · 4 comments
Closed

Spring Boot Form Validation with JTE #284

don41382 opened this issue Oct 7, 2023 · 4 comments

Comments

@don41382
Copy link

don41382 commented Oct 7, 2023

Hi Andreas, first of all thanks for the awesome template engine.

I would like to use Spring Boot Validation, like with Thymleaf, e.g. https://stackabuse.com/spring-boot-thymeleaf-form-data-validation-with-bean-validator/

Is it possible to access the BindingsResult, like in the Thymleaf example with ${#fields.hasErrors('age')}?

I am not sure, if this is possible.

@casid
Copy link
Owner

casid commented Oct 7, 2023

No, this isn't implemented in the spring-boot-starter as far as I'm aware of.

I think you would need to find a way to pass the BindingResult to the template and could do it by hand as in thymeleaf template. I'm no Spring user, so I'm not sure what the best practice for this is.

That being said, jte has an HtmlInterceptor feature that could be used to make this even more usable. We wrote such an interceptor for the Stripes framework we're using at work. Unfortunately that one is still closed source so I can't share it here.

But the unit tests here show what's possible:
https://github.com/casid/jte/blob/main/jte/src/test/java/gg/jte/TemplateEngine_HtmlInterceptorTest.java

You can basically automatically bind controller values to HTML elements, show field errors, etc. At least we could do all of that in Stipes :-)

@don41382
Copy link
Author

don41382 commented Oct 7, 2023

Thanks for pointing out the HtmlInceptor. Meanwhile, I will use my own validation setup / path the BindingResult manually.

You are using stripes at work and with jte?

@casid
Copy link
Owner

casid commented Oct 8, 2023

Yes, but it is rather for historic reasons and we maintain our own fork of it by now. But it is a very lightweight, easy to understand and highly customizable framework.

Good luck with the validation!

@casid casid closed this as completed Oct 8, 2023
@maxwellt
Copy link

I saw this issue and wanted to give some insight into how I've solved this.

I didn't want to add the BindingResult to the Model each time because it is repetitive and the view you're returning of the form would likely used templates for the form inputs which meant you'd have to pass the BindingResult along with each of those templates.

Spring has the concept of an HandlerInterceptor. Which exposes a preHandle and postHandle hook. The documentation for the postHandle reads:

Interception point after successful execution of a handler. Called after HandlerAdapter actually invoked the handler, but before the DispatcherServlet renders the view. Can expose additional model objects to the view via the given ModelAndView.

This seems to me exactly what I want, to automatically add the BindingResult BEFORE the view is rendered. So I setup a JteContextInterceptor which implements the HandlerInterceptor.

In the JteContextInterceptor I implement the postHandle method and there I "initialize" a JteContext class which holds a number of ThreadLocals, one of them being the one for the BindingResult. So my JteContext class looks like this (now only contains the "form" inner static class, but normally contains others as well):

public class JteContext {
    public static class ctx {
        public static class form {

            private static final ThreadLocal<BindingResult> bindingResultContext = new ThreadLocal<>();

            public static void init(BindingResult bindingResult) {
                bindingResultContext.set(bindingResult);
            }

            public static boolean hasFieldError(String fieldName) {
                return bindingResultContext.get() != null && bindingResultContext.get().hasFieldErrors(fieldName);
            }

            public static boolean hasGlobalError() {
                return bindingResultContext.get() != null && bindingResultContext.get().hasGlobalErrors();
            }

            public static String getFieldError(String fieldName) {
                if (bindingResultContext.get() == null) {
                    return "";
                }

                FieldError fieldError = bindingResultContext.get().getFieldError(fieldName);

                return Optional.ofNullable(fieldError)
                        .map(fe -> i18n.localizerContext.get()
                                .getMessageSource()
                                .getMessage(fe, LocaleContextHolder.getLocale())
                        )
                        .orElse("");
            }

            public static String getGlobalError() {
                BindingResult bindingResult = bindingResultContext.get();
                if (bindingResult == null) {
                    return "";
                }

                if (!bindingResult.hasGlobalErrors()) {
                    return "";
                }

                return bindingResult.getGlobalErrors().iterator().next().getDefaultMessage();
            }
        }
    }
}

The ThreadLocal is necessary to couple the right BindingResult to the in-flight request and can then be accessed when rendering the template. The code to init the JteContext in the JteContextInterceptor:

@RequiredArgsConstructor
public class JteContextInterceptor implements HandlerInterceptor {

    private final MessageSource messageSource;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        JteContext.ctx.i18n.init(new JteLocalizer(this.messageSource, LocaleContextHolder.getLocale()));
        JteContext.ctx.request.init(request);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
        if (modelAndView == null) {
            return;
        }

        // in order to be able to access the form's BindingResult we need to intercept it here and initialize it in
        // the JteContext as a ThreadLocal.
        // the alternative would be to declare it as a param on the relevant .jte file, but seeing as that would be a form, it would then
        // have to pass the BindingResult as a param to each input on the form, this leads to a cascade of passing the BindingResult along
        // the approach used here to set it in a ThreadLocal which can then be accessed directly at the correct level seems easier and cleaner
        Map<String, Object> model = modelAndView.getModel();
        if (model.containsKey("org.springframework.validation.BindingResult.form")) {
            BindingResult bindingResult = (BindingResult) model.get("org.springframework.validation.BindingResult.form");
            JteContext.ctx.form.init(bindingResult);
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        JteContext.dispose();
    }
}

I also init some other things as you can see in the preHandle method, but that's not important.

Having done this, I now simply do a static import of my JteContext in my templates and can then easily interact with the BindingResult without having to pass it around from controller to view and to its children templates, an example would look like this:

@import static be.bluemagma.web.infrastructure.jte.JteContext.*

<input  class="input ${ctx.form.hasFieldError("firstName") ? "is-danger" : ""}">

@if(ctx.form.hasFieldError("firstName"))
    <p class="help is-danger">
        ${ctx.form.getFieldError("firstName")}
    </p>
@endif

I've left out some bits of code here and there as I've taken this from a larger project so apologies if there are any inconsistencies.

Happy to hear your thouhts too @casid about the implementation approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants