From ae17aa024771efaed7b10c5e6fd2db5bfb500c6e Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Sat, 23 Nov 2024 10:12:02 +0100 Subject: [PATCH] [fix] Annotations on exception handler methods do not work Fixes gh-150 --- ...HtmxExceptionHandlerExceptionResolver.java | 38 +++ .../boot/mvc/HtmxHandlerInterceptor.java | 206 +-------------- .../HtmxHandlerMethodAnnotationHandler.java | 235 ++++++++++++++++++ .../boot/mvc/HtmxMvcAutoConfiguration.java | 12 +- .../boot/mvc/HtmxHandlerMethodTest.java | 103 ++++++++ 5 files changed, 389 insertions(+), 205 deletions(-) create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxExceptionHandlerExceptionResolver.java create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodAnnotationHandler.java diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxExceptionHandlerExceptionResolver.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxExceptionHandlerExceptionResolver.java new file mode 100644 index 0000000..bd7cbbe --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxExceptionHandlerExceptionResolver.java @@ -0,0 +1,38 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; +import org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod; + +import java.lang.reflect.Method; + +/** + * A custom {@link ExceptionHandlerExceptionResolver} that handles htmx annotations + * present on exception handler methods. + * + * @since 3.6.2 + */ +public class HtmxExceptionHandlerExceptionResolver extends ExceptionHandlerExceptionResolver { + + private final HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler; + + public HtmxExceptionHandlerExceptionResolver(HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler) { + this.handlerMethodAnnotationHandler = handlerMethodAnnotationHandler; + } + + @Override + protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod, Exception exception) { + + ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception); + if (exceptionHandlerMethod != null) { + Method method = exceptionHandlerMethod.getMethod(); + handlerMethodAnnotationHandler.handleMethod(method, request, response); + } + + return super.doResolveHandlerMethodException(request, response, handlerMethod, exception); + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java index 3ecfde8..7c09a72 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java @@ -4,29 +4,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpHeaders; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.servlet.ModelAndView; import java.lang.reflect.Method; -import java.time.Duration; import java.util.Collection; import java.util.HashMap; import java.util.stream.Collectors; -import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*; - /** * HandlerInterceptor that adds htmx specific headers to the response. */ public class HtmxHandlerInterceptor implements HandlerInterceptor { private final ObjectMapper objectMapper; + private final HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler; - public HtmxHandlerInterceptor(ObjectMapper objectMapper) { + public HtmxHandlerInterceptor(ObjectMapper objectMapper, HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler) { this.objectMapper = objectMapper; + this.handlerMethodAnnotationHandler = handlerMethodAnnotationHandler; } @Override @@ -78,18 +75,8 @@ public boolean preHandle(HttpServletRequest request, if (handler instanceof HandlerMethod) { Method method = ((HandlerMethod) handler).getMethod(); - setHxLocation(request, response, method); - setHxPushUrl(request, response, method); - setHxRedirect(request, response, method); - setHxReplaceUrl(request, response, method); - setHxReswap(response, method); - setHxRetarget(response, method); - setHxReselect(response, method); - setHxTrigger(response, method); - setHxTriggerAfterSettle(response, method); - setHxTriggerAfterSwap(response, method); - setHxRefresh(response, method); setVary(request, response); + handlerMethodAnnotationHandler.handleMethod(method, request, response); } return true; @@ -101,110 +88,6 @@ private void setVary(HttpServletRequest request, HttpServletResponse response) { } } - private void setHxLocation(HttpServletRequest request, HttpServletResponse response, Method method) { - HxLocation methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxLocation.class); - if (methodAnnotation != null) { - var location = convertToLocation(methodAnnotation); - if (location.hasContextData()) { - location.setPath(RequestContextUtils.createUrl(request, location.getPath(), methodAnnotation.contextRelative())); - setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION, location); - } else { - setHeader(response, HtmxResponseHeader.HX_LOCATION, RequestContextUtils.createUrl(request, location.getPath(), methodAnnotation.contextRelative())); - } - } - } - - private void setHxPushUrl(HttpServletRequest request, HttpServletResponse response, Method method) { - HxPushUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxPushUrl.class); - if (methodAnnotation != null) { - if (HtmxValue.TRUE.equals(methodAnnotation.value())) { - setHeader(response, HX_PUSH_URL, getRequestUrl(request)); - } else { - setHeader(response, HX_PUSH_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); - } - } - } - - private void setHxRedirect(HttpServletRequest request, HttpServletResponse response, Method method) { - HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class); - if (methodAnnotation != null) { - setHeader(response, HX_REDIRECT, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); - } - } - - private void setHxReplaceUrl(HttpServletRequest request, HttpServletResponse response, Method method) { - HxReplaceUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReplaceUrl.class); - if (methodAnnotation != null) { - if (HtmxValue.TRUE.equals(methodAnnotation.value())) { - setHeader(response, HX_REPLACE_URL, getRequestUrl(request)); - } else { - setHeader(response, HX_REPLACE_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); - } - } - } - - private void setHxReswap(HttpServletResponse response, Method method) { - HxReswap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReswap.class); - if (methodAnnotation != null) { - setHeader(response, HX_RESWAP, convertToReswap(methodAnnotation)); - } - } - - private void setHxRetarget(HttpServletResponse response, Method method) { - HxRetarget methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRetarget.class); - if (methodAnnotation != null) { - setHeader(response, HX_RETARGET, methodAnnotation.value()); - } - } - - private void setHxReselect(HttpServletResponse response, Method method) { - HxReselect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReselect.class); - if (methodAnnotation != null) { - setHeader(response, HX_RESELECT, methodAnnotation.value()); - } - } - - private void setHxTrigger(HttpServletResponse response, Method method) { - HxTrigger methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTrigger.class); - if (methodAnnotation != null) { - setHeader(response, convertToHeader(methodAnnotation.lifecycle()), methodAnnotation.value()); - } - } - - private void setHxTriggerAfterSettle(HttpServletResponse response, Method method) { - HxTriggerAfterSettle methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSettle.class); - if (methodAnnotation != null) { - setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, methodAnnotation.value()); - } - } - - private void setHxTriggerAfterSwap(HttpServletResponse response, Method method) { - HxTriggerAfterSwap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSwap.class); - if (methodAnnotation != null) { - setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, methodAnnotation.value()); - } - } - - private void setHxRefresh(HttpServletResponse response, Method method) { - HxRefresh methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRefresh.class); - if (methodAnnotation != null) { - setHeader(response, HX_REFRESH, HtmxValue.TRUE); - } - } - - private HtmxResponseHeader convertToHeader(HxTriggerLifecycle lifecycle) { - switch (lifecycle) { - case RECEIVE: - return HX_TRIGGER; - case SETTLE: - return HX_TRIGGER_AFTER_SETTLE; - case SWAP: - return HX_TRIGGER_AFTER_SWAP; - default: - throw new IllegalArgumentException("Unknown lifecycle:" + lifecycle); - } - } - private void setHeaderJsonValue(HttpServletResponse response, HtmxResponseHeader header, Object value) { try { response.setHeader(header.getValue(), objectMapper.writeValueAsString(value)); @@ -213,87 +96,6 @@ private void setHeaderJsonValue(HttpServletResponse response, HtmxResponseHeader } } - private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String value) { - response.setHeader(header.getValue(), value); - } - - private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String[] values) { - response.setHeader(header.getValue(), String.join(",", values)); - } - - private HtmxLocation convertToLocation(HxLocation annotation) { - var location = new HtmxLocation(); - location.setPath(annotation.path()); - if (!annotation.source().isEmpty()) { - location.setSource(annotation.source()); - } - if (!annotation.event().isEmpty()) { - location.setEvent(annotation.event()); - } - if (!annotation.handler().isEmpty()) { - location.setHandler(annotation.handler()); - } - if (!annotation.target().isEmpty()) { - location.setTarget(annotation.target()); - } - if (!annotation.target().isEmpty()) { - location.setSwap(annotation.swap()); - } - if (!annotation.select().isEmpty()) { - location.setSelect(annotation.select()); - } - return location; - } - - private String convertToReswap(HxReswap annotation) { - - var reswap = new HtmxReswap(annotation.value()); - if (annotation.swap() != -1) { - reswap.swap(Duration.ofMillis(annotation.swap())); - } - if (annotation.settle() != -1) { - reswap.swap(Duration.ofMillis(annotation.settle())); - } - if (annotation.transition()) { - reswap.transition(); - } - if (annotation.focusScroll() != HxReswap.FocusScroll.UNDEFINED) { - reswap.focusScroll(annotation.focusScroll() == HxReswap.FocusScroll.TRUE); - } - if (annotation.show() != HxReswap.Position.UNDEFINED) { - reswap.show(convertToPosition(annotation.show())); - if (!annotation.showTarget().isEmpty()) { - reswap.scrollTarget(annotation.showTarget()); - } - } - if (annotation.scroll() != HxReswap.Position.UNDEFINED) { - reswap.scroll(convertToPosition(annotation.scroll())); - if (!annotation.scrollTarget().isEmpty()) { - reswap.scrollTarget(annotation.scrollTarget()); - } - } - - return reswap.toString(); - } - - private HtmxReswap.Position convertToPosition(HxReswap.Position position) { - return switch (position) { - case TOP -> HtmxReswap.Position.TOP; - case BOTTOM -> HtmxReswap.Position.BOTTOM; - default -> throw new IllegalStateException("Unexpected value: " + position); - }; - } - - private String getRequestUrl(HttpServletRequest request) { - String path = request.getRequestURI(); - String queryString = request.getQueryString(); - - if (queryString != null && !queryString.isEmpty()) { - path += "?" + queryString; - } - return path; - } - private void addHxTriggerHeaders(HttpServletResponse response, HtmxResponseHeader headerName, Collection triggers) { if (triggers.isEmpty()) { return; diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodAnnotationHandler.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodAnnotationHandler.java new file mode 100644 index 0000000..97cb271 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodAnnotationHandler.java @@ -0,0 +1,235 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.HttpHeaders; + +import java.lang.reflect.Method; +import java.time.Duration; + +import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*; + +/** + * A handler for processing htmx annotations present on exception handler methods. + * + * @since 3.6.2 + */ +class HtmxHandlerMethodAnnotationHandler { + + private final ObjectMapper objectMapper; + + public HtmxHandlerMethodAnnotationHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public void handleMethod(Method method, HttpServletRequest request, HttpServletResponse response) { + setHxLocation(request, response, method); + setHxPushUrl(request, response, method); + setHxRedirect(request, response, method); + setHxReplaceUrl(request, response, method); + setHxReswap(response, method); + setHxRetarget(response, method); + setHxReselect(response, method); + setHxTrigger(response, method); + setHxTriggerAfterSettle(response, method); + setHxTriggerAfterSwap(response, method); + setHxRefresh(response, method); + } + + private void setHxLocation(HttpServletRequest request, HttpServletResponse response, Method method) { + HxLocation methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxLocation.class); + if (methodAnnotation != null) { + var location = convertToLocation(methodAnnotation); + if (location.hasContextData()) { + location.setPath(RequestContextUtils.createUrl(request, location.getPath(), methodAnnotation.contextRelative())); + setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION, location); + } else { + setHeader(response, HtmxResponseHeader.HX_LOCATION, RequestContextUtils.createUrl(request, location.getPath(), methodAnnotation.contextRelative())); + } + } + } + + private void setHxPushUrl(HttpServletRequest request, HttpServletResponse response, Method method) { + HxPushUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxPushUrl.class); + if (methodAnnotation != null) { + if (HtmxValue.TRUE.equals(methodAnnotation.value())) { + setHeader(response, HX_PUSH_URL, getRequestUrl(request)); + } else { + setHeader(response, HX_PUSH_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); + } + } + } + + private void setHxRedirect(HttpServletRequest request, HttpServletResponse response, Method method) { + HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class); + if (methodAnnotation != null) { + setHeader(response, HX_REDIRECT, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); + } + } + + private void setHxReplaceUrl(HttpServletRequest request, HttpServletResponse response, Method method) { + HxReplaceUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReplaceUrl.class); + if (methodAnnotation != null) { + if (HtmxValue.TRUE.equals(methodAnnotation.value())) { + setHeader(response, HX_REPLACE_URL, getRequestUrl(request)); + } else { + setHeader(response, HX_REPLACE_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); + } + } + } + + private void setHxReswap(HttpServletResponse response, Method method) { + HxReswap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReswap.class); + if (methodAnnotation != null) { + setHeader(response, HX_RESWAP, convertToReswap(methodAnnotation)); + } + } + + private void setHxRetarget(HttpServletResponse response, Method method) { + HxRetarget methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRetarget.class); + if (methodAnnotation != null) { + setHeader(response, HX_RETARGET, methodAnnotation.value()); + } + } + + private void setHxReselect(HttpServletResponse response, Method method) { + HxReselect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReselect.class); + if (methodAnnotation != null) { + setHeader(response, HX_RESELECT, methodAnnotation.value()); + } + } + + private void setHxTrigger(HttpServletResponse response, Method method) { + HxTrigger methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTrigger.class); + if (methodAnnotation != null) { + setHeader(response, convertToHeader(methodAnnotation.lifecycle()), methodAnnotation.value()); + } + } + + private void setHxTriggerAfterSettle(HttpServletResponse response, Method method) { + HxTriggerAfterSettle methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSettle.class); + if (methodAnnotation != null) { + setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, methodAnnotation.value()); + } + } + + private void setHxTriggerAfterSwap(HttpServletResponse response, Method method) { + HxTriggerAfterSwap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSwap.class); + if (methodAnnotation != null) { + setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, methodAnnotation.value()); + } + } + + private void setHxRefresh(HttpServletResponse response, Method method) { + HxRefresh methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRefresh.class); + if (methodAnnotation != null) { + setHeader(response, HX_REFRESH, HtmxValue.TRUE); + } + } + + private HtmxResponseHeader convertToHeader(HxTriggerLifecycle lifecycle) { + switch (lifecycle) { + case RECEIVE: + return HX_TRIGGER; + case SETTLE: + return HX_TRIGGER_AFTER_SETTLE; + case SWAP: + return HX_TRIGGER_AFTER_SWAP; + default: + throw new IllegalArgumentException("Unknown lifecycle:" + lifecycle); + } + } + + private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String value) { + response.setHeader(header.getValue(), value); + } + + private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String[] values) { + response.setHeader(header.getValue(), String.join(",", values)); + } + + private HtmxLocation convertToLocation(HxLocation annotation) { + var location = new HtmxLocation(); + location.setPath(annotation.path()); + if (!annotation.source().isEmpty()) { + location.setSource(annotation.source()); + } + if (!annotation.event().isEmpty()) { + location.setEvent(annotation.event()); + } + if (!annotation.handler().isEmpty()) { + location.setHandler(annotation.handler()); + } + if (!annotation.target().isEmpty()) { + location.setTarget(annotation.target()); + } + if (!annotation.target().isEmpty()) { + location.setSwap(annotation.swap()); + } + if (!annotation.select().isEmpty()) { + location.setSelect(annotation.select()); + } + return location; + } + + private String convertToReswap(HxReswap annotation) { + + var reswap = new HtmxReswap(annotation.value()); + if (annotation.swap() != -1) { + reswap.swap(Duration.ofMillis(annotation.swap())); + } + if (annotation.settle() != -1) { + reswap.swap(Duration.ofMillis(annotation.settle())); + } + if (annotation.transition()) { + reswap.transition(); + } + if (annotation.focusScroll() != HxReswap.FocusScroll.UNDEFINED) { + reswap.focusScroll(annotation.focusScroll() == HxReswap.FocusScroll.TRUE); + } + if (annotation.show() != HxReswap.Position.UNDEFINED) { + reswap.show(convertToPosition(annotation.show())); + if (!annotation.showTarget().isEmpty()) { + reswap.scrollTarget(annotation.showTarget()); + } + } + if (annotation.scroll() != HxReswap.Position.UNDEFINED) { + reswap.scroll(convertToPosition(annotation.scroll())); + if (!annotation.scrollTarget().isEmpty()) { + reswap.scrollTarget(annotation.scrollTarget()); + } + } + + return reswap.toString(); + } + + private HtmxReswap.Position convertToPosition(HxReswap.Position position) { + return switch (position) { + case TOP -> HtmxReswap.Position.TOP; + case BOTTOM -> HtmxReswap.Position.BOTTOM; + default -> throw new IllegalStateException("Unexpected value: " + position); + }; + } + + private String getRequestUrl(HttpServletRequest request) { + String path = request.getRequestURI(); + String queryString = request.getQueryString(); + + if (queryString != null && !queryString.isEmpty()) { + path += "?" + queryString; + } + return path; + } + + private void setHeaderJsonValue(HttpServletResponse response, HtmxResponseHeader header, Object value) { + try { + response.setHeader(header.getValue(), objectMapper.writeValueAsString(value)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to set header " + header.getValue() + " to " + value, e); + } + } + +} 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 336380d..0189fc1 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 @@ -18,10 +18,9 @@ import org.springframework.web.servlet.View; import org.springframework.web.servlet.ViewResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import org.springframework.web.servlet.view.BeanNameViewResolver; import java.util.List; @@ -32,6 +31,7 @@ public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConf private final ObjectFactory viewResolverObjectFactory; private final ObjectFactory localeResolverObjectFactory; private final ObjectMapper objectMapper; + private final HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler; HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory viewResolverObjectFactory, ObjectFactory localeResolverObjectFactory) { @@ -42,6 +42,7 @@ public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConf this.viewResolverObjectFactory = viewResolverObjectFactory; this.localeResolverObjectFactory = localeResolverObjectFactory; this.objectMapper = JsonMapper.builder().build(); + this.handlerMethodAnnotationHandler = new HtmxHandlerMethodAnnotationHandler(this.objectMapper); } @Override @@ -51,7 +52,7 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper)); + registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper, handlerMethodAnnotationHandler)); } @Override @@ -66,6 +67,11 @@ public void addReturnValueHandlers(List handler handlers.add(new HtmxViewMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory.getObject())); } + @Override + public ExceptionHandlerExceptionResolver getExceptionHandlerExceptionResolver() { + return new HtmxExceptionHandlerExceptionResolver(handlerMethodAnnotationHandler); + } + @Bean @ConditionalOnBean(View.class) @ConditionalOnMissingBean diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java index 7330014..b1c3fb6 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java @@ -33,6 +33,15 @@ private static HttpHeaders htmxRequest() { return headers; } + @Test + public void testExceptionHandlerWithOverride() throws Exception { + + mockMvc.perform(get("/throw-exception-annotation-override").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#container")) + .andExpect(content().string("View1\n")); + } + @Test public void testExceptionHandler() throws Exception { @@ -42,6 +51,33 @@ public void testExceptionHandler() throws Exception { .andExpect(content().string("View1\n")); } + @Test + public void testExceptionHandlerWithHtmxView() throws Exception { + + mockMvc.perform(get("/throw-exception-htmx-view").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#container")) + .andExpect(content().string("View1\n")); + } + + @Test + public void testExceptionHandlerUsingAnnotation() throws Exception { + + mockMvc.perform(get("/throw-exception-annotated").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#container")) + .andExpect(content().string("View1\n")); + } + + @Test + public void testExceptionHandlerUsingAnnotationWithHtmxView() throws Exception { + + mockMvc.perform(get("/throw-exception-annotated-htmx-view").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "none")) + .andExpect(content().string("View1\n")); + } + @Test public void testLocationRedirect() throws Exception { @@ -218,6 +254,32 @@ public String handleError(RuntimeException ex, HtmxRequest htmxRequest, HtmxResp return "view1"; } + @ExceptionHandler(TestExceptionForHtmxViewHandler.class) + public HtmxView handleError(TestExceptionForHtmxViewHandler ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + if (htmxRequest.isHtmxRequest()) { + htmxResponse.setRetarget("#container"); + } + return new HtmxView("view1"); + } + + @ExceptionHandler(TestExceptionForAnnotatedHandlerWithAnnotationOverride.class) + @HxRetarget("#container") + public String handleError(TestExceptionForAnnotatedHandlerWithAnnotationOverride ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + return "view1"; + } + + @ExceptionHandler(TestExceptionForAnnotatedHandler.class) + @HxRetarget("#container") + public String handleError(TestExceptionForAnnotatedHandler ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + return "view1"; + } + + @ExceptionHandler(TestExceptionForAnnotatedHandlerWithHtmxView.class) + @HxReswap(HxSwapType.NONE) + public HtmxView handleError(TestExceptionForAnnotatedHandlerWithHtmxView ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + return new HtmxView("view1"); + } + @HxRequest @GetMapping("/location-redirect") public HtmxLocationRedirectView locationRedirect() { @@ -380,6 +442,47 @@ public void throwException() { throw new RuntimeException(); } + @HxRequest + @GetMapping("/throw-exception-htmx-view") + public void throwExceptionHtmxView() { + throw new TestExceptionForHtmxViewHandler(); + } + + @HxRequest + @GetMapping("/throw-exception-annotation-override") + @HxRetarget("controller-method-target") + public void throwExceptionAnnotationOverride() { + throw new TestExceptionForAnnotatedHandlerWithAnnotationOverride(); + } + + @HxRequest + @GetMapping("/throw-exception-annotated") + public void throwExceptionForAnnotatedHandler() { + throw new TestExceptionForAnnotatedHandler(); + } + + @HxRequest + @GetMapping("/throw-exception-annotated-htmx-view") + public void throwExceptionForAnnotatedHandlerWithHtmxView() { + throw new TestExceptionForAnnotatedHandlerWithHtmxView(); + } + + } + + static class TestExceptionForHtmxViewHandler extends RuntimeException { + + } + + static class TestExceptionForAnnotatedHandler extends RuntimeException { + + } + + static class TestExceptionForAnnotatedHandlerWithHtmxView extends RuntimeException { + + } + + static class TestExceptionForAnnotatedHandlerWithAnnotationOverride extends RuntimeException { + } }