Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,24 @@ The following annotations are currently supported:

>**Note** Please refer to the related Javadoc to learn more about the available options.

#### Examples
There are two ways to set htmx response headers on controller methods.
The first is to use annotations, e.g. `@HxTrigger`, and the second is to use the class [HtmxResponse](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.html) as the return type of the controller method.

Here you can find a list of all available annotations:
* [@HxLocation](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.html)
* [@HxPushUrl](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.html)
* [@HxRedirect](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.html)
* [@HxRefresh](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRefresh.html)
* [@HxReplaceUrl](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.html)
* [@HxReselect](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReselect.html)
* [@HxReswap](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReswap.html)
* [@HxRetarget](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRetarget.html)
* [@HxTrigger](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.html)

If you want htmx to trigger an event after the response is processed, you can use the annotation `@HxTrigger` which sets the necessary response header [HX-Trigger](https://htmx.org/headers/hx-trigger/).
>**Note** Please check the corresponding Javadoc to learn about the available options.

#### Examples
If you want htmx to trigger an event by setting the response header [HX-Trigger](https://htmx.org/headers/hx-trigger/), you can use the annotation `@HxTrigger`.
```java
@HxRequest
@HxTrigger("userUpdated") // the event 'userUpdated' will be triggered by htmx
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_REFRESH;
import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER;
import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE;
import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP;

import java.lang.reflect.Method;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Nonnull;
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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.time.Duration;

import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*;

public class HtmxHandlerInterceptor implements HandlerInterceptor {

private final ObjectMapper objectMapper;

public HtmxHandlerInterceptor(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
public boolean preHandle(@Nonnull HttpServletRequest request,
@Nonnull HttpServletResponse response,
@Nonnull Object handler) {
if (handler instanceof HandlerMethod) {
Method method = ((HandlerMethod) handler).getMethod();
setHxLocation(response, method);
setHxPushUrl(response, method);
setHxRedirect(response, method);
setHxReplaceUrl(response, method);
setHxReswap(response, method);
setHxRetarget(response, method);
setHxReselect(response, method);
setHxTrigger(response, method);
setHxRefresh(response, method);
setVary(request, response);
Expand All @@ -30,6 +44,60 @@ public boolean preHandle(HttpServletRequest request,
return true;
}

private void setHxLocation(HttpServletResponse response, Method method) {
HxLocation methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxLocation.class);
if (methodAnnotation != null) {
var location = convertToLocation(methodAnnotation);
if (location.hasContextData()) {
setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION.getValue(), location);
} else {
response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), location.getPath());
}
}
}

private void setHxPushUrl(HttpServletResponse response, Method method) {
HxPushUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxPushUrl.class);
if (methodAnnotation != null) {
response.setHeader(HX_PUSH_URL.getValue(), methodAnnotation.value());
}
}

private void setHxRedirect(HttpServletResponse response, Method method) {
HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class);
if (methodAnnotation != null) {
response.setHeader(HX_REDIRECT.getValue(), methodAnnotation.value());
}
}

private void setHxReplaceUrl(HttpServletResponse response, Method method) {
HxReplaceUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReplaceUrl.class);
if (methodAnnotation != null) {
response.setHeader(HX_REPLACE_URL.getValue(), methodAnnotation.value());
}
}

private void setHxReswap(HttpServletResponse response, Method method) {
HxReswap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReswap.class);
if (methodAnnotation != null) {
response.setHeader(HX_RESWAP.getValue(), convertToReswap(methodAnnotation));
}
}

private void setHxRetarget(HttpServletResponse response, Method method) {
HxRetarget methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRetarget.class);
if (methodAnnotation != null) {
response.setHeader(HX_RETARGET.getValue(), methodAnnotation.value());
}
}

private void setHxReselect(HttpServletResponse response, Method method) {
HxReselect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReselect.class);
if (methodAnnotation != null) {
response.setHeader(HX_RESELECT.getValue(), methodAnnotation.value());
}
}

private void setVary(HttpServletRequest request, HttpServletResponse response) {
if (request.getHeader(HtmxRequestHeader.HX_REQUEST.getValue()) != null) {
response.addHeader(HttpHeaders.VARY, HtmxRequestHeader.HX_REQUEST.getValue());
Expand Down Expand Up @@ -62,4 +130,73 @@ private String getHeaderName(HxTriggerLifecycle lifecycle) {
throw new IllegalArgumentException("Unknown lifecycle:" + lifecycle);
}
}

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);
}
}

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());
}
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);
};
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HtmxHandlerInterceptor());
registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

/**
* Holder for constant values.
*/
public class HtmxValue {

/**
* Constant for use in annotations that support a {@code false} value to disable functions.
*/
public static final String FALSE = "false";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Map;

import org.springframework.core.annotation.AliasFor;

/**
* Annotation to do a client side redirect that does not do a full page reload.
* <br>
* Note that this annotation does not provide support for specifying {@code values} or {@code headers}.
* If you want to do this, use {@link HtmxResponse} instead.
*
* @see <a href="https://htmx.org/headers/hx-location/">HX-Location Response Header</a>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HxLocation {

/**
* The url path to make the redirect.
* <p>This is an alias for {@link #path}. For example,
* {@code @HxLocation("/foo")} is equivalent to
* {@code @HxLocation(path="/foo")}.
*/
@AliasFor("path")
String value() default "";
/**
* The url path to make the redirect.
*/
String path() default "";
/**
* The source element of the request
*/
String source() default "";
/**
* An event that "triggered" the request
*/
String event() default "";
/**
* A callback that will handle the response HTML.
*/
String handler() default "";
/**
* The target to swap the response into.
*/
String target() default "";
/**
* How the response will be swapped in relative to the target
*/
String swap() default "";
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use HxSwapType here ?


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation to push a new url into the history stack.
*
* @see <a href="https://htmx.org/headers/hx-push-url/">HX-Push-Url Response Header</a>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HxPushUrl {

/**
* The URL to be pushed into the location bar. This may be relative or absolute,
* as per <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/pushState">history.pushState()</a>.
* Or {@link HtmxValue#FALSE}, which prevents the browser’s history from being updated.
*/
String value();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation to do a client-side redirect to a new location.
*
* @see <a href="https://htmx.org/reference/#response_headers">HX-Redirect</a>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HxRedirect {

/**
* The URL to use to do a client-side redirect to a new location.
*/
String value();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.springframework.core.annotation.AliasFor;

/**
* Annotation to replace the current URL in the location bar.
*
* @see <a href="https://htmx.org/headers/hx-replace-url/">HX-Replace-Url</a>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HxReplaceUrl {

/**
* The URL to replace the current URL in the location bar.
* This may be relative or absolute, as per
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState">history.replaceState()</a>,
* but must have the same origin as the current URL.
* Or {@link HtmxValue#FALSE} which prevents the browser’s current URL from being updated.
*/
String value();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation to specify a CSS selector that allows you to choose which part
* of the response is used to be swapped in.
*
* @see <a href="https://htmx.org/reference/#response_headers">HX-Retarget</a>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HxReselect {

/**
* A CSS selector that allows you to choose which part of the response is used to be swapped in.
* <p>Overrides an existing <a href="https://htmx.org/attributes/hx-select/">hx-select</a> on the triggering element.
*/
String value();

}
Loading