add core view extension with JTE and Thymeleaf support
This commit is contained in:
+40
@@ -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);
|
||||
}
|
||||
+68
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+83
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user