diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/.idea/encodings.xml b/.idea/encodings.xml index 420c512..ff14d95 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -15,6 +15,12 @@ + + + + + + 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/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/docs/annotation.md b/flash-extensions/flash-ext-limiter/docs/annotation.md index 8523a7a..35006d1 100644 --- a/flash-extensions/flash-ext-limiter/docs/annotation.md +++ b/flash-extensions/flash-ext-limiter/docs/annotation.md @@ -92,6 +92,9 @@ public class PriceHandler extends RequestHandler { ... } Each annotation is processed by its own processor; Flash collects all middleware and composes them in processor registration order. +When `flash-ext-openapi` is installed, `@Limit` also contributes OpenAPI response +headers (`X-RateLimit-*`) and `Retry-After` on `429` automatically. + ```java @Route(method = HttpMethod.DELETE, path = "/admin/users/{id}") @Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES) diff --git a/flash-extensions/flash-ext-limiter/docs/http-headers.md b/flash-extensions/flash-ext-limiter/docs/http-headers.md index 1d91443..42f7772 100644 --- a/flash-extensions/flash-ext-limiter/docs/http-headers.md +++ b/flash-extensions/flash-ext-limiter/docs/http-headers.md @@ -117,6 +117,12 @@ and **instead of** calling it on rejected requests. This means: ## Integration with Swagger UI (flash-ext-openapi) -Rate-limit headers are not currently injected into the OpenAPI spec. If you want to -document them, add them manually via `@ApiOperation` on the handler class using the -response headers section of the OpenAPI spec. +When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically +contribute rate-limit response headers to generated OpenAPI responses: + +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` +- `Retry-After` on `429` + +If `429` is not manually declared, OpenAPI auto-adds `429 Too Many Requests`. diff --git a/flash-extensions/flash-ext-limiter/pom.xml b/flash-extensions/flash-ext-limiter/pom.xml index f339e5a..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 @@ -17,6 +17,11 @@ dev.relism flash + + dev.relism + flash-ext-openapi + true + org.projectlombok lombok diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java index c1b5e0e..896e43b 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java @@ -1,13 +1,18 @@ package dev.relism.ext.limiter; +import dev.relism.ext.openapi.OpenApiContributorRegistry; +import dev.relism.ext.openapi.OpenApiOperationContribution; +import dev.relism.ext.openapi.OpenApiResponseContribution; import dev.relism.extension.ExtensionPhase; import dev.relism.extension.FlashContext; import dev.relism.extension.FlashExtension; +import dev.relism.extension.FlashRegistrar; import dev.relism.http.HttpStatus; import dev.relism.routing.Middleware; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; /** * Rate-limiting extension for Flash. @@ -87,6 +92,15 @@ public final class LimiterExtension implements FlashExtension { }); } + @Override + public void routes(FlashRegistrar app, FlashContext ctx) { + try { + OpenApiIntegration.register(ctx); + } catch (NoClassDefFoundError ignored) { + // flash-ext-openapi not available — OpenAPI integration disabled + } + } + // ── Package-private helper — shared with Guard ──────────────────────────── /** @@ -126,4 +140,57 @@ public final class LimiterExtension implements FlashExtension { return next.handle(req, res); }; } + + private static final class OpenApiIntegration { + private static final Map INTEGER_SCHEMA = Map.of("type", "integer"); + private static final Map LIMIT_HEADER = Map.of( + "description", "Maximum requests allowed in current window", + "schema", INTEGER_SCHEMA + ); + private static final Map REMAINING_HEADER = Map.of( + "description", "Requests remaining in current window", + "schema", INTEGER_SCHEMA + ); + private static final Map RESET_HEADER = Map.of( + "description", "Unix epoch seconds when quota resets or next token arrives", + "schema", INTEGER_SCHEMA + ); + private static final Map RETRY_AFTER_HEADER = Map.of( + "description", "Seconds to wait before retrying", + "schema", INTEGER_SCHEMA + ); + + static void register(FlashContext ctx) { + ctx.find(OpenApiContributorRegistry.class) + .ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() { + @Override + public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class handlerClass) { + if (handlerClass.getAnnotation(Limit.class) == null) { + return OpenApiOperationContribution.builder().build(); + } + + OpenApiResponseContribution common = + OpenApiResponseContribution.builder() + .header("X-RateLimit-Limit", LIMIT_HEADER) + .header("X-RateLimit-Remaining", REMAINING_HEADER) + .header("X-RateLimit-Reset", RESET_HEADER) + .build(); + + OpenApiResponseContribution tooManyRequests = + OpenApiResponseContribution.builder() + .description("Too Many Requests") + .header("X-RateLimit-Limit", LIMIT_HEADER) + .header("X-RateLimit-Remaining", REMAINING_HEADER) + .header("X-RateLimit-Reset", RESET_HEADER) + .header("Retry-After", RETRY_AFTER_HEADER) + .build(); + + return OpenApiOperationContribution.builder() + .allResponses(common) + .response(429, tooManyRequests) + .build(); + } + })); + } + } } diff --git a/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java new file mode 100644 index 0000000..37b5aa4 --- /dev/null +++ b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java @@ -0,0 +1,84 @@ +package dev.relism.ext.limiter; + +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.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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LimiterOpenApiInteropTest { + + @GET("/limited") + @Limit(requests = 10, window = 1) + static class LimitedHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @GET("/plain") + static class PlainHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + @Test + void registersContributor_whenOpenApiRegistryExists() { + FlashContext ctx = new FlashContext(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + ctx.provide(OpenApiContributorRegistry.class, registry); + + new LimiterExtension().routes(null, ctx); + + assertEquals(1, registry.contributors().size()); + } + + @Test + void limitedHandler_contributesHeadersAnd429() { + FlashContext ctx = new FlashContext(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + ctx.provide(OpenApiContributorRegistry.class, registry); + new LimiterExtension().routes(null, ctx); + + OpenApiContributor contributor = registry.contributors().getFirst(); + OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class); + + OpenApiResponseContribution all = operation.allResponses(); + assertNotNull(all); + assertTrue(all.headers().containsKey("X-RateLimit-Limit")); + assertTrue(all.headers().containsKey("X-RateLimit-Remaining")); + assertTrue(all.headers().containsKey("X-RateLimit-Reset")); + + OpenApiResponseContribution tooMany = operation.responses().get(429); + assertNotNull(tooMany); + assertEquals("Too Many Requests", tooMany.description()); + assertTrue(tooMany.headers().containsKey("Retry-After")); + } + + @Test + void plainHandler_hasNoContribution() { + FlashContext ctx = new FlashContext(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + ctx.provide(OpenApiContributorRegistry.class, registry); + new LimiterExtension().routes(null, ctx); + + OpenApiContributor contributor = registry.contributors().getFirst(); + OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class); + + assertTrue(operation.isEmpty()); + assertFalse(operation.responses().containsKey(429)); + } +} 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 a019bf1..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. @@ -263,62 +264,61 @@ public class OidcExtension implements FlashExtension { /** * Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at - * runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath. + * 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.OpenApiSecurityRegistry.class) - .ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() { - - @Override - public String schemeName() { return config.schemeName(); } - - @Override - public java.util.Map schemeDefinition() { - java.util.Map scopesMap = new java.util.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()); - flow.put("scopes", scopesMap); - - java.util.Map scheme = new java.util.LinkedHashMap<>(); - scheme.put("type", "oauth2"); - scheme.put("flows", java.util.Map.of("authorizationCode", flow)); - return scheme; - } + ctx.find(OpenApiContributorRegistry.class) + .ifPresent(registry -> registry.add(new OpenApiContributor() { @Override - public java.util.List requiredFor(Class handlerClass) { - return OidcAuthPolicy.openApiScopesFor(handlerClass); + public Map componentContributions() { + Map scopesMap = new LinkedHashMap<>(); + for (String s : config.scopes().split("\\s+")) { + if (!s.isBlank()) scopesMap.put(s, s); + } + Map flow = new LinkedHashMap<>(); + flow.put("authorizationUrl", meta.authorizationEndpoint()); + flow.put("tokenUrl", meta.tokenEndpoint()); + flow.put("scopes", scopesMap); + + Map scheme = new LinkedHashMap<>(); + scheme.put("type", "oauth2"); + scheme.put("flows", Map.of("authorizationCode", flow)); + + Map securitySchemes = new LinkedHashMap<>(); + securitySchemes.put(config.schemeName(), scheme); + return Map.of("securitySchemes", securitySchemes); } - @Override - public java.util.Map autoResponsesFor(Class handlerClass) { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); - if (policy == null || policy.optionalAuth()) return java.util.Map.of(); + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + OpenApiOperationContribution.Builder out = + OpenApiOperationContribution.builder(); - java.util.LinkedHashMap out = new java.util.LinkedHashMap<>(); - out.put(401, "Authentication required"); + List operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass); + if (operationScopes != null) { + out.security(config.schemeName(), operationScopes); + } + + OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); + if (policy == null || policy.optionalAuth()) return out.build(); + + out.response(401, OpenApiResponseContribution.of("Authentication required")); String[] roles = policy.requiredRoles(); String[] scopes = policy.requiredScopes(); - if (roles.length == 0 && scopes.length == 0) return out; + if (roles.length == 0 && scopes.length == 0) return out.build(); String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles); String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes); if (roleMessage != null && scopeMessage != null) { - out.put(403, roleMessage + "; " + scopeMessage); - } else if (roleMessage != null) { - out.put(403, roleMessage); - } else { - out.put(403, scopeMessage); - } - return out; - } + 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-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java index 1035d51..e199d04 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java +++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java @@ -1,10 +1,11 @@ package dev.relism.ext.oidc; -import dev.relism.ext.openapi.OpenApiSecurityContributor; +import dev.relism.ext.openapi.OpenApiContributor; import org.junit.jupiter.api.Test; import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -37,49 +38,57 @@ class OidcOpenApiInteropTest { @Test void autoResponses_authOnly() throws Exception { - Map responses = contributor().autoResponsesFor(AuthOnly.class); + Map responses = responses(AuthOnly.class); assertEquals("Authentication required", responses.get(401)); assertFalse(responses.containsKey(403)); } @Test void autoResponses_optionalAuth_addsNothing() throws Exception { - Map responses = contributor().autoResponsesFor(AuthOptional.class); + Map responses = responses(AuthOptional.class); assertTrue(responses.isEmpty()); } @Test void autoResponses_oneRole_formatsSingular() throws Exception { - Map responses = contributor().autoResponsesFor(OneRole.class); + Map responses = responses(OneRole.class); assertEquals("Authentication required", responses.get(401)); assertEquals("\"admin\" role required", responses.get(403)); } @Test void autoResponses_multiRoles_formatsPlural() throws Exception { - Map responses = contributor().autoResponsesFor(MultiRole.class); + Map responses = responses(MultiRole.class); assertEquals("Roles \"admin, operator\" are required", responses.get(403)); } @Test void autoResponses_oneScope_formatsSingular() throws Exception { - Map responses = contributor().autoResponsesFor(OneScope.class); + Map responses = responses(OneScope.class); assertEquals("\"orders:write\" scope required", responses.get(403)); } @Test void autoResponses_multiScopes_formatsPlural() throws Exception { - Map responses = contributor().autoResponsesFor(MultiScope.class); + Map responses = responses(MultiScope.class); assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403)); } @Test void autoResponses_roleAndScope_combinesMessages() throws Exception { - Map responses = contributor().autoResponsesFor(RoleAndScope.class); + Map responses = responses(RoleAndScope.class); assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403)); } - private static OpenApiSecurityContributor contributor() throws Exception { + @Test + void securityContribution_presentForAuthenticatedHandler() throws Exception { + dev.relism.ext.openapi.OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class); + List>> security = operation.security(); + assertEquals(1, security.size()); + assertTrue(security.getFirst().containsKey("issuer")); + } + + private static OpenApiContributor contributor() throws Exception { Class clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration"); Constructor ctor = clazz.getDeclaredConstructor(); ctor.setAccessible(true); @@ -89,8 +98,8 @@ class OidcOpenApiInteropTest { m.setAccessible(true); dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext(); - dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry(); - ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry); + dev.relism.ext.openapi.OpenApiContributorRegistry registry = new dev.relism.ext.openapi.OpenApiContributorRegistry(); + ctx.provide(dev.relism.ext.openapi.OpenApiContributorRegistry.class, registry); OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build(); OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e"); @@ -98,4 +107,13 @@ class OidcOpenApiInteropTest { return registry.contributors().getFirst(); } + + private static Map responses(Class cls) throws Exception { + Map byCode = contributor().operationFor(cls).responses(); + java.util.LinkedHashMap out = new java.util.LinkedHashMap<>(); + for (Map.Entry e : byCode.entrySet()) { + out.put(e.getKey(), e.getValue().description()); + } + return out; + } } diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index fe096ae..b49159c 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -105,6 +105,22 @@ Supported field-level exclusion: - `@JsonIgnoreProperties(...)` - `transient` / `static` +## Contributor API + +OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via +`OpenApiContributorRegistry`. + +Supported contribution surfaces: + +- `components` fragments (merged with last-wins) +- operation `security` requirements (additive) +- operation `responses` and response `headers` (additive) + +Merge policy: + +- contributor collisions use **last-wins** +- manual `@APIResponse` description always wins over contributors for the same status + ## OIDC interop When `flash-ext-oidc` is installed, OpenAPI integrates automatically: @@ -117,6 +133,18 @@ When `flash-ext-oidc` is installed, OpenAPI integrates automatically: Manual `@APIResponse` for the same status code always wins. +## Limiter interop + +When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response +headers documented in OpenAPI: + +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` +- `Retry-After` on `429` + +If `429` is missing, it is auto-added as `Too Many Requests`. + ## Notes - Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. 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/ApiResponse.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java similarity index 100% rename from flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java rename to flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java similarity index 100% rename from flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java rename to flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java 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 76f278b..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 @@ -51,7 +51,7 @@ public final class OpenApiBuilder { private final Map> paths = new LinkedHashMap<>(); private final Map>> operationHandlers = new LinkedHashMap<>(); private final SchemaRegistry schemas = new SchemaRegistry(); - private OpenApiSecurityRegistry securityRegistry; + private OpenApiContributorRegistry contributorRegistry; private int revision; private int builtRevision = -1; private Map cachedSpec; @@ -59,7 +59,7 @@ public final class OpenApiBuilder { public OpenApiBuilder title(String title) { this.title = title; return this; } public OpenApiBuilder version(String version) { this.version = version; return this; } public OpenApiBuilder description(String description) { this.description = description; return this; } - void setSecurityRegistry(OpenApiSecurityRegistry registry) { this.securityRegistry = registry; } + void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } public void addOperation(Route route, ApiOperation op, Class handlerClass) { String path = normalizePath(route.path()); @@ -90,8 +90,8 @@ public final class OpenApiBuilder { info.put("version", version); if (!description.isEmpty()) info.put("description", description); - List contributors = securityRegistry != null - ? securityRegistry.contributors() : List.of(); + List contributors = contributorRegistry != null + ? contributorRegistry.contributors() : List.of(); Map renderedPaths = new LinkedHashMap<>(); for (var pathEntry : paths.entrySet()) { @@ -103,8 +103,7 @@ public final class OpenApiBuilder { Map op = new LinkedHashMap<>(original); Class handler = handlers.get(methodEntry.getKey()); if (handler != null && !contributors.isEmpty()) { - List>> security = buildOperationSecurity(contributors, handler); - if (!security.isEmpty()) op.put("security", security); + applyContributorOperation(op, handler, contributors); } renderedPathItem.put(methodEntry.getKey(), op); } @@ -119,11 +118,7 @@ public final class OpenApiBuilder { Map components = new LinkedHashMap<>(); Map renderedSchemas = schemas.render(); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); - if (!contributors.isEmpty()) { - Map securitySchemes = new LinkedHashMap<>(); - for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition()); - components.put("securitySchemes", securitySchemes); - } + if (!contributors.isEmpty()) applyContributorComponents(components, contributors); if (!components.isEmpty()) spec.put("components", components); cachedSpec = spec; @@ -181,16 +176,12 @@ public final class OpenApiBuilder { responseByCode.put(code, buildAnnotatedResponse(code, ann, cls)); } - for (OpenApiSecurityContributor c : securityContributors()) { - for (var auto : c.autoResponsesFor(cls).entrySet()) { - responseByCode.putIfAbsent(auto.getKey(), Map.of("description", auto.getValue())); - } - } - if (responseByCode.isEmpty()) { responseByCode.put(200, Map.of("description", "OK")); } + applyContributorResponses(responseByCode, cls); + Map responses = new LinkedHashMap<>(); responseByCode.entrySet().stream() .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) @@ -198,8 +189,97 @@ public final class OpenApiBuilder { op.put("responses", responses); } - private List securityContributors() { - return securityRegistry != null ? securityRegistry.contributors() : List.of(); + private List contributors() { + return contributorRegistry != null ? contributorRegistry.contributors() : List.of(); + } + + private void applyContributorResponses(Map> responseByCode, Class handlerClass) { + APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class); + Set manualStatusCodes = new HashSet<>(); + for (APIResponse ann : manual) { + manualStatusCodes.add(parseStatus(ann.responseCode())); + } + + for (OpenApiContributor contributor : contributors()) { + OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); + if (contribution == null) continue; + + for (Map.Entry entry : contribution.responses().entrySet()) { + int status = entry.getKey(); + OpenApiResponseContribution responseContribution = entry.getValue(); + if (responseContribution == null) continue; + + Map response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>()); + mergeContributorResponse(response, responseContribution, status, manualStatusCodes); + } + + OpenApiResponseContribution allResponses = contribution.allResponses(); + if (allResponses != null) { + for (Map.Entry> entry : responseByCode.entrySet()) { + mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes); + } + } + } + } + + private static void mergeContributorResponse(Map response, + OpenApiResponseContribution contribution, + int status, + Set manualStatusCodes) { + String desc = contribution.description(); + if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) { + response.put("description", desc); + } + + Map> headerContributions = contribution.headers(); + if (!headerContributions.isEmpty()) { + @SuppressWarnings("unchecked") + Map headers = (Map) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>()); + for (Map.Entry> h : headerContributions.entrySet()) { + headers.put(h.getKey(), new LinkedHashMap<>(h.getValue())); + } + } + + if (!response.containsKey("description")) { + response.put("description", defaultDescription(status)); + } + } + + private static void applyContributorComponents(Map components, List contributors) { + for (OpenApiContributor contributor : contributors) { + Map c = contributor.componentContributions(); + if (c == null || c.isEmpty()) continue; + deepMergeLastWins(components, c); + } + } + + @SuppressWarnings("unchecked") + private static void deepMergeLastWins(Map target, Map incoming) { + for (Map.Entry e : incoming.entrySet()) { + Object existing = target.get(e.getKey()); + Object value = e.getValue(); + if (existing instanceof Map em && value instanceof Map vm) { + Map merged = new LinkedHashMap<>((Map) em); + deepMergeLastWins(merged, (Map) vm); + target.put(e.getKey(), merged); + } else { + target.put(e.getKey(), value); + } + } + } + + private void applyContributorOperation(Map op, Class handlerClass, List contributors) { + for (OpenApiContributor contributor : contributors) { + OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); + if (contribution == null || contribution.isEmpty()) continue; + + if (!contribution.security().isEmpty()) { + @SuppressWarnings("unchecked") + List>> security = (List>>) op + .computeIfAbsent("security", __ -> new ArrayList<>()); + security.addAll(contribution.security()); + } + } } private Map buildAnnotatedResponse(int code, APIResponse ann, Class handlerClass) { @@ -264,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) { @@ -272,16 +356,6 @@ public final class OpenApiBuilder { return reason == null ? "" : reason; } - private static List>> buildOperationSecurity(List contributors, - Class handlerClass) { - List>> security = new ArrayList<>(); - for (OpenApiSecurityContributor c : contributors) { - List scopes = c.requiredFor(handlerClass); - if (scopes != null) security.add(Map.of(c.schemeName(), scopes)); - } - return security; - } - private static Class rawType(Type type) { if (type instanceof Class c) return c; if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) return c; diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java new file mode 100644 index 0000000..19c9d2a --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java @@ -0,0 +1,14 @@ +package dev.relism.ext.openapi; + +import java.util.Map; + +public interface OpenApiContributor { + + default Map componentContributions() { + return Map.of(); + } + + default OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.empty(); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java new file mode 100644 index 0000000..f3b5e30 --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java @@ -0,0 +1,17 @@ +package dev.relism.ext.openapi; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class OpenApiContributorRegistry { + private final List contributors = new CopyOnWriteArrayList<>(); + + public void add(OpenApiContributor contributor) { + contributors.add(contributor); + } + + public List contributors() { + return Collections.unmodifiableList(contributors); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java index 8c7ce8c..b7c52a2 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java @@ -65,12 +65,12 @@ public class OpenApiExtension implements FlashExtension { @Override public void provide(FlashContext ctx) { - OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description); - OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry(); + OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); - ctx.provide(OpenApiSecurityRegistry.class, secRegistry); - ctx.provide(OpenApiBuilder.class, builder); - builder.setSecurityRegistry(secRegistry); + ctx.provide(OpenApiContributorRegistry.class, registry); + ctx.provide(OpenApiBuilder.class, builder); + builder.setContributorRegistry(registry); // Collect operation metadata from final compiled routes. // This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec. diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java new file mode 100644 index 0000000..a0d9793 --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java @@ -0,0 +1,71 @@ +package dev.relism.ext.openapi; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class OpenApiOperationContribution { + private static final OpenApiOperationContribution EMPTY = new OpenApiOperationContribution(List.of(), Map.of(), null); + + private final List>> security; + private final Map responses; + private final OpenApiResponseContribution allResponses; + + private OpenApiOperationContribution(List>> security, + Map responses, + OpenApiResponseContribution allResponses) { + this.security = List.copyOf(security); + this.responses = Map.copyOf(responses); + this.allResponses = allResponses; + } + + public List>> security() { + return security; + } + + public Map responses() { + return responses; + } + + public OpenApiResponseContribution allResponses() { + return allResponses; + } + + public boolean isEmpty() { + return security.isEmpty() && responses.isEmpty() && allResponses == null; + } + + public static OpenApiOperationContribution empty() { + return EMPTY; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final List>> security = new ArrayList<>(); + private final Map responses = new LinkedHashMap<>(); + private OpenApiResponseContribution allResponses; + + public Builder security(String schemeName, List scopes) { + security.add(Map.of(schemeName, List.copyOf(scopes))); + return this; + } + + public Builder response(int statusCode, OpenApiResponseContribution response) { + responses.put(statusCode, response); + return this; + } + + public Builder allResponses(OpenApiResponseContribution response) { + this.allResponses = response; + return this; + } + + public OpenApiOperationContribution build() { + return new OpenApiOperationContribution(security, responses, allResponses); + } + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java new file mode 100644 index 0000000..e1a73d7 --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java @@ -0,0 +1,49 @@ +package dev.relism.ext.openapi; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class OpenApiResponseContribution { + private final String description; + private final Map> headers; + + private OpenApiResponseContribution(String description, Map> headers) { + this.description = description; + this.headers = Map.copyOf(headers); + } + + public String description() { + return description; + } + + public Map> headers() { + return headers; + } + + public static Builder builder() { + return new Builder(); + } + + public static OpenApiResponseContribution of(String description) { + return builder().description(description).build(); + } + + public static final class Builder { + private String description; + private final Map> headers = new LinkedHashMap<>(); + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder header(String name, Map headerObject) { + headers.put(name, Map.copyOf(headerObject)); + return this; + } + + public OpenApiResponseContribution build() { + return new OpenApiResponseContribution(description, headers); + } + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java deleted file mode 100644 index c33aae5..0000000 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.relism.ext.openapi; - -import java.util.List; -import java.util.Map; - -/** - * Pluggable security scheme contributor for the OpenAPI spec. - * - *

Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement - * this interface and register an instance into {@link OpenApiSecurityRegistry} via the - * {@link dev.relism.extension.FlashContext}. {@link OpenApiExtension} picks it up - * at spec-generation time — no coupling between the two extensions at install time. - * - *

Multi-tenant: multiple contributors may coexist. For handlers secured by - * {@code @Authenticated}/{@code @RolesAllowed}, each matching contributor adds its - * own entry to the operation's {@code security} array (OpenAPI OR semantics). - */ -public interface OpenApiSecurityContributor { - - /** - * Unique scheme name used as a key in {@code components.securitySchemes} - * and referenced from each operation's {@code security} array. - */ - String schemeName(); - - /** - * The OpenAPI security scheme definition object placed under - * {@code components.securitySchemes.}. - * - *

Example for OIDC: - *

{@code
-     * Map.of("type", "openIdConnect",
-     *        "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
-     * }
- */ - Map schemeDefinition(); - - /** - * Returns the scopes/roles required for the given handler class under this scheme, - * or {@code null} if this contributor does not secure the handler. - * - *
    - *
  • {@code null} — handler is not secured by this contributor (skip)
  • - *
  • empty list — handler requires authentication, no specific scopes
  • - *
  • non-empty list — handler requires these scopes/roles
  • - *
- */ - List requiredFor(Class handlerClass); - - /** - * Optional auto-injected operation responses for handlers secured by this contributor. - * Key = HTTP status code, value = description. - */ - default Map autoResponsesFor(Class handlerClass) { - return Map.of(); - } -} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java deleted file mode 100644 index 0ed8f0e..0000000 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.relism.ext.openapi; - -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * Mutable registry of {@link OpenApiSecurityContributor}s. - * - *

