diff --git a/htmx-api/pom.xml b/htmx-api/pom.xml new file mode 100644 index 000000000..ad4b7e30c --- /dev/null +++ b/htmx-api/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + io.avaje + avaje-http-parent + 2.7 + + + avaje-htmx-api + + + + + + + io.avaje + avaje-lang + 1.0 + + + + diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java new file mode 100644 index 000000000..c213b6e13 --- /dev/null +++ b/htmx-api/src/main/java/io/avaje/htmx/api/DHxRequest.java @@ -0,0 +1,141 @@ +package io.avaje.htmx.api; + +import io.avaje.lang.Nullable; + +final class DHxRequest implements HtmxRequest { + + private final boolean htmxRequest; + + private final boolean boosted; + private final String currentUrl; + private final boolean historyRestoreRequest; + private final String promptResponse; + private final String target; + private final String triggerName; + private final String triggerId; + + DHxRequest() { + this.htmxRequest = false; + this.boosted = false; + this.currentUrl = null; + this.historyRestoreRequest = false; + this.promptResponse = null; + this.target = null; + this.triggerName = null; + this.triggerId = null; + } + + DHxRequest(boolean boosted, String currentUrl, boolean historyRestoreRequest, String promptResponse, String target, String triggerName, String triggerId) { + this.htmxRequest = true; + this.boosted = boosted; + this.currentUrl = currentUrl; + this.historyRestoreRequest = historyRestoreRequest; + this.promptResponse = promptResponse; + this.target = target; + this.triggerName = triggerName; + this.triggerId = triggerId; + } + + @Override + public boolean isHtmxRequest() { + return htmxRequest; + } + + @Override + public boolean isBoosted() { + return boosted; + } + + @Nullable + @Override + public String currentUrl() { + return currentUrl; + } + + @Override + public boolean isHistoryRestoreRequest() { + return historyRestoreRequest; + } + + @Nullable + @Override + public String promptResponse() { + return promptResponse; + } + + @Nullable + @Override + public String target() { + return target; + } + + @Nullable + @Override + public String triggerName() { + return triggerName; + } + + @Nullable + public String triggerId() { + return triggerId; + } + + static final class DBuilder implements Builder { + + private boolean boosted; + private String currentUrl; + private boolean historyRestoreRequest; + private String promptResponse; + private String target; + private String triggerName; + private String triggerId; + + @Override + public DBuilder boosted(boolean boosted) { + this.boosted = boosted; + return this; + } + + @Override + public DBuilder currentUrl(String currentUrl) { + this.currentUrl = currentUrl; + return this; + } + + @Override + public DBuilder historyRestoreRequest(boolean historyRestoreRequest) { + this.historyRestoreRequest = historyRestoreRequest; + return this; + } + + @Override + public DBuilder promptResponse(String promptResponse) { + this.promptResponse = promptResponse; + return this; + } + + @Override + public DBuilder target(String target) { + this.target = target; + return this; + } + + @Override + public DBuilder triggerName(String triggerName) { + this.triggerName = triggerName; + return this; + } + + @Override + public DBuilder triggerId(String triggerId) { + this.triggerId = triggerId; + return this; + } + + @Override + public HtmxRequest build() { + return new DHxRequest(boosted, currentUrl, historyRestoreRequest, promptResponse, target, triggerName, triggerId); + } + } + +} diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/Html.java b/htmx-api/src/main/java/io/avaje/htmx/api/Html.java new file mode 100644 index 000000000..bedae0ded --- /dev/null +++ b/htmx-api/src/main/java/io/avaje/htmx/api/Html.java @@ -0,0 +1,18 @@ +package io.avaje.htmx.api; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Mark a controller as producing HTML by default and using "Templating" + * meaning that response objects are expected to a "Model View" passed to + * the "Templating" library. + */ +@Target(TYPE) +@Retention(RUNTIME) +public @interface Html { + +} diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java new file mode 100644 index 000000000..d8a47a22c --- /dev/null +++ b/htmx-api/src/main/java/io/avaje/htmx/api/HtmxRequest.java @@ -0,0 +1,106 @@ +package io.avaje.htmx.api; + +import io.avaje.lang.Nullable; + +/** + * This class can be used as a controller method argument to access + * the htmx Request Headers. + * + *
{@code
+ *
+ *   @HxRequest
+ *   @Get("/users")
+ *   String users(HtmxRequest htmxRequest) {
+ *     if (htmxRequest.isBoosted()) {
+ *         ...
+ *     }
+ *   }
+ *
+ * }
+ * + * @see Request Headers Reference + */ +public interface HtmxRequest { + + /** + * Represents a non-Htmx request. + */ + HtmxRequest EMPTY = new DHxRequest(); + + /** + * Return a new builder for the HtmxRequest. + */ + static Builder builder() { + return new DHxRequest.DBuilder(); + } + + /** + * Return true if this is an Htmx request. + */ + boolean isHtmxRequest(); + + /** + * Indicates that the request is via an element using hx-boost. + * + * @return true if the request was made via HX-Boost, false otherwise + */ + boolean isBoosted(); + + /** + * Return the current URL of the browser when the htmx request was made. + */ + @Nullable + String currentUrl(); + + /** + * Indicates if the request is for history restoration after a miss in the local history cache + * + * @return true if this request is for history restoration, false otherwise + */ + boolean isHistoryRestoreRequest(); + + /** + * Return the user response to an HX-Prompt. + */ + @Nullable + String promptResponse(); + + /** + * Return the id of the target element if it exists. + */ + @Nullable + String target(); + + /** + * Return the name of the triggered element if it exists. + */ + @Nullable + String triggerName(); + + /** + * Return the id of the triggered element if it exists. + */ + @Nullable + String triggerId(); + + /** + * Builder for {@link HtmxRequest}. + */ + interface Builder { + Builder boosted(boolean boosted); + + Builder currentUrl(String currentUrl); + + Builder historyRestoreRequest(boolean historyRestoreRequest); + + Builder promptResponse(String promptResponse); + + Builder target(String target); + + Builder triggerName(String triggerName); + + Builder triggerId(String triggerId); + + HtmxRequest build(); + } +} diff --git a/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java b/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java new file mode 100644 index 000000000..e643f31e5 --- /dev/null +++ b/htmx-api/src/main/java/io/avaje/htmx/api/HxRequest.java @@ -0,0 +1,54 @@ +package io.avaje.htmx.api; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Mark a controller method as handling Htmx requests and potentially restrict + * the handler to only be used for specific Htmx target or Htmx trigger. + *

