Skip to content

Commit

Permalink
Allow HtmxResponse to be used as return type in error handlers
Browse files Browse the repository at this point in the history
Fixes #94
  • Loading branch information
wimdeblauwe committed Dec 10, 2023
1 parent 93ed4cd commit 1f29cd7
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 127 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,27 @@ public HtmxResponse getMainAndPartial(Model model){

Using `ModelAndView` means that each fragment can have its own model (which is merged with the controller model before rendering).

### Error handlers

It is possible to use `HtmxResponse` as a return type from error handlers.
This makes it quite easy to declare a global error handler that will show a message somewhere whenever there is an error
by declaring a global error handler like this:

```java

@ExceptionHandler(Exception.class)
public HtmxResponse handleError(Exception ex) {
return HtmxResponse.builder()
.reswap(HtmxReswap.none())
.view(new ModelAndView("fragments :: error-message", Map.of("message", ex.getMessage())))
.build();
}
```

This will override the normal swapping behaviour of any htmx request that has an exception to avoid swapping to occur.
If the `error-message` fragment is declared as an Out Of Band Swap and your page layout has an empty div to "receive"
that piece of HTML, then only that will be placed on the screen.

### Spring Security

The library has an `HxRefreshHeaderAuthenticationEntryPoint` that you can use to have htmx force a full page browser
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.util.List;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.context.annotation.Bean;
import org.springframework.util.Assert;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;

@AutoConfiguration
@ConditionalOnWebApplication
Expand All @@ -25,7 +26,9 @@ public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConf
private final ObjectFactory<LocaleResolver> locales;
private final ObjectMapper objectMapper;

HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory<ViewResolver> resolver, ObjectFactory<LocaleResolver> locales, ObjectMapper objectMapper) {
HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory<ViewResolver> resolver,
ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
Assert.notNull(resolver, "ViewResolver must not be null!");
Assert.notNull(locales, "LocaleResolver must not be null!");

Expand All @@ -42,11 +45,26 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HtmxHandlerInterceptor());
registry.addInterceptor(new HtmxViewHandlerInterceptor(resolver.getObject(), locales, objectMapper));
registry.addInterceptor(new HtmxViewHandlerInterceptor(htmxResponseHelper()));
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new HtmxHandlerMethodArgumentResolver());
}

@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
handlers.add(htmxResponseHandlerMethodReturnValueHandler(htmxResponseHelper()));
}

@Bean
public HtmxResponseHelper htmxResponseHelper() {
return new HtmxResponseHelper(resolver.getObject(), locales, objectMapper);
}

@Bean
public HtmxResponseHandlerMethodReturnValueHandler htmxResponseHandlerMethodReturnValueHandler(HtmxResponseHelper helper) {
return new HtmxResponseHandlerMethodReturnValueHandler(helper);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.MethodParameter;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.method.support.ModelAndViewContainer;

public class HtmxResponseHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
private final HtmxResponseHelper responseHelper;

public HtmxResponseHandlerMethodReturnValueHandler(HtmxResponseHelper responseHelper) {
this.responseHelper = responseHelper;
}

@Override
public boolean supportsReturnType(MethodParameter returnType) {
return returnType.getParameterType().equals(HtmxResponse.class);
}

@Override
public void handleReturnValue(Object returnValue,
MethodParameter returnType,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest) throws Exception {

HtmxResponse htmxResponse = (HtmxResponse) returnValue;
mavContainer.setView(responseHelper.toView(htmxResponse));

responseHelper.addHxHeaders(htmxResponse, webRequest.getNativeResponse(HttpServletResponse.class));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.util.Assert;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.stream.Collectors;

public class HtmxResponseHelper {
private final ViewResolver views;
private final ObjectFactory<LocaleResolver> locales;
private final ObjectMapper objectMapper;

public HtmxResponseHelper(ViewResolver views,
ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
this.views = views;
this.locales = locales;
this.objectMapper = objectMapper;
}

public View toView(HtmxResponse htmxResponse) {

Assert.notNull(htmxResponse, "HtmxResponse must not be null!");

return (model, request, response) -> {
Locale locale = locales.getObject().resolveLocale(request);
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
for (ModelAndView modelAndView : htmxResponse.getViews()) {
View view = modelAndView.getView();
if (view == null) {
view = views.resolveViewName(modelAndView.getViewName(), locale);
}
for (String key : model.keySet()) {
if (!modelAndView.getModel().containsKey(key)) {
modelAndView.getModel().put(key, model.get(key));
}
}
Assert.notNull(view, "Template '" + modelAndView + "' could not be resolved");
view.render(modelAndView.getModel(), request, wrapper);
}
wrapper.copyBodyToResponse();
};
}

public void addHxHeaders(HtmxResponse htmxResponse, HttpServletResponse response) {
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER, htmxResponse.getTriggersInternal());
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, htmxResponse.getTriggersAfterSettleInternal());
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, htmxResponse.getTriggersAfterSwapInternal());

if (htmxResponse.getLocation() != null) {
if (htmxResponse.getLocation().hasContextData()) {
setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION.getValue(), htmxResponse.getLocation());
} else {
response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), htmxResponse.getLocation().getPath());
}
}
if (htmxResponse.getReplaceUrl() != null) {
response.setHeader(HtmxResponseHeader.HX_REPLACE_URL.getValue(), htmxResponse.getReplaceUrl());
}
if (htmxResponse.getPushUrl() != null) {
response.setHeader(HtmxResponseHeader.HX_PUSH_URL.getValue(), htmxResponse.getPushUrl());
}
if (htmxResponse.getRedirect() != null) {
response.setHeader(HtmxResponseHeader.HX_REDIRECT.getValue(), htmxResponse.getRedirect());
}
if (htmxResponse.isRefresh()) {
response.setHeader(HtmxResponseHeader.HX_REFRESH.getValue(), "true");
}
if (htmxResponse.getRetarget() != null) {
response.setHeader(HtmxResponseHeader.HX_RETARGET.getValue(), htmxResponse.getRetarget());
}
if (htmxResponse.getReselect() != null) {
response.setHeader(HtmxResponseHeader.HX_RESELECT.getValue(), htmxResponse.getReselect());
}
if (htmxResponse.getReswap() != null) {
response.setHeader(HtmxResponseHeader.HX_RESWAP.getValue(), htmxResponse.getReswap().toHeaderValue());
}
}

private void addHxTriggerHeaders(HttpServletResponse response, HtmxResponseHeader headerName, Collection<HtmxTrigger> triggers) {
if (triggers.isEmpty()) {
return;
}

// separate event names by commas if no additional details are available
if (triggers.stream().allMatch(t -> t.getEventDetail() == null)) {
String value = triggers.stream()
.map(HtmxTrigger::getEventName)
.collect(Collectors.joining(","));

response.setHeader(headerName.getValue(), value);
return;
}

// multiple events with or without details
var triggerMap = new HashMap<String, Object>();
for (HtmxTrigger trigger : triggers) {
triggerMap.put(trigger.getEventName(), trigger.getEventDetail());
}
setHeaderJsonValue(response, headerName.getValue(), triggerMap);
}

private void setHeaderJsonValue(HttpServletResponse response, String name, Object value) {
try {
response.setHeader(name, objectMapper.writeValueAsString(value));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Unable to set header " + name + " to " + value, e);
}
}
}
Loading

0 comments on commit 1f29cd7

Please sign in to comment.