From 34fd74068af82bfc31601bfab7ee7eeb14e60ce5 Mon Sep 17 00:00:00 2001 From: Relism Date: Sun, 19 Apr 2026 23:42:50 +0200 Subject: [PATCH 1/2] implement OpenAPI contributor integration for rate limiting and response headers --- .ai/mcp/mcp.json | 0 .idea/misc.xml | 5 + .idea/workspace.xml | 185 +++++++----------- .../flash-ext-limiter/docs/annotation.md | 3 + .../flash-ext-limiter/docs/http-headers.md | 12 +- flash-extensions/flash-ext-limiter/pom.xml | 5 + .../relism/ext/limiter/LimiterExtension.java | 67 +++++++ .../limiter/LimiterOpenApiInteropTest.java | 84 ++++++++ .../dev/relism/ext/oidc/OidcExtension.java | 66 ++++--- .../ext/oidc/OidcOpenApiInteropTest.java | 40 ++-- flash-extensions/flash-ext-openapi/README.md | 28 +++ .../{ApiResponse.java => APIResponse.java} | 0 .../{ApiResponses.java => APIResponses.java} | 0 .../relism/ext/openapi/OpenApiBuilder.java | 128 +++++++++--- .../ext/openapi/OpenApiContributor.java | 14 ++ .../openapi/OpenApiContributorRegistry.java | 17 ++ .../relism/ext/openapi/OpenApiExtension.java | 10 +- .../openapi/OpenApiOperationContribution.java | 71 +++++++ .../openapi/OpenApiResponseContribution.java | 49 +++++ .../openapi/OpenApiSecurityContributor.java | 57 ------ .../ext/openapi/OpenApiSecurityRegistry.java | 31 --- .../ext/openapi/OpenApiBuilderTest.java | 104 +++++++++- .../src/main/resources/routeviewer/index.html | 22 +-- flash-extensions/flash-ext-view/README.md | 1 - .../docs/migration-from-legacy-view.md | 33 ---- .../WebBundlerExtensionIntegrationTest.java | 2 +- 26 files changed, 703 insertions(+), 331 deletions(-) create mode 100644 .ai/mcp/mcp.json create mode 100644 flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java rename flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/{ApiResponse.java => APIResponse.java} (100%) rename flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/{ApiResponses.java => APIResponses.java} (100%) create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java delete mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java delete mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java delete mode 100644 flash-extensions/flash-ext-view/docs/migration-from-legacy-view.md diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/.idea/misc.xml b/.idea/misc.xml index 64a39dc..ff846ff 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -13,6 +13,11 @@ + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 6f89113..206108b 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,61 +5,26 @@ + - - - + + - + + - - - - - + + - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - @@ -95,12 +60,12 @@ "assignee": "Relism" } } - { + "selectedUrlAndAccountId": { + "url": "https://github.com/Relism/Flash5.git", + "accountId": "86d8a39c-af27-4b79-8d5d-dc74375c3348" } -}]]> +} @@ -116,62 +81,62 @@ - { + "keyToString": { + "Application.(dev) flash-bench.executor": "Run", + "Application.ExternalBenchmark (1).executor": "Run", + "Application.ExternalBenchmark.executor": "Run", + "Application.Main.executor": "Run", + "Application.MainAlt.executor": "Run", + "Application.dev.relism.bench.Main.executor": "Run", + "JUnit.RequestParserTest.executor": "Run", + "JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug", + "Maven.FlashPractice [test].executor": "Run", + "Maven.flash [compile].executor": "Run", + "Maven.flash [install].executor": "Run", + "Maven.flash [test].executor": "Run", + "Maven.flash [verify].executor": "Run", + "Maven.flash-bench [clean].executor": "Run", + "Maven.flash-bench [install].executor": "Run", + "Maven.flash-bench [package].executor": "Run", + "Maven.flash-bench [validate].executor": "Run", + "Maven.flash-ext-limiter [install].executor": "Run", + "Maven.flash-ext-limiter [package].executor": "Run", + "Maven.flash-ext-view [verify].executor": "Run", + "Maven.flash-parent [clean].executor": "Run", + "Maven.flash-parent [compile].executor": "Run", + "Maven.flash-parent [deploy].executor": "Run", + "Maven.flash-parent [install].executor": "Run", + "Maven.flash-parent [package].executor": "Run", + "Maven.flash-parent [verify].executor": "Run", + "Maven.flash-web-bundler [test].executor": "Run", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.MCP Project settings loaded": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "SHARE_PROJECT_CONFIGURATION_FILES": "true", + "codeWithMe.voiceChat.enabledByDefault": "false", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "npm.build.executor": "Run", + "onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java", + "project.structure.last.edited": "Modules", + "project.structure.proportion": "0.15", + "project.structure.side.proportion": "0.1150748", + "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings", + "ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib", + "vue.rearranger.settings.migration": "true" } -}]]> +} @@ -299,6 +264,8 @@ + + - - + + - - + + - + - - - - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - @@ -108,6 +136,8 @@ "Maven.flash-parent [deploy].executor": "Run", "Maven.flash-parent [install].executor": "Run", "Maven.flash-parent [package].executor": "Run", + "Maven.flash-parent [test].executor": "Run", + "Maven.flash-parent [validate].executor": "Run", "Maven.flash-parent [verify].executor": "Run", "Maven.flash-web-bundler [test].executor": "Run", "ModuleVcsDetector.initialDetectionPerformed": "true", @@ -265,7 +295,9 @@ - + + + - @@ -387,7 +427,8 @@ - diff --git a/README.md b/README.md index b2a87be..8f0560a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ A high-performance HTTP/1.1 server library for Java 21, built around virtual thr | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | +| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | +| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | +| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | | `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) | ## Requirements @@ -139,6 +142,8 @@ See extension-specific READMEs for full details: - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) - [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) +- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) +- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) ## Error handlers diff --git a/flash-extensions/flash-ext-jackson/pom.xml b/flash-extensions/flash-ext-jackson/pom.xml index 6223ddc..0cddcdb 100644 --- a/flash-extensions/flash-ext-jackson/pom.xml +++ b/flash-extensions/flash-ext-jackson/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-ext-jackson diff --git a/flash-extensions/flash-ext-limiter/pom.xml b/flash-extensions/flash-ext-limiter/pom.xml index c1ac619..225e10f 100644 --- a/flash-extensions/flash-ext-limiter/pom.xml +++ b/flash-extensions/flash-ext-limiter/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-ext-limiter diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-oidc/pom.xml index c862e3b..97742ab 100644 --- a/flash-extensions/flash-ext-oidc/pom.xml +++ b/flash-extensions/flash-ext-oidc/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-ext-oidc diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java index f447dd7..3093c14 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java @@ -1,5 +1,9 @@ package dev.relism.ext.oidc; +import dev.relism.ext.openapi.OpenApiContributor; +import dev.relism.ext.openapi.OpenApiContributorRegistry; +import dev.relism.ext.openapi.OpenApiOperationContribution; +import dev.relism.ext.openapi.OpenApiResponseContribution; import dev.relism.extension.FlashContext; import dev.relism.extension.FlashExtension; import dev.relism.extension.FlashRegistrar; @@ -13,10 +17,7 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.security.cert.X509Certificate; import java.time.Instant; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; /** * Full OIDC Authorization Code + PKCE flow for Flash. @@ -266,37 +267,37 @@ public class OidcExtension implements FlashExtension { * runtime when {@link dev.relism.ext.openapi.OpenApiContributorRegistry} is actually on the classpath. */ private static final class OpenApiIntegration { - static void register(dev.relism.extension.FlashContext ctx, + static void register(FlashContext ctx, OidcConfig config, OidcProviderMetadata meta) { - ctx.find(dev.relism.ext.openapi.OpenApiContributorRegistry.class) - .ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() { + ctx.find(OpenApiContributorRegistry.class) + .ifPresent(registry -> registry.add(new OpenApiContributor() { @Override - public java.util.Map componentContributions() { - java.util.Map scopesMap = new java.util.LinkedHashMap<>(); + public Map componentContributions() { + Map scopesMap = new LinkedHashMap<>(); for (String s : config.scopes().split("\\s+")) { if (!s.isBlank()) scopesMap.put(s, s); - } - java.util.Map flow = new java.util.LinkedHashMap<>(); - flow.put("authorizationUrl", meta.authorizationEndpoint()); - flow.put("tokenUrl", meta.tokenEndpoint()); + } + Map flow = new LinkedHashMap<>(); + flow.put("authorizationUrl", meta.authorizationEndpoint()); + flow.put("tokenUrl", meta.tokenEndpoint()); flow.put("scopes", scopesMap); - java.util.Map scheme = new java.util.LinkedHashMap<>(); + Map scheme = new LinkedHashMap<>(); scheme.put("type", "oauth2"); - scheme.put("flows", java.util.Map.of("authorizationCode", flow)); + scheme.put("flows", Map.of("authorizationCode", flow)); - java.util.Map securitySchemes = new java.util.LinkedHashMap<>(); + Map securitySchemes = new LinkedHashMap<>(); securitySchemes.put(config.schemeName(), scheme); - return java.util.Map.of("securitySchemes", securitySchemes); + return Map.of("securitySchemes", securitySchemes); } @Override - public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class handlerClass) { - dev.relism.ext.openapi.OpenApiOperationContribution.Builder out = - dev.relism.ext.openapi.OpenApiOperationContribution.builder(); + public OpenApiOperationContribution operationFor(Class handlerClass) { + OpenApiOperationContribution.Builder out = + OpenApiOperationContribution.builder(); - java.util.List operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass); + List operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass); if (operationScopes != null) { out.security(config.schemeName(), operationScopes); } @@ -304,7 +305,7 @@ public class OidcExtension implements FlashExtension { OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); if (policy == null || policy.optionalAuth()) return out.build(); - out.response(401, dev.relism.ext.openapi.OpenApiResponseContribution.of("Authentication required")); + out.response(401, OpenApiResponseContribution.of("Authentication required")); String[] roles = policy.requiredRoles(); String[] scopes = policy.requiredScopes(); @@ -313,12 +314,9 @@ public class OidcExtension implements FlashExtension { String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles); String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes); if (roleMessage != null && scopeMessage != null) { - out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage)); - } else if (roleMessage != null) { - out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage)); - } else { - out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(scopeMessage)); - } + out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage)); + } else + out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage))); return out.build(); } })); diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml index 1e4dbf1..62c8c15 100644 --- a/flash-extensions/flash-ext-openapi/pom.xml +++ b/flash-extensions/flash-ext-openapi/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-ext-openapi diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java index 84a2357..5149d13 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java @@ -344,7 +344,11 @@ public final class OpenApiBuilder { } private static String normalizePath(String path) { - return path.startsWith("/") ? path : "/" + path; + String normalized = path.startsWith("/") ? path : "/" + path; + while (normalized.startsWith("//")) { + normalized = normalized.substring(1); + } + return normalized; } private static String defaultDescription(int status) { diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java index 3c5efcf..67ab89e 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java @@ -108,6 +108,21 @@ class OpenApiExtensionTest { assertFalse(paths.containsKey("/users")); } + @Test + void normalizes_double_slash_paths_from_events() { + FlashContext ctx = new FlashContext(); + OpenApiExtension ext = new OpenApiExtension(); + ext.provide(ctx); + + emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class); + + OpenApiBuilder builder = ctx.require(OpenApiBuilder.class); + Map spec = builder.build(); + Map paths = cast(spec.get("paths")); + assertTrue(paths.containsKey("/blogs")); + assertFalse(paths.containsKey("//blogs")); + } + @SuppressWarnings("unchecked") private static List listeners(FlashContext ctx) { try { diff --git a/flash-extensions/flash-ext-routeviewer/pom.xml b/flash-extensions/flash-ext-routeviewer/pom.xml index 56c6734..b1b68d1 100644 --- a/flash-extensions/flash-ext-routeviewer/pom.xml +++ b/flash-extensions/flash-ext-routeviewer/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-ext-routeviewer diff --git a/flash-extensions/flash-ext-view-core/README.md b/flash-extensions/flash-ext-view-core/README.md new file mode 100644 index 0000000..5a1be6c --- /dev/null +++ b/flash-extensions/flash-ext-view-core/README.md @@ -0,0 +1,15 @@ +# flash-ext-view-core + +Minimal runtime primitives for Flash SSR view extensions. + +This module is intentionally small and not engine-opinionated. It provides: + +- `BaseViewHandler` +- `BaseViewExtension` +- `ViewRuntimeBridge` +- `ViewModel` +- `RenderedView` +- `GlobalValue` + +Engine semantics (`@Template`, `@Fragment`, layout/macros/tags, resolver conventions) +belong in engine-specific modules such as `flash-ext-view-thymeleaf`. diff --git a/flash-extensions/flash-ext-view-core/pom.xml b/flash-extensions/flash-ext-view-core/pom.xml new file mode 100644 index 0000000..da55ba0 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 1.1-indev6 + + + flash-ext-view-core + + + + dev.relism + flash + + + org.projectlombok + lombok + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java new file mode 100644 index 0000000..4df2096 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java @@ -0,0 +1,40 @@ +package dev.relism.ext.view.core; + +import dev.relism.extension.FlashContext; +import dev.relism.extension.FlashExtension; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +public abstract class BaseViewExtension implements FlashExtension { + private final List globals = new ArrayList<>(); + + public BaseViewExtension addGlobal(String key, Function resolver) { + String k = Objects.requireNonNull(key, "global key must not be null").trim(); + if (k.isEmpty()) { + throw new IllegalArgumentException("global key must not be blank"); + } + if (k.equals("global") || k.contains(".")) { + throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key); + } + globals.add(new GlobalValue(k, Objects.requireNonNull(resolver, "global resolver must not be null"))); + return this; + } + + @Override + public final void provide(FlashContext ctx) { + ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals)); + ctx.provide(ViewRuntimeBridge.class, runtime); + ctx.addAnnotationProcessor(handlerClass -> { + validateHandlerClass(handlerClass); + return List.of(); + }); + } + + protected abstract ViewRuntimeBridge createRuntime(List globals); + protected abstract void validateHandlerClass(Class handlerClass); +} diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java new file mode 100644 index 0000000..4a4a650 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java @@ -0,0 +1,68 @@ +package dev.relism.ext.view.core; + +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.Response; + +public abstract class BaseViewHandler extends RequestHandler { + private static final byte DISPATCH_RENDER_2 = 1; + private static final byte DISPATCH_RENDER_1 = 2; + + private ViewRuntimeBridge runtime; + private TTarget target; + private byte dispatchMode; + + public ViewModel render(Request req) throws Exception { + throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)"); + } + + public ViewModel render(Request req, Response res) throws Exception { + return render(req); + } + + @Override + protected final void onInit() { + @SuppressWarnings("unchecked") + ViewRuntimeBridge resolvedRuntime = (ViewRuntimeBridge) require(ViewRuntimeBridge.class); + runtime = resolvedRuntime; + target = runtime.resolve(getClass()); + dispatchMode = resolveDispatchMode(getClass()); + onViewInit(); + } + + protected void onViewInit() {} + + @Override + public final Object handle(Request request, Response response) throws Exception { + RenderedView rendered = runtime.render(this, target, request, response); + if (rendered.contentType() != null) { + response.type(rendered.contentType()); + } + return rendered.body(); + } + + public final ViewModel renderInternal(Request req, Response res) throws Exception { + if (dispatchMode == 0) { + dispatchMode = resolveDispatchMode(getClass()); + } + return switch (dispatchMode) { + case DISPATCH_RENDER_2 -> render(req, res); + case DISPATCH_RENDER_1 -> render(req); + default -> throw new UnsupportedOperationException("No render method overridden for " + getClass().getName()); + }; + } + + private static byte resolveDispatchMode(Class handlerClass) { + if (overrides(handlerClass, "render", Request.class, Response.class)) return DISPATCH_RENDER_2; + if (overrides(handlerClass, "render", Request.class)) return DISPATCH_RENDER_1; + return 0; + } + + private static boolean overrides(Class handlerClass, String method, Class... args) { + try { + return handlerClass.getMethod(method, args).getDeclaringClass() != BaseViewHandler.class; + } catch (NoSuchMethodException e) { + return false; + } + } +} diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java new file mode 100644 index 0000000..757727d --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java @@ -0,0 +1,7 @@ +package dev.relism.ext.view.core; + +import dev.relism.models.Request; + +import java.util.function.Function; + +public record GlobalValue(String key, Function resolver) {} diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java new file mode 100644 index 0000000..1c6a9ea --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java @@ -0,0 +1,15 @@ +package dev.relism.ext.view.core; + +import dev.relism.http.ContentType; + +/** + * Render result returned by engine runtimes. + * + * @param body rendered response body + * @param contentType optional explicit response content type; if null caller keeps existing type + */ +public record RenderedView(String body, ContentType contentType) { + public static RenderedView html(String body) { + return new RenderedView(body, ContentType.TEXT_HTML); + } +} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewModel.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java similarity index 72% rename from flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewModel.java rename to flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java index a053b03..e1db8e1 100644 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewModel.java +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java @@ -1,18 +1,14 @@ -package dev.relism.ext.view; +package dev.relism.ext.view.core; -import java.util.Collections; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** - * Mutable view-model builder used by the SSR view runtime. - * - *

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()}. + * Mutable per-request model builder. */ public final class ViewModel { private final LinkedHashMap values; @@ -29,13 +25,11 @@ 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 values) { if (values == null || values.isEmpty()) return this; for (Map.Entry e : values.entrySet()) { @@ -44,31 +38,26 @@ 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 toMap() { return Collections.unmodifiableMap(values); } - static ViewModel copyOf(ViewModel source) { + public static ViewModel copyOf(ViewModel source) { if (source == null || source.values.isEmpty()) return empty(); return new ViewModel(new LinkedHashMap<>(source.values)); } - static ViewModel owned(LinkedHashMap values) { + public static ViewModel owned(LinkedHashMap values) { return new ViewModel(values); } - static Object unwrapValue(Object value) { + public static Object unwrapValue(Object value) { if (value instanceof ViewModel vm) { return vm.toMap(); } diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java new file mode 100644 index 0000000..42ec4ea --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java @@ -0,0 +1,9 @@ +package dev.relism.ext.view.core; + +import dev.relism.models.Request; +import dev.relism.models.Response; + +public interface ViewRuntimeBridge { + TTarget resolve(Class handlerClass); + RenderedView render(BaseViewHandler handler, TTarget target, Request req, Response res) throws Exception; +} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewModelTest.java b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java similarity index 98% rename from flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewModelTest.java rename to flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java index ba4c403..1c01470 100644 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewModelTest.java +++ b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java @@ -1,4 +1,4 @@ -package dev.relism.ext.view; +package dev.relism.ext.view.core; import org.junit.jupiter.api.Test; diff --git a/flash-extensions/flash-ext-view-jte/README.md b/flash-extensions/flash-ext-view-jte/README.md new file mode 100644 index 0000000..910ec10 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/README.md @@ -0,0 +1,57 @@ +# flash-ext-view-jte + +Opinionated jte SSR extension for Flash. + +This module keeps jte semantics front and center: + +- `JteExtension` +- `JteHandler` +- `@Template` +- `ViewModel` from `flash-ext-view-core` +- `global.*` reserved namespace + +The extension mirrors `gg.jte.ContentType` into Flash HTTP content type: + +- `gg.jte.ContentType.Html` -> `text/html` +- `gg.jte.ContentType.Plain` -> `text/plain` + +## Quick Start + +```java +import dev.relism.extension.FlashApp; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.jte.*; + +FlashApp.create(8080) + .install(new JteExtension(cfg -> cfg + .templateRoot("/templates") + .contentType(gg.jte.ContentType.Html))) + .scan("com.example.web") + .startAndBlock(); +``` + +```java +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.jte.*; +import dev.relism.routing.GET; + +@GET("/") +@Template("pages/home.jte") +public final class HomePage extends JteHandler { + @Override + public ViewModel render(dev.relism.models.Request req) { + return ViewModel.empty() + .with("page", new HomePageModel("Flash + jte", "elorc")) + .with("build", "dev"); + } + + public record HomePageModel(String title, String author) {} +} +``` + +## Docs + +- `docs/architecture.md` +- `docs/handlers.md` +- `docs/model-and-globals.md` +- `docs/performance.md` diff --git a/flash-extensions/flash-ext-view-jte/docs/architecture.md b/flash-extensions/flash-ext-view-jte/docs/architecture.md new file mode 100644 index 0000000..3f5c766 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/docs/architecture.md @@ -0,0 +1,29 @@ +# Architecture + +`flash-ext-view-jte` has two layers: + +1. **Boot-time** + - `JteExtension` installs runtime + annotation processor. + - `JteTargetResolver` validates handlers and resolves `@Template`. + - Resolved targets are cached per handler class. + +2. **Request-time** + - Handler builds local `ViewModel`. + - Runtime injects globals under reserved `global` namespace and merges local model. + - jte renders template into `StringOutput`. + +## Handler Contract + +- Must extend `JteHandler`. +- Must have route annotation (`@Route`, `@GET`, `@POST`, ...). +- Must declare exactly one view annotation: `@Template`. + +Invalid configurations fail fast at startup. + +## Defaults + +- `templateRoot`: `/templates` +- `contentType`: `gg.jte.ContentType.Html` +- `developmentMode`: `Flash.DEV` +- `usePrecompiled`: derived from `!developmentMode` unless explicitly set +- `binaryStaticContent`: `false` diff --git a/flash-extensions/flash-ext-view-jte/docs/handlers.md b/flash-extensions/flash-ext-view-jte/docs/handlers.md new file mode 100644 index 0000000..5d40202 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/docs/handlers.md @@ -0,0 +1,34 @@ +# Handlers + +Use `JteHandler` for class-based jte routes. + +## Lifecycle + +- `onViewInit()` runs once at boot. +- `render(...)` runs per request. + +Use `onViewInit()` to cache dependencies via `require(...)`. + +## Example + +```java +@GET("/dashboard") +@Template("pages/dashboard.jte") +public final class DashboardPage extends JteHandler { + + private DashboardService service; + + @Override + protected void onViewInit() { + service = require(DashboardService.class); + } + + @Override + public ViewModel render(dev.relism.models.Request req) { + return ViewModel.empty() + .with("page", service.page(req)); + } +} +``` + +Use `render(Request, Response)` when you need response access while building model variables. diff --git a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md new file mode 100644 index 0000000..08b4e82 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md @@ -0,0 +1,33 @@ +# Model And Globals + +`ViewModel` is the per-request data container. + +## POJO-First Pattern + +Keep page state in a POJO and pass it as a single key: + +```java +return ViewModel.empty() + .with("page", new HomePage("Flash + jte", "elorc")) + .with("build", "dev"); +``` + +Then type it in template: + +```jte +@import com.example.HomePage +@param HomePage page +@param String build +``` + +## Globals + +Register globals in extension setup: + +```java +new JteExtension().addGlobal("appName", req -> "Flash") +``` + +Globals are available under `global` namespace in templates. + +`global` is reserved and cannot be used as local model key. diff --git a/flash-extensions/flash-ext-view-jte/docs/performance.md b/flash-extensions/flash-ext-view-jte/docs/performance.md new file mode 100644 index 0000000..cb53987 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/docs/performance.md @@ -0,0 +1,18 @@ +# Performance Notes + +- Runtime caches resolved targets by handler class. +- Request path allocates only what jte rendering requires. +- No reflection on hot path after target cache is warm. +- `TemplateEngine` is created once and shared. + +## Dev vs Prod + +- Dev mode (`developmentMode=true`) uses code resolver rendering. +- Prod mode (`usePrecompiled=true`) uses precompiled classes for startup and render speed. + +## Content Type + +`gg.jte.ContentType` drives both escaping mode and HTTP response content type mirror: + +- `Html` -> context-aware HTML escaping + `text/html` +- `Plain` -> plain output + `text/plain` diff --git a/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class new file mode 100644 index 0000000000000000000000000000000000000000..7ef77154eb76337a852585cf8c7e0a19af45eacc GIT binary patch literal 2108 zcmd5-TT>%N6#hE5nS=p`EMW zz~A8yuq>-o)Utf?!5?M$O=lqNvSn%MgGrs!)8}-b`ObGv_x%3HkG}x8hfh+7AgUo| zAdZB9d19a1mTR{fR`u{i9{B=^qSJExl0bBRVPA(X5Kk44S4u-jVMv2vU>G9;HyRD= z#Fy5w-*l}9RC}`7acy5#dw!?KIL0H-@#R{(<;xbQnfdbIUAw;Lv>JsVz9|qnUI~Jy z4P3*xK&mT!we2yt-Fn8hX9Y@O0@pQ68pvWwV6HAtEl;{mw`s}8zIE!zM+$DUU6;h$ zZpt0IA(@-+_%6rs`Gu=t15`5xW`lf9mCnPi^um;MJSUJWTBIgb?38r8ARsycj++Kv z3P2?6o`2l-bj*>BqSfKLZqs&MuAS$b)w7NqV;8NyW3V7EpxK>H#cs+WEWy&SY+wbi z2n;J4d%ojZ+jfU#YDn@lcbTVtDGQe~3QZbbd!p$5Lg=`oQVBumxGNCrkRKhZ1bWzW z+`5h;Gitc)L)%TEgts-kW3Z#%6<7{ip&?t+V+Yi&cB?L%cB^g$LAutKFOSLi{c}G9 zd6G*!fiLIFf89uhEB;M(ly@XMe%mXQ|6ZL7S8Ln1oYTlwOfsqW46I{=9B=KdACxyM z>j#^ajVe3z(@gf%^&Q(|#hG811>uhK!<6Q8aL6v@&IP+pqh_f%TaKQ=e~#dh7SVU)Pb8uB!iQQ zs9O3P$2hHDQRMR9A#;Z5z;q*YJwI^07`k2#UAIElVrXA@%ASp- zZ=Yc?f?x4!xa4)UWPooKBSeyf&YN`%LzsqvoBW{UX}^O}l3+6ZF;tMoK0gNs7{_PG z;B#K974FQ$QRT2hxl3846e%B2BKVMjcd-|yT~llUEBW*rA*zW=KK_ji&5gSYAq43)-;MvF@uSznF(rL>7>#rrH4$7c0=i5rAJJimQkfmrN_)9Eor5% uDLrncXvru&q4f3OC6mEBvO&P9Kurf~Myq~{wL#AP4$|019}{{6wLbwJ3;~z` literal 0 HcmV?d00001 diff --git a/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java new file mode 100644 index 0000000..fc74c2b --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java @@ -0,0 +1,29 @@ +package gg.jte.generated.ondemand.pages; +import dev.relism.ext.view.jte.model.HomePage; +import java.util.Map; +@SuppressWarnings("unchecked") +public final class JtehomeGenerated { + public static final String JTE_NAME = "pages/home.jte"; + public static final int[] JTE_LINE_INFO = {0,0,1,2,2,2,2,6,6,6,6,7,7,7,8,8,8,9,9,9,10,10,10,2,3,4,4,4,4}; + public static void render(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, HomePage page, String build, Map global) { + jteOutput.writeContent("\n

"); + jteOutput.setContext("h1", null); + jteOutput.writeUserContent(page.title()); + jteOutput.writeContent("

\n

"); + jteOutput.setContext("p", null); + jteOutput.writeUserContent(page.author()); + jteOutput.writeContent("

\n"); + jteOutput.setContext("small", null); + jteOutput.writeUserContent(build); + jteOutput.writeContent("\n"); + jteOutput.setContext("small", null); + jteOutput.writeUserContent((String) global.get("appName")); + jteOutput.writeContent("\n"); + } + public static void renderMap(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, java.util.Map params) { + HomePage page = (HomePage)params.get("page"); + String build = (String)params.get("build"); + Map global = (Map)params.get("global"); + render(jteOutput, jteHtmlInterceptor, page, build, global); + } +} diff --git a/flash-extensions/flash-ext-view-jte/pom.xml b/flash-extensions/flash-ext-view-jte/pom.xml new file mode 100644 index 0000000..b6c7a0e --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 1.1-indev6 + + + flash-ext-view-jte + + + 0.8.12 + + + + + dev.relism + flash-ext-view-core + + + gg.jte + jte + 3.2.3 + + + org.projectlombok + lombok + + + org.junit.jupiter + junit-jupiter + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + jacoco-prepare-agent + + prepare-agent + + + + jacoco-report-and-check + verify + + report + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + + + + + diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java new file mode 100644 index 0000000..d60b6e9 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java @@ -0,0 +1,70 @@ +package dev.relism.ext.view.jte; + +import dev.relism.ext.view.core.BaseViewExtension; +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.ViewRuntimeBridge; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Opinionated jte SSR extension for Flash. + */ +public final class JteExtension extends BaseViewExtension { + private final JteSettings settings; + + static { + ensureJtePresent(); + } + + public JteExtension() { + this(JteSettings.builder().build()); + } + + public JteExtension(Consumer customizer) { + JteSettings.Builder builder = JteSettings.builder(); + java.util.Objects.requireNonNull(customizer, "customizer must not be null").accept(builder); + this.settings = builder.build(); + } + + private JteExtension(JteSettings settings) { + this.settings = settings; + } + + @Override + public JteExtension addGlobal(String key, Function resolver) { + super.addGlobal(key, resolver); + return this; + } + + @Override + protected ViewRuntimeBridge createRuntime(List globals) { + return new JteRuntime(settings, globals); + } + + @Override + protected void validateHandlerClass(Class handlerClass) { + JteTarget target = JteTargetResolver.resolve(handlerClass, settings); + if (target == null) { + if (JteHandler.class.isAssignableFrom(handlerClass)) { + throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template"); + } + return; + } + if (!JteHandler.class.isAssignableFrom(handlerClass)) { + throw new IllegalStateException("Handler " + handlerClass.getName() + + " declares @Template but does not extend JteHandler"); + } + } + + private static void ensureJtePresent() { + try { + Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("jte is not on the classpath. Add dependency gg.jte:jte:3.2.3", e); + } + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java new file mode 100644 index 0000000..30d290b --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java @@ -0,0 +1,8 @@ +package dev.relism.ext.view.jte; + +import dev.relism.ext.view.core.BaseViewHandler; + +/** + * Base class for class-based jte handlers. + */ +public abstract class JteHandler extends BaseViewHandler {} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java new file mode 100644 index 0000000..df3cde4 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java @@ -0,0 +1,113 @@ +package dev.relism.ext.view.jte; + +import dev.relism.ext.view.core.BaseViewHandler; +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.RenderedView; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.core.ViewRuntimeBridge; +import dev.relism.models.Request; +import dev.relism.models.Response; +import gg.jte.CodeResolver; +import gg.jte.TemplateEngine; +import gg.jte.output.StringOutput; +import gg.jte.resolve.DirectoryCodeResolver; +import gg.jte.resolve.ResourceCodeResolver; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +final class JteRuntime implements ViewRuntimeBridge { + private static final String GLOBAL_NAMESPACE = "global"; + + private final JteSettings settings; + private final List globals; + private final ConcurrentHashMap, JteTarget> targets = new ConcurrentHashMap<>(); + private final TemplateEngine engine; + + JteRuntime(JteSettings settings, List globals) { + this.settings = settings; + this.globals = globals; + this.engine = createEngine(settings); + } + + @Override + public JteTarget resolve(Class handlerClass) { + JteTarget cached = targets.get(handlerClass); + if (cached != null) return cached; + JteTarget resolved = JteTargetResolver.resolve(handlerClass, settings); + if (resolved == null) { + throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template"); + } + targets.put(handlerClass, resolved); + return resolved; + } + + @Override + public RenderedView render(BaseViewHandler handler, + JteTarget target, + Request req, + Response res) throws Exception { + ViewModel local = handler.renderInternal(req, res); + Map merged = merge(req, local).toMap(); + StringOutput out = new StringOutput(1024); + engine.render(target.template(), merged, out); + return new RenderedView(out.toString(), target.contentType()); + } + + private ViewModel merge(Request req, ViewModel local) { + LinkedHashMap values = null; + + if (!globals.isEmpty()) { + LinkedHashMap globalMap = new LinkedHashMap<>(); + for (GlobalValue binding : globals) { + Object resolved = binding.resolver().apply(req); + globalMap.put(binding.key(), ViewModel.unwrapValue(resolved)); + } + if (!globalMap.isEmpty()) { + values = new LinkedHashMap<>(); + values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap)); + } + } + + if (local != null) { + Map 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); + } + + private static TemplateEngine createEngine(JteSettings settings) { + TemplateEngine engine; + if (settings.usePrecompiled()) { + Path path = settings.precompiledClassesPath(); + engine = Files.isDirectory(path) + ? TemplateEngine.createPrecompiled(path, settings.contentType()) + : TemplateEngine.createPrecompiled(settings.contentType()); + } else { + CodeResolver resolver = createResolver(settings.templateRoot(), Thread.currentThread().getContextClassLoader()); + engine = TemplateEngine.create(resolver, settings.dynamicClassesPath(), settings.contentType()); + } + engine.setBinaryStaticContent(settings.binaryStaticContent()); + return engine; + } + + private static CodeResolver createResolver(String templateRoot, ClassLoader classLoader) { + String root = templateRoot.startsWith("/") ? templateRoot.substring(1) : templateRoot; + Path maybeDir = Path.of(root); + if (Files.isDirectory(maybeDir)) { + return new DirectoryCodeResolver(maybeDir); + } + return new ResourceCodeResolver(root, classLoader); + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java new file mode 100644 index 0000000..f0b8365 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java @@ -0,0 +1,109 @@ +package dev.relism.ext.view.jte; + +import dev.relism.Flash; + +import java.nio.file.Path; + +/** + * jte runtime settings with Flash-sensitive defaults. + */ +public final class JteSettings { + private final String templateRoot; + private final gg.jte.ContentType contentType; + private final boolean developmentMode; + private final boolean usePrecompiled; + private final boolean binaryStaticContent; + private final Path dynamicClassesPath; + private final Path precompiledClassesPath; + + private JteSettings(Builder b) { + this.templateRoot = b.templateRoot; + this.contentType = b.contentType; + this.developmentMode = b.developmentMode; + this.usePrecompiled = b.usePrecompiled; + this.binaryStaticContent = b.binaryStaticContent; + this.dynamicClassesPath = b.dynamicClassesPath; + this.precompiledClassesPath = b.precompiledClassesPath; + } + + public static Builder builder() { + return new Builder(); + } + + String templateRoot() { return templateRoot; } + gg.jte.ContentType contentType() { return contentType; } + boolean developmentMode() { return developmentMode; } + boolean usePrecompiled() { return usePrecompiled; } + boolean binaryStaticContent() { return binaryStaticContent; } + Path dynamicClassesPath() { return dynamicClassesPath; } + Path precompiledClassesPath() { return precompiledClassesPath; } + + public static final class Builder { + private String templateRoot = "/templates"; + private gg.jte.ContentType contentType = gg.jte.ContentType.Html; + private Boolean developmentMode; + private Boolean usePrecompiled; + private boolean binaryStaticContent; + private Path dynamicClassesPath = Path.of("jte-classes"); + private Path precompiledClassesPath = Path.of("jte-classes"); + + public Builder templateRoot(String templateRoot) { + String root = templateRoot == null ? "" : templateRoot.trim(); + if (root.isEmpty()) throw new IllegalArgumentException("templateRoot must not be blank"); + this.templateRoot = normalizeResourceRoot(root); + return this; + } + + public Builder contentType(gg.jte.ContentType contentType) { + this.contentType = java.util.Objects.requireNonNull(contentType, "contentType must not be null"); + return this; + } + + public Builder developmentMode(boolean developmentMode) { + this.developmentMode = developmentMode; + return this; + } + + public Builder usePrecompiled(boolean usePrecompiled) { + this.usePrecompiled = usePrecompiled; + return this; + } + + public Builder binaryStaticContent(boolean binaryStaticContent) { + this.binaryStaticContent = binaryStaticContent; + return this; + } + + public Builder dynamicClassesPath(Path dynamicClassesPath) { + this.dynamicClassesPath = java.util.Objects.requireNonNull(dynamicClassesPath, "dynamicClassesPath must not be null"); + return this; + } + + public Builder precompiledClassesPath(Path precompiledClassesPath) { + this.precompiledClassesPath = java.util.Objects.requireNonNull(precompiledClassesPath, "precompiledClassesPath must not be null"); + return this; + } + + public JteSettings build() { + boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV; + boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev; + Builder resolved = new Builder(); + resolved.templateRoot = this.templateRoot; + resolved.contentType = this.contentType; + resolved.developmentMode = resolvedDev; + resolved.usePrecompiled = resolvedPrecompiled; + resolved.binaryStaticContent = this.binaryStaticContent; + resolved.dynamicClassesPath = this.dynamicClassesPath; + resolved.precompiledClassesPath = this.precompiledClassesPath; + return new JteSettings(resolved); + } + + private static String normalizeResourceRoot(String root) { + String normalized = root.replace('\\', '/'); + if (!normalized.startsWith("/")) normalized = '/' + normalized; + while (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1); + if (normalized.isEmpty()) return "/"; + return normalized; + } + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java new file mode 100644 index 0000000..41c2a98 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java @@ -0,0 +1,5 @@ +package dev.relism.ext.view.jte; + +import dev.relism.http.ContentType; + +record JteTarget(String template, ContentType contentType) {} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java new file mode 100644 index 0000000..7cf33e4 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java @@ -0,0 +1,42 @@ +package dev.relism.ext.view.jte; + +import dev.relism.http.ContentType; +import dev.relism.routing.Route; +import dev.relism.routing.Routes; + +final class JteTargetResolver { + private JteTargetResolver() {} + + static JteTarget resolve(Class handlerClass, JteSettings settings) { + Template template = find(handlerClass, Template.class); + if (template == null) return null; + + Route route = Routes.of(handlerClass); + if (route == null) { + throw new IllegalStateException("Jte handler " + handlerClass.getName() + + " has @Template but no route annotation (@Route/@GET/@POST/...)"); + } + + String name = template.value() == null ? "" : template.value().trim(); + if (name.isEmpty()) { + throw new IllegalStateException("Handler " + handlerClass.getName() + + " route " + route.method() + " " + route.path() + + " uses @Template with an empty value"); + } + + return new JteTarget(name, toHttpContentType(settings.contentType())); + } + + private static ContentType toHttpContentType(gg.jte.ContentType contentType) { + return contentType == gg.jte.ContentType.Plain ? ContentType.TEXT_PLAIN : ContentType.TEXT_HTML; + } + + private static A find(Class cls, Class type) { + while (cls != null && !cls.equals(Object.class)) { + A a = cls.getAnnotation(type); + if (a != null) return a; + cls = cls.getSuperclass(); + } + return null; + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java new file mode 100644 index 0000000..54bbbd4 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java @@ -0,0 +1,15 @@ +package dev.relism.ext.view.jte; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Binds a handler to a jte template. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Template { + String value(); +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java new file mode 100644 index 0000000..e36f5aa --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java @@ -0,0 +1,79 @@ +package dev.relism.ext.view.jte; + +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 static org.junit.jupiter.api.Assertions.*; + +class JteExtensionTest { + + @GET("/ok") + @Template("pages/home.jte") + static class ValidHandler extends JteHandler { + @Override + public dev.relism.ext.view.core.ViewModel render(Request req) { + return dev.relism.ext.view.core.ViewModel.empty(); + } + } + + @GET("/wrong") + @Template("pages/home.jte") + static class WrongBaseHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/missing") + static class MissingTemplateHandler extends JteHandler {} + + @Test + void constructor_requiresNonNullCustomizer() { + assertThrows(NullPointerException.class, () -> new JteExtension(null)); + } + + @Test + void validate_acceptsProperHandler() throws Exception { + JteExtension ext = new JteExtension(); + invokeValidate(ext, ValidHandler.class); + } + + @Test + void validate_rejectsTemplateWithoutJteHandlerBase() { + JteExtension ext = new JteExtension(); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> invokeValidate(ext, WrongBaseHandler.class)); + + assertTrue(ex.getMessage().contains("does not extend JteHandler")); + } + + @Test + void validate_rejectsJteHandlerWithoutTemplate() { + JteExtension ext = new JteExtension(); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> invokeValidate(ext, MissingTemplateHandler.class)); + + assertTrue(ex.getMessage().contains("must declare @Template")); + } + + private static void invokeValidate(JteExtension ext, Class type) throws Exception { + Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class); + m.setAccessible(true); + try { + m.invoke(ext, type); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new RuntimeException(cause); + } + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java new file mode 100644 index 0000000..e889604 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java @@ -0,0 +1,68 @@ +package dev.relism.ext.view.jte; + +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.RenderedView; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.jte.model.HomePage; +import dev.relism.http.ContentType; +import dev.relism.models.Request; +import dev.relism.models.Response; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JteRuntimeTest { + + @Test + void render_template_rendersPojoAndGlobals() throws Exception { + JteRuntime runtime = new JteRuntime( + JteSettings.builder().templateRoot("/templates").developmentMode(true).build(), + List.of(new GlobalValue("appName", req -> "Flash")) + ); + + RenderedView out = runtime.render(new JteHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty() + .with("page", new HomePage("Flash + jte", "elorc")) + .with("build", "dev"); + } + }, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)); + + assertEquals(ContentType.TEXT_HTML, out.contentType()); + assertTrue(out.body().contains("Flash + jte")); + assertTrue(out.body().contains("elorc")); + assertTrue(out.body().contains("Flash")); + } + + @Test + void render_rejectsReservedGlobalKeyInLocalModel() { + JteRuntime runtime = new JteRuntime( + JteSettings.builder().templateRoot("/templates").developmentMode(true).build(), + List.of(new GlobalValue("appName", req -> "Flash")) + ); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + runtime.render(new JteHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.of("global", "bad"); + } + }, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)) + ); + + assertTrue(ex.getMessage().contains("reserved")); + } + + @Test + void resolve_missingTemplateOnHandler_failsFast() { + JteRuntime runtime = new JteRuntime(JteSettings.builder().templateRoot("/templates").developmentMode(true).build(), List.of()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runtime.resolve(NoTemplateHandler.class)); + assertTrue(ex.getMessage().contains("must declare @Template")); + } + + static class NoTemplateHandler extends JteHandler {} +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java new file mode 100644 index 0000000..bf59fb9 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java @@ -0,0 +1,69 @@ +package dev.relism.ext.view.jte; + +import dev.relism.Flash; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class JteSettingsTest { + + @Test + void defaults_followFlashDev_andUseHtml() { + JteSettings settings = JteSettings.builder().build(); + + assertEquals("/templates", settings.templateRoot()); + assertEquals(gg.jte.ContentType.Html, settings.contentType()); + assertEquals(Flash.DEV, settings.developmentMode()); + assertEquals(!Flash.DEV, settings.usePrecompiled()); + assertFalse(settings.binaryStaticContent()); + assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath()); + assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath()); + } + + @Test + void explicitOverrides_winOverDerivedDefaults() { + JteSettings settings = JteSettings.builder() + .templateRoot("src/main/jte") + .contentType(gg.jte.ContentType.Plain) + .developmentMode(true) + .usePrecompiled(true) + .binaryStaticContent(true) + .dynamicClassesPath(Path.of("var", "jte-dev")) + .precompiledClassesPath(Path.of("var", "jte-prod")) + .build(); + + assertEquals("/src/main/jte", settings.templateRoot()); + assertEquals(gg.jte.ContentType.Plain, settings.contentType()); + assertTrue(settings.developmentMode()); + assertTrue(settings.usePrecompiled()); + assertTrue(settings.binaryStaticContent()); + assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath()); + assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath()); + } + + @Test + void usePrecompiled_derivesFromResolvedDevModeWhenMissing() { + JteSettings dev = JteSettings.builder().developmentMode(true).build(); + JteSettings prod = JteSettings.builder().developmentMode(false).build(); + + assertFalse(dev.usePrecompiled()); + assertTrue(prod.usePrecompiled()); + } + + @Test + void templateRoot_normalizesAndRejectsBlank() { + JteSettings normalized = JteSettings.builder().templateRoot("templates").build(); + assertEquals("/templates", normalized.templateRoot()); + + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" ")); + } + + @Test + void nulls_areRejectedForRequiredObjects() { + assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null)); + assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null)); + assertThrows(NullPointerException.class, () -> JteSettings.builder().precompiledClassesPath(null)); + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java new file mode 100644 index 0000000..27b4ebd --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java @@ -0,0 +1,70 @@ +package dev.relism.ext.view.jte; + +import dev.relism.http.ContentType; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.Response; +import dev.relism.routing.GET; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class JteTargetResolverTest { + + @GET("/home") + @Template("pages/home.jte") + static class TemplateHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @Template("pages/no-route.jte") + static class NoRouteHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/empty") + @Template(" ") + static class EmptyTemplateHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @Test + void resolve_template_returnsTarget_withMirroredHttpContentType() { + JteSettings htmlSettings = JteSettings.builder().contentType(gg.jte.ContentType.Html).build(); + JteSettings plainSettings = JteSettings.builder().contentType(gg.jte.ContentType.Plain).build(); + + JteTarget html = JteTargetResolver.resolve(TemplateHandler.class, htmlSettings); + JteTarget plain = JteTargetResolver.resolve(TemplateHandler.class, plainSettings); + + assertNotNull(html); + assertEquals("pages/home.jte", html.template()); + assertEquals(ContentType.TEXT_HTML, html.contentType()); + assertEquals(ContentType.TEXT_PLAIN, plain.contentType()); + } + + @Test + void resolve_withoutTemplate_returnsNull() { + assertNull(JteTargetResolver.resolve(RequestHandler.class, JteSettings.builder().build())); + } + + @Test + void resolve_templateWithoutRoute_fails() { + assertThrows(IllegalStateException.class, + () -> JteTargetResolver.resolve(NoRouteHandler.class, JteSettings.builder().build())); + } + + @Test + void resolve_emptyTemplate_fails() { + assertThrows(IllegalStateException.class, + () -> JteTargetResolver.resolve(EmptyTemplateHandler.class, JteSettings.builder().build())); + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java new file mode 100644 index 0000000..d829be6 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java @@ -0,0 +1,3 @@ +package dev.relism.ext.view.jte.model; + +public record HomePage(String title, String author) {} diff --git a/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte b/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte new file mode 100644 index 0000000..2ea188d --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte @@ -0,0 +1,10 @@ +@import dev.relism.ext.view.jte.model.HomePage +@import java.util.Map +@param HomePage page +@param String build +@param Map global + +

${page.title()}

+

${page.author()}

+${build} +${(String) global.get("appName")} diff --git a/flash-extensions/flash-ext-view-thymeleaf/README.md b/flash-extensions/flash-ext-view-thymeleaf/README.md new file mode 100644 index 0000000..39ffe03 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/README.md @@ -0,0 +1,65 @@ +# flash-ext-view-thymeleaf + +Opinionated Thymeleaf SSR extension for Flash. + +This module keeps Thymeleaf semantics front and center: + +- `ThymeleafExtension` +- `ThymeleafHandler` +- `@Template` and `@Fragment` +- `ViewModel` from `flash-ext-view-core` +- `global.*` reserved namespace + +The extension uses Thymeleaf-native patterns (`template` names resolved by prefix/suffix, +fragment selection via `template :: fragment`) while keeping Flash boot-time fail-fast checks. + +## Quick Start + +```java +import dev.relism.extension.FlashApp; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.thymeleaf.*; + +FlashApp.create(8080) + .install(new ThymeleafExtension() + .addGlobal("appName", req -> "Flash") + .addGlobal("requestPath", req -> req.path())) + .scan("com.example.web") + .startAndBlock(); +``` + +```java +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.thymeleaf.*; +import dev.relism.routing.GET; + +@GET("/") +@Template("pages/home") +public final class HomePage extends ThymeleafHandler { + @Override + public ViewModel render(dev.relism.models.Request req) { + return ViewModel.of("title", "Home"); + } +} +``` + +## Fragment Example + +```java +@GET("/users/rows") +@Fragment(template = "fragments/users", value = "rows") +public final class UserRows extends ThymeleafHandler { + @Override + public ViewModel render(dev.relism.models.Request req) { + return ViewModel.of("users", List.of()); + } +} +``` + +## Docs + +- `docs/architecture.md` +- `docs/handlers.md` +- `docs/model-and-globals.md` +- `docs/fragments.md` +- `docs/performance.md` diff --git a/flash-extensions/flash-ext-view-thymeleaf/docs/architecture.md b/flash-extensions/flash-ext-view-thymeleaf/docs/architecture.md new file mode 100644 index 0000000..ab804c2 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/docs/architecture.md @@ -0,0 +1,23 @@ +# Architecture + +`flash-ext-view-thymeleaf` has two layers: + +1. **Boot-time** + - `ThymeleafExtension` installs runtime + annotation processor. + - `ThymeleafTargetResolver` validates handlers and resolves `@Template` / `@Fragment`. + - Resolved targets are cached per handler class. + +2. **Request-time** + - Handler builds local `ViewModel`. + - Runtime injects globals under reserved `global` namespace and merges local model. + - Thymeleaf renders template or fragment. + +## Handler Contract + +- Must extend `ThymeleafHandler`. +- Must have route annotation (`@Route`, `@GET`, `@POST`, ...). +- Must declare exactly one view annotation: + - `@Template` + - `@Fragment` + +Invalid configurations fail fast at startup. diff --git a/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md b/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md new file mode 100644 index 0000000..cc5c619 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md @@ -0,0 +1,16 @@ +# Fragments + +Use `@Fragment` for Thymeleaf fragment responses. + +```java +@GET("/users/table") +@Fragment(template = "fragments/users", value = "rows") +public final class UsersRows extends ThymeleafHandler { + @Override + public ViewModel render(dev.relism.models.Request req) { + return ViewModel.of("users", List.of()); + } +} +``` + +If `value` is blank, runtime uses the configured default fragment (default: `content`). diff --git a/flash-extensions/flash-ext-view/docs/handlers.md b/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md similarity index 69% rename from flash-extensions/flash-ext-view/docs/handlers.md rename to flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md index df1bb14..874729a 100644 --- a/flash-extensions/flash-ext-view/docs/handlers.md +++ b/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md @@ -1,6 +1,6 @@ # Handlers -Use `ViewHandler` for class-based SSR routes. +Use `ThymeleafHandler` for class-based Thymeleaf routes. ## Lifecycle @@ -13,8 +13,8 @@ Use `onViewInit()` to cache dependencies via `require(...)`. ```java @GET("/dashboard") -@Page("pages/dashboard") -public final class DashboardPage extends ViewHandler { +@Template("pages/dashboard") +public final class DashboardPage extends ThymeleafHandler { private DashboardService service; @@ -32,4 +32,4 @@ public final class DashboardPage extends ViewHandler { } ``` -Use `render(Request, Response)` only when you need response access while building the model. +Use `render(Request, Response)` when you need response access while building model variables. diff --git a/flash-extensions/flash-ext-view/docs/model-and-globals.md b/flash-extensions/flash-ext-view-thymeleaf/docs/model-and-globals.md similarity index 84% rename from flash-extensions/flash-ext-view/docs/model-and-globals.md rename to flash-extensions/flash-ext-view-thymeleaf/docs/model-and-globals.md index 0598d80..3ede391 100644 --- a/flash-extensions/flash-ext-view/docs/model-and-globals.md +++ b/flash-extensions/flash-ext-view-thymeleaf/docs/model-and-globals.md @@ -1,6 +1,6 @@ # Model and Globals -`ViewModel` is the per-request model builder. +`ViewModel` (from `flash-ext-view-core`) is the per-request model builder. ## Merge Order @@ -16,7 +16,7 @@ Handlers cannot set top-level `global`; runtime throws fail-fast to prevent name Register globals on extension setup: ```java -new ViewExtension(ViewEngineType.THYMELEAF) +new ThymeleafExtension() .addGlobal("appName", req -> "Flash") .addGlobal("path", req -> req.path()); ``` diff --git a/flash-extensions/flash-ext-view/docs/performance.md b/flash-extensions/flash-ext-view-thymeleaf/docs/performance.md similarity index 68% rename from flash-extensions/flash-ext-view/docs/performance.md rename to flash-extensions/flash-ext-view-thymeleaf/docs/performance.md index c4804ca..229305d 100644 --- a/flash-extensions/flash-ext-view/docs/performance.md +++ b/flash-extensions/flash-ext-view-thymeleaf/docs/performance.md @@ -1,12 +1,13 @@ # Performance -`flash-ext-view` is optimized for low overhead on request path. +`flash-ext-view-thymeleaf` 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. +- Thymeleaf target metadata resolved and cached per handler class. +- No engine-agnostic adapter indirection. ## Best practices diff --git a/flash-extensions/flash-ext-view/pom.xml b/flash-extensions/flash-ext-view-thymeleaf/pom.xml similarity index 85% rename from flash-extensions/flash-ext-view/pom.xml rename to flash-extensions/flash-ext-view-thymeleaf/pom.xml index 7f3b584..a12783f 100644 --- a/flash-extensions/flash-ext-view/pom.xml +++ b/flash-extensions/flash-ext-view-thymeleaf/pom.xml @@ -7,10 +7,10 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 - flash-ext-view + flash-ext-view-thymeleaf 0.8.12 @@ -19,20 +19,13 @@ dev.relism - flash + flash-ext-view-core - org.thymeleaf thymeleaf 3.1.2.RELEASE - true diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java new file mode 100644 index 0000000..010f18f --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java @@ -0,0 +1,16 @@ +package dev.relism.ext.view.thymeleaf; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Binds a handler to a Thymeleaf fragment render target. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Fragment { + String template(); + String value() default ""; +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java new file mode 100644 index 0000000..f7730c1 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java @@ -0,0 +1,15 @@ +package dev.relism.ext.view.thymeleaf; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Binds a handler to a Thymeleaf template. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Template { + String value(); +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java new file mode 100644 index 0000000..23561dc --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java @@ -0,0 +1,63 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.ext.view.core.BaseViewExtension; +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.ViewRuntimeBridge; +import dev.relism.models.RequestHandler; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Opinionated Thymeleaf SSR extension for Flash. + */ +public class ThymeleafExtension extends BaseViewExtension { + private final ThymeleafSettings settings; + + static { + ensureThymeleafPresent(); + } + + public ThymeleafExtension() { + this(ThymeleafSettings.builder().build()); + } + + public ThymeleafExtension(Consumer customizer) { + ThymeleafSettings.Builder builder = ThymeleafSettings.builder(); + java.util.Objects.requireNonNull(customizer, "customizer must not be null").accept(builder); + this.settings = builder.build(); + } + + private ThymeleafExtension(ThymeleafSettings settings) { + this.settings = settings; + } + + @Override + public ThymeleafExtension addGlobal(String key, java.util.function.Function resolver) { + super.addGlobal(key, resolver); + return this; + } + + @Override + protected ViewRuntimeBridge createRuntime(List globals) { + return new ThymeleafRuntime(settings, globals); + } + + @Override + protected void validateHandlerClass(Class handlerClass) { + ThymeleafTarget target = ThymeleafTargetResolver.resolve(handlerClass, settings); + if (target == null) return; + if (!ThymeleafHandler.class.isAssignableFrom(handlerClass)) { + throw new IllegalStateException("Handler " + handlerClass.getName() + + " declares @Template/@Fragment but does not extend ThymeleafHandler"); + } + } + + private static void ensureThymeleafPresent() { + try { + Class.forName("org.thymeleaf.TemplateEngine", false, ThymeleafExtension.class.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Thymeleaf is not on the classpath. Add dependency org.thymeleaf:thymeleaf:3.1.2.RELEASE", e); + } + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java new file mode 100644 index 0000000..46fcd6d --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java @@ -0,0 +1,8 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.ext.view.core.BaseViewHandler; + +/** + * Base class for class-based Thymeleaf handlers. + */ +public abstract class ThymeleafHandler extends BaseViewHandler {} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java new file mode 100644 index 0000000..163ab99 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java @@ -0,0 +1,159 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.ext.view.core.BaseViewHandler; +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.RenderedView; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.ext.view.core.ViewRuntimeBridge; +import dev.relism.models.Request; +import dev.relism.models.Response; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.context.IExpressionContext; +import org.thymeleaf.linkbuilder.ILinkBuilder; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +final class ThymeleafRuntime implements ViewRuntimeBridge { + private static final String GLOBAL_NAMESPACE = "global"; + + private final ThymeleafSettings settings; + private final List globals; + private final ConcurrentHashMap, ThymeleafTarget> targets = new ConcurrentHashMap<>(); + private final TemplateEngine engine; + + ThymeleafRuntime(ThymeleafSettings settings, List globals) { + this.settings = settings; + this.globals = globals; + this.engine = createEngine(settings); + } + + @Override + public ThymeleafTarget resolve(Class handlerClass) { + ThymeleafTarget cached = targets.get(handlerClass); + if (cached != null) return cached; + ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(handlerClass, settings); + if (resolved == null) { + throw new IllegalStateException("ThymeleafHandler " + handlerClass.getName() + + " must declare @Template or @Fragment"); + } + targets.put(handlerClass, resolved); + return resolved; + } + + @Override + public RenderedView render(BaseViewHandler handler, + ThymeleafTarget target, + Request req, + Response res) throws Exception { + ViewModel local = handler.renderInternal(req, res); + ViewModel merged = merge(req, local); + + Context ctx = new Context(); + for (Map.Entry e : merged.toMap().entrySet()) { + ctx.setVariable(e.getKey(), e.getValue()); + } + + String output; + if (target.kind() == ThymeleafTarget.Kind.TEMPLATE) { + output = engine.process(target.template(), ctx); + } else { + output = engine.process(target.template(), Set.of(target.fragment()), ctx); + } + + return new RenderedView(output, target.contentType()); + } + + private ViewModel merge(Request req, ViewModel local) { + LinkedHashMap values = null; + + if (!globals.isEmpty()) { + LinkedHashMap globalMap = new LinkedHashMap<>(); + for (GlobalValue 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)); + } + } + + if (local != null) { + Map 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); + } + + private static TemplateEngine createEngine(ThymeleafSettings settings) { + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix(settings.prefix()); + resolver.setSuffix(settings.suffix()); + resolver.setTemplateMode(settings.mode()); + resolver.setCharacterEncoding("UTF-8"); + resolver.setCacheable(settings.cacheEnabled()); + + TemplateEngine engine = new TemplateEngine(); + engine.setTemplateResolver(resolver); + engine.addLinkBuilder(FlashLinkBuilder.INSTANCE); + return engine; + } + + 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 params) { + if (base == null) return ""; + String url = expandPathVars(base, params); + return appendQueryString(url, base, params); + } + + private static String expandPathVars(String base, Map params) { + if (params == null || params.isEmpty() || !base.contains("{")) return base; + String result = base; + for (Map.Entry 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; + } + + private static String appendQueryString(String url, String base, Map params) { + if (params == null || params.isEmpty()) return url; + StringBuilder qs = new StringBuilder(); + for (Map.Entry 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"); + } + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java new file mode 100644 index 0000000..c782e13 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java @@ -0,0 +1,76 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.Flash; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * Thymeleaf resolver/engine defaults tuned for Flash. + */ +public final class ThymeleafSettings { + private final String prefix; + private final String suffix; + private final TemplateMode mode; + private final boolean cacheEnabled; + private final String defaultFragment; + + private ThymeleafSettings(Builder b) { + this.prefix = b.prefix; + this.suffix = b.suffix; + this.mode = b.mode; + this.cacheEnabled = b.cacheEnabled; + this.defaultFragment = b.defaultFragment; + } + + public static Builder builder() { + return new Builder(); + } + + String prefix() { return prefix; } + String suffix() { return suffix; } + TemplateMode mode() { return mode; } + boolean cacheEnabled() { return cacheEnabled; } + String defaultFragment() { return defaultFragment; } + + public static final class Builder { + private String prefix = "/templates/"; + private String suffix = ".html"; + private TemplateMode mode = TemplateMode.HTML; + private boolean cacheEnabled = !Flash.DEV; + private String defaultFragment = "content"; + + public Builder prefix(String prefix) { + String p = prefix == null ? "" : prefix.trim(); + if (p.isEmpty()) throw new IllegalArgumentException("prefix must not be blank"); + this.prefix = p.endsWith("/") ? p : p + '/'; + return this; + } + + public Builder suffix(String suffix) { + String s = suffix == null ? "" : suffix.trim(); + if (s.isEmpty()) throw new IllegalArgumentException("suffix must not be blank"); + this.suffix = s.startsWith(".") ? s : "." + s; + return this; + } + + public Builder mode(TemplateMode mode) { + this.mode = java.util.Objects.requireNonNull(mode, "mode must not be null"); + return this; + } + + public Builder cacheEnabled(boolean enabled) { + this.cacheEnabled = enabled; + return this; + } + + public Builder defaultFragment(String fragment) { + String f = fragment == null ? "" : fragment.trim(); + if (f.isEmpty()) throw new IllegalArgumentException("default fragment must not be blank"); + this.defaultFragment = f; + return this; + } + + public ThymeleafSettings build() { + return new ThymeleafSettings(this); + } + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java new file mode 100644 index 0000000..fb3473a --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java @@ -0,0 +1,15 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.http.ContentType; + +record ThymeleafTarget( + Kind kind, + String template, + String fragment, + ContentType contentType +) { + enum Kind { + TEMPLATE, + FRAGMENT + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java new file mode 100644 index 0000000..fa231d5 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java @@ -0,0 +1,58 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.http.ContentType; +import dev.relism.routing.Route; +import dev.relism.routing.Routes; + +final class ThymeleafTargetResolver { + private ThymeleafTargetResolver() {} + + static ThymeleafTarget resolve(Class handlerClass, ThymeleafSettings settings) { + Template template = find(handlerClass, Template.class); + Fragment fragment = find(handlerClass, Fragment.class); + + int count = (template != null ? 1 : 0) + (fragment != null ? 1 : 0); + if (count == 0) return null; + + Route route = Routes.of(handlerClass); + if (route == null) { + throw new IllegalStateException("Thymeleaf handler " + handlerClass.getName() + + " has view annotation but no route annotation (@Route/@GET/@POST/...)"); + } + + if (count > 1) { + throw new IllegalStateException("Handler " + handlerClass.getName() + + " route " + route.method() + " " + route.path() + + " declares multiple Thymeleaf view annotations. Use exactly one of @Template, @Fragment"); + } + + if (template != null) { + String name = value(template.value(), "@Template", handlerClass, route); + return new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, name, "", ContentType.TEXT_HTML); + } + + String name = value(fragment.template(), "@Fragment(template)", handlerClass, route); + String frag = fragment.value() == null ? "" : fragment.value().trim(); + if (frag.isEmpty()) frag = settings.defaultFragment(); + return new ThymeleafTarget(ThymeleafTarget.Kind.FRAGMENT, name, frag, ContentType.TEXT_HTML); + } + + private static String value(String v, String field, Class handler, Route route) { + String value = v == null ? "" : v.trim(); + if (value.isEmpty()) { + throw new IllegalStateException("Handler " + handler.getName() + + " route " + route.method() + " " + route.path() + + " uses " + field + " with an empty template value"); + } + return value; + } + + private static
A find(Class cls, Class type) { + while (cls != null && !cls.equals(Object.class)) { + A a = cls.getAnnotation(type); + if (a != null) return a; + cls = cls.getSuperclass(); + } + return null; + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java new file mode 100644 index 0000000..e1b58bc --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java @@ -0,0 +1,92 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.ext.view.core.ViewModel; +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 static org.junit.jupiter.api.Assertions.*; + +class ThymeleafExtensionTest { + + @GET("/ok") + @Template("pages/home") + static class ValidTemplateHandler extends ThymeleafHandler { + @Override + public ViewModel render(Request req) { + return ViewModel.of("title", "ok"); + } + } + + @GET("/fragment") + @Fragment(template = "pages/home", value = "rows") + static class ValidFragmentHandler extends ThymeleafHandler { + @Override + public ViewModel render(Request req) { + return ViewModel.of("id", 1); + } + } + + @GET("/wrong") + @Template("pages/home") + static class WrongBaseHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/missing") + static class MissingViewAnnotationHandler extends ThymeleafHandler {} + + @Test + void constructor_requiresNonNullCustomizer() { + assertThrows(NullPointerException.class, () -> new ThymeleafExtension(null)); + } + + @Test + void addGlobal_isFluent() { + ThymeleafExtension ext = new ThymeleafExtension(); + assertSame(ext, ext.addGlobal("app", req -> "Flash")); + } + + @Test + void validate_acceptsTemplateAndFragmentHandlers() throws Exception { + ThymeleafExtension ext = new ThymeleafExtension(); + invokeValidate(ext, ValidTemplateHandler.class); + invokeValidate(ext, ValidFragmentHandler.class); + } + + @Test + void validate_rejectsTemplateWithoutThymeleafBase() { + ThymeleafExtension ext = new ThymeleafExtension(); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> invokeValidate(ext, WrongBaseHandler.class)); + + assertTrue(ex.getMessage().contains("does not extend ThymeleafHandler")); + } + + @Test + void validate_allowsThymeleafBaseWithoutViewAnnotation() throws Exception { + ThymeleafExtension ext = new ThymeleafExtension(); + invokeValidate(ext, MissingViewAnnotationHandler.class); + } + + private static void invokeValidate(ThymeleafExtension ext, Class type) throws Exception { + Method m = ThymeleafExtension.class.getDeclaredMethod("validateHandlerClass", Class.class); + m.setAccessible(true); + try { + m.invoke(ext, type); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error err) throw err; + throw new RuntimeException(cause); + } + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java new file mode 100644 index 0000000..1f5c61e --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java @@ -0,0 +1,153 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.ext.view.core.GlobalValue; +import dev.relism.ext.view.core.RenderedView; +import dev.relism.ext.view.core.ViewModel; +import dev.relism.http.ContentType; +import dev.relism.models.Request; +import dev.relism.models.Response; +import org.junit.jupiter.api.Test; +import org.thymeleaf.templatemode.TemplateMode; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ThymeleafRuntimeTest { + + @Test + void render_template_resolvesTemplateAndLinks() throws Exception { + ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of()); + + RenderedView out = runtime.render(new ThymeleafHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty().with("title", "Home").with("id", 42).with("page", 2); + } + }, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/home", "", ContentType.TEXT_HTML), 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_fragment_usesExplicitFragment() throws Exception { + ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of()); + + RenderedView out = runtime.render(new ThymeleafHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty().with("id", 42); + } + }, new ThymeleafTarget(ThymeleafTarget.Kind.FRAGMENT, "pages/home", "rows", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("row-42")); + } + + @Test + void render_mergesGlobalsInReservedNamespace() throws Exception { + ThymeleafRuntime runtime = new ThymeleafRuntime( + ThymeleafSettings.builder().cacheEnabled(false).build(), + List.of(new GlobalValue("appName", req -> "Flash"), new GlobalValue("path", req -> "/x")) + ); + + RenderedView out = runtime.render(new ThymeleafHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.of("title", "Home"); + } + }, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/with-globals", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("Flash")); + assertTrue(out.body().contains("/x")); + } + + @Test + void render_rejectsLocalGlobalKeyOverride() { + ThymeleafRuntime runtime = new ThymeleafRuntime( + ThymeleafSettings.builder().cacheEnabled(false).build(), + List.of(new GlobalValue("appName", req -> "Flash")) + ); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + runtime.render(new ThymeleafHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.of("global", "bad"); + } + }, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/home", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)) + ); + + assertTrue(ex.getMessage().contains("reserved")); + } + + @Test + void render_template_withoutParams_linkBuilderCoversEmptyBranches() throws Exception { + ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of()); + + RenderedView out = runtime.render(new ThymeleafHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.of("title", "NoParams"); + } + }, new ThymeleafTarget(ThymeleafTarget.Kind.TEMPLATE, "pages/link-cases", "", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("/static")); + assertTrue(out.body().contains("/users/")); + assertTrue(out.body().contains("/search")); + } + + @Test + void resolve_cachesTargetInstance() { + ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of()); + + ThymeleafTarget first = runtime.resolve(CachedTemplateHandler.class); + ThymeleafTarget second = runtime.resolve(CachedTemplateHandler.class); + + assertTrue(first == second); + } + + @Test + void resolve_missingViewAnnotation_fails() { + ThymeleafRuntime runtime = new ThymeleafRuntime(ThymeleafSettings.builder().cacheEnabled(false).build(), List.of()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runtime.resolve(MissingViewHandler.class)); + assertTrue(ex.getMessage().contains("must declare @Template or @Fragment")); + } + + @Test + void builderBranches_coverPrefixSuffixFragmentModeAndCacheFlag() { + ThymeleafSettings settings = ThymeleafSettings.builder() + .prefix("templates") + .suffix("html") + .mode(TemplateMode.HTML) + .cacheEnabled(false) + .defaultFragment("rows") + .build(); + + assertEquals("templates/", settings.prefix()); + assertEquals(".html", settings.suffix()); + assertEquals("rows", settings.defaultFragment()); + assertFalse(settings.cacheEnabled()); + } + + @dev.relism.routing.GET("/cached") + @Template("pages/home") + static class CachedTemplateHandler extends ThymeleafHandler { + @Override + public ViewModel render(Request req) { + return ViewModel.of("title", "cached").with("id", 1).with("page", 1); + } + } + + static class MissingViewHandler extends ThymeleafHandler { + @Override + public ViewModel render(Request req) { + return ViewModel.empty(); + } + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java new file mode 100644 index 0000000..9546a70 --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java @@ -0,0 +1,46 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.Flash; +import org.junit.jupiter.api.Test; +import org.thymeleaf.templatemode.TemplateMode; + +import static org.junit.jupiter.api.Assertions.*; + +class ThymeleafSettingsTest { + + @Test + void defaults_matchFlashConventions() { + ThymeleafSettings settings = ThymeleafSettings.builder().build(); + + assertEquals("/templates/", settings.prefix()); + assertEquals(".html", settings.suffix()); + assertEquals(TemplateMode.HTML, settings.mode()); + assertEquals(!Flash.DEV, settings.cacheEnabled()); + assertEquals("content", settings.defaultFragment()); + } + + @Test + void builder_normalizesPrefixAndSuffix() { + ThymeleafSettings settings = ThymeleafSettings.builder() + .prefix("templates") + .suffix("jte") + .mode(TemplateMode.TEXT) + .cacheEnabled(true) + .defaultFragment("rows") + .build(); + + assertEquals("templates/", settings.prefix()); + assertEquals(".jte", settings.suffix()); + assertEquals(TemplateMode.TEXT, settings.mode()); + assertTrue(settings.cacheEnabled()); + assertEquals("rows", settings.defaultFragment()); + } + + @Test + void builder_rejectsBlankValuesAndNullMode() { + assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().prefix(" ")); + assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().suffix(" ")); + assertThrows(IllegalArgumentException.class, () -> ThymeleafSettings.builder().defaultFragment(" ")); + assertThrows(NullPointerException.class, () -> ThymeleafSettings.builder().mode(null)); + } +} diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java new file mode 100644 index 0000000..d72d59f --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java @@ -0,0 +1,87 @@ +package dev.relism.ext.view.thymeleaf; + +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.Response; +import dev.relism.routing.GET; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ThymeleafTargetResolverTest { + + @GET("/home") + @Template("pages/home") + static class TemplateHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/partial") + @Fragment(template = "pages/home", value = "rows") + static class FragmentHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/partial-default") + @Fragment(template = "pages/home") + static class FragmentDefaultHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/bad") + @Template("a") + @Fragment(template = "b") + static class ConflictingHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @Template("pages/no-route") + static class NoRouteHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @Test + void resolve_template_returnsTemplateTarget() { + ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(TemplateHandler.class, ThymeleafSettings.builder().build()); + + assertNotNull(resolved); + assertEquals(ThymeleafTarget.Kind.TEMPLATE, resolved.kind()); + assertEquals("pages/home", resolved.template()); + } + + @Test + void resolve_fragment_withoutValue_usesDefaultFragment() { + ThymeleafTarget resolved = ThymeleafTargetResolver.resolve(FragmentDefaultHandler.class, ThymeleafSettings.builder().defaultFragment("content").build()); + + assertNotNull(resolved); + assertEquals(ThymeleafTarget.Kind.FRAGMENT, resolved.kind()); + assertEquals("content", resolved.fragment()); + } + + @Test + void resolve_multipleViewAnnotations_fails() { + assertThrows(IllegalStateException.class, + () -> ThymeleafTargetResolver.resolve(ConflictingHandler.class, ThymeleafSettings.builder().build())); + } + + @Test + void resolve_viewAnnotationWithoutRoute_fails() { + assertThrows(IllegalStateException.class, + () -> ThymeleafTargetResolver.resolve(NoRouteHandler.class, ThymeleafSettings.builder().build())); + } +} diff --git a/flash-extensions/flash-ext-view/src/test/resources/templates/pages/home.html b/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/home.html similarity index 100% rename from flash-extensions/flash-ext-view/src/test/resources/templates/pages/home.html rename to flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/home.html diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/link-cases.html b/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/link-cases.html new file mode 100644 index 0000000..a2e62de --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/link-cases.html @@ -0,0 +1,8 @@ + + + +plain +pathvar-null +query-null + + diff --git a/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/with-globals.html b/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/with-globals.html new file mode 100644 index 0000000..b33823c --- /dev/null +++ b/flash-extensions/flash-ext-view-thymeleaf/src/test/resources/templates/pages/with-globals.html @@ -0,0 +1,7 @@ + + + +app +path + + diff --git a/flash-extensions/flash-ext-view/README.md b/flash-extensions/flash-ext-view/README.md deleted file mode 100644 index 36d0c1e..0000000 --- a/flash-extensions/flash-ext-view/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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` diff --git a/flash-extensions/flash-ext-view/docs/adapters.md b/flash-extensions/flash-ext-view/docs/adapters.md deleted file mode 100644 index 091c059..0000000 --- a/flash-extensions/flash-ext-view/docs/adapters.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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 model, - Request req, - Response res) { - String body = "..."; - return RenderOutput.html(body); - } -} -``` - -Adapter instances must be thread-safe after construction. diff --git a/flash-extensions/flash-ext-view/docs/architecture.md b/flash-extensions/flash-ext-view/docs/architecture.md deleted file mode 100644 index 06c81af..0000000 --- a/flash-extensions/flash-ext-view/docs/architecture.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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. diff --git a/flash-extensions/flash-ext-view/docs/partials.md b/flash-extensions/flash-ext-view/docs/partials.md deleted file mode 100644 index f85e498..0000000 --- a/flash-extensions/flash-ext-view/docs/partials.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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. diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/EngineCapabilities.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/EngineCapabilities.java deleted file mode 100644 index 5e06b6d..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/EngineCapabilities.java +++ /dev/null @@ -1,6 +0,0 @@ -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); -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/GlobalBinding.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/GlobalBinding.java deleted file mode 100644 index 5ec7321..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/GlobalBinding.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.ext.view; - -import dev.relism.models.Request; - -import java.util.function.Function; - -record GlobalBinding(String key, Function resolver) {} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Page.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Page.java deleted file mode 100644 index a488042..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Page.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.relism.ext.view; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Binds a class-based route handler to a full-page template. - * - *

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(); -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Partial.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Partial.java deleted file mode 100644 index 2fbe223..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Partial.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.relism.ext.view; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Binds a class-based route handler to a partial template render. - * - *

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 ""; -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/RenderOutput.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/RenderOutput.java deleted file mode 100644 index e442bf9..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/RenderOutput.java +++ /dev/null @@ -1,16 +0,0 @@ -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); - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java deleted file mode 100644 index 9f4574d..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java +++ /dev/null @@ -1,160 +0,0 @@ -package dev.relism.ext.view; - -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; -import java.util.Set; - -/** - * Thymeleaf 3.x adapter for Flash SSR views. - * - *

Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}. - * - *

Default configuration

- *
    - *
  • Prefix : {@code /templates/} (classpath-relative)
  • - *
  • Suffix : {@code .html}
  • - *
  • Mode : {@link TemplateMode#HTML}
  • - *
  • Encoding: UTF-8
  • - *
  • Cache : enabled in production, disabled in dev mode - * ({@code flash.env=dev} or {@code FLASH_ENV=dev})
  • - *
- * - *

Link building

- * 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. - * - *

Model conventions

- *
    - *
  • {@link Map} model → each entry is a named Thymeleaf variable.
  • - *
  • Any other non-null value → registered under the key {@code "it"}.
  • - *
  • {@code null} model → empty context.
  • - *
- */ -final class ThymeleafEngine implements ViewEngineAdapter { - - private static final String PREFIX = "/templates/"; - private static final String SUFFIX = ".html"; - 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 EngineCapabilities capabilities() { - return new EngineCapabilities(true); - } - - @Override - public RenderOutput render(ViewTarget target, Map model, - dev.relism.models.Request req, - dev.relism.models.Response res) { - String template = target.template(); - if (target.kind() == ViewKind.PAGE) { - Context ctx = new Context(); - populateContext(ctx, model); - return RenderOutput.html(engine.process(template, ctx)); - } else { - String slot = target.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)); - } - } - - 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. - * - *

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: - *

    - *
  • Path variables — {@code @{/posts/{id}(id=${post.id})}} → {@code /posts/abc}
  • - *
  • Query params — {@code @{/search(q=${term})}} → {@code /search?q=hello}
  • - *
  • Mixed — {@code @{/posts/{id}(id=x,p=2)}} → {@code /posts/x?p=2}
  • - *
