preparing for another refactoring...
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user