i spent the last year just spinning

This commit is contained in:
Relism
2026-04-17 18:56:06 +02:00
parent 9efbe38c0c
commit e161497f2c
77 changed files with 2545 additions and 877 deletions
+55
View File
@@ -0,0 +1,55 @@
# flash-ext-view
Lightweight SSR view extension for Flash.
This module provides a focused MVC surface:
- `ViewExtension`
- `ViewHandler`
- `@Page` and `@Partial`
- `ViewModel` and opinionated `global.*` values
- `ViewEngineAdapter`
No legacy annotation/renderer API is exposed.
`@Page`/`@Partial` are valid only on `ViewHandler` subclasses.
## Quick Start
```java
import dev.relism.extension.FlashApp;
import dev.relism.ext.view.*;
FlashApp.create(8080)
.install(new ViewExtension(ViewEngineType.THYMELEAF)
.addGlobal("appName", req -> "Flash")
.addGlobal("requestPath", req -> req.path()))
.scan("com.example.web")
.startAndBlock();
```
`global` is a reserved namespace. Handlers cannot write a top-level `global` key.
```java
import dev.relism.ext.view.*;
import dev.relism.routing.GET;
@GET("/")
@Page("pages/home")
public final class HomePage extends ViewHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("title", "Home");
}
}
```
## Docs
- `docs/architecture.md`
- `docs/handlers.md`
- `docs/model-and-globals.md`
- `docs/partials.md`
- `docs/adapters.md`
- `docs/performance.md`
- `docs/migration-from-legacy-view.md`
@@ -0,0 +1,29 @@
# Adapters
`ViewEngineAdapter` is the rendering boundary.
## Built-in
- `ViewEngineType.THYMELEAF`
## Custom adapter
```java
public final class MyAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target,
Map<String, Object> model,
Request req,
Response res) {
String body = "...";
return RenderOutput.html(body);
}
}
```
Adapter instances must be thread-safe after construction.
@@ -0,0 +1,23 @@
# Architecture
`flash-ext-view` runs in two layers:
1. **Boot-time**
- `ViewExtension` registers `ViewRuntime` and an annotation processor.
- `ViewTargetResolver` validates handlers and maps annotations to `ViewTarget`.
- Resolved targets are cached per handler class.
2. **Request-time**
- Handler builds local `ViewModel`.
- `ViewRuntime` injects extension globals under reserved `global` namespace, then merges local model.
- `ViewEngineAdapter` renders `RenderOutput`.
## Valid Handler Contract
- Must extend `ViewHandler`.
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
- Must declare exactly one view annotation:
- `@Page`
- `@Partial`
Invalid configurations fail fast at startup.
@@ -0,0 +1,35 @@
# Handlers
Use `ViewHandler` for class-based SSR routes.
## Lifecycle
- `onViewInit()` runs once at boot.
- `render(...)` runs per request.
Use `onViewInit()` to cache dependencies via `require(...)`.
## Example
```java
@GET("/dashboard")
@Page("pages/dashboard")
public final class DashboardPage extends ViewHandler {
private DashboardService service;
@Override
protected void onViewInit() {
service = require(DashboardService.class);
}
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.empty()
.with("title", "Dashboard")
.with("stats", service.stats());
}
}
```
Use `render(Request, Response)` only when you need response access while building the model.
@@ -0,0 +1,33 @@
# Migration from Legacy View API
Legacy API (`@View`, `ViewEngine`, `Renderer`, `Template`) has been removed.
## Replace annotations
- `@View("page")` -> `@Page("page")`
- `@View(value = "x", fragment = true)` -> `@Partial(template = "x")`
`@Page` and `@Partial` must be declared on classes extending `ViewHandler`.
## Replace handler return contract
Before (legacy):
```java
public Object handle(Request req, Response res) {
return Map.of("name", "Flash");
}
```
Now:
```java
public ViewModel render(Request req) {
return ViewModel.of("name", "Flash");
}
```
## Replace engine integration
- Implement `ViewEngineAdapter` directly.
- Or use `ViewEngineType.THYMELEAF`.
@@ -0,0 +1,31 @@
# Model and Globals
`ViewModel` is the per-request model builder.
## Merge Order
Runtime merge order is:
1. all extension globals under reserved `global` namespace
2. local handler model
Handlers cannot set top-level `global`; runtime throws fail-fast to prevent namespace collisions.
## Globals
Register globals on extension setup:
```java
new ViewExtension(ViewEngineType.THYMELEAF)
.addGlobal("appName", req -> "Flash")
.addGlobal("path", req -> req.path());
```
Template usage:
```html
<span th:text="${global.appName}"></span>
<span th:text="${global.path}"></span>
```
Keep globals cheap: no blocking I/O or heavy allocations.
@@ -0,0 +1,19 @@
# Partials
Use `@Partial` for fragment responses.
```java
@GET("/users/table")
@Partial(template = "fragments/users", slot = "rows")
public final class UsersRows extends ViewHandler {
@Override
public ViewModel render(dev.relism.models.Request req) {
return ViewModel.of("users", List.of());
}
}
```
If slot is empty, the adapter default slot is used.
If a slot is specified but the adapter does not support slot selection,
startup fails with a clear error.
@@ -0,0 +1,16 @@
# Performance
`flash-ext-view` is optimized for low overhead on request path.
## Current runtime choices
- Handler view metadata resolved once and cached.
- Global/local model merge done in a single pass.
- No legacy rendering branches in runtime pipeline.
## Best practices
- Cache services in `onViewInit()`.
- Keep `addGlobal(...)` resolvers cheap and side-effect free.
- Build only the model fields needed by template.
- Avoid blocking I/O in `render(...)`; delegate to precomputed service data when possible.
+45 -1
View File
@@ -7,11 +7,15 @@
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.0-SNAPSHOT</version>
<version>1.1-indev5</version>
</parent>
<artifactId>flash-ext-view</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
@@ -41,4 +45,44 @@
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report-and-check</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,5 +1,6 @@
package dev.relism.ext.view;
/** Engine feature flags used for boot-time validation. */
public record EngineCapabilities(boolean supportsPartialSlot) {
public static final EngineCapabilities NONE = new EngineCapabilities(false);
}
@@ -0,0 +1,7 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import java.util.function.Function;
record GlobalBinding(String key, Function<Request, Object> resolver) {}
@@ -1,38 +0,0 @@
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);
}
}
@@ -5,8 +5,16 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a class-based route handler to a full-page template.
*
* <p>Use on subclasses of {@link dev.relism.models.RequestHandler}, typically
* {@link ViewHandler}. The handler must also declare a route annotation
* ({@code @Route}, {@code @GET}, {@code @POST}, ...).
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Page {
/** Template name/path (engine-specific). */
String value();
}
@@ -5,9 +5,18 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds a class-based route handler to a partial template render.
*
* <p>Useful for progressive/fragment updates (e.g. HTMX). When {@link #slot()} is blank,
* the adapter default slot is used.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Partial {
/** Template containing the fragment slot. */
String template();
/** Optional fragment slot selector. */
String slot() default "";
}
@@ -2,6 +2,13 @@ package dev.relism.ext.view;
import dev.relism.http.ContentType;
/**
* Render result produced by a {@link ViewEngineAdapter}.
*
* @param body rendered response body
* @param contentType optional explicit response content type; if null runtime falls back to
* route-level default ({@code text/html})
*/
public record RenderOutput(String body, ContentType contentType) {
public static RenderOutput html(String body) {
return new RenderOutput(body, ContentType.TEXT_HTML);
@@ -1,98 +0,0 @@
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.type(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;
}
}
@@ -1,62 +0,0 @@
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;
}
}
@@ -1,6 +1,5 @@
package dev.relism.ext.view;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IExpressionContext;
@@ -11,9 +10,10 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
/**
* {@link ViewEngine} bridge for Thymeleaf 3.x.
* Thymeleaf 3.x adapter for Flash SSR views.
*
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
*
@@ -41,12 +41,10 @@ import java.util.Map;
* <li>{@code null} model → empty context.</li>
* </ul>
*/
final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
final class ThymeleafEngine implements ViewEngineAdapter {
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) {
@@ -64,13 +62,6 @@ final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
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);
}
@Override
public EngineCapabilities capabilities() {
return new EngineCapabilities(true);
@@ -80,16 +71,18 @@ final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
public RenderOutput render(ViewTarget target, Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
String selector;
String template = target.template();
if (target.kind() == ViewKind.PAGE) {
selector = target.template();
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(template, ctx));
} else {
String slot = target.slot();
selector = target.template() + " :: " + ((slot == null || slot.isBlank()) ? "content" : slot);
String fragment = (slot == null || slot.isBlank()) ? "content" : slot;
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(template, Set.of(fragment), ctx));
}
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(selector, ctx));
}
private static void populateContext(Context ctx, Object model) {
@@ -1,75 +0,0 @@
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;
}
@@ -1,45 +0,0 @@
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);
}
}
@@ -5,7 +5,25 @@ import dev.relism.models.Response;
import java.util.Map;
/**
* Rendering adapter contract used by the Flash SSR view runtime.
*
* <p>Implementations must be thread-safe after construction because one instance is shared by
* all requests.
*/
public interface ViewEngineAdapter {
/** Engine feature flags used for boot-time route/view validation. */
EngineCapabilities capabilities();
/**
* Renders a page/partial target with the merged model for the current request.
*
* @param target resolved rendering target
* @param model merged request model (globals first, local model last)
* @param req current request
* @param res current response
* @return response body + optional explicit content type
*/
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
}
@@ -22,8 +22,8 @@ package dev.relism.ext.view;
* 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.
* For unsupported engines, implement {@link ViewEngineAdapter} and pass it to
* {@link ViewExtension#ViewExtension(ViewEngineAdapter)}.
*/
public enum ViewEngineType {
@@ -47,8 +47,8 @@ public enum ViewEngineType {
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Instantiates and configures the {@link ViewEngine} for this type.
* Called once at {@link ViewExtension#install} time — never on the hot-path.
* Instantiates and configures the {@link ViewEngineAdapter} for this type.
* Called once at extension setup 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
@@ -59,12 +59,6 @@ public enum ViewEngineType {
};
}
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 ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
@@ -3,39 +3,45 @@ package dev.relism.ext.view;
import dev.relism.Flash;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
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;
import java.util.function.Function;
/**
* Installs server-side view rendering for class-based handlers.
*
* <p>Strict contract: only {@link ViewHandler} subclasses may declare {@link Page}/{@link Partial}.
* Annotating a plain {@link dev.relism.models.RequestHandler} fails fast at boot.
*/
public final class ViewExtension implements FlashExtension {
private final ViewEngineAdapter adapter;
private final ViewEngine legacyEngine;
private final List<ViewGlobals> globals = new ArrayList<>();
private final List<GlobalBinding> globals = new ArrayList<>();
public ViewExtension(ViewEngineType type) {
this(type.createAdapter(!Flash.DEV));
}
public ViewExtension(ViewEngine engine) {
this(new LegacyViewEngineAdapter(engine), engine);
}
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"));
/**
* Registers one request-scoped global value under {@code global.<key>}.
*
* <p>This is the only supported global registration API. Keep resolvers fast and side-effect free.
*/
public ViewExtension addGlobal(String key, Function<dev.relism.models.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 GlobalBinding(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
return this;
}
@@ -45,37 +51,16 @@ public final class ViewExtension implements FlashExtension {
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));
}
// Processor kept for boot-time contract enforcement. Rendering itself stays in ViewHandler.
ctx.addAnnotationProcessor(handlerClass -> {
if (ViewHandler.class.isAssignableFrom(handlerClass)) return List.of();
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) return List.of();
Middleware renderingMiddleware = next -> (req, res) -> {
Object result = next.handle(req, res);
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());
}
return out.body();
};
return List.of(renderingMiddleware);
if (!ViewHandler.class.isAssignableFrom(handlerClass)) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " declares @Page/@Partial but does not extend ViewHandler");
}
return List.of();
});
}
}
@@ -1,8 +0,0 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
@FunctionalInterface
public interface ViewGlobals {
ViewModel provide(Request req);
}
@@ -4,25 +4,52 @@ import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
/**
* Base class for class-based SSR handlers.
*
* <p>Subclass contract:
* <ol>
* <li>Declare exactly one of {@link Page} or {@link Partial} on the class hierarchy.</li>
* <li>Cache dependencies in {@link #onViewInit()} (one-time, boot-time).</li>
* <li>Build per-request model in {@link #render(Request)} or {@link #render(Request, Response)}.</li>
* </ol>
*/
public abstract class ViewHandler extends RequestHandler {
private ViewRuntime runtime;
private ViewTargetResolver.ResolvedView resolved;
/**
* Per-request model hook.
*
* <p>Override this method for request-only rendering. If you need to mutate response
* metadata while building the model, override {@link #render(Request, Response)}.
*/
public ViewModel render(Request req) throws Exception {
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
}
/**
* Per-request model hook with response access.
*
* <p>Default implementation delegates to {@link #render(Request)}.
*/
public ViewModel render(Request req, Response res) throws Exception {
return render(req);
}
@Override
protected final void onInit() {
runtime = require(ViewRuntime.class);
runtime = require(ViewRuntime.class);
resolved = runtime.resolve(getClass());
onViewInit();
}
/**
* One-time initialization hook invoked after view metadata resolution.
*
* <p>Use this to cache services via {@link #require(Class)}. Do not perform request-bound
* work here.
*/
protected void onViewInit() {}
@Override
@@ -1,5 +1,6 @@
package dev.relism.ext.view;
/** Render mode for resolved handler view targets. */
public enum ViewKind {
PAGE,
PARTIAL
@@ -7,6 +7,13 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Mutable view-model builder used by the SSR view runtime.
*
* <p>Instances are typically created per request in {@code ViewHandler.render(...)} then passed
* to the renderer. The internal map preserves insertion order and is exposed as an immutable
* snapshot through {@link #toMap()}.
*/
public final class ViewModel {
private final LinkedHashMap<String, Object> values;
@@ -22,11 +29,13 @@ public final class ViewModel {
return empty().with(key, value);
}
/** Adds or replaces a model entry. Nested maps/lists/view-models are normalized recursively. */
public ViewModel with(String key, Object value) {
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
return this;
}
/** Bulk variant of {@link #with(String, Object)}. */
public ViewModel withAll(Map<String, Object> values) {
if (values == null || values.isEmpty()) return this;
for (Map.Entry<String, Object> e : values.entrySet()) {
@@ -35,12 +44,17 @@ public final class ViewModel {
return this;
}
/**
* Creates a merged copy where {@code other} wins on key collisions.
* Both source models remain unchanged.
*/
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;
}
/** Immutable view over current values. */
public Map<String, Object> toMap() {
return Collections.unmodifiableMap(values);
}
@@ -50,6 +64,10 @@ public final class ViewModel {
return new ViewModel(new LinkedHashMap<>(source.values));
}
static ViewModel owned(LinkedHashMap<String, Object> values) {
return new ViewModel(values);
}
static Object unwrapValue(Object value) {
if (value instanceof ViewModel vm) {
return vm.toMap();
@@ -4,17 +4,23 @@ import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Internal runtime for view target resolution and per-request rendering.
*
* <p>All expensive reflection is done once and cached per handler class.
*/
final class ViewRuntime {
private static final ViewModel EMPTY = ViewModel.empty();
private static final String GLOBAL_NAMESPACE = "global";
private final ViewEngineAdapter adapter;
private final List<ViewGlobals> globals;
private final List<GlobalBinding> globals;
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
ViewRuntime(ViewEngineAdapter adapter, List<ViewGlobals> globals) {
ViewRuntime(ViewEngineAdapter adapter, List<GlobalBinding> globals) {
this.adapter = adapter;
this.globals = globals;
}
@@ -25,7 +31,7 @@ final class ViewRuntime {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) {
throw new IllegalStateException("ViewHandler " + handlerClass.getName()
+ " must declare @Page, @Partial, or @View");
+ " must declare @Page or @Partial");
}
resolvedCache.put(handlerClass, resolved);
return resolved;
@@ -34,46 +40,52 @@ final class ViewRuntime {
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");
+ " must declare @Page or @Partial");
}
ViewModel local = handler.render(req, res);
ViewModel merged = merge(req, local);
RenderOutput out = render(resolved, req, res, handler.render(req, res));
return out.body();
}
RenderOutput render(ViewTargetResolver.ResolvedView resolved, Request req, Response res, ViewModel local) throws Exception {
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()));
ViewModel merge(Request req, ViewModel local) {
// Single-pass merge: reserved global namespace + local model.
// We avoid intermediate ViewModel allocations on the hot path.
LinkedHashMap<String, Object> values = null;
if (!globals.isEmpty()) {
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
for (GlobalBinding binding : globals) {
Object resolved = binding.resolver().apply(req);
globalMap.put(binding.key(), ViewModel.unwrapValue(resolved));
}
if (!globalMap.isEmpty()) {
if (values == null) values = new LinkedHashMap<>();
values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap));
}
return vm;
}
return ViewModel.of("it", legacyModel);
if (local != null) {
var localMap = local.toMap();
if (localMap.containsKey(GLOBAL_NAMESPACE)) {
throw new IllegalStateException("ViewModel key 'global' is reserved for framework globals");
}
if (values == null) return ViewModel.copyOf(local);
values.putAll(localMap);
}
if (values == null || values.isEmpty()) return ViewModel.empty();
return ViewModel.owned(values);
}
}
@@ -1,5 +1,12 @@
package dev.relism.ext.view;
/**
* Resolved render target for one handler class.
*
* @param kind page or partial render
* @param template template identifier/path
* @param slot optional partial slot selector
*/
public record ViewTarget(
ViewKind kind,
String template,
@@ -4,6 +4,7 @@ import dev.relism.http.ContentType;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
/** Boot-time resolver that maps handler annotations to concrete render targets. */
final class ViewTargetResolver {
private ViewTargetResolver() {}
@@ -11,9 +12,8 @@ final class 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);
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0);
if (count == 0) return null;
Route route = Routes.of(handlerClass);
@@ -25,28 +25,36 @@ final class ViewTargetResolver {
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");
+ " declares multiple view annotations. Use exactly one of @Page, @Partial");
}
if (page != null) {
return new ResolvedView(new ViewTarget(ViewKind.PAGE, page.value(), ""), ContentType.TEXT_HTML, false);
String template = page.value() == null ? "" : page.value().trim();
if (template.isEmpty()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Page with an empty template name");
}
return new ResolvedView(new ViewTarget(ViewKind.PAGE, template, ""), ContentType.TEXT_HTML);
}
if (partial != null) {
String template = partial.template() == null ? "" : partial.template().trim();
if (template.isEmpty()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Partial with an empty template name");
}
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(ViewKind.PARTIAL, template, slot), ContentType.TEXT_HTML);
}
return new ResolvedView(
new ViewTarget(legacy.fragment() ? ViewKind.PARTIAL : ViewKind.PAGE, legacy.value(), ""),
legacy.contentType(),
true
);
return null;
}
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
@@ -58,5 +66,5 @@ final class ViewTargetResolver {
return null;
}
record ResolvedView(ViewTarget target, ContentType contentType, boolean legacy) {}
record ResolvedView(ViewTarget target, ContentType contentType) {}
}
@@ -0,0 +1,61 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ThymeleafEngineTest {
@Test
void render_page_resolvesTemplateAndLinks() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
ViewModel.empty().with("title", "Home").with("id", 42).with("page", 2).toMap(),
null,
new Response(200, ContentType.JSON)
);
assertEquals(ContentType.TEXT_HTML, out.contentType());
assertTrue(out.body().contains("Home"));
assertTrue(out.body().contains("/users/42?page=2"));
}
@Test
void render_partial_usesExplicitSlot() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PARTIAL, "pages/home", "rows"),
ViewModel.empty().with("id", 42).toMap(),
null,
new Response(200, ContentType.JSON)
);
assertTrue(out.body().contains("row-42"));
}
@Test
void render_partial_usesDefaultContentSlotWhenBlank() {
ThymeleafEngine engine = new ThymeleafEngine(false);
RenderOutput out = engine.render(
new ViewTarget(ViewKind.PARTIAL, "pages/home", " "),
ViewModel.empty().with("title", "ContentSlot").toMap(),
null,
new Response(200, ContentType.JSON)
);
assertTrue(out.body().contains("content-ContentSlot"));
}
@Test
void capabilities_supportSlotSelection() {
ThymeleafEngine engine = new ThymeleafEngine(true);
assertTrue(engine.capabilities().supportsPartialSlot());
}
}
@@ -0,0 +1,112 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.extension.AnnotationProcessor;
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 java.lang.reflect.Method;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ViewExtensionContractTest {
@GET("/plain")
@Page("pages/plain")
static final class PlainPageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/view")
@Page("pages/view")
static final class ViewPageHandler extends ViewHandler {
@Override
public ViewModel render(Request req) {
return ViewModel.empty();
}
}
@Test
void processor_rejectsViewAnnotationOnNonViewHandler() {
FlashContext ctx = new FlashContext();
new ViewExtension(new NoopAdapter()).provide(ctx);
assertThrows(IllegalStateException.class,
() -> processors(ctx).forEach(p -> p.process(PlainPageHandler.class)));
}
@Test
void processor_acceptsViewHandlerWithViewAnnotation() {
FlashContext ctx = new FlashContext();
new ViewExtension(new NoopAdapter()).provide(ctx);
assertDoesNotThrow(() -> processors(ctx).forEach(p -> p.process(ViewPageHandler.class)));
}
@Test
void constructor_withEngineType_buildsAndProvidesRuntime() {
FlashContext ctx = new FlashContext();
ViewExtension extension = new ViewExtension(ViewEngineType.THYMELEAF);
extension.provide(ctx);
assertNotNull(ctx.require(ViewRuntime.class));
assertNotNull(ctx.require(ViewEngineAdapter.class));
}
@Test
void addGlobal_nullResolver_throws() {
ViewExtension extension = new ViewExtension(new NoopAdapter());
assertThrows(NullPointerException.class, () -> extension.addGlobal("appName", null));
}
@Test
void addGlobal_invalidKey_throws() {
ViewExtension extension = new ViewExtension(new NoopAdapter());
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("", req -> "x"));
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("global", req -> "x"));
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("a.b", req -> "x"));
}
@Test
void constructor_withNullAdapter_throws() {
assertThrows(NullPointerException.class, () -> new ViewExtension((ViewEngineAdapter) null));
}
@SuppressWarnings("unchecked")
private static List<AnnotationProcessor> processors(FlashContext ctx) {
try {
Method m = FlashContext.class.getDeclaredMethod("processors");
m.setAccessible(true);
return ((List<AnnotationProcessor>) m.invoke(ctx)).stream()
.filter(p -> p.getClass().getName().contains("ViewExtension"))
.collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static final class NoopAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return new EngineCapabilities(true);
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
Request req,
Response res) {
return RenderOutput.html("");
}
}
}
@@ -0,0 +1,81 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ViewHandlerLifecycleTest {
@GET("/lifecycle")
@Page("pages/home")
static final class LifecycleHandler extends ViewHandler {
boolean onViewInitCalled;
DummyService service;
@Override
protected void onViewInit() {
onViewInitCalled = true;
service = require(DummyService.class);
}
@Override
public ViewModel render(Request req) {
return ViewModel.of("title", service.value());
}
}
static final class DummyService {
String value() { return "ok"; }
}
@Test
void onInit_resolvesRuntime_and_onViewInit_runs_once() {
FlashContext ctx = new FlashContext();
ctx.provide(DummyService.class, new DummyService());
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
LifecycleHandler handler = new LifecycleHandler();
handler.bind(ctx);
assertTrue(handler.onViewInitCalled);
assertEquals("ok", handler.service.value());
}
@Test
void handle_rendersThroughRuntime() throws Exception {
FlashContext ctx = new FlashContext();
ctx.provide(DummyService.class, new DummyService());
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
LifecycleHandler handler = new LifecycleHandler();
handler.bind(ctx);
Response res = new Response(200, ContentType.JSON);
Object out = handler.handle(null, res);
assertEquals("ok", out);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
}
private static final class EchoAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
Request req,
Response res) {
return RenderOutput.html(String.valueOf(model.get("title")));
}
}
}
@@ -53,4 +53,31 @@ class ViewModelTest {
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"));
}
}
@@ -3,33 +3,112 @@ 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ViewRuntimeGlobalsTest {
private static final GlobalBinding APP = new GlobalBinding("appName", req -> "flash");
private static final GlobalBinding PATH = new GlobalBinding("requestPath", req -> "/x");
@Test
void merge_globalsThenLocal_localWins() {
void merge_globalsAreNestedUnderReservedNamespace() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
req -> ViewModel.of("nav", "global").with("app", "flash"),
req -> ViewModel.of("nav", "global-2")
APP,
PATH
));
ViewModel merged = runtime.merge(null, ViewModel.of("nav", "local"));
ViewModel merged = runtime.merge(null, ViewModel.of("title", "dashboard"));
assertEquals("local", merged.toMap().get("nav"));
assertEquals("flash", merged.toMap().get("app"));
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
assertEquals("flash", global.get("appName"));
assertEquals("/x", global.get("requestPath"));
assertEquals("dashboard", merged.toMap().get("title"));
}
@Test
void merge_nullLocal_keepsGlobals() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
req -> ViewModel.of("signedIn", true)
new GlobalBinding("signedIn", req -> true)
));
ViewModel merged = runtime.merge(null, null);
assertEquals(true, merged.toMap().get("signedIn"));
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
assertEquals(true, global.get("signedIn"));
}
@Test
void merge_withoutGlobals_returnsCopyOfLocal() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
ViewModel local = ViewModel.of("k", "v");
ViewModel merged = runtime.merge(null, local);
assertEquals("v", merged.toMap().get("k"));
}
@Test
void merge_withNullGlobalsAndNullLocal_returnsEmpty() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
ViewModel merged = runtime.merge(null, null);
assertTrue(merged.toMap().isEmpty());
}
@Test
void merge_localGlobalNamespace_throws() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(APP));
assertThrows(IllegalStateException.class,
() -> runtime.merge(null, ViewModel.of("global", Map.of("x", 1))));
}
@Test
void resolve_withoutViewAnnotation_failsFast() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
assertThrows(IllegalStateException.class,
() -> runtime.resolve(NoViewHandler.class));
}
@Test
void render_prefersAdapterContentTypeWhenProvided() throws Exception {
ViewRuntime runtime = new ViewRuntime(new PlainTextAdapter(), List.of());
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
dev.relism.http.ContentType.TEXT_HTML
);
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
runtime.render(resolved, null, res, ViewModel.of("a", 1));
assertEquals(new String(dev.relism.http.ContentType.TEXT_PLAIN.getBytes()), new String(res.getContentType()));
}
@Test
void render_usesResolvedDefaultContentTypeWhenAdapterOmitsIt() throws Exception {
ViewRuntime runtime = new ViewRuntime(new NoTypeAdapter(), List.of());
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
dev.relism.http.ContentType.TEXT_HTML
);
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
runtime.render(resolved, null, res, ViewModel.of("a", 1));
assertEquals(new String(dev.relism.http.ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
}
static final class NoViewHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
private static final class NoopAdapter implements ViewEngineAdapter {
@@ -45,4 +124,32 @@ class ViewRuntimeGlobalsTest {
return RenderOutput.html("");
}
}
private static final class NoTypeAdapter 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 new RenderOutput("", null);
}
}
private static final class PlainTextAdapter 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 new RenderOutput("", dev.relism.http.ContentType.TEXT_PLAIN);
}
}
}
@@ -1,6 +1,5 @@
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;
@@ -29,9 +28,9 @@ class ViewTargetResolverTest {
}
}
@GET("/legacy")
@View(value = "legacy/home", contentType = ContentType.TEXT_PLAIN, fragment = true)
static class LegacyHandler extends RequestHandler {
@GET("/partial-default-slot")
@Partial(template = "fragments/card")
static class DefaultSlotPartialHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
@@ -72,13 +71,30 @@ class ViewTargetResolverTest {
}
@Test
void resolve_legacyView_mapsToResolvedTarget() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(LegacyHandler.class, EngineCapabilities.NONE);
void resolve_partial_withoutSlot_defaultsToEmptySlot() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(DefaultSlotPartialHandler.class, new EngineCapabilities(true));
assertNotNull(resolved);
assertTrue(resolved.legacy());
assertEquals(ContentType.TEXT_PLAIN, resolved.contentType());
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
assertEquals("", resolved.target().slot());
}
@GET("/blank-page")
@Page(" ")
static class BlankPageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/blank-partial")
@Partial(template = " ")
static class BlankPartialTemplateHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
@@ -92,4 +108,16 @@ class ViewTargetResolverTest {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_blankPageTemplate_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(BlankPageHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_blankPartialTemplate_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(BlankPartialTemplateHandler.class, new EngineCapabilities(true)));
}
}
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<body>
<h1 th:text="${title}">fallback</h1>
<a th:href="@{/users/{id}(id=${id},page=${page})}">user</a>
<div th:fragment="content" th:text="'content-' + ${title}">content</div>
<div th:fragment="rows" th:attr="id=${'row-' + id}">row</div>
</body>
</html>