Skip to content

Commit f6ec7f1

Browse files
committed
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<String, Object> 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
1 parent 9e10b39 commit f6ec7f1

52 files changed

Lines changed: 2332 additions & 55 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

jooby/src/main/java/io/jooby/Jooby.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,11 @@ public ServiceRegistry getServices() {
776776
return this.router.getServices();
777777
}
778778

779+
@Override
780+
public List<TemplateEngine> getTemplateEngines() {
781+
return this.router.getTemplateEngines();
782+
}
783+
779784
/**
780785
* Get base application package. This is the package from where application was initialized or the
781786
* package of a Jooby application sub-class.

jooby/src/main/java/io/jooby/ModelAndView.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,13 @@ public static MapModelAndView map(String view, Map<String, Object> model) {
8888
* any other object.
8989
* @return A {@code ModelAndView} instance corresponding to the specified view and model.
9090
*/
91-
public static ModelAndView<Map<String, Object>> of(String view, Object model) {
91+
@SuppressWarnings({"unchecked", "rawtypes"})
92+
public static <T> ModelAndView<T> of(String view, @Nullable Object model) {
9293
if (model == null) {
93-
return map(view);
94+
return (ModelAndView<T>) map(view);
9495
}
9596
if (model instanceof Map mapModel) {
96-
return map(view, mapModel);
97+
return (ModelAndView<T>) map(view, mapModel);
9798
}
9899
return new ModelAndView(view, model);
99100
}

jooby/src/main/java/io/jooby/Router.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,13 @@ default Executor executor(String name) {
879879
*/
880880
ValueFactory getValueFactory();
881881

882+
/**
883+
* Retrieves a list of available template engines.
884+
*
885+
* @return a list of TemplateEngine objects representing the available template engines.
886+
*/
887+
List<TemplateEngine> getTemplateEngines();
888+
882889
/**
883890
* Set value factory, useful for custom value factory.
884891
*

jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,8 @@ public Output encode(Context ctx, Object value) throws Exception {
106106
return MessageEncoder.TO_STRING.encode(ctx, value);
107107
}
108108
}
109+
110+
public List<TemplateEngine> getTemplateEngines() {
111+
return templateEngineList;
112+
}
109113
}

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,10 @@ public Router setCurrentUser(Function<Context, Object> provider) {
816816
return this;
817817
}
818818

819+
public List<TemplateEngine> getTemplateEngines() {
820+
return Collections.unmodifiableList(encoder.getTemplateEngines());
821+
}
822+
819823
@Override
820824
public String toString() {
821825
StringBuilder buff = new StringBuilder();

jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public ValidationExceptionChain add(ValidationExceptionMapper mapper) {
7878
// Assume is a client error, provide a default result
7979
return new ValidationResult(
8080
"Validation failed",
81-
suggestedCode.value(),
81+
StatusCode.UNPROCESSABLE_ENTITY.value(),
8282
List.of(
8383
new ValidationResult.Error(
8484
null,

jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError()
6363

6464
assertNotNull(result);
6565
assertEquals("Validation failed", result.getTitle());
66-
assertEquals(400, result.getStatus());
66+
assertEquals(422, result.getStatus());
6767

6868
assertEquals(1, result.getErrors().size());
6969
ValidationResult.Error error = result.getErrors().get(0);
@@ -82,7 +82,7 @@ void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() {
8282

8383
assertNotNull(result);
8484
assertEquals("Validation failed", result.getTitle());
85-
assertEquals(400, result.getStatus());
85+
assertEquals(422, result.getStatus());
8686

8787
assertEquals(1, result.getErrors().size());
8888
ValidationResult.Error error = result.getErrors().get(0);

modules/jooby-apt/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@
4545
<scope>test</scope>
4646
</dependency>
4747

48+
<dependency>
49+
<groupId>io.jooby</groupId>
50+
<artifactId>jooby-htmx</artifactId>
51+
<version>${jooby.version}</version>
52+
<scope>test</scope>
53+
</dependency>
54+
4855
<dependency>
4956
<groupId>io.jooby</groupId>
5057
<artifactId>jooby-jsonrpc</artifactId>

modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,20 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
130130
context.getRouters().forEach(it -> context.debug(" %s", it.getGeneratedType()));
131131
return false;
132132
} else {
133-
// Discover all unique Controller classes
134133
var controllers = findControllers(annotations, roundEnv);
135-
136-
// Factory Pattern: Build specific routers for each class based on method annotations
137134
List<WebRouter<?>> activeRouters = new ArrayList<>();
135+
138136
for (var controller : controllers) {
139137
if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue;
140138

141-
var restRouter = RestRouter.parse(context, controller);
142-
if (!restRouter.isEmpty()) {
143-
activeRouters.add(restRouter);
139+
// --- PASS 1: Specialized Routers & Claim Gathering ---
140+
Set<String> masterClaimedRoutes = new HashSet<>();
141+
142+
// Parse HTMX first to claim route paths
143+
var htmxRouter = io.jooby.internal.apt.htmx.HtmxRouter.parse(context, controller);
144+
if (!htmxRouter.isEmpty()) {
145+
activeRouters.add(htmxRouter);
146+
masterClaimedRoutes.addAll(htmxRouter.getClaimedRoutes());
144147
}
145148

146149
var jsonRpcRouter = JsonRpcRouter.parse(context, controller);
@@ -162,6 +165,13 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
162165
if (!wsRouter.isEmpty()) {
163166
activeRouters.add(wsRouter);
164167
}
168+
169+
// --- PASS 2: Standard Rest Router (Fallback) ---
170+
// Pass the claimed routes to RestRouter so it knows what to skip
171+
var restRouter = RestRouter.parse(context, controller, masterClaimedRoutes);
172+
if (!restRouter.isEmpty()) {
173+
activeRouters.add(restRouter);
174+
}
165175
}
166176

167177
verifyBeanValidationDependency(activeRouters);
@@ -288,6 +298,20 @@ public Set<String> getSupportedAnnotationTypes() {
288298
supportedTypes.add("io.jooby.annotation.ws.OnClose");
289299
supportedTypes.add("io.jooby.annotation.ws.OnMessage");
290300
supportedTypes.add("io.jooby.annotation.ws.OnError");
301+
// Add Htmx Annotations
302+
supportedTypes.addAll(
303+
Set.of(
304+
"io.jooby.annotation.htmx.HxView",
305+
"io.jooby.annotation.htmx.HxError",
306+
"io.jooby.annotation.htmx.HxOob",
307+
"io.jooby.annotation.htmx.HxOobs",
308+
"io.jooby.annotation.htmx.HxPushUrl",
309+
"io.jooby.annotation.htmx.HxRedirect",
310+
"io.jooby.annotation.htmx.HxRefresh",
311+
"io.jooby.annotation.htmx.HxSwap",
312+
"io.jooby.annotation.htmx.HxTarget",
313+
"io.jooby.annotation.htmx.HxTrigger",
314+
"io.jooby.annotation.htmx.HxTriggers"));
291315
return supportedTypes;
292316
}
293317

modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static io.jooby.internal.apt.CodeBlock.*;
99

1010
import java.io.IOException;
11+
import java.util.Set;
1112
import java.util.stream.Collectors;
1213

1314
import javax.lang.model.element.ElementKind;
@@ -19,7 +20,8 @@ public RestRouter(MvcContext context, TypeElement clazz) {
1920
super(context, clazz);
2021
}
2122

22-
public static RestRouter parse(MvcContext context, TypeElement controller) {
23+
public static RestRouter parse(
24+
MvcContext context, TypeElement controller, Set<String> claimedRoutes) {
2325
var router = new RestRouter(context, controller);
2426

2527
for (var type : context.superTypes(controller)) {
@@ -36,6 +38,21 @@ public static RestRouter parse(MvcContext context, TypeElement controller) {
3638
var annoElement = (TypeElement) annoMirror.getAnnotationType().asElement();
3739

3840
if (HttpMethod.hasAnnotation(annoElement)) {
41+
// Check if the current route is claimed by a specialized router (e.g., HTMX)
42+
var httpMethod =
43+
HttpMethod.findByAnnotationName(annoElement.getQualifiedName().toString());
44+
var paths = context.path(controller, method, annoElement);
45+
46+
boolean isClaimed =
47+
paths.stream()
48+
.map(path -> httpMethod + WebRoute.leadingSlash(path))
49+
.anyMatch(claimedRoutes::contains);
50+
51+
// If HTMX claimed it, skip generating a REST route for it!
52+
if (isClaimed) {
53+
continue;
54+
}
55+
3956
var route = new RestRoute(router, method, annoElement);
4057
var uniqueKey = method.toString() + annoElement.getSimpleName();
4158
router.routes.putIfAbsent(uniqueKey, route);

0 commit comments

Comments
 (0)