Skip to content

Commit

Permalink
Prepend context path to context relative URLs in htmx response headers
Browse files Browse the repository at this point in the history
  • Loading branch information
xhaggi committed Oct 25, 2024
1 parent c7c8cdf commit 7b456e2
Show file tree
Hide file tree
Showing 10 changed files with 130 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand All @@ -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()));
}
}
}
Expand All @@ -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()));
}
}

Expand All @@ -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()));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -27,6 +27,7 @@ public final class HtmxResponse {
private final Set<HtmxTrigger> 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;
Expand Down Expand Up @@ -55,11 +56,12 @@ public HtmxResponse() {
this.triggersAfterSwap = new LinkedHashSet<>();
this.replaceUrl = null;
this.reselect = null;
this.contextRelative = true;
}

HtmxResponse(Set<ModelAndView> views, Set<HtmxTrigger> triggers, Set<HtmxTrigger> triggersAfterSettle,
Set<HtmxTrigger> 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;
Expand All @@ -72,6 +74,7 @@ public HtmxResponse() {
this.reselect = reselect;
this.reswap = reswap;
this.location = location;
this.contextRelative = contextRelative;
}

/**
Expand Down Expand Up @@ -380,6 +383,10 @@ public boolean isRefresh() {
return refresh;
}

public boolean isContextRelative() {
return contextRelative;
}

/**
* @deprecated will be removed in 4.0.
*/
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -728,8 +751,8 @@ private static void mergeTriggers(Collection<HtmxTrigger> triggers, Collection<H
for (HtmxTrigger otherTrigger : otherTriggers) {
if (LOGGER.isWarnEnabled()) {
Optional<HtmxTrigger> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}
Original file line number Diff line number Diff line change
@@ -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() {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
Loading

0 comments on commit 7b456e2

Please sign in to comment.