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
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-jackson</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-limiter</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-oidc</artifactId>
@@ -1,5 +1,9 @@
package dev.relism.ext.oidc;
import dev.relism.ext.openapi.OpenApiContributor;
import dev.relism.ext.openapi.OpenApiContributorRegistry;
import dev.relism.ext.openapi.OpenApiOperationContribution;
import dev.relism.ext.openapi.OpenApiResponseContribution;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
@@ -13,10 +17,7 @@ import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
@@ -266,37 +267,37 @@ public class OidcExtension implements FlashExtension {
* runtime when {@link dev.relism.ext.openapi.OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(dev.relism.extension.FlashContext ctx,
static void register(FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(dev.relism.ext.openapi.OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public java.util.Map<String, Object> componentContributions() {
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
public Map<String, Object> componentContributions() {
Map<String, String> scopesMap = new LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
java.util.Map<String, Object> flow = new java.util.LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
}
Map<String, Object> flow = new LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
Map<String, Object> scheme = new LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", java.util.Map.of("authorizationCode", flow));
scheme.put("flows", Map.of("authorizationCode", flow));
java.util.Map<String, Object> securitySchemes = new java.util.LinkedHashMap<>();
Map<String, Object> securitySchemes = new LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return java.util.Map.of("securitySchemes", securitySchemes);
return Map.of("securitySchemes", securitySchemes);
}
@Override
public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class<?> handlerClass) {
dev.relism.ext.openapi.OpenApiOperationContribution.Builder out =
dev.relism.ext.openapi.OpenApiOperationContribution.builder();
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
java.util.List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
@@ -304,7 +305,7 @@ public class OidcExtension implements FlashExtension {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, dev.relism.ext.openapi.OpenApiResponseContribution.of("Authentication required"));
out.response(401, OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
@@ -313,12 +314,9 @@ public class OidcExtension implements FlashExtension {
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else if (roleMessage != null) {
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage));
} else {
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(scopeMessage));
}
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
return out.build();
}
}));
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-openapi</artifactId>
@@ -344,7 +344,11 @@ public final class OpenApiBuilder {
}
private static String normalizePath(String path) {
return path.startsWith("/") ? path : "/" + path;
String normalized = path.startsWith("/") ? path : "/" + path;
while (normalized.startsWith("//")) {
normalized = normalized.substring(1);
}
return normalized;
}
private static String defaultDescription(int status) {
@@ -108,6 +108,21 @@ class OpenApiExtensionTest {
assertFalse(paths.containsKey("/users"));
}
@Test
void normalizes_double_slash_paths_from_events() {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension();
ext.provide(ctx);
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map<String, Object> spec = builder.build();
Map<String, Object> paths = cast(spec.get("paths"));
assertTrue(paths.containsKey("/blogs"));
assertFalse(paths.containsKey("//blogs"));
}
@SuppressWarnings("unchecked")
private static List<RouteListener> listeners(FlashContext ctx) {
try {
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-routeviewer</artifactId>
@@ -0,0 +1,15 @@
# flash-ext-view-core
Minimal runtime primitives for Flash SSR view extensions.
This module is intentionally small and not engine-opinionated. It provides:
- `BaseViewHandler`
- `BaseViewExtension`
- `ViewRuntimeBridge`
- `ViewModel`
- `RenderedView`
- `GlobalValue`
Engine semantics (`@Template`, `@Fragment`, layout/macros/tags, resolver conventions)
belong in engine-specific modules such as `flash-ext-view-thymeleaf`.
@@ -0,0 +1,29 @@
<?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-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,40 @@
package dev.relism.ext.view.core;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
public abstract class BaseViewExtension<TTarget> implements FlashExtension {
private final List<GlobalValue> globals = new ArrayList<>();
public BaseViewExtension<TTarget> addGlobal(String key, Function<Request, Object> resolver) {
String k = Objects.requireNonNull(key, "global key must not be null").trim();
if (k.isEmpty()) {
throw new IllegalArgumentException("global key must not be blank");
}
if (k.equals("global") || k.contains(".")) {
throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key);
}
globals.add(new GlobalValue(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
return this;
}
@Override
public final void provide(FlashContext ctx) {
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
ctx.provide(ViewRuntimeBridge.class, runtime);
ctx.addAnnotationProcessor(handlerClass -> {
validateHandlerClass(handlerClass);
return List.of();
});
}
protected abstract ViewRuntimeBridge<TTarget> createRuntime(List<GlobalValue> globals);
protected abstract void validateHandlerClass(Class<? extends RequestHandler> handlerClass);
}
@@ -0,0 +1,68 @@
package dev.relism.ext.view.core;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
public abstract class BaseViewHandler<TTarget> extends RequestHandler {
private static final byte DISPATCH_RENDER_2 = 1;
private static final byte DISPATCH_RENDER_1 = 2;
private ViewRuntimeBridge<TTarget> runtime;
private TTarget target;
private byte dispatchMode;
public ViewModel render(Request req) throws Exception {
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
}
public ViewModel render(Request req, Response res) throws Exception {
return render(req);
}
@Override
protected final void onInit() {
@SuppressWarnings("unchecked")
ViewRuntimeBridge<TTarget> resolvedRuntime = (ViewRuntimeBridge<TTarget>) require(ViewRuntimeBridge.class);
runtime = resolvedRuntime;
target = runtime.resolve(getClass());
dispatchMode = resolveDispatchMode(getClass());
onViewInit();
}
protected void onViewInit() {}
@Override
public final Object handle(Request request, Response response) throws Exception {
RenderedView rendered = runtime.render(this, target, request, response);
if (rendered.contentType() != null) {
response.type(rendered.contentType());
}
return rendered.body();
}
public final ViewModel renderInternal(Request req, Response res) throws Exception {
if (dispatchMode == 0) {
dispatchMode = resolveDispatchMode(getClass());
}
return switch (dispatchMode) {
case DISPATCH_RENDER_2 -> render(req, res);
case DISPATCH_RENDER_1 -> render(req);
default -> throw new UnsupportedOperationException("No render method overridden for " + getClass().getName());
};
}
private static byte resolveDispatchMode(Class<?> handlerClass) {
if (overrides(handlerClass, "render", Request.class, Response.class)) return DISPATCH_RENDER_2;
if (overrides(handlerClass, "render", Request.class)) return DISPATCH_RENDER_1;
return 0;
}
private static boolean overrides(Class<?> handlerClass, String method, Class<?>... args) {
try {
return handlerClass.getMethod(method, args).getDeclaringClass() != BaseViewHandler.class;
} catch (NoSuchMethodException e) {
return false;
}
}
}
@@ -0,0 +1,7 @@
package dev.relism.ext.view.core;
import dev.relism.models.Request;
import java.util.function.Function;
public record GlobalValue(String key, Function<Request, Object> resolver) {}
@@ -0,0 +1,15 @@
package dev.relism.ext.view.core;
import dev.relism.http.ContentType;
/**
* Render result returned by engine runtimes.
*
* @param body rendered response body
* @param contentType optional explicit response content type; if null caller keeps existing type
*/
public record RenderedView(String body, ContentType contentType) {
public static RenderedView html(String body) {
return new RenderedView(body, ContentType.TEXT_HTML);
}
}
@@ -1,18 +1,14 @@
package dev.relism.ext.view;
package dev.relism.ext.view.core;
import java.util.Collections;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Mutable view-model builder used by the SSR view runtime.
*
* <p>Instances are typically created per request in {@code ViewHandler.render(...)} then passed
* to the renderer. The internal map preserves insertion order and is exposed as an immutable
* snapshot through {@link #toMap()}.
* Mutable per-request model builder.
*/
public final class ViewModel {
private final LinkedHashMap<String, Object> values;
@@ -29,13 +25,11 @@ public final class ViewModel {
return empty().with(key, value);
}
/** Adds or replaces a model entry. Nested maps/lists/view-models are normalized recursively. */
public ViewModel with(String key, Object value) {
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
return this;
}
/** Bulk variant of {@link #with(String, Object)}. */
public ViewModel withAll(Map<String, Object> values) {
if (values == null || values.isEmpty()) return this;
for (Map.Entry<String, Object> e : values.entrySet()) {
@@ -44,31 +38,26 @@ public final class ViewModel {
return this;
}
/**
* Creates a merged copy where {@code other} wins on key collisions.
* Both source models remain unchanged.
*/
public ViewModel merge(ViewModel other) {
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
return merged;
}
/** Immutable view over current values. */
public Map<String, Object> toMap() {
return Collections.unmodifiableMap(values);
}
static ViewModel copyOf(ViewModel source) {
public static ViewModel copyOf(ViewModel source) {
if (source == null || source.values.isEmpty()) return empty();
return new ViewModel(new LinkedHashMap<>(source.values));
}
static ViewModel owned(LinkedHashMap<String, Object> values) {
public static ViewModel owned(LinkedHashMap<String, Object> values) {
return new ViewModel(values);
}
static Object unwrapValue(Object value) {
public static Object unwrapValue(Object value) {
if (value instanceof ViewModel vm) {
return vm.toMap();
}
@@ -0,0 +1,9 @@
package dev.relism.ext.view.core;
import dev.relism.models.Request;
import dev.relism.models.Response;
public interface ViewRuntimeBridge<TTarget> {
TTarget resolve(Class<?> handlerClass);
RenderedView render(BaseViewHandler<TTarget> handler, TTarget target, Request req, Response res) throws Exception;
}
@@ -1,4 +1,4 @@
package dev.relism.ext.view;
package dev.relism.ext.view.core;
import org.junit.jupiter.api.Test;
@@ -0,0 +1,57 @@
# flash-ext-view-jte
Opinionated jte SSR extension for Flash.
This module keeps jte semantics front and center:
- `JteExtension`
- `JteHandler`
- `@Template`
- `ViewModel` from `flash-ext-view-core`
- `global.*` reserved namespace
The extension mirrors `gg.jte.ContentType` into Flash HTTP content type:
- `gg.jte.ContentType.Html` -> `text/html`
- `gg.jte.ContentType.Plain` -> `text/plain`
## Quick Start
```java
import dev.relism.extension.FlashApp;
import dev.relism.ext.view.core.ViewModel;
import dev.relism.ext.view.jte.*;
FlashApp.create(8080)
.install(new JteExtension(cfg -> cfg
.templateRoot("/templates")
.contentType(gg.jte.ContentType.Html)))
.scan("com.example.web")
.startAndBlock();
```
```java
import dev.relism.ext.view.core.ViewModel;
import dev.relism.ext.view.jte.*;
import dev.relism.routing.GET;
@GET("/")
@Template("pages/home.jte")
public final class HomePage extends JteHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.empty()
.with("page", new HomePageModel("Flash + jte", "elorc"))
.with("build", "dev");
}
public record HomePageModel(String title, String author) {}
}
```
## Docs
- `docs/architecture.md`
- `docs/handlers.md`
- `docs/model-and-globals.md`
- `docs/performance.md`
@@ -0,0 +1,29 @@
# Architecture
`flash-ext-view-jte` has two layers:
1. **Boot-time**
- `JteExtension` installs runtime + annotation processor.
- `JteTargetResolver` validates handlers and resolves `@Template`.
- 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.
- jte renders template into `StringOutput`.
## Handler Contract
- Must extend `JteHandler`.
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
- Must declare exactly one view annotation: `@Template`.
Invalid configurations fail fast at startup.
## Defaults
- `templateRoot`: `/templates`
- `contentType`: `gg.jte.ContentType.Html`
- `developmentMode`: `Flash.DEV`
- `usePrecompiled`: derived from `!developmentMode` unless explicitly set
- `binaryStaticContent`: `false`
@@ -0,0 +1,34 @@
# Handlers
Use `JteHandler` for class-based jte 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.jte")
public final class DashboardPage extends JteHandler {
private DashboardService service;
@Override
protected void onViewInit() {
service = require(DashboardService.class);
}
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.empty()
.with("page", service.page(req));
}
}
```
Use `render(Request, Response)` when you need response access while building model variables.
@@ -0,0 +1,33 @@
# Model And Globals
`ViewModel` is the per-request data container.
## POJO-First Pattern
Keep page state in a POJO and pass it as a single key:
```java
return ViewModel.empty()
.with("page", new HomePage("Flash + jte", "elorc"))
.with("build", "dev");
```
Then type it in template:
```jte
@import com.example.HomePage
@param HomePage page
@param String build
```
## Globals
Register globals in extension setup:
```java
new JteExtension().addGlobal("appName", req -> "Flash")
```
Globals are available under `global` namespace in templates.
`global` is reserved and cannot be used as local model key.
@@ -0,0 +1,18 @@
# Performance Notes
- Runtime caches resolved targets by handler class.
- Request path allocates only what jte rendering requires.
- No reflection on hot path after target cache is warm.
- `TemplateEngine` is created once and shared.
## Dev vs Prod
- Dev mode (`developmentMode=true`) uses code resolver rendering.
- Prod mode (`usePrecompiled=true`) uses precompiled classes for startup and render speed.
## Content Type
`gg.jte.ContentType` drives both escaping mode and HTTP response content type mirror:
- `Html` -> context-aware HTML escaping + `text/html`
- `Plain` -> plain output + `text/plain`
@@ -0,0 +1,29 @@
package gg.jte.generated.ondemand.pages;
import dev.relism.ext.view.jte.model.HomePage;
import java.util.Map;
@SuppressWarnings("unchecked")
public final class JtehomeGenerated {
public static final String JTE_NAME = "pages/home.jte";
public static final int[] JTE_LINE_INFO = {0,0,1,2,2,2,2,6,6,6,6,7,7,7,8,8,8,9,9,9,10,10,10,2,3,4,4,4,4};
public static void render(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, HomePage page, String build, Map<String, Object> global) {
jteOutput.writeContent("\n<h1>");
jteOutput.setContext("h1", null);
jteOutput.writeUserContent(page.title());
jteOutput.writeContent("</h1>\n<p>");
jteOutput.setContext("p", null);
jteOutput.writeUserContent(page.author());
jteOutput.writeContent("</p>\n<small>");
jteOutput.setContext("small", null);
jteOutput.writeUserContent(build);
jteOutput.writeContent("</small>\n<small>");
jteOutput.setContext("small", null);
jteOutput.writeUserContent((String) global.get("appName"));
jteOutput.writeContent("</small>\n");
}
public static void renderMap(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, java.util.Map<String, Object> params) {
HomePage page = (HomePage)params.get("page");
String build = (String)params.get("build");
Map<String, Object> global = (Map<String, Object>)params.get("global");
render(jteOutput, jteHtmlInterceptor, page, build, global);
}
}
@@ -0,0 +1,79 @@
<?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-jte</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>gg.jte</groupId>
<artifactId>jte</artifactId>
<version>3.2.3</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,70 @@
package dev.relism.ext.view.jte;
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.Request;
import dev.relism.models.RequestHandler;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Opinionated jte SSR extension for Flash.
*/
public final class JteExtension extends BaseViewExtension<JteTarget> {
private final JteSettings settings;
static {
ensureJtePresent();
}
public JteExtension() {
this(JteSettings.builder().build());
}
public JteExtension(Consumer<JteSettings.Builder> customizer) {
JteSettings.Builder builder = JteSettings.builder();
java.util.Objects.requireNonNull(customizer, "customizer must not be null").accept(builder);
this.settings = builder.build();
}
private JteExtension(JteSettings settings) {
this.settings = settings;
}
@Override
public JteExtension addGlobal(String key, Function<Request, Object> resolver) {
super.addGlobal(key, resolver);
return this;
}
@Override
protected ViewRuntimeBridge<JteTarget> createRuntime(List<GlobalValue> globals) {
return new JteRuntime(settings, globals);
}
@Override
protected void validateHandlerClass(Class<? extends RequestHandler> handlerClass) {
JteTarget target = JteTargetResolver.resolve(handlerClass, settings);
if (target == null) {
if (JteHandler.class.isAssignableFrom(handlerClass)) {
throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template");
}
return;
}
if (!JteHandler.class.isAssignableFrom(handlerClass)) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " declares @Template but does not extend JteHandler");
}
}
private static void ensureJtePresent() {
try {
Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader());
} catch (ClassNotFoundException e) {
throw new IllegalStateException("jte is not on the classpath. Add dependency gg.jte:jte:3.2.3", e);
}
}
}
@@ -0,0 +1,8 @@
package dev.relism.ext.view.jte;
import dev.relism.ext.view.core.BaseViewHandler;
/**
* Base class for class-based jte handlers.
*/
public abstract class JteHandler extends BaseViewHandler<JteTarget> {}
@@ -0,0 +1,113 @@
package dev.relism.ext.view.jte;
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 gg.jte.CodeResolver;
import gg.jte.TemplateEngine;
import gg.jte.output.StringOutput;
import gg.jte.resolve.DirectoryCodeResolver;
import gg.jte.resolve.ResourceCodeResolver;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
final class JteRuntime implements ViewRuntimeBridge<JteTarget> {
private static final String GLOBAL_NAMESPACE = "global";
private final JteSettings settings;
private final List<GlobalValue> globals;
private final ConcurrentHashMap<Class<?>, JteTarget> targets = new ConcurrentHashMap<>();
private final TemplateEngine engine;
JteRuntime(JteSettings settings, List<GlobalValue> globals) {
this.settings = settings;
this.globals = globals;
this.engine = createEngine(settings);
}
@Override
public JteTarget resolve(Class<?> handlerClass) {
JteTarget cached = targets.get(handlerClass);
if (cached != null) return cached;
JteTarget resolved = JteTargetResolver.resolve(handlerClass, settings);
if (resolved == null) {
throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template");
}
targets.put(handlerClass, resolved);
return resolved;
}
@Override
public RenderedView render(BaseViewHandler<JteTarget> handler,
JteTarget target,
Request req,
Response res) throws Exception {
ViewModel local = handler.renderInternal(req, res);
Map<String, Object> merged = merge(req, local).toMap();
StringOutput out = new StringOutput(1024);
engine.render(target.template(), merged, out);
return new RenderedView(out.toString(), 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()) {
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(JteSettings settings) {
TemplateEngine engine;
if (settings.usePrecompiled()) {
Path path = settings.precompiledClassesPath();
engine = Files.isDirectory(path)
? TemplateEngine.createPrecompiled(path, settings.contentType())
: TemplateEngine.createPrecompiled(settings.contentType());
} else {
CodeResolver resolver = createResolver(settings.templateRoot(), Thread.currentThread().getContextClassLoader());
engine = TemplateEngine.create(resolver, settings.dynamicClassesPath(), settings.contentType());
}
engine.setBinaryStaticContent(settings.binaryStaticContent());
return engine;
}
private static CodeResolver createResolver(String templateRoot, ClassLoader classLoader) {
String root = templateRoot.startsWith("/") ? templateRoot.substring(1) : templateRoot;
Path maybeDir = Path.of(root);
if (Files.isDirectory(maybeDir)) {
return new DirectoryCodeResolver(maybeDir);
}
return new ResourceCodeResolver(root, classLoader);
}
}
@@ -0,0 +1,109 @@
package dev.relism.ext.view.jte;
import dev.relism.Flash;
import java.nio.file.Path;
/**
* jte runtime settings with Flash-sensitive defaults.
*/
public final class JteSettings {
private final String templateRoot;
private final gg.jte.ContentType contentType;
private final boolean developmentMode;
private final boolean usePrecompiled;
private final boolean binaryStaticContent;
private final Path dynamicClassesPath;
private final Path precompiledClassesPath;
private JteSettings(Builder b) {
this.templateRoot = b.templateRoot;
this.contentType = b.contentType;
this.developmentMode = b.developmentMode;
this.usePrecompiled = b.usePrecompiled;
this.binaryStaticContent = b.binaryStaticContent;
this.dynamicClassesPath = b.dynamicClassesPath;
this.precompiledClassesPath = b.precompiledClassesPath;
}
public static Builder builder() {
return new Builder();
}
String templateRoot() { return templateRoot; }
gg.jte.ContentType contentType() { return contentType; }
boolean developmentMode() { return developmentMode; }
boolean usePrecompiled() { return usePrecompiled; }
boolean binaryStaticContent() { return binaryStaticContent; }
Path dynamicClassesPath() { return dynamicClassesPath; }
Path precompiledClassesPath() { return precompiledClassesPath; }
public static final class Builder {
private String templateRoot = "/templates";
private gg.jte.ContentType contentType = gg.jte.ContentType.Html;
private Boolean developmentMode;
private Boolean usePrecompiled;
private boolean binaryStaticContent;
private Path dynamicClassesPath = Path.of("jte-classes");
private Path precompiledClassesPath = Path.of("jte-classes");
public Builder templateRoot(String templateRoot) {
String root = templateRoot == null ? "" : templateRoot.trim();
if (root.isEmpty()) throw new IllegalArgumentException("templateRoot must not be blank");
this.templateRoot = normalizeResourceRoot(root);
return this;
}
public Builder contentType(gg.jte.ContentType contentType) {
this.contentType = java.util.Objects.requireNonNull(contentType, "contentType must not be null");
return this;
}
public Builder developmentMode(boolean developmentMode) {
this.developmentMode = developmentMode;
return this;
}
public Builder usePrecompiled(boolean usePrecompiled) {
this.usePrecompiled = usePrecompiled;
return this;
}
public Builder binaryStaticContent(boolean binaryStaticContent) {
this.binaryStaticContent = binaryStaticContent;
return this;
}
public Builder dynamicClassesPath(Path dynamicClassesPath) {
this.dynamicClassesPath = java.util.Objects.requireNonNull(dynamicClassesPath, "dynamicClassesPath must not be null");
return this;
}
public Builder precompiledClassesPath(Path precompiledClassesPath) {
this.precompiledClassesPath = java.util.Objects.requireNonNull(precompiledClassesPath, "precompiledClassesPath must not be null");
return this;
}
public JteSettings build() {
boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV;
boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev;
Builder resolved = new Builder();
resolved.templateRoot = this.templateRoot;
resolved.contentType = this.contentType;
resolved.developmentMode = resolvedDev;
resolved.usePrecompiled = resolvedPrecompiled;
resolved.binaryStaticContent = this.binaryStaticContent;
resolved.dynamicClassesPath = this.dynamicClassesPath;
resolved.precompiledClassesPath = this.precompiledClassesPath;
return new JteSettings(resolved);
}
private static String normalizeResourceRoot(String root) {
String normalized = root.replace('\\', '/');
if (!normalized.startsWith("/")) normalized = '/' + normalized;
while (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1);
if (normalized.isEmpty()) return "/";
return normalized;
}
}
}
@@ -0,0 +1,5 @@
package dev.relism.ext.view.jte;
import dev.relism.http.ContentType;
record JteTarget(String template, ContentType contentType) {}
@@ -0,0 +1,42 @@
package dev.relism.ext.view.jte;
import dev.relism.http.ContentType;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
final class JteTargetResolver {
private JteTargetResolver() {}
static JteTarget resolve(Class<?> handlerClass, JteSettings settings) {
Template template = find(handlerClass, Template.class);
if (template == null) return null;
Route route = Routes.of(handlerClass);
if (route == null) {
throw new IllegalStateException("Jte handler " + handlerClass.getName()
+ " has @Template but no route annotation (@Route/@GET/@POST/...)");
}
String name = template.value() == null ? "" : template.value().trim();
if (name.isEmpty()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Template with an empty value");
}
return new JteTarget(name, toHttpContentType(settings.contentType()));
}
private static ContentType toHttpContentType(gg.jte.ContentType contentType) {
return contentType == gg.jte.ContentType.Plain ? ContentType.TEXT_PLAIN : ContentType.TEXT_HTML;
}
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,15 @@
package dev.relism.ext.view.jte;
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 jte template.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Template {
String value();
}
@@ -0,0 +1,79 @@
package dev.relism.ext.view.jte;
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 JteExtensionTest {
@GET("/ok")
@Template("pages/home.jte")
static class ValidHandler extends JteHandler {
@Override
public dev.relism.ext.view.core.ViewModel render(Request req) {
return dev.relism.ext.view.core.ViewModel.empty();
}
}
@GET("/wrong")
@Template("pages/home.jte")
static class WrongBaseHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/missing")
static class MissingTemplateHandler extends JteHandler {}
@Test
void constructor_requiresNonNullCustomizer() {
assertThrows(NullPointerException.class, () -> new JteExtension(null));
}
@Test
void validate_acceptsProperHandler() throws Exception {
JteExtension ext = new JteExtension();
invokeValidate(ext, ValidHandler.class);
}
@Test
void validate_rejectsTemplateWithoutJteHandlerBase() {
JteExtension ext = new JteExtension();
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> invokeValidate(ext, WrongBaseHandler.class));
assertTrue(ex.getMessage().contains("does not extend JteHandler"));
}
@Test
void validate_rejectsJteHandlerWithoutTemplate() {
JteExtension ext = new JteExtension();
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> invokeValidate(ext, MissingTemplateHandler.class));
assertTrue(ex.getMessage().contains("must declare @Template"));
}
private static void invokeValidate(JteExtension ext, Class<? extends RequestHandler> type) throws Exception {
Method m = JteExtension.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,68 @@
package dev.relism.ext.view.jte;
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.jte.model.HomePage;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class JteRuntimeTest {
@Test
void render_template_rendersPojoAndGlobals() throws Exception {
JteRuntime runtime = new JteRuntime(
JteSettings.builder().templateRoot("/templates").developmentMode(true).build(),
List.of(new GlobalValue("appName", req -> "Flash"))
);
RenderedView out = runtime.render(new JteHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.empty()
.with("page", new HomePage("Flash + jte", "elorc"))
.with("build", "dev");
}
}, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
assertEquals(ContentType.TEXT_HTML, out.contentType());
assertTrue(out.body().contains("Flash + jte"));
assertTrue(out.body().contains("elorc"));
assertTrue(out.body().contains("Flash"));
}
@Test
void render_rejectsReservedGlobalKeyInLocalModel() {
JteRuntime runtime = new JteRuntime(
JteSettings.builder().templateRoot("/templates").developmentMode(true).build(),
List.of(new GlobalValue("appName", req -> "Flash"))
);
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
runtime.render(new JteHandler() {
@Override
public ViewModel render(Request req) {
return ViewModel.of("global", "bad");
}
}, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON))
);
assertTrue(ex.getMessage().contains("reserved"));
}
@Test
void resolve_missingTemplateOnHandler_failsFast() {
JteRuntime runtime = new JteRuntime(JteSettings.builder().templateRoot("/templates").developmentMode(true).build(), List.of());
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runtime.resolve(NoTemplateHandler.class));
assertTrue(ex.getMessage().contains("must declare @Template"));
}
static class NoTemplateHandler extends JteHandler {}
}
@@ -0,0 +1,69 @@
package dev.relism.ext.view.jte;
import dev.relism.Flash;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class JteSettingsTest {
@Test
void defaults_followFlashDev_andUseHtml() {
JteSettings settings = JteSettings.builder().build();
assertEquals("/templates", settings.templateRoot());
assertEquals(gg.jte.ContentType.Html, settings.contentType());
assertEquals(Flash.DEV, settings.developmentMode());
assertEquals(!Flash.DEV, settings.usePrecompiled());
assertFalse(settings.binaryStaticContent());
assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath());
assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath());
}
@Test
void explicitOverrides_winOverDerivedDefaults() {
JteSettings settings = JteSettings.builder()
.templateRoot("src/main/jte")
.contentType(gg.jte.ContentType.Plain)
.developmentMode(true)
.usePrecompiled(true)
.binaryStaticContent(true)
.dynamicClassesPath(Path.of("var", "jte-dev"))
.precompiledClassesPath(Path.of("var", "jte-prod"))
.build();
assertEquals("/src/main/jte", settings.templateRoot());
assertEquals(gg.jte.ContentType.Plain, settings.contentType());
assertTrue(settings.developmentMode());
assertTrue(settings.usePrecompiled());
assertTrue(settings.binaryStaticContent());
assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath());
assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath());
}
@Test
void usePrecompiled_derivesFromResolvedDevModeWhenMissing() {
JteSettings dev = JteSettings.builder().developmentMode(true).build();
JteSettings prod = JteSettings.builder().developmentMode(false).build();
assertFalse(dev.usePrecompiled());
assertTrue(prod.usePrecompiled());
}
@Test
void templateRoot_normalizesAndRejectsBlank() {
JteSettings normalized = JteSettings.builder().templateRoot("templates").build();
assertEquals("/templates", normalized.templateRoot());
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" "));
}
@Test
void nulls_areRejectedForRequiredObjects() {
assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null));
assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null));
assertThrows(NullPointerException.class, () -> JteSettings.builder().precompiledClassesPath(null));
}
}
@@ -0,0 +1,70 @@
package dev.relism.ext.view.jte;
import dev.relism.http.ContentType;
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 JteTargetResolverTest {
@GET("/home")
@Template("pages/home.jte")
static class TemplateHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Template("pages/no-route.jte")
static class NoRouteHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/empty")
@Template(" ")
static class EmptyTemplateHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void resolve_template_returnsTarget_withMirroredHttpContentType() {
JteSettings htmlSettings = JteSettings.builder().contentType(gg.jte.ContentType.Html).build();
JteSettings plainSettings = JteSettings.builder().contentType(gg.jte.ContentType.Plain).build();
JteTarget html = JteTargetResolver.resolve(TemplateHandler.class, htmlSettings);
JteTarget plain = JteTargetResolver.resolve(TemplateHandler.class, plainSettings);
assertNotNull(html);
assertEquals("pages/home.jte", html.template());
assertEquals(ContentType.TEXT_HTML, html.contentType());
assertEquals(ContentType.TEXT_PLAIN, plain.contentType());
}
@Test
void resolve_withoutTemplate_returnsNull() {
assertNull(JteTargetResolver.resolve(RequestHandler.class, JteSettings.builder().build()));
}
@Test
void resolve_templateWithoutRoute_fails() {
assertThrows(IllegalStateException.class,
() -> JteTargetResolver.resolve(NoRouteHandler.class, JteSettings.builder().build()));
}
@Test
void resolve_emptyTemplate_fails() {
assertThrows(IllegalStateException.class,
() -> JteTargetResolver.resolve(EmptyTemplateHandler.class, JteSettings.builder().build()));
}
}
@@ -0,0 +1,3 @@
package dev.relism.ext.view.jte.model;
public record HomePage(String title, String author) {}
@@ -0,0 +1,10 @@
@import dev.relism.ext.view.jte.model.HomePage
@import java.util.Map
@param HomePage page
@param String build
@param Map<String, Object> global
<h1>${page.title()}</h1>
<p>${page.author()}</p>
<small>${build}</small>
<small>${(String) global.get("appName")}</small>
@@ -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`).
@@ -1,6 +1,6 @@
# Handlers
Use `ViewHandler` for class-based SSR routes.
Use `ThymeleafHandler` for class-based Thymeleaf routes.
## Lifecycle
@@ -13,8 +13,8 @@ Use `onViewInit()` to cache dependencies via `require(...)`.
```java
@GET("/dashboard")
@Page("pages/dashboard")
public final class DashboardPage extends ViewHandler {
@Template("pages/dashboard")
public final class DashboardPage extends ThymeleafHandler {
private DashboardService service;
@@ -32,4 +32,4 @@ public final class DashboardPage extends ViewHandler {
}
```
Use `render(Request, Response)` only when you need response access while building the model.
Use `render(Request, Response)` when you need response access while building model variables.
@@ -1,6 +1,6 @@
# Model and Globals
`ViewModel` is the per-request model builder.
`ViewModel` (from `flash-ext-view-core`) is the per-request model builder.
## Merge Order
@@ -16,7 +16,7 @@ Handlers cannot set top-level `global`; runtime throws fail-fast to prevent name
Register globals on extension setup:
```java
new ViewExtension(ViewEngineType.THYMELEAF)
new ThymeleafExtension()
.addGlobal("appName", req -> "Flash")
.addGlobal("path", req -> req.path());
```
@@ -1,12 +1,13 @@
# Performance
`flash-ext-view` is optimized for low overhead on request path.
`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.
- No legacy rendering branches in runtime pipeline.
- Thymeleaf target metadata resolved and cached per handler class.
- No engine-agnostic adapter indirection.
## Best practices
@@ -7,10 +7,10 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-ext-view</artifactId>
<artifactId>flash-ext-view-thymeleaf</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
@@ -19,20 +19,13 @@
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
<artifactId>flash-ext-view-core</artifactId>
</dependency>
<!--
Optional engine bridges — not transitive.
Users must add whichever engine they select via ViewEngineType
to their own pom.xml. If absent at runtime, ViewExtension throws
a descriptive IllegalStateException at boot time.
-->
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -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,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>
-54
View File
@@ -1,54 +0,0 @@
# flash-ext-view
Lightweight SSR view extension for Flash.
This module provides a focused MVC surface:
- `ViewExtension`
- `ViewHandler`
- `@Page` and `@Partial`
- `ViewModel` and opinionated `global.*` values
- `ViewEngineAdapter`
No legacy annotation/renderer API is exposed.
`@Page`/`@Partial` are valid only on `ViewHandler` subclasses.
## Quick Start
```java
import dev.relism.extension.FlashApp;
import dev.relism.ext.view.*;
FlashApp.create(8080)
.install(new ViewExtension(ViewEngineType.THYMELEAF)
.addGlobal("appName", req -> "Flash")
.addGlobal("requestPath", req -> req.path()))
.scan("com.example.web")
.startAndBlock();
```
`global` is a reserved namespace. Handlers cannot write a top-level `global` key.
```java
import dev.relism.ext.view.*;
import dev.relism.routing.GET;
@GET("/")
@Page("pages/home")
public final class HomePage extends ViewHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("title", "Home");
}
}
```
## Docs
- `docs/architecture.md`
- `docs/handlers.md`
- `docs/model-and-globals.md`
- `docs/partials.md`
- `docs/adapters.md`
- `docs/performance.md`
@@ -1,29 +0,0 @@
# Adapters
`ViewEngineAdapter` is the rendering boundary.
## Built-in
- `ViewEngineType.THYMELEAF`
## Custom adapter
```java
public final class MyAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target,
Map<String, Object> model,
Request req,
Response res) {
String body = "...";
return RenderOutput.html(body);
}
}
```
Adapter instances must be thread-safe after construction.
@@ -1,23 +0,0 @@
# Architecture
`flash-ext-view` runs in two layers:
1. **Boot-time**
- `ViewExtension` registers `ViewRuntime` and an annotation processor.
- `ViewTargetResolver` validates handlers and maps annotations to `ViewTarget`.
- Resolved targets are cached per handler class.
2. **Request-time**
- Handler builds local `ViewModel`.
- `ViewRuntime` injects extension globals under reserved `global` namespace, then merges local model.
- `ViewEngineAdapter` renders `RenderOutput`.
## Valid Handler Contract
- Must extend `ViewHandler`.
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
- Must declare exactly one view annotation:
- `@Page`
- `@Partial`
Invalid configurations fail fast at startup.
@@ -1,19 +0,0 @@
# Partials
Use `@Partial` for fragment responses.
```java
@GET("/users/table")
@Partial(template = "fragments/users", slot = "rows")
public final class UsersRows extends ViewHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("users", List.of());
}
}
```
If slot is empty, the adapter default slot is used.
If a slot is specified but the adapter does not support slot selection,
startup fails with a clear error.
@@ -1,6 +0,0 @@
package dev.relism.ext.view;
/** Engine feature flags used for boot-time validation. */
public record EngineCapabilities(boolean supportsPartialSlot) {
public static final EngineCapabilities NONE = new EngineCapabilities(false);
}
@@ -1,7 +0,0 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import java.util.function.Function;
record GlobalBinding(String key, Function<Request, Object> resolver) {}
@@ -1,20 +0,0 @@
package dev.relism.ext.view;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a class-based route handler to a full-page template.
*
* <p>Use on subclasses of {@link dev.relism.models.RequestHandler}, typically
* {@link ViewHandler}. The handler must also declare a route annotation
* ({@code @Route}, {@code @GET}, {@code @POST}, ...).
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Page {
/** Template name/path (engine-specific). */
String value();
}
@@ -1,22 +0,0 @@
package dev.relism.ext.view;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a class-based route handler to a partial template render.
*
* <p>Useful for progressive/fragment updates (e.g. HTMX). When {@link #slot()} is blank,
* the adapter default slot is used.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Partial {
/** Template containing the fragment slot. */
String template();
/** Optional fragment slot selector. */
String slot() default "";
}
@@ -1,16 +0,0 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
/**
* Render result produced by a {@link ViewEngineAdapter}.
*
* @param body rendered response body
* @param contentType optional explicit response content type; if null runtime falls back to
* route-level default ({@code text/html})
*/
public record RenderOutput(String body, ContentType contentType) {
public static RenderOutput html(String body) {
return new RenderOutput(body, ContentType.TEXT_HTML);
}
}
@@ -1,160 +0,0 @@
package dev.relism.ext.view;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.linkbuilder.ILinkBuilder;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
/**
* Thymeleaf 3.x adapter for Flash SSR views.
*
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
*
* <h3>Default configuration</h3>
* <ul>
* <li>Prefix : {@code /templates/} (classpath-relative)</li>
* <li>Suffix : {@code .html}</li>
* <li>Mode : {@link TemplateMode#HTML}</li>
* <li>Encoding: UTF-8</li>
* <li>Cache : enabled in production, disabled in dev mode
* ({@code flash.env=dev} or {@code FLASH_ENV=dev})</li>
* </ul>
*
* <h3>Link building</h3>
* Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (servlet context) to resolve context-relative paths
* ({@code @{/foo}}). Flash runs standalone, so this engine registers a custom
* {@link FlashLinkBuilder} that resolves {@code @{...}} expressions without a
* servlet context — path variables and query parameters are supported as usual.
*
* <h3>Model conventions</h3>
* <ul>
* <li>{@link Map} model → each entry is a named Thymeleaf variable.</li>
* <li>Any other non-null value → registered under the key {@code "it"}.</li>
* <li>{@code null} model → empty context.</li>
* </ul>
*/
final class ThymeleafEngine implements ViewEngineAdapter {
private static final String PREFIX = "/templates/";
private static final String SUFFIX = ".html";
private final TemplateEngine engine;
ThymeleafEngine(boolean cacheEnabled) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix(PREFIX);
resolver.setSuffix(SUFFIX);
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(cacheEnabled);
this.engine = new TemplateEngine();
this.engine.setTemplateResolver(resolver);
// Replace the default StandardLinkBuilder (which requires IWebContext)
// with our standalone-compatible link builder.
this.engine.addLinkBuilder(FlashLinkBuilder.INSTANCE);
}
@Override
public EngineCapabilities capabilities() {
return new EngineCapabilities(true);
}
@Override
public RenderOutput render(ViewTarget target, Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
String template = target.template();
if (target.kind() == ViewKind.PAGE) {
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(template, ctx));
} else {
String slot = target.slot();
String fragment = (slot == null || slot.isBlank()) ? "content" : slot;
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(template, Set.of(fragment), ctx));
}
}
private static void populateContext(Context ctx, Object model) {
if (model instanceof Map<?, ?> map) {
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
} else if (model != null) {
ctx.setVariable("it", model);
}
}
// ── Link builder ──────────────────────────────────────────────────────────
/**
* Standalone-compatible link builder for Thymeleaf's {@code @{...}} expressions.
*
* <p>Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (i.e. a servlet container) to resolve context-relative
* paths starting with {@code /}. This builder replicates that behaviour without
* the servlet dependency:
* <ul>
* <li>Path variables — {@code @{/posts/{id}(id=${post.id})}} → {@code /posts/abc}</li>
* <li>Query params — {@code @{/search(q=${term})}} → {@code /search?q=hello}</li>
* <li>Mixed — {@code @{/posts/{id}(id=x,p=2)}} → {@code /posts/x?p=2}</li>
* </ul>
* Registered at order {@link Integer#MIN_VALUE} so it takes precedence over
* {@code StandardLinkBuilder} ({@code Integer.MAX_VALUE}).
*/
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);
}
/** Substitutes {@code {key}} placeholders in the path with their encoded values. */
private static String expandPathVars(String base, Map<String, Object> params) {
if (params == null || params.isEmpty() || !base.contains("{")) return base;
String result = base;
for (var 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;
}
/** Appends parameters that were NOT consumed as path variables as {@code ?k=v&…} pairs. */
private static String appendQueryString(String url, String base, Map<String, Object> params) {
if (params == null || params.isEmpty()) return url;
StringBuilder qs = new StringBuilder();
for (var 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");
}
}
}
@@ -1,29 +0,0 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.Map;
/**
* Rendering adapter contract used by the Flash SSR view runtime.
*
* <p>Implementations must be thread-safe after construction because one instance is shared by
* all requests.
*/
public interface ViewEngineAdapter {
/** Engine feature flags used for boot-time route/view validation. */
EngineCapabilities capabilities();
/**
* Renders a page/partial target with the merged model for the current request.
*
* @param target resolved rendering target
* @param model merged request model (globals first, local model last)
* @param req current request
* @param res current response
* @return response body + optional explicit content type
*/
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
}
@@ -1,80 +0,0 @@
package dev.relism.ext.view;
/**
* Managed template engine types supported out-of-the-box by {@link ViewExtension}.
*
* <p>Pass one of these constants to {@link ViewExtension#ViewExtension(ViewEngineType)}
* for zero-boilerplate setup. The extension auto-configures the selected engine with
* sensible defaults and validates that the required library is on the runtime classpath,
* throwing a descriptive {@link IllegalStateException} at boot time if it is not.
*
* <pre>{@code
* // Zero-boilerplate — Thymeleaf auto-configured with defaults
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Dev mode</h3>
* Template caching is <b>disabled</b> when either:
* <ul>
* <li>the JVM property {@code flash.env} equals {@code dev} (case-insensitive), or</li>
* <li>the environment variable {@code FLASH_ENV} equals {@code dev}.</li>
* </ul>
* In all other cases caching is enabled (production default).
*
* <h3>Adding your own engine</h3>
* For unsupported engines, implement {@link ViewEngineAdapter} and pass it to
* {@link ViewExtension#ViewExtension(ViewEngineAdapter)}.
*/
public enum ViewEngineType {
/**
* Thymeleaf 3.x — natural HTML templates with server-side rendering.
*
* <p>Required dependency (add to your {@code pom.xml}):
* <pre>{@code
* <dependency>
* <groupId>org.thymeleaf</groupId>
* <artifactId>thymeleaf</artifactId>
* <version>3.1.2.RELEASE</version>
* </dependency>
* }</pre>
*
* Default resolver: classpath, prefix {@code /templates/}, suffix {@code .html},
* mode {@code HTML}, encoding UTF-8.
*/
THYMELEAF;
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Instantiates and configures the {@link ViewEngineAdapter} for this type.
* Called once at extension setup time — never on the hot-path.
*
* @param cacheEnabled whether the engine should cache compiled templates
* @throws IllegalStateException if the required library is not on the classpath
*/
ViewEngineAdapter createAdapter(boolean cacheEnabled) {
return switch (this) {
case THYMELEAF -> createThymeleaf(cacheEnabled);
};
}
// ── Engine factories ──────────────────────────────────────────────────────
private static ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
try {
return new ThymeleafEngine(cacheEnabled);
} catch (NoClassDefFoundError e) {
throw new IllegalStateException("""
Thymeleaf is not on the classpath. \
Add the following dependency to your pom.xml:
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
""", e);
}
}
}
@@ -1,66 +0,0 @@
package dev.relism.ext.view;
import dev.relism.Flash;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
/**
* Installs server-side view rendering for class-based handlers.
*
* <p>Strict contract: only {@link ViewHandler} subclasses may declare {@link Page}/{@link Partial}.
* Annotating a plain {@link dev.relism.models.RequestHandler} fails fast at boot.
*/
public final class ViewExtension implements FlashExtension {
private final ViewEngineAdapter adapter;
private final List<GlobalBinding> globals = new ArrayList<>();
public ViewExtension(ViewEngineType type) {
this(type.createAdapter(!Flash.DEV));
}
public ViewExtension(ViewEngineAdapter adapter) {
this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null");
}
/**
* Registers one request-scoped global value under {@code global.<key>}.
*
* <p>This is the only supported global registration API. Keep resolvers fast and side-effect free.
*/
public ViewExtension addGlobal(String key, Function<dev.relism.models.Request, Object> resolver) {
String k = Objects.requireNonNull(key, "global key must not be null").trim();
if (k.isEmpty()) {
throw new IllegalArgumentException("global key must not be blank");
}
if (k.equals("global") || k.contains(".")) {
throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key);
}
globals.add(new GlobalBinding(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
return this;
}
@Override
public void provide(FlashContext ctx) {
ViewRuntime runtime = new ViewRuntime(adapter, List.copyOf(globals));
ctx.provide(ViewEngineAdapter.class, adapter);
ctx.provide(ViewRuntime.class, runtime);
// Processor kept for boot-time contract enforcement. Rendering itself stays in ViewHandler.
ctx.addAnnotationProcessor(handlerClass -> {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) return List.of();
if (!ViewHandler.class.isAssignableFrom(handlerClass)) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " declares @Page/@Partial but does not extend ViewHandler");
}
return List.of();
});
}
}
@@ -1,59 +0,0 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
/**
* Base class for class-based SSR handlers.
*
* <p>Subclass contract:
* <ol>
* <li>Declare exactly one of {@link Page} or {@link Partial} on the class hierarchy.</li>
* <li>Cache dependencies in {@link #onViewInit()} (one-time, boot-time).</li>
* <li>Build per-request model in {@link #render(Request)} or {@link #render(Request, Response)}.</li>
* </ol>
*/
public abstract class ViewHandler extends RequestHandler {
private ViewRuntime runtime;
private ViewTargetResolver.ResolvedView resolved;
/**
* Per-request model hook.
*
* <p>Override this method for request-only rendering. If you need to mutate response
* metadata while building the model, override {@link #render(Request, Response)}.
*/
public ViewModel render(Request req) throws Exception {
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
}
/**
* Per-request model hook with response access.
*
* <p>Default implementation delegates to {@link #render(Request)}.
*/
public ViewModel render(Request req, Response res) throws Exception {
return render(req);
}
@Override
protected final void onInit() {
runtime = require(ViewRuntime.class);
resolved = runtime.resolve(getClass());
onViewInit();
}
/**
* One-time initialization hook invoked after view metadata resolution.
*
* <p>Use this to cache services via {@link #require(Class)}. Do not perform request-bound
* work here.
*/
protected void onViewInit() {}
@Override
public final Object handle(Request request, Response response) throws Exception {
return runtime.render(this, resolved, request, response);
}
}
@@ -1,7 +0,0 @@
package dev.relism.ext.view;
/** Render mode for resolved handler view targets. */
public enum ViewKind {
PAGE,
PARTIAL
}
@@ -1,91 +0,0 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Internal runtime for view target resolution and per-request rendering.
*
* <p>All expensive reflection is done once and cached per handler class.
*/
final class ViewRuntime {
private static final String GLOBAL_NAMESPACE = "global";
private final ViewEngineAdapter adapter;
private final List<GlobalBinding> globals;
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
ViewRuntime(ViewEngineAdapter adapter, List<GlobalBinding> globals) {
this.adapter = adapter;
this.globals = globals;
}
ViewTargetResolver.ResolvedView resolve(Class<?> handlerClass) {
ViewTargetResolver.ResolvedView cached = resolvedCache.get(handlerClass);
if (cached != null) return cached;
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) {
throw new IllegalStateException("ViewHandler " + handlerClass.getName()
+ " must declare @Page or @Partial");
}
resolvedCache.put(handlerClass, resolved);
return resolved;
}
Object render(ViewHandler handler, ViewTargetResolver.ResolvedView resolved, Request req, Response res) throws Exception {
if (resolved == null) {
throw new IllegalStateException("ViewHandler " + handler.getClass().getName()
+ " must declare @Page or @Partial");
}
RenderOutput out = render(resolved, req, res, handler.render(req, res));
return out.body();
}
RenderOutput render(ViewTargetResolver.ResolvedView resolved, Request req, Response res, ViewModel local) throws Exception {
ViewModel merged = merge(req, local);
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
if (out.contentType() != null) {
res.type(out.contentType());
} else {
res.type(resolved.contentType());
}
return out;
}
ViewModel merge(Request req, ViewModel local) {
// Single-pass merge: reserved global namespace + local model.
// We avoid intermediate ViewModel allocations on the hot path.
LinkedHashMap<String, Object> values = null;
if (!globals.isEmpty()) {
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
for (GlobalBinding 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) {
var 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);
}
}
@@ -1,14 +0,0 @@
package dev.relism.ext.view;
/**
* Resolved render target for one handler class.
*
* @param kind page or partial render
* @param template template identifier/path
* @param slot optional partial slot selector
*/
public record ViewTarget(
ViewKind kind,
String template,
String slot
) {}
@@ -1,70 +0,0 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
/** Boot-time resolver that maps handler annotations to concrete render targets. */
final class ViewTargetResolver {
private ViewTargetResolver() {}
static ResolvedView resolve(Class<?> handlerClass, EngineCapabilities capabilities) {
Page page = find(handlerClass, Page.class);
Partial partial = find(handlerClass, Partial.class);
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0);
if (count == 0) return null;
Route route = Routes.of(handlerClass);
if (route == null) {
throw new IllegalStateException("View 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 view annotations. Use exactly one of @Page, @Partial");
}
if (page != null) {
String template = page.value() == null ? "" : page.value().trim();
if (template.isEmpty()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Page with an empty template name");
}
return new ResolvedView(new ViewTarget(ViewKind.PAGE, template, ""), ContentType.TEXT_HTML);
}
if (partial != null) {
String template = partial.template() == null ? "" : partial.template().trim();
if (template.isEmpty()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Partial with an empty template name");
}
String slot = partial.slot() == null ? "" : partial.slot().trim();
if (!slot.isEmpty() && !capabilities.supportsPartialSlot()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Partial(slot=\"" + slot + "\") but current engine does not support partial slots");
}
return new ResolvedView(new ViewTarget(ViewKind.PARTIAL, template, slot), ContentType.TEXT_HTML);
}
return null;
}
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;
}
record ResolvedView(ViewTarget target, ContentType contentType) {}
}
@@ -1,61 +0,0 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ThymeleafEngineTest {
@Test
void render_page_resolvesTemplateAndLinks() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
ViewModel.empty().with("title", "Home").with("id", 42).with("page", 2).toMap(),
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_partial_usesExplicitSlot() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PARTIAL, "pages/home", "rows"),
ViewModel.empty().with("id", 42).toMap(),
null,
new Response(200, ContentType.JSON)
);
assertTrue(out.body().contains("row-42"));
}
@Test
void render_partial_usesDefaultContentSlotWhenBlank() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PARTIAL, "pages/home", " "),
ViewModel.empty().with("title", "ContentSlot").toMap(),
null,
new Response(200, ContentType.JSON)
);
assertTrue(out.body().contains("content-ContentSlot"));
}
@Test
void capabilities_supportSlotSelection() {
ThymeleafEngine engine = new ThymeleafEngine(true);
assertTrue(engine.capabilities().supportsPartialSlot());
}
}
@@ -1,112 +0,0 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.extension.AnnotationProcessor;
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 java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ViewExtensionContractTest {
@GET("/plain")
@Page("pages/plain")
static final class PlainPageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/view")
@Page("pages/view")
static final class ViewPageHandler extends ViewHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.empty();
}
}
@Test
void processor_rejectsViewAnnotationOnNonViewHandler() {
FlashContext ctx = new FlashContext();
new ViewExtension(new NoopAdapter()).provide(ctx);
assertThrows(IllegalStateException.class,
() -> processors(ctx).forEach(p -> p.process(PlainPageHandler.class)));
}
@Test
void processor_acceptsViewHandlerWithViewAnnotation() {
FlashContext ctx = new FlashContext();
new ViewExtension(new NoopAdapter()).provide(ctx);
assertDoesNotThrow(() -> processors(ctx).forEach(p -> p.process(ViewPageHandler.class)));
}
@Test
void constructor_withEngineType_buildsAndProvidesRuntime() {
FlashContext ctx = new FlashContext();
ViewExtension extension = new ViewExtension(ViewEngineType.THYMELEAF);
extension.provide(ctx);
assertNotNull(ctx.require(ViewRuntime.class));
assertNotNull(ctx.require(ViewEngineAdapter.class));
}
@Test
void addGlobal_nullResolver_throws() {
ViewExtension extension = new ViewExtension(new NoopAdapter());
assertThrows(NullPointerException.class, () -> extension.addGlobal("appName", null));
}
@Test
void addGlobal_invalidKey_throws() {
ViewExtension extension = new ViewExtension(new NoopAdapter());
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("", req -> "x"));
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("global", req -> "x"));
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("a.b", req -> "x"));
}
@Test
void constructor_withNullAdapter_throws() {
assertThrows(NullPointerException.class, () -> new ViewExtension((ViewEngineAdapter) null));
}
@SuppressWarnings("unchecked")
private static List<AnnotationProcessor> processors(FlashContext ctx) {
try {
Method m = FlashContext.class.getDeclaredMethod("processors");
m.setAccessible(true);
return ((List<AnnotationProcessor>) m.invoke(ctx)).stream()
.filter(p -> p.getClass().getName().contains("ViewExtension"))
.collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static final class NoopAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return new EngineCapabilities(true);
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
Request req,
Response res) {
return RenderOutput.html("");
}
}
}
@@ -1,81 +0,0 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ViewHandlerLifecycleTest {
@GET("/lifecycle")
@Page("pages/home")
static final class LifecycleHandler extends ViewHandler {
boolean onViewInitCalled;
DummyService service;
@Override
protected void onViewInit() {
onViewInitCalled = true;
service = require(DummyService.class);
}
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", service.value());
}
}
static final class DummyService {
String value() { return "ok"; }
}
@Test
void onInit_resolvesRuntime_and_onViewInit_runs_once() {
FlashContext ctx = new FlashContext();
ctx.provide(DummyService.class, new DummyService());
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
LifecycleHandler handler = new LifecycleHandler();
handler.bind(ctx);
assertTrue(handler.onViewInitCalled);
assertEquals("ok", handler.service.value());
}
@Test
void handle_rendersThroughRuntime() throws Exception {
FlashContext ctx = new FlashContext();
ctx.provide(DummyService.class, new DummyService());
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
LifecycleHandler handler = new LifecycleHandler();
handler.bind(ctx);
Response res = new Response(200, ContentType.JSON);
Object out = handler.handle(null, res);
assertEquals("ok", out);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
}
private static final class EchoAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
Request req,
Response res) {
return RenderOutput.html(String.valueOf(model.get("title")));
}
}
}
@@ -1,155 +0,0 @@
package dev.relism.ext.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ViewRuntimeGlobalsTest {
private static final GlobalBinding APP = new GlobalBinding("appName", req -> "flash");
private static final GlobalBinding PATH = new GlobalBinding("requestPath", req -> "/x");
@Test
void merge_globalsAreNestedUnderReservedNamespace() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
APP,
PATH
));
ViewModel merged = runtime.merge(null, ViewModel.of("title", "dashboard"));
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
assertEquals("flash", global.get("appName"));
assertEquals("/x", global.get("requestPath"));
assertEquals("dashboard", merged.toMap().get("title"));
}
@Test
void merge_nullLocal_keepsGlobals() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
new GlobalBinding("signedIn", req -> true)
));
ViewModel merged = runtime.merge(null, null);
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
assertEquals(true, global.get("signedIn"));
}
@Test
void merge_withoutGlobals_returnsCopyOfLocal() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
ViewModel local = ViewModel.of("k", "v");
ViewModel merged = runtime.merge(null, local);
assertEquals("v", merged.toMap().get("k"));
}
@Test
void merge_withNullGlobalsAndNullLocal_returnsEmpty() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
ViewModel merged = runtime.merge(null, null);
assertTrue(merged.toMap().isEmpty());
}
@Test
void merge_localGlobalNamespace_throws() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(APP));
assertThrows(IllegalStateException.class,
() -> runtime.merge(null, ViewModel.of("global", Map.of("x", 1))));
}
@Test
void resolve_withoutViewAnnotation_failsFast() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
assertThrows(IllegalStateException.class,
() -> runtime.resolve(NoViewHandler.class));
}
@Test
void render_prefersAdapterContentTypeWhenProvided() throws Exception {
ViewRuntime runtime = new ViewRuntime(new PlainTextAdapter(), List.of());
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
dev.relism.http.ContentType.TEXT_HTML
);
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
runtime.render(resolved, null, res, ViewModel.of("a", 1));
assertEquals(new String(dev.relism.http.ContentType.TEXT_PLAIN.getBytes()), new String(res.getContentType()));
}
@Test
void render_usesResolvedDefaultContentTypeWhenAdapterOmitsIt() throws Exception {
ViewRuntime runtime = new ViewRuntime(new NoTypeAdapter(), List.of());
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
dev.relism.http.ContentType.TEXT_HTML
);
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
runtime.render(resolved, null, res, ViewModel.of("a", 1));
assertEquals(new String(dev.relism.http.ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
}
static final class NoViewHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
private static final class NoopAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
return RenderOutput.html("");
}
}
private static final class NoTypeAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
return new RenderOutput("", null);
}
}
private static final class PlainTextAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
return new RenderOutput("", dev.relism.http.ContentType.TEXT_PLAIN);
}
}
}
@@ -1,123 +0,0 @@
package dev.relism.ext.view;
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 ViewTargetResolverTest {
@GET("/home")
@Page("pages/home")
static class PageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/partial")
@Partial(template = "fragments/row", slot = "row")
static class SlotPartialHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/partial-default-slot")
@Partial(template = "fragments/card")
static class DefaultSlotPartialHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/bad")
@Page("a")
@Partial(template = "b")
static class ConflictingHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Page("pages/no-route")
static class NoRouteHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void resolve_page_returnsPageTarget() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(PageHandler.class, new EngineCapabilities(true));
assertNotNull(resolved);
assertEquals(ViewKind.PAGE, resolved.target().kind());
assertEquals("pages/home", resolved.target().template());
}
@Test
void resolve_slotPartial_withoutCapability_failsFast() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(SlotPartialHandler.class, EngineCapabilities.NONE));
}
@Test
void resolve_partial_withoutSlot_defaultsToEmptySlot() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(DefaultSlotPartialHandler.class, new EngineCapabilities(true));
assertNotNull(resolved);
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
assertEquals("", resolved.target().slot());
}
@GET("/blank-page")
@Page(" ")
static class BlankPageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/blank-partial")
@Partial(template = " ")
static class BlankPartialTemplateHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void resolve_multipleViewAnnotations_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(ConflictingHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_viewAnnotationWithoutRoute_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_blankPageTemplate_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(BlankPageHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_blankPartialTemplate_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(BlankPartialTemplateHandler.class, new EngineCapabilities(true)));
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-web-bundler</artifactId>
+9 -2
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-parent</artifactId>
<version>1.1-indev5</version>
<version>1.1-indev6</version>
</parent>
<artifactId>flash-extensions</artifactId>
@@ -18,7 +18,9 @@
<module>flash-ext-openapi</module>
<module>flash-ext-oidc</module>
<module>flash-ext-routeviewer</module>
<module>flash-ext-view</module>
<module>flash-ext-view-core</module>
<module>flash-ext-view-jte</module>
<module>flash-ext-view-thymeleaf</module>
<module>flash-ext-limiter</module>
<module>flash-web-bundler</module>
</modules>
@@ -30,6 +32,11 @@
<artifactId>flash-ext-jackson</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-view-jte</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>