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

Functional HTMX Endpoints #104

Open
tschuehly opened this issue Apr 15, 2024 · 15 comments · May be fixed by #130
Open

Functional HTMX Endpoints #104

tschuehly opened this issue Apr 15, 2024 · 15 comments · May be fixed by #130

Comments

@tschuehly
Copy link
Contributor

I propose adding a HtmxEndpoint Class that can be used to create HTTP Endpoints and can be directly called from Template Engines.

The Controller would look like this:

@Controller 
class ExampleController {
  public HtmxEndpoint<UserForm> createUserEndpoint = new HtmxEndpoint<>(
      "/createUser",
      HttpMethod.POST,
      this::createUser
  );

  private ModelAndView createUser(UserForm userForm) {
    return new ModelAndView("createUser", Map.of("createUserEndpoint", createUserEndpoint));
  }
}

The class would like this:

public class HtmxEndpoint<T> implements RouterFunction<ServerResponse> {

  private final String path;
  private final HttpMethod method;
  private final Supplier<ModelAndView> modelAndViewSupplier;
  private final Function<T, ModelAndView> function;

  ParameterizedTypeReference<T> requestType = new ParameterizedTypeReference<>() {
  };

  public HtmxEndpoint(String path, HttpMethod method, Function<T, ModelAndView> function) {
    this.path = path;
    this.method = method;
    this.function = function;
    this.modelAndViewSupplier = null;
  }

  public HtmxEndpoint(String path, HttpMethod method, Supplier<ModelAndView> modelAndViewSupplier) {
    this.path = path;
    this.method = method;
    this.modelAndViewSupplier = modelAndViewSupplier;
    this.function = null;
    this.requestType = null;
  }

  @NotNull
  @Override
  public Optional<HandlerFunction<ServerResponse>> route(@NotNull ServerRequest request) {
    RequestPredicate predicate = RequestPredicates.method(method).and(RequestPredicates.path(path));
    if (predicate.test(request)) {
      ModelAndView modelAndView = getBody(request);
      return Optional.of(
          req -> RenderingResponse.create(modelAndView.view())
              .modelAttribute(modelAndView.model())
              .build()
      );
    }
    return Optional.empty();
  }

  private ModelAndView getBody(ServerRequest req) {
    if (function == null) {
      return modelAndViewSupplier.get();
    }

    try {
      return function.apply(
          req.body(requestType)
      );
    } catch (ServletException | IOException e) {
      throw new RuntimeException(e);
    }
  }

  public String call() {
    return "hx-" + method.name().toLowerCase() + " =\"" + path + "\"";
  }

}

In the template you would call it like this in Thymeleaf:

<div th:hx=${createUserEndpoint}>
<div>

And this template would render like this:

<div hx-post="/createUser">
<div>

Of course, the HtmxEndpoint could be expanded to all the possible Htmx attributes.

The Endpoints would be scanned at Startup using Reflection and added to the Spring RouterFunction

 @Bean
  ApplicationRunner applicationRunner() {
    return args -> {
      applicationContext.getBeansWithAnnotation(Controller.class)
          .values().forEach(controller ->
              {
                List<Field> fieldList = Arrays.stream(controller.getClass().getDeclaredFields())
                    .filter(method -> method.getType() == HtmxEndpoint.class)
                    .toList();

                fieldList.forEach(field -> {
                  RouterFunction<?> function = (RouterFunction<?>) ReflectionUtils.getField(field, controller);
                  if(routerFunctionMapping.getRouterFunction() == null){
                    routerFunctionMapping.setRouterFunction(function);
                  }
                  RouterFunction<?> routerFunction = routerFunctionMapping.getRouterFunction().andOther(function);
                  routerFunctionMapping.setRouterFunction(routerFunction);
                });
              }
          );
    };
  }
@wimdeblauwe
Copy link
Owner

@checketts @xhaggi What are your thoughts on this?

@checketts
Copy link
Collaborator

checketts commented Apr 15, 2024

1 - Reflection (Against)

I would avoid the reflection scanning (or at least ensure you have a graalvm compatible solution). The existing SpringBoot functional routing doesn't use an annotation scan, does it?

2 - Standard method/path representation (For)

I do like how the path and method are representated in the endpoint, so it can be used consistently

