diff --git a/README.md b/README.md index 01b0119..0e823ba 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 1cb4cd5..f11347a 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,7 +1,6 @@ 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; @@ -9,13 +8,14 @@ import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; 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 +25,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 +44,15 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HtmxHandlerInterceptor()); - registry.addInterceptor(new HtmxViewHandlerInterceptor(resolver.getObject(), locales, objectMapper)); } @Override public void addArgumentResolvers(List resolvers) { resolvers.add(new HtmxHandlerMethodArgumentResolver()); } + + @Override + public void addReturnValueHandlers(List handlers) { + handlers.add(new HtmxResponseHandlerMethodReturnValueHandler(resolver.getObject(), locales, objectMapper)); + } } 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/HtmxResponseHandlerMethodReturnValueHandler.java similarity index 59% rename from htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptor.java rename to htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java index d10b586..5755fc8 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/HtmxResponseHandlerMethodReturnValueHandler.java @@ -1,103 +1,77 @@ -/* - * Copyright 2021-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ 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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectFactory; +import org.springframework.core.MethodParameter; import org.springframework.util.Assert; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.method.support.ModelAndViewContainer; 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 - * returned from controller methods into a - * - * @author Oliver Drotbohm - * @author Sascha Woo - */ -class HtmxViewHandlerInterceptor implements HandlerInterceptor { - - private static final Logger LOGGER = LoggerFactory.getLogger(HtmxViewHandlerInterceptor.class); +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.stream.Collectors; +public class HtmxResponseHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler { private final ViewResolver views; private final ObjectFactory locales; private final ObjectMapper objectMapper; - public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory locales, - ObjectMapper objectMapper) { + public HtmxResponseHandlerMethodReturnValueHandler(ViewResolver views, + ObjectFactory locales, + ObjectMapper objectMapper) { this.views = views; this.locales = locales; this.objectMapper = objectMapper; } - /* - * (non-Javadoc) - * - * @see - * org.springframework.web.servlet.HandlerInterceptor#postHandle(javax.servlet. - * http.HttpServletRequest, javax.servlet.http.HttpServletResponse, - * java.lang.Object, org.springframework.web.servlet.ModelAndView) - */ @Override - public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, - ModelAndView modelAndView) throws Exception { - - if (modelAndView == null || !HandlerMethod.class.isInstance(handler)) { - return; - } - - HandlerMethod method = (HandlerMethod) handler; + public boolean supportsReturnType(MethodParameter returnType) { + return returnType.getParameterType().equals(HtmxResponse.class); + } - String partialsAttributeName = null; - if (method.getReturnType().getParameterType().equals(HtmxResponse.class)) { - partialsAttributeName = "htmxResponse"; - } - if (partialsAttributeName == null) { - return; - } + @Override + public void handleReturnValue(Object returnValue, + MethodParameter returnType, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest) throws Exception { - Object attribute = modelAndView.getModel().get(partialsAttributeName); + HtmxResponse htmxResponse = (HtmxResponse) returnValue; + mavContainer.setView(toView(htmxResponse)); - if (!HtmxResponse.class.isInstance(attribute)) { - return; - } + addHxHeaders(htmxResponse, webRequest.getNativeResponse(HttpServletResponse.class)); + } - HtmxResponse htmxResponse = (HtmxResponse) attribute; + private View toView(HtmxResponse htmxResponse) { - modelAndView.setView(toView(htmxResponse)); + Assert.notNull(htmxResponse, "HtmxResponse must not be null!"); - addHxHeaders(htmxResponse, response); + 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(); + }; } private void addHxHeaders(HtmxResponse htmxResponse, HttpServletResponse response) { @@ -158,30 +132,6 @@ private void addHxTriggerHeaders(HttpServletResponse response, HtmxResponseHeade 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)); @@ -189,5 +139,4 @@ private void setHeaderJsonValue(HttpServletResponse response, String name, Objec 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/HtmxResponseHandlerMethodReturnValueHandlerController.java similarity index 87% rename from htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorController.java rename to htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerController.java index 8f0d1fd..c4292f7 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/HtmxResponseHandlerMethodReturnValueHandlerController.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; @@ -10,7 +12,7 @@ @Controller @RequestMapping("/hvhi") -public class HtmxViewHandlerInterceptorController { +public class HtmxResponseHandlerMethodReturnValueHandlerController { @GetMapping("/hx-location-with-context-data") public HtmxResponse hxLocationWithContextData() { @@ -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/HtmxResponseHandlerMethodReturnValueHandlerTest.java similarity index 87% rename from htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewHandlerInterceptorTest.java rename to htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerTest.java index 77de3e9..099c554 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/HtmxResponseHandlerMethodReturnValueHandlerTest.java @@ -7,13 +7,14 @@ 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; -@WebMvcTest(HtmxViewHandlerInterceptorController.class) +@WebMvcTest(HtmxResponseHandlerMethodReturnValueHandlerController.class) @WithMockUser -public class HtmxViewHandlerInterceptorTest { +public class HtmxResponseHandlerMethodReturnValueHandlerTest { @Autowired private MockMvc mockMvc; @@ -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 dcd9d4a..aec8a45 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 + + + +