diff --git a/README.md b/README.md index 01b01199..0e823ba3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java index 1cb4cd56..613c4e39 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java @@ -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 @@ -25,7 +26,9 @@ public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConf private final ObjectFactory locales; private final ObjectMapper objectMapper; - HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory resolver, ObjectFactory locales, ObjectMapper objectMapper) { + HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory resolver, + ObjectFactory locales, + ObjectMapper objectMapper) { Assert.notNull(resolver, "ViewResolver must not be null!"); Assert.notNull(locales, "LocaleResolver must not be null!"); @@ -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 resolvers) { resolvers.add(new HtmxHandlerMethodArgumentResolver()); } + + @Override + public void addReturnValueHandlers(List 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); + } } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java new file mode 100644 index 00000000..e0201ba2 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java @@ -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)); + } +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHelper.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHelper.java new file mode 100644 index 00000000..5d5852ad --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHelper.java @@ -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 locales; + private final ObjectMapper objectMapper; + + public HtmxResponseHelper(ViewResolver views, + ObjectFactory 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 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(); + 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); + } + } +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptor.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptor.java index d10b5866..15ddb4b0 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptor.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptor.java @@ -15,29 +15,11 @@ */ package io.github.wimdeblauwe.htmx.spring.boot.mvc; -import java.util.Collection; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.stream.Collectors; - import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectFactory; -import org.springframework.util.Assert; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -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 com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; /** * A {@link HandlerInterceptor} that turns {@link HtmxResponse} instances @@ -48,17 +30,10 @@ */ class HtmxViewHandlerInterceptor implements HandlerInterceptor { - private static final Logger LOGGER = LoggerFactory.getLogger(HtmxViewHandlerInterceptor.class); + private final HtmxResponseHelper responseHelper; - private final ViewResolver views; - private final ObjectFactory locales; - private final ObjectMapper objectMapper; - - public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory locales, - ObjectMapper objectMapper) { - this.views = views; - this.locales = locales; - this.objectMapper = objectMapper; + public HtmxViewHandlerInterceptor(HtmxResponseHelper responseHelper) { + this.responseHelper = responseHelper; } /* @@ -71,7 +46,7 @@ public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory 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(); - for (HtmxTrigger trigger : triggers) { - triggerMap.put(trigger.getEventName(), trigger.getEventDetail()); - } - setHeaderJsonValue(response, headerName.getValue(), triggerMap); - } - - private View toView(HtmxResponse partials) { - - Assert.notNull(partials, "HtmxPartials must not be null!"); - - return (model, request, response) -> { - Locale locale = locales.getObject().resolveLocale(request); - ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); - for (ModelAndView template : partials.getTemplates()) { - View view = template.getView(); - if (view == null) { - view = views.resolveViewName(template.getViewName(), locale); - } - for (String key: model.keySet()) { - if(!template.getModel().containsKey(key)) { - template.getModel().put(key, model.get(key)); - } - } - Assert.notNull(view, "Template '" + template + "' could not be resolved"); - view.render(template.getModel(), request, wrapper); - } - wrapper.copyBodyToResponse(); - }; - } - - 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); - } - } - } diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorController.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorController.java index 8f0d1fd4..61faf80a 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorController.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorController.java @@ -1,8 +1,10 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; import java.time.Duration; import java.util.Map; @@ -130,4 +132,16 @@ public HtmxResponse preventHistoryUpdate() { return HtmxResponse.builder().preventHistoryUpdate().build(); } + @GetMapping("/exception") + public void throwException() { + throw new RuntimeException("Fake exception"); + } + + @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(); + } } diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorTest.java index 77de3e93..2e5d9755 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorTest.java @@ -7,6 +7,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -131,4 +132,15 @@ public void testPreventHistoryUpdate() throws Exception { .andExpect(header().doesNotExist("HX-Replace-Url")); } + @Test + public void testException() throws Exception { + String html = mockMvc.perform(get("/hvhi/exception")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "none")) + .andReturn().getResponse().getContentAsString(); + assertThat(html).contains(""" + + Fake exception + """); + } } diff --git a/htmx-spring-boot/src/test/resources/templates/fragments.html b/htmx-spring-boot/src/test/resources/templates/fragments.html index dcd9d4ae..aec8a453 100644 --- a/htmx-spring-boot/src/test/resources/templates/fragments.html +++ b/htmx-spring-boot/src/test/resources/templates/fragments.html @@ -18,4 +18,8 @@ Item name + + + +