3 - Adding endpoint to model (Yuck)

I don't like how the endpoint has to be added to the model to facilitate referencing it. Since it adds boiler plate code to set it and non-typesafe ways of calling it.

What if instead the endpoint was a property on a bean that then is reference via the bean @ Thymeleaf annotation? Usage would then be: @myController.createUserEndpoint

So I think the idea has merit, but I would like to see the utility played with further.

@tschuehly
Copy link
Contributor Author

@checketts

There are 2 problem with the bean method

  1. Currently it is just a field and we can reference to the method that executes statically.
  2. For non Thymeleaf user this direct bean access method doesn't exist. (I'm not a big fan of bean access in templates.)

In an optimal scenario I would like to reference them statically like I do it with URLs now, instead of passing it into the model.
#106 (comment)

Maybe there is a way to do it statically: https://stackoverflow.com/questions/12537851/accessing-spring-beans-in-static-method

The non typesafe access is more of a Spring MVC problem: https://github.com/tschuehly/htmx-spring-workshop/blob/endpoint-test/src/main/java/de/tschuehly/easy/spring/auth/htmx/HtmxComponent.java

@checketts
Copy link
Collaborator

I don't see either points you brought up as problems though. Perhaps I'm misunderstanding something

Point 1- You are saying the a bean wouldn't be needed and can could be referenced as a static reference. I'm not familiar with that syntax. Do you have an example of it? Is it simpler than the bean approach?
Point 2- You are saying the the bean solution wouldn't need to be leveraged in a non-Thymeleaf engine. Which I also agree with, but so far the discussion has been around Thymeleaf, so let's keep focusing on that for this duscussion.

However, I'm also looking more and more at JTE. So I'm very interested in that type safe templating and considering how the HTMX helpers could be helpful in those cases. In the next few weeks I plan on porting a Thymeleaf HTMX app over to JTE to learn more and compare.

@wimdeblauwe
Copy link
Owner

In the next few weeks I plan on porting a Thymeleaf HTMX app over to JTE to learn more and compare.

Would love to hear how that went afterwards. I was also thinking about porting my Taming Thymeleaf application to JTE to compare (The speed and the GraalVM compatibility are really nice I think).

@tschuehly
Copy link
Contributor Author

tschuehly commented Apr 16, 2024

Point 1- You are saying the a bean wouldn't be needed and can could be referenced as a static reference. I'm not familiar with that syntax. Do you have an example of it? Is it simpler than the bean approach?
I thought about it again and think the bean approach is better.
In Thymeleaf we can just use the @beanName syntax.

Using JTE you would pass in the Bean into the Model. -> Here the Model is the ViewContext
image

Point 2- You are saying the the bean solution wouldn't need to be leveraged in a non-Thymeleaf engine. Which I also agree with, but so far the discussion has been around Thymeleaf, so let's keep focusing on that for this duscussion.

I like to look at both but as I've said above it is pretty easy

For registering I looked at how Spring does it and I think I will use a BeanDefinitionParser:
https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java

I would try to implement these two in my test project, and if you guys are fine with it then I could start working on a PR

@atomfrede
Copy link

I have seen the first post without any context on X during the weekend and was wondering what the HTMXEndpoint is about. I like the idea, looking forward how this will evolve!

@tschuehly
Copy link
Contributor Author

tschuehly commented Apr 16, 2024

For registering I looked at how Spring does it and I think I will use a BeanDefinitionParser: https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java

Sadly the Bean Definition Parser is just for XML. Looks like there is no way around reflection.
Spring does it for example for the @Autowired annotation in the BeanPostProcessor:
AutowiredAnnotationBeanPostProcessor

@tschuehly
Copy link
Contributor Author

tschuehly commented Apr 19, 2024

I got a first working version here:
https://github.com/tschuehly/htmx-spring-workshop/tree/53cf5d8a953673615ee4b0988896e1b7c0d07ab0/src/main/java/de/tschuehly/easy/spring/auth/htmx

The AbstractHtmxEndpoint would be the base implementation, that then could be implemented for ModelAndView and for Spring ViewComponent with ViewContext. I also made the Endpoint scanning configurable.
The HtmxFormConverter tries to map a form payload to the parameter body type.

What do you guys think?

