add core view extension with JTE and Thymeleaf support

This commit is contained in:
Relism
2026-04-21 00:32:09 +02:00
parent 34fd74068a
commit 9e19f439be
93 changed files with 2200 additions and 1404 deletions
@@ -0,0 +1,92 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.ext.view.core.ViewModel;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
class ThymeleafExtensionTest {
@GET("/ok")
@Template("pages/home")
static class ValidTemplateHandler extends ThymeleafHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", "ok");
}
}
@GET("/fragment")
@Fragment(template = "pages/home", value = "rows")
static class ValidFragmentHandler extends ThymeleafHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.of("id", 1);
}
}
@GET("/wrong")
@Template("pages/home")
static class WrongBaseHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/missing")
static class MissingViewAnnotationHandler extends ThymeleafHandler {}
@Test
void constructor_requiresNonNullCustomizer() {
assertThrows(NullPointerException.class, () -> new ThymeleafExtension(null));
}
@Test
void addGlobal_isFluent() {
ThymeleafExtension ext = new ThymeleafExtension();
assertSame(ext, ext.addGlobal("app", req -> "Flash"));
}
@Test
void validate_acceptsTemplateAndFragmentHandlers() throws Exception {
ThymeleafExtension ext = new ThymeleafExtension();
invokeValidate(ext, ValidTemplateHandler.class);
invokeValidate(ext, ValidFragmentHandler.class);
}
@Test
void validate_rejectsTemplateWithoutThymeleafBase() {
ThymeleafExtension ext = new ThymeleafExtension();
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> invokeValidate(ext, WrongBaseHandler.class));
assertTrue(ex.getMessage().contains("does not extend ThymeleafHandler"));
}
@Test
void validate_allowsThymeleafBaseWithoutViewAnnotation() throws Exception {
ThymeleafExtension ext = new ThymeleafExtension();
invokeValidate(ext, MissingViewAnnotationHandler.class);
}
private static void invokeValidate(ThymeleafExtension ext, Class<? extends RequestHandler> type) throws Exception {
Method m = ThymeleafExtension.class.getDeclaredMethod("validateHandlerClass", Class.class);
m.setAccessible(true);
try {
m.invoke(ext, type);
} catch (java.lang.reflect.InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException re) throw re;
if (cause instanceof Error err) throw err;
throw new RuntimeException(cause);
}
}
}
@@ -0,0 +1,153 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.ext.view.core.GlobalValue;
import dev.relism.ext.view.core.RenderedView;
import dev.relism.ext.view.core.ViewModel;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
import org.junit.jupiter.api.Test;
import org.thymeleaf.templatemode.TemplateMode;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ThymeleafRuntimeTest {
@Test
void render_template_resolvesTemplateAndLinks() throws Exception {
ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of());
RenderedView out = runtime.render(new ThymeleafHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.empty().with("title", "Home").with("id", 42).with("page", 2);
}
}, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/home", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
assertEquals(ContentType.TEXT_HTML, out.contentType());
assertTrue(out.body().contains("Home"));
assertTrue(out.body().contains("/users/42?page=2"));
}
@Test
void render_fragment_usesExplicitFragment() throws Exception {
ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of());
RenderedView out = runtime.render(new ThymeleafHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.empty().with("id", 42);
}
}, new ThymeleafTarget(ThymeleafTarget.Kind.FRAGMENT, "pages/home", "rows", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
assertTrue(out.body().contains("row-42"));
}
@Test
void render_mergesGlobalsInReservedNamespace() throws Exception {
ThymeleafRuntime runtime = new ThymeleafRuntime(
ThymeleafSettings.builder().cacheEnabled(false).build(),
List.of(new GlobalValue("appName", req -> "Flash"), new GlobalValue("path", req -> "/x"))
);
RenderedView out = runtime.render(new ThymeleafHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", "Home");
}
}, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/with-globals", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
assertTrue(out.body().contains("Flash"));
assertTrue(out.body().contains("/x"));
}
@Test
void render_rejectsLocalGlobalKeyOverride() {
ThymeleafRuntime runtime = new ThymeleafRuntime(
ThymeleafSettings.builder().cacheEnabled(false).build(),
List.of(new GlobalValue("appName", req -> "Flash"))
);
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
runtime.render(new ThymeleafHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.of("global", "bad");
}
}, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/home", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON))
);
assertTrue(ex.getMessage().contains("reserved"));
}
@Test
void render_template_withoutParams_linkBuilderCoversEmptyBranches() throws Exception {
ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of());
RenderedView out = runtime.render(new ThymeleafHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", "NoParams");
}
}, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/link-cases", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
assertTrue(out.body().contains("/static"));
assertTrue(out.body().contains("/users/"));
assertTrue(out.body().contains("/search"));
}
@Test
void resolve_cachesTargetInstance() {
ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of());
ThymeleafTarget first = runtime.resolve(CachedTemplateHandler.class);
ThymeleafTarget second = runtime.resolve(CachedTemplateHandler.class);
assertTrue(first == second);
}
@Test
void resolve_missingViewAnnotation_fails() {
ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of());
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runtime.resolve(MissingViewHandler.class));
assertTrue(ex.getMessage().contains("must declare @Template or @Fragment"));
}
@Test
void builderBranches_coverPrefixSuffixFragmentModeAndCacheFlag() {
ThymeleafSettings settings = ThymeleafSettings.builder()
.prefix("templates")
.suffix("html")
.mode(TemplateMode.HTML)
.cacheEnabled(false)
.defaultFragment("rows")
.build();
assertEquals("templates/", settings.prefix());
assertEquals(".html", settings.suffix());
assertEquals("rows", settings.defaultFragment());
assertFalse(settings.cacheEnabled());
}
@dev.relism.routing.GET("/cached")
@Template("pages/home")
static class CachedTemplateHandler extends ThymeleafHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", "cached").with("id", 1).with("page", 1);
}
}
static class MissingViewHandler extends ThymeleafHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.empty();
}
}
}
@@ -0,0 +1,46 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.Flash;
import org.junit.jupiter.api.Test;
import org.thymeleaf.templatemode.TemplateMode;
import static org.junit.jupiter.api.Assertions.*;
class ThymeleafSettingsTest {
@Test
void defaults_matchFlashConventions() {
ThymeleafSettings settings = ThymeleafSettings.builder().build();
assertEquals("/templates/", settings.prefix());
assertEquals(".html", settings.suffix());
assertEquals(TemplateMode.HTML, settings.mode());
assertEquals(!Flash.DEV, settings.cacheEnabled());
assertEquals("content", settings.defaultFragment());
}
@Test
void builder_normalizesPrefixAndSuffix() {
ThymeleafSettings settings = ThymeleafSettings.builder()
.prefix("templates")
.suffix("jte")
.mode(TemplateMode.TEXT)
.cacheEnabled(true)
.defaultFragment("rows")
.build();
assertEquals("templates/", settings.prefix());
assertEquals(".jte", settings.suffix());
assertEquals(TemplateMode.TEXT, settings.mode());
assertTrue(settings.cacheEnabled());
assertEquals("rows", settings.defaultFragment());
}
@Test
void builder_rejectsBlankValuesAndNullMode() {
assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().prefix(" "));
assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().suffix(" "));
assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().defaultFragment(" "));
assertThrows(NullPointerException.class, () -> ThymeleafSettings.builder().mode(null));
}
}
@@ -0,0 +1,87 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ThymeleafTargetResolverTest {
@GET("/home")
@Template("pages/home")
static class TemplateHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/partial")
@Fragment(template = "pages/home", value = "rows")
static class FragmentHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/partial-default")
@Fragment(template = "pages/home")
static class FragmentDefaultHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/bad")
@Template("a")
@Fragment(template = "b")
static class ConflictingHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Template("pages/no-route")
static class NoRouteHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void resolve_template_returnsTemplateTarget() {
ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(TemplateHandler.class, ThymeleafSettings.builder().build());
assertNotNull(resolved);
assertEquals(ThymeleafTarget.Kind.TEMPLATE, resolved.kind());
assertEquals("pages/home", resolved.template());
}
@Test
void resolve_fragment_withoutValue_usesDefaultFragment() {
ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(FragmentDefaultHandler.class, ThymeleafSettings.builder().defaultFragment("content").build());
assertNotNull(resolved);
assertEquals(ThymeleafTarget.Kind.FRAGMENT, resolved.kind());
assertEquals("content", resolved.fragment());
}
@Test
void resolve_multipleViewAnnotations_fails() {
assertThrows(IllegalStateException.class,
() -> ThymeleafTargetResolver.resolve(ConflictingHandler.class, ThymeleafSettings.builder().build()));
}
@Test
void resolve_viewAnnotationWithoutRoute_fails() {
assertThrows(IllegalStateException.class,
() -> ThymeleafTargetResolver.resolve(NoRouteHandler.class, ThymeleafSettings.builder().build()));
}
}
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<body>
<h1 th:text="${title}">fallback</h1>
<a th:href="@{/users/{id}(id=${id},page=${page})}">user</a>
<div th:fragment="content" th:text="'content-' + ${title}">content</div>
<div th:fragment="rows" th:attr="id=${'row-' + id}">row</div>
</body>
</html>
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<a id="plain" th:href="@{/static}">plain</a>
<a id="pathvar-null" th:href="@{/users/{id}(id=${null})}">pathvar-null</a>
<a id="query-null" th:href="@{/search(q=${null})}">query-null</a>
</body>
</html>
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<body>
<span th:text="${global.appName}">app</span>
<span th:text="${global.path}">path</span>
</body>
</html>