Skip to content

Commit 85b0331

Browse files
authored
Merge pull request #3940 from jooby-project/3936
jooby-htmx
2 parents 9e10b39 + 09519ab commit 85b0331

79 files changed

Lines changed: 4510 additions & 57 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.

docs/asciidoc/modules/htmx.adoc

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
== HTMX
2+
3+
https://htmx.org[HTMX] first-class support for Jooby.
4+
5+
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.
6+
7+
*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.
8+
9+
=== Usage
10+
11+
1) Add the dependencies (HTMX and your preferred template engine):
12+
13+
[dependency, artifactId="jooby-htmx, jooby-handlebars:Handlebars Module"]
14+
.
15+
16+
2) Write your templates inside the `views` folder. Notice how the layout dynamically embeds the requested partial using `childView`.
17+
18+
.views/layout.hbs
19+
[source, html]
20+
----
21+
<!DOCTYPE html>
22+
<html>
23+
<body>
24+
<nav>My App</nav>
25+
<main>
26+
{{> (lookup childView) }}
27+
</main>
28+
</body>
29+
</html>
30+
----
31+
32+
.views/tasks.hbs
33+
[source, html]
34+
----
35+
<ul id="task-list">
36+
{{#each tasks}}
37+
<li>{{title}}</li>
38+
{{/each}}
39+
</ul>
40+
----
41+
42+
3) Install the module and write your controller.
43+
44+
.Java
45+
[source, java, role="primary"]
46+
----
47+
import io.jooby.htmx.HtmxModule;
48+
import io.jooby.handlebars.HandlebarsModule;
49+
import io.jooby.annotation.htmx.HxView;
50+
51+
{
52+
install(new HandlebarsModule()); <1>
53+
install(new HtmxModule()); <2>
54+
55+
mvc(new TaskUIHtmx_()); <3>
56+
}
57+
58+
public class TaskUI {
59+
60+
@GET("/tasks")
61+
@HxView(value = "tasks.hbs", layout = "layout.hbs")
62+
public Map<String, Object> getTasks() {
63+
return Map.of("tasks", List.of(new Task("Buy milk")));
64+
}
65+
}
66+
----
67+
68+
.Kotlin
69+
[source, kt, role="secondary"]
70+
----
71+
import io.jooby.htmx.HtmxModule
72+
import io.jooby.handlebars.HandlebarsModule
73+
import io.jooby.annotation.htmx.HxView
74+
75+
{
76+
install(HandlebarsModule()) <1>
77+
install(HtmxModule()) <2>
78+
79+
mvc(TaskUIHtmx_()) <3>
80+
}
81+
82+
class TaskUI {
83+
84+
@GET("/tasks")
85+
@HxView(value = "tasks.hbs", layout = "layout.hbs")
86+
fun getTasks(): Map<String, Any> {
87+
return mapOf("tasks" to listOf(Task("Buy milk")))
88+
}
89+
}
90+
----
91+
92+
<1> Install your base template engine
93+
<2> Install the HTMX engine
94+
<3> Add generated `Htmx_` controller
95+
96+
=== The SPA Shell Layout Engine
97+
98+
The `@HxView` annotation implements a secure, Fail-Fast Guard Clause for layout management.
99+
100+
When you define a `layout` attribute, the framework intelligently checks the origin of the request:
101+
102+
* **HTMX AJAX Requests:** The layout is ignored. The framework responds only with the fast, targeted partial view (`tasks.hbs`).
103+
* **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).
104+
105+
If a method returns a dynamic HTMX fragment but *lacks* a layout, direct browser access is automatically blocked via a `406 Not Acceptable` exception.
106+
107+
=== Declarative API (Annotations)
108+
109+
When using Jooby's MVC routes, you can orchestrate complex UI state entirely through annotations:
110+
111+
.Java
112+
[source, java]
113+
----
114+
@POST("/tasks")
115+
@HxView("task_row.hbs")
116+
@HxOob("task_counter.hbs") // Automatically appends an Out-Of-Band swap
117+
@HxTrigger("taskAdded") // Triggers a client-side JS event
118+
@HxError("task_error.hbs") // Scoped Error Handler: Catches validation errors
119+
public Task addTask(@Valid TaskDto dto) {
120+
return db.save(dto);
121+
}
122+
----
123+
124+
==== Scoped Error Handling & Validation
125+
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.
126+
127+
* **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`).
128+
* **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.
129+
130+
=== Imperative API (HtmxResponse)
131+
132+
For scenarios lacking a primary view (like a `DELETE` operation), use the fluent `HtmxResponse` builder to explicitly chain events, headers, and OOB updates.
133+
134+
.Java
135+
[source, java, role="primary"]
136+
----
137+
@DELETE("/tasks/{id}")
138+
public HtmxResponse deleteTask(@PathParam String id) {
139+
db.delete(id);
140+
141+
return HtmxResponse.empty()
142+
.addOob("task_counter.hbs", Map.of("activeCount", db.getActiveCount()))
143+
.triggerAfterSettle("showToast", Map.of("message", "Task deleted!"));
144+
}
145+
----
146+
147+
.Kotlin
148+
[source, kt, role="secondary"]
149+
----
150+
@DELETE("/tasks/{id}")
151+
fun deleteTask(@PathParam id: String): HtmxResponse {
152+
db.delete(id)
153+
154+
return HtmxResponse.empty()
155+
.addOob("task_counter.hbs", mapOf("activeCount" to db.getActiveCount()))
156+
.triggerAfterSettle("showToast", mapOf("message" to "Task deleted!"))
157+
}
158+
----
159+
160+
=== Global Error Handling
161+
162+
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.
163+
164+
**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.
165+
166+
.Java
167+
[source, java, role="primary"]
168+
----
169+
import io.jooby.htmx.HtmxModule;
170+
171+
{
172+
install(new HtmxModule((ctx, cause, code) -> {
173+
// Convert the crash into a safe UI notification without breaking the DOM
174+
return HtmxResponse.empty(code)
175+
.addOob("toast.hbs", Map.of("error", cause.getMessage()));
176+
}));
177+
}
178+
----
179+
180+
.Kotlin
181+
[source, kt, role="secondary"]
182+
----
183+
import io.jooby.htmx.HtmxModule
184+
185+
{
186+
install(HtmxModule { ctx, cause, code ->
187+
HtmxResponse.empty(code)
188+
.addOob("toast.hbs", mapOf("error" to cause.message))
189+
})
190+
}
191+
----

docs/asciidoc/modules/modules.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ Modules are distributed as separate dependencies. Below is the catalog of offici
5555
* link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports.
5656

5757
==== Template Engine
58+
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
5859
* link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine.
60+
* link:{uiVersion}/modules/htmx[HTMX]: First-class HTMX support with declarative annotations and SPA layout management.
5961
* link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine.
6062
* link:{uiVersion}/modules/jte[jte]: jte template engine.
61-
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
6263
* link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine.
6364
* link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine.
6465
* link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine.

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/TemplateEngine.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
* @author edgar
1919
*/
2020
public interface TemplateEngine extends MessageEncoder {
21+
/** Just a template engine that is on top of the stack (run before all other engines). */
22+
interface OnTop extends TemplateEngine {}
2123

2224
/** Name of application property that defines the template path. */
2325
String TEMPLATE_PATH = "templates.path";

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ public class HttpMessageEncoder implements MessageEncoder {
3030
public HttpMessageEncoder add(MediaType type, MessageEncoder encoder) {
3131
if (encoder instanceof TemplateEngine engine) {
3232
// Media type is ignored for template engines. They have a custom object type
33-
templateEngineList.add(engine);
33+
if (engine instanceof TemplateEngine.OnTop) {
34+
// need to go first
35+
templateEngineList.addFirst(engine);
36+
} else {
37+
templateEngineList.add(engine);
38+
}
3439
} else {
3540
if (encoders == null) {
3641
encoders = new LinkedHashMap<>();
@@ -106,4 +111,8 @@ public Output encode(Context ctx, Object value) throws Exception {
106111
return MessageEncoder.TO_STRING.encode(ctx, value);
107112
}
108113
}
114+
115+
public List<TemplateEngine> getTemplateEngines() {
116+
return templateEngineList;
117+
}
109118
}

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);

0 commit comments

Comments
 (0)