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,65 @@
# flash-ext-view-thymeleaf
Opinionated Thymeleaf SSR extension for Flash.
This module keeps Thymeleaf semantics front and center:
- `ThymeleafExtension`
- `ThymeleafHandler`
- `@Template` and `@Fragment`
- `ViewModel` from `flash-ext-view-core`
- `global.*` reserved namespace
The extension uses Thymeleaf-native patterns (`template` names resolved by prefix/suffix,
fragment selection via `template :: fragment`) while keeping Flash boot-time fail-fast checks.
## Quick Start
```java
import dev.relism.extension.FlashApp;
import dev.relism.ext.view.core.ViewModel;
import dev.relism.ext.view.thymeleaf.*;
FlashApp.create(8080)
.install(new ThymeleafExtension()
.addGlobal("appName", req -> "Flash")
.addGlobal("requestPath", req -> req.path()))
.scan("com.example.web")
.startAndBlock();
```
```java
import dev.relism.ext.view.core.ViewModel;
import dev.relism.ext.view.thymeleaf.*;
import dev.relism.routing.GET;
@GET("/")
@Template("pages/home")
public final class HomePage extends ThymeleafHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("title", "Home");
}
}
```
## Fragment Example
```java
@GET("/users/rows")
@Fragment(template = "fragments/users", value = "rows")
public final class UserRows extends ThymeleafHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("users", List.of());
}
}
```
## Docs
- `docs/architecture.md`
- `docs/handlers.md`
- `docs/model-and-globals.md`
- `docs/fragments.md`
- `docs/performance.md`
@@ -0,0 +1,23 @@
# Architecture
`flash-ext-view-thymeleaf` has two layers:
1. **Boot-time**
- `ThymeleafExtension` installs runtime + annotation processor.
- `ThymeleafTargetResolver` validates handlers and resolves `@Template` / `@Fragment`.
- Resolved targets are cached per handler class.
2. **Request-time**
- Handler builds local `ViewModel`.
- Runtime injects globals under reserved `global` namespace and merges local model.
- Thymeleaf renders template or fragment.
## Handler Contract
- Must extend `ThymeleafHandler`.
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
- Must declare exactly one view annotation:
- `@Template`
- `@Fragment`
Invalid configurations fail fast at startup.
@@ -0,0 +1,16 @@
# Fragments
Use `@Fragment` for Thymeleaf fragment responses.
```java
@GET("/users/table")
@Fragment(template = "fragments/users", value = "rows")
public final class UsersRows extends ThymeleafHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("users", List.of());
}
}
```
If `value` is blank, runtime uses the configured default fragment (default: `content`).
@@ -0,0 +1,35 @@
# Handlers
Use `ThymeleafHandler` for class-based Thymeleaf routes.
## Lifecycle
- `onViewInit()` runs once at boot.
- `render(...)` runs per request.
Use `onViewInit()` to cache dependencies via `require(...)`.
## Example
```java
@GET("/dashboard")
@Template("pages/dashboard")
public final class DashboardPage extends ThymeleafHandler {
private DashboardService service;
@Override
protected void onViewInit() {
service = require(DashboardService.class);
}
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.empty()
.with("title", "Dashboard")
.with("stats", service.stats());
}
}
```
Use `render(Request, Response)` when you need response access while building model variables.
@@ -0,0 +1,31 @@
# Model and Globals
`ViewModel` (from `flash-ext-view-core`) is the per-request model builder.
## Merge Order
Runtime merge order is:
1. all extension globals under reserved `global` namespace
2. local handler model
Handlers cannot set top-level `global`; runtime throws fail-fast to prevent namespace collisions.
## Globals
Register globals on extension setup:
```java
new ThymeleafExtension()
.addGlobal("appName", req -> "Flash")
.addGlobal("path", req -> req.path());
```
Template usage:
```html
<span th:text="${global.appName}"></span>
<span th:text="${global.path}"></span>
```
Keep globals cheap: no blocking I/O or heavy allocations.
@@ -0,0 +1,17 @@
# Performance
`flash-ext-view-thymeleaf` is optimized for low overhead on request path.
## Current runtime choices
- Handler view metadata resolved once and cached.
- Global/local model merge done in a single pass.
- Thymeleaf target metadata resolved and cached per handler class.
- No engine-agnostic adapter indirection.
## Best practices
- Cache services in `onViewInit()`.
- Keep `addGlobal(...)` resolvers cheap and side-effect free.
- Build only the model fields needed by template.
- Avoid blocking I/O in `render(...)`; delegate to precomputed service data when possible.
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-view-thymeleaf</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-view-core</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report-and-check</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package dev.relism.ext.view.thymeleaf;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a handler to a Thymeleaf fragment render target.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Fragment {
String template();
String value() default "";
}
@@ -0,0 +1,15 @@
package dev.relism.ext.view.thymeleaf;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a handler to a Thymeleaf template.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Template {
String value();
}
@@ -0,0 +1,63 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.ext.view.core.BaseViewExtension;
import dev.relism.ext.view.core.GlobalValue;
import dev.relism.ext.view.core.ViewRuntimeBridge;
import dev.relism.models.RequestHandler;
import java.util.List;
import java.util.function.Consumer;
/**
* Opinionated Thymeleaf SSR extension for Flash.
*/
public class ThymeleafExtension extends BaseViewExtension<ThymeleafTarget> {
private final ThymeleafSettings settings;
static {
ensureThymeleafPresent();
}
public ThymeleafExtension() {
this(ThymeleafSettings.builder().build());
}
public ThymeleafExtension(Consumer<ThymeleafSettings.Builder> customizer) {
ThymeleafSettings.Builder builder = ThymeleafSettings.builder();
java.util.Objects.requireNonNull(customizer, "customizer must not be null").accept(builder);
this.settings = builder.build();
}
private ThymeleafExtension(ThymeleafSettings settings) {
this.settings = settings;
}
@Override
public ThymeleafExtension addGlobal(String key, java.util.function.Function<dev.relism.models.Request, Object> resolver) {
super.addGlobal(key, resolver);
return this;
}
@Override
protected ViewRuntimeBridge<ThymeleafTarget> createRuntime(List<GlobalValue> globals) {
return new ThymeleafRuntime(settings, globals);
}
@Override
protected void validateHandlerClass(Class<? extends RequestHandler> handlerClass) {
ThymeleafTarget target = ThymeleafTargetResolver.resolve(handlerClass, settings);
if (target == null) return;
if (!ThymeleafHandler.class.isAssignableFrom(handlerClass)) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " declares @Template/@Fragment but does not extend ThymeleafHandler");
}
}
private static void ensureThymeleafPresent() {
try {
Class.forName("org.thymeleaf.TemplateEngine", false, ThymeleafExtension.class.getClassLoader());
} catch (ClassNotFoundException e) {
throw new IllegalStateException("Thymeleaf is not on the classpath. Add dependency org.thymeleaf:thymeleaf:3.1.2.RELEASE", e);
}
}
}
@@ -0,0 +1,8 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.ext.view.core.BaseViewHandler;
/**
* Base class for class-based Thymeleaf handlers.
*/
public abstract class ThymeleafHandler extends BaseViewHandler<ThymeleafTarget> {}
@@ -0,0 +1,159 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.ext.view.core.BaseViewHandler;
import dev.relism.ext.view.core.GlobalValue;
import dev.relism.ext.view.core.RenderedView;
import dev.relism.ext.view.core.ViewModel;
import dev.relism.ext.view.core.ViewRuntimeBridge;
import dev.relism.models.Request;
import dev.relism.models.Response;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.linkbuilder.ILinkBuilder;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
final class ThymeleafRuntime implements ViewRuntimeBridge<ThymeleafTarget> {
private static final String GLOBAL_NAMESPACE = "global";
private final ThymeleafSettings settings;
private final List<GlobalValue> globals;
private final ConcurrentHashMap<Class<?>, ThymeleafTarget> targets = new ConcurrentHashMap<>();
private final TemplateEngine engine;
ThymeleafRuntime(ThymeleafSettings settings, List<GlobalValue> globals) {
this.settings = settings;
this.globals = globals;
this.engine = createEngine(settings);
}
@Override
public ThymeleafTarget resolve(Class<?> handlerClass) {
ThymeleafTarget cached = targets.get(handlerClass);
if (cached != null) return cached;
ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(handlerClass, settings);
if (resolved == null) {
throw new IllegalStateException("ThymeleafHandler " + handlerClass.getName()
+ " must declare @Template or @Fragment");
}
targets.put(handlerClass, resolved);
return resolved;
}
@Override
public RenderedView render(BaseViewHandler<ThymeleafTarget> handler,
ThymeleafTarget target,
Request req,
Response res) throws Exception {
ViewModel local = handler.renderInternal(req, res);
ViewModel merged = merge(req, local);
Context ctx = new Context();
for (Map.Entry<String, Object> e : merged.toMap().entrySet()) {
ctx.setVariable(e.getKey(), e.getValue());
}
String output;
if (target.kind() == ThymeleafTarget.Kind.TEMPLATE) {
output = engine.process(target.template(), ctx);
} else {
output = engine.process(target.template(), Set.of(target.fragment()), ctx);
}
return new RenderedView(output, target.contentType());
}
private ViewModel merge(Request req, ViewModel local) {
LinkedHashMap<String, Object> values = null;
if (!globals.isEmpty()) {
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
for (GlobalValue binding : globals) {
Object resolved = binding.resolver().apply(req);
globalMap.put(binding.key(), ViewModel.unwrapValue(resolved));
}
if (!globalMap.isEmpty()) {
if (values == null) values = new LinkedHashMap<>();
values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap));
}
}
if (local != null) {
Map<String, Object> localMap = local.toMap();
if (localMap.containsKey(GLOBAL_NAMESPACE)) {
throw new IllegalStateException("ViewModel key 'global' is reserved for framework globals");
}
if (values == null) return ViewModel.copyOf(local);
values.putAll(localMap);
}
if (values == null || values.isEmpty()) return ViewModel.empty();
return ViewModel.owned(values);
}
private static TemplateEngine createEngine(ThymeleafSettings settings) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix(settings.prefix());
resolver.setSuffix(settings.suffix());
resolver.setTemplateMode(settings.mode());
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(settings.cacheEnabled());
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
engine.addLinkBuilder(FlashLinkBuilder.INSTANCE);
return engine;
}
private static final class FlashLinkBuilder implements ILinkBuilder {
static final FlashLinkBuilder INSTANCE = new FlashLinkBuilder();
@Override public String getName() { return "flash"; }
@Override public Integer getOrder() { return Integer.MIN_VALUE; }
@Override
public String buildLink(IExpressionContext ctx, String base, Map<String, Object> params) {
if (base == null) return "";
String url = expandPathVars(base, params);
return appendQueryString(url, base, params);
}
private static String expandPathVars(String base, Map<String, Object> params) {
if (params == null || params.isEmpty() || !base.contains("{")) return base;
String result = base;
for (Map.Entry<String, Object> e : params.entrySet()) {
String placeholder = '{' + e.getKey() + '}';
if (result.contains(placeholder) && e.getValue() != null) {
result = result.replace(placeholder, encode(String.valueOf(e.getValue())));
}
}
return result;
}
private static String appendQueryString(String url, String base, Map<String, Object> params) {
if (params == null || params.isEmpty()) return url;
StringBuilder qs = new StringBuilder();
for (Map.Entry<String, Object> e : params.entrySet()) {
if (base.contains('{' + e.getKey() + '}') || e.getValue() == null) continue;
qs.append(qs.isEmpty() ? '?' : '&')
.append(encode(e.getKey()))
.append('=')
.append(encode(String.valueOf(e.getValue())));
}
return qs.isEmpty() ? url : url + qs;
}
private static String encode(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
}
}
}
@@ -0,0 +1,76 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.Flash;
import org.thymeleaf.templatemode.TemplateMode;
/**
* Thymeleaf resolver/engine defaults tuned for Flash.
*/
public final class ThymeleafSettings {
private final String prefix;
private final String suffix;
private final TemplateMode mode;
private final boolean cacheEnabled;
private final String defaultFragment;
private ThymeleafSettings(Builder b) {
this.prefix = b.prefix;
this.suffix = b.suffix;
this.mode = b.mode;
this.cacheEnabled = b.cacheEnabled;
this.defaultFragment = b.defaultFragment;
}
public static Builder builder() {
return new Builder();
}
String prefix() { return prefix; }
String suffix() { return suffix; }
TemplateMode mode() { return mode; }
boolean cacheEnabled() { return cacheEnabled; }
String defaultFragment() { return defaultFragment; }
public static final class Builder {
private String prefix = "/templates/";
private String suffix = ".html";
private TemplateMode mode = TemplateMode.HTML;
private boolean cacheEnabled = !Flash.DEV;
private String defaultFragment = "content";
public Builder prefix(String prefix) {
String p = prefix == null ? "" : prefix.trim();
if (p.isEmpty()) throw new IllegalArgumentException("prefix must not be blank");
this.prefix = p.endsWith("/") ? p : p + '/';
return this;
}
public Builder suffix(String suffix) {
String s = suffix == null ? "" : suffix.trim();
if (s.isEmpty()) throw new IllegalArgumentException("suffix must not be blank");
this.suffix = s.startsWith(".") ? s : "." + s;
return this;
}
public Builder mode(TemplateMode mode) {
this.mode = java.util.Objects.requireNonNull(mode, "mode must not be null");
return this;
}
public Builder cacheEnabled(boolean enabled) {
this.cacheEnabled = enabled;
return this;
}
public Builder defaultFragment(String fragment) {
String f = fragment == null ? "" : fragment.trim();
if (f.isEmpty()) throw new IllegalArgumentException("default fragment must not be blank");
this.defaultFragment = f;
return this;
}
public ThymeleafSettings build() {
return new ThymeleafSettings(this);
}
}
}
@@ -0,0 +1,15 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.http.ContentType;
record ThymeleafTarget(
Kind kind,
String template,
String fragment,
ContentType contentType
) {
enum Kind {
TEMPLATE,
FRAGMENT
}
}
@@ -0,0 +1,58 @@
package dev.relism.ext.view.thymeleaf;
import dev.relism.http.ContentType;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
final class ThymeleafTargetResolver {
private ThymeleafTargetResolver() {}
static ThymeleafTarget resolve(Class<?> handlerClass, ThymeleafSettings settings) {
Template template = find(handlerClass, Template.class);
Fragment fragment = find(handlerClass, Fragment.class);
int count = (template != null ? 1 : 0) + (fragment != null ? 1 : 0);
if (count == 0) return null;
Route route = Routes.of(handlerClass);
if (route == null) {
throw new IllegalStateException("Thymeleaf handler " + handlerClass.getName()
+ " has view annotation but no route annotation (@Route/@GET/@POST/...)");
}
if (count > 1) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " declares multiple Thymeleaf view annotations. Use exactly one of @Template, @Fragment");
}
if (template != null) {
String name = value(template.value(), "@Template", handlerClass, route);
return new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, name, "", ContentType.TEXT_HTML);
}
String name = value(fragment.template(), "@Fragment(template)", handlerClass, route);
String frag = fragment.value() == null ? "" : fragment.value().trim();
if (frag.isEmpty()) frag = settings.defaultFragment();
return new ThymeleafTarget(ThymeleafTarget.Kind.FRAGMENT, name, frag, ContentType.TEXT_HTML);
}
private static String value(String v, String field, Class<?> handler, Route route) {
String value = v == null ? "" : v.trim();
if (value.isEmpty()) {
throw new IllegalStateException("Handler " + handler.getName()
+ " route " + route.method() + " " + route.path()
+ " uses " + field + " with an empty template value");
}
return value;
}
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
while (cls != null && !cls.equals(Object.class)) {
A a = cls.getAnnotation(type);
if (a != null) return a;
cls = cls.getSuperclass();
}
return null;
}
}
@@ -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>