- * 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 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 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 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"); - } - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineAdapter.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineAdapter.java deleted file mode 100644 index d993784..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineAdapter.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.relism.ext.view; - -import dev.relism.models.Request; -import dev.relism.models.Response; - -import java.util.Map; - -/** - * Rendering adapter contract used by the Flash SSR view runtime. - * - *

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 model, Request req, Response res) throws Exception; -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java deleted file mode 100644 index 4d89926..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java +++ /dev/null @@ -1,80 +0,0 @@ -package dev.relism.ext.view; - -/** - * Managed template engine types supported out-of-the-box by {@link ViewExtension}. - * - *

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. - * - *

{@code
- * // Zero-boilerplate — Thymeleaf auto-configured with defaults
- * app.install(new ViewExtension(ViewEngineType.THYMELEAF));
- * }
- * - *

Dev mode

- * Template caching is disabled when either: - *
    - *
  • the JVM property {@code flash.env} equals {@code dev} (case-insensitive), or
  • - *
  • the environment variable {@code FLASH_ENV} equals {@code dev}.
  • - *
- * In all other cases caching is enabled (production default). - * - *

Adding your own engine

- * For unsupported engines, implement {@link ViewEngineAdapter} and pass it to - * {@link ViewExtension#ViewExtension(ViewEngineAdapter)}. - */ -public enum ViewEngineType { - - /** - * Thymeleaf 3.x — natural HTML templates with server-side rendering. - * - *

Required dependency (add to your {@code pom.xml}): - *

{@code
-     * 
-     *     org.thymeleaf
-     *     thymeleaf
-     *     3.1.2.RELEASE
-     * 
-     * }
- * - * Default resolver: classpath, prefix {@code /templates/}, suffix {@code .html}, - * mode {@code HTML}, encoding UTF-8. - */ - THYMELEAF; - - // ── Factory ─────────────────────────────────────────────────────────────── - - /** - * 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 - */ - ViewEngineAdapter createAdapter(boolean cacheEnabled) { - return switch (this) { - case THYMELEAF -> createThymeleaf(cacheEnabled); - }; - } - - // ── Engine factories ────────────────────────────────────────────────────── - - private static ViewEngineAdapter 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: - - - org.thymeleaf - thymeleaf - 3.1.2.RELEASE - - """, e); - } - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java deleted file mode 100644 index 477be9e..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java +++ /dev/null @@ -1,66 +0,0 @@ -package dev.relism.ext.view; - -import dev.relism.Flash; -import dev.relism.extension.FlashContext; -import dev.relism.extension.FlashExtension; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.Function; - -/** - * Installs server-side view rendering for class-based handlers. - * - *

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 List globals = new ArrayList<>(); - - public ViewExtension(ViewEngineType type) { - this(type.createAdapter(!Flash.DEV)); - } - - public ViewExtension(ViewEngineAdapter adapter) { - this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null"); - } - - /** - * Registers one request-scoped global value under {@code global.}. - * - *

This is the only supported global registration API. Keep resolvers fast and side-effect free. - */ - public ViewExtension addGlobal(String key, Function 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; - } - - @Override - public void provide(FlashContext ctx) { - ViewRuntime runtime = new ViewRuntime(adapter, List.copyOf(globals)); - ctx.provide(ViewEngineAdapter.class, adapter); - ctx.provide(ViewRuntime.class, runtime); - - // Processor kept for boot-time contract enforcement. Rendering itself stays in ViewHandler. - ctx.addAnnotationProcessor(handlerClass -> { - ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities()); - if (resolved == null) return List.of(); - - if (!ViewHandler.class.isAssignableFrom(handlerClass)) { - throw new IllegalStateException("Handler " + handlerClass.getName() - + " declares @Page/@Partial but does not extend ViewHandler"); - } - return List.of(); - }); - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewHandler.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewHandler.java deleted file mode 100644 index cb412ac..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewHandler.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.relism.ext.view; - -import dev.relism.models.Request; -import dev.relism.models.RequestHandler; -import dev.relism.models.Response; - -/** - * Base class for class-based SSR handlers. - * - *

Subclass contract: - *

    - *
  1. Declare exactly one of {@link Page} or {@link Partial} on the class hierarchy.
  2. - *
  3. Cache dependencies in {@link #onViewInit()} (one-time, boot-time).
  4. - *
  5. Build per-request model in {@link #render(Request)} or {@link #render(Request, Response)}.
  6. - *
- */ -public abstract class ViewHandler extends RequestHandler { - private ViewRuntime runtime; - private ViewTargetResolver.ResolvedView resolved; - - /** - * Per-request model hook. - * - *

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. - * - *

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); - resolved = runtime.resolve(getClass()); - onViewInit(); - } - - /** - * One-time initialization hook invoked after view metadata resolution. - * - *

Use this to cache services via {@link #require(Class)}. Do not perform request-bound - * work here. - */ - protected void onViewInit() {} - - @Override - public final Object handle(Request request, Response response) throws Exception { - return runtime.render(this, resolved, request, response); - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewKind.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewKind.java deleted file mode 100644 index d2f18b3..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewKind.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.ext.view; - -/** Render mode for resolved handler view targets. */ -public enum ViewKind { - PAGE, - PARTIAL -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewRuntime.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewRuntime.java deleted file mode 100644 index f557505..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewRuntime.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.relism.ext.view; - -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; - -/** - * Internal runtime for view target resolution and per-request rendering. - * - *

All expensive reflection is done once and cached per handler class. - */ -final class ViewRuntime { - private static final String GLOBAL_NAMESPACE = "global"; - - private final ViewEngineAdapter adapter; - private final List globals; - private final ConcurrentHashMap, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>(); - - ViewRuntime(ViewEngineAdapter adapter, List globals) { - this.adapter = adapter; - this.globals = globals; - } - - ViewTargetResolver.ResolvedView resolve(Class handlerClass) { - ViewTargetResolver.ResolvedView cached = resolvedCache.get(handlerClass); - if (cached != null) return cached; - ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities()); - if (resolved == null) { - throw new IllegalStateException("ViewHandler " + handlerClass.getName() - + " must declare @Page or @Partial"); - } - resolvedCache.put(handlerClass, resolved); - return resolved; - } - - Object render(ViewHandler handler, ViewTargetResolver.ResolvedView resolved, Request req, Response res) throws Exception { - if (resolved == null) { - throw new IllegalStateException("ViewHandler " + handler.getClass().getName() - + " must declare @Page or @Partial"); - } - - 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; - } - - ViewModel merge(Request req, ViewModel local) { - // Single-pass merge: reserved global namespace + local model. - // We avoid intermediate ViewModel allocations on the hot path. - LinkedHashMap values = null; - - if (!globals.isEmpty()) { - LinkedHashMap 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)); - } - } - - 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); - } -} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTarget.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTarget.java deleted file mode 100644 index a3a976d..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTarget.java +++ /dev/null @@ -1,14 +0,0 @@ -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, - String slot -) {} diff --git a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTargetResolver.java b/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTargetResolver.java deleted file mode 100644 index e51d8e3..0000000 --- a/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTargetResolver.java +++ /dev/null @@ -1,70 +0,0 @@ -package dev.relism.ext.view; - -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() {} - - static ResolvedView resolve(Class handlerClass, EngineCapabilities capabilities) { - Page page = find(handlerClass, Page.class); - Partial partial = find(handlerClass, Partial.class); - - int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0); - if (count == 0) return null; - - Route route = Routes.of(handlerClass); - if (route == null) { - throw new IllegalStateException("View handler " + handlerClass.getName() - + " has view annotation but no route annotation (@Route/@GET/@POST/...)"); - } - - if (count > 1) { - throw new IllegalStateException("Handler " + handlerClass.getName() - + " route " + route.method() + " " + route.path() - + " declares multiple view annotations. Use exactly one of @Page, @Partial"); - } - - if (page != null) { - 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, template, slot), ContentType.TEXT_HTML); - } - - return null; - } - - private static A find(Class cls, Class type) { - while (cls != null && !cls.equals(Object.class)) { - A a = cls.getAnnotation(type); - if (a != null) return a; - cls = cls.getSuperclass(); - } - return null; - } - - record ResolvedView(ViewTarget target, ContentType contentType) {} -} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ThymeleafEngineTest.java b/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ThymeleafEngineTest.java deleted file mode 100644 index cd89b9d..0000000 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ThymeleafEngineTest.java +++ /dev/null @@ -1,61 +0,0 @@ -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()); - } -} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewExtensionContractTest.java b/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewExtensionContractTest.java deleted file mode 100644 index 01b34f3..0000000 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewExtensionContractTest.java +++ /dev/null @@ -1,112 +0,0 @@ -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 processors(FlashContext ctx) { - try { - Method m = FlashContext.class.getDeclaredMethod("processors"); - m.setAccessible(true); - return ((List) 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 model, - Request req, - Response res) { - return RenderOutput.html(""); - } - } -} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewHandlerLifecycleTest.java b/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewHandlerLifecycleTest.java deleted file mode 100644 index c0ec348..0000000 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewHandlerLifecycleTest.java +++ /dev/null @@ -1,81 +0,0 @@ -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 model, - Request req, - Response res) { - return RenderOutput.html(String.valueOf(model.get("title"))); - } - } -} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewRuntimeGlobalsTest.java b/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewRuntimeGlobalsTest.java deleted file mode 100644 index 4c4aaee..0000000 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewRuntimeGlobalsTest.java +++ /dev/null @@ -1,155 +0,0 @@ -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_globalsAreNestedUnderReservedNamespace() { - ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of( - APP, - PATH - )); - - ViewModel merged = runtime.merge(null, ViewModel.of("title", "dashboard")); - - 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( - new GlobalBinding("signedIn", req -> true) - )); - - ViewModel merged = runtime.merge(null, null); - - 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 { - @Override - public EngineCapabilities capabilities() { - return EngineCapabilities.NONE; - } - - @Override - public RenderOutput render(ViewTarget target, java.util.Map model, - dev.relism.models.Request req, - dev.relism.models.Response res) { - 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 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 model, - dev.relism.models.Request req, - dev.relism.models.Response res) { - return new RenderOutput("", dev.relism.http.ContentType.TEXT_PLAIN); - } - } -} diff --git a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewTargetResolverTest.java b/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewTargetResolverTest.java deleted file mode 100644 index bb33cd0..0000000 --- a/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewTargetResolverTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package dev.relism.ext.view; - -import dev.relism.models.Request; -import dev.relism.models.RequestHandler; -import dev.relism.models.Response; -import dev.relism.routing.GET; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class ViewTargetResolverTest { - - @GET("/home") - @Page("pages/home") - static class PageHandler extends RequestHandler { - @Override - public Object handle(Request request, Response response) { - return null; - } - } - - @GET("/partial") - @Partial(template = "fragments/row", slot = "row") - static class SlotPartialHandler extends RequestHandler { - @Override - public Object handle(Request request, Response response) { - return null; - } - } - - @GET("/partial-default-slot") - @Partial(template = "fragments/card") - static class DefaultSlotPartialHandler extends RequestHandler { - @Override - public Object handle(Request request, Response response) { - return null; - } - } - - @GET("/bad") - @Page("a") - @Partial(template = "b") - static class ConflictingHandler extends RequestHandler { - @Override - public Object handle(Request request, Response response) { - return null; - } - } - - @Page("pages/no-route") - static class NoRouteHandler extends RequestHandler { - @Override - public Object handle(Request request, Response response) { - return null; - } - } - - @Test - void resolve_page_returnsPageTarget() { - ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(PageHandler.class, new EngineCapabilities(true)); - - assertNotNull(resolved); - assertEquals(ViewKind.PAGE, resolved.target().kind()); - assertEquals("pages/home", resolved.target().template()); - } - - @Test - void resolve_slotPartial_withoutCapability_failsFast() { - assertThrows(IllegalStateException.class, - () -> ViewTargetResolver.resolve(SlotPartialHandler.class, EngineCapabilities.NONE)); - } - - @Test - void resolve_partial_withoutSlot_defaultsToEmptySlot() { - ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(DefaultSlotPartialHandler.class, new EngineCapabilities(true)); - - assertNotNull(resolved); - 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 - void resolve_multipleViewAnnotations_fails() { - assertThrows(IllegalStateException.class, - () -> ViewTargetResolver.resolve(ConflictingHandler.class, new EngineCapabilities(true))); - } - - @Test - void resolve_viewAnnotationWithoutRoute_fails() { - assertThrows(IllegalStateException.class, - () -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true))); - } - - @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))); - } -} diff --git a/flash-extensions/flash-web-bundler/pom.xml b/flash-extensions/flash-web-bundler/pom.xml index 57fe221..e425d0d 100644 --- a/flash-extensions/flash-web-bundler/pom.xml +++ b/flash-extensions/flash-web-bundler/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-extensions - 1.1-indev5 + 1.1-indev6 flash-web-bundler diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index bb79a62..0cb6949 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-parent - 1.1-indev5 + 1.1-indev6 flash-extensions @@ -18,7 +18,9 @@ flash-ext-openapi flash-ext-oidc flash-ext-routeviewer - flash-ext-view + flash-ext-view-core + flash-ext-view-jte + flash-ext-view-thymeleaf flash-ext-limiter flash-web-bundler @@ -30,6 +32,11 @@ flash-ext-jackson ${project.version} + + dev.relism + flash-ext-view-jte + ${project.version} + com.fasterxml.jackson.core jackson-databind diff --git a/flash/pom.xml b/flash/pom.xml index f8f8366..f9e102a 100644 --- a/flash/pom.xml +++ b/flash/pom.xml @@ -7,7 +7,7 @@ dev.relism flash-parent - 1.1-indev5 + 1.1-indev6 flash diff --git a/flash/src/main/java/dev/relism/Flash.java b/flash/src/main/java/dev/relism/Flash.java index c32c400..513976c 100644 --- a/flash/src/main/java/dev/relism/Flash.java +++ b/flash/src/main/java/dev/relism/Flash.java @@ -10,7 +10,7 @@ package dev.relism; *

Dev mode enables: *

* *

Extensions can read {@link #DEV} to branch behavior without re-implementing the detection: diff --git a/flash/src/main/java/dev/relism/extension/FlashScope.java b/flash/src/main/java/dev/relism/extension/FlashScope.java index 892733f..c9da165 100644 --- a/flash/src/main/java/dev/relism/extension/FlashScope.java +++ b/flash/src/main/java/dev/relism/extension/FlashScope.java @@ -31,7 +31,7 @@ public final class FlashScope extends FlashRegistrar { private final List deferredRoutes = new ArrayList<>(); FlashScope(String namespace, FlashContext parentCtx) { - this.namespace = namespace; + this.namespace = PathUtils.sanitize(namespace); this.ctx = parentCtx.child(); } @@ -54,5 +54,5 @@ public final class FlashScope extends FlashRegistrar { List routes() { return deferredRoutes; } - private String ns(String path) { return namespace + PathUtils.sanitize(path); } + private String ns(String path) { return PathUtils.join(namespace, path); } } diff --git a/flash/src/test/java/dev/relism/routing/PathUtilsTest.java b/flash/src/test/java/dev/relism/routing/PathUtilsTest.java index 177a7a3..a4fd52e 100644 --- a/flash/src/test/java/dev/relism/routing/PathUtilsTest.java +++ b/flash/src/test/java/dev/relism/routing/PathUtilsTest.java @@ -39,6 +39,7 @@ class PathUtilsTest { void join_withRootBase_returnsSanitizedPath() { assertEquals("/users", PathUtils.join("/", "/users/")); assertEquals("/users", PathUtils.join("/", "users")); + assertEquals("/blogs", PathUtils.join("/", "/blogs")); } @Test diff --git a/pom.xml b/pom.xml index 2a7ee1d..b43f0b9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.relism flash-parent - 1.1-indev5 + 1.1-indev6 pom @@ -66,7 +66,17 @@ dev.relism - flash-ext-view + flash-ext-view-core + ${project.version} + + + dev.relism + flash-ext-view-jte + ${project.version} + + + dev.relism + flash-ext-view-thymeleaf ${project.version}