diff --git a/.gitignore b/.gitignore index 4ed9b0c4..abf5abde 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ build/ ### VS Code ### .vscode/ +/jte-classes/ +javac.** diff --git a/htmx-spring-boot-jte/pom.xml b/htmx-spring-boot-jte/pom.xml new file mode 100644 index 00000000..db772916 --- /dev/null +++ b/htmx-spring-boot-jte/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + io.github.wimdeblauwe + htmx-spring-boot-parent + 3.6.0-SNAPSHOT + + + htmx-spring-boot-jte + Spring Boot library for htmx and JTE + Spring Boot library to make it easy to work with htmx and JTE + + + + io.github.wimdeblauwe + htmx-spring-boot + ${project.parent.version} + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-web + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + gg.jte + jte-spring-boot-starter-3 + 3.1.12 + + + gg.jte + jte + 3.1.12 + + + diff --git a/htmx-spring-boot-jte/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/jte/endpoint/JteConfig.java b/htmx-spring-boot-jte/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/jte/endpoint/JteConfig.java new file mode 100644 index 00000000..b2e426f5 --- /dev/null +++ b/htmx-spring-boot-jte/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/jte/endpoint/JteConfig.java @@ -0,0 +1,31 @@ +package io.github.wimdeblauwe.htmx.spring.boot.jte.endpoint; + +import gg.jte.TemplateEngine; +import gg.jte.html.policy.*; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JteConfig { + final + TemplateEngine templateEngine; + + public JteConfig(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + @PostConstruct + void configureTemplateEngine() { + templateEngine.setHtmlPolicy(new JtePolicy()); + } + + static class JtePolicy extends PolicyGroup { + + JtePolicy() { + addPolicy(new PreventUppercaseTagsAndAttributes()); + addPolicy(new PreventOutputInTagsAndAttributes(false)); + addPolicy(new PreventUnquotedAttributes()); + addPolicy(new PreventInvalidAttributeNames()); + } + } +} diff --git a/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/DummyApplication.java b/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/DummyApplication.java new file mode 100644 index 00000000..66d58974 --- /dev/null +++ b/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/DummyApplication.java @@ -0,0 +1,17 @@ +package io.github.wimdeblauwe.htmx.spring.boot.jte; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +/** + * Just created this here to make Spring Boot test slices work + */ +@SpringBootApplication(exclude = SecurityAutoConfiguration.class) // Security is on by default +public class DummyApplication { + + public static void main(String[] args) { + SpringApplication.run(DummyApplication.class); + } + +} diff --git a/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/EndpointController.java b/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/EndpointController.java new file mode 100644 index 00000000..bef325ce --- /dev/null +++ b/htmx-spring-boot-jte/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/jte/EndpointController.java @@ -0,0 +1,39 @@ +package io.github.wimdeblauwe.htmx.spring.boot.jte; + +import io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpoint; + +import java.util.Map; + +import java.util.Optional; + +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.servlet.ModelAndView; + +@Controller +public class EndpointController { + + + public HtmxEndpoint htmxTestEndpoint = new HtmxEndpoint<>( + "/test", HttpMethod.GET, this::test); + + private ModelAndView test() { + return new ModelAndView("test",Map.of("endpointController", this, "userForm", Optional.empty())); + } + + + public record UserForm( + String userName, String password) { + } + + public HtmxEndpoint createUserEndpoint = new HtmxEndpoint<>( + "/createUser", + HttpMethod.POST, + this::createUser + ); + + private ModelAndView createUser(UserForm userForm) { + return new ModelAndView("test",Map.of("endpointController", this,"userForm",Optional.ofNullable(userForm))); + } +} diff --git a/htmx-spring-boot-jte/src/test/jte/.jteroot b/htmx-spring-boot-jte/src/test/jte/.jteroot new file mode 100644 index 00000000..e69de29b diff --git a/htmx-spring-boot-jte/src/test/jte/test.jte b/htmx-spring-boot-jte/src/test/jte/test.jte new file mode 100644 index 00000000..720a5368 --- /dev/null +++ b/htmx-spring-boot-jte/src/test/jte/test.jte @@ -0,0 +1,24 @@ +@import io.github.wimdeblauwe.htmx.spring.boot.jte.EndpointController.UserForm +@param java.util.Optional userForm +@param io.github.wimdeblauwe.htmx.spring.boot.jte.EndpointController endpointController + + +
+ + + +
+ +@if( userForm.isPresent()) +
+ Hello ${userForm.get().userName()}! Your password is: ${userForm.get().password()} +
+@endif diff --git a/htmx-spring-boot-jte/src/test/resources/application.properties b/htmx-spring-boot-jte/src/test/resources/application.properties new file mode 100644 index 00000000..442af358 --- /dev/null +++ b/htmx-spring-boot-jte/src/test/resources/application.properties @@ -0,0 +1,2 @@ +gg.jte.developmentMode=true +gg.jte.templateLocation=htmx-spring-boot-jte/src/test/jte diff --git a/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java b/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java new file mode 100644 index 00000000..0a143724 --- /dev/null +++ b/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java @@ -0,0 +1,33 @@ +package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf.endpoint; + +import io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpoint; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Controller; +import org.springframework.web.servlet.ModelAndView; + +@Controller +public class EndpointController { + + + public HtmxEndpoint htmxTestEndpoint = new HtmxEndpoint<>( + "/test", HttpMethod.GET, this::test); + + private ModelAndView test() { + return new ModelAndView("test"); + } + + + public record UserForm( + String userName, String password) { + } + + public HtmxEndpoint createUserEndpoint = new HtmxEndpoint<>( + "/createUser", + HttpMethod.POST, + this::createUser + ); + + private ModelAndView createUser(UserForm userForm) { + return new ModelAndView("test"); + } +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java new file mode 100644 index 00000000..2bcb1e17 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java @@ -0,0 +1,101 @@ +package io.github.wimdeblauwe.htmx.spring.boot.endpoint; + +import jakarta.servlet.ServletException; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; +import org.springframework.validation.BindException; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.function.*; + +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +public abstract class AbstractHtmxEndpoint implements RouterFunction { + + public final String path; + public final HttpMethod method; + private final Supplier supplier; + private final Function function; + private final Class tClass; + + public AbstractHtmxEndpoint(String path, HttpMethod method, Function function, T... tClass) { + this.path = path; + this.method = method; + this.function = function; + this.tClass = getClassOf(tClass); + this.supplier = null; + + } + + static Class getClassOf(T[] array) { + return (Class) array.getClass().getComponentType(); + } + + public AbstractHtmxEndpoint(String path, HttpMethod method, Supplier supplier) { + this.path = path; + this.method = method; + this.supplier = supplier; + this.function = null; + this.tClass = null; + } + + @Override + @NonNull + public Optional> route(@NonNull ServerRequest request) { + RequestPredicate predicate = RequestPredicates.method(method).and(RequestPredicates.path(path)); + if (predicate.test(request)) { + R model = getBody(request); + if (model instanceof ModelAndView modelAndView) { + Map modelData = modelAndView.getModel(); + return Optional.of( + req -> RenderingResponse.create(templateName(model)) + .modelAttributes(modelData) + .build() + ); + } else { + return Optional.of( + req -> RenderingResponse.create(templateName(model)) + .modelAttribute(model) + .build()); + } + } + return Optional.empty(); + } + + abstract String templateName(R modelAndView); + + private R getBody(ServerRequest req) { + if (function == null && supplier != null) { + return supplier.get(); + } + try { + if (function != null) { + MediaType mediaType = req.headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM); + if(mediaType.includes(MediaType.APPLICATION_FORM_URLENCODED)){ + T formData = req.bind(tClass); + return function.apply( + formData + ); + } + T body = req.body(tClass); + return function.apply( + body + ); + } + } catch (ServletException | IOException e) { + throw new RuntimeException(e); + } catch (BindException e) { + throw new RuntimeException(e); + } + throw new RuntimeException("Failed to get body"); + } + + public String call() { + return "hx-" + method.name().toLowerCase() + " =\"" + path + "\""; + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java new file mode 100644 index 00000000..6178a7a7 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java @@ -0,0 +1,30 @@ +package io.github.wimdeblauwe.htmx.spring.boot.endpoint; + +import org.springframework.http.HttpMethod; + +import java.util.function.Function; +import java.util.function.Supplier; + +import org.springframework.web.servlet.ModelAndView; + +public class HtmxEndpoint extends AbstractHtmxEndpoint { + + public HtmxEndpoint(String path, HttpMethod method, + Supplier supplier) { + super(path, method, supplier); + } + + @Override + String templateName(S responseType) { + if(responseType instanceof String viewName){ + return viewName; + } else if (responseType instanceof ModelAndView modelAndView) { + return modelAndView.getViewName(); + } + throw new IllegalArgumentException("HtmxEndpoint doesnt support" + responseType.getClass()); + } + + public HtmxEndpoint(String path, HttpMethod method, Function function, T... tClass) { + super(path, method, function, tClass); + } +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java new file mode 100644 index 00000000..5d453b01 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java @@ -0,0 +1,93 @@ +package io.github.wimdeblauwe.htmx.spring.boot.endpoint; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Controller; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.support.RouterFunctionMapping; + +@AutoConfiguration +@EnableConfigurationProperties(HtmxEndpointConfig.HtmxEndpointProperties.class) +public class HtmxEndpointConfig { + + private final Logger logger = LoggerFactory.getLogger( + io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpointConfig.class); + + private final ApplicationContext applicationContext; + private final RouterFunctionMapping routerFunctionMapping; + private final HtmxEndpointProperties htmxEndpointProperties; + + public HtmxEndpointConfig(ApplicationContext applicationContext, + RouterFunctionMapping routerFunctionMapping, + HtmxEndpointProperties htmxEndpointProperties) { + this.applicationContext = applicationContext; + this.routerFunctionMapping = routerFunctionMapping; + this.htmxEndpointProperties = htmxEndpointProperties; + } + + @ConfigurationProperties("htmx.endpoint") + public static class HtmxEndpointProperties { + + private final List> annotationClassesToScan; + + public HtmxEndpointProperties(List> annotationClassesToScan) { + if (annotationClassesToScan == null || annotationClassesToScan.isEmpty()) { + this.annotationClassesToScan = Collections.singletonList(Controller.class); + } else { + this.annotationClassesToScan = annotationClassesToScan; + } + } + + public List> annotationClassesToScan() { + return annotationClassesToScan; + } + } + + + @Bean + ApplicationRunner applicationRunner() { + return args -> { + Map beans = new HashMap<>(); + for (Class aClass : htmxEndpointProperties.annotationClassesToScan()) { + beans.putAll(applicationContext.getBeansWithAnnotation(aClass)); + } + beans.values().forEach(bean -> + { + List fieldList = Arrays.stream(bean.getClass().getDeclaredFields()) + .filter(method -> AbstractHtmxEndpoint.class.isAssignableFrom(method.getType())) + .toList(); + + fieldList.forEach(field -> { + AbstractHtmxEndpoint function = (AbstractHtmxEndpoint) ReflectionUtils.getField(field, bean); + if (function == null) { + throw new RuntimeException("Router function could not be found"); + } + logger.info("Add endpoint: {}, with method: {}", function.path, function.method); + if (routerFunctionMapping.getRouterFunction() == null) { + routerFunctionMapping.setRouterFunction(function); + } else { + RouterFunction routerFunction = routerFunctionMapping.getRouterFunction().andOther(function); + routerFunctionMapping.setRouterFunction(routerFunction); + } + }); + } + ); + System.out.println(routerFunctionMapping.getRouterFunction()); + }; + } +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java new file mode 100644 index 00000000..e71696b2 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java @@ -0,0 +1,82 @@ +package io.github.wimdeblauwe.htmx.spring.boot.endpoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; + +@Component +public class HtmxFormConverter implements HttpMessageConverter { + + private final Charset charset = StandardCharsets.UTF_8; + + @Override + public boolean canRead(@NonNull Class clazz, MediaType mediaType) { + if (mediaType == null) { + return false; + } + return mediaType.includes(MediaType.APPLICATION_FORM_URLENCODED); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public List getSupportedMediaTypes() { + return List.of(); + } + + @NonNull + @Override + public List getSupportedMediaTypes(@NonNull Class clazz) { + return List.of(MediaType.APPLICATION_FORM_URLENCODED); + } + + + @NonNull + @Override + public Object read(@NonNull Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + MediaType contentType = inputMessage.getHeaders().getContentType(); + Charset charset = (contentType != null && contentType.getCharset() != null ? + contentType.getCharset() : this.charset); + String body = StreamUtils.copyToString(inputMessage.getBody(), charset); + + String[] pairs = StringUtils.tokenizeToStringArray(body, "&"); + HashMap result = new HashMap<>(pairs.length); + for (String pair : pairs) { + int idx = pair.indexOf('='); + if (idx == -1) { + result.put(URLDecoder.decode(pair, charset), null); + } else { + String name = URLDecoder.decode(pair.substring(0, idx), charset); + String value = URLDecoder.decode(pair.substring(idx + 1), charset); + result.put(name, value); + } + } + final ObjectMapper mapper = new ObjectMapper(); + return mapper.convertValue(result, clazz); + } + + @Override + public void write(@NonNull Object o, MediaType contentType, HttpOutputMessage outputMessage) + throws HttpMessageNotWritableException { + + } +} diff --git a/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2994466b..0499a5fb 100644 --- a/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxMvcAutoConfiguration +io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpointConfig diff --git a/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports b/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports index 2994466b..0499a5fb 100644 --- a/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports +++ b/htmx-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc.imports @@ -1 +1,2 @@ io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxMvcAutoConfiguration +io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpointConfig diff --git a/pom.xml b/pom.xml index f11e6d02..af1b2c86 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,7 @@ htmx-spring-boot htmx-spring-boot-thymeleaf + htmx-spring-boot-jte @@ -73,7 +74,7 @@ - [${java.version}, 22) + [${java.version}, 23) @@ -143,7 +144,7 @@ - [${java.version}, 18) + [${java.version}, 23)