preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
+44
View File
@@ -0,0 +1,44 @@
<?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.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-view</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</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>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,98 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
/**
* Imperative view renderer — the programmatic counterpart to {@link View @View}.
*
* <p>Retrieve once at boot time in {@link dev.relism.models.RequestHandler#onInit onInit()},
* cache in a private field, and call on the hot-path with zero lookup overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/dashboard")
* public class DashboardHandler extends RequestHandler {
* private Renderer renderer;
* private DashboardService svc;
*
* @Override protected void onInit() {
* renderer = require(Renderer.class);
* svc = require(DashboardService.class);
* }
*
* @Override public Object handle(Request req, Response res) throws Exception {
* return renderer.view(res, "dashboard", Map.of("data", svc.stats()));
* }
* }
* }</pre>
*
* <p>For lambda handlers, capture {@code Renderer} from the context at registration
* time — it is available immediately after {@code ViewExtension} is installed:
*
* <pre>{@code
* app.install(new ViewExtension(engine));
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <p>The underlying {@link ViewEngine} is thread-safe after construction — no
* synchronization is needed on the hot-path.
*/
public final class Renderer {
private final ViewEngine engine;
/** Package-private — constructed exclusively by {@link ViewExtension}. */
Renderer(ViewEngine engine) {
this.engine = engine;
}
// ── Rendering ────────────────────────────────────────────────────────────
/**
* Renders {@code template} with {@code model}, sets the {@code Content-Type}
* 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);
return engine.render(template, model);
}
/**
* Renders {@code template} with {@code model} and sets
* {@code Content-Type: text/html}.
*/
public String view(Response res, String template, Object model) throws Exception {
return view(res, template, model, ContentType.TEXT_HTML);
}
/** Renders {@code template} with a {@code null} model. */
public String view(Response res, String template) throws Exception {
return view(res, template, null);
}
// ── Template signal ───────────────────────────────────────────────────────
/**
* Creates a deferred {@link Template} signal that will be intercepted by the
* {@link View @View} middleware. Use this from handlers that carry {@code @View}
* but need to dynamically override the template name or supply a different model.
*
* <p>Does <em>not</em> render immediately — rendering happens in the middleware.
*/
public Template template(String name, Object model) {
return Template.of(name, model);
}
/** Creates a {@link Template} signal with a {@code null} model. */
public Template template(String name) {
return Template.of(name);
}
// ── Escape hatch ─────────────────────────────────────────────────────────
/** Direct access to the underlying {@link ViewEngine} for advanced use cases. */
public ViewEngine engine() {
return engine;
}
}
@@ -0,0 +1,62 @@
package dev.relism.ext.view;
/**
* Explicit render signal returned from a handler to override the template name
* and/or model chosen by {@link View @View}.
*
* <p>{@code Template} is a lightweight value object — it carries the template
* name and an optional model, but performs no rendering itself. The
* {@link ViewExtension}-injected middleware detects it at the call site and
* delegates to the {@link ViewEngine}.
*
* <p>Use {@code Template} when:
* <ul>
* <li>The handler is annotated with {@code @View} but needs to redirect to a
* different template dynamically (e.g. on validation failure).</li>
* <li>A lambda handler or a handler <em>without</em> {@code @View} wants to
* trigger rendering without registering the annotation — pair with a
* {@link Renderer} captured at construction time.</li>
* </ul>
*
* <pre>{@code
* // Inside a @View-annotated handler — overrides the default template on error
* public Object handle(Request req, Response res) {
* if (!valid) return Template.of("form-error", Map.of("errors", errors));
* return service.findAll(); // falls back to @View template
* }
*
* // Lambda handler — pair with Renderer captured from ctx at boot time
* Renderer renderer = ctx.require(Renderer.class);
* app.get("/page", (req, res) -> renderer.view(res, "page", model));
* }</pre>
*/
public final class Template {
private final String name;
private final Object model;
private Template(String name, Object model) {
this.name = name;
this.model = model;
}
/** Creates a {@code Template} signal with the given name and model. */
public static Template of(String name, Object model) {
return new Template(name, model);
}
/** Creates a {@code Template} signal with a {@code null} model. */
public static Template of(String name) {
return new Template(name, null);
}
/** The template name/path to render. */
public String name() {
return name;
}
/** The model to bind; may be {@code null}. */
public Object model() {
return model;
}
}
@@ -0,0 +1,146 @@
package dev.relism.ext.view;
import org.thymeleaf.IEngineConfiguration;
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;
/**
* {@link ViewEngine} bridge for Thymeleaf 3.x.
*
* <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 ViewEngine {
private static final String PREFIX = "/templates/";
private static final String SUFFIX = ".html";
private static final String FRAGMENT = " :: content";
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 String render(String template, Object model, boolean fragment) {
Context ctx = new Context();
populateContext(ctx, model);
return engine.process(fragment ? template + FRAGMENT : template, 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");
}
}
}
@@ -0,0 +1,75 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declarative view binding for class-based handlers.
*
* <p>When {@code ViewExtension} is installed, handlers annotated with {@code @View}
* receive an injected middleware that intercepts the handler's return value and
* passes it to the {@link ViewEngine} for rendering. The rendered string replaces
* the handler's return value as the response body, and {@link #contentType()} is
* written to the {@code Content-Type} header.
*
* <h3>Return-value semantics</h3>
* <ul>
* <li>Return a {@link Template} — overrides both the template name <em>and</em>
* the model dynamically (e.g. redirect to a different template on error).</li>
* <li>Return any other non-null value — used as the model; the template name
* comes from {@link #value()}.</li>
* <li>Return {@code null} — renders {@link #value()} with a {@code null} model.</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* private PostService posts;
* @Override protected void onInit() { posts = require(PostService.class); }
*
* @Override public Object handle(Request req, Response res) {
* return Map.of("posts", posts.findAll()); // model → home template
* }
* }
*
* // Dynamic template override via Template signal
* @View("list")
* public class ConditionalHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (something) return Template.of("error", Map.of("msg", "oops"));
* return data; // uses "list" template
* }
* }
* }</pre>
*
* <p>The annotation is inspected via superclass traversal, so a base handler class
* can declare the view template and all concrete subclasses inherit it.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface View {
/**
* Template name or path passed to the {@link ViewEngine}.
* The exact format is engine-specific (e.g. {@code "home"}, {@code "views/home.html"}).
*/
String value();
/**
* {@code Content-Type} written to the response.
* Defaults to {@link ContentType#TEXT_HTML}.
*/
ContentType contentType() default ContentType.TEXT_HTML;
/**
* If {@code true}, instructs the {@link ViewEngine} to render only a named
* fragment inside the template rather than the full page.
* Useful for HTMX / partial-update patterns.
*/
boolean fragment() default false;
}
@@ -0,0 +1,45 @@
package dev.relism.ext.view;
/**
* Contract for template engine integrations.
*
* <p>Implement this interface to plug any template engine (Thymeleaf, Jinjava,
* Mustache, FreeMarker, …) into the Flash view layer. A single instance is
* shared across all handlers, so implementations must be thread-safe.
*
* <pre>{@code
* // Thymeleaf example
* ViewEngine thymeleaf = (template, model, fragment) -> {
* Context ctx = new Context();
* if (model instanceof Map<?,?> m) m.forEach((k, v) -> ctx.setVariable(k.toString(), v));
* else if (model != null) ctx.setVariable("model", model);
* return engine.process(fragment ? template + " :: fragment" : template, ctx);
* };
*
* app.install(new ViewExtension(thymeleaf));
* }</pre>
*/
@FunctionalInterface
public interface ViewEngine {
/**
* Renders {@code template} with the supplied {@code model}.
*
* @param template the template name or path — engine-specific convention
* (e.g. {@code "views/home"}, {@code "home.html"})
* @param model the model object passed to the template; may be {@code null}
* @param fragment if {@code true}, only a named fragment inside the template
* should be rendered (Thymeleaf: {@code template :: fragment},
* Mustache: partial name, etc.)
* @return the rendered output string
* @throws Exception any rendering error — propagated as a 500 by the Flash runtime
*/
String render(String template, Object model, boolean fragment) throws Exception;
/**
* Convenience overload — renders the full template ({@code fragment = false}).
*/
default String render(String template, Object model) throws Exception {
return render(template, model, false);
}
}
@@ -0,0 +1,80 @@
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 ViewEngine} directly and use
* {@link ViewExtension#ViewExtension(ViewEngine)} instead.
*/
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 ViewEngine} for this type.
* Called once at {@link ViewExtension#install} 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
*/
ViewEngine createEngine(boolean cacheEnabled) {
return switch (this) {
case THYMELEAF -> createThymeleaf(cacheEnabled);
};
}
// ── Engine factories ──────────────────────────────────────────────────────
private static ViewEngine 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);
}
}
}
@@ -0,0 +1,166 @@
package dev.relism.ext.view;
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.List;
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;
// ── 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()));
}
/**
* 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");
}
// ── FlashExtension ────────────────────────────────────────────────────────
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
Renderer renderer = new Renderer(engine);
ctx.provide(ViewEngine.class, engine);
ctx.provide(Renderer.class, renderer);
ctx.addAnnotationProcessor(handlerClass -> {
View view = findView(handlerClass);
if (view == null) return List.of();
String defaultTemplate = view.value();
ContentType contentType = view.contentType();
boolean fragment = view.fragment();
// 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;
}
res.setContentType(contentType);
return engine.render(tpl, model, fragment);
};
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);
}
}