@landsman
Copy link

Concept of views is great, I would love to work with them like this.
What is your opinion @wimdeblauwe?

@dsyer
Copy link
Contributor

dsyer commented Jun 15, 2024

I don't think I understand the example. Isn't it just doing this (i.e. marshalling a POST request and rendering a view)?

@Controller
public class ExampleController {
  @PostMapping("/createUser")
  public String createUser(UserForm user) {
    return "createUser";
  }
}

I know it's just an example, but it seems odd to do the whole "endpoint" thing when a @PostMapping works fine. Maybe I'm missing something?

@tschuehly
Copy link
Contributor Author

tschuehly commented Jun 15, 2024

@dsyer
One thing that we found really helps in a large HTMX project is to use constants and render the endpoints in the template:

public static final String GET_EDIT_USER_MODAL = "/save-user/modal/{uuid}";
  
@GetMapping(GET_EDIT_USER_MODAL)
public String editUserModal(Model model, @PathVariable UUID uuid) {
  var user = userService.findById(uuid);
  model.addAttribute("userForm", 
    new UserForm(user.uuid.toString(), user.username, user.password));
  return "EditUserForm";
}
<button hx-get="${URI(GET_EDIT_USER_MODAL,uuid)}"
                hx-target="#${MODAL_CONTAINER_ID}">
    <img src="/edit.svg">
</button>

But the information that it needs an hx-get needs to be encoded in the URL.
If you want to target this endpoint from another part of the application it's not a complete "API" spec.

The HX-Target attribute is another candidate for adding to the HtmxEndpoint.

Imagine you want to open this EditUserModal from another part of the application.

 public HtmxEndpoint<UserForm> editUserEndpoint = new HtmxEndpoint<>(
      path = "/editUser",
      HTTP = HttpMethod.POST,
      method = this::editUser,
      target = MODAL_CONTAINER_ID
  );

All information is embedded in the HtmxEndpoint. If we now want to show the modal in a different container instead of the modal we can change it in the endpoint and it would change everywhere.

<form ${EditUserComponent.editUserEndpoint.call()}>
  <input type="text" name="userName">
</form>

Hope this makes it clear 🙂

@wimdeblauwe
Copy link
Owner

@tschuehly and me had a discussion about this issue and this is a summary of that. To get this idea included in the library, I would like to following to happen:

  • HtmxEndpoint should not depend on Thymeleaf or JTE classes.
  • The htmx-spring-boot-thymeleaf module should be able to work with the HtmxEndpoint and have the necessary code to make it work with Thymeleaf.
  • A new htmx-spring-boot-jte module should be added to make it work with JTE.

For the Thymeleaf part, we can expand the hx: tag processor to support hx:endpoint that could refer to a bean method that returns an HtmxEndpoint like this:

<form hx:endpoint="${@userController.createUserEndpoint()}">
  <input type="text" name="userName">
</form>

With the controller code like this:

@Controller
class UserController {
  HtmxEndpoint createUserEndpoint() {
    return new HtmxEndpoint("/users/create", HttpMethod.POST, this::createUser);
  }

  @HxRequest
  @PostMapping("/users/create")
  public String createUser(@ModelAttribute CreateUserFormData formData, Model model) {
    // create the user here
    return "..." // Thymeleaf view to render after the user was created
  }
}

I'll let @tschuehly comment on how that would look with JTE. :-)

@tschuehly
Copy link
Contributor Author

@HxRequest
@PostMapping("/users/create")
public String createUser(@ModelAttribute CreateUserFormData formData, Model model) {
// create the user here
return "..." // Thymeleaf view to render after the user was created
}
}

The @HxRequest and PostMapping would not be needed

@tschuehly tschuehly linked a pull request Aug 30, 2024 that will close this issue
@tschuehly
Copy link
Contributor Author

tschuehly commented Aug 30, 2024

I've started a PR, for now JTE works with ModelAndView.
Currently in a JTE template one needs to <form ${endpointController.createUserEndpoint.call()}>, I think in the Thymeleaf Dialect, we could just call this method aswell and replace the hx:endpoint attribute.
This way the behaviour would be consistent.

I'll probably have time again in a couple weeks to continue. If anyone wants to join in!

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

Successfully merging a pull request may close this issue.

6 participants