From f6ec7f1623e9a9de136c419e24c0ded175ae4ebc Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 6 May 2026 21:18:35 -0300 Subject: [PATCH 1/5] As HTMX becomes the industry standard for building modern, reactive Single Page Applications (SPAs) without heavy JavaScript frameworks, Jooby developers need a first-class way to handle HTMX's unique response lifecycle. Currently, managing `HX-Request` headers, Out-Of-Band (OOB) swaps, Javascript triggers, and partial vs. full-page layout rendering requires significant boilerplate and repetitive `try/catch` logic in every controller. Introduce `jooby-htmx`, a dedicated module providing both a **Declarative Annotation API** (via APT generation) and a memory-safe **Imperative Builder API** to orchestrate HTMX responses seamlessly. This allows developers to write 100% pure "Happy Path" business logic while the framework handles the UI state. **1. The Interceptor Pipeline (`HtmxModule` & `HtmxMessageEncoder`)** * Registers directly into Jooby's `MessageEncoder` chain ahead of standard template engines. * Intercepts `HtmxModelAndView` payloads and safely drives the underlying template engine (e.g., Handlebars) in a loop to concatenate the primary view and multiple OOB templates into a single HTTP response. **2. The Imperative API (`HtmxResponse`)** * A fluent builder for scenarios lacking a primary view (e.g., `HTTP 204 No Content` for drag-and-drop reordering or delete operations). * Easily chains multiple `.addOob()` and `.trigger()` events. **3. The Declarative API (APT Code Generation)** * `@HxView(value = "partial.hbs", layout = "base.hbs")`: Automatically serves the partial for HTMX AJAX requests, but smartly wraps it in the layout for direct browser navigation or `F5` refreshes. * `@HxOob("counter.hbs")` & `@HxTrigger("itemAdded")`: Automatically injects the necessary headers and models into the response. * `@HxError("error.hbs")`: The "UI Janitor". Automatically catches Bean Validation (`@Valid`) `ConstraintViolationException`s to render scoped inline errors (HTTP 422). Crucially, it **automatically appends an empty OOB swap on success** to instantly clear the UI of previous errors. **4. Global Error Resilience** * Allows passing an `HtmxErrorHandler` directly into `install(new HtmxModule(errorHandler))`. * Safely intercepts global `500` server crashes and translates them into OOB Toast notifications, preventing raw HTML stack traces from breaking the client's DOM. The resulting controller is entirely decoupled from HTTP headers and serialization logic: ```java @POST("/tasks") @HxView("task_row.hbs") @HxOob("task_counter.hbs") @HxOob("toast.hbs") @HxTrigger("taskAdded") @HxError("task_error.hbs") // Automatically renders errors on failure, and clears them on success! public Map addTask(@FormParam @Valid TaskDto dto) { var newTask = db.save(dto); return Map.of( "id", newTask.id(), "title", newTask.title(), "activeCount", db.getActiveCount(), "message", "Task added successfully!" ); } fix #3936 --- jooby/src/main/java/io/jooby/Jooby.java | 5 + .../src/main/java/io/jooby/ModelAndView.java | 7 +- jooby/src/main/java/io/jooby/Router.java | 7 + .../io/jooby/internal/HttpMessageEncoder.java | 4 + .../java/io/jooby/internal/RouterImpl.java | 4 + .../internal/ValidationExceptionChain.java | 2 +- .../ValidationExceptionChainTest.java | 4 +- modules/jooby-apt/pom.xml | 7 + .../java/io/jooby/apt/JoobyProcessor.java | 36 +- .../io/jooby/internal/apt/RestRouter.java | 19 +- .../io/jooby/internal/apt/htmx/HtmxRoute.java | 466 ++++++++++++++++++ .../jooby/internal/apt/htmx/HtmxRouter.java | 193 ++++++++ .../java/io/jooby/apt/ProcessorRunner.java | 75 +-- .../src/test/java/tests/htmx/BasicUserHx.java | 65 +++ .../test/java/tests/htmx/ClaimedRouteHx.java | 24 + .../java/tests/htmx/ContextInjectionHx.java | 35 ++ .../java/tests/htmx/DynamicResponseHx.java | 46 ++ .../test/java/tests/htmx/ErrorBoundaryHx.java | 47 ++ .../src/test/java/tests/htmx/HtmxTest.java | 173 +++++++ .../src/test/java/tests/htmx/RiskDto3936.java | 8 + .../src/test/java/tests/htmx/User3936.java | 8 + .../src/test/java/tests/htmx/UserDto3936.java | 8 + modules/jooby-bom/pom.xml | 5 + .../io/jooby/freemarker/FreemarkerModule.java | 3 +- .../freemarker/FreemarkerModuleTest.java | 12 +- .../io/jooby/handlebars/HandlebarsModule.java | 5 +- .../handlebars/HandlebarsModuleTest.java | 20 +- modules/jooby-htmx/pom.xml | 35 ++ .../io/jooby/annotation/htmx/HxError.java | 37 ++ .../java/io/jooby/annotation/htmx/HxOob.java | 31 ++ .../java/io/jooby/annotation/htmx/HxOobs.java | 18 + .../io/jooby/annotation/htmx/HxPushUrl.java | 30 ++ .../io/jooby/annotation/htmx/HxRedirect.java | 26 + .../io/jooby/annotation/htmx/HxRefresh.java | 19 + .../java/io/jooby/annotation/htmx/HxSwap.java | 28 ++ .../io/jooby/annotation/htmx/HxTarget.java | 26 + .../io/jooby/annotation/htmx/HxTrigger.java | 56 +++ .../io/jooby/annotation/htmx/HxTriggers.java | 18 + .../java/io/jooby/annotation/htmx/HxView.java | 28 ++ .../main/java/io/jooby/htmx/HtmxContext.java | 153 ++++++ .../java/io/jooby/htmx/HtmxErrorHandler.java | 28 ++ .../java/io/jooby/htmx/HtmxModelAndView.java | 69 +++ .../main/java/io/jooby/htmx/HtmxModule.java | 50 ++ .../main/java/io/jooby/htmx/HtmxResponse.java | 303 ++++++++++++ .../io/jooby/htmx/HtmxTemplateEngine.java | 61 +++ .../main/java/io/jooby/htmx/package-info.java | 47 ++ .../src/main/java/io/jooby/jte/JteModule.java | 4 +- .../java/io/jooby/pebble/PebbleModule.java | 7 +- .../io/jooby/pebble/PebbleModuleTest.java | 10 + .../io/jooby/thymeleaf/ThymeleafModule.java | 4 +- .../jooby/thymeleaf/ThymeleafModuleTest.java | 10 + modules/pom.xml | 1 + 52 files changed, 2332 insertions(+), 55 deletions(-) create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRouter.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/BasicUserHx.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/ClaimedRouteHx.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/ContextInjectionHx.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/DynamicResponseHx.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/ErrorBoundaryHx.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/RiskDto3936.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/User3936.java create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/UserDto3936.java create mode 100644 modules/jooby-htmx/pom.xml create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxError.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOob.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxOobs.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxPushUrl.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRedirect.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxRefresh.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxSwap.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTarget.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTriggers.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModelAndView.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/package-info.java 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/internal/HttpMessageEncoder.java b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java index 06dd2acea0..fc3643e675 100644 --- a/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java +++ b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java @@ -106,4 +106,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..7abcfe6e12 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java @@ -0,0 +1,466 @@ +/* + * 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; + + // Strip quotes from APT extraction so string() works correctly below + if (errorView != null) { + errorView = errorView.replace("\"", ""); + } + + 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; + } + + buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt))); + + appendDeclarativeHeaders(buffer, kt, indent); + + // 5. Response Processing + if (isDynamicResponse) { + if (errorView != null) { + // USE IDIOMATIC KOTLIN MAPS + String emptyMap = kt ? "mapOf()" : "java.util.Map.of()"; + buffer.add( + statement( + indent(indent), + "result_.addOob(", + string(errorView), + ", ", + emptyMap, + ")", + semicolon(kt))); + } + buffer.add(statement(indent(indent), "return result_.send(ctx)", semicolon(kt))); + } else { + generateModelAndViewReturn( + buffer, kt, indent, string(primaryView).toString(), "result_", errorView); + } + + // 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: Exception) {")); + } else { + 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))); + buffer.add( + statement( + indent(indent + 2), + "return io.jooby.ModelAndView.of(\"" + 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 modelStr, + String errorView) { + boolean isView = + getReturnType().is("io.jooby.ModelAndView") + || getReturnType().is("io.jooby.MapModelAndView") + || getReturnType().is("io.jooby.htmx.HtmxModelAndView"); + + if (isView) { + 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) { + buffer.add( + statement( + indent(indent), + var(kt), + "mv_ = ", + kt ? "" : "new ", + "io.jooby.htmx.HtmxModelAndView(", + viewStr, + ", ", + modelStr, + ")", + semicolon(kt))); + + for (var oobView : oobViews) { + buffer.add(statement(indent(indent), "mv_.addOob(", string(oobView), ")", semicolon(kt))); + } + + // MAGIC REPAIRED: Add the empty map parameter correctly! + if (errorView != null) { + 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; + } + + 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))); + } + + List triggers = + extractRepeatableValues( + "io.jooby.annotation.htmx.HxTrigger", "io.jooby.annotation.htmx.HxTriggers"); + + if (!triggers.isEmpty()) { + String combinedTriggers = String.join(", ", triggers); + buffer.add( + statement( + indent(indent), + "ctx.setResponseHeader(", + string("HX-Trigger"), + ", ", + string(combinedTriggers), + ")", + semicolon(kt))); + } + } + + 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..c2ed325632 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java @@ -0,0 +1,173 @@ +/* + * 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); + 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); + 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); + 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); + 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 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); + 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 shouldDoDynamicResponse() throws Exception { + new ProcessorRunner(new DynamicResponseHx()) + .withHtmxCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public Object deleteUser(io.jooby.Context ctx) throws Exception { + var c = this.factory.apply(ctx); + var result_ = c.deleteUser(ctx.path("id").valueOrNull(), ctx); + 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 { + 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_); + mv_.addOob("users/risk_form", java.util.Map.of()); + return mv_; + } 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 { + 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_); + mv_.addOob("users/risk_form_top", java.util.Map.of()); + return mv_; + } 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/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/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..5cf24616db --- /dev/null +++ b/modules/jooby-htmx/pom.xml @@ -0,0 +1,35 @@ + + + + 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 + + + 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..949c722ae6 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.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.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(); + + /** + * An optional JSON payload string to pass with the event. Example: {@code "{\"level\": + * \"info\"}"} + * + * @return The JSON payload, or empty string if none. + */ + String payload() default ""; + + /** + * 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..9020a3af7e --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.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; + +/** + * 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(); +} 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..43de35f09f --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java @@ -0,0 +1,153 @@ +/* + * 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; + +public class HtmxContext { + + private final Context ctx; + + // Notice the value type is now Object! + private final Map triggers = new LinkedHashMap<>(); + private final Map triggersAfterSettle = new LinkedHashMap<>(); + private final Map triggersAfterSwap = new LinkedHashMap<>(); + + public HtmxContext(Context ctx) { + this.ctx = ctx; + } + + // --- Request State Readers --- + + /** Indicates that the request is via an element using hx-boost. */ + public boolean isBoosted() { + return Boolean.parseBoolean(ctx.header("HX-Boosted").value("false")); + } + + /** Indicates that the request is a standard HTMX request. */ + public boolean isHtmxRequest() { + return Boolean.parseBoolean(ctx.header("HX-Request").value("false")); + } + + /** True if the request is for history restoration after a miss in the local history cache. */ + public boolean isHistoryRestoreRequest() { + return Boolean.parseBoolean(ctx.header("HX-History-Restore-Request").value("false")); + } + + /** The current URL of the browser. */ + public @Nullable String getCurrentUrl() { + return ctx.header("HX-Current-Url").valueOrNull(); + } + + /** The id of the target element if it exists. */ + public @Nullable String getTarget() { + return ctx.header("HX-Target").valueOrNull(); + } + + // --- Response Header Modifiers --- + + /** Pushes a new url into the history stack. */ + public HtmxContext pushUrl(String url) { + ctx.setResponseHeader("HX-Push-Url", url); + return this; + } + + /** Replaces the current URL in the location bar. */ + public HtmxContext replaceUrl(String url) { + ctx.setResponseHeader("HX-Replace-Url", url); + return this; + } + + /** Can be used to do a client-side redirect to a new location. */ + public HtmxContext redirect(String url) { + ctx.setResponseHeader("HX-Redirect", url); + return this; + } + + /** If set to true the client side will do a full refresh of the page. */ + public HtmxContext refresh() { + ctx.setResponseHeader("HX-Refresh", "true"); + return this; + } + + /** Allows you to specify how the response will be swapped. */ + public HtmxContext reswap(String swap) { + ctx.setResponseHeader("HX-Reswap", swap); + return this; + } + + /** + * A CSS selector that updates the target of the content update to a different element on the + * page. + */ + public HtmxContext retarget(String target) { + ctx.setResponseHeader("HX-Retarget", target); + return this; + } + + // ... [Request readers and simple header setters remain the same] ... + + // --- Trigger Builders (Object Payloads) --- + + public HtmxContext trigger(String eventName) { + this.triggers.put(eventName, null); + updateTriggerHeader("HX-Trigger", triggers); + return this; + } + + public HtmxContext trigger(String eventName, @Nullable Object payload) { + this.triggers.put(eventName, payload); + updateTriggerHeader("HX-Trigger", triggers); + return this; + } + + public HtmxContext triggerAfterSettle(String eventName) { + this.triggersAfterSettle.put(eventName, null); + updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle); + return this; + } + + public HtmxContext triggerAfterSettle(String eventName, @Nullable Object payload) { + this.triggersAfterSettle.put(eventName, payload); + updateTriggerHeader("HX-Trigger-After-Settle", triggersAfterSettle); + return this; + } + + public HtmxContext triggerAfterSwap(String eventName) { + this.triggersAfterSwap.put(eventName, null); + updateTriggerHeader("HX-Trigger-After-Swap", triggersAfterSwap); + return this; + } + + 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/HtmxErrorHandler.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java new file mode 100644 index 0000000000..2be529beae --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.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.htmx; + +import org.slf4j.event.Level; + +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.StatusCode; + +public interface HtmxErrorHandler { + HtmxResponse apply(Context ctx, Throwable cause, StatusCode code); + + default ErrorHandler toErrorHandler() { + return (ctx, cause, code) -> { + if (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); + 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..6b40d6d3a2 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java @@ -0,0 +1,50 @@ +/* + * 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; + +/** + * Module for HTMX support. + * + *

Installing this module enables: + * + *

    + *
  • Sequential template streaming for Out-of-Band (OOB) swaps via {@code @HxOob}. + *
  • Native dependency injection of {@link HtmxContext} into MVC controllers. + *
+ * + *

Usage:

+ * + *
{@code
+ * {
+ *   install(new HtmxModule());
+ * }
+ * }
+ */ +public class HtmxModule implements Extension { + + private @Nullable HtmxErrorHandler errorHandler; + + public HtmxModule(HtmxErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + public HtmxModule() {} + + @Override + public void install(Jooby app) throws Exception { + + if (errorHandler != null) { + app.error(errorHandler.toErrorHandler()); + } + + app.encoder(new HtmxTemplateEngine()); + } +} 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..8d4653ad98 --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java @@ -0,0 +1,303 @@ +/* + * 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; + +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 the provided 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. + * + *

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(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. + * @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. + * @return This builder instance. + */ + public HtmxResponse triggerAfterSettle(String eventName, 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. + * @return This builder instance. + */ + public HtmxResponse triggerAfterSwap(String eventName, 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 this response will be shared with the 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 HtmxResponse instance. + */ + public HtmxResponse addOob(String oobView, 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 by the APT-generated Route.Handler 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..86fa8b33aa --- /dev/null +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java @@ -0,0 +1,61 @@ +/* + * 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.*; +import io.jooby.output.Output; + +/** + * Intercepts {@link HtmxModelAndView} returns and streams multiple templates sequentially to the + * HTMX client. + */ +public class HtmxTemplateEngine implements TemplateEngine { + + @Override + public Output render(Context ctx, ModelAndView modelAndView) throws Exception { + if (modelAndView instanceof HtmxModelAndView htmxView) { + var engineEncoder = resolveTemplateEngine(ctx, 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 ctx The web context containing the registered resources and state information. + * @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(Context ctx, ModelAndView mv) { + // Find the encoder that handles standard ModelAndView + for (var templateEngine : ctx.getRouter().getTemplateEngines()) { + if (templateEngine != this && templateEngine.supports(mv)) { + return templateEngine; + } + } + 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..82e3b69b2a --- /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",
+ *         layout = "layouts/main",
+ *         errorView = "users/form",
+ *         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-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 From e771c89fae0488db94763e8eed67622fa046b891 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 7 May 2026 09:33:00 -0300 Subject: [PATCH 2/5] - add integration test for Htmx --- .../io/jooby/internal/apt/htmx/HtmxRoute.java | 10 +- tests/pom.xml | 6 + .../test/java/io/jooby/i3936/Issue3936.java | 188 ++++++++++++++++++ .../test/java/io/jooby/i3936/Task3936.java | 8 + .../java/io/jooby/i3936/TaskBoard3936.java | 8 + .../test/java/io/jooby/i3936/TaskDto3936.java | 12 ++ .../java/io/jooby/i3936/TaskRepo3936.java | 54 +++++ .../src/test/java/io/jooby/i3936/TaskUI.java | 76 +++++++ tests/src/test/kotlin/i3936/KtTaskUI.kt | 64 ++++++ tests/src/test/resources/htmx/board.hbs | 34 ++++ tests/src/test/resources/htmx/index.hbs | 117 +++++++++++ .../src/test/resources/htmx/task_counter.hbs | 3 + tests/src/test/resources/htmx/task_error.hbs | 10 + tests/src/test/resources/htmx/task_row.hbs | 17 ++ tests/src/test/resources/htmx/toast.hbs | 11 + 15 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 tests/src/test/java/io/jooby/i3936/Issue3936.java create mode 100644 tests/src/test/java/io/jooby/i3936/Task3936.java create mode 100644 tests/src/test/java/io/jooby/i3936/TaskBoard3936.java create mode 100644 tests/src/test/java/io/jooby/i3936/TaskDto3936.java create mode 100644 tests/src/test/java/io/jooby/i3936/TaskRepo3936.java create mode 100644 tests/src/test/java/io/jooby/i3936/TaskUI.java create mode 100644 tests/src/test/kotlin/i3936/KtTaskUI.kt create mode 100644 tests/src/test/resources/htmx/board.hbs create mode 100644 tests/src/test/resources/htmx/index.hbs create mode 100644 tests/src/test/resources/htmx/task_counter.hbs create mode 100644 tests/src/test/resources/htmx/task_error.hbs create mode 100644 tests/src/test/resources/htmx/task_row.hbs create mode 100644 tests/src/test/resources/htmx/toast.hbs 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 index 7abcfe6e12..72fc1c9847 100644 --- 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 @@ -216,10 +216,13 @@ private void generateErrorCatchBlock( indent(indent + 2), "errorModel_.put(\"validationResult\", validationResult_)", semicolon(kt))); + var inferType = kt ? "" : ""; buffer.add( statement( indent(indent + 2), - "return io.jooby.ModelAndView.of(\"" + errorView + "\", errorModel_)", + "return io.jooby.ModelAndView.of", + inferType, + "(\"" + errorView + "\", errorModel_)", semicolon(kt))); buffer.add(statement(indent(indent), "}")); @@ -355,10 +358,13 @@ private void generateModelAndViewReturn( return; } + var inferType = kt ? "" : ""; buffer.add( statement( indent(indent), - "return io.jooby.ModelAndView.of(", + "return io.jooby.ModelAndView.of", + inferType, + "(", viewStr, ", ", modelStr, 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..4a2bb048b5 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3936/Issue3936.java @@ -0,0 +1,188 @@ +/* + * 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()); + }); + + // 2. Add Task - Success Path + http.header("Content-Type", "application/x-www-form-urlencoded"); + 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. +
+
+ """); + }); + + // 3.b simulate a network error => 500 response with default error handler (no + // Hx-Request header) + http.header("Content-Type", "application/x-www-form-urlencoded"); + http.post( + "/tasks", + new FormBody.Builder().add("title", "Wont save").build(), + rsp -> { + assertEquals(500, rsp.code()); + assertThat(rsp.body().string()) + .containsIgnoringWhitespaces( + """ +

message: Connection error! Please try again.

+

status code: 500

+ """); + }); + + // 4. Load the initial board + 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.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.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.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 From dbcd7e925f301d3fd0092760037850f982e72f23 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 7 May 2026 13:27:59 -0300 Subject: [PATCH 3/5] - implement Hx.layout feature for full page load - check explicitily for `Hx-Request: true` header - applies layout when possible - early fail with 406 - make more flexible rendering of ModelAndView --- .../io/jooby/internal/apt/htmx/HtmxRoute.java | 224 ++++++++++++++---- .../src/test/java/tests/htmx/HtmxTest.java | 157 ++++++++---- .../src/test/java/tests/htmx/LayoutHx.java | 33 +++ .../java/io/jooby/annotation/htmx/HxView.java | 28 +++ .../jooby/htmx/HtmxDirectAccessException.java | 31 +++ .../java/io/jooby/htmx/HtmxErrorHandler.java | 4 +- .../test/java/io/jooby/i3936/Issue3936.java | 36 +-- 7 files changed, 407 insertions(+), 106 deletions(-) create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/LayoutHx.java create mode 100644 modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxDirectAccessException.java 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 index 72fc1c9847..988c8d5fbc 100644 --- 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 @@ -93,11 +93,12 @@ public List generateHandlerCall(boolean kt) { .findFirst() .orElse(null) : null; - - // Strip quotes from APT extraction so string() works correctly below - if (errorView != null) { - errorView = errorView.replace("\"", ""); - } + String layoutView = + hxView != null + ? AnnotationSupport.findAnnotationValue(hxView, "layout"::equals).stream() + .findFirst() + .orElse(null) + : null; boolean isDynamicResponse = getReturnType().getRawType().toString().equals("io.jooby.htmx.HtmxResponse"); @@ -108,15 +109,29 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement(indent(indent), "try {")); indent += 2; } - - buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt))); - - appendDeclarativeHeaders(buffer, kt, indent); - // 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) { - // USE IDIOMATIC KOTLIN MAPS String emptyMap = kt ? "mapOf()" : "java.util.Map.of()"; buffer.add( statement( @@ -128,10 +143,13 @@ public List generateHandlerCall(boolean kt) { ")", 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(), "result_", errorView); + buffer, kt, indent, string(primaryView).toString(), call, errorView, layoutView); } // 6. Error Handling block @@ -156,8 +174,14 @@ private AnnotationMirror findHxError() { 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) {")); } @@ -222,7 +246,9 @@ private void generateErrorCatchBlock( indent(indent + 2), "return io.jooby.ModelAndView.of", inferType, - "(\"" + errorView + "\", errorModel_)", + "(", + string(errorView), + ", errorModel_)", semicolon(kt))); buffer.add(statement(indent(indent), "}")); @@ -306,14 +332,104 @@ private void generateModelAndViewReturn( boolean kt, int indent, String viewStr, - String modelStr, - String errorView) { - boolean isView = + String call, + String errorView, + String layoutView) { + boolean isStandardView = getReturnType().is("io.jooby.ModelAndView") - || getReturnType().is("io.jooby.MapModelAndView") - || getReturnType().is("io.jooby.htmx.HtmxModelAndView"); + || 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; } @@ -323,25 +439,37 @@ private void generateModelAndViewReturn( "io.jooby.annotation.htmx.HxOob", "io.jooby.annotation.htmx.HxOobs"); if (!oobViews.isEmpty() || errorView != null) { - buffer.add( - statement( - indent(indent), - var(kt), - "mv_ = ", - kt ? "" : "new ", - "io.jooby.htmx.HtmxModelAndView(", - viewStr, - ", ", - modelStr, - ")", - semicolon(kt))); + // 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))); } - // MAGIC REPAIRED: Add the empty map parameter correctly! if (errorView != null) { + buffer.add(statement(indent(indent), "// clear error: ", errorView)); String emptyMap = kt ? "mapOf()" : "java.util.Map.of()"; buffer.add( statement( @@ -358,18 +486,28 @@ private void generateModelAndViewReturn( return; } - var inferType = kt ? "" : ""; - buffer.add( - statement( - indent(indent), - "return io.jooby.ModelAndView.of", - inferType, - "(", - viewStr, - ", ", - modelStr, - ")", - semicolon(kt))); + // 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) { diff --git a/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java index c2ed325632..ca2fb49978 100644 --- a/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java +++ b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java @@ -23,6 +23,9 @@ public void shouldDoBasicHtmx() throws Exception { """ 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_); } @@ -30,31 +33,75 @@ public Object getUser(io.jooby.Context ctx) throws Exception { .containsIgnoringWhitespaces( """ public Object getUserMap(io.jooby.Context ctx) throws Exception { - var c = this.factory.apply(ctx); - var result_ = c.getUserMap(ctx.path("id").valueOrNull()); - return io.jooby.ModelAndView.of("users/profile.hbs", result_); + 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); - var result_ = c.getUserModelAndView(ctx.path("id").valueOrNull()); - return result_; + 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); - 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_; + 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_); } """); }); @@ -70,8 +117,11 @@ public void shouldInjectContext() throws Exception { """ 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_); + var mv_ = new io.jooby.htmx.HtmxModelAndView<>("users/profile.hbs", result_); mv_.addOob("components/notification_toast"); return mv_; } @@ -87,9 +137,12 @@ public void shouldDoDynamicResponse() throws Exception { assertThat(source) .containsIgnoringWhitespaces( """ - public Object deleteUser(io.jooby.Context ctx) throws Exception { + public Object deleteTask(io.jooby.Context ctx) throws Exception { var c = this.factory.apply(ctx); - var result_ = c.deleteUser(ctx.path("id").valueOrNull(), 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); } """); @@ -105,36 +158,18 @@ public void shouldHandleError() throws Exception { .containsIgnoringWhitespaces( """ public Object saveRiskProfile(io.jooby.Context ctx) throws Exception { - var c = this.factory.apply(ctx); - try { - 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_); - mv_.addOob("users/risk_form", java.util.Map.of()); - return mv_; - } 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 { - 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_); - mv_.addOob("users/risk_form_top", java.util.Map.of()); + 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); @@ -142,12 +177,42 @@ public Object saveRiskProfileBeanValidation(io.jooby.Context ctx) throws Excepti throw ex; } ctx.setResponseCode(io.jooby.StatusCode.UNPROCESSABLE_ENTITY); - ctx.setResponseHeader("HX-Retarget", "#risk-form-top-container"); + 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_top", errorModel_); + 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_); + } + } """); }); } 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-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java b/modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxView.java index 9020a3af7e..ba9bf30755 100644 --- 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 @@ -25,4 +25,32 @@ * @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/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 index 2be529beae..378211e6e9 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java @@ -16,7 +16,9 @@ public interface HtmxErrorHandler { default ErrorHandler toErrorHandler() { return (ctx, cause, code) -> { - if (ctx.header("HX-Request").booleanValue(false)) { + // 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); diff --git a/tests/src/test/java/io/jooby/i3936/Issue3936.java b/tests/src/test/java/io/jooby/i3936/Issue3936.java index 4a2bb048b5..06623c2a68 100644 --- a/tests/src/test/java/io/jooby/i3936/Issue3936.java +++ b/tests/src/test/java/io/jooby/i3936/Issue3936.java @@ -54,8 +54,24 @@ void shouldUnderstandHtmxRequest(ServerTestRunner runner) { 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(), @@ -104,23 +120,8 @@ void shouldUnderstandHtmxRequest(ServerTestRunner runner) { """); }); - // 3.b simulate a network error => 500 response with default error handler (no - // Hx-Request header) - http.header("Content-Type", "application/x-www-form-urlencoded"); - http.post( - "/tasks", - new FormBody.Builder().add("title", "Wont save").build(), - rsp -> { - assertEquals(500, rsp.code()); - assertThat(rsp.body().string()) - .containsIgnoringWhitespaces( - """ -

    message: Connection error! Please try again.

    -

    status code: 500

    - """); - }); - // 4. Load the initial board + http.header("Hx-Request", "true"); http.get( "/tasks", rsp -> { @@ -140,6 +141,7 @@ void shouldUnderstandHtmxRequest(ServerTestRunner runner) { // 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(), @@ -154,6 +156,7 @@ void shouldUnderstandHtmxRequest(ServerTestRunner runner) { // 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 -> { @@ -168,6 +171,7 @@ void shouldUnderstandHtmxRequest(ServerTestRunner runner) { // 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() From 9ee196667d60d04306e76ea5d774091562107452 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 7 May 2026 15:22:48 -0300 Subject: [PATCH 4/5] - unit tests - javadoc - module doc --- docs/asciidoc/modules/htmx.adoc | 191 ++++++++++++++++++ docs/asciidoc/modules/modules.adoc | 3 +- .../io/jooby/internal/apt/htmx/HtmxRoute.java | 75 ++++++- .../src/test/java/tests/htmx/HtmxTest.java | 23 +++ .../src/test/java/tests/htmx/TriggersHx.java | 26 +++ modules/jooby-htmx/pom.xml | 5 + .../io/jooby/annotation/htmx/HxTrigger.java | 8 - .../main/java/io/jooby/htmx/HtmxContext.java | 134 ++++++++++-- .../java/io/jooby/htmx/HtmxErrorHandler.java | 32 ++- .../main/java/io/jooby/htmx/HtmxModule.java | 32 ++- .../main/java/io/jooby/htmx/HtmxResponse.java | 35 ++-- .../io/jooby/htmx/HtmxTemplateEngine.java | 9 + .../main/java/io/jooby/htmx/package-info.java | 6 +- .../jooby-htmx/src/main/java/module-info.java | 54 +++++ .../java/io/jooby/htmx/HtmxContextTest.java | 177 ++++++++++++++++ .../io/jooby/htmx/HtmxErrorHandlerTest.java | 128 ++++++++++++ .../java/io/jooby/htmx/HtmxModuleTest.java | 58 ++++++ .../java/io/jooby/htmx/HtmxResponseTest.java | 181 +++++++++++++++++ .../io/jooby/htmx/HtmxTemplateEngineTest.java | 134 ++++++++++++ .../java/io/jooby/htmx/HxTriggerTest.java | 24 +++ 20 files changed, 1273 insertions(+), 62 deletions(-) create mode 100644 docs/asciidoc/modules/htmx.adoc create mode 100644 modules/jooby-apt/src/test/java/tests/htmx/TriggersHx.java create mode 100644 modules/jooby-htmx/src/main/java/module-info.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxContextTest.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxErrorHandlerTest.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxResponseTest.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java create mode 100644 modules/jooby-htmx/src/test/java/io/jooby/htmx/HxTriggerTest.java 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] +---- +
      + {{#each tasks}} +
    • {{title}}
    • + {{/each}} +
    +---- + +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/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 index 988c8d5fbc..5423bd61ba 100644 --- 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 @@ -527,24 +527,85 @@ private void appendDeclarativeHeaders(List buffer, boolean kt, int inden semicolon(kt))); } - List triggers = - extractRepeatableValues( - "io.jooby.annotation.htmx.HxTrigger", "io.jooby.annotation.htmx.HxTriggers"); + // 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(); - if (!triggers.isEmpty()) { - String combinedTriggers = String.join(", ", triggers); + 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("HX-Trigger"), + string(headerName), ", ", - string(combinedTriggers), + 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); diff --git a/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java index ca2fb49978..2bd028c2bd 100644 --- a/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java +++ b/modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java @@ -129,6 +129,29 @@ public Object updateUser(io.jooby.Context ctx) throws Exception { }); } + @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()) 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-htmx/pom.xml b/modules/jooby-htmx/pom.xml index 5cf24616db..db64520c52 100644 --- a/modules/jooby-htmx/pom.xml +++ b/modules/jooby-htmx/pom.xml @@ -31,5 +31,10 @@ runtime test
    + + org.mockito + mockito-core + test + 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 index 949c722ae6..08b6d56dc3 100644 --- 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 @@ -27,14 +27,6 @@ */ String value(); - /** - * An optional JSON payload string to pass with the event. Example: {@code "{\"level\": - * \"info\"}"} - * - * @return The JSON payload, or empty string if none. - */ - String payload() default ""; - /** * The lifecycle phase at which the event should be triggered. * 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 index 43de35f09f..89a06acca2 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxContext.java @@ -14,121 +14,217 @@ 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; - // Notice the value type is now Object! 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. */ + /** + * 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 Boolean.parseBoolean(ctx.header("HX-Boosted").value("false")); + return ctx.header("HX-Boosted").booleanValue(false); } - /** Indicates that the request is a standard HTMX request. */ + /** + * 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 Boolean.parseBoolean(ctx.header("HX-Request").value("false")); + return ctx.header("HX-Request").booleanValue(false); } - /** True if the request is for history restoration after a miss in the local history cache. */ + /** + * 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 Boolean.parseBoolean(ctx.header("HX-History-Restore-Request").value("false")); + return ctx.header("HX-History-Restore-Request").booleanValue(false); } - /** The current URL of the browser. */ + /** + * 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(); } - /** The id of the target element if it exists. */ + /** + * 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. */ + /** + * 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. */ + /** + * 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; } - /** Can be used to do a client-side redirect to a new location. */ + /** + * 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; } - /** If set to true the client side will do a full refresh of the page. */ + /** + * 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"); + ctx.setResponseHeader("HX-Refresh", true); return this; } - /** Allows you to specify how the response will be swapped. */ + /** + * 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; } /** - * A CSS selector that updates the target of the content update to a different element on the - * page. + * 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; } - // ... [Request readers and simple header setters remain the same] ... - // --- 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); 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 index 378211e6e9..d6831967b6 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxErrorHandler.java @@ -11,9 +11,38 @@ 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. @@ -22,7 +51,8 @@ default ErrorHandler toErrorHandler() { 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); + 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/HtmxModule.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java index 6b40d6d3a2..3ec0b8c9a3 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java @@ -11,20 +11,21 @@ import io.jooby.Jooby; /** - * Module for HTMX support. + * The primary extension for enabling first-class HTMX support in a Jooby application. * - *

    Installing this module enables: + *

    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. * - *

      - *
    • Sequential template streaming for Out-of-Band (OOB) swaps via {@code @HxOob}. - *
    • Native dependency injection of {@link HtmxContext} into MVC controllers. - *
    - * - *

    Usage:

    + *

    Usage: * *

    {@code
      * {
    - *   install(new HtmxModule());
    + * // Basic installation
    + * install(new HtmxModule());
    + *
    + * // Installation with a global HTMX error handler
    + * install(new HtmxModule(new MyHtmxErrorHandler()));
      * }
      * }
    */ @@ -32,12 +33,25 @@ 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 { 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 index 8d4653ad98..4b661515b0 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxResponse.java @@ -15,6 +15,14 @@ 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; @@ -45,7 +53,7 @@ public static HtmxResponse view(String view, @Nullable Object model) { } /** - * Creates an HtmxResponse that renders a specific view template with the provided model. + * 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. @@ -67,11 +75,9 @@ public static HtmxResponse empty() { } /** - * 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. + * 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) { @@ -109,7 +115,7 @@ public HtmxResponse trigger(String eventName) { * header. * * @param eventName The name of the event to trigger. - * @param jsonPayload The event detail. + * @param jsonPayload The event detail to be serialized into JSON. * @return This builder instance. */ public HtmxResponse trigger(String eventName, Object jsonPayload) { @@ -121,9 +127,10 @@ public HtmxResponse trigger(String eventName, Object jsonPayload) { * 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, Object value) { + public HtmxResponse triggerAfterSettle(String eventName, @Nullable Object value) { this.triggersAfterSettle.put(eventName, value); return this; } @@ -132,9 +139,10 @@ public HtmxResponse triggerAfterSettle(String eventName, Object value) { * 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, Object value) { + public HtmxResponse triggerAfterSwap(String eventName, @Nullable Object value) { this.triggersAfterSwap.put(eventName, value); return this; } @@ -205,7 +213,7 @@ public HtmxResponse header(String name, String value) { /** * Instructs HTMX to render an out-of-band (OOB) swap using the specified view template. The model - * provided to this response will be shared with the OOB template. + * 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. @@ -221,9 +229,9 @@ public HtmxResponse addOob(String oobView) { * * @param oobView The classpath location of the OOB view template. * @param model The data model to associate with the OOB view template. - * @return This HtmxResponse instance. + * @return This builder instance. */ - public HtmxResponse addOob(String oobView, Object model) { + public HtmxResponse addOob(String oobView, @Nullable Object model) { this.oobs.put(oobView, ofNullable(model).orElse(Map.of())); return this; } @@ -251,7 +259,7 @@ public Context send(Context ctx) { } } if (hasViews) { - HtmxModelAndView htmxView; + HtmxModelAndView htmxView; if (view == null) { var oobIter = oobs.entrySet().iterator(); var firstOob = oobIter.next(); @@ -272,8 +280,7 @@ public Context send(Context ctx) { } /** - * Called by the APT-generated Route.Handler to safely encode and write all headers directly to - * the Jooby Context. + * Called internally to safely encode and write all headers directly to the Jooby Context. * * @param ctx The active request context. */ 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 index 86fa8b33aa..4ea48f77aa 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java @@ -13,6 +13,15 @@ /** * 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 { 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 index 82e3b69b2a..0089f64d67 100644 --- 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 @@ -25,9 +25,9 @@ * * @POST * @HxView( - * value = "users/row", - * layout = "layouts/main", - * errorView = "users/form", + * value = "users/row.hbs", + * layout = "layouts/main.hbs", + * errorView = "users/form.hbs", * errorTarget = "#user-form" * ) * @HxTrigger(value = "userListUpdated", phase = Phase.AFTER_SETTLE) 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..2a4faf643f --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java @@ -0,0 +1,58 @@ +/* + * 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)); + } + + @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)); + } +} 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..9b06fb13aa --- /dev/null +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java @@ -0,0 +1,134 @@ +/* + * 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.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; + + @BeforeEach + void setUp() { + engine = new HtmxTemplateEngine(); + ctx = mock(Context.class); + router = mock(Router.class); + when(ctx.getRouter()).thenReturn(router); + } + + // --- 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() { + HtmxModelAndView htmxView = mock(HtmxModelAndView.class); + when(htmxView.getView()).thenReturn("missing.hbs"); + + // Router has no other engines registered + when(router.getTemplateEngines()).thenReturn(List.of(engine)); + + 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 a delegate Template Engine (e.g., Handlebars) + TemplateEngine delegateEngine = mock(TemplateEngine.class); + when(delegateEngine.supports(htmxView)).thenReturn(true); + + // Mock an incompatible engine to cover the "continue" branch inside resolveTemplateEngine + TemplateEngine incompatibleEngine = mock(TemplateEngine.class); + when(incompatibleEngine.supports(htmxView)).thenReturn(false); + + // Register engines. We include `engine` (this) to ensure the `!= this` branch is hit. + when(router.getTemplateEngines()) + .thenReturn(Arrays.asList(engine, incompatibleEngine, delegateEngine)); + + // 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)); + } +} From 09519ab93fbcfc28419a0ff0912c5a33a2045a64 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 7 May 2026 16:32:52 -0300 Subject: [PATCH 5/5] - make sure htmx always run first - setup engines at startup time --- .../main/java/io/jooby/TemplateEngine.java | 2 ++ .../io/jooby/internal/HttpMessageEncoder.java | 7 +++- .../main/java/io/jooby/htmx/HtmxModule.java | 6 ++-- .../io/jooby/htmx/HtmxTemplateEngine.java | 26 ++++++++++---- .../java/io/jooby/htmx/HtmxModuleTest.java | 6 ++++ .../io/jooby/htmx/HtmxTemplateEngineTest.java | 35 +++++++++++++++---- 6 files changed, 66 insertions(+), 16 deletions(-) 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 fc3643e675..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<>(); 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 index 3ec0b8c9a3..fbf6f91919 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxModule.java @@ -58,7 +58,9 @@ public void install(Jooby app) throws Exception { if (errorHandler != null) { app.error(errorHandler.toErrorHandler()); } - - app.encoder(new HtmxTemplateEngine()); + 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/HtmxTemplateEngine.java b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java index 4ea48f77aa..46f9b72b54 100644 --- a/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java +++ b/modules/jooby-htmx/src/main/java/io/jooby/htmx/HtmxTemplateEngine.java @@ -5,6 +5,9 @@ */ package io.jooby.htmx; +import java.util.ArrayList; +import java.util.List; + import org.jspecify.annotations.Nullable; import io.jooby.*; @@ -23,12 +26,22 @@ * @author edgar * @since 4.5.0 */ -public class HtmxTemplateEngine implements TemplateEngine { +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(ctx, htmxView); + var engineEncoder = resolveTemplateEngine(htmxView); if (engineEncoder == null) { throw new IllegalStateException( "No template engine registered to handle: " + htmxView.getView()); @@ -47,17 +60,16 @@ public Output render(Context ctx, ModelAndView modelAndView) throws Exception * ModelAndView}. Iterates through the available template engines in the context, returning the * first one that supports the provided model and view. * - * @param ctx The web context containing the registered resources and state information. * @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(Context ctx, ModelAndView mv) { + private @Nullable TemplateEngine resolveTemplateEngine(ModelAndView mv) { // Find the encoder that handles standard ModelAndView - for (var templateEngine : ctx.getRouter().getTemplateEngines()) { - if (templateEngine != this && templateEngine.supports(mv)) { - return templateEngine; + for (var engine : engines) { + if (engine.supports(mv)) { + return engine; } } return null; 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 index 2a4faf643f..37f61ddcc5 100644 --- a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxModuleTest.java @@ -36,6 +36,9 @@ void shouldInstallWithoutErrorHandler() throws Exception { // Verify the template engine WAS registered verify(app).encoder(any(HtmxTemplateEngine.class)); + + // Verify the init lifecycle hook was registered + verify(app).onStarting(any()); } @Test @@ -54,5 +57,8 @@ void shouldInstallWithErrorHandler() throws Exception { // 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/HtmxTemplateEngineTest.java b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java index 9b06fb13aa..284369feb4 100644 --- a/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java +++ b/modules/jooby-htmx/src/test/java/io/jooby/htmx/HtmxTemplateEngineTest.java @@ -23,6 +23,7 @@ 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; @@ -35,13 +36,29 @@ 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 --- @@ -68,12 +85,17 @@ void shouldReturnNullForStandardModelAndView() throws Exception { @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); - // Router has no other engines registered - when(router.getTemplateEngines()).thenReturn(List.of(engine)); - + // 3. Execute and verify IllegalStateException ex = assertThrows(IllegalStateException.class, () -> engine.render(ctx, htmxView)); @@ -90,17 +112,18 @@ void shouldRenderMultipleTemplatesIntoCompositeOutput() throws Exception { when(htmxView.iterator()).thenReturn(views.iterator()); - // 2. Mock a delegate Template Engine (e.g., Handlebars) + // 2. Mock Delegate Engines TemplateEngine delegateEngine = mock(TemplateEngine.class); when(delegateEngine.supports(htmxView)).thenReturn(true); - // Mock an incompatible engine to cover the "continue" branch inside resolveTemplateEngine TemplateEngine incompatibleEngine = mock(TemplateEngine.class); when(incompatibleEngine.supports(htmxView)).thenReturn(false); - // Register engines. We include `engine` (this) to ensure the `!= this` branch is hit. + // 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);