Created and provided to the {@link dev.relism.extension.FlashContext} by - * {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc}) - * retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their - * contributor — the OpenAPI extension then picks it up lazily at spec-generation time. - * - *

Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec - * generation without blocking registration. - */ -public final class OpenApiSecurityRegistry { - - private final List contributors = new CopyOnWriteArrayList<>(); - - /** Registers a contributor. Safe to call concurrently. */ - public void add(OpenApiSecurityContributor contributor) { - contributors.add(contributor); - } - - /** Returns an unmodifiable snapshot of all registered contributors. */ - public List contributors() { - return Collections.unmodifiableList(contributors); - } -} diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java index 626bac3..b10fae0 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java @@ -88,6 +88,16 @@ class OpenApiBuilderTest { } } + @GET("/merge") + @ApiOperation(summary = "Merge") + @APIResponse(responseCode = "200", description = "Manual 200") + static class MergeHandler extends dev.relism.models.RequestHandler { + @Override + public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) { + return null; + } + } + @Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true) @JsonIgnoreProperties({"ignoredByType"}) static class UserDto { @@ -177,16 +187,23 @@ class OpenApiBuilderTest { @Test void auto_security_responses_are_added_and_manual_wins_sorted() { OpenApiBuilder b = new OpenApiBuilder(); - OpenApiSecurityRegistry registry = new OpenApiSecurityRegistry(); - registry.add(new OpenApiSecurityContributor() { - @Override public String schemeName() { return "oidc"; } - @Override public Map schemeDefinition() { return Map.of("type", "oauth2"); } - @Override public List requiredFor(Class handlerClass) { return List.of(); } - @Override public Map autoResponsesFor(Class handlerClass) { - return Map.of(401, "Authentication required", 403, "Auto forbidden"); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override + public Map componentContributions() { + return Map.of("securitySchemes", Map.of("oidc", Map.of("type", "oauth2"))); + } + + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .security("oidc", List.of()) + .response(401, OpenApiResponseContribution.of("Authentication required")) + .response(403, OpenApiResponseContribution.of("Auto forbidden")) + .build(); } }); - b.setSecurityRegistry(registry); + b.setContributorRegistry(registry); b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); Map spec = b.build(); @@ -250,6 +267,77 @@ class OpenApiBuilderTest { assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref")); } + @Test + void contributor_response_merge_appliesAllResponses_andManualDescriptionWins() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .allResponses(OpenApiResponseContribution.builder() + .header("X-Trace", Map.of("schema", Map.of("type", "string"))) + .build()) + .response(200, OpenApiResponseContribution.of("Auto 200")) + .response(429, OpenApiResponseContribution.of("Auto 429")) + .build(); + } + }); + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class); + + Map spec = b.build(); + Map get = getOperation(spec, "/merge", "get"); + Map responses = cast(get.get("responses")); + + Map resp200 = cast(responses.get("200")); + assertEquals("Manual 200", resp200.get("description")); + Map headers200 = cast(resp200.get("headers")); + assertTrue(headers200.containsKey("X-Trace")); + + Map resp429 = cast(responses.get("429")); + assertEquals("Auto 429", resp429.get("description")); + Map headers429 = cast(resp429.get("headers")); + assertTrue(headers429.containsKey("X-Trace")); + } + + @Test + void contributor_lastWins_forResponseHeaderCollisions() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .response(200, OpenApiResponseContribution.builder() + .header("X-RateLimit-Limit", Map.of("description", "old", "schema", Map.of("type", "integer"))) + .build()) + .build(); + } + }); + registry.add(new OpenApiContributor() { + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .response(200, OpenApiResponseContribution.builder() + .header("X-RateLimit-Limit", Map.of("description", "new", "schema", Map.of("type", "integer"))) + .build()) + .build(); + } + }); + + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class); + + Map spec = b.build(); + Map get = getOperation(spec, "/merge", "get"); + Map responses = cast(get.get("responses")); + Map resp200 = cast(responses.get("200")); + Map headers = cast(resp200.get("headers")); + Map header = cast(headers.get("X-RateLimit-Limit")); + assertEquals("new", header.get("description")); + } + private static Map getOperation(Map spec, String path, String method) { Map paths = cast(spec.get("paths")); Map pathItem = cast(paths.get(path)); 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-routeviewer/src/main/resources/routeviewer/index.html b/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html index 720f499..bab0ef8 100644 --- a/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html +++ b/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html @@ -1,13 +1,13 @@ - - - - - - Flash Route Viewer + + + + + + Flash Route Viewer - - -

- - + + +
+ + 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 0000000..7ef7715 Binary files /dev/null and b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class differ 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 12b70c3..0000000 --- a/flash-extensions/flash-ext-view/README.md +++ /dev/null @@ -1,55 +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` -- `docs/migration-from-legacy-view.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/migration-from-legacy-view.md b/flash-extensions/flash-ext-view/docs/migration-from-legacy-view.md deleted file mode 100644 index ee1418d..0000000 --- a/flash-extensions/flash-ext-view/docs/migration-from-legacy-view.md +++ /dev/null @@ -1,33 +0,0 @@ -# Migration from Legacy View API - -Legacy API (`@View`, `ViewEngine`, `Renderer`, `Template`) has been removed. - -## Replace annotations - -- `@View("page")` -> `@Page("page")` -- `@View(value = "x", fragment = true)` -> `@Partial(template = "x")` - -`@Page` and `@Partial` must be declared on classes extending `ViewHandler`. - -## Replace handler return contract - -Before (legacy): - -```java -public Object handle(Request req, Response res) { - return Map.of("name", "Flash"); -} -``` - -Now: - -```java -public ViewModel render(Request req) { - return ViewModel.of("name", "Flash"); -} -``` - -## Replace engine integration - -- Implement `ViewEngineAdapter` directly. -- Or use `ViewEngineType.THYMELEAF`. 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/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java index d9465ce..2dbedf6 100644 --- a/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ b/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java @@ -49,7 +49,7 @@ class WebBundlerExtensionIntegrationTest { .basePath("/app") .build(); - app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build()); + app = FlashApp.create(port); app.install(new WebBundlerExtension(config)); app.get("/api/ping", (req, res) -> "pong"); app.start(); 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}