add core view extension with JTE and Thymeleaf support
This commit is contained in:
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+87
@@ -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>
|
||||
+8
@@ -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>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<span th:text="${global.appName}">app</span>
|
||||
<span th:text="${global.path}">path</span>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user