add core view extension with JTE and Thymeleaf support
This commit is contained in:
@@ -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>
|
||||
+16
@@ -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 "";
|
||||
}
|
||||
+15
@@ -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();
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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> {}
|
||||
+159
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+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