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 0d98cb7..661ab69 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 @@ -10,7 +10,6 @@ import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.View; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.lang.reflect.Method; import java.time.Duration; @@ -45,7 +44,7 @@ private void buildAndRender(HtmxResponse htmxResponse, ModelAndView mav, HttpSer View v = htmxResponseHandlerMethodReturnValueHandler.toView(htmxResponse); try { v.render(mav.getModel(), request, response); - htmxResponseHandlerMethodReturnValueHandler.addHxHeaders(htmxResponse, response); + htmxResponseHandlerMethodReturnValueHandler.addHxHeaders(htmxResponse, request, response); } catch (Exception e) { throw new RuntimeException(e); } @@ -58,9 +57,9 @@ public boolean preHandle(HttpServletRequest request, if (handler instanceof HandlerMethod) { Method method = ((HandlerMethod) handler).getMethod(); - setHxLocation(response, method); + setHxLocation(request, response, method); setHxPushUrl(request, response, method); - setHxRedirect(response, method); + setHxRedirect(request, response, method); setHxReplaceUrl(request, response, method); setHxReswap(response, method); setHxRetarget(response, method); @@ -81,14 +80,15 @@ private void setVary(HttpServletRequest request, HttpServletResponse response) { } } - private void setHxLocation(HttpServletResponse response, Method 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(UrlUtils.createTargetUrl(request, location.getPath(), methodAnnotation.contextRelative())); setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION, location); } else { - setHeader(response, HtmxResponseHeader.HX_LOCATION, location.getPath()); + setHeader(response, HtmxResponseHeader.HX_LOCATION, UrlUtils.createTargetUrl(request, location.getPath(), methodAnnotation.contextRelative())); } } } @@ -99,15 +99,15 @@ private void setHxPushUrl(HttpServletRequest request, HttpServletResponse respon if (HtmxValue.TRUE.equals(methodAnnotation.value())) { setHeader(response, HX_PUSH_URL, getRequestUrl(request)); } else { - setHeader(response, HX_PUSH_URL, methodAnnotation.value()); + setHeader(response, HX_PUSH_URL, UrlUtils.createTargetUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); } } } - private void setHxRedirect(HttpServletResponse response, Method method) { + private void setHxRedirect(HttpServletRequest request, HttpServletResponse response, Method method) { HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class); if (methodAnnotation != null) { - setHeader(response, HX_REDIRECT, methodAnnotation.value()); + setHeader(response, HX_REDIRECT, UrlUtils.createTargetUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); } } @@ -117,7 +117,7 @@ private void setHxReplaceUrl(HttpServletRequest request, HttpServletResponse res if (HtmxValue.TRUE.equals(methodAnnotation.value())) { setHeader(response, HX_REPLACE_URL, getRequestUrl(request)); } else { - setHeader(response, HX_REPLACE_URL, methodAnnotation.value()); + setHeader(response, HX_REPLACE_URL, UrlUtils.createTargetUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative())); } } } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java index 27a968d..34904a5 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java @@ -1,13 +1,13 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; -import java.util.*; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.Assert; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.View; +import java.util.*; + /** * Used as a controller method return type to specify htmx-related response headers * and returning multiple template partials in a single response. @@ -27,6 +27,7 @@ public final class HtmxResponse { private final Set triggersAfterSwap; private final String replaceUrl; private final String reselect; + private final boolean contextRelative; // TODO should also be final after switching to builder pattern private String retarget; private boolean refresh; @@ -55,11 +56,12 @@ public HtmxResponse() { this.triggersAfterSwap = new LinkedHashSet<>(); this.replaceUrl = null; this.reselect = null; + this.contextRelative = true; } HtmxResponse(Set views, Set triggers, Set triggersAfterSettle, Set triggersAfterSwap, String retarget, boolean refresh, String redirect, - String pushUrl, String replaceUrl, String reselect, HtmxReswap reswap, HtmxLocation location) { + String pushUrl, String replaceUrl, String reselect, HtmxReswap reswap, HtmxLocation location, boolean contextRelative) { this.views = views; this.triggers = triggers; this.triggersAfterSettle = triggersAfterSettle; @@ -72,6 +74,7 @@ public HtmxResponse() { this.reselect = reselect; this.reswap = reswap; this.location = location; + this.contextRelative = contextRelative; } /** @@ -380,6 +383,10 @@ public boolean isRefresh() { return refresh; } + public boolean isContextRelative() { + return contextRelative; + } + /** * @deprecated will be removed in 4.0. */ @@ -406,6 +413,7 @@ public static final class Builder { private HtmxReswap reswap; private String retarget; private String reselect; + private boolean contextRelative = true; /** * Merges another {@link HtmxResponse} into this builder. @@ -468,7 +476,22 @@ public HtmxResponse build() { replaceUrl, reselect, reswap, - location); + location, + contextRelative); + } + + /** + * Set whether URLs used in the htmx response that starts with a slash ("/") should be interpreted as + * relative to the current ServletContext, i.e. as relative to the web application root. + * Default is "true": A URL that starts with a slash will be interpreted as relative to + * the web application root, i.e. the context path will be prepended to the URL. + * + * @param contextRelative whether to interpret URLs in the htmx response as relative to the current ServletContext + * @return the builder + */ + public Builder contextRelative(boolean contextRelative) { + this.contextRelative = contextRelative; + return this; } /** @@ -728,8 +751,8 @@ private static void mergeTriggers(Collection triggers, Collection otrigger = triggers.stream() - .filter(t -> t.getEventName().equals(otherTrigger.getEventName())) - .findFirst(); + .filter(t -> t.getEventName().equals(otherTrigger.getEventName())) + .findFirst(); if (otrigger.isPresent()) { LOGGER.warn("Duplicate trigger event '{}' found. Details '{}' will be overwritten by with '{}'", otherTrigger.getEventName(), otrigger.get().getEventDetail(), otherTrigger.getEventDetail()); 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 index fbd4f47..e48f1da 100644 --- 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 @@ -2,6 +2,7 @@ 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.beans.factory.ObjectFactory; import org.springframework.core.MethodParameter; @@ -47,7 +48,10 @@ public void handleReturnValue(Object returnValue, HtmxResponse htmxResponse = (HtmxResponse) returnValue; mavContainer.setView(toView(htmxResponse)); - addHxHeaders(htmxResponse, webRequest.getNativeResponse(HttpServletResponse.class)); + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class); + + addHxHeaders(htmxResponse, request, response); } View toView(HtmxResponse htmxResponse) { @@ -74,26 +78,28 @@ View toView(HtmxResponse htmxResponse) { }; } - void addHxHeaders(HtmxResponse htmxResponse, HttpServletResponse response) { + void addHxHeaders(HtmxResponse htmxResponse, HttpServletRequest request, 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()); + HtmxLocation location = htmxResponse.getLocation(); + if (location.hasContextData()) { + location.setPath(UrlUtils.createTargetUrl(request, location.getPath(), htmxResponse.isContextRelative())); + setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION.getValue(), location); } else { - response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), htmxResponse.getLocation().getPath()); + response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), UrlUtils.createTargetUrl(request, location.getPath(), htmxResponse.isContextRelative())); } } if (htmxResponse.getReplaceUrl() != null) { - response.setHeader(HtmxResponseHeader.HX_REPLACE_URL.getValue(), htmxResponse.getReplaceUrl()); + response.setHeader(HtmxResponseHeader.HX_REPLACE_URL.getValue(), UrlUtils.createTargetUrl(request, htmxResponse.getReplaceUrl(), htmxResponse.isContextRelative())); } if (htmxResponse.getPushUrl() != null) { - response.setHeader(HtmxResponseHeader.HX_PUSH_URL.getValue(), htmxResponse.getPushUrl()); + response.setHeader(HtmxResponseHeader.HX_PUSH_URL.getValue(), UrlUtils.createTargetUrl(request, htmxResponse.getPushUrl(), htmxResponse.isContextRelative())); } if (htmxResponse.getRedirect() != null) { - response.setHeader(HtmxResponseHeader.HX_REDIRECT.getValue(), htmxResponse.getRedirect()); + response.setHeader(HtmxResponseHeader.HX_REDIRECT.getValue(), UrlUtils.createTargetUrl(request, htmxResponse.getRedirect(), htmxResponse.isContextRelative())); } if (htmxResponse.isRefresh()) { response.setHeader(HtmxResponseHeader.HX_REFRESH.getValue(), "true"); diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java index ef23410..20f006f 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java @@ -51,5 +51,9 @@ * How the response will be swapped in relative to the target */ String swap() default ""; + /** + * If the path should be interpreted as context relative if it starts with a slash ("/"). + */ + boolean contextRelative() default true; } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java index 9af108e..9bcd440 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java @@ -27,5 +27,9 @@ * The value for the {@code HX-Push-Url} response header. */ String value() default HtmxValue.TRUE; + /** + * If the URL should be interpreted as context relative if it starts with a slash ("/"). + */ + boolean contextRelative() default true; } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java index 4edf356..ac9f69a 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java @@ -18,5 +18,9 @@ * The URL to use to do a client-side redirect to a new location. */ String value(); + /** + * If the URL should be interpreted as context relative if it starts with a slash ("/"). + */ + boolean contextRelative() default true; } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java index 72a7125..6fb23f4 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java @@ -28,5 +28,9 @@ * The value for the {@code HX-Replace-Url} response header. */ String value() default HtmxValue.TRUE; + /** + * If the URL should be interpreted as context relative if it starts with a slash ("/"). + */ + boolean contextRelative() default true; } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/UrlUtils.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/UrlUtils.java new file mode 100644 index 0000000..d005c07 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/UrlUtils.java @@ -0,0 +1,35 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import jakarta.servlet.http.HttpServletRequest; + +class UrlUtils { + + /** + * Creates a target URL by prepending the context path if {@code contextRelative} + * is {@code true} and the URL starts with a slash ("/"). + * + * @param request + * @param url + * @param contextRelative + * @return + */ + static String createTargetUrl(HttpServletRequest request, String url, boolean contextRelative) { + if (contextRelative && url.startsWith("/")) { + // Do not apply context path to relative URLs. + return getContextPath(request) + url; + } + return url; + } + + static String getContextPath(HttpServletRequest request) { + String contextPath = request.getContextPath(); + while (contextPath.startsWith("//")) { + contextPath = contextPath.substring(1); + } + return contextPath; + } + + private UrlUtils() { + } + +} diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java index 0f86208..125a344 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java @@ -128,62 +128,62 @@ public void testHxRefresh() throws Exception { @Test public void testHxLocationWithContextData() throws Exception { - mockMvc.perform(get("/hx-location-with-context-data")) + mockMvc.perform(get("/test/hx-location-with-context-data").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Location", "{\"path\":\"/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\"}")); + .andExpect(header().string("HX-Location", "{\"path\":\"/test/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\"}")); } @Test public void testHxLocationWithoutContextData() throws Exception { - mockMvc.perform(get("/hx-location-without-context-data")) + mockMvc.perform(get("/test/hx-location-without-context-data").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Location", "/path")); + .andExpect(header().string("HX-Location", "/test/path")); } @Test public void testHxPushUrlPath() throws Exception { - mockMvc.perform(get("/hx-push-url-path")) + mockMvc.perform(get("/test/hx-push-url-path").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Push-Url", "/path")); + .andExpect(header().string("HX-Push-Url", "/test/path")); } @Test public void testHxPushUrl() throws Exception { - mockMvc.perform(get("/hx-push-url?test=hello")) + mockMvc.perform(get("/test/hx-push-url?test=hello").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Push-Url", "/hx-push-url?test=hello")); + .andExpect(header().string("HX-Push-Url", "/test/hx-push-url?test=hello")); } @Test public void testHxPushUrlFalse() throws Exception { - mockMvc.perform(get("/hx-push-url-false?test=hello")) + mockMvc.perform(get("/test/hx-push-url-false?test=hello").contextPath("/test")) .andExpect(status().isOk()) .andExpect(header().string("HX-Push-Url", "false")); } @Test public void testHxRedirect() throws Exception { - mockMvc.perform(get("/hx-redirect")) + mockMvc.perform(get("/test/hx-redirect").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Redirect", "/path")); + .andExpect(header().string("HX-Redirect", "/test/path")); } @Test public void testHxReplaceUrlPath() throws Exception { - mockMvc.perform(get("/hx-replace-url-path")) + mockMvc.perform(get("/test/hx-replace-url-path").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Replace-Url", "/path")); + .andExpect(header().string("HX-Replace-Url", "/test/path")); } @Test public void testHxReplaceUrl() throws Exception { - mockMvc.perform(get("/hx-replace-url?test=hello&test2=hello2")) + mockMvc.perform(get("/test/hx-replace-url?test=hello&test2=hello2").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Replace-Url", "/hx-replace-url?test=hello&test2=hello2")); + .andExpect(header().string("HX-Replace-Url", "/test/hx-replace-url?test=hello&test2=hello2")); } @Test public void testHxReplaceUrlFalse() throws Exception { - mockMvc.perform(get("/hx-replace-url-false?test=hello")) + mockMvc.perform(get("/test/hx-replace-url-false?test=hello").contextPath("/test")) .andExpect(status().isOk()) .andExpect(header().string("HX-Replace-Url", "false")); } diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerTest.java index de6452a..d0a2a9b 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandlerTest.java @@ -21,30 +21,30 @@ public class HtmxResponseHandlerMethodReturnValueHandlerTest { @Test public void testHxLocationWithContextData() throws Exception { - mockMvc.perform(get("/hvhi/hx-location-with-context-data")) + mockMvc.perform(get("/test/hvhi/hx-location-with-context-data").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Location", "{\"path\":\"/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\",\"values\":{\"value1\":\"v1\",\"value2\":\"v2\"},\"headers\":{\"header1\":\"v1\",\"header2\":\"v2\"}}")); + .andExpect(header().string("HX-Location", "{\"path\":\"/test/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\",\"values\":{\"value1\":\"v1\",\"value2\":\"v2\"},\"headers\":{\"header1\":\"v1\",\"header2\":\"v2\"}}")); } @Test public void testHxLocationWithoutContextData() throws Exception { - mockMvc.perform(get("/hvhi/hx-location-without-context-data")) + mockMvc.perform(get("/test/hvhi/hx-location-without-context-data").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Location", "/path")); + .andExpect(header().string("HX-Location", "/test/path")); } @Test public void testHxPushUrl() throws Exception { - mockMvc.perform(get("/hvhi/hx-push-url")) + mockMvc.perform(get("/test/hvhi/hx-push-url").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Push-Url", "/path")); + .andExpect(header().string("HX-Push-Url", "/test/path")); } @Test public void testHxRedirect() throws Exception { - mockMvc.perform(get("/hvhi/hx-redirect")) + mockMvc.perform(get("/test/hvhi/hx-redirect").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Redirect", "/path")); + .andExpect(header().string("HX-Redirect", "/test/path")); } @Test @@ -56,9 +56,9 @@ public void testHxRefresh() throws Exception { @Test public void testHxReplaceUrl() throws Exception { - mockMvc.perform(get("/hvhi/hx-replace-url")) + mockMvc.perform(get("/test/hvhi/hx-replace-url").contextPath("/test")) .andExpect(status().isOk()) - .andExpect(header().string("HX-Replace-Url", "/path")); + .andExpect(header().string("HX-Replace-Url", "/test/path")); } @Test