weeks of bullshit
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
public record EngineCapabilities(boolean supportsPartialSlot) {
|
||||
public static final EngineCapabilities NONE = new EngineCapabilities(false);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
final class LegacyViewEngineAdapter implements ViewEngineAdapter {
|
||||
private static final String DEFAULT_FRAGMENT_SLOT = "content";
|
||||
|
||||
private final ViewEngine engine;
|
||||
|
||||
LegacyViewEngineAdapter(ViewEngine engine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception {
|
||||
String template = target.template();
|
||||
boolean fragment = target.kind() == ViewKind.PARTIAL;
|
||||
|
||||
if (fragment && target.slot() != null && !target.slot().isBlank()) {
|
||||
template = template + " :: " + target.slot();
|
||||
fragment = false;
|
||||
} else if (fragment) {
|
||||
template = template + " :: " + DEFAULT_FRAGMENT_SLOT;
|
||||
fragment = false;
|
||||
}
|
||||
|
||||
return new RenderOutput(engine.render(template, model, fragment), ContentType.TEXT_HTML);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Page {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Partial {
|
||||
String template();
|
||||
String slot() default "";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
public record RenderOutput(String body, ContentType contentType) {
|
||||
public static RenderOutput html(String body) {
|
||||
return new RenderOutput(body, ContentType.TEXT_HTML);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public final class Renderer {
|
||||
* header to {@code type}, and returns the rendered string as the handler body.
|
||||
*/
|
||||
public String view(Response res, String template, Object model, ContentType type) throws Exception {
|
||||
res.setContentType(type);
|
||||
res.type(type);
|
||||
return engine.render(template, model);
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -41,7 +41,7 @@ import java.util.Map;
|
||||
* <li>{@code null} model → empty context.</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class ThymeleafEngine implements ViewEngine {
|
||||
final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
|
||||
|
||||
private static final String PREFIX = "/templates/";
|
||||
private static final String SUFFIX = ".html";
|
||||
@@ -71,6 +71,27 @@ final class ThymeleafEngine implements ViewEngine {
|
||||
return engine.process(fragment ? template + FRAGMENT : template, ctx);
|
||||
}
|
||||
|
||||
@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 selector;
|
||||
if (target.kind() == ViewKind.PAGE) {
|
||||
selector = target.template();
|
||||
} else {
|
||||
String slot = target.slot();
|
||||
selector = target.template() + " :: " + ((slot == null || slot.isBlank()) ? "content" : slot);
|
||||
}
|
||||
Context ctx = new Context();
|
||||
populateContext(ctx, model);
|
||||
return RenderOutput.html(engine.process(selector, ctx));
|
||||
}
|
||||
|
||||
private static void populateContext(Context ctx, Object model) {
|
||||
if (model instanceof Map<?, ?> map) {
|
||||
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ViewEngineAdapter {
|
||||
EngineCapabilities capabilities();
|
||||
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
|
||||
}
|
||||
+8
-2
@@ -53,15 +53,21 @@ public enum ViewEngineType {
|
||||
* @param cacheEnabled whether the engine should cache compiled templates
|
||||
* @throws IllegalStateException if the required library is not on the classpath
|
||||
*/
|
||||
ViewEngine createEngine(boolean cacheEnabled) {
|
||||
ViewEngineAdapter createAdapter(boolean cacheEnabled) {
|
||||
return switch (this) {
|
||||
case THYMELEAF -> createThymeleaf(cacheEnabled);
|
||||
};
|
||||
}
|
||||
|
||||
ViewEngine createEngine(boolean cacheEnabled) {
|
||||
ViewEngineAdapter adapter = createAdapter(cacheEnabled);
|
||||
if (adapter instanceof ViewEngine engine) return engine;
|
||||
throw new IllegalStateException("Selected engine does not expose legacy ViewEngine interface: " + this);
|
||||
}
|
||||
|
||||
// ── Engine factories ──────────────────────────────────────────────────────
|
||||
|
||||
private static ViewEngine createThymeleaf(boolean cacheEnabled) {
|
||||
private static ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
|
||||
try {
|
||||
return new ThymeleafEngine(cacheEnabled);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
|
||||
+47
-132
@@ -1,166 +1,81 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.Flash;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Installs the view layer into a Flash application.
|
||||
*
|
||||
* <h3>Managed mode (recommended)</h3>
|
||||
* Pass a {@link ViewEngineType} — the extension auto-configures the engine,
|
||||
* detects dev mode for cache settings, and validates classpath dependencies at boot:
|
||||
* <pre>{@code
|
||||
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Manual mode (BYOE)</h3>
|
||||
* Supply your own {@link ViewEngine} implementation for full control:
|
||||
* <pre>{@code
|
||||
* ViewEngine myEngine = (template, model, fragment) -> { ... };
|
||||
* app.install(new ViewExtension(myEngine));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>What gets installed</h3>
|
||||
* <ol>
|
||||
* <li>{@link ViewEngine} and {@link Renderer} are bound in the {@link FlashContext} —
|
||||
* any handler can retrieve them via {@code require(Renderer.class)}.</li>
|
||||
* <li>An {@link dev.relism.extension.AnnotationProcessor} is registered: class-based
|
||||
* handlers carrying {@link View @View} receive an injected rendering middleware
|
||||
* that intercepts the handler return value, resolves the template + model, and
|
||||
* delegates to the engine. No boilerplate required in the handler itself.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Handler patterns</h3>
|
||||
* <pre>{@code
|
||||
* // Declarative — annotation drives template selection
|
||||
* @Route(method = HttpMethod.GET, path = "/")
|
||||
* @View("home")
|
||||
* public class HomeHandler extends RequestHandler {
|
||||
* public Object handle(Request req, Response res) {
|
||||
* return Map.of("posts", service.findAll()); // model → home.html
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Dynamic override via Template signal
|
||||
* @View("list")
|
||||
* public class ListHandler extends RequestHandler {
|
||||
* public Object handle(Request req, Response res) {
|
||||
* if (error) return Template.of("error", Map.of("msg", "oops"));
|
||||
* return data; // falls back to list.html
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Imperative — explicit render call (lambda-friendly)
|
||||
* Renderer renderer = app.ctx().require(Renderer.class);
|
||||
* app.get("/about", (req, res) -> renderer.view(res, "about"));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Dev mode / cache</h3>
|
||||
* In managed mode, template caching is disabled when the JVM property
|
||||
* {@code flash.env=dev} or the environment variable {@code FLASH_ENV=dev} is set.
|
||||
*
|
||||
* <h3>Cross-extension integration</h3>
|
||||
* <pre>{@code
|
||||
* ctx.optional(ViewEngine.class).ifPresent(engine -> { ... });
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ViewExtension implements FlashExtension {
|
||||
|
||||
private final ViewEngine engine;
|
||||
private final ViewEngineAdapter adapter;
|
||||
private final ViewEngine legacyEngine;
|
||||
private final List<ViewGlobals> globals = new ArrayList<>();
|
||||
|
||||
// ── Constructors ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Managed mode — auto-configures the engine selected by {@code type}.
|
||||
*
|
||||
* <p>Template caching is enabled unless {@code flash.env=dev} (JVM property)
|
||||
* or {@code FLASH_ENV=dev} (environment variable) is set.
|
||||
*
|
||||
* @param type the engine to use; must have its library on the runtime classpath
|
||||
* @throws IllegalStateException at boot time if the library is missing
|
||||
*/
|
||||
public ViewExtension(ViewEngineType type) {
|
||||
this(type.createEngine(!isDevMode()));
|
||||
this(type.createAdapter(!Flash.DEV));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual mode — use a pre-constructed {@link ViewEngine} implementation.
|
||||
* Suitable for custom engines or engines that need non-default configuration.
|
||||
*
|
||||
* @param engine the engine implementation; must be thread-safe
|
||||
*/
|
||||
public ViewExtension(ViewEngine engine) {
|
||||
this.engine = Objects.requireNonNull(engine, "ViewEngine must not be null");
|
||||
this(new LegacyViewEngineAdapter(engine), engine);
|
||||
}
|
||||
|
||||
// ── FlashExtension ────────────────────────────────────────────────────────
|
||||
public ViewExtension(ViewEngineAdapter adapter) {
|
||||
this(adapter, null);
|
||||
}
|
||||
|
||||
private ViewExtension(ViewEngineAdapter adapter, ViewEngine legacyEngine) {
|
||||
this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null");
|
||||
this.legacyEngine = legacyEngine;
|
||||
}
|
||||
|
||||
public ViewExtension addGlobals(ViewGlobals provider) {
|
||||
globals.add(Objects.requireNonNull(provider, "ViewGlobals must not be null"));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
Renderer renderer = new Renderer(engine);
|
||||
ctx.provide(ViewEngine.class, engine);
|
||||
ctx.provide(Renderer.class, renderer);
|
||||
public void provide(FlashContext ctx) {
|
||||
ViewRuntime runtime = new ViewRuntime(adapter, List.copyOf(globals));
|
||||
ctx.provide(ViewEngineAdapter.class, adapter);
|
||||
ctx.provide(ViewRuntime.class, runtime);
|
||||
|
||||
if (legacyEngine != null) {
|
||||
ctx.provide(ViewEngine.class, legacyEngine);
|
||||
ctx.provide(Renderer.class, new Renderer(legacyEngine));
|
||||
} else if (adapter instanceof ViewEngine engine) {
|
||||
ctx.provide(ViewEngine.class, engine);
|
||||
ctx.provide(Renderer.class, new Renderer(engine));
|
||||
}
|
||||
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
View view = findView(handlerClass);
|
||||
if (view == null) return List.of();
|
||||
if (ViewHandler.class.isAssignableFrom(handlerClass)) return List.of();
|
||||
|
||||
String defaultTemplate = view.value();
|
||||
ContentType contentType = view.contentType();
|
||||
boolean fragment = view.fragment();
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
|
||||
if (resolved == null) return List.of();
|
||||
|
||||
// Injected once per handler at boot — zero overhead on the hot-path.
|
||||
// Intercepts the return value: Template signal overrides name+model;
|
||||
// any other value becomes the model for the annotation's template.
|
||||
Middleware renderingMiddleware = next -> (req, res) -> {
|
||||
Object result = next.handle(req, res);
|
||||
String tpl;
|
||||
Object model;
|
||||
if (result instanceof Template t) {
|
||||
tpl = t.name();
|
||||
model = t.model();
|
||||
} else {
|
||||
tpl = defaultTemplate;
|
||||
model = result;
|
||||
ViewModel local = ViewRuntime.legacyLocalModel(result);
|
||||
ViewModel merged = runtime.merge(req, local);
|
||||
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
|
||||
|
||||
if (resolved.legacy()) {
|
||||
ContentType type = resolved.contentType();
|
||||
if (type != null) res.type(type);
|
||||
else if (out.contentType() != null) res.type(out.contentType());
|
||||
} else if (out.contentType() != null) {
|
||||
res.type(out.contentType());
|
||||
}
|
||||
res.setContentType(contentType);
|
||||
return engine.render(tpl, model, fragment);
|
||||
return out.body();
|
||||
};
|
||||
|
||||
return List.of(renderingMiddleware);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walks the superclass chain to find {@link View @View}.
|
||||
* Supports inheritance: a base handler can declare the view template and
|
||||
* concrete subclasses inherit it without re-annotating.
|
||||
*/
|
||||
private static View findView(Class<?> cls) {
|
||||
while (cls != null && !cls.equals(Object.class)) {
|
||||
View v = cls.getAnnotation(View.class);
|
||||
if (v != null) return v;
|
||||
cls = cls.getSuperclass();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when running in dev mode.
|
||||
* Checks JVM property {@code flash.env} first, then env var {@code FLASH_ENV}.
|
||||
*/
|
||||
private static boolean isDevMode() {
|
||||
String prop = System.getProperty("flash.env");
|
||||
if (prop != null) return "dev".equalsIgnoreCase(prop);
|
||||
String env = System.getenv("FLASH_ENV");
|
||||
return "dev".equalsIgnoreCase(env);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ViewGlobals {
|
||||
ViewModel provide(Request req);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
public abstract class ViewHandler extends RequestHandler {
|
||||
private ViewRuntime runtime;
|
||||
private ViewTargetResolver.ResolvedView resolved;
|
||||
|
||||
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() {
|
||||
runtime = require(ViewRuntime.class);
|
||||
resolved = runtime.resolve(getClass());
|
||||
onViewInit();
|
||||
}
|
||||
|
||||
protected void onViewInit() {}
|
||||
|
||||
@Override
|
||||
public final Object handle(Request request, Response response) throws Exception {
|
||||
return runtime.render(this, resolved, request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
public enum ViewKind {
|
||||
PAGE,
|
||||
PARTIAL
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static ViewModel copyOf(ViewModel source) {
|
||||
if (source == null || source.values.isEmpty()) return empty();
|
||||
return new ViewModel(new LinkedHashMap<>(source.values));
|
||||
}
|
||||
|
||||
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,79 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class ViewRuntime {
|
||||
private static final ViewModel EMPTY = ViewModel.empty();
|
||||
|
||||
private final ViewEngineAdapter adapter;
|
||||
private final List<ViewGlobals> globals;
|
||||
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
|
||||
|
||||
ViewRuntime(ViewEngineAdapter adapter, List<ViewGlobals> 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, @Partial, or @View");
|
||||
}
|
||||
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, @Partial, or @View");
|
||||
}
|
||||
|
||||
ViewModel local = handler.render(req, res);
|
||||
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.body();
|
||||
}
|
||||
|
||||
ViewModel merge(Request req, ViewModel local) {
|
||||
ViewModel global = computeGlobals(req);
|
||||
return global.merge(local == null ? EMPTY : local);
|
||||
}
|
||||
|
||||
private ViewModel computeGlobals(Request req) {
|
||||
if (globals.isEmpty()) return EMPTY;
|
||||
ViewModel out = ViewModel.empty();
|
||||
for (ViewGlobals provider : globals) {
|
||||
ViewModel vm = provider.provide(req);
|
||||
if (vm != null) out.withAll(vm.toMap());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static ViewModel legacyLocalModel(Object legacyModel) {
|
||||
if (legacyModel == null) return ViewModel.empty();
|
||||
if (legacyModel instanceof ViewModel vm) return ViewModel.copyOf(vm);
|
||||
if (legacyModel instanceof Map<?, ?> map) {
|
||||
ViewModel vm = ViewModel.empty();
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
vm.with(String.valueOf(e.getKey()), ViewModel.unwrapValue(e.getValue()));
|
||||
}
|
||||
return vm;
|
||||
}
|
||||
return ViewModel.of("it", legacyModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
public record ViewTarget(
|
||||
ViewKind kind,
|
||||
String template,
|
||||
String slot
|
||||
) {}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.Routes;
|
||||
|
||||
final class ViewTargetResolver {
|
||||
|
||||
private ViewTargetResolver() {}
|
||||
|
||||
static ResolvedView resolve(Class<?> handlerClass, EngineCapabilities capabilities) {
|
||||
Page page = find(handlerClass, Page.class);
|
||||
Partial partial = find(handlerClass, Partial.class);
|
||||
View legacy = find(handlerClass, View.class);
|
||||
|
||||
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0) + (legacy != 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, @View");
|
||||
}
|
||||
|
||||
if (page != null) {
|
||||
return new ResolvedView(new ViewTarget(ViewKind.PAGE, page.value(), ""), ContentType.TEXT_HTML, false);
|
||||
}
|
||||
|
||||
if (partial != null) {
|
||||
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, partial.template(), slot), ContentType.TEXT_HTML, false);
|
||||
}
|
||||
|
||||
return new ResolvedView(
|
||||
new ViewTarget(legacy.fragment() ? ViewKind.PARTIAL : ViewKind.PAGE, legacy.value(), ""),
|
||||
legacy.contentType(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
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, boolean legacy) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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.*;
|
||||
|
||||
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<?, ?>);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ViewRuntimeGlobalsTest {
|
||||
|
||||
@Test
|
||||
void merge_globalsThenLocal_localWins() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||
req -> ViewModel.of("nav", "global").with("app", "flash"),
|
||||
req -> ViewModel.of("nav", "global-2")
|
||||
));
|
||||
|
||||
ViewModel merged = runtime.merge(null, ViewModel.of("nav", "local"));
|
||||
|
||||
assertEquals("local", merged.toMap().get("nav"));
|
||||
assertEquals("flash", merged.toMap().get("app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_nullLocal_keepsGlobals() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||
req -> ViewModel.of("signedIn", true)
|
||||
));
|
||||
|
||||
ViewModel merged = runtime.merge(null, null);
|
||||
|
||||
assertEquals(true, merged.toMap().get("signedIn"));
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
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 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("/legacy")
|
||||
@View(value = "legacy/home", contentType = ContentType.TEXT_PLAIN, fragment = true)
|
||||
static class LegacyHandler 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_legacyView_mapsToResolvedTarget() {
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(LegacyHandler.class, EngineCapabilities.NONE);
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertTrue(resolved.legacy());
|
||||
assertEquals(ContentType.TEXT_PLAIN, resolved.contentType());
|
||||
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
|
||||
}
|
||||
|
||||
@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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user