add core view extension with JTE and Thymeleaf support

This commit is contained in:
Relism
2026-04-21 00:32:09 +02:00
parent 34fd74068a
commit 9e19f439be
93 changed files with 2200 additions and 1404 deletions
@@ -0,0 +1,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);
}
}
@@ -0,0 +1,78 @@
package dev.relism.ext.view.core;
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 per-request model builder.
*/
public final class ViewModel {
private final LinkedHashMap<String, Object> values;
private ViewModel(LinkedHashMap<String, Object> values) {
this.values = values;
}
public static ViewModel empty() {
return new ViewModel(new LinkedHashMap<>());
}
public static ViewModel of(String key, Object value) {
return empty().with(key, value);
}
public ViewModel with(String key, Object value) {
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
return this;
}
public ViewModel withAll(Map<String, Object> values) {
if (values == null || values.isEmpty()) return this;
for (Map.Entry<String, Object> e : values.entrySet()) {
with(e.getKey(), e.getValue());
}
return this;
}
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;
}
public Map<String, Object> toMap() {
return Collections.unmodifiableMap(values);
}
public static ViewModel copyOf(ViewModel source) {
if (source == null || source.values.isEmpty()) return empty();
return new ViewModel(new LinkedHashMap<>(source.values));
}
public static ViewModel owned(LinkedHashMap<String, Object> values) {
return new ViewModel(values);
}
public static Object unwrapValue(Object value) {
if (value instanceof ViewModel vm) {
return vm.toMap();
}
if (value instanceof Map<?, ?> map) {
LinkedHashMap<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<?, ?> e : map.entrySet()) {
out.put(String.valueOf(e.getKey()), unwrapValue(e.getValue()));
}
return Collections.unmodifiableMap(out);
}
if (value instanceof List<?> list) {
ArrayList<Object> out = new ArrayList<>(list.size());
for (Object item : list) out.add(unwrapValue(item));
return Collections.unmodifiableList(out);
}
return value;
}
}
@@ -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;
}
@@ -0,0 +1,83 @@
package dev.relism.ext.view.core;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ViewModelTest {
@Test
void ofWithAndWithAll_populateModel() {
ViewModel model = ViewModel.of("a", 1)
.with("b", "x")
.withAll(Map.of("c", true));
assertEquals(1, model.toMap().get("a"));
assertEquals("x", model.toMap().get("b"));
assertEquals(true, model.toMap().get("c"));
}
@Test
void merge_otherWinsOnCollision() {
ViewModel global = ViewModel.of("nav", "global").with("app", "flash");
ViewModel local = ViewModel.of("nav", "local");
ViewModel merged = global.merge(local);
assertEquals("local", merged.toMap().get("nav"));
assertEquals("flash", merged.toMap().get("app"));
}
@Test
void toMap_isReadOnly() {
ViewModel model = ViewModel.of("k", "v");
Map<String, Object> map = model.toMap();
assertThrows(UnsupportedOperationException.class, () -> map.put("x", 1));
}
@Test
void nestedViewModel_isUnwrappedToMapRecursively() {
ViewModel nested = ViewModel.of("name", "Flash");
ViewModel root = ViewModel.of("app", nested)
.with("list", List.of(ViewModel.of("n", 1), ViewModel.of("n", 2)));
Object app = root.toMap().get("app");
Object list = root.toMap().get("list");
assertTrue(app instanceof Map<?, ?>);
assertEquals("Flash", ((Map<?, ?>) app).get("name"));
assertTrue(list instanceof List<?>);
assertTrue(((List<?>) list).getFirst() instanceof Map<?, ?>);
}
@Test
void with_nullKey_throws() {
ViewModel model = ViewModel.empty();
assertThrows(NullPointerException.class, () -> model.with(null, 1));
}
@Test
void withAll_nullOrEmpty_noop() {
ViewModel model = ViewModel.of("a", 1);
model.withAll(null);
model.withAll(Map.of());
assertEquals(1, model.toMap().size());
assertEquals(1, model.toMap().get("a"));
}
@Test
void merge_nullOther_returnsCopy() {
ViewModel base = ViewModel.of("a", 1);
ViewModel merged = base.merge(null);
assertNotSame(base, merged);
assertEquals(1, merged.toMap().get("a"));
}
}