+ * Controller methods with {@code @HxRequest} require the {@code HX-Request} + * HTTP Header to be set for the handler to process the request. Additionally, + * we can specify {@link #target()}, {@link #triggerId()}, or {@link #triggerName()} + * such that the handler is only invoked specifically for requests with those + * matching headers. + */ +@Target({TYPE, METHOD}) +@Retention(RUNTIME) +public @interface HxRequest { + + /** + * Restricts the mapping to the {@code id} of a specific target element. + * + * @see HX-Target + */ + String target() default ""; + + /** + * Restricts the mapping to the {@code id} of a specific triggered element. + * + * @see HX-Trigger + */ + String triggerId() default ""; + + /** + * Restricts the mapping to the {@code name} of a specific triggered element. + * + * @see HX-Trigger-Name + */ + String triggerName() default ""; + + /** + * Restricts the mapping to the {@code id}, if any, or to the {@code name} of a specific triggered element. + *

+ * If you want to be explicit use {@link #triggerId()} or {@link #triggerName()}. + * + * @see HX-Trigger + * @see HX-Trigger-Name + */ + String value() default ""; +} diff --git a/htmx-api/src/main/java/module-info.java b/htmx-api/src/main/java/module-info.java new file mode 100644 index 000000000..c018025a9 --- /dev/null +++ b/htmx-api/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module io.avaje.htmx.api { + + exports io.avaje.htmx.api; + + requires static io.avaje.lang; +} diff --git a/htmx-nima-jstache/pom.xml b/htmx-nima-jstache/pom.xml new file mode 100644 index 000000000..3305f71c0 --- /dev/null +++ b/htmx-nima-jstache/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + io.avaje + avaje-http-parent + 2.7 + + + avaje-htmx-nima-jstache + + + 21 + 21 + 21 + UTF-8 + false + 1.3.5 + + + + + io.jstach + jstachio + ${io.jstach.version} + + + io.avaje + avaje-htmx-api + ${project.version} + + + io.avaje + avaje-htmx-nima + ${project.version} + + + io.avaje + avaje-inject + 10.3 + provided + true + + + diff --git a/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java new file mode 100644 index 000000000..342e2bd45 --- /dev/null +++ b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/DefaultTemplateProvider.java @@ -0,0 +1,21 @@ +package io.avaje.htmx.nima.jstache; + +import io.avaje.htmx.nima.TemplateRender; +import io.avaje.inject.BeanScopeBuilder; +import io.avaje.inject.spi.Plugin; + +/** + * Plugin for avaje inject that provides a default TemplateRender instance. + */ +public final class DefaultTemplateProvider implements Plugin { + + @Override + public Class[] provides() { + return new Class[]{TemplateRender.class}; + } + + @Override + public void apply(BeanScopeBuilder builder) { + builder.provideDefault(null, TemplateRender.class, JStacheTemplateRender::new); + } +} diff --git a/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java new file mode 100644 index 000000000..beb4bff60 --- /dev/null +++ b/htmx-nima-jstache/src/main/java/io/avaje/htmx/nima/jstache/JStacheTemplateRender.java @@ -0,0 +1,15 @@ +package io.avaje.htmx.nima.jstache; + +import io.avaje.htmx.nima.TemplateRender; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; +import io.jstach.jstachio.JStachio; + +public final class JStacheTemplateRender implements TemplateRender { + + @Override + public void render(Object viewModel, ServerRequest req, ServerResponse res) { + var content = JStachio.render(viewModel); + res.send(content); + } +} diff --git a/htmx-nima-jstache/src/main/java/module-info.java b/htmx-nima-jstache/src/main/java/module-info.java new file mode 100644 index 000000000..2da4668b1 --- /dev/null +++ b/htmx-nima-jstache/src/main/java/module-info.java @@ -0,0 +1,11 @@ +module io.avaje.htmx.nima.jstache { + + exports io.avaje.htmx.nima.jstache; + + requires transitive io.avaje.htmx.nima; + requires transitive io.helidon.webserver; + requires transitive io.jstach.jstachio; + requires io.avaje.inject; + + provides io.avaje.inject.spi.Plugin with io.avaje.htmx.nima.jstache.DefaultTemplateProvider; +} diff --git a/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin b/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin new file mode 100644 index 000000000..456aa71f2 --- /dev/null +++ b/htmx-nima-jstache/src/main/resources/META-INF/services/io.avaje.inject.spi.Plugin @@ -0,0 +1 @@ +io.avaje.htmx.nima.jstache.DefaultTemplateProvider diff --git a/htmx-nima/pom.xml b/htmx-nima/pom.xml new file mode 100644 index 000000000..bc405cfec --- /dev/null +++ b/htmx-nima/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + io.avaje + avaje-http-parent + 2.7 + + + avaje-htmx-nima + + + 21 + false + UTF-8 + + + + + io.avaje + avaje-htmx-api + 2.7 + + + io.helidon.webserver + helidon-webserver + 4.0.7 + + + diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java new file mode 100644 index 000000000..6b44530a1 --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandler.java @@ -0,0 +1,50 @@ +package io.avaje.htmx.nima; + +import io.helidon.http.Header; +import io.helidon.http.ServerRequestHeaders; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import static io.avaje.htmx.nima.HxHeaders.*; + +final class DHxHandler implements Handler { + + + private final Handler delegate; + private final String target; + private final String trigger; + private final String triggerName; + + DHxHandler(Handler delegate, String target, String trigger, String triggerName) { + this.delegate = delegate; + this.target = target; + this.trigger = trigger; + this.triggerName = triggerName; + } + + @Override + public void handle(ServerRequest req, ServerResponse res) throws Exception { + final var headers = req.headers(); + if (headers.contains(HX_REQUEST) && matched(headers)) { + delegate.handle(req, res); + } else { + res.next(); + } + } + + private boolean matched(ServerRequestHeaders headers) { + if (target != null && notMatched(headers.get(HX_TARGET), target)) { + return false; + } + if (trigger != null && notMatched(headers.get(HX_TRIGGER), trigger)) { + return false; + } + return triggerName == null || !notMatched(headers.get(HX_TRIGGER_NAME), triggerName); + } + + private boolean notMatched(Header header, String matchValue) { + return header == null || !matchValue.equals(header.get()); + } + +} diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java new file mode 100644 index 000000000..b9370074c --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/DHxHandlerBuilder.java @@ -0,0 +1,38 @@ +package io.avaje.htmx.nima; + +import io.helidon.webserver.http.Handler; + +final class DHxHandlerBuilder implements HxHandler.Builder { + + private final Handler delegate; + private String target; + private String trigger; + private String triggerName; + + DHxHandlerBuilder(Handler delegate) { + this.delegate = delegate; + } + + @Override + public DHxHandlerBuilder target(String target) { + this.target = target; + return this; + } + + @Override + public DHxHandlerBuilder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + @Override + public DHxHandlerBuilder triggerName(String triggerName) { + this.triggerName = triggerName; + return this; + } + + @Override + public Handler build() { + return new DHxHandler(delegate, target, trigger, triggerName); + } +} diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java new file mode 100644 index 000000000..0c28dbbd5 --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHandler.java @@ -0,0 +1,46 @@ +package io.avaje.htmx.nima; + +import io.helidon.webserver.http.Handler; + +/** + * Wrap a Handler with filtering for Htmx specific headers. + *

+ * The underlying Handler will not be invoked unless the request + * is a Htmx request and matches the required attributes. + */ +public interface HxHandler { + + /** + * Create a builder that wraps the underlying handler with Htmx + * specific attribute matching. + */ + static Builder builder(Handler delegate) { + return new DHxHandlerBuilder(delegate); + } + + /** + * Build the Htmx request handler. + */ + interface Builder { + + /** + * Match on the given target. + */ + Builder target(String target); + + /** + * Match on the given trigger. + */ + Builder trigger(String trigger); + + /** + * Match on the given trigger name. + */ + Builder triggerName(String triggerName); + + /** + * Build and return the Handler. + */ + Handler build(); + } +} diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java new file mode 100644 index 000000000..156798d29 --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxHeaders.java @@ -0,0 +1,65 @@ +package io.avaje.htmx.nima; + +import io.helidon.http.HeaderName; +import io.helidon.http.HeaderNames; + +/** + * HTMX request headers. + * + * @see Request Headers Reference + */ +public interface HxHeaders { + + /** + * Indicates that the request comes from an element that uses hx-boost. + * + * @see HX-Boosted + */ + HeaderName HX_BOOSTED = HeaderNames.create("HX-Boosted"); + + /** + * The current URL of the browser + * + * @see HX-Current-URL + */ + HeaderName HX_CURRENT_URL = HeaderNames.create("HX-Current-URL"); + + /** + * Indicates if the request is for history restoration after a miss in the local history cache. + * + * @see HX-History-Restore-Request + */ + HeaderName HX_HISTORY_RESTORE_REQUEST = HeaderNames.create("HX-History-Restore-Request"); + + /** + * Contains the user response to a hx-prompt. + * + * @see HX-Prompt + */ + HeaderName HX_PROMPT = HeaderNames.create("HX-Prompt"); + /** + * Only present and {@code true} if the request is issued by htmx. + * + * @see HX-Request + */ + HeaderName HX_REQUEST = HeaderNames.create("HX-Request"); + /** + * The {@code id} of the target element if it exists. + * + * @see HX-Target + */ + HeaderName HX_TARGET = HeaderNames.create("HX-Target"); + /** + * The {@code name} of the triggered element if it exists + * + * @see HX-Trigger-Name + */ + HeaderName HX_TRIGGER_NAME = HeaderNames.create("HX-Trigger-Name"); + /** + * The {@code id} of the triggered element if it exists. + * + * @see HX-Trigger + */ + HeaderName HX_TRIGGER = HeaderNames.create("HX-Trigger"); + +} diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java new file mode 100644 index 000000000..2dda4c675 --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/HxReq.java @@ -0,0 +1,49 @@ +package io.avaje.htmx.nima; + +import io.avaje.htmx.api.HtmxRequest; +import io.helidon.webserver.http.ServerRequest; + +/** + * Obtain the HtmxRequest for the given Helidon ServerRequest. + */ +public class HxReq { + + /** + * Create given the server request. + */ + public static HtmxRequest of(ServerRequest request) { + final var headers = request.headers(); + if (!headers.contains(HxHeaders.HX_REQUEST)) { + return HtmxRequest.EMPTY; + } + + var builder = HtmxRequest.builder(); + if (headers.contains(HxHeaders.HX_BOOSTED)) { + builder.boosted(true); + } + if (headers.contains(HxHeaders.HX_HISTORY_RESTORE_REQUEST)) { + builder.historyRestoreRequest(true); + } + var currentUrl = headers.get(HxHeaders.HX_CURRENT_URL); + if (currentUrl != null) { + builder.currentUrl(currentUrl.get()); + } + var prompt = headers.get(HxHeaders.HX_PROMPT); + if (prompt != null) { + builder.promptResponse(prompt.get()); + } + var target = headers.get(HxHeaders.HX_TARGET); + if (target != null) { + builder.target(target.get()); + } + var triggerName = headers.get(HxHeaders.HX_TRIGGER_NAME); + if (triggerName != null) { + builder.triggerName(triggerName.get()); + } + var trigger = headers.get(HxHeaders.HX_TRIGGER); + if (trigger != null) { + builder.triggerId(trigger.get()); + } + return builder.build(); + } +} diff --git a/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java b/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java new file mode 100644 index 000000000..4cc1a62fa --- /dev/null +++ b/htmx-nima/src/main/java/io/avaje/htmx/nima/TemplateRender.java @@ -0,0 +1,16 @@ +package io.avaje.htmx.nima; + + +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +/** + * Template render API for Helidon. + */ +public interface TemplateRender { + + /** + * Render the given template view model to the server response. + */ + void render(Object viewModel, ServerRequest req, ServerResponse res); +} diff --git a/htmx-nima/src/main/java/module-info.java b/htmx-nima/src/main/java/module-info.java new file mode 100644 index 000000000..8adfb5b45 --- /dev/null +++ b/htmx-nima/src/main/java/module-info.java @@ -0,0 +1,7 @@ +module io.avaje.htmx.nima { + + requires io.avaje.htmx.api; + requires io.helidon.webserver; + + exports io.avaje.htmx.nima; +} diff --git a/http-generator-core/pom.xml b/http-generator-core/pom.xml index 817ca4211..2f386802c 100644 --- a/http-generator-core/pom.xml +++ b/http-generator-core/pom.xml @@ -26,6 +26,14 @@ provided + + io.avaje + avaje-htmx-api + ${project.version} + true + provided + + io.swagger.core.v3 swagger-annotations diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java index 3914821cc..12e89fdd6 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/ControllerReader.java @@ -47,6 +47,8 @@ public final class ControllerReader { private final String producesPrism; private final boolean hasValid; + /** Set true via {@code @Html} to indicate use of Templating */ + private final boolean html; private boolean methodHasValid; /** @@ -70,7 +72,8 @@ public ControllerReader(TypeElement beanType, String contextPath) { docHidden = initDocHidden(); } this.hasValid = initHasValid(); - this.producesPrism = initProduces(); + this.html = initHtml(); + this.producesPrism = initProduces(html); this.apiResponses = buildApiResponses(); hasInstrument = instrumentAllWebMethods() @@ -172,8 +175,13 @@ private boolean matchMethod(ExecutableElement interfaceMethod, ExecutableElement return interfaceMethod.toString().equals(element.toString()); } - private String initProduces() { - return findAnnotation(ProducesPrism::getOptionalOn).map(ProducesPrism::value).orElse(null); + private boolean initHtml() { + return findAnnotation(HtmlPrism::getOptionalOn).isPresent(); + } + + private String initProduces(boolean html) { + String defaultProduces = html ? "text/html;charset=UTF8" : null; + return findAnnotation(ProducesPrism::getOptionalOn).map(ProducesPrism::value).orElse(defaultProduces); } private boolean initDocHidden() { @@ -188,6 +196,10 @@ String produces() { return producesPrism; } + public boolean html() { + return html; + } + public TypeElement beanType() { return beanType; } diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java index 2ffa86e20..e679f267e 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/MethodReader.java @@ -43,6 +43,7 @@ public class MethodReader { private final boolean hasValid; private final List superMethods; private final Optional timeout; + private final HxRequestPrism hxRequest; private WebMethod webMethod; private int statusCode; @@ -77,6 +78,7 @@ public class MethodReader { this.securityRequirements = readSecurityRequirements(); this.apiResponses = buildApiResponses(); this.javadoc = buildJavadoc(element); + this.hxRequest = HxRequestPrism.getInstanceOn(element); this.timeout = RequestTimeoutPrism.getOptionalOn(element); timeout.ifPresent( p -> { @@ -190,6 +192,13 @@ private void initSetWebMethod(WebMethod webMethod, ExceptionHandlerPrism excepti bean.addImportType(exType); } + /** + * Return the Htmx request annotation for this method. + */ + public HxRequestPrism hxRequest() { + return hxRequest; + } + public Javadoc javadoc() { return javadoc; } diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java index 129ae8cc7..efdbc2609 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/package-info.java @@ -34,6 +34,8 @@ @GeneratePrism(value = io.swagger.v3.oas.annotations.Hidden.class, publicAccess = true) @GeneratePrism(value = io.avaje.http.api.Client.Import.class, publicAccess = true) @GeneratePrism(value = io.avaje.http.api.RequestTimeout.class, publicAccess = true) +@GeneratePrism(value = io.avaje.htmx.api.HxRequest.class, publicAccess = true) +@GeneratePrism(value = io.avaje.htmx.api.Html.class, publicAccess = true) package io.avaje.http.generator.core; import io.avaje.prism.GeneratePrism; diff --git a/http-generator-core/src/main/java/module-info.java b/http-generator-core/src/main/java/module-info.java index 0d5787e3f..f5142424b 100644 --- a/http-generator-core/src/main/java/module-info.java +++ b/http-generator-core/src/main/java/module-info.java @@ -10,6 +10,7 @@ // SHADED: All content after this line will be removed at package time requires static io.avaje.prism; requires static io.avaje.http.api; + requires static io.avaje.htmx.api; requires static io.swagger.v3.oas.models; requires static io.swagger.v3.oas.annotations; requires static java.validation; diff --git a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java index 8a31e3a51..ee7bf699b 100644 --- a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java +++ b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java @@ -7,14 +7,7 @@ import java.util.Map; import java.util.Optional; -import io.avaje.http.generator.core.Append; -import io.avaje.http.generator.core.CoreWebMethod; -import io.avaje.http.generator.core.MethodParam; -import io.avaje.http.generator.core.MethodReader; -import io.avaje.http.generator.core.ParamType; -import io.avaje.http.generator.core.PathSegments; -import io.avaje.http.generator.core.UType; -import io.avaje.http.generator.core.WebMethod; +import io.avaje.http.generator.core.*; import io.avaje.http.generator.core.openapi.MediaType; import javax.lang.model.type.TypeMirror; @@ -67,8 +60,10 @@ final class ControllerMethodWriter { private final boolean useJsonB; private final boolean instrumentContext; private final boolean isFilter; + private final ControllerReader reader; - ControllerMethodWriter(MethodReader method, Append writer, boolean useJsonB) { + ControllerMethodWriter(MethodReader method, Append writer, boolean useJsonB, ControllerReader reader) { + this.reader = reader; this.method = method; this.writer = writer; this.webMethod = method.webMethod(); @@ -92,10 +87,35 @@ void writeRule() { } else if (isFilter) { writer.append(" routing.addFilter(this::_%s);", method.simpleName()).eol(); } else { - writer.append(" routing.%s(\"%s\", this::_%s);", webMethod.name().toLowerCase(), method.fullPath().replace("\\", "\\\\"), method.simpleName()).eol(); + writer.append(" routing.%s(\"%s\", ", webMethod.name().toLowerCase(), method.fullPath().replace("\\", "\\\\")); + var hxRequest = method.hxRequest(); + if (hxRequest != null) { + writer.append("HxHandler.builder(this::_%s)", method.simpleName()); + if (hasValue(hxRequest.target())) { + writer.append(".target(\"%s\")", hxRequest.target()); + } + if (hasValue(hxRequest.triggerId())) { + writer.append(".trigger(\"%s\")", hxRequest.triggerId()); + } else if (hasValue(hxRequest.value())) { + writer.append(".trigger(\"%s\")", hxRequest.value()); + } + if (hasValue(hxRequest.triggerName())) { + writer.append(".triggerName(\"%s\")", hxRequest.triggerName()); + } else if (hasValue(hxRequest.value())) { + writer.append(".triggerName(\"%s\")", hxRequest.value()); + } + writer.append(".build());").eol(); + + } else { + writer.append("this::_%s);", method.simpleName()).eol(); + } } } + private static boolean hasValue(String value) { + return value != null && !value.isBlank(); + } + void writeHandler(boolean requestScoped) { if (method.isErrorMethod()) { writer.append(" private void _%s(ServerRequest req, ServerResponse res, %s ex) {", method.simpleName(), method.exceptionShortName()).eol(); @@ -205,6 +225,8 @@ void writeHandler(boolean requestScoped) { final var uType = UType.parse(method.returnType()); writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol(); } + } else if (useTemplating()) { + writer.append(indent).append("renderer.render(result, req, res);").eol(); } else { writer.append(indent).append("res.send(result);").eol(); } @@ -215,6 +237,12 @@ void writeHandler(boolean requestScoped) { writer.append(" }").eol().eol(); } + private boolean useTemplating() { + return reader.html() + && !"byte[]".equals(method.returnType().toString()) + && (method.produces() == null || method.produces().toLowerCase().contains("html")); + } + private static boolean isExceptionOrFilterChain(MethodParam param) { return isAssignable2Interface(param.utype().mainType(), "java.lang.Exception") || "FilterChain".equals(param.shortType()); diff --git a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java index e192f9d05..e3254ba39 100644 --- a/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java +++ b/http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerWriter.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Objects; import io.avaje.http.generator.core.BaseControllerWriter; import io.avaje.http.generator.core.Constants; @@ -59,6 +60,14 @@ class ControllerWriter extends BaseControllerWriter { reader.addImportType("io.helidon.webserver.http.RoutingRequest"); reader.addImportType("io.helidon.webserver.http.RoutingResponse"); } + if (reader.methods().stream() + .map(MethodReader::hxRequest) + .anyMatch(Objects::nonNull)) { + reader.addImportType("io.avaje.htmx.nima.HxHandler"); + } + if (reader.html()) { + reader.addImportType("io.avaje.htmx.nima.TemplateRender"); + } } void write() { @@ -80,7 +89,7 @@ protected void writeImports() { private List writerMethods() { return reader.methods().stream() .filter(MethodReader::isWebMethod) - .map(it -> new ControllerMethodWriter(it, writer, useJsonB)) + .map(it -> new ControllerMethodWriter(it, writer, useJsonB, reader)) .toList(); } @@ -126,10 +135,12 @@ private void writeClassStart() { if (reader.isIncludeValidator()) { writer.append(" private final Validator validator;").eol(); } - if (instrumentContext) { writer.append(" private final RequestContextResolver resolver;").eol(); } + if (reader.html()) { + writer.append(" private final TemplateRender renderer;").eol(); + } for (final UType type : jsonTypes.values()) { if (!isInputStream(type.full())) { @@ -146,6 +157,9 @@ private void writeClassStart() { if (useJsonB) { writer.append(", Jsonb jsonb"); } + if (reader.html()) { + writer.append(", TemplateRender renderer"); + } if (instrumentContext) { writer.append(", RequestContextResolver resolver"); } @@ -155,6 +169,9 @@ private void writeClassStart() { if (reader.isIncludeValidator()) { writer.append(" this.validator = validator;").eol(); } + if (reader.html()) { + writer.append(" this.renderer = renderer;").eol(); + } if (instrumentContext) { writer.append(" this.resolver = resolver;").eol(); } @@ -176,6 +193,6 @@ private void writeClassStart() { } private boolean isInputStream(String type) { - return isAssignable2Interface(type.toString(), "java.io.InputStream"); + return isAssignable2Interface(type, "java.io.InputStream"); } } diff --git a/pom.xml b/pom.xml index 34d114bc1..4bfa390f0 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,7 @@ + htmx-api http-api http-api-javalin http-client @@ -63,9 +64,20 @@ [21,22] + htmx-nima + htmx-nima-jstache http-generator-helidon + + test21 + + htmx-nima + htmx-nima-jstache + http-generator-helidon + tests + + module-info.shade diff --git a/tests/pom.xml b/tests/pom.xml index c4173ff3a..449c3b843 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -38,6 +38,7 @@ test-nima test-nima-jsonb + test-nima-htmx diff --git a/tests/test-nima-htmx/pom.xml b/tests/test-nima-htmx/pom.xml new file mode 100644 index 000000000..2316c1bb5 --- /dev/null +++ b/tests/test-nima-htmx/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + io.avaje + tests + 2.7 + + + test-nima-htmx + + + 21 + UTF-8 + false + 1.3.5 + + + + + io.avaje + avaje-inject + ${avaje-inject.version} + + + io.avaje + avaje-htmx-nima-jstache + ${project.version} + + + io.jstach + jstachio + ${io.jstach.version} + + + io.avaje + avaje-htmx-api + ${project.version} + + + io.avaje + avaje-htmx-nima + ${project.version} + + + io.avaje + avaje-nima + 1.0 + + + + + io.avaje + avaje-nima-test + 1.0 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + + + io.avaje + avaje-http-helidon-generator + ${project.version} + + + io.avaje + avaje-inject-generator + ${avaje-inject.version} + + + io.avaje + avaje-jsonb-generator + 1.11 + + + io.jstach + jstachio-apt + ${io.jstach.version} + + + + + + + + + + diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java new file mode 100644 index 000000000..1b6d205ad --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/Main.java @@ -0,0 +1,15 @@ +package org.example.htmx; + +import io.avaje.inject.InjectModule; +import io.avaje.nima.Nima; + +@InjectModule(name = "hxTest") +public class Main { + + public static void main(String[] args) { + Nima.builder() + .port(8090) + .build() + .start(); + } +} diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java new file mode 100644 index 000000000..05c59ded0 --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/UIController.java @@ -0,0 +1,29 @@ +package org.example.htmx; + +import io.avaje.htmx.api.Html; +import io.avaje.htmx.api.HxRequest; +import io.avaje.http.api.Controller; +import io.avaje.http.api.Get; +import io.avaje.http.api.Path; + +import java.time.Instant; +import java.util.List; + +@Html +@Controller +@Path("/") +public class UIController { + + @Get + ViewHome index() { + return new ViewHome("Robin3"); + } + + @HxRequest(target = "name") + @Get("name") + ViewName name() { + var mlist = List.of("one","two","three", "four"); + return new ViewName("JimBolin", Instant.now(), "MoreMeMore", mlist); + } + +} diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java new file mode 100644 index 000000000..607dc690b --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewHome.java @@ -0,0 +1,7 @@ +package org.example.htmx; + +import io.jstach.jstache.JStache; + +@JStache(path = "home") +public record ViewHome(String name) { +} diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java new file mode 100644 index 000000000..2cda3b0cf --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/ViewName.java @@ -0,0 +1,13 @@ +package org.example.htmx; + +import io.jstach.jstache.JStache; + +import java.time.Instant; +import java.util.List; + +@JStache(path = "name") +public record ViewName(String name, Instant foo, String more, List mlist) { + public String when() { + return foo.toString(); + } +} diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java new file mode 100644 index 000000000..a0e8d10d8 --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/package-info.java @@ -0,0 +1,4 @@ +@JStachePath(prefix = "ui/", suffix = ".mustache") +package org.example.htmx; + +import io.jstach.jstache.JStachePath; diff --git a/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java b/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java new file mode 100644 index 000000000..4acb8ed00 --- /dev/null +++ b/tests/test-nima-htmx/src/main/java/org/example/htmx/template/JstacheTemplateRender.java @@ -0,0 +1,17 @@ +package org.example.htmx.template; + +import io.avaje.htmx.nima.TemplateRender; +import io.avaje.inject.Component; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; +import io.jstach.jstachio.JStachio; + +@Component +public class JstacheTemplateRender implements TemplateRender { + + @Override + public void render(Object viewModel, ServerRequest req, ServerResponse res) { + String content = JStachio.render(viewModel); + res.send(content); + } +} diff --git a/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache b/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache new file mode 100644 index 000000000..26a8c9e85 --- /dev/null +++ b/tests/test-nima-htmx/src/main/resources/ui/fragments/layout.mustache @@ -0,0 +1,11 @@ + + + + Hi + + + +

Heading

+{{$body}}Empty body{{/body}} + + diff --git a/tests/test-nima-htmx/src/main/resources/ui/home.mustache b/tests/test-nima-htmx/src/main/resources/ui/home.mustache new file mode 100644 index 000000000..af4686584 --- /dev/null +++ b/tests/test-nima-htmx/src/main/resources/ui/home.mustache @@ -0,0 +1,8 @@ +{{ + Hi there {{ name }} + + +{{/body}} +{{/fragments/layout}} diff --git a/tests/test-nima-htmx/src/main/resources/ui/name.mustache b/tests/test-nima-htmx/src/main/resources/ui/name.mustache new file mode 100644 index 000000000..4c2f0f851 --- /dev/null +++ b/tests/test-nima-htmx/src/main/resources/ui/name.mustache @@ -0,0 +1,6 @@ +
+Yond {{ name }} its {{ when }} !! and {{ more }} sad + {{#mlist}} + in {{.}} ot + {{/mlist}} +
diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java b/tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java similarity index 92% rename from tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java rename to tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java index 47fb7479c..dcc7aa3ec 100644 --- a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/PathNestController.java +++ b/tests/test-nima-jsonb/src/main/java/org/example/htmx/PathNestController.java @@ -1,4 +1,4 @@ -package org.example.path.nest; +package org.example.htmx; import io.avaje.http.api.Controller; import io.avaje.http.api.Get; diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java b/tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java similarity index 61% rename from tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java rename to tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java index 73f306ff6..d9695fcaa 100644 --- a/tests/test-nima-jsonb/src/main/java/org/example/path/nest/package-info.java +++ b/tests/test-nima-jsonb/src/main/java/org/example/htmx/package-info.java @@ -1,5 +1,5 @@ @Path("nested") -package org.example.path.nest; +package org.example.htmx; import io.avaje.http.api.Path; diff --git a/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java b/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java index 5d1a3f970..2421bd26e 100644 --- a/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java +++ b/tests/test-nima-jsonb/src/main/java/org/example/path/PathTestController.java @@ -1,6 +1,6 @@ package org.example.path; -import org.example.path.nest.PathNestController.NestedTypeResponse; +import org.example.htmx.PathNestController.NestedTypeResponse; import io.avaje.http.api.Controller; import io.avaje.http.api.Get;