Skip to content

Commit dbcd7e9

Browse files
committed
- 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
1 parent e771c89 commit dbcd7e9

7 files changed

Lines changed: 407 additions & 106 deletions

File tree

modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java

Lines changed: 181 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ public List<String> generateHandlerCall(boolean kt) {
9393
.findFirst()
9494
.orElse(null)
9595
: null;
96-
97-
// Strip quotes from APT extraction so string() works correctly below
98-
if (errorView != null) {
99-
errorView = errorView.replace("\"", "");
100-
}
96+
String layoutView =
97+
hxView != null
98+
? AnnotationSupport.findAnnotationValue(hxView, "layout"::equals).stream()
99+
.findFirst()
100+
.orElse(null)
101+
: null;
101102

102103
boolean isDynamicResponse =
103104
getReturnType().getRawType().toString().equals("io.jooby.htmx.HtmxResponse");
@@ -108,15 +109,29 @@ public List<String> generateHandlerCall(boolean kt) {
108109
buffer.add(statement(indent(indent), "try {"));
109110
indent += 2;
110111
}
111-
112-
buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt)));
113-
114-
appendDeclarativeHeaders(buffer, kt, indent);
115-
116112
// 5. Response Processing
117113
if (isDynamicResponse) {
114+
// Guard for dynamic responses (e.g. POST/DELETE endpoints)
115+
buffer.add(
116+
statement(indent(indent), "if (!ctx.header(\"HX-Request\").booleanValue(false)) {"));
117+
if (kt) {
118+
buffer.add(
119+
statement(
120+
indent(indent + 2),
121+
"throw io.jooby.exception.BadRequestException(\"Direct browser access to this HTMX"
122+
+ " fragment is not allowed.\")"));
123+
} else {
124+
buffer.add(
125+
statement(
126+
indent(indent + 2),
127+
"throw new io.jooby.exception.BadRequestException(\"Direct browser access to this"
128+
+ " HTMX fragment is not allowed.\");"));
129+
}
130+
buffer.add(statement(indent(indent), "}"));
131+
132+
buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt)));
133+
118134
if (errorView != null) {
119-
// USE IDIOMATIC KOTLIN MAPS
120135
String emptyMap = kt ? "mapOf<String, Any>()" : "java.util.Map.of()";
121136
buffer.add(
122137
statement(
@@ -128,10 +143,13 @@ public List<String> generateHandlerCall(boolean kt) {
128143
")",
129144
semicolon(kt)));
130145
}
146+
147+
appendDeclarativeHeaders(buffer, kt, indent);
148+
131149
buffer.add(statement(indent(indent), "return result_.send(ctx)", semicolon(kt)));
132150
} else {
133151
generateModelAndViewReturn(
134-
buffer, kt, indent, string(primaryView).toString(), "result_", errorView);
152+
buffer, kt, indent, string(primaryView).toString(), call, errorView, layoutView);
135153
}
136154

137155
// 6. Error Handling block
@@ -156,8 +174,14 @@ private AnnotationMirror findHxError() {
156174
private void generateErrorCatchBlock(
157175
List<String> buffer, boolean kt, int indent, String errorView, String errorTarget) {
158176
if (kt) {
177+
buffer.add(
178+
statement(indent(indent), "} catch (ex: io.jooby.htmx.HtmxDirectAccessException) {"));
179+
buffer.add(statement(indent(indent + 2), "throw ex"));
159180
buffer.add(statement(indent(indent), "} catch (ex: Exception) {"));
160181
} else {
182+
buffer.add(
183+
statement(indent(indent), "} catch (io.jooby.htmx.HtmxDirectAccessException ex) {"));
184+
buffer.add(statement(indent(indent + 2), "throw ex;"));
161185
buffer.add(statement(indent(indent), "} catch (Exception ex) {"));
162186
}
163187

@@ -222,7 +246,9 @@ private void generateErrorCatchBlock(
222246
indent(indent + 2),
223247
"return io.jooby.ModelAndView.of",
224248
inferType,
225-
"(\"" + errorView + "\", errorModel_)",
249+
"(",
250+
string(errorView),
251+
", errorModel_)",
226252
semicolon(kt)));
227253

228254
buffer.add(statement(indent(indent), "}"));
@@ -306,14 +332,104 @@ private void generateModelAndViewReturn(
306332
boolean kt,
307333
int indent,
308334
String viewStr,
309-
String modelStr,
310-
String errorView) {
311-
boolean isView =
335+
String call,
336+
String errorView,
337+
String layoutView) {
338+
boolean isStandardView =
312339
getReturnType().is("io.jooby.ModelAndView")
313-
|| getReturnType().is("io.jooby.MapModelAndView")
314-
|| getReturnType().is("io.jooby.htmx.HtmxModelAndView");
340+
|| getReturnType().is("io.jooby.MapModelAndView");
341+
boolean isHtmxView = getReturnType().is("io.jooby.htmx.HtmxModelAndView");
342+
boolean isView = isStandardView || isHtmxView;
343+
344+
// Check if the developer explicitly added @HxView
345+
boolean hasHxView =
346+
io.jooby.internal.apt.AnnotationSupport.findAnnotationByName(
347+
method, "io.jooby.annotation.htmx.HxView")
348+
!= null;
349+
350+
// RULE: We apply the HTMX Guard Clause to EVERYTHING EXCEPT standard views lacking the @HxView
351+
// annotation.
352+
boolean requiresGuard = !isStandardView || hasHxView;
353+
354+
var modelStr = "result_";
355+
356+
// ==========================================
357+
// 1. THE BROWSER FULL-REFRESH GUARD
358+
// ==========================================
359+
if (requiresGuard) {
360+
buffer.add(
361+
statement(indent(indent), "if (!ctx.header(\"HX-Request\").booleanValue(false)) {"));
362+
if (layoutView != null && !layoutView.isEmpty()) {
363+
buffer.add(statement(indent(indent + 2), var(kt), "result_ = ", call, semicolon(kt)));
364+
365+
// Inject the child view name as a request attribute (Safe for ANY model type: Map, Record,
366+
// POJO)
367+
buffer.add(
368+
statement(
369+
indent(indent + 2),
370+
"ctx.setAttribute(\"childView\", ",
371+
viewStr,
372+
")",
373+
semicolon(kt)));
374+
375+
// Extract the data model. If the controller returned a ModelAndView, unwrap it using
376+
// .getModel()
377+
String targetModel = isView ? modelStr + ".getModel()" : modelStr;
378+
379+
// Return a BRAND NEW immutable ModelAndView pointing to the layout
380+
if (kt) {
381+
buffer.add(
382+
statement(
383+
indent(indent + 2),
384+
"return io.jooby.ModelAndView.of<Any>(",
385+
string(layoutView),
386+
", ",
387+
targetModel,
388+
")",
389+
semicolon(kt)));
390+
} else {
391+
buffer.add(
392+
statement(
393+
indent(indent + 2),
394+
"return io.jooby.ModelAndView.of(",
395+
string(layoutView),
396+
", ",
397+
targetModel,
398+
")",
399+
semicolon(kt)));
400+
}
401+
402+
} else {
403+
// No layout defined: Reject direct access
404+
if (kt) {
405+
buffer.add(
406+
statement(
407+
indent(indent + 2),
408+
"throw io.jooby.htmx.HtmxDirectAccessException(\"Direct browser access to this"
409+
+ " HTMX fragment is not allowed.\")"));
410+
} else {
411+
buffer.add(
412+
statement(
413+
indent(indent + 2),
414+
"throw new io.jooby.htmx.HtmxDirectAccessException(\"Direct browser access to"
415+
+ " this HTMX fragment is not allowed.\");"));
416+
}
417+
}
418+
buffer.add(statement(indent(indent), "}"));
419+
}
420+
421+
// Execute the controller method if it wasn't already handled and returned by the layout block
422+
// above
423+
buffer.add(statement(indent(indent), var(kt), "result_ = ", call, semicolon(kt)));
424+
425+
appendDeclarativeHeaders(buffer, kt, indent);
426+
427+
// ==========================================
428+
// 2. THE HTMX AJAX PIPELINE
429+
// ==========================================
315430

316431
if (isView) {
432+
// Controller handled its own view creation
317433
buffer.add(statement(indent(indent), "return ", modelStr, semicolon(kt)));
318434
return;
319435
}
@@ -323,25 +439,37 @@ private void generateModelAndViewReturn(
323439
"io.jooby.annotation.htmx.HxOob", "io.jooby.annotation.htmx.HxOobs");
324440

325441
if (!oobViews.isEmpty() || errorView != null) {
326-
buffer.add(
327-
statement(
328-
indent(indent),
329-
var(kt),
330-
"mv_ = ",
331-
kt ? "" : "new ",
332-
"io.jooby.htmx.HtmxModelAndView(",
333-
viewStr,
334-
", ",
335-
modelStr,
336-
")",
337-
semicolon(kt)));
442+
// Upgrade to HtmxModelAndView to support OOB responses
443+
if (kt) {
444+
buffer.add(
445+
statement(
446+
indent(indent),
447+
var(kt),
448+
"mv_ = io.jooby.htmx.HtmxModelAndView<Any>(",
449+
viewStr,
450+
", ",
451+
modelStr,
452+
")",
453+
semicolon(kt)));
454+
} else {
455+
buffer.add(
456+
statement(
457+
indent(indent),
458+
var(kt),
459+
"mv_ = new io.jooby.htmx.HtmxModelAndView<>(",
460+
viewStr,
461+
", ",
462+
modelStr,
463+
")",
464+
semicolon(kt)));
465+
}
338466

339467
for (var oobView : oobViews) {
340468
buffer.add(statement(indent(indent), "mv_.addOob(", string(oobView), ")", semicolon(kt)));
341469
}
342470

343-
// MAGIC REPAIRED: Add the empty map parameter correctly!
344471
if (errorView != null) {
472+
buffer.add(statement(indent(indent), "// clear error: ", errorView));
345473
String emptyMap = kt ? "mapOf<String, Any>()" : "java.util.Map.of()";
346474
buffer.add(
347475
statement(
@@ -358,18 +486,28 @@ private void generateModelAndViewReturn(
358486
return;
359487
}
360488

361-
var inferType = kt ? "<Any>" : "";
362-
buffer.add(
363-
statement(
364-
indent(indent),
365-
"return io.jooby.ModelAndView.of",
366-
inferType,
367-
"(",
368-
viewStr,
369-
", ",
370-
modelStr,
371-
")",
372-
semicolon(kt)));
489+
// Fallback: Standard Jooby ModelAndView
490+
if (kt) {
491+
buffer.add(
492+
statement(
493+
indent(indent),
494+
"return io.jooby.ModelAndView.of<Any>(",
495+
viewStr,
496+
", ",
497+
modelStr,
498+
")",
499+
semicolon(kt)));
500+
} else {
501+
buffer.add(
502+
statement(
503+
indent(indent),
504+
"return io.jooby.ModelAndView.of(",
505+
viewStr,
506+
", ",
507+
modelStr,
508+
")",
509+
semicolon(kt)));
510+
}
373511
}
374512

375513
private void appendDeclarativeHeaders(List<String> buffer, boolean kt, int indent) {

0 commit comments

Comments
 (0)