diff --git a/docs/asciidoc/modules/htmx.adoc b/docs/asciidoc/modules/htmx.adoc new file mode 100644 index 0000000000..ea036243bd --- /dev/null +++ b/docs/asciidoc/modules/htmx.adoc @@ -0,0 +1,191 @@ +== HTMX + +https://htmx.org[HTMX] first-class support for Jooby. + +The HTMX module provides a seamless bridge between modern, reactive Single Page Application (SPA) mechanics and traditional server-side rendering. It offers both a memory-safe Imperative Builder and a powerful Declarative Annotation API (via APT) to orchestrate HTMX responses without repetitive boilerplate. + +*Note:* `HtmxTemplateEngine` acts as a composite delegator. You must also install a backing template engine (like Handlebars, Freemarker, or Pebble) to actually render the views. + +=== Usage + +1) Add the dependencies (HTMX and your preferred template engine): + +[dependency, artifactId="jooby-htmx, jooby-handlebars:Handlebars Module"] +. + +2) Write your templates inside the `views` folder. Notice how the layout dynamically embeds the requested partial using `childView`. + +.views/layout.hbs +[source, html] +---- + + + + +
+ {{> (lookup childView) }} +
+ + +---- + +.views/tasks.hbs +[source, html] +---- + +---- + +3) Install the module and write your controller. + +.Java +[source, java, role="primary"] +---- +import io.jooby.htmx.HtmxModule; +import io.jooby.handlebars.HandlebarsModule; +import io.jooby.annotation.htmx.HxView; + +{ + install(new HandlebarsModule()); <1> + install(new HtmxModule()); <2> + + mvc(new TaskUIHtmx_()); <3> +} + +public class TaskUI { + + @GET("/tasks") + @HxView(value = "tasks.hbs", layout = "layout.hbs") + public Map getTasks() { + return Map.of("tasks", List.of(new Task("Buy milk"))); + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.htmx.HtmxModule +import io.jooby.handlebars.HandlebarsModule +import io.jooby.annotation.htmx.HxView + +{ + install(HandlebarsModule()) <1> + install(HtmxModule()) <2> + + mvc(TaskUIHtmx_()) <3> +} + +class TaskUI { + + @GET("/tasks") + @HxView(value = "tasks.hbs", layout = "layout.hbs") + fun getTasks(): Map { + return mapOf("tasks" to listOf(Task("Buy milk"))) + } +} +---- + +<1> Install your base template engine +<2> Install the HTMX engine +<3> Add generated `Htmx_` controller + +=== The SPA Shell Layout Engine + +The `@HxView` annotation implements a secure, Fail-Fast Guard Clause for layout management. + +When you define a `layout` attribute, the framework intelligently checks the origin of the request: + +* **HTMX AJAX Requests:** The layout is ignored. The framework responds only with the fast, targeted partial view (`tasks.hbs`). +* **Direct Browser Requests (F5 / Bookmarks):** The framework intercepts the request, blocks the raw fragment from rendering, and automatically injects the partial inside your defined `layout.hbs` (passed as the `childView` attribute). + +If a method returns a dynamic HTMX fragment but *lacks* a layout, direct browser access is automatically blocked via a `406 Not Acceptable` exception. + +=== Declarative API (Annotations) + +When using Jooby's MVC routes, you can orchestrate complex UI state entirely through annotations: + +.Java +[source, java] +---- +@POST("/tasks") +@HxView("task_row.hbs") +@HxOob("task_counter.hbs") // Automatically appends an Out-Of-Band swap +@HxTrigger("taskAdded") // Triggers a client-side JS event +@HxError("task_error.hbs") // Scoped Error Handler: Catches validation errors +public Task addTask(@Valid TaskDto dto) { + return db.save(dto); +} +---- + +==== Scoped Error Handling & Validation +The `@HxError` annotation acts as a "UI Janitor" for **Scoped Errors** (such as HTTP 400 Bad Request or 422 Unprocessable Entity). If Bean Validation fails, it catches the exception and renders your targeted error template. + +* **Validation Integration:** The model passed to your error template automatically includes a `validationResult` object that perfectly follows the `io.jooby.validation.ValidationResult` format. This allows seamless integration with Jooby's Jakarta validation modules (`hibernate-validator` or `avaje-validator`). +* **Auto-Clearing:** Crucially, on a *successful* request, the framework automatically appends an empty OOB swap for the error template, instantly clearing the UI of any previous error messages. + +=== Imperative API (HtmxResponse) + +For scenarios lacking a primary view (like a `DELETE` operation), use the fluent `HtmxResponse` builder to explicitly chain events, headers, and OOB updates. + +.Java +[source, java, role="primary"] +---- +@DELETE("/tasks/{id}") +public HtmxResponse deleteTask(@PathParam String id) { + db.delete(id); + + return HtmxResponse.empty() + .addOob("task_counter.hbs", Map.of("activeCount", db.getActiveCount())) + .triggerAfterSettle("showToast", Map.of("message", "Task deleted!")); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +@DELETE("/tasks/{id}") +fun deleteTask(@PathParam id: String): HtmxResponse { + db.delete(id) + + return HtmxResponse.empty() + .addOob("task_counter.hbs", mapOf("activeCount" to db.getActiveCount())) + .triggerAfterSettle("showToast", mapOf("message" to "Task deleted!")) +} +---- + +=== Global Error Handling + +While `@HxError` handles scoped validation, you can seamlessly convert **Global Application Errors** (like 500 Server Crashes) into graceful HTMX responses (like OOB toast notifications) by passing a custom `HtmxErrorHandler` to the module during installation. + +**Smart Interception:** This global handler is highly intelligent. It *only* intercepts requests that contain the `HX-Request: true` header. If a standard browser request crashes (e.g., a normal page load or hitting F5), this handler is safely bypassed, and the default Jooby global application error handler takes over to display a standard error page. + +.Java +[source, java, role="primary"] +---- +import io.jooby.htmx.HtmxModule; + +{ + install(new HtmxModule((ctx, cause, code) -> { + // Convert the crash into a safe UI notification without breaking the DOM + return HtmxResponse.empty(code) + .addOob("toast.hbs", Map.of("error", cause.getMessage())); + })); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.htmx.HtmxModule + +{ + install(HtmxModule { ctx, cause, code -> + HtmxResponse.empty(code) + .addOob("toast.hbs", mapOf("error" to cause.message)) + }) +} +---- diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 5295fbe2a1..4c72696387 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -55,10 +55,11 @@ Modules are distributed as separate dependencies. Below is the catalog of offici * link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports. ==== Template Engine + * link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine. * link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine. + * link:{uiVersion}/modules/htmx[HTMX]: First-class HTMX support with declarative annotations and SPA layout management. * link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine. * link:{uiVersion}/modules/jte[jte]: jte template engine. - * link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine. * link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine. * link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine. * link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine. diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index a6680b2fb7..cb681d5a97 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -776,6 +776,11 @@ public ServiceRegistry getServices() { return this.router.getServices(); } + @Override + public List getTemplateEngines() { + return this.router.getTemplateEngines(); + } + /** * Get base application package. This is the package from where application was initialized or the * package of a Jooby application sub-class. diff --git a/jooby/src/main/java/io/jooby/ModelAndView.java b/jooby/src/main/java/io/jooby/ModelAndView.java index 91e08cc0e4..819073f122 100644 --- a/jooby/src/main/java/io/jooby/ModelAndView.java +++ b/jooby/src/main/java/io/jooby/ModelAndView.java @@ -88,12 +88,13 @@ public static MapModelAndView map(String view, Map model) { * any other object. * @return A {@code ModelAndView} instance corresponding to the specified view and model. */ - public static ModelAndView> of(String view, Object model) { + @SuppressWarnings({"unchecked", "rawtypes"}) + public static ModelAndView of(String view, @Nullable Object model) { if (model == null) { - return map(view); + return (ModelAndView) map(view); } if (model instanceof Map mapModel) { - return map(view, mapModel); + return (ModelAndView) map(view, mapModel); } return new ModelAndView(view, model); } diff --git a/jooby/src/main/java/io/jooby/Router.java b/jooby/src/main/java/io/jooby/Router.java index 0a0af59ef9..940a7ff7a4 100644 --- a/jooby/src/main/java/io/jooby/Router.java +++ b/jooby/src/main/java/io/jooby/Router.java @@ -879,6 +879,13 @@ default Executor executor(String name) { */ ValueFactory getValueFactory(); + /** + * Retrieves a list of available template engines. + * + * @return a list of TemplateEngine objects representing the available template engines. + */ + List getTemplateEngines(); + /** * Set value factory, useful for custom value factory. * diff --git a/jooby/src/main/java/io/jooby/TemplateEngine.java b/jooby/src/main/java/io/jooby/TemplateEngine.java index c7f9f792f7..2dfdec8ada 100644 --- a/jooby/src/main/java/io/jooby/TemplateEngine.java +++ b/jooby/src/main/java/io/jooby/TemplateEngine.java @@ -18,6 +18,8 @@ * @author edgar */ public interface TemplateEngine extends MessageEncoder { + /** Just a template engine that is on top of the stack (run before all other engines). */ + interface OnTop extends TemplateEngine {} /** Name of application property that defines the template path. */ String TEMPLATE_PATH = "templates.path"; diff --git a/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java index 06dd2acea0..327ff9bd87 100644 --- a/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java +++ b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java @@ -30,7 +30,12 @@ public class HttpMessageEncoder implements MessageEncoder { public HttpMessageEncoder add(MediaType type, MessageEncoder encoder) { if (encoder instanceof TemplateEngine engine) { // Media type is ignored for template engines. They have a custom object type - templateEngineList.add(engine); + if (engine instanceof TemplateEngine.OnTop) { + // need to go first + templateEngineList.addFirst(engine); + } else { + templateEngineList.add(engine); + } } else { if (encoders == null) { encoders = new LinkedHashMap<>(); @@ -106,4 +111,8 @@ public Output encode(Context ctx, Object value) throws Exception { return MessageEncoder.TO_STRING.encode(ctx, value); } } + + public List getTemplateEngines() { + return templateEngineList; + } } diff --git a/jooby/src/main/java/io/jooby/internal/RouterImpl.java b/jooby/src/main/java/io/jooby/internal/RouterImpl.java index 3ab9f7f5e7..e15ca6b29e 100644 --- a/jooby/src/main/java/io/jooby/internal/RouterImpl.java +++ b/jooby/src/main/java/io/jooby/internal/RouterImpl.java @@ -816,6 +816,10 @@ public Router setCurrentUser(Function provider) { return this; } + public List getTemplateEngines() { + return Collections.unmodifiableList(encoder.getTemplateEngines()); + } + @Override public String toString() { StringBuilder buff = new StringBuilder(); diff --git a/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java b/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java index cef59bbad3..05b12fe49a 100644 --- a/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java +++ b/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java @@ -78,7 +78,7 @@ public ValidationExceptionChain add(ValidationExceptionMapper mapper) { // Assume is a client error, provide a default result return new ValidationResult( "Validation failed", - suggestedCode.value(), + StatusCode.UNPROCESSABLE_ENTITY.value(), List.of( new ValidationResult.Error( null, diff --git a/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java b/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java index 5c93184521..89f801b455 100644 --- a/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java +++ b/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java @@ -63,7 +63,7 @@ void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError() assertNotNull(result); assertEquals("Validation failed", result.getTitle()); - assertEquals(400, result.getStatus()); + assertEquals(422, result.getStatus()); assertEquals(1, result.getErrors().size()); ValidationResult.Error error = result.getErrors().get(0); @@ -82,7 +82,7 @@ void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() { assertNotNull(result); assertEquals("Validation failed", result.getTitle()); - assertEquals(400, result.getStatus()); + assertEquals(422, result.getStatus()); assertEquals(1, result.getErrors().size()); ValidationResult.Error error = result.getErrors().get(0); diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 20f4bff3e8..a260333b34 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -45,6 +45,13 @@ test + + io.jooby + jooby-htmx + ${jooby.version} + test + + io.jooby jooby-jsonrpc diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index c380a2c0ed..ce89f664e4 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -130,17 +130,20 @@ public boolean process(Set annotations, RoundEnvironment context.getRouters().forEach(it -> context.debug(" %s", it.getGeneratedType())); return false; } else { - // Discover all unique Controller classes var controllers = findControllers(annotations, roundEnv); - - // Factory Pattern: Build specific routers for each class based on method annotations List> activeRouters = new ArrayList<>(); + for (var controller : controllers) { if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue; - var restRouter = RestRouter.parse(context, controller); - if (!restRouter.isEmpty()) { - activeRouters.add(restRouter); + // --- PASS 1: Specialized Routers & Claim Gathering --- + Set masterClaimedRoutes = new HashSet<>(); + + // Parse HTMX first to claim route paths + var htmxRouter = io.jooby.internal.apt.htmx.HtmxRouter.parse(context, controller); + if (!htmxRouter.isEmpty()) { + activeRouters.add(htmxRouter); + masterClaimedRoutes.addAll(htmxRouter.getClaimedRoutes()); } var jsonRpcRouter = JsonRpcRouter.parse(context, controller); @@ -162,6 +165,13 @@ public boolean process(Set annotations, RoundEnvironment if (!wsRouter.isEmpty()) { activeRouters.add(wsRouter); } + + // --- PASS 2: Standard Rest Router (Fallback) --- + // Pass the claimed routes to RestRouter so it knows what to skip + var restRouter = RestRouter.parse(context, controller, masterClaimedRoutes); + if (!restRouter.isEmpty()) { + activeRouters.add(restRouter); + } } verifyBeanValidationDependency(activeRouters); @@ -288,6 +298,20 @@ public Set getSupportedAnnotationTypes() { supportedTypes.add("io.jooby.annotation.ws.OnClose"); supportedTypes.add("io.jooby.annotation.ws.OnMessage"); supportedTypes.add("io.jooby.annotation.ws.OnError"); + // Add Htmx Annotations + supportedTypes.addAll( + Set.of( + "io.jooby.annotation.htmx.HxView", + "io.jooby.annotation.htmx.HxError", + "io.jooby.annotation.htmx.HxOob", + "io.jooby.annotation.htmx.HxOobs", + "io.jooby.annotation.htmx.HxPushUrl", + "io.jooby.annotation.htmx.HxRedirect", + "io.jooby.annotation.htmx.HxRefresh", + "io.jooby.annotation.htmx.HxSwap", + "io.jooby.annotation.htmx.HxTarget", + "io.jooby.annotation.htmx.HxTrigger", + "io.jooby.annotation.htmx.HxTriggers")); return supportedTypes; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java index 8659360b3c..0dbd21a786 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -8,6 +8,7 @@ import static io.jooby.internal.apt.CodeBlock.*; import java.io.IOException; +import java.util.Set; import java.util.stream.Collectors; import javax.lang.model.element.ElementKind; @@ -19,7 +20,8 @@ public RestRouter(MvcContext context, TypeElement clazz) { super(context, clazz); } - public static RestRouter parse(MvcContext context, TypeElement controller) { + public static RestRouter parse( + MvcContext context, TypeElement controller, Set claimedRoutes) { var router = new RestRouter(context, controller); for (var type : context.superTypes(controller)) { @@ -36,6 +38,21 @@ public static RestRouter parse(MvcContext context, TypeElement controller) { var annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); if (HttpMethod.hasAnnotation(annoElement)) { + // Check if the current route is claimed by a specialized router (e.g., HTMX) + var httpMethod = + HttpMethod.findByAnnotationName(annoElement.getQualifiedName().toString()); + var paths = context.path(controller, method, annoElement); + + boolean isClaimed = + paths.stream() + .map(path -> httpMethod + WebRoute.leadingSlash(path)) + .anyMatch(claimedRoutes::contains); + + // If HTMX claimed it, skip generating a REST route for it! + if (isClaimed) { + continue; + } + var route = new RestRoute(router, method, annoElement); var uniqueKey = method.toString() + annoElement.getSimpleName(); router.routes.putIfAbsent(uniqueKey, route); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java new file mode 100644 index 0000000000..5423bd61ba --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java @@ -0,0 +1,671 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.htmx; + +import static io.jooby.internal.apt.CodeBlock.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; + +import io.jooby.internal.apt.AnnotationSupport; +import io.jooby.internal.apt.CodeBlock; +import io.jooby.internal.apt.WebRoute; + +public class HtmxRoute extends WebRoute { + + private final TypeElement httpMethodAnnotation; + private String generatedName; + + public HtmxRoute(HtmxRouter router, ExecutableElement method, TypeElement httpMethodAnnotation) { + super(router, method); + this.httpMethodAnnotation = httpMethodAnnotation; + this.generatedName = method.getSimpleName().toString(); + } + + public String getGeneratedName() { + return generatedName; + } + + public void setGeneratedName(String generatedName) { + this.generatedName = generatedName; + } + + public List generateHandlerCall(boolean kt) { + var buffer = new ArrayList(); + var methodName = getGeneratedName(); + var paramList = new StringJoiner(", ", "(", ")"); + + int indent = 2; + + // 1. Method Signature + if (kt) { + buffer.add(statement("fun ", methodName, "(ctx: io.jooby.Context): Any {")); + } else { + buffer.add( + statement("public Object ", methodName, "(io.jooby.Context ctx) throws Exception {")); + } + + // 2. Parameter Extraction + for (var parameter : getParameters(true)) { + // Check if parameter is our HtmxContext! + if (parameter.getType().getRawType().toString().equals("io.jooby.htmx.HtmxContext")) { + paramList.add((kt ? "" : "new ") + "io.jooby.htmx.HtmxContext(ctx)"); + continue; + } + + var generatedParameter = parameter.generateMapping(kt); + if (parameter.isRequireBeanValidation()) { + generatedParameter = + CodeBlock.of("io.jooby.validation.BeanValidator.apply(ctx, ", generatedParameter, ")"); + } + paramList.add(generatedParameter); + } + + // Fetch Controller Instance + buffer.add(statement(indent(indent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); + + // 3. Extract Annotation Metadata + var hxView = AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxView"); + var hxError = findHxError(); + String primaryView = + hxView != null + ? AnnotationSupport.findAnnotationValue(hxView, "value"::equals).stream() + .findFirst() + .orElse(null) + : null; + String errorView = + hxError != null + ? AnnotationSupport.findAnnotationValue(hxError, "value"::equals).stream() + .findFirst() + .orElse(null) + : null; + String errorTarget = + hxError != null + ? AnnotationSupport.findAnnotationValue(hxError, "target"::equals).stream() + .findFirst() + .orElse(null) + : null; + String layoutView = + hxView != null + ? AnnotationSupport.findAnnotationValue(hxView, "layout"::equals).stream() + .findFirst() + .orElse(null) + : null; + + boolean isDynamicResponse = + getReturnType().getRawType().toString().equals("io.jooby.htmx.HtmxResponse"); + String call = makeCall(kt, paramList.toString(), false, false); + + // 4. Controller Invocation (with Try/Catch if errorView is present) + if (errorView != null) { + buffer.add(statement(indent(indent), "try {")); + indent += 2; + } + // 5. Response Processing + if (isDynamicResponse) { + // Guard for dynamic responses (e.g. POST/DELETE endpoints) + buffer.add( + statement(indent(indent), "if (!ctx.header(\"HX-Request\").booleanValue(false)) {")); + if (kt) { + buffer.add( + statement( + indent(indent + 2), + "throw io.jooby.exception.BadRequestException(\"Direct browser access to this HTMX" + + " fragment is not allowed.\")")); + } else { + buffer.add( + statement( + indent(indent + 2), + "throw new io.jooby.exception.BadRequestException(\"Direct browser access to this" + + " HTMX fragment is not allowed.\");")); + } + buffer.add(statement(indent(indent), "}")); + + buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt))); + + if (errorView != null) { + String emptyMap = kt ? "mapOf()" : "java.util.Map.of()"; + buffer.add( + statement( + indent(indent), + "result_.addOob(", + string(errorView), + ", ", + emptyMap, + ")", + semicolon(kt))); + } + + appendDeclarativeHeaders(buffer, kt, indent); + + buffer.add(statement(indent(indent), "return result_.send(ctx)", semicolon(kt))); + } else { + generateModelAndViewReturn( + buffer, kt, indent, string(primaryView).toString(), call, errorView, layoutView); + } + + // 6. Error Handling block + if (errorView != null) { + generateErrorCatchBlock(buffer, kt, indent - 2, errorView, errorTarget); + } + + buffer.add(statement("}", System.lineSeparator())); + return buffer; + } + + private AnnotationMirror findHxError() { + var hxError = + AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxError"); + if (hxError == null) { + return AnnotationSupport.findAnnotationByName( + method.getEnclosingElement(), "io.jooby.annotation.htmx.HxError"); + } + return hxError; + } + + private void generateErrorCatchBlock( + List buffer, boolean kt, int indent, String errorView, String errorTarget) { + if (kt) { + buffer.add( + statement(indent(indent), "} catch (ex: io.jooby.htmx.HtmxDirectAccessException) {")); + buffer.add(statement(indent(indent + 2), "throw ex")); + buffer.add(statement(indent(indent), "} catch (ex: Exception) {")); + } else { + buffer.add( + statement(indent(indent), "} catch (io.jooby.htmx.HtmxDirectAccessException ex) {")); + buffer.add(statement(indent(indent + 2), "throw ex;")); + buffer.add(statement(indent(indent), "} catch (Exception ex) {")); + } + + buffer.add( + statement( + indent(indent + 2), + var(kt), + "statusCode_ = ctx.getRouter().errorCode(ex)", + semicolon(kt))); + + buffer.add( + statement( + indent(indent + 2), + var(kt), + "validationResult_ = ctx.require(io.jooby.validation.ValidationExceptionMapper", + clazz(kt), + ").toResult(statusCode_, ex)", + semicolon(kt))); + + buffer.add(statement(indent(indent + 2), "if (validationResult_ == null) {")); + buffer.add(statement(indent(indent + 4), "throw ex", semicolon(kt))); + buffer.add(statement(indent(indent + 2), "}")); + + buffer.add( + statement( + indent(indent + 2), + "ctx.setResponseCode(io.jooby.StatusCode.UNPROCESSABLE_ENTITY)", + semicolon(kt))); + + if (errorTarget != null && !errorTarget.isEmpty()) { + buffer.add( + statement( + indent(indent + 2), + "ctx.setResponseHeader(\"HX-Retarget\", \"" + errorTarget + "\")", + semicolon(kt))); + } + + // USE IDIOMATIC KOTLIN MUTABLE MAPS + if (kt) { + buffer.add( + statement( + indent(indent + 2), + var(kt), + "errorModel_ = mutableMapOf()", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(indent + 2), + "java.util.Map errorModel_ = new java.util.HashMap<>()", + semicolon(kt))); + } + + buffer.add( + statement( + indent(indent + 2), + "errorModel_.put(\"validationResult\", validationResult_)", + semicolon(kt))); + var inferType = kt ? "" : ""; + buffer.add( + statement( + indent(indent + 2), + "return io.jooby.ModelAndView.of", + inferType, + "(", + string(errorView), + ", errorModel_)", + semicolon(kt))); + + buffer.add(statement(indent(indent), "}")); + } + + public List generateMapping(boolean kt, String routerName, boolean isLastRoute) { + List block = new ArrayList<>(); + var methodName = getGeneratedName(); + var returnType = getReturnType(); + var paramString = String.join(", ", getJavaMethodSignature(kt)); + var javadocLink = seeControllerMethodJavadoc(kt, routerName); + var attributeGenerator = + new io.jooby.internal.apt.RouteAttributesGenerator(context, hasBeanValidation); + + var dslMethod = httpMethodAnnotation.getSimpleName().toString().toLowerCase(); + var paths = context.path(router.getTargetType(), method, httpMethodAnnotation); + + for (var path : paths) { + var lastLine = isLastRoute && paths.get(paths.size() - 1).equals(path); + block.add(javadocLink); + + String handlerRef = + kt + ? (isSuspendFun() ? "{ ctx -> " + methodName + "(ctx) }" : "this::" + methodName) + : "this::" + methodName; + + block.add( + statement( + isSuspendFun() ? "" : "app.", + dslMethod, + "(", + string(leadingSlash(path)), + ", ", + handlerRef, + ")")); + + if (context.nonBlocking(getReturnType().getRawType()) || isSuspendFun()) { + block.add(statement(indent(2), ".setNonBlocking(true)")); + } + + attributeGenerator + .toSourceCode(kt, this, 2) + .ifPresent( + attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")"))); + + var lineSep = + lastLine ? System.lineSeparator() : System.lineSeparator() + System.lineSeparator(); + + if (context.generateMvcMethod()) { + block.add( + CodeBlock.of( + indent(2), + ".setMvcMethod(", + kt ? "" : "new ", + "io.jooby.Route.MvcMethod(", + routerName, + clazz(kt), + ", ", + string(getMethodName()), + ", ", + type(kt, returnType.getRawType().toString()), + clazz(kt), + paramString.isEmpty() ? "" : ", " + paramString, + "))", + semicolon(kt), + lineSep)); + } else { + var lastStatement = block.getLast(); + if (lastStatement.endsWith(System.lineSeparator())) { + lastStatement = + lastStatement.substring(0, lastStatement.length() - System.lineSeparator().length()); + } + block.set(block.size() - 1, lastStatement + semicolon(kt) + lineSep); + } + } + return block; + } + + private void generateModelAndViewReturn( + List buffer, + boolean kt, + int indent, + String viewStr, + String call, + String errorView, + String layoutView) { + boolean isStandardView = + getReturnType().is("io.jooby.ModelAndView") + || getReturnType().is("io.jooby.MapModelAndView"); + boolean isHtmxView = getReturnType().is("io.jooby.htmx.HtmxModelAndView"); + boolean isView = isStandardView || isHtmxView; + + // Check if the developer explicitly added @HxView + boolean hasHxView = + io.jooby.internal.apt.AnnotationSupport.findAnnotationByName( + method, "io.jooby.annotation.htmx.HxView") + != null; + + // RULE: We apply the HTMX Guard Clause to EVERYTHING EXCEPT standard views lacking the @HxView + // annotation. + boolean requiresGuard = !isStandardView || hasHxView; + + var modelStr = "result_"; + + // ========================================== + // 1. THE BROWSER FULL-REFRESH GUARD + // ========================================== + if (requiresGuard) { + buffer.add( + statement(indent(indent), "if (!ctx.header(\"HX-Request\").booleanValue(false)) {")); + if (layoutView != null && !layoutView.isEmpty()) { + buffer.add(statement(indent(indent + 2), var(kt), "result_ = ", call, semicolon(kt))); + + // Inject the child view name as a request attribute (Safe for ANY model type: Map, Record, + // POJO) + buffer.add( + statement( + indent(indent + 2), + "ctx.setAttribute(\"childView\", ", + viewStr, + ")", + semicolon(kt))); + + // Extract the data model. If the controller returned a ModelAndView, unwrap it using + // .getModel() + String targetModel = isView ? modelStr + ".getModel()" : modelStr; + + // Return a BRAND NEW immutable ModelAndView pointing to the layout + if (kt) { + buffer.add( + statement( + indent(indent + 2), + "return io.jooby.ModelAndView.of(", + string(layoutView), + ", ", + targetModel, + ")", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(indent + 2), + "return io.jooby.ModelAndView.of(", + string(layoutView), + ", ", + targetModel, + ")", + semicolon(kt))); + } + + } else { + // No layout defined: Reject direct access + if (kt) { + buffer.add( + statement( + indent(indent + 2), + "throw io.jooby.htmx.HtmxDirectAccessException(\"Direct browser access to this" + + " HTMX fragment is not allowed.\")")); + } else { + buffer.add( + statement( + indent(indent + 2), + "throw new io.jooby.htmx.HtmxDirectAccessException(\"Direct browser access to" + + " this HTMX fragment is not allowed.\");")); + } + } + buffer.add(statement(indent(indent), "}")); + } + + // Execute the controller method if it wasn't already handled and returned by the layout block + // above + buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt))); + + appendDeclarativeHeaders(buffer, kt, indent); + + // ========================================== + // 2. THE HTMX AJAX PIPELINE + // ========================================== + + if (isView) { + // Controller handled its own view creation + buffer.add(statement(indent(indent), "return ", modelStr, semicolon(kt))); + return; + } + + var oobViews = + extractRepeatableValues( + "io.jooby.annotation.htmx.HxOob", "io.jooby.annotation.htmx.HxOobs"); + + if (!oobViews.isEmpty() || errorView != null) { + // Upgrade to HtmxModelAndView to support OOB responses + if (kt) { + buffer.add( + statement( + indent(indent), + var(kt), + "mv_ = io.jooby.htmx.HtmxModelAndView(", + viewStr, + ", ", + modelStr, + ")", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(indent), + var(kt), + "mv_ = new io.jooby.htmx.HtmxModelAndView<>(", + viewStr, + ", ", + modelStr, + ")", + semicolon(kt))); + } + + for (var oobView : oobViews) { + buffer.add(statement(indent(indent), "mv_.addOob(", string(oobView), ")", semicolon(kt))); + } + + if (errorView != null) { + buffer.add(statement(indent(indent), "// clear error: ", errorView)); + String emptyMap = kt ? "mapOf()" : "java.util.Map.of()"; + buffer.add( + statement( + indent(indent), + "mv_.addOob(", + string(errorView), + ", ", + emptyMap, + ")", + semicolon(kt))); + } + + buffer.add(statement(indent(indent), "return mv_", semicolon(kt))); + return; + } + + // Fallback: Standard Jooby ModelAndView + if (kt) { + buffer.add( + statement( + indent(indent), + "return io.jooby.ModelAndView.of(", + viewStr, + ", ", + modelStr, + ")", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(indent), + "return io.jooby.ModelAndView.of(", + viewStr, + ", ", + modelStr, + ")", + semicolon(kt))); + } + } + + private void appendDeclarativeHeaders(List buffer, boolean kt, int indent) { + writeStringHeader(buffer, kt, indent, "io.jooby.annotation.htmx.HxTarget", "HX-Retarget"); + writeStringHeader(buffer, kt, indent, "io.jooby.annotation.htmx.HxSwap", "HX-Reswap"); + writeStringHeader(buffer, kt, indent, "io.jooby.annotation.htmx.HxPushUrl", "HX-Push-Url"); + writeStringHeader(buffer, kt, indent, "io.jooby.annotation.htmx.HxRedirect", "HX-Redirect"); + + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxRefresh") + != null) { + buffer.add( + statement( + indent(indent), + "ctx.setResponseHeader(", + string("HX-Refresh"), + ", true)", + semicolon(kt))); + } + + // NEW: Specialized trigger extraction + appendTriggers(buffer, kt, indent); + } + + private void appendTriggers(List buffer, boolean kt, int indent) { + // Use LinkedHashMap to ensure deterministic code generation order + java.util.Map> triggersByHeader = new java.util.LinkedHashMap<>(); + + // 1. Process Single Annotation + var singleMirror = + AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxTrigger"); + if (singleMirror != null) { + extractTriggerData(singleMirror, triggersByHeader); + } + + // 2. Process Repeatable Container + var containerMirror = + AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxTriggers"); + if (containerMirror != null) { + for (var entry : containerMirror.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("value")) { + var nestedList = + (java.util.List) + entry.getValue().getValue(); + + for (var nestedItem : nestedList) { + if (nestedItem.getValue() + instanceof javax.lang.model.element.AnnotationMirror nestedMirror) { + extractTriggerData(nestedMirror, triggersByHeader); + } + } + } + } + } + + // 3. Write out the grouped headers + for (var entry : triggersByHeader.entrySet()) { + String headerName = entry.getKey(); + String combinedValues = String.join(", ", entry.getValue()); + buffer.add( + statement( + indent(indent), + "ctx.setResponseHeader(", + string(headerName), + ", ", + string(combinedValues), + ")", + semicolon(kt))); + } + } + + private void extractTriggerData( + AnnotationMirror mirror, java.util.Map> map) { + String eventName = + AnnotationSupport.findAnnotationValue(mirror, "value"::equals).stream() + .map(Object::toString) + .findFirst() + .orElse(""); + + if (eventName.isEmpty()) return; + + // Default header if phase is omitted + var headerName = "HX-Trigger"; + + // Extract the phase enum if present + var phaseValues = AnnotationSupport.findAnnotationValue(mirror, "phase"::equals); + if (!phaseValues.isEmpty()) { + var phaseRaw = phaseValues.getFirst(); + + if (phaseRaw.endsWith("AFTER_SETTLE")) { + headerName = "HX-Trigger-After-Settle"; + } else if (phaseRaw.endsWith("AFTER_SWAP")) { + headerName = "HX-Trigger-After-Swap"; + } + } + + map.computeIfAbsent(headerName, k -> new ArrayList<>()).add(eventName); + } + + private void writeStringHeader( + List buffer, boolean kt, int indent, String annotationFqn, String headerName) { + var annotation = AnnotationSupport.findAnnotationByName(method, annotationFqn); + if (annotation != null) { + String value = + AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + .findFirst() + .orElse(""); + value = value.replace("\"", ""); + + if (!value.isEmpty()) { + buffer.add( + statement( + indent(indent), + "ctx.setResponseHeader(", + string(headerName), + ", ", + string(value), + ")", + semicolon(kt))); + } + } + } + + @SuppressWarnings("unchecked") + private List extractRepeatableValues( + String singleAnnotationFqn, String containerAnnotationFqn) { + List values = new ArrayList<>(); + + var singleMirror = AnnotationSupport.findAnnotationByName(method, singleAnnotationFqn); + if (singleMirror != null) { + AnnotationSupport.findAnnotationValue(singleMirror, "value"::equals).stream() + .map(Object::toString) + .map(s -> s.replace("\"", "")) + .findFirst() + .ifPresent(values::add); + } + + var containerMirror = AnnotationSupport.findAnnotationByName(method, containerAnnotationFqn); + if (containerMirror != null) { + for (var entry : containerMirror.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("value")) { + var nestedList = + (java.util.List) + entry.getValue().getValue(); + + for (var nestedItem : nestedList) { + if (nestedItem.getValue() + instanceof javax.lang.model.element.AnnotationMirror nestedMirror) { + AnnotationSupport.findAnnotationValue(nestedMirror, "value"::equals).stream() + .map(Object::toString) + .map(s -> s.replace("\"", "")) + .findFirst() + .ifPresent(values::add); + } + } + } + } + } + + return values; + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRouter.java new file mode 100644 index 0000000000..720036263d --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRouter.java @@ -0,0 +1,193 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.htmx; + +import static io.jooby.internal.apt.CodeBlock.*; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; + +import io.jooby.internal.apt.*; + +public class HtmxRouter extends WebRouter { + + private static final Set HTMX_ANNOTATIONS = + Set.of( + "io.jooby.annotation.htmx.HxView", + "io.jooby.annotation.htmx.HxOob", + "io.jooby.annotation.htmx.HxOobs", + "io.jooby.annotation.htmx.HxPushUrl", + "io.jooby.annotation.htmx.HxRedirect", + "io.jooby.annotation.htmx.HxRefresh", + "io.jooby.annotation.htmx.HxSwap", + "io.jooby.annotation.htmx.HxTarget", + "io.jooby.annotation.htmx.HxTrigger", + "io.jooby.annotation.htmx.HxTriggers"); + + // The registry used to fuel the two-pass RestRouter bypass + private final Set claimedRoutes = new HashSet<>(); + + public HtmxRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static HtmxRouter parse(MvcContext context, TypeElement controller) { + var router = new HtmxRouter(context, controller); + + for (var type : context.superTypes(controller)) { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + var method = (ExecutableElement) enclosed; + + if (method.getModifiers().contains(Modifier.ABSTRACT)) { + continue; + } + + // 1. Identify HTMX endpoints + if (isHtmxMethod(context, method)) { + for (var annoMirror : method.getAnnotationMirrors()) { + var annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); + + if (HttpMethod.hasAnnotation(annoElement)) { + var route = new HtmxRoute(router, method, annoElement); + var uniqueKey = method.toString() + annoElement.getSimpleName(); + router.routes.putIfAbsent(uniqueKey, route); + + // 2. Claim the route for the two-pass pipeline! + var httpMethod = + HttpMethod.findByAnnotationName(annoElement.getQualifiedName().toString()); + var paths = context.path(controller, method, annoElement); + for (String path : paths) { + router.claimedRoutes.add(httpMethod + WebRoute.leadingSlash(path)); + } + } + } + } + } + } + } + + // 3. Resolve Overloads (identical to standard Jooby behavior) + var grouped = + router.routes.values().stream().collect(Collectors.groupingBy(HtmxRoute::getMethodName)); + for (var overloads : grouped.values()) { + long distinctMethods = + overloads.stream().map(r -> r.getMethod().toString()).distinct().count(); + if (distinctMethods > 1) { + for (var route : overloads) { + var paramsString = + route.getRawParameterTypes(true, false).stream() + .map(it -> it.substring(Math.max(0, it.lastIndexOf(".") + 1))) + .map(it -> Character.toUpperCase(it.charAt(0)) + it.substring(1)) + .collect(Collectors.joining()); + route.setGeneratedName(route.getMethodName() + paramsString); + } + } + } + return router; + } + + private static boolean isHtmxMethod(MvcContext ctx, ExecutableElement method) { + boolean hasHtmxAnnotation = + method.getAnnotationMirrors().stream() + .map(am -> am.getAnnotationType().toString()) + .anyMatch(HTMX_ANNOTATIONS::contains); + + return hasHtmxAnnotation + || Set.of( + "io.jooby.htmx.HtmxResponse", + "io.jooby.htmx.HtmxModelAndView", + "io.jooby.ModelAndView", + "io.jooby.MapModelAndView") + .contains( + new TypeDefinition( + ctx.getProcessingEnvironment().getTypeUtils(), method.getReturnType()) + .getRawType() + .toString()); + } + + /** Exposes the paths this router has claimed so RestRouter can ignore them. */ + public Set getClaimedRoutes() { + return claimedRoutes; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName() + "Htmx"); + } + + @Override + public String toSourceCode(boolean kt) throws IOException { + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + + var template = getTemplate(kt); + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + } else { + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + } + + var routesList = getRoutes(); + for (int i = 0; i < routesList.size(); i++) { + boolean isLast = i == routesList.size() - 1; + for (String line : routesList.get(i).generateMapping(kt, generateTypeName, isLast)) { + buffer.append(indent(6)).append(line); + } + } + + trimr(buffer); + buffer + .append(System.lineSeparator()) + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + // 2. Generate the private handler methods containing our HtmxRoute logic + var generatedHandlers = new HashSet(); + for (var route : routesList) { + if (generatedHandlers.add(route.getGeneratedName())) { + for (String line : route.generateHandlerCall(kt)) { + buffer.append(indent(4)).append(line); + } + } + } + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } +} diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index 75ebe490d8..d639e3f6bd 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -14,7 +14,6 @@ import java.nio.file.Paths; import java.util.*; import java.util.function.Consumer; -import java.util.function.Predicate; import java.util.stream.Stream; import javax.tools.JavaFileObject; @@ -23,17 +22,42 @@ import com.google.testing.compile.JavaFileObjects; import com.google.testing.compile.JavaSourcesSubjectFactory; import io.jooby.*; -import io.jooby.internal.apt.MvcContext; public class ProcessorRunner { + enum RouterType { + Default, + Trpc, + Rpc, + Mcp, + Htmx, + Ws; + + public String suffix() { + return name() + "_"; + } + + public static RouterType of(String filename) { + var extra = EnumSet.complementOf(EnumSet.of(Default)); + for (RouterType generatedRouter : extra) { + if (filename.endsWith(generatedRouter.suffix())) { + return generatedRouter; + } + } + return Default; + } + } + + record Router(RouterType type, String classname) {} + private static class GeneratedSourceClassLoader extends ClassLoader { private final Map classes = new LinkedHashMap<>(); - public GeneratedSourceClassLoader(ClassLoader parent, Map sources) { + public GeneratedSourceClassLoader(ClassLoader parent, Map sources) { super(parent); for (var e : sources.entrySet()) { - classes.put(e.getKey(), javac().compile(List.of(e.getValue())).generatedFiles().get(0)); + classes.put( + e.getKey().classname, javac().compile(List.of(e.getValue())).generatedFiles().get(0)); } } @@ -52,8 +76,8 @@ protected Class findClass(String name) throws ClassNotFoundException { } private static class HookJoobyProcessor extends JoobyProcessor { - private Map javaFiles = new LinkedHashMap<>(); - private Map kotlinFiles = new LinkedHashMap<>(); + private Map javaFiles = new LinkedHashMap<>(); + private Map kotlinFiles = new LinkedHashMap<>(); public HookJoobyProcessor(Consumer console) { super((kind, message) -> console.accept(message)); @@ -67,20 +91,14 @@ public JavaFileObject getSource() { return javaFiles.isEmpty() ? null : javaFiles.entrySet().iterator().next().getValue(); } - public String getKotlinSource() { - return kotlinFiles.entrySet().iterator().next().getValue(); - } - - public MvcContext getContext() { - return context; - } - @Override protected void onGeneratedSource(String classname, JavaFileObject source) { - javaFiles.put(classname, source); + javaFiles.put(new Router(RouterType.of(classname), classname), source); try { // Generate kotlin source code inside the compiler scope... avoid false positive errors - kotlinFiles.put(classname, context.getRouters().get(0).toSourceCode(true)); + kotlinFiles.put( + new Router(RouterType.of(classname), classname), + context.getRouters().get(0).toSourceCode(true)); } catch (IOException e) { SneakyThrows.propagate(e); } @@ -175,41 +193,40 @@ public ProcessorRunner withSourceCode(SneakyThrows.Consumer consumer) { } public ProcessorRunner withMcpCode(SneakyThrows.Consumer consumer) { - return withSourceCode(false, it -> it.endsWith("Mcp_"), consumer); + return withSourceCode(false, RouterType.Mcp, consumer); } public ProcessorRunner withTrpcCode(SneakyThrows.Consumer consumer) { - return withSourceCode(false, it -> it.endsWith("Trpc_"), consumer); + return withSourceCode(false, RouterType.Trpc, consumer); } public ProcessorRunner withRpcCode(SneakyThrows.Consumer consumer) { - return withSourceCode(false, it -> it.endsWith("Rpc_"), consumer); + return withSourceCode(false, RouterType.Rpc, consumer); + } + + public ProcessorRunner withHtmxCode(SneakyThrows.Consumer consumer) { + return withSourceCode(false, RouterType.Htmx, consumer); } public ProcessorRunner withWsCode(SneakyThrows.Consumer consumer) { - return withSourceCode(false, it -> it.endsWith("Ws_"), consumer); + return withSourceCode(false, RouterType.Ws, consumer); } public ProcessorRunner withSourceCode(boolean kt, SneakyThrows.Consumer consumer) { - consumer.accept( - kt - ? processor.kotlinFiles.values().iterator().next() - : Optional.ofNullable(processor.getSource()).map(Objects::toString).orElse(null)); - return withSourceCode( - kt, it -> !it.endsWith("Trpc_") && !it.endsWith("Rpc_") && !it.endsWith("Mcp_"), consumer); + return withSourceCode(kt, RouterType.Default, consumer); } private ProcessorRunner withSourceCode( - boolean kt, Predicate filter, SneakyThrows.Consumer consumer) { + boolean kt, RouterType routerType, SneakyThrows.Consumer consumer) { consumer.accept( kt ? processor.kotlinFiles.entrySet().stream() - .filter(it -> filter.test(it.getKey())) + .filter(it -> it.getKey().type().equals(routerType)) .map(Map.Entry::getValue) .findFirst() .orElse(null) : processor.javaFiles.entrySet().stream() - .filter(it -> filter.test(it.getKey())) + .filter(it -> it.getKey().type().equals(routerType)) .map(Map.Entry::getValue) .map(Objects::toString) .findFirst() diff --git a/modules/jooby-apt/src/test/java/tests/htmx/BasicUserHx.java b/modules/jooby-apt/src/test/java/tests/htmx/BasicUserHx.java new file mode 100644 index 0000000000..c6787a6a28 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/BasicUserHx.java @@ -0,0 +1,65 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import java.util.Map; + +import org.jspecify.annotations.NonNull; + +import io.jooby.ModelAndView; +import io.jooby.annotation.*; +import io.jooby.annotation.htmx.*; + +@Path("/users") +public class BasicUserHx { + + /** + * TEST 1: The Basics (View Rendering) Verifies: @HxView wraps the return object into + * ModelAndView. + */ + @GET("/{id}") + @HxView("users/profile.hbs") + public User3936 getUser(@PathParam @NonNull String id) { + return new User3936(id, "Edgar", "edgar@example.com"); + } + + /** + * TEST 2: The Basics (View Rendering) Verifies: @HxView wraps the return object into + * MapModelAndView. + */ + @GET("/{id}/map") + @HxView("users/profile.hbs") + public Map getUserMap(@PathParam String id) { + return Map.of("id", id, "email", "edgar@example.com"); + } + + /** + * TEST 3: The Basics (View Rendering) Verifies: @HxView keep existing model and view as they are + */ + @GET("/{id}/map") + @HxView("users/profile.hbs") + public ModelAndView getUserModelAndView(@PathParam String id) { + return new ModelAndView("users/profile-ext.hbs", getUser(id)); + } + + /** + * TEST: The Declarative Powerhouse (OOB + Headers) Verifies: Multiple @HxOob appends, declarative + * header generation, and trigger aggregation. The APT should generate `ctx.setResponseHeader()` + * calls securely without reflection. + */ + @POST + @HxView("users/row.hbs") + @HxOob("components/notification_toast") + @HxOob("components/stats_counter") + @HxTarget("#user-table") + @HxSwap("beforeend") + @HxTrigger("userCreated") + @HxTrigger("updateGraph") + public Map createUser(UserDto3936 dto) { + // Save to DB... + return Map.of("user", dto, "message", "User " + dto.name() + " created successfully!"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/ClaimedRouteHx.java b/modules/jooby-apt/src/test/java/tests/htmx/ClaimedRouteHx.java new file mode 100644 index 0000000000..dbecbad0ce --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/ClaimedRouteHx.java @@ -0,0 +1,24 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import io.jooby.ModelAndView; +import io.jooby.annotation.GET; +import io.jooby.annotation.htmx.*; + +public class ClaimedRouteHx { + + @GET("/") + public ModelAndView index() { + return null; + } + + @GET("/tasks") + @HxView("tasks.hbs") + public User3936 tasks() { + return null; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/ContextInjectionHx.java b/modules/jooby-apt/src/test/java/tests/htmx/ContextInjectionHx.java new file mode 100644 index 0000000000..518f665df4 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/ContextInjectionHx.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import java.util.Map; + +import io.jooby.annotation.*; +import io.jooby.annotation.htmx.*; +import io.jooby.htmx.HtmxContext; + +@Path("/users") +public class ContextInjectionHx { + + /** + * TEST: Context Injection (Imperative State) Verifies: The APT generator sees `HtmxContext`, + * instantiates it dynamically using `new HtmxContext(ctx)`, and passes it in. Verifies JSON + * encoding for the trigger payload. + */ + @PUT("/{id}") + @HxView("users/profile.hbs") + @HxOob("components/notification_toast") + public User3936 updateUser(@PathParam String id, UserDto3936 dto, HtmxContext hx) { + // Read incoming HTMX state + if (hx.isBoosted()) { + hx.pushUrl("/users/" + id); + } + + hx.trigger("userUpdated", Map.of("id", id, "changes", dto)); + + return new User3936(id, dto.name(), dto.email()); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/DynamicResponseHx.java b/modules/jooby-apt/src/test/java/tests/htmx/DynamicResponseHx.java new file mode 100644 index 0000000000..c67649b21f --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/DynamicResponseHx.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import java.util.Map; + +import io.jooby.Context; +import io.jooby.StatusCode; +import io.jooby.annotation.DELETE; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; +import io.jooby.htmx.HtmxResponse; + +@Path("/users") +public class DynamicResponseHx { + + /** + * TEST: The Dynamic Response Builder Verifies: The APT recognizes `HtmxResponse`, skips standard + * view wrapping, and calls `((HtmxResponse) result).writeHeaders(ctx)` before returning. + */ + @DELETE("/{id}") + public HtmxResponse deleteUser(@PathParam String id, Context ctx) { + boolean deleted = true; // Assume DB call + + if (deleted) { + // Event-only response (200 OK, no content, just triggers) + return HtmxResponse.empty() + .trigger("userDeleted", id) + .triggerAfterSwap("showToast", "User permanently removed."); + } else { + // Dynamic view routing based on logic + return HtmxResponse.view("errors/notfound", Map.of("id", id)) + .status(StatusCode.NOT_FOUND) + .target("#error-container") + .swap("innerHTML"); + } + } + + @DELETE("/{id}") + public HtmxResponse deleteTask(@PathParam String id) { + return HtmxResponse.empty().addOob("views/task_counter.hbs"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/ErrorBoundaryHx.java b/modules/jooby-apt/src/test/java/tests/htmx/ErrorBoundaryHx.java new file mode 100644 index 0000000000..e33f6d6c4e --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/ErrorBoundaryHx.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import io.jooby.annotation.POST; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; +import io.jooby.annotation.htmx.HxError; +import io.jooby.annotation.htmx.HxView; +import jakarta.validation.Valid; + +@Path("/users") +@HxError(value = "users/risk_form_top", target = "#risk-form-top-container") +public class ErrorBoundaryHx { + + /** + * TEST: The Error Boundary Verifies: The APT generates a `try/catch` block. If `saveRiskProfile` + * throws an exception, it catches it, sets 422 Unprocessable Entity, retargets, and re-renders + * the input form. + */ + @POST("/{id}/risk") + @HxView(value = "users/risk_badge.hbs") + @HxError(value = "users/risk_form", target = "#risk-form-container") + public String saveRiskProfile(@PathParam String id, RiskDto3936 dto) { + if (dto.score() < 0 || dto.score() > 100) { + throw new IllegalArgumentException("Risk score must be between 0 and 100"); + } + return "High"; + } + + /** + * TEST: The Error Boundary Verifies: The APT generates a `try/catch` block. If `saveRiskProfile` + * throws an exception, it catches it, sets 422 Unprocessable Entity, retargets, and re-renders + * the input form. + */ + @POST("/{id}/risk") + @HxView(value = "users/risk_badge.hbs") + public String saveRiskProfileBeanValidation(@PathParam String id, @Valid RiskDto3936 dto) { + if (dto.score() < 0 || dto.score() > 100) { + throw new IllegalArgumentException("Risk score must be between 0 and 100"); + } + return "High"; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java new file mode 100644 index 0000000000..2bd028c2bd --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java @@ -0,0 +1,261 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class HtmxTest { + + @Test + public void shouldDoBasicHtmx() throws Exception { + new ProcessorRunner(new BasicUserHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object getUser(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.getUser(ctx.path("id").value()); + return io.jooby.ModelAndView.of("users/profile.hbs", result_); + } + """) + .containsIgnoringWhitespaces( + """ + public Object getUserMap(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.getUserMap(ctx.path("id").valueOrNull()); + return io.jooby.ModelAndView.of("users/profile.hbs", result_); + } + """) + .containsIgnoringWhitespaces( + """ + public Object getUserModelAndView(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.getUserModelAndView(ctx.path("id").valueOrNull()); + return result_; + } + """) + .containsIgnoringWhitespaces( + """ + public Object createUser(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.createUser(ctx.body(tests.htmx.UserDto3936.class)); + ctx.setResponseHeader("HX-Retarget", "#user-table"); + ctx.setResponseHeader("HX-Reswap", "beforeend"); + ctx.setResponseHeader("HX-Trigger", "userCreated, updateGraph"); + var mv_ = new io.jooby.htmx.HtmxModelAndView<>("users/row.hbs", result_); + mv_.addOob("components/notification_toast"); + mv_.addOob("components/stats_counter"); + return mv_; + } + """); + }); + } + + @Test + public void shouldDoLayoutHtmx() throws Exception { + new ProcessorRunner(new LayoutHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object layout(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + var result_ = c.layout(); + ctx.setAttribute("childView", "users/profile.hbs"); + return io.jooby.ModelAndView.of("layout.hbs", result_); + } + var result_ = c.layout(); + ctx.setResponseHeader("HX-Trigger", "pageLoaded"); + return io.jooby.ModelAndView.of("users/profile.hbs", result_); + } + """) + .containsIgnoringWhitespaces( + """ + public Object nolayout(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.nolayout(ctx.path("id").value()); + ctx.setResponseHeader("HX-Trigger", "userRead"); + return io.jooby.ModelAndView.of("users/profile.hbs", result_); + } + """); + }); + } + + @Test + public void shouldInjectContext() throws Exception { + new ProcessorRunner(new ContextInjectionHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object updateUser(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.updateUser(ctx.path("id").valueOrNull(), ctx.body(tests.htmx.UserDto3936.class), new io.jooby.htmx.HtmxContext(ctx)); + var mv_ = new io.jooby.htmx.HtmxModelAndView<>("users/profile.hbs", result_); + mv_.addOob("components/notification_toast"); + return mv_; + } + """); + }); + } + + @Test + public void shouldGenerateTriggers() throws Exception { + new ProcessorRunner(new TriggersHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object triggers(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.triggers(); + ctx.setResponseHeader("HX-Trigger", "t1"); + ctx.setResponseHeader("HX-Trigger-After-Settle", "t2"); + ctx.setResponseHeader("HX-Trigger-After-Swap", "t3"); + return io.jooby.ModelAndView.of("users/profile.hbs", result_); + } + """); + }); + } + + @Test + public void shouldDoDynamicResponse() throws Exception { + new ProcessorRunner(new DynamicResponseHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object deleteTask(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.exception.BadRequestException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.deleteTask(ctx.path("id").valueOrNull()); + return result_.send(ctx); + } + """); + }); + } + + @Test + public void shouldHandleError() throws Exception { + new ProcessorRunner(new ErrorBoundaryHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object saveRiskProfile(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + try { + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.saveRiskProfile(ctx.path("id").valueOrNull(), ctx.body(tests.htmx.RiskDto3936.class)); + var mv_ = new io.jooby.htmx.HtmxModelAndView<>("users/risk_badge.hbs", result_); + // clear error: users/risk_form + mv_.addOob("users/risk_form", java.util.Map.of()); + return mv_; + } catch (io.jooby.htmx.HtmxDirectAccessException ex) { + throw ex; + } catch (Exception ex) { + var statusCode_ = ctx.getRouter().errorCode(ex); + var validationResult_ = ctx.require(io.jooby.validation.ValidationExceptionMapper.class).toResult(statusCode_, ex); + if (validationResult_ == null) { + throw ex; + } + ctx.setResponseCode(io.jooby.StatusCode.UNPROCESSABLE_ENTITY); + ctx.setResponseHeader("HX-Retarget", "#risk-form-container"); + java.util.Map errorModel_ = new java.util.HashMap<>(); + errorModel_.put("validationResult", validationResult_); + return io.jooby.ModelAndView.of("users/risk_form", errorModel_); + } + } + """) + // Bean validation + .containsIgnoringWhitespaces( + """ + public Object saveRiskProfileBeanValidation(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + try { + if (!ctx.header("HX-Request").booleanValue(false)) { + throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed."); + } + var result_ = c.saveRiskProfileBeanValidation(ctx.path("id").valueOrNull(), io.jooby.validation.BeanValidator.apply(ctx, ctx.body(tests.htmx.RiskDto3936.class))); + var mv_ = new io.jooby.htmx.HtmxModelAndView<>("users/risk_badge.hbs", result_); + // clear error: users/risk_form_top + mv_.addOob("users/risk_form_top", java.util.Map.of()); + return mv_; + } catch (io.jooby.htmx.HtmxDirectAccessException ex) { + throw ex; + } catch (Exception ex) { + var statusCode_ = ctx.getRouter().errorCode(ex); + var validationResult_ = ctx.require(io.jooby.validation.ValidationExceptionMapper.class).toResult(statusCode_, ex); + if (validationResult_ == null) { + throw ex; + } + ctx.setResponseCode(io.jooby.StatusCode.UNPROCESSABLE_ENTITY); + ctx.setResponseHeader("HX-Retarget", "#risk-form-top-container"); + java.util.Map errorModel_ = new java.util.HashMap<>(); + errorModel_.put("validationResult", validationResult_); + return io.jooby.ModelAndView.of("users/risk_form_top", errorModel_); + } + } + """); + }); + } + + @Test + public void shouldClaimModelAndView() throws Exception { + new ProcessorRunner(new ClaimedRouteHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public void install(io.jooby.Jooby app) throws Exception { + /** See {@link ClaimedRouteHx#index()} */ + app.get("/", this::index); + + /** See {@link ClaimedRouteHx#tasks()} */ + app.get("/tasks", this::tasks); + } + """); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/LayoutHx.java b/modules/jooby-apt/src/test/java/tests/htmx/LayoutHx.java new file mode 100644 index 0000000000..d63a48e0e7 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/LayoutHx.java @@ -0,0 +1,33 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import java.util.Map; + +import org.jspecify.annotations.NonNull; + +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; +import io.jooby.annotation.htmx.*; + +@Path("/users") +public class LayoutHx { + + @GET + @HxView(value = "users/profile.hbs", layout = "layout.hbs") + @HxTrigger("pageLoaded") + public Map layout() { + return Map.of(); + } + + @GET("/{id}") + @HxView(value = "users/profile.hbs") + @HxTrigger("userRead") + public User3936 nolayout(@PathParam @NonNull String id) { + return new User3936(id, "Edgar", "edgar@example.com"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/RiskDto3936.java b/modules/jooby-apt/src/test/java/tests/htmx/RiskDto3936.java new file mode 100644 index 0000000000..094a52630c --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/RiskDto3936.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +public record RiskDto3936(int score) {} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/TriggersHx.java b/modules/jooby-apt/src/test/java/tests/htmx/TriggersHx.java new file mode 100644 index 0000000000..abbfabbfca --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/TriggersHx.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +import java.util.Map; + +import io.jooby.annotation.GET; +import io.jooby.annotation.Path; +import io.jooby.annotation.htmx.HxTrigger; +import io.jooby.annotation.htmx.HxView; + +@Path("/users") +public class TriggersHx { + + @GET + @HxView(value = "users/profile.hbs") + @HxTrigger(value = "t1", phase = HxTrigger.Phase.TRIGGER) + @HxTrigger(value = "t2", phase = HxTrigger.Phase.AFTER_SETTLE) + @HxTrigger(value = "t3", phase = HxTrigger.Phase.AFTER_SWAP) + public Map triggers() { + return Map.of(); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/User3936.java b/modules/jooby-apt/src/test/java/tests/htmx/User3936.java new file mode 100644 index 0000000000..3bb2deab26 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/User3936.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +public record User3936(String id, String name, String email) {} diff --git a/modules/jooby-apt/src/test/java/tests/htmx/UserDto3936.java b/modules/jooby-apt/src/test/java/tests/htmx/UserDto3936.java new file mode 100644 index 0000000000..c98d5770f8 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/UserDto3936.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.htmx; + +public record UserDto3936(String name, String email) {} diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 689d68dc62..c08c9b43e5 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -150,6 +150,11 @@ jooby-hikari ${project.version} + + io.jooby + jooby-htmx + ${project.version} + io.jooby jooby-jackson diff --git a/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java b/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java index b1f33dfc4f..5adbf3b4d9 100644 --- a/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java +++ b/modules/jooby-freemarker/src/main/java/io/jooby/freemarker/FreemarkerModule.java @@ -273,7 +273,8 @@ public void install(Jooby application) { .setTemplatesPath(templatesPath) .build(application.getEnvironment()); } - application.encoder(new FreemarkerTemplateEngine(freemarker, EXT)); + var templateEngine = new FreemarkerTemplateEngine(freemarker, EXT); + application.encoder(templateEngine); var services = application.getServices(); services.put(Configuration.class, freemarker); diff --git a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java index cd42ef3a80..16f1546cdc 100644 --- a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java +++ b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java @@ -7,6 +7,7 @@ import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -43,7 +44,7 @@ import io.jooby.*; import io.jooby.test.MockContext; -public class FreemarkerModuleTest { +class FreemarkerModuleTest { public static class MyModel { public String firstname; @@ -89,6 +90,7 @@ void setUp() { when(app.getEnvironment()).thenReturn(env); when(app.getServices()).thenReturn(registry); + when(env.getConfig()).thenReturn(config); when(config.hasPath("freemarker")).thenReturn(false); when(env.isActive("dev", "test")).thenReturn(false); @@ -208,6 +210,14 @@ void testBuilderCacheStorageInProdMode() { assertEquals("freemarker.cache.MruCacheStorage", conf.getCacheStorage().getClass().getName()); } + @Test + void testBuilderWithNullTemplatesPathStringFallback() { + // Tests the Optional.ofNullable(this.templatesPathString).orElse(TemplateEngine.PATH) branch + Configuration conf = FreemarkerModule.create().setTemplatesPath((String) null).build(env); + + assertNotNull(conf.getTemplateLoader()); + } + // --- DEFAULT TEMPLATE LOADER RESOLUTION TESTS --- @Test diff --git a/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java b/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java index 57452d86d8..cdee73c705 100644 --- a/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java +++ b/modules/jooby-handlebars/src/main/java/io/jooby/handlebars/HandlebarsModule.java @@ -257,8 +257,9 @@ public void install(Jooby application) throws Exception { .setTemplatesPath(templatesPath) .build(application.getEnvironment()); } - application.encoder( - new HandlebarsTemplateEngine(handlebars, resolvers.toArray(new ValueResolver[0]), EXT)); + var templateEngine = + new HandlebarsTemplateEngine(handlebars, resolvers.toArray(new ValueResolver[0]), EXT); + application.encoder(templateEngine); var services = application.getServices(); services.put(Handlebars.class, handlebars); diff --git a/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java b/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java index ddd8c4cfbe..30a427d905 100644 --- a/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java +++ b/modules/jooby-handlebars/src/test/java/io/jooby/handlebars/HandlebarsModuleTest.java @@ -167,6 +167,12 @@ void testDefaultTemplateLoaderClasspathFallback() { @Test void testClassPathTemplateLoaderResourceResolution() throws IOException { + URL resourceUrl = new URL("file:///dummy"); + ClassLoader classLoader = mock(ClassLoader.class); + + when(env.getClassLoader()).thenReturn(classLoader); + when(classLoader.getResource(anyString())).thenReturn(resourceUrl); + Handlebars hbs = HandlebarsModule.create() .setTemplatesPath("this_path_does_not_exist_on_file_system") @@ -174,10 +180,14 @@ void testClassPathTemplateLoaderResourceResolution() throws IOException { ClassPathTemplateLoader loader = (ClassPathTemplateLoader) hbs.getLoader(); - // Test the overridden getResource method uses the Environment's ClassLoader - URL resourceUrl = new URL("file:///dummy"); - ClassLoader classLoader = mock(ClassLoader.class); - when(env.getClassLoader()).thenReturn(classLoader); - when(classLoader.getResource("test.hbs")).thenReturn(resourceUrl); + try { + loader.sourceAt("test"); + } catch (Exception e) { + // It might throw an exception attempting to read "file:///dummy", + // but that's fine for this test since we only care about resolution. + } + + verify(env).getClassLoader(); + verify(classLoader).getResource(anyString()); } } diff --git a/modules/jooby-htmx/pom.xml b/modules/jooby-htmx/pom.xml new file mode 100644 index 0000000000..db64520c52 --- /dev/null +++ b/modules/jooby-htmx/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.4.1-SNAPSHOT + + jooby-htmx + jooby-htmx + + + + io.jooby + jooby + ${jooby.version} + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + org.mockito + mockito-core + test + + + diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java new file mode 100644 index 0000000000..0d5700608e --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java @@ -0,0 +1,37 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines the HTMX error template to render if a validation or parameter binding exception occurs. + * + * @author edgar + * @since 4.5.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.SOURCE) +public @interface HxError { + /** + * The fallback template to render if a validation or parameter binding exception occurs. + * + * @return The error template path. + */ + String value(); + + /** + * Automatically appends an {@code HX-Retarget} header when an exception triggers the {@link + * #value()} ()}. Useful for redirecting failed form submissions back to the form container + * instead of the default target. + * + * @return The CSS selector of the error target. + */ + String target() default ""; +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java new file mode 100644 index 0000000000..922591ed56 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares an additional template to be rendered and streamed as an Out-of-Band (OOB) swap. + * + *

Multiple {@code @HxOob} annotations can be applied to a single method. The generated encoder + * will stream the primary view and all OOB views sequentially. Note that the method's return value + * must provide the necessary model data for all views. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +@Repeatable(HxOobs.class) +public @interface HxOob { + /** + * The classpath location of the template file to render as an OOB swap. + * + * @return The template path. + */ + String value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java new file mode 100644 index 0000000000..2fb5c15244 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java @@ -0,0 +1,18 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Container annotation for repeatable {@link HxOob} annotations. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxOobs { + HxOob[] value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java new file mode 100644 index 0000000000..d5fba47992 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Instructs HTMX to push a new URL into the browser's history stack. Maps to the {@code + * HX-Push-Url} header. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxPushUrl { + /** + * The URL to push to the history stack. + * + *

Use {@code "true"} (the default) to push the current request URL. Use {@code "false"} to + * explicitly prevent history pushing. Provide a path (e.g., {@code "/users/list"}) to push a + * specific URL. + * + * @return The URL directive. + */ + String value() default "true"; +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java new file mode 100644 index 0000000000..4a0f0c1985 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Instructs HTMX to perform a full-page client-side redirect to a new URL, bypassing standard swap + * logic. Maps to the {@code HX-Redirect} header. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxRedirect { + /** + * The URL to redirect the client to. + * + * @return The destination URL. + */ + String value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java new file mode 100644 index 0000000000..dcb63cb2d7 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java @@ -0,0 +1,19 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Instructs HTMX to perform a full-page reload of the current client-side context. Maps to the + * {@code HX-Refresh: true} header. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxRefresh {} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java new file mode 100644 index 0000000000..8adb140435 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Instructs HTMX to override the client-side swap style for the response. Maps to the {@code + * HX-Reswap} header. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxSwap { + /** + * The HTMX swap style, optionally including modifiers. + * + *

Examples: {@code "innerHTML"}, {@code "outerHTML"}, {@code "outerHTML scroll:top"} + * + * @return The swap style string. + */ + String value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java new file mode 100644 index 0000000000..0bad0a03b7 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Instructs HTMX to swap the response into a different target element than the one that initiated + * the request. Maps to the {@code HX-Retarget} header. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxTarget { + /** + * The CSS selector of the target element. + * + * @return The CSS selector. + */ + String value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java new file mode 100644 index 0000000000..08b6d56dc3 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Triggers a client-side event upon successful response. Maps to the {@code HX-Trigger}, {@code + * HX-Trigger-After-Settle}, or {@code HX-Trigger-After-Swap} headers. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +@Repeatable(HxTriggers.class) +public @interface HxTrigger { + + /** + * The name of the client-side event to trigger. + * + * @return The event name. + */ + String value(); + + /** + * The lifecycle phase at which the event should be triggered. + * + * @return The trigger phase. Defaults to {@link Phase#TRIGGER}. + */ + Phase phase() default Phase.TRIGGER; + + /** Represents the HTMX trigger lifecycle headers. */ + enum Phase { + /** Appends to the {@code HX-Trigger} header. */ + TRIGGER, + + /** Appends to the {@code HX-Trigger-After-Settle} header. */ + AFTER_SETTLE, + + /** Appends to the {@code HX-Trigger-After-Swap} header. */ + AFTER_SWAP + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java new file mode 100644 index 0000000000..d76fa1f2ae --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java @@ -0,0 +1,18 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Container annotation for repeatable {@link HxTrigger} annotations. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxTriggers { + HxTrigger[] value(); +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java new file mode 100644 index 0000000000..ba9bf30755 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java @@ -0,0 +1,56 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.htmx; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines the HTMX view rendering strategy for an MVC route. + * + *

This annotation is intercepted by the HTMX APT generator to produce a {@code ModelAndView}. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface HxView { + + /** + * The classpath location of the template file (e.g., "users/profile"). + * + * @return The template path. + */ + String value(); + + /** + * Defines the outer HTML layout (or "SPA Shell") that wraps this partial view. + * + *

This attribute enables seamless deep-linking and full-page refreshes in an HTMX application. + * It allows a single controller method to serve both dynamic UI fragments and fully-formed HTML + * pages depending on the origin of the incoming request. + * + *

How it works:

+ * + *
    + *
  • HTMX Requests: If the request contains the {@code HX-Request: true} header, this + * layout attribute is completely ignored. The framework responds only with the partial view + * defined in the primary {@code value()} attribute, ensuring fast, targeted DOM swaps. + *
  • Standard Browser Requests: If a user accesses the endpoint directly via the URL + * bar, a bookmark, or an {@code F5} refresh, the framework intercepts the request. It + * renders this layout file. + *
+ * + *

Template Integration:

+ * + *

When a layout fallback is triggered, the framework automatically injects the name of the + * target partial view into the response model under the {@code childView} key. Your layout file + * must use your template engine's dynamic include syntax to render the child view. + * + * @return The path to the layout template file, or an empty string if no layout is required. + */ + String layout() default ""; +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java new file mode 100644 index 0000000000..89a06acca2 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java @@ -0,0 +1,249 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import io.jooby.Context; +import io.jooby.json.JsonEncoder; + +/** + * Provides a fluent API for interacting with HTMX specific HTTP headers. * This context wraps a + * standard Jooby {@link Context} and makes it easy to read incoming HTMX request states and safely + * build outgoing HTMX response headers, including complex JSON-encoded trigger payloads. + * + * @see HTMX Reference + * @author edgar + * @since 4.5.0 + */ +public class HtmxContext { + + private final Context ctx; + + private final Map triggers = new LinkedHashMap<>(); + private final Map triggersAfterSettle = new LinkedHashMap<>(); + private final Map triggersAfterSwap = new LinkedHashMap<>(); + + /** + * Creates a new HTMX context. + * + * @param ctx The current Jooby HTTP context. + */ + public HtmxContext(Context ctx) { + this.ctx = ctx; + } + + // --- Request State Readers --- + + /** + * Indicates that the request is via an element using hx-boost. + * + * @return True if the {@code HX-Boosted} header is present and true. + */ + public boolean isBoosted() { + return ctx.header("HX-Boosted").booleanValue(false); + } + + /** + * Indicates that the request is a standard HTMX request. + * + * @return True if the {@code HX-Request} header is present and true. + */ + public boolean isHtmxRequest() { + return ctx.header("HX-Request").booleanValue(false); + } + + /** + * Indicates if the request is for history restoration after a miss in the local history cache. + * + * @return True if the {@code HX-History-Restore-Request} header is present and true. + */ + public boolean isHistoryRestoreRequest() { + return ctx.header("HX-History-Restore-Request").booleanValue(false); + } + + /** + * Retrieves the current URL of the browser that made the HTMX request. + * + * @return The value of the {@code HX-Current-Url} header, or null if missing. + */ + public @Nullable String getCurrentUrl() { + return ctx.header("HX-Current-Url").valueOrNull(); + } + + /** + * Retrieves the id of the target element if it exists. + * + * @return The value of the {@code HX-Target} header, or null if missing. + */ + public @Nullable String getTarget() { + return ctx.header("HX-Target").valueOrNull(); + } + + // --- Response Header Modifiers --- + + /** + * Pushes a new url into the history stack. + * + * @param url The URL to push into the history stack. + * @return This context for method chaining. + */ + public HtmxContext pushUrl(String url) { + ctx.setResponseHeader("HX-Push-Url", url); + return this; + } + + /** + * Replaces the current URL in the location bar. + * + * @param url The URL to replace in the location bar. + * @return This context for method chaining. + */ + public HtmxContext replaceUrl(String url) { + ctx.setResponseHeader("HX-Replace-Url", url); + return this; + } + + /** + * Forces a client-side redirect to a new location. + * + * @param url The target URL for the redirect. + * @return This context for method chaining. + */ + public HtmxContext redirect(String url) { + ctx.setResponseHeader("HX-Redirect", url); + return this; + } + + /** + * Instructs the client side to do a full refresh of the page. + * + * @return This context for method chaining. + */ + public HtmxContext refresh() { + ctx.setResponseHeader("HX-Refresh", true); + return this; + } + + /** + * Specifies how the response will be swapped, overriding the default behavior. + * + * @param swap The swap strategy (e.g., innerHTML, outerHTML, beforebegin). + * @return This context for method chaining. + */ + public HtmxContext reswap(String swap) { + ctx.setResponseHeader("HX-Reswap", swap); + return this; + } + + /** + * Updates the target of the content update to a different element on the page. + * + * @param target A CSS selector representing the new target element. + * @return This context for method chaining. + */ + public HtmxContext retarget(String target) { + ctx.setResponseHeader("HX-Retarget", target); + return this; + } + + // --- Trigger Builders (Object Payloads) --- + + /** + * Triggers a client-side event as soon as the response is received. + * + * @param eventName The name of the event to trigger. + * @return This context for method chaining. + */ + public HtmxContext trigger(String eventName) { + this.triggers.put(eventName, null); + updateTriggerHeader("HX-Trigger", triggers); + return this; + } + + /** + * Triggers a client-side event with an attached data payload. + * + * @param eventName The name of the event to trigger. + * @param payload The data object to send with the event. + * @return This context for method chaining. + */ + public HtmxContext trigger(String eventName, @Nullable Object payload) { + this.triggers.put(eventName, payload); + updateTriggerHeader("HX-Trigger", triggers); + return this; + } + + /** + * Triggers a client-side event after the settling step. + * + * @param eventName The name of the event to trigger. + * @return This context for method chaining. + */ + public HtmxContext triggerAfterSettle(String eventName) { + this.triggersAfterSettle.put(eventName, null); + updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle); + return this; + } + + /** + * Triggers a client-side event with a payload after the settling step. + * + * @param eventName The name of the event to trigger. + * @param payload The data object to send with the event. + * @return This context for method chaining. + */ + public HtmxContext triggerAfterSettle(String eventName, @Nullable Object payload) { + this.triggersAfterSettle.put(eventName, payload); + updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle); + return this; + } + + /** + * Triggers a client-side event after the swap step. + * + * @param eventName The name of the event to trigger. + * @return This context for method chaining. + */ + public HtmxContext triggerAfterSwap(String eventName) { + this.triggersAfterSwap.put(eventName, null); + updateTriggerHeader("HX-Trigger-After-Swap", triggersAfterSwap); + return this; + } + + /** + * Triggers a client-side event with a payload after the swap step. + * + * @param eventName The name of the event to trigger. + * @param payload The data object to send with the event. + * @return This context for method chaining. + */ + public HtmxContext triggerAfterSwap(String eventName, @Nullable Object payload) { + this.triggersAfterSwap.put(eventName, payload); + updateTriggerHeader("HX-Trigger-After-Swap", triggersAfterSwap); + return this; + } + + // --- Safe JSON Encoding --- + + private void updateTriggerHeader(String headerName, Map triggerMap) { + if (triggerMap.isEmpty()) return; + + boolean hasPayloads = triggerMap.values().stream().anyMatch(Objects::nonNull); + + if (!hasPayloads) { + // No objects to serialize, safe to use simple comma separation + ctx.setResponseHeader(headerName, String.join(", ", triggerMap.keySet())); + } else { + var encoder = ctx.require(JsonEncoder.class); + ctx.setResponseHeader(headerName, encoder.encode(triggerMap)); + } + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java new file mode 100644 index 0000000000..c3b905b8f6 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java @@ -0,0 +1,31 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import io.jooby.StatusCode; +import io.jooby.exception.StatusCodeException; + +/** + * Exception thrown to indicate that a direct access attempt to resources via HTMX has been blocked. + * This typically corresponds to an HTTP 406 Not Acceptable status. + * + *

HtmxDirectAccessException is a specialized form of {@code StatusCodeException} that sets. + * + * @author edgar + * @since 4.5.0 + */ +public class HtmxDirectAccessException extends StatusCodeException { + /** + * Constructs a new {@code HtmxDirectAccessException} with a default HTTP status code of 406 Not + * Acceptable. This exception is used to signal that a direct access attempt to HTMX resources has + * been disallowed. + * + * @param message The error message. + */ + public HtmxDirectAccessException(String message) { + super(StatusCode.NOT_ACCEPTABLE, message); + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java new file mode 100644 index 0000000000..d6831967b6 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java @@ -0,0 +1,60 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import org.slf4j.event.Level; + +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.StatusCode; + +/** + * A specialized error handler designed to intercept and format exceptions specifically for HTMX + * requests. + * + *

By implementing this interface, developers can seamlessly convert global server crashes or + * validation failures (e.g., HTTP 422 or 500) into graceful HTMX responses, such as Out-Of-Band + * (OOB) toast notifications, preventing raw HTML stack traces from breaking the client's DOM. * + * + *

Standard browser requests will bypass this handler and fall back to Jooby's default error + * pages. + */ +public interface HtmxErrorHandler { + + /** + * Processes the error and generates an appropriate HTMX response. + * + * @param ctx The current HTTP context. + * @param cause The exception that was thrown. + * @param code The resolved HTTP status code for the error. + * @return An {@link HtmxResponse} containing the partial views or triggers to send to the client. + */ + HtmxResponse apply(Context ctx, Throwable cause, StatusCode code); + + /** + * Converts this HTMX-specific error handler into an {@link ErrorHandler}. + * + *

This method automatically applies guard clauses: it ensures the request is an actual HTMX + * request (via the {@code HX-Request} header) and ignores {@link HtmxDirectAccessException} + * (which is deliberately thrown to reject direct browser access to partials). + * + * @return An ErrorHandler that wraps this implementation. + */ + default ErrorHandler toErrorHandler() { + return (ctx, cause, code) -> { + // error is thrown on bad Htmx request, ignore we can't handle it. + if (!(cause instanceof HtmxDirectAccessException) + && ctx.header("HX-Request").booleanValue(false)) { + var log = ctx.getRouter().getLog(); + var level = code.value() < 500 ? Level.DEBUG : Level.ERROR; + log.atLevel(level).log(ErrorHandler.errorMessage(ctx, code), cause); + ErrorHandler.errorMessage( + ctx, code); // Note: This line has no side effects and can be safely removed. + apply(ctx, cause, code).send(ctx); + } + }; + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java new file mode 100644 index 0000000000..ae39e004f2 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java @@ -0,0 +1,69 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import java.util.*; + +import org.jspecify.annotations.Nullable; + +import io.jooby.ModelAndView; + +/** + * A specialized view carrier for HTMX Out-of-Band (OOB) swaps. + * + *

The HTMX APT generator instantiates this class when a controller method uses {@code @HxOob} + * annotations alongside a primary {@code @HxView}. It instructs the {@link HtmxTemplateEngine} to + * sequentially render multiple templates using the same model. + */ +public class HtmxModelAndView extends ModelAndView implements Iterable> { + + private final Map oobViews = new LinkedHashMap<>(); + + /** + * Creates a new HTMX multi-view. + * + * @param primaryView The main template path (e.g., from {@code @HxView}). + * @param model The data model shared across all templates. + */ + public HtmxModelAndView(String primaryView, @Nullable T model) { + super(primaryView, model); + } + + /** + * Adds an Out-of-Band view to the rendering pipeline. + * + * @param view The OOB template path. + * @return This instance. + */ + public HtmxModelAndView addOob(String view) { + return addOob(view, model); + } + + /** + * Adds an Out-of-Band (OOB) view and its associated model to the rendering pipeline. + * + * @param view The template path for the OOB view. + * @param model The data model associated with the specified OOB view. + * @return The current instance of {@code HtmxModelAndView}. + */ + public HtmxModelAndView addOob(String view, Object model) { + this.oobViews.put(view, model); + return this; + } + + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public Iterator> iterator() { + var views = new ArrayList(); + views.add(ModelAndView.of(getView(), model)); + + for (var oob : oobViews.entrySet()) { + views.add(ModelAndView.of(oob.getKey(), oob.getValue())); + } + + return views.iterator(); + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java new file mode 100644 index 0000000000..fbf6f91919 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java @@ -0,0 +1,66 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import org.jspecify.annotations.Nullable; + +import io.jooby.Extension; +import io.jooby.Jooby; + +/** + * The primary extension for enabling first-class HTMX support in a Jooby application. + * + *

Installing this module registers the {@link HtmxTemplateEngine}, which intercepts {@code + * HtmxModelAndView} responses and enables advanced features like Out-Of-Band (OOB) template + * swapping and declarative HTTP headers. + * + *

Usage: + * + *

{@code
+ * {
+ * // Basic installation
+ * install(new HtmxModule());
+ *
+ * // Installation with a global HTMX error handler
+ * install(new HtmxModule(new MyHtmxErrorHandler()));
+ * }
+ * }
+ */ +public class HtmxModule implements Extension { + + private @Nullable HtmxErrorHandler errorHandler; + + /** + * Creates a new HTMX module with a custom global error handler. + * + * @param errorHandler The handler to process and format exceptions into HTMX-compatible + * responses. + */ + public HtmxModule(HtmxErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + /** Creates a new HTMX module with default settings. */ + public HtmxModule() {} + + /** + * Installs the HTMX extension into the Jooby application. + * + * @param app The target Jooby application. + * @throws Exception If an error occurs during installation. + */ + @Override + public void install(Jooby app) throws Exception { + + if (errorHandler != null) { + app.error(errorHandler.toErrorHandler()); + } + var htmxEngine = new HtmxTemplateEngine(); + app.encoder(htmxEngine); + // validate and setup engines: + app.onStarting(() -> htmxEngine.init(app)); + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java new file mode 100644 index 0000000000..4b661515b0 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java @@ -0,0 +1,310 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static java.util.Optional.ofNullable; + +import java.util.*; + +import org.jspecify.annotations.Nullable; + +import io.jooby.Context; +import io.jooby.StatusCode; +import io.jooby.json.JsonEncoder; + +/** + * An imperative builder for constructing HTMX responses safely and fluently. + * + *

This class allows developers to explicitly orchestrate complex HTMX interactions directly from + * the controller, such as triggering client-side events, chaining Out-Of-Band (OOB) template swaps, + * and managing HTTP status code behaviors (e.g., automatically upgrading a 204 No Content to a 200 + * OK if HTML views are attached). + */ +public class HtmxResponse { + + private final @Nullable String view; + private final Object model; + private @Nullable StatusCode status; + + private final Map headers = new LinkedHashMap<>(); + + private final Map oobs = new LinkedHashMap<>(); + private final Map triggers = new LinkedHashMap<>(); + private final Map triggersAfterSettle = new LinkedHashMap<>(); + private final Map triggersAfterSwap = new LinkedHashMap<>(); + + private HtmxResponse(@Nullable String view, Object model) { + this.view = view; + this.model = model; + } + + /** + * Creates an HtmxResponse that renders a specific view template with the provided model. + * + * @param view The classpath location of the template. + * @param model The data model to pass to the template engine. + * @return A new HtmxResponse instance. + */ + public static HtmxResponse view(String view, @Nullable Object model) { + return new HtmxResponse(view, ofNullable(model).orElse(Map.of())); + } + + /** + * Creates an HtmxResponse that renders a specific view template with an empty model. + * + * @param view The classpath location of the template. + * @return A new HtmxResponse instance. + */ + public static HtmxResponse view(String view) { + return view(view, null); + } + + /** + * Creates an empty action-only response. + * + *

Defaults the HTTP status to {@link StatusCode#NO_CONTENT} (204). HTMX interprets a 204 as a + * successful request but will not attempt to swap any content into the DOM. + * + * @return A new HtmxResponse instance. + */ + public static HtmxResponse empty() { + return empty(StatusCode.NO_CONTENT); + } + + /** + * Creates an empty action-only response with a specific status code. + * + * @param status The status code to return. + * @return A new HtmxResponse instance. + */ + public static HtmxResponse empty(StatusCode status) { + var res = new HtmxResponse(null, Map.of()); + res.status = status; + return res; + } + + // --- Builder Methods --- + + /** + * Sets the HTTP status code for the response. + * + * @param status The status code. + * @return This builder instance. + */ + public HtmxResponse status(StatusCode status) { + this.status = status; + return this; + } + + /** + * Triggers a client-side event immediately using the {@code HX-Trigger} header. + * + * @param eventName The name of the event to trigger. + * @return This builder instance. + */ + public HtmxResponse trigger(String eventName) { + this.triggers.put(eventName, null); + return this; + } + + /** + * Triggers a client-side event with a JSON payload immediately using the {@code HX-Trigger} + * header. + * + * @param eventName The name of the event to trigger. + * @param jsonPayload The event detail to be serialized into JSON. + * @return This builder instance. + */ + public HtmxResponse trigger(String eventName, Object jsonPayload) { + this.triggers.put(eventName, jsonPayload); + return this; + } + + /** + * Triggers a client-side event after the settling phase using {@code HX-Trigger-After-Settle}. + * + * @param eventName The name of the event to trigger. + * @param value The event detail to be serialized into JSON, or null. + * @return This builder instance. + */ + public HtmxResponse triggerAfterSettle(String eventName, @Nullable Object value) { + this.triggersAfterSettle.put(eventName, value); + return this; + } + + /** + * Triggers a client-side event after the swap phase using {@code HX-Trigger-After-Swap}. + * + * @param eventName The name of the event to trigger. + * @param value The event detail to be serialized into JSON, or null. + * @return This builder instance. + */ + public HtmxResponse triggerAfterSwap(String eventName, @Nullable Object value) { + this.triggersAfterSwap.put(eventName, value); + return this; + } + + /** + * Instructs HTMX to swap the response into a different target element. Sets the {@code + * HX-Retarget} header. + * + * @param targetSelector The CSS selector of the new target. + * @return This builder instance. + */ + public HtmxResponse target(String targetSelector) { + return header("HX-Retarget", targetSelector); + } + + /** + * Overrides the client-side swap logic for this specific response. Sets the {@code HX-Reswap} + * header. + * + * @param swapStyle The swap style (e.g., "innerHTML", "outerHTML", "none"). + * @return This builder instance. + */ + public HtmxResponse swap(String swapStyle) { + return header("HX-Reswap", swapStyle); + } + + /** + * Pushes a new URL into the browser's history stack. Sets the {@code HX-Push-Url} header. + * + * @param url The URL to push. Use "false" to explicitly prevent history pushing. + * @return This builder instance. + */ + public HtmxResponse pushUrl(String url) { + return header("HX-Push-Url", url); + } + + /** + * Forces the client to perform a full-page redirect to the specified URL. Sets the {@code + * HX-Redirect} header. + * + * @param url The destination URL. + * @return This builder instance. + */ + public HtmxResponse redirect(String url) { + return header("HX-Redirect", url); + } + + /** + * Forces the client to perform a full-page reload. Sets the {@code HX-Refresh: true} header. + * + * @return This builder instance. + */ + public HtmxResponse refresh() { + return header("HX-Refresh", "true"); + } + + /** + * Adds a custom header to the HTMX response. + * + * @param name The header name. + * @param value The header value. + * @return This builder instance. + */ + public HtmxResponse header(String name, String value) { + this.headers.put(name, value); + return this; + } + + /** + * Instructs HTMX to render an out-of-band (OOB) swap using the specified view template. The model + * provided to the main response will be automatically shared with this OOB template. + * + * @param oobView The classpath location of the OOB template. + * @return This builder instance. + */ + public HtmxResponse addOob(String oobView) { + return addOob(oobView, model); + } + + /** + * Adds an out-of-band (OOB) swap to this response, using the specified view template and + * associated data model. The OOB swap allows rendering an HTML fragment outside the regular + * content replacement target. + * + * @param oobView The classpath location of the OOB view template. + * @param model The data model to associate with the OOB view template. + * @return This builder instance. + */ + public HtmxResponse addOob(String oobView, @Nullable Object model) { + this.oobs.put(oobView, ofNullable(model).orElse(Map.of())); + return this; + } + + /** + * Sends the HTTP response based on the configuration of the current HtmxResponse instance. If a + * view is set, it returns a rendered {@code HtmxModelAndView} containing the view and model. + * Otherwise, it sends the HTTP status directly through the provided context. Headers are written + * to the context before forming the response. + * + * @param ctx The HTTP {@code Context} object representing the current request and response + * context. + * @return The HTTP context object. + */ + public Context send(Context ctx) { + writeHeaders(ctx); + var hasViews = view != null || !oobs.isEmpty(); + if (status != null) { + if (status == StatusCode.NO_CONTENT && hasViews) { + // HTTP 204 cannot contain a body. Upgrade to 200 OK if we are sending HTML. + ctx.setResponseCode(StatusCode.OK); + } else { + // Respect user's 422, 201, etc. + ctx.setResponseCode(status); + } + } + if (hasViews) { + HtmxModelAndView htmxView; + if (view == null) { + var oobIter = oobs.entrySet().iterator(); + var firstOob = oobIter.next(); + htmxView = new HtmxModelAndView<>(firstOob.getKey(), firstOob.getValue()); + + while (oobIter.hasNext()) { + var nextOob = oobIter.next(); + htmxView.addOob(nextOob.getKey(), nextOob.getValue()); + } + } else { + htmxView = new HtmxModelAndView<>(view, model); + oobs.forEach(htmxView::addOob); + } + return ctx.render(htmxView); + } else { + return ctx.send(status != null ? status : StatusCode.NO_CONTENT); + } + } + + /** + * Called internally to safely encode and write all headers directly to the Jooby Context. + * + * @param ctx The active request context. + */ + private void writeHeaders(Context ctx) { + // Write simple static headers + headers.forEach(ctx::setResponseHeader); + + // Safely encode and write dynamic triggers + writeTriggerMap(ctx, "HX-Trigger", triggers); + writeTriggerMap(ctx, "HX-Trigger-After-Settle", triggersAfterSettle); + writeTriggerMap(ctx, "HX-Trigger-After-Swap", triggersAfterSwap); + } + + private void writeTriggerMap( + Context ctx, String headerName, Map triggerMap) { + if (triggerMap.isEmpty()) return; + + boolean hasPayloads = triggerMap.values().stream().anyMatch(Objects::nonNull); + + if (!hasPayloads) { + ctx.setResponseHeader(headerName, String.join(", ", triggerMap.keySet())); + } else { + var encoder = ctx.require(JsonEncoder.class); + ctx.setResponseHeader(headerName, encoder.encode(triggerMap)); + } + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java new file mode 100644 index 0000000000..46f9b72b54 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java @@ -0,0 +1,82 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import io.jooby.*; +import io.jooby.output.Output; + +/** + * Intercepts {@link HtmxModelAndView} returns and streams multiple templates sequentially to the + * HTMX client. + * + *

Note: This class is not a standalone template engine (such as Handlebars or + * Freemarker). Instead, it acts as a composite delegator. When an {@link HtmxModelAndView} is + * detected, this engine resolves the actual, registered {@link TemplateEngine} capable of handling + * the views. It then uses that underlying engine to render both the primary view and all attached + * Out-Of-Band (OOB) views, concatenating their output into a single HTTP response payload. + * + * @author edgar + * @since 4.5.0 + */ +public class HtmxTemplateEngine implements TemplateEngine.OnTop { + + private List engines; + + void init(Jooby app) { + engines = new ArrayList<>(app.getRouter().getTemplateEngines()); + engines.remove(this); + if (engines.isEmpty()) { + throw new IllegalStateException("No template engines registered"); + } + } + + @Override + public Output render(Context ctx, ModelAndView modelAndView) throws Exception { + if (modelAndView instanceof HtmxModelAndView htmxView) { + var engineEncoder = resolveTemplateEngine(htmxView); + if (engineEncoder == null) { + throw new IllegalStateException( + "No template engine registered to handle: " + htmxView.getView()); + } + var composite = ctx.getOutputFactory().newComposite(); + for (ModelAndView mv : htmxView) { + composite.write(engineEncoder.encode(ctx, mv).asByteBuffer()); + } + return composite; + } + return null; + } + + /** + * Resolves a {@link TemplateEngine} instance capable of rendering the specified {@link + * ModelAndView}. Iterates through the available template engines in the context, returning the + * first one that supports the provided model and view. + * + * @param mv The {@link ModelAndView} to be rendered. The method determines its compatibility with + * the available template engines. + * @return The {@link TemplateEngine} capable of rendering the provided {@link ModelAndView}, or + * {@code null} if no suitable engine is found. + */ + private @Nullable TemplateEngine resolveTemplateEngine(ModelAndView mv) { + // Find the encoder that handles standard ModelAndView + for (var engine : engines) { + if (engine.supports(mv)) { + return engine; + } + } + return null; + } + + @Override + public boolean supports(ModelAndView modelAndView) { + return modelAndView instanceof HtmxModelAndView; + } +} diff --git a/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java new file mode 100644 index 0000000000..0089f64d67 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java @@ -0,0 +1,47 @@ +/** + * Provides declarative HTMX support for Jooby MVC routes. + * + *

This package contains annotations processed at compile-time by the Jooby HTMX APT generator. + * It allows developers to define partial HTML responses, out-of-band swaps, and dynamic client-side + * behaviors directly on their route methods without polluting business logic with header + * management. + * + *

Core Concepts

+ * + *
    + *
  • Fragments: Use {@link io.jooby.annotation.htmx.HxView} to define the HTML fragment + * to render. + *
  • Content Negotiation: Define the {@code layout} attribute in {@code @HxView} to + * automatically handle direct browser navigation versus HTMX AJAX requests. + *
  • Behaviors: Use annotations like {@link io.jooby.annotation.htmx.HxTrigger} or {@link + * io.jooby.annotation.htmx.HxTarget} to append {@code HX-} headers to the response. + *
+ * + *

Example Usage

+ * + *
{@code
+ * @Path("/users")
+ * public class UserController {
+ *
+ *     @POST
+ *     @HxView(
+ *         value = "users/row.hbs",
+ *         layout = "layouts/main.hbs",
+ *         errorView = "users/form.hbs",
+ *         errorTarget = "#user-form"
+ *     )
+ *     @HxTrigger(value = "userListUpdated", phase = Phase.AFTER_SETTLE)
+ *     @HxOob("widgets/total-count")
+ *     public User saveUser(UserDto dto) {
+ *         // Business logic here. The APT generator handles view resolution,
+ *         // validation errors, and HTMX headers.
+ *         return repository.save(dto);
+ *     }
+ * }
+ * }
+ * + * @since 4.5.0 + * @author edgar + */ +@org.jspecify.annotations.NullMarked +package io.jooby.htmx; diff --git a/modules/jooby-htmx/src/main/java/module-info.java b/modules/jooby-htmx/src/main/java/module-info.java new file mode 100644 index 0000000000..6e8c65ebbf --- /dev/null +++ b/modules/jooby-htmx/src/main/java/module-info.java @@ -0,0 +1,54 @@ +/** + * Provides declarative HTMX support for Jooby MVC routes. + * + *

This package contains annotations processed at compile-time by the Jooby HTMX APT generator. + * It allows developers to define partial HTML responses, out-of-band swaps, and dynamic client-side + * behaviors directly on their route methods without polluting business logic with header + * management. + * + *

Core Concepts

+ * + *
    + *
  • Fragments: Use {@link io.jooby.annotation.htmx.HxView} to define the HTML fragment + * to render. + *
  • Content Negotiation: Define the {@code layout} attribute in {@code @HxView} to + * automatically handle direct browser navigation versus HTMX AJAX requests. + *
  • Behaviors: Use annotations like {@link io.jooby.annotation.htmx.HxTrigger} or {@link + * io.jooby.annotation.htmx.HxTarget} to append {@code HX-} headers to the response. + *
+ * + *

Example Usage

+ * + *
{@code
+ * @Path("/users")
+ * public class UserController {
+ *
+ *     @POST
+ *     @HxView(
+ *         value = "users/row.hbs",
+ *         layout = "layouts/main.hbs",
+ *         errorView = "users/form.hbs",
+ *         errorTarget = "#user-form"
+ *     )
+ *     @HxTrigger(value = "userListUpdated", phase = Phase.AFTER_SETTLE)
+ *     @HxOob("widgets/total-count")
+ *     public User saveUser(UserDto dto) {
+ *         // Business logic here. The APT generator handles view resolution,
+ *         // validation errors, and HTMX headers.
+ *         return repository.save(dto);
+ *     }
+ * }
+ * }
+ * + * @since 4.5.0 + * @author edgar + */ +module io.jooby.htmx { + exports io.jooby.annotation.htmx; + exports io.jooby.htmx; + + requires io.jooby; + requires static org.jspecify; + requires typesafe.config; + requires org.slf4j; +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java new file mode 100644 index 0000000000..40ca1ad88e --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java @@ -0,0 +1,177 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.Context; +import io.jooby.json.JsonEncoder; +import io.jooby.value.Value; + +class HtmxContextTest { + + private Context ctx; + private HtmxContext htmx; + + @BeforeEach + void setUp() { + ctx = mock(Context.class); + htmx = new HtmxContext(ctx); + } + + // --- Reader Tests --- + + @Test + void shouldReadBooleanHeadersWhenTrue() { + mockHeader("HX-Boosted", true); + mockHeader("HX-Request", true); + mockHeader("HX-History-Restore-Request", true); + + assertTrue(htmx.isBoosted()); + assertTrue(htmx.isHtmxRequest()); + assertTrue(htmx.isHistoryRestoreRequest()); + } + + @Test + void shouldReadBooleanHeadersWhenFalseOrMissing() { + mockHeader("HX-Boosted", false); + mockHeader("HX-Request", false); + mockHeader("HX-History-Restore-Request", false); + + assertFalse(htmx.isBoosted()); + assertFalse(htmx.isHtmxRequest()); + assertFalse(htmx.isHistoryRestoreRequest()); + } + + @Test + void shouldReadStringHeaders() { + Value urlValue = mock(Value.class); + when(urlValue.valueOrNull()).thenReturn("https://jooby.io"); + when(ctx.header("HX-Current-Url")).thenReturn(urlValue); + + Value targetValue = mock(Value.class); + when(targetValue.valueOrNull()).thenReturn("#main-div"); + when(ctx.header("HX-Target")).thenReturn(targetValue); + + assertEquals("https://jooby.io", htmx.getCurrentUrl()); + assertEquals("#main-div", htmx.getTarget()); + } + + @Test + void shouldReturnNullForMissingStringHeaders() { + Value missingValue = mock(Value.class); + when(missingValue.valueOrNull()).thenReturn(null); + when(ctx.header("HX-Current-Url")).thenReturn(missingValue); + when(ctx.header("HX-Target")).thenReturn(missingValue); + + assertNull(htmx.getCurrentUrl()); + assertNull(htmx.getTarget()); + } + + // --- Modifier Tests --- + + @Test + void shouldSetModifierHeaders() { + assertSame(htmx, htmx.pushUrl("/new-url")); + verify(ctx).setResponseHeader("HX-Push-Url", "/new-url"); + + assertSame(htmx, htmx.replaceUrl("/replace-url")); + verify(ctx).setResponseHeader("HX-Replace-Url", "/replace-url"); + + assertSame(htmx, htmx.redirect("/redirect")); + verify(ctx).setResponseHeader("HX-Redirect", "/redirect"); + + assertSame(htmx, htmx.refresh()); + verify(ctx).setResponseHeader("HX-Refresh", true); + + assertSame(htmx, htmx.reswap("outerHTML")); + verify(ctx).setResponseHeader("HX-Reswap", "outerHTML"); + + assertSame(htmx, htmx.retarget("#error-box")); + verify(ctx).setResponseHeader("HX-Retarget", "#error-box"); + } + + // --- Trigger Tests (String Join Branch) --- + + @Test + void shouldTriggerEventsWithoutPayloads() { + htmx.trigger("event1"); + verify(ctx).setResponseHeader("HX-Trigger", "event1"); + + // Add a second event to verify joining logic + htmx.trigger("event2", null); + verify(ctx).setResponseHeader("HX-Trigger", "event1, event2"); + + htmx.triggerAfterSettle("settle1"); + verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "settle1"); + + htmx.triggerAfterSwap("swap1"); + verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "swap1"); + } + + // --- Trigger Tests (JSON Encoder Branch) --- + + @Test + void shouldTriggerEventsWithPayloads() { + JsonEncoder encoder = mock(JsonEncoder.class); + when(ctx.require(JsonEncoder.class)).thenReturn(encoder); + + // Simulate JSON encoding output + when(encoder.encode(any())).thenReturn("{\"event1\":{\"key\":\"value\"}}"); + + Map payload = Map.of("key", "value"); + + // HX-Trigger + htmx.trigger("event1", payload); + verify(encoder, times(1)).encode(any()); + verify(ctx).setResponseHeader("HX-Trigger", "{\"event1\":{\"key\":\"value\"}}"); + + // HX-Trigger-After-Settle + htmx.triggerAfterSettle("event1", payload); + verify(encoder, times(2)).encode(any()); + verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "{\"event1\":{\"key\":\"value\"}}"); + + // HX-Trigger-After-Swap + htmx.triggerAfterSwap("event1", payload); + verify(encoder, times(3)).encode(any()); + verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "{\"event1\":{\"key\":\"value\"}}"); + } + + // --- Defensive Branch Coverage --- + + @Test + void shouldSafelyIgnoreEmptyMapInUpdateTriggerHeader() throws Exception { + // Uses reflection to hit the defensive `if (triggerMap.isEmpty()) return;` + // which is practically unreachable via public methods since .put() happens first. + Method updateMethod = + HtmxContext.class.getDeclaredMethod("updateTriggerHeader", String.class, Map.class); + updateMethod.setAccessible(true); + + // Invoke with empty map + updateMethod.invoke(htmx, "HX-Trigger", Collections.emptyMap()); + + // Verify context was never touched + verify(ctx, never()).setResponseHeader(anyString(), anyString()); + verify(ctx, never()).require(JsonEncoder.class); + } + + // --- Helper Methods --- + + private void mockHeader(String headerName, boolean value) { + Value val = mock(Value.class); + when(val.booleanValue(false)).thenReturn(value); + when(ctx.header(headerName)).thenReturn(val); + } +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java new file mode 100644 index 0000000000..3b574ecbc6 --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java @@ -0,0 +1,128 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.event.Level; +import org.slf4j.spi.LoggingEventBuilder; + +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.Router; +import io.jooby.StatusCode; +import io.jooby.value.Value; + +class HtmxErrorHandlerTest { + + private Context ctx; + private Router router; + private Logger logger; + private LoggingEventBuilder logBuilder; + private Value hxRequestValue; + + private boolean applyWasCalled; + private HtmxResponse mockResponse; + private HtmxErrorHandler htmxErrorHandler; + + @BeforeEach + void setUp() { + ctx = mock(Context.class); + router = mock(Router.class); + logger = mock(Logger.class); + logBuilder = mock(LoggingEventBuilder.class); + hxRequestValue = mock(Value.class); + mockResponse = mock(HtmxResponse.class); + + when(ctx.getRouter()).thenReturn(router); + when(router.getLog()).thenReturn(logger); + when(logger.atLevel(any())).thenReturn(logBuilder); + when(ctx.header("HX-Request")).thenReturn(hxRequestValue); + + applyWasCalled = false; + + // Create a concrete instance to test the default method behavior + htmxErrorHandler = + (context, cause, code) -> { + applyWasCalled = true; + return mockResponse; + }; + } + + @Test + void shouldIgnoreNonHtmxRequests() throws Exception { + when(hxRequestValue.booleanValue(false)).thenReturn(false); + + ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler(); + joobyHandler.apply(ctx, new RuntimeException("Test"), StatusCode.SERVER_ERROR); + + // Ensure apply() was never reached + assertTrue(!applyWasCalled, "Apply should not be called for non-HTMX requests"); + verify(mockResponse, never()).send(any()); + } + + @Test + void shouldIgnoreHtmxDirectAccessExceptions() throws Exception { + when(hxRequestValue.booleanValue(false)).thenReturn(true); + + ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler(); + HtmxDirectAccessException directAccessException = + new HtmxDirectAccessException("Direct access block"); + + joobyHandler.apply(ctx, directAccessException, StatusCode.NOT_ACCEPTABLE); + + // Ensure apply() was never reached + assertTrue(!applyWasCalled, "Apply should not be called for HtmxDirectAccessException"); + verify(mockResponse, never()).send(any()); + } + + @Test + void shouldHandleClientErrorsWithDebugLog() throws Exception { + when(hxRequestValue.booleanValue(false)).thenReturn(true); + + ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler(); + RuntimeException cause = new RuntimeException("Validation Failed"); + + // Act: 422 Unprocessable Entity (< 500) + joobyHandler.apply(ctx, cause, StatusCode.UNPROCESSABLE_ENTITY); + + // Assert: Handled successfully + assertTrue(applyWasCalled, "Apply should be called for valid HTMX client errors"); + verify(mockResponse).send(ctx); + + // Assert: Logged at DEBUG level + verify(logger).atLevel(Level.DEBUG); + verify(logBuilder).log(anyString(), any(Throwable.class)); + } + + @Test + void shouldHandleServerErrorsWithErrorLog() throws Exception { + when(hxRequestValue.booleanValue(false)).thenReturn(true); + + ErrorHandler joobyHandler = htmxErrorHandler.toErrorHandler(); + RuntimeException cause = new RuntimeException("Database Offline"); + + // Act: 500 Server Error (>= 500) + joobyHandler.apply(ctx, cause, StatusCode.SERVER_ERROR); + + // Assert: Handled successfully + assertTrue(applyWasCalled, "Apply should be called for valid HTMX server errors"); + verify(mockResponse).send(ctx); + + // Assert: Logged at ERROR level + verify(logger).atLevel(Level.ERROR); + verify(logBuilder).log(anyString(), any(Throwable.class)); + } +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java new file mode 100644 index 0000000000..37f61ddcc5 --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java @@ -0,0 +1,64 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.ErrorHandler; +import io.jooby.Jooby; + +class HtmxModuleTest { + + private Jooby app; + + @BeforeEach + void setUp() { + app = mock(Jooby.class); + } + + @Test + void shouldInstallWithoutErrorHandler() throws Exception { + HtmxModule module = new HtmxModule(); + module.install(app); + + // Verify error handler was NOT registered + verify(app, never()).error(any(ErrorHandler.class)); + + // Verify the template engine WAS registered + verify(app).encoder(any(HtmxTemplateEngine.class)); + + // Verify the init lifecycle hook was registered + verify(app).onStarting(any()); + } + + @Test + void shouldInstallWithErrorHandler() throws Exception { + // 1. Mock the HTMX Error Handler and its conversion method + HtmxErrorHandler htmxErrorHandler = mock(HtmxErrorHandler.class); + ErrorHandler joobyErrorHandler = mock(ErrorHandler.class); + when(htmxErrorHandler.toErrorHandler()).thenReturn(joobyErrorHandler); + + // 2. Initialize and install the module + HtmxModule module = new HtmxModule(htmxErrorHandler); + module.install(app); + + // 3. Verify the converted error handler WAS registered + verify(app).error(joobyErrorHandler); + + // 4. Verify the template engine WAS registered + verify(app).encoder(any(HtmxTemplateEngine.class)); + + // 5. Verify the init lifecycle hook was registered + verify(app).onStarting(any()); + } +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java new file mode 100644 index 0000000000..b948cfeafc --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java @@ -0,0 +1,181 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.jooby.Context; +import io.jooby.StatusCode; +import io.jooby.json.JsonEncoder; + +class HtmxResponseTest { + + private Context ctx; + + @BeforeEach + void setUp() { + ctx = mock(Context.class); + when(ctx.render(any())).thenReturn(ctx); + when(ctx.send(any(StatusCode.class))).thenReturn(ctx); + } + + // --- Static Initializers --- + + @Test + void shouldCreateViewResponse() { + var response = HtmxResponse.view("main.hbs"); + response.send(ctx); + + // Status is null by default for pure view responses, falls back to Jooby's default 200 + verify(ctx, never()).setResponseCode(any()); + verify(ctx).render(any(HtmxModelAndView.class)); + } + + @Test + void shouldCreateViewResponseWithModel() { + var response = HtmxResponse.view("main.hbs", Map.of("key", "val")); + response.send(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class); + verify(ctx).render(captor.capture()); + + assertEquals("main.hbs", captor.getValue().getView()); + assertEquals(Map.of("key", "val"), captor.getValue().getModel()); + } + + @Test + void shouldCreateEmptyResponseAndSend204() { + HtmxResponse.empty().send(ctx); + verify(ctx).setResponseCode(StatusCode.NO_CONTENT); // status != null block + verify(ctx).send(StatusCode.NO_CONTENT); // fallback send block + } + + @Test + void shouldHandleExplicitNullStatusForEmptyResponse() { + HtmxResponse.empty(null).send(ctx); + // If explicitly forced to null, it sends 204 fallback at the very end + verify(ctx).send(StatusCode.NO_CONTENT); + } + + // --- Builder Headers & Triggers --- + + @Test + void shouldBuildAndWriteStaticHeaders() { + HtmxResponse.empty() + .target("#app") + .swap("outerHTML") + .pushUrl("/new-url") + .redirect("/redirect") + .refresh() + .header("Custom-Header", "Value") + .send(ctx); + + verify(ctx).setResponseHeader("HX-Retarget", "#app"); + verify(ctx).setResponseHeader("HX-Reswap", "outerHTML"); + verify(ctx).setResponseHeader("HX-Push-Url", "/new-url"); + verify(ctx).setResponseHeader("HX-Redirect", "/redirect"); + verify(ctx).setResponseHeader("HX-Refresh", "true"); + verify(ctx).setResponseHeader("Custom-Header", "Value"); + } + + @Test + void shouldWriteTriggersWithoutPayloads() { + HtmxResponse.empty() + .trigger("event1") + .triggerAfterSettle("event2", null) + .triggerAfterSwap("event3", null) + .send(ctx); + + verify(ctx).setResponseHeader("HX-Trigger", "event1"); + verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "event2"); + verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "event3"); + } + + @Test + void shouldWriteTriggersWithJsonPayloads() { + JsonEncoder encoder = mock(JsonEncoder.class); + when(ctx.require(JsonEncoder.class)).thenReturn(encoder); + when(encoder.encode(any())).thenReturn("{\"event\":{\"data\":1}}"); + + Map payload = Map.of("data", 1); + + HtmxResponse.empty() + .trigger("event1", payload) + .triggerAfterSettle("event2", payload) + .triggerAfterSwap("event3", payload) + .send(ctx); + + verify(encoder, times(3)).encode(any()); + verify(ctx).setResponseHeader("HX-Trigger", "{\"event\":{\"data\":1}}"); + verify(ctx).setResponseHeader("HX-Trigger-After-Settle", "{\"event\":{\"data\":1}}"); + verify(ctx).setResponseHeader("HX-Trigger-After-Swap", "{\"event\":{\"data\":1}}"); + } + + // --- OOB and Status Code Intelligence --- + + @Test + void shouldUpgrade204To200WhenSendingHtmlViews() { + // We create an empty response (default 204), but then add a view. + // It MUST upgrade to 200, because HTTP 204 strictly forbids body content! + HtmxResponse.empty().addOob("toast.hbs").send(ctx); + + verify(ctx).setResponseCode(StatusCode.OK); + verify(ctx).render(any(HtmxModelAndView.class)); + } + + @Test + void shouldRespectExplicitCustomStatusCodeWhenSendingViews() { + HtmxResponse.view("form.hbs") + .status(StatusCode.UNPROCESSABLE_ENTITY) // 422 Validations failed + .send(ctx); + + verify(ctx).setResponseCode(StatusCode.UNPROCESSABLE_ENTITY); + verify(ctx).render(any(HtmxModelAndView.class)); + } + + @Test + void shouldPromoteFirstOobToMainViewIfPrimaryViewIsNull() { + // If no primary view exists, the first OOB added becomes the root view + // so that HtmxModelAndView functions correctly. + HtmxResponse.empty() + .addOob("oob1.hbs", Map.of("id", 1)) + .addOob("oob2.hbs", null) // null model falls back to empty map + .send(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class); + verify(ctx).render(captor.capture()); + + HtmxModelAndView rendered = captor.getValue(); + + // First OOB was promoted + assertEquals("oob1.hbs", rendered.getView()); + assertEquals(Map.of("id", 1), rendered.getModel()); + } + + @Test + void shouldAppendOobsToPrimaryView() { + Object parentModel = Map.of("parent", "data"); + + HtmxResponse.view("main.hbs", parentModel) + .addOob("oob1.hbs") // Should inherit parentModel + .addOob("oob2.hbs", Map.of("child", "data")) // Should use custom model + .send(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HtmxModelAndView.class); + verify(ctx).render(captor.capture()); + + HtmxModelAndView rendered = captor.getValue(); + assertEquals("main.hbs", rendered.getView()); + } +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java new file mode 100644 index 0000000000..284369feb4 --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java @@ -0,0 +1,157 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.jooby.Context; +import io.jooby.Jooby; +import io.jooby.ModelAndView; +import io.jooby.Router; +import io.jooby.TemplateEngine; +import io.jooby.output.BufferedOutput; +import io.jooby.output.Output; +import io.jooby.output.OutputFactory; + +class HtmxTemplateEngineTest { + + private HtmxTemplateEngine engine; + private Context ctx; + private Router router; + private Jooby app; + + @BeforeEach + void setUp() { + engine = new HtmxTemplateEngine(); + ctx = mock(Context.class); + router = mock(Router.class); + app = mock(Jooby.class); + + when(ctx.getRouter()).thenReturn(router); + when(app.getRouter()).thenReturn(router); + } + + // --- Lifecycle / Init Tests --- + + @Test + void shouldThrowIllegalStateExceptionWhenNoOtherTemplateEnginesRegistered() { + // Router only has the HtmxTemplateEngine registered, no underlying engines like Handlebars + when(router.getTemplateEngines()).thenReturn(List.of(engine)); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> engine.init(app)); + + assertEquals("No template engines registered", ex.getMessage()); + } + + // --- Supports Tests --- + + @Test + void shouldSupportHtmxModelAndView() { + HtmxModelAndView htmxView = mock(HtmxModelAndView.class); + assertTrue(engine.supports(htmxView)); + } + + @Test + void shouldNotSupportStandardModelAndView() { + ModelAndView standardView = ModelAndView.of("view.hbs", null); + assertFalse(engine.supports(standardView)); + } + + // --- Render Tests --- + + @Test + void shouldReturnNullForStandardModelAndView() throws Exception { + ModelAndView standardView = ModelAndView.of("view.hbs", null); + assertNull(engine.render(ctx, standardView)); + } + + @Test + void shouldThrowIllegalStateExceptionWhenNoTemplateEngineFound() { + // 1. Setup incompatible engine and initialize the HtmxTemplateEngine + TemplateEngine incompatibleEngine = mock(TemplateEngine.class); + when(router.getTemplateEngines()).thenReturn(Arrays.asList(engine, incompatibleEngine)); + engine.init(app); // Cache the engines + + // 2. Setup the HTMX view + HtmxModelAndView htmxView = mock(HtmxModelAndView.class); + when(htmxView.getView()).thenReturn("missing.hbs"); + when(incompatibleEngine.supports(htmxView)).thenReturn(false); + + // 3. Execute and verify + IllegalStateException ex = + assertThrows(IllegalStateException.class, () -> engine.render(ctx, htmxView)); + + assertEquals("No template engine registered to handle: missing.hbs", ex.getMessage()); + } + + @Test + void shouldRenderMultipleTemplatesIntoCompositeOutput() throws Exception { + // 1. Mock the HtmxModelAndView and its iterator (simulating multiple OOB views) + HtmxModelAndView htmxView = mock(HtmxModelAndView.class); + ModelAndView primaryView = ModelAndView.of("main.hbs", null); + ModelAndView oobView = ModelAndView.of("oob.hbs", null); + List views = Arrays.asList(primaryView, oobView); + + when(htmxView.iterator()).thenReturn(views.iterator()); + + // 2. Mock Delegate Engines + TemplateEngine delegateEngine = mock(TemplateEngine.class); + when(delegateEngine.supports(htmxView)).thenReturn(true); + + TemplateEngine incompatibleEngine = mock(TemplateEngine.class); + when(incompatibleEngine.supports(htmxView)).thenReturn(false); + + // Register and initialize engines (HtmxTemplateEngine should remove itself from the cached + // list) + when(router.getTemplateEngines()) + .thenReturn(Arrays.asList(engine, incompatibleEngine, delegateEngine)); + engine.init(app); + + // 3. Mock the Output Pipeline + OutputFactory outputFactory = mock(OutputFactory.class); + when(ctx.getOutputFactory()).thenReturn(outputFactory); + + BufferedOutput composite = mock(BufferedOutput.class); + when(outputFactory.newComposite()).thenReturn(composite); + + // Primary View Output + Output primaryOutput = mock(Output.class); + ByteBuffer primaryBuffer = ByteBuffer.wrap("primary".getBytes()); + when(primaryOutput.asByteBuffer()).thenReturn(primaryBuffer); + when(delegateEngine.encode(ctx, primaryView)).thenReturn(primaryOutput); + + // OOB View Output + Output oobOutput = mock(Output.class); + ByteBuffer oobBuffer = ByteBuffer.wrap("oob".getBytes()); + when(oobOutput.asByteBuffer()).thenReturn(oobBuffer); + when(delegateEngine.encode(ctx, oobView)).thenReturn(oobOutput); + + // 4. Execute + Output result = engine.render(ctx, htmxView); + + // 5. Verify + assertSame(composite, result, "Should return the composite output builder"); + + // Verify that the byte buffers were written to the composite sequentially + verify(composite).write(primaryBuffer); + verify(composite).write(oobBuffer); + } +} diff --git a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java new file mode 100644 index 0000000000..5235740e5a --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java @@ -0,0 +1,24 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.htmx; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; + +import org.junit.jupiter.api.Test; + +import io.jooby.annotation.htmx.HxTrigger; + +public class HxTriggerTest { + + @Test + void makeHappyEnumCoverage() { + assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.TRIGGER)); + assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.AFTER_SETTLE)); + assertTrue(EnumSet.allOf(HxTrigger.Phase.class).contains(HxTrigger.Phase.AFTER_SWAP)); + } +} diff --git a/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java b/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java index c5d1bd5ad0..83e7f9c18d 100644 --- a/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java +++ b/modules/jooby-jte/src/main/java/io/jooby/jte/JteModule.java @@ -87,7 +87,9 @@ public void install(Jooby application) { ServiceRegistry services = application.getServices(); services.put(TemplateEngine.class, templateEngine); // model and view - application.encoder(MediaType.html, new JteTemplateEngine(templateEngine)); + var jteTemplateEngine = new JteTemplateEngine(templateEngine); + application.encoder(MediaType.html, jteTemplateEngine); + services.listOf(io.jooby.TemplateEngine.class).add(jteTemplateEngine); // jte models application.encoder(new JteModelEncoder()); } diff --git a/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java b/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java index 2f513aee66..52451319d4 100644 --- a/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java +++ b/modules/jooby-pebble/src/main/java/io/jooby/pebble/PebbleModule.java @@ -15,6 +15,8 @@ import java.util.Locale; import java.util.concurrent.ExecutorService; +import org.jspecify.annotations.Nullable; + import com.typesafe.config.Config; import io.jooby.Environment; import io.jooby.Extension; @@ -253,7 +255,7 @@ private static String stripLeadingSlash(String value) { private static final List EXT = asList(".peb", ".pebble", ".html"); - private PebbleEngine.Builder builder; + private PebbleEngine.@Nullable Builder builder; private String templatesPath; @@ -286,7 +288,8 @@ public void install(Jooby application) throws Exception { if (builder == null) { builder = create().setTemplatesPath(templatesPath).build(application.getEnvironment()); } - application.encoder(new PebbleTemplateEngine(builder, EXT)); + var templateEngine = new PebbleTemplateEngine(builder, EXT); + application.encoder(templateEngine); ServiceRegistry services = application.getServices(); services.put(PebbleEngine.Builder.class, builder); diff --git a/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java b/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java index a5f8b28972..9f48d311cd 100644 --- a/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java +++ b/modules/jooby-pebble/src/test/java/io/jooby/pebble/PebbleModuleTest.java @@ -31,6 +31,7 @@ import io.jooby.Jooby; import io.jooby.ModelAndView; import io.jooby.ServiceRegistry; +import io.jooby.TemplateEngine; import io.jooby.output.Output; import io.jooby.test.MockContext; import io.pebbletemplates.pebble.PebbleEngine; @@ -141,14 +142,18 @@ public void renderWithLocale() throws Exception { // --- Branch and Line Coverage Tests --- @Test + @SuppressWarnings("unchecked") public void installDefault() throws Exception { Jooby app = mock(Jooby.class); Environment env = mock(Environment.class); Config config = mock(Config.class); ServiceRegistry registry = mock(ServiceRegistry.class); + ServiceRegistry.MultiBinder enginesBinder = + mock(ServiceRegistry.MultiBinder.class); when(app.getEnvironment()).thenReturn(env); when(app.getServices()).thenReturn(registry); + when(registry.listOf(TemplateEngine.class)).thenReturn(enginesBinder); when(env.getConfig()).thenReturn(config); when(env.isActive("dev", "test")).thenReturn(false); @@ -160,10 +165,15 @@ public void installDefault() throws Exception { } @Test + @SuppressWarnings("unchecked") public void installCustomBuilderConstructor() throws Exception { Jooby app = mock(Jooby.class); ServiceRegistry registry = mock(ServiceRegistry.class); + ServiceRegistry.MultiBinder enginesBinder = + mock(ServiceRegistry.MultiBinder.class); + when(app.getServices()).thenReturn(registry); + when(registry.listOf(TemplateEngine.class)).thenReturn(enginesBinder); PebbleEngine.Builder engineBuilder = new PebbleEngine.Builder(); PebbleModule module = new PebbleModule(engineBuilder); diff --git a/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java b/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java index a0c286e56c..367ffe696c 100644 --- a/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java +++ b/modules/jooby-thymeleaf/src/main/java/io/jooby/thymeleaf/ThymeleafModule.java @@ -262,10 +262,12 @@ public void install(Jooby application) { .build(application.getEnvironment()); } - application.encoder(new ThymeleafTemplateEngine(templateEngine, EXT)); + var thymeleafTE = new ThymeleafTemplateEngine(templateEngine, EXT); + application.encoder(thymeleafTE); ServiceRegistry services = application.getServices(); services.put(TemplateEngine.class, templateEngine); + services.listOf(io.jooby.TemplateEngine.class).add(thymeleafTE); } /** diff --git a/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java b/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java index 6ece463e85..ea22f1a288 100644 --- a/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java +++ b/modules/jooby-thymeleaf/src/test/java/io/jooby/thymeleaf/ThymeleafModuleTest.java @@ -45,10 +45,16 @@ class ThymeleafModuleTest { @Mock Environment env; @Mock ServiceRegistry registry; + private ServiceRegistry.MultiBinder enginesBinder; + @BeforeEach + @SuppressWarnings("unchecked") void setup() { + enginesBinder = mock(ServiceRegistry.MultiBinder.class); + lenient().when(app.getEnvironment()).thenReturn(env); lenient().when(app.getServices()).thenReturn(registry); + lenient().when(registry.listOf(io.jooby.TemplateEngine.class)).thenReturn(enginesBinder); // Make getProperty pass through the provided default value to simulate standard behavior lenient() @@ -69,6 +75,7 @@ void testDefaultConstructorInstall() { verify(app).encoder(any(ThymeleafTemplateEngine.class)); verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class)); + verify(enginesBinder).add(any(io.jooby.TemplateEngine.class)); } @Test @@ -79,6 +86,7 @@ void testStringPathConstructorInstall() { verify(app).encoder(any(ThymeleafTemplateEngine.class)); verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class)); + verify(enginesBinder).add(any(io.jooby.TemplateEngine.class)); } @Test @@ -89,6 +97,7 @@ void testPathObjectConstructorInstall(@TempDir Path tempDir) { verify(app).encoder(any(ThymeleafTemplateEngine.class)); verify(registry).put(eq(TemplateEngine.class), any(TemplateEngine.class)); + verify(enginesBinder).add(any(io.jooby.TemplateEngine.class)); } @Test @@ -103,6 +112,7 @@ void testTemplateEngineConstructorInstall() { // Verify it registered the exact instance provided verify(registry).put(TemplateEngine.class, mockEngine); + verify(enginesBinder).add(any(io.jooby.TemplateEngine.class)); } // --- BUILDER CONFIGURATION TESTS --- diff --git a/modules/pom.xml b/modules/pom.xml index adfd2c5586..d0e18b7762 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -82,6 +82,7 @@ jooby-pebble jooby-rocker jooby-thymeleaf + jooby-htmx jooby-camel diff --git a/tests/pom.xml b/tests/pom.xml index 31d9c572eb..307b25094b 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -182,6 +182,12 @@ ${jooby.version}
+ + io.jooby + jooby-htmx + ${jooby.version} + + io.jooby jooby-jsonrpc diff --git a/tests/src/test/java/io/jooby/i3936/Issue3936.java b/tests/src/test/java/io/jooby/i3936/Issue3936.java new file mode 100644 index 0000000000..06623c2a68 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/Issue3936.java @@ -0,0 +1,192 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import io.jooby.handlebars.HandlebarsModule; +import io.jooby.hibernate.validator.HibernateValidatorModule; +import io.jooby.htmx.HtmxErrorHandler; +import io.jooby.htmx.HtmxModule; +import io.jooby.htmx.HtmxResponse; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.test.TestUtil; +import okhttp3.FormBody; + +class Issue3936 { + + @ServerTest + void shouldUnderstandHtmxRequest(ServerTestRunner runner) { + runner + .define( + app -> { + app.install(new Jackson3Module()); + HtmxErrorHandler globalErrorHandler = + (ctx, cause, status) -> + HtmxResponse.empty(status) + .addOob( + "toast.hbs", + Map.of( + "message", + status.reason() + ": " + cause.getMessage(), + "isError", + true)); + app.install(new HtmxModule(globalErrorHandler)); + app.install(new HandlebarsModule(TestUtil.userdir("src/test/resources/htmx"))); + app.install(new HibernateValidatorModule()); + + app.mvc(new TaskUIHtmx_(new TaskRepo3936())); + }) + .ready( + http -> { + // 1. Index page loads normally + http.get( + "/", + rsp -> { + assertEquals(200, rsp.code()); + }); + + // No header => 406 + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.post( + "/tasks", + new FormBody.Builder().add("title", "Buy groceries").build(), + rsp -> { + assertEquals(406, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ +

message: Direct browser access to this HTMX fragment is not allowed.

+

status code: 406

+ """); + }); + + // 2. Add Task - Success Path + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.header("Hx-Request", "true"); + http.post( + "/tasks", + new FormBody.Builder().add("title", "Buy groceries").build(), + rsp -> { + assertEquals(200, rsp.code()); + assertEquals("taskAdded", rsp.header("HX-Trigger")); + + String body = rsp.body().string(); + assertThat(body) + .containsIgnoringWhitespaces( + """ +
+ Buy groceries +
+ """) + .containsIgnoringWhitespaces( + """ + 1 Tasks Remaining + """) + .containsIgnoringWhitespaces( + """ + Task added successfully! + """); + }); + + // 3.a simulate a network error => 500 response with Htmx error handler + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.header("Hx-Request", "true"); + http.post( + "/tasks", + new FormBody.Builder().add("title", "Wont save").build(), + rsp -> { + assertEquals(500, rsp.code()); + + String body = rsp.body().string(); + assertThat(body) + .containsIgnoringWhitespaces( + """ +
+
+ Server Error: Connection error! Please try again. +
+
+ """); + }); + + // 4. Load the initial board + http.header("Hx-Request", "true"); + http.get( + "/tasks", + rsp -> { + assertEquals(200, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ + + +
+ Buy groceries +
+ """); + }); + + // 5. Add Task - Validation Error (Sad Path) + // Should fail @Valid (e.g., title too short/blank) and return 422 + // as orchestrated by the @HxError class-level annotation + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.header("Hx-Request", "true"); + http.post( + "/tasks", + new FormBody.Builder().add("title", "a").build(), + rsp -> { + assertEquals(422, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ +
  • size must be between 3 and 25
  • + """); + }); + + // 6. Delete a task + // Returns an empty HtmxResponse but HTTP status should be 200 OK + http.header("Hx-Request", "true"); + http.delete( + "/tasks/123", + rsp -> { + assertEquals(200, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ + Task deleted! + """); + }); + + // 7. Reorder tasks + // Verifies passing a list of IDs via form parameters + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.header("Hx-Request", "true"); + http.post( + "/tasks/reorder", + new FormBody.Builder() + .add("taskIds", "3") + .add("taskIds", "1") + .add("taskIds", "2") + .build(), + rsp -> { + assertEquals(200, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ + Board saved. + """); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3936/Task3936.java b/tests/src/test/java/io/jooby/i3936/Task3936.java new file mode 100644 index 0000000000..5b2d85ff7a --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/Task3936.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +public record Task3936(String id, String title, boolean completed) {} diff --git a/tests/src/test/java/io/jooby/i3936/TaskBoard3936.java b/tests/src/test/java/io/jooby/i3936/TaskBoard3936.java new file mode 100644 index 0000000000..d9bb8511c7 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/TaskBoard3936.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +public record TaskBoard3936(int activeCount, java.util.List tasks) {} diff --git a/tests/src/test/java/io/jooby/i3936/TaskDto3936.java b/tests/src/test/java/io/jooby/i3936/TaskDto3936.java new file mode 100644 index 0000000000..bd18eb915e --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/TaskDto3936.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +public record TaskDto3936(@NotEmpty @NotBlank @Size(min = 3, max = 25) String title) {} diff --git a/tests/src/test/java/io/jooby/i3936/TaskRepo3936.java b/tests/src/test/java/io/jooby/i3936/TaskRepo3936.java new file mode 100644 index 0000000000..87567a3fcd --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/TaskRepo3936.java @@ -0,0 +1,54 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +public class TaskRepo3936 { + private final AtomicInteger errors = new AtomicInteger(0); + private final Map db = new ConcurrentHashMap<>(); + private final AtomicInteger idGen = new AtomicInteger(1); + // Stores the physical order of the board! + private final List taskOrder = new CopyOnWriteArrayList<>(); + + public TaskBoard3936 getBoardState() { + var orderedTasks = taskOrder.stream().map(db::get).filter(Objects::nonNull).toList(); + return new TaskBoard3936(getActiveCount(), orderedTasks); + } + + public Task3936 save(TaskDto3936 dto) { + if (errors.incrementAndGet() > 1) { + // fake unexpected error + throw new IllegalStateException("Connection error! Please try again."); + } + if (db.values().stream().anyMatch(it -> it.title().equalsIgnoreCase(dto.title()))) { + // 400 error are scoped to local error handler (if any) or to global error handler + throw new IllegalArgumentException("Duplicated Task"); + } + String id = String.valueOf(idGen.getAndIncrement()); + Task3936 task = new Task3936(id, dto.title(), false); + db.put(id, task); + taskOrder.add(id); + return task; + } + + public void delete(String id) { + db.remove(id); + taskOrder.remove(id); + } + + public int getActiveCount() { + return db.size(); // Simplified for the demo + } + + public void updateOrder(List newOrder) { + taskOrder.clear(); + taskOrder.addAll(newOrder); + } +} diff --git a/tests/src/test/java/io/jooby/i3936/TaskUI.java b/tests/src/test/java/io/jooby/i3936/TaskUI.java new file mode 100644 index 0000000000..bfe8412913 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/TaskUI.java @@ -0,0 +1,76 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936; + +import java.util.List; +import java.util.Map; + +import io.jooby.ModelAndView; +import io.jooby.annotation.*; +import io.jooby.annotation.htmx.HxError; +import io.jooby.annotation.htmx.HxOob; +import io.jooby.annotation.htmx.HxTrigger; +import io.jooby.annotation.htmx.HxView; +import io.jooby.htmx.HtmxResponse; +import jakarta.validation.Valid; + +@HxError("task_error.hbs") +public class TaskUI { + private final TaskRepo3936 db; + + public TaskUI(TaskRepo3936 db) { + this.db = db; + } + + @GET("/") + public ModelAndView index() { + return new ModelAndView<>("index.hbs", getBoard()); + } + + // 1. Load the initial board + @GET("/tasks") + @HxView(value = "board.hbs") + public TaskBoard3936 getBoard() { + return db.getBoardState(); + } + + // 2. Add a task and update the counter simultaneously + @POST("/tasks") + @HxView(value = "task_row.hbs") + @HxOob("task_counter.hbs") + @HxOob("toast.hbs") + @HxTrigger("taskAdded") + public Map addTask(@FormParam @Valid TaskDto3936 dto) { + var newTask = db.save(dto); + return Map.of( + "id", + newTask.id(), + "title", + newTask.title(), + "completed", + newTask.completed(), + "activeCount", + db.getActiveCount(), + "message", + "Task added successfully!"); + } + + // 3. Delete a task (Returns nothing, but triggers the OOB counter) + @DELETE("/tasks/{id}") + public HtmxResponse deleteTask(@PathParam String id) { + db.delete(id); + return HtmxResponse.empty() + .addOob("task_counter.hbs", Map.of("activeCount", db.getActiveCount())) + .addOob("toast.hbs", Map.of("message", "Task deleted!")); + } + + // 4. Save the new Drag-and-Drop order + @POST("/tasks/reorder") + public HtmxResponse reorderTasks(@FormParam List taskIds) { + db.updateOrder(taskIds); + return HtmxResponse.empty().addOob("toast.hbs", Map.of("message", "Board saved.")); + } +} diff --git a/tests/src/test/kotlin/i3936/KtTaskUI.kt b/tests/src/test/kotlin/i3936/KtTaskUI.kt new file mode 100644 index 0000000000..8adfc0673d --- /dev/null +++ b/tests/src/test/kotlin/i3936/KtTaskUI.kt @@ -0,0 +1,64 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3936 + +import io.jooby.ModelAndView +import io.jooby.annotation.* +import io.jooby.annotation.htmx.HxError +import io.jooby.annotation.htmx.HxOob +import io.jooby.annotation.htmx.HxTrigger +import io.jooby.annotation.htmx.HxView +import io.jooby.htmx.HtmxResponse +import jakarta.validation.Valid + +class KtTaskUI(private val db: TaskRepo3936) { + @GET("/") + fun index(): ModelAndView { + return ModelAndView("index.hbs", getBoard()) + } + + @HxView(value = "board.hbs") + @GET("/tasks") + fun getBoard(): TaskBoard3936 { + // 1. Load the initial board + val taskBoard3936 = TaskBoard3936(4, listOf()) + return taskBoard3936 + } + + // 2. Add a task and update the counter simultaneously + @POST("/tasks") + @HxView(value = "task_row.hbs") + @HxOob("task_counter.hbs") + @HxOob("toast.hbs") + @HxTrigger("taskAdded") + @HxError("task_error.hbs") + fun addTask(@FormParam @Valid dto: @Valid TaskDto3936?): Map { + val newTask = db.save(dto) + return mapOf( + "id" to newTask.id, + "title" to newTask.title, + "completed" to newTask.completed, + "activeCount" to db.getActiveCount(), + "message" to "Task added successfully!", + ) + } + + // 3. Delete a task (Returns nothing, but triggers the OOB counter) + @DELETE("/tasks/{id}") + fun deleteTask(@PathParam id: String?): HtmxResponse { + db.delete(id) + return HtmxResponse.empty() + .addOob("task_counter.hbs", mapOf("activeCount" to db.getActiveCount())) + .addOob("toast.hbs", mapOf("message" to "Task deleted!")) + } + + // 4. Save the new Drag-and-Drop order + @POST("/tasks/reorder") + fun reorderTasks(@FormParam taskIds: MutableList?): HtmxResponse { + db.updateOrder(taskIds) + return HtmxResponse.empty().addOob("toast.hbs", mapOf("message" to "Board saved.")) + } +} diff --git a/tests/src/test/resources/htmx/board.hbs b/tests/src/test/resources/htmx/board.hbs new file mode 100644 index 0000000000..6d0689c5ba --- /dev/null +++ b/tests/src/test/resources/htmx/board.hbs @@ -0,0 +1,34 @@ + +
    +

    Tasks

    + + + {{activeCount}} Tasks Remaining + +
    + +
    + +
    +
    + + +
    + +
    +
    + + +
    +
    + {{#each tasks}} + {{> task_row.hbs}} + {{else}} +
    No tasks yet.
    + {{/each}} +
    +
    +
    diff --git a/tests/src/test/resources/htmx/index.hbs b/tests/src/test/resources/htmx/index.hbs new file mode 100644 index 0000000000..d48b42d01a --- /dev/null +++ b/tests/src/test/resources/htmx/index.hbs @@ -0,0 +1,117 @@ + + + + + HTMX Task Board + + + + + + + + + + + + + + +
    +
    +
    + Loading Task Board... +
    +
    +
    + +
    +
    + + Error Handling Sandbox +
    +
    + +
    + HTTP 400 +
    +

    Scoped Validation Error

    +

    Try to submit a task that is completely blank, less than 3 characters, or more than 25 characters. This triggers standard Java Bean Validation (@Valid) to fail, automatically injecting the specific constraint error message directly beneath the input field without losing your current page state.

    +
    +
    + +
    + HTTP 500 +
    +

    Global System Error

    +

    The backend is rigged to crash on every 3rd task creation. Rapidly add a few tasks to simulate a database failure. This triggers the global application handler, bypassing the form entirely to show a global red toast notification.

    +
    +
    + +
    +
    + +
    + + + + + \ No newline at end of file diff --git a/tests/src/test/resources/htmx/task_counter.hbs b/tests/src/test/resources/htmx/task_counter.hbs new file mode 100644 index 0000000000..f328364f19 --- /dev/null +++ b/tests/src/test/resources/htmx/task_counter.hbs @@ -0,0 +1,3 @@ + + {{activeCount}} Tasks Remaining + \ No newline at end of file diff --git a/tests/src/test/resources/htmx/task_error.hbs b/tests/src/test/resources/htmx/task_error.hbs new file mode 100644 index 0000000000..a0d7fd5cc7 --- /dev/null +++ b/tests/src/test/resources/htmx/task_error.hbs @@ -0,0 +1,10 @@ +
    +

    {{validationResult.title}}

    +
      + {{#each validationResult.errors}} + {{#each this.messages}} +
    • {{this}}
    • + {{/each}} + {{/each}} +
    +
    \ No newline at end of file diff --git a/tests/src/test/resources/htmx/task_row.hbs b/tests/src/test/resources/htmx/task_row.hbs new file mode 100644 index 0000000000..f7ca6f12c4 --- /dev/null +++ b/tests/src/test/resources/htmx/task_row.hbs @@ -0,0 +1,17 @@ +
    + + + +
    + {{title}} +
    + + + + +
    \ No newline at end of file diff --git a/tests/src/test/resources/htmx/toast.hbs b/tests/src/test/resources/htmx/toast.hbs new file mode 100644 index 0000000000..b7a6a1ddee --- /dev/null +++ b/tests/src/test/resources/htmx/toast.hbs @@ -0,0 +1,11 @@ +
    + +
    + + {{message}} + +
    + +
    \ No newline at end of file