Merge remote-tracking branch 'origin/master'
# Conflicts: # .idea/workspace.xml
This commit is contained in:
Generated
+6
@@ -15,6 +15,12 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
Generated
+5
@@ -13,6 +13,11 @@
|
|||||||
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="ignoredFiles">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
|||||||
@@ -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-jackson` | Jackson JSON integration |
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
| `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-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) |
|
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -139,6 +142,8 @@ See extension-specific READMEs for full details:
|
|||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/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
|
## Error handlers
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash-ext-jackson</artifactId>
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ public class PriceHandler extends RequestHandler { ... }
|
|||||||
Each annotation is processed by its own processor; Flash collects all middleware and
|
Each annotation is processed by its own processor; Flash collects all middleware and
|
||||||
composes them in processor registration order.
|
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
|
```java
|
||||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
||||||
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
|
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ and **instead of** calling it on rejected requests. This means:
|
|||||||
|
|
||||||
## Integration with Swagger UI (flash-ext-openapi)
|
## Integration with Swagger UI (flash-ext-openapi)
|
||||||
|
|
||||||
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to
|
When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
|
||||||
document them, add them manually via `@ApiOperation` on the handler class using the
|
contribute rate-limit response headers to generated OpenAPI responses:
|
||||||
response headers section of the OpenAPI spec.
|
|
||||||
|
- `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`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-limiter</artifactId>
|
<artifactId>flash-ext-limiter</artifactId>
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-openapi</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|||||||
+67
@@ -1,13 +1,18 @@
|
|||||||
package dev.relism.ext.limiter;
|
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.ExtensionPhase;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
|
import dev.relism.extension.FlashRegistrar;
|
||||||
import dev.relism.http.HttpStatus;
|
import dev.relism.http.HttpStatus;
|
||||||
import dev.relism.routing.Middleware;
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate-limiting extension for Flash.
|
* 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 ────────────────────────────
|
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,4 +140,57 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class OpenApiIntegration {
|
||||||
|
private static final Map<String, Object> INTEGER_SCHEMA = Map.of("type", "integer");
|
||||||
|
private static final Map<String, Object> LIMIT_HEADER = Map.of(
|
||||||
|
"description", "Maximum requests allowed in current window",
|
||||||
|
"schema", INTEGER_SCHEMA
|
||||||
|
);
|
||||||
|
private static final Map<String, Object> REMAINING_HEADER = Map.of(
|
||||||
|
"description", "Requests remaining in current window",
|
||||||
|
"schema", INTEGER_SCHEMA
|
||||||
|
);
|
||||||
|
private static final Map<String, Object> RESET_HEADER = Map.of(
|
||||||
|
"description", "Unix epoch seconds when quota resets or next token arrives",
|
||||||
|
"schema", INTEGER_SCHEMA
|
||||||
|
);
|
||||||
|
private static final Map<String, Object> 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();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+84
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-oidc</artifactId>
|
||||||
|
|||||||
+45
-45
@@ -1,5 +1,9 @@
|
|||||||
package dev.relism.ext.oidc;
|
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.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
import dev.relism.extension.FlashRegistrar;
|
||||||
@@ -13,10 +17,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.*;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
* 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
|
* 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 {
|
private static final class OpenApiIntegration {
|
||||||
static void register(dev.relism.extension.FlashContext ctx,
|
static void register(FlashContext ctx,
|
||||||
OidcConfig config, OidcProviderMetadata meta) {
|
OidcConfig config, OidcProviderMetadata meta) {
|
||||||
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class)
|
ctx.find(OpenApiContributorRegistry.class)
|
||||||
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() {
|
.ifPresent(registry -> registry.add(new OpenApiContributor() {
|
||||||
|
|
||||||
@Override
|
|
||||||
public String schemeName() { return config.schemeName(); }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.Map<String, Object> schemeDefinition() {
|
|
||||||
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
|
|
||||||
for (String s : config.scopes().split("\\s+")) {
|
|
||||||
if (!s.isBlank()) scopesMap.put(s, s);
|
|
||||||
}
|
|
||||||
java.util.Map<String, Object> flow = new java.util.LinkedHashMap<>();
|
|
||||||
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
|
||||||
flow.put("tokenUrl", meta.tokenEndpoint());
|
|
||||||
flow.put("scopes", scopesMap);
|
|
||||||
|
|
||||||
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
|
|
||||||
scheme.put("type", "oauth2");
|
|
||||||
scheme.put("flows", java.util.Map.of("authorizationCode", flow));
|
|
||||||
return scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public java.util.List<String> requiredFor(Class<?> handlerClass) {
|
public Map<String, Object> componentContributions() {
|
||||||
return OidcAuthPolicy.openApiScopesFor(handlerClass);
|
Map<String, String> scopesMap = new LinkedHashMap<>();
|
||||||
|
for (String s : config.scopes().split("\\s+")) {
|
||||||
|
if (!s.isBlank()) scopesMap.put(s, s);
|
||||||
|
}
|
||||||
|
Map<String, Object> flow = new LinkedHashMap<>();
|
||||||
|
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
||||||
|
flow.put("tokenUrl", meta.tokenEndpoint());
|
||||||
|
flow.put("scopes", scopesMap);
|
||||||
|
|
||||||
|
Map<String, Object> scheme = new LinkedHashMap<>();
|
||||||
|
scheme.put("type", "oauth2");
|
||||||
|
scheme.put("flows", Map.of("authorizationCode", flow));
|
||||||
|
|
||||||
|
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
||||||
|
securitySchemes.put(config.schemeName(), scheme);
|
||||||
|
return Map.of("securitySchemes", securitySchemes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public java.util.Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
OpenApiOperationContribution.Builder out =
|
||||||
if (policy == null || policy.optionalAuth()) return java.util.Map.of();
|
OpenApiOperationContribution.builder();
|
||||||
|
|
||||||
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
|
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
||||||
out.put(401, "Authentication required");
|
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[] roles = policy.requiredRoles();
|
||||||
String[] scopes = policy.requiredScopes();
|
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 roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
|
||||||
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
||||||
if (roleMessage != null && scopeMessage != null) {
|
if (roleMessage != null && scopeMessage != null) {
|
||||||
out.put(403, roleMessage + "; " + scopeMessage);
|
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
|
||||||
} else if (roleMessage != null) {
|
} else
|
||||||
out.put(403, roleMessage);
|
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
|
||||||
} else {
|
return out.build();
|
||||||
out.put(403, scopeMessage);
|
}
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-11
@@ -1,10 +1,11 @@
|
|||||||
package dev.relism.ext.oidc;
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
import dev.relism.ext.openapi.OpenApiSecurityContributor;
|
import dev.relism.ext.openapi.OpenApiContributor;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -37,49 +38,57 @@ class OidcOpenApiInteropTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_authOnly() throws Exception {
|
void autoResponses_authOnly() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOnly.class);
|
Map<Integer, String> responses = responses(AuthOnly.class);
|
||||||
assertEquals("Authentication required", responses.get(401));
|
assertEquals("Authentication required", responses.get(401));
|
||||||
assertFalse(responses.containsKey(403));
|
assertFalse(responses.containsKey(403));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_optionalAuth_addsNothing() throws Exception {
|
void autoResponses_optionalAuth_addsNothing() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOptional.class);
|
Map<Integer, String> responses = responses(AuthOptional.class);
|
||||||
assertTrue(responses.isEmpty());
|
assertTrue(responses.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_oneRole_formatsSingular() throws Exception {
|
void autoResponses_oneRole_formatsSingular() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(OneRole.class);
|
Map<Integer, String> responses = responses(OneRole.class);
|
||||||
assertEquals("Authentication required", responses.get(401));
|
assertEquals("Authentication required", responses.get(401));
|
||||||
assertEquals("\"admin\" role required", responses.get(403));
|
assertEquals("\"admin\" role required", responses.get(403));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_multiRoles_formatsPlural() throws Exception {
|
void autoResponses_multiRoles_formatsPlural() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(MultiRole.class);
|
Map<Integer, String> responses = responses(MultiRole.class);
|
||||||
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
|
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_oneScope_formatsSingular() throws Exception {
|
void autoResponses_oneScope_formatsSingular() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(OneScope.class);
|
Map<Integer, String> responses = responses(OneScope.class);
|
||||||
assertEquals("\"orders:write\" scope required", responses.get(403));
|
assertEquals("\"orders:write\" scope required", responses.get(403));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_multiScopes_formatsPlural() throws Exception {
|
void autoResponses_multiScopes_formatsPlural() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(MultiScope.class);
|
Map<Integer, String> responses = responses(MultiScope.class);
|
||||||
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
|
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoResponses_roleAndScope_combinesMessages() throws Exception {
|
void autoResponses_roleAndScope_combinesMessages() throws Exception {
|
||||||
Map<Integer, String> responses = contributor().autoResponsesFor(RoleAndScope.class);
|
Map<Integer, String> responses = responses(RoleAndScope.class);
|
||||||
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
|
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<Map<String, List<String>>> 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");
|
Class<?> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration");
|
||||||
Constructor<?> ctor = clazz.getDeclaredConstructor();
|
Constructor<?> ctor = clazz.getDeclaredConstructor();
|
||||||
ctor.setAccessible(true);
|
ctor.setAccessible(true);
|
||||||
@@ -89,8 +98,8 @@ class OidcOpenApiInteropTest {
|
|||||||
m.setAccessible(true);
|
m.setAccessible(true);
|
||||||
|
|
||||||
dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
|
dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
|
||||||
dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry();
|
dev.relism.ext.openapi.OpenApiContributorRegistry registry = new dev.relism.ext.openapi.OpenApiContributorRegistry();
|
||||||
ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry);
|
ctx.provide(dev.relism.ext.openapi.OpenApiContributorRegistry.class, registry);
|
||||||
|
|
||||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||||
@@ -98,4 +107,13 @@ class OidcOpenApiInteropTest {
|
|||||||
|
|
||||||
return registry.contributors().getFirst();
|
return registry.contributors().getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
|
||||||
|
Map<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
|
||||||
|
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
|
||||||
|
for (Map.Entry<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> e : byCode.entrySet()) {
|
||||||
|
out.put(e.getKey(), e.getValue().description());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,22 @@ Supported field-level exclusion:
|
|||||||
- `@JsonIgnoreProperties(...)`
|
- `@JsonIgnoreProperties(...)`
|
||||||
- `transient` / `static`
|
- `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
|
## OIDC interop
|
||||||
|
|
||||||
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
|
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.
|
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
|
## Notes
|
||||||
|
|
||||||
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-openapi</artifactId>
|
<artifactId>flash-ext-openapi</artifactId>
|
||||||
|
|||||||
+104
-30
@@ -51,7 +51,7 @@ public final class OpenApiBuilder {
|
|||||||
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||||
private final SchemaRegistry schemas = new SchemaRegistry();
|
private final SchemaRegistry schemas = new SchemaRegistry();
|
||||||
private OpenApiSecurityRegistry securityRegistry;
|
private OpenApiContributorRegistry contributorRegistry;
|
||||||
private int revision;
|
private int revision;
|
||||||
private int builtRevision = -1;
|
private int builtRevision = -1;
|
||||||
private Map<String, Object> cachedSpec;
|
private Map<String, Object> cachedSpec;
|
||||||
@@ -59,7 +59,7 @@ public final class OpenApiBuilder {
|
|||||||
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
||||||
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
||||||
public OpenApiBuilder description(String description) { this.description = description; 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) {
|
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||||
String path = normalizePath(route.path());
|
String path = normalizePath(route.path());
|
||||||
@@ -90,8 +90,8 @@ public final class OpenApiBuilder {
|
|||||||
info.put("version", version);
|
info.put("version", version);
|
||||||
if (!description.isEmpty()) info.put("description", description);
|
if (!description.isEmpty()) info.put("description", description);
|
||||||
|
|
||||||
List<OpenApiSecurityContributor> contributors = securityRegistry != null
|
List<OpenApiContributor> contributors = contributorRegistry != null
|
||||||
? securityRegistry.contributors() : List.of();
|
? contributorRegistry.contributors() : List.of();
|
||||||
|
|
||||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||||
for (var pathEntry : paths.entrySet()) {
|
for (var pathEntry : paths.entrySet()) {
|
||||||
@@ -103,8 +103,7 @@ public final class OpenApiBuilder {
|
|||||||
Map<String, Object> op = new LinkedHashMap<>(original);
|
Map<String, Object> op = new LinkedHashMap<>(original);
|
||||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
Class<?> handler = handlers.get(methodEntry.getKey());
|
||||||
if (handler != null && !contributors.isEmpty()) {
|
if (handler != null && !contributors.isEmpty()) {
|
||||||
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
|
applyContributorOperation(op, handler, contributors);
|
||||||
if (!security.isEmpty()) op.put("security", security);
|
|
||||||
}
|
}
|
||||||
renderedPathItem.put(methodEntry.getKey(), op);
|
renderedPathItem.put(methodEntry.getKey(), op);
|
||||||
}
|
}
|
||||||
@@ -119,11 +118,7 @@ public final class OpenApiBuilder {
|
|||||||
Map<String, Object> components = new LinkedHashMap<>();
|
Map<String, Object> components = new LinkedHashMap<>();
|
||||||
Map<String, Object> renderedSchemas = schemas.render();
|
Map<String, Object> renderedSchemas = schemas.render();
|
||||||
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
||||||
if (!contributors.isEmpty()) {
|
if (!contributors.isEmpty()) applyContributorComponents(components, contributors);
|
||||||
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
|
||||||
for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition());
|
|
||||||
components.put("securitySchemes", securitySchemes);
|
|
||||||
}
|
|
||||||
if (!components.isEmpty()) spec.put("components", components);
|
if (!components.isEmpty()) spec.put("components", components);
|
||||||
|
|
||||||
cachedSpec = spec;
|
cachedSpec = spec;
|
||||||
@@ -181,16 +176,12 @@ public final class OpenApiBuilder {
|
|||||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
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()) {
|
if (responseByCode.isEmpty()) {
|
||||||
responseByCode.put(200, Map.of("description", "OK"));
|
responseByCode.put(200, Map.of("description", "OK"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyContributorResponses(responseByCode, cls);
|
||||||
|
|
||||||
Map<String, Object> responses = new LinkedHashMap<>();
|
Map<String, Object> responses = new LinkedHashMap<>();
|
||||||
responseByCode.entrySet().stream()
|
responseByCode.entrySet().stream()
|
||||||
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
||||||
@@ -198,8 +189,97 @@ public final class OpenApiBuilder {
|
|||||||
op.put("responses", responses);
|
op.put("responses", responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<OpenApiSecurityContributor> securityContributors() {
|
private List<OpenApiContributor> contributors() {
|
||||||
return securityRegistry != null ? securityRegistry.contributors() : List.of();
|
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
|
||||||
|
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||||
|
Set<Integer> 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<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
|
||||||
|
int status = entry.getKey();
|
||||||
|
OpenApiResponseContribution responseContribution = entry.getValue();
|
||||||
|
if (responseContribution == null) continue;
|
||||||
|
|
||||||
|
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
|
||||||
|
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenApiResponseContribution allResponses = contribution.allResponses();
|
||||||
|
if (allResponses != null) {
|
||||||
|
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
|
||||||
|
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void mergeContributorResponse(Map<String, Object> response,
|
||||||
|
OpenApiResponseContribution contribution,
|
||||||
|
int status,
|
||||||
|
Set<Integer> manualStatusCodes) {
|
||||||
|
String desc = contribution.description();
|
||||||
|
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
|
||||||
|
response.put("description", desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, Object>> headerContributions = contribution.headers();
|
||||||
|
if (!headerContributions.isEmpty()) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
|
||||||
|
for (Map.Entry<String, Map<String, Object>> 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<String, Object> components, List<OpenApiContributor> contributors) {
|
||||||
|
for (OpenApiContributor contributor : contributors) {
|
||||||
|
Map<String, Object> c = contributor.componentContributions();
|
||||||
|
if (c == null || c.isEmpty()) continue;
|
||||||
|
deepMergeLastWins(components, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
||||||
|
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||||
|
Object existing = target.get(e.getKey());
|
||||||
|
Object value = e.getValue();
|
||||||
|
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
|
||||||
|
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
|
||||||
|
deepMergeLastWins(merged, (Map<String, Object>) vm);
|
||||||
|
target.put(e.getKey(), merged);
|
||||||
|
} else {
|
||||||
|
target.put(e.getKey(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
|
||||||
|
for (OpenApiContributor contributor : contributors) {
|
||||||
|
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||||
|
if (contribution == null || contribution.isEmpty()) continue;
|
||||||
|
|
||||||
|
if (!contribution.security().isEmpty()) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
|
||||||
|
.computeIfAbsent("security", __ -> new ArrayList<>());
|
||||||
|
security.addAll(contribution.security());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
||||||
@@ -264,7 +344,11 @@ public final class OpenApiBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizePath(String path) {
|
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) {
|
private static String defaultDescription(int status) {
|
||||||
@@ -272,16 +356,6 @@ public final class OpenApiBuilder {
|
|||||||
return reason == null ? "" : reason;
|
return reason == null ? "" : reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
|
|
||||||
Class<?> handlerClass) {
|
|
||||||
List<Map<String, List<String>>> security = new ArrayList<>();
|
|
||||||
for (OpenApiSecurityContributor c : contributors) {
|
|
||||||
List<String> scopes = c.requiredFor(handlerClass);
|
|
||||||
if (scopes != null) security.add(Map.of(c.schemeName(), scopes));
|
|
||||||
}
|
|
||||||
return security;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Class<?> rawType(Type type) {
|
private static Class<?> rawType(Type type) {
|
||||||
if (type instanceof Class<?> c) return c;
|
if (type instanceof Class<?> c) return c;
|
||||||
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
|
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface OpenApiContributor {
|
||||||
|
|
||||||
|
default Map<String, Object> componentContributions() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
default OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
||||||
|
return OpenApiOperationContribution.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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<OpenApiContributor> contributors = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
public void add(OpenApiContributor contributor) {
|
||||||
|
contributors.add(contributor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OpenApiContributor> contributors() {
|
||||||
|
return Collections.unmodifiableList(contributors);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -65,12 +65,12 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
||||||
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
|
|
||||||
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
ctx.provide(OpenApiBuilder.class, builder);
|
ctx.provide(OpenApiBuilder.class, builder);
|
||||||
builder.setSecurityRegistry(secRegistry);
|
builder.setContributorRegistry(registry);
|
||||||
|
|
||||||
// Collect operation metadata from final compiled routes.
|
// Collect operation metadata from final compiled routes.
|
||||||
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
||||||
|
|||||||
+71
@@ -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<Map<String, List<String>>> security;
|
||||||
|
private final Map<Integer, OpenApiResponseContribution> responses;
|
||||||
|
private final OpenApiResponseContribution allResponses;
|
||||||
|
|
||||||
|
private OpenApiOperationContribution(List<Map<String, List<String>>> security,
|
||||||
|
Map<Integer, OpenApiResponseContribution> responses,
|
||||||
|
OpenApiResponseContribution allResponses) {
|
||||||
|
this.security = List.copyOf(security);
|
||||||
|
this.responses = Map.copyOf(responses);
|
||||||
|
this.allResponses = allResponses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, List<String>>> security() {
|
||||||
|
return security;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Integer, OpenApiResponseContribution> 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<Map<String, List<String>>> security = new ArrayList<>();
|
||||||
|
private final Map<Integer, OpenApiResponseContribution> responses = new LinkedHashMap<>();
|
||||||
|
private OpenApiResponseContribution allResponses;
|
||||||
|
|
||||||
|
public Builder security(String schemeName, List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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<String, Map<String, Object>> headers;
|
||||||
|
|
||||||
|
private OpenApiResponseContribution(String description, Map<String, Map<String, Object>> headers) {
|
||||||
|
this.description = description;
|
||||||
|
this.headers = Map.copyOf(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String description() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, Object>> 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<String, Map<String, Object>> headers = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public Builder description(String description) {
|
||||||
|
this.description = description;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder header(String name, Map<String, Object> headerObject) {
|
||||||
|
headers.put(name, Map.copyOf(headerObject));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OpenApiResponseContribution build() {
|
||||||
|
return new OpenApiResponseContribution(description, headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-57
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <p>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.<schemeName>}.
|
|
||||||
*
|
|
||||||
* <p>Example for OIDC:
|
|
||||||
* <pre>{@code
|
|
||||||
* Map.of("type", "openIdConnect",
|
|
||||||
* "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
Map<String, Object> 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.
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code null} — handler is not secured by this contributor (skip)</li>
|
|
||||||
* <li>empty list — handler requires authentication, no specific scopes</li>
|
|
||||||
* <li>non-empty list — handler requires these scopes/roles</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
List<String> requiredFor(Class<?> handlerClass);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional auto-injected operation responses for handlers secured by this contributor.
|
|
||||||
* Key = HTTP status code, value = description.
|
|
||||||
*/
|
|
||||||
default Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <p>Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec
|
|
||||||
* generation without blocking registration.
|
|
||||||
*/
|
|
||||||
public final class OpenApiSecurityRegistry {
|
|
||||||
|
|
||||||
private final List<OpenApiSecurityContributor> 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<OpenApiSecurityContributor> contributors() {
|
|
||||||
return Collections.unmodifiableList(contributors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+96
-8
@@ -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)
|
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
|
||||||
@JsonIgnoreProperties({"ignoredByType"})
|
@JsonIgnoreProperties({"ignoredByType"})
|
||||||
static class UserDto {
|
static class UserDto {
|
||||||
@@ -177,16 +187,23 @@ class OpenApiBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
void auto_security_responses_are_added_and_manual_wins_sorted() {
|
void auto_security_responses_are_added_and_manual_wins_sorted() {
|
||||||
OpenApiBuilder b = new OpenApiBuilder();
|
OpenApiBuilder b = new OpenApiBuilder();
|
||||||
OpenApiSecurityRegistry registry = new OpenApiSecurityRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
registry.add(new OpenApiSecurityContributor() {
|
registry.add(new OpenApiContributor() {
|
||||||
@Override public String schemeName() { return "oidc"; }
|
@Override
|
||||||
@Override public Map<String, Object> schemeDefinition() { return Map.of("type", "oauth2"); }
|
public Map<String, Object> componentContributions() {
|
||||||
@Override public List<String> requiredFor(Class<?> handlerClass) { return List.of(); }
|
return Map.of("securitySchemes", Map.of("oidc", Map.of("type", "oauth2")));
|
||||||
@Override public Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
}
|
||||||
return Map.of(401, "Authentication required", 403, "Auto forbidden");
|
|
||||||
|
@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);
|
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
|
||||||
|
|
||||||
Map<String, Object> spec = b.build();
|
Map<String, Object> spec = b.build();
|
||||||
@@ -250,6 +267,77 @@ class OpenApiBuilderTest {
|
|||||||
assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref"));
|
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<String, Object> spec = b.build();
|
||||||
|
Map<String, Object> get = getOperation(spec, "/merge", "get");
|
||||||
|
Map<String, Object> responses = cast(get.get("responses"));
|
||||||
|
|
||||||
|
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||||
|
assertEquals("Manual 200", resp200.get("description"));
|
||||||
|
Map<String, Object> headers200 = cast(resp200.get("headers"));
|
||||||
|
assertTrue(headers200.containsKey("X-Trace"));
|
||||||
|
|
||||||
|
Map<String, Object> resp429 = cast(responses.get("429"));
|
||||||
|
assertEquals("Auto 429", resp429.get("description"));
|
||||||
|
Map<String, Object> 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<String, Object> spec = b.build();
|
||||||
|
Map<String, Object> get = getOperation(spec, "/merge", "get");
|
||||||
|
Map<String, Object> responses = cast(get.get("responses"));
|
||||||
|
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||||
|
Map<String, Object> headers = cast(resp200.get("headers"));
|
||||||
|
Map<String, Object> header = cast(headers.get("X-RateLimit-Limit"));
|
||||||
|
assertEquals("new", header.get("description"));
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
|
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
|
||||||
Map<String, Object> paths = cast(spec.get("paths"));
|
Map<String, Object> paths = cast(spec.get("paths"));
|
||||||
Map<String, Object> pathItem = cast(paths.get(path));
|
Map<String, Object> pathItem = cast(paths.get(path));
|
||||||
|
|||||||
+15
@@ -108,6 +108,21 @@ class OpenApiExtensionTest {
|
|||||||
assertFalse(paths.containsKey("/users"));
|
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<String, Object> spec = builder.build();
|
||||||
|
Map<String, Object> paths = cast(spec.get("paths"));
|
||||||
|
assertTrue(paths.containsKey("/blogs"));
|
||||||
|
assertFalse(paths.containsKey("//blogs"));
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static List<RouteListener> listeners(FlashContext ctx) {
|
private static List<RouteListener> listeners(FlashContext ctx) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-routeviewer</artifactId>
|
<artifactId>flash-ext-routeviewer</artifactId>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Flash Route Viewer</title>
|
<title>Flash Route Viewer</title>
|
||||||
<script type="module" crossorigin src="/routeviewer/app.js"></script>
|
<script type="module" crossorigin src="/routeviewer/app.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/routeviewer/app.css">
|
<link rel="stylesheet" crossorigin href="/routeviewer/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>1.1-indev6</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-view-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+40
@@ -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<TTarget> implements FlashExtension {
|
||||||
|
private final List<GlobalValue> globals = new ArrayList<>();
|
||||||
|
|
||||||
|
public BaseViewExtension<TTarget> addGlobal(String key, Function<Request, Object> resolver) {
|
||||||
|
String k = Objects.requireNonNull(key, "global key must not be null").trim();
|
||||||
|
if (k.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("global key must not be blank");
|
||||||
|
}
|
||||||
|
if (k.equals("global") || k.contains(".")) {
|
||||||
|
throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key);
|
||||||
|
}
|
||||||
|
globals.add(new GlobalValue(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void provide(FlashContext ctx) {
|
||||||
|
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
|
||||||
|
ctx.provide(ViewRuntimeBridge.class, runtime);
|
||||||
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
|
validateHandlerClass(handlerClass);
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract ViewRuntimeBridge<TTarget> createRuntime(List<GlobalValue> globals);
|
||||||
|
protected abstract void validateHandlerClass(Class<? extends RequestHandler> handlerClass);
|
||||||
|
}
|
||||||
+68
@@ -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<TTarget> extends RequestHandler {
|
||||||
|
private static final byte DISPATCH_RENDER_2 = 1;
|
||||||
|
private static final byte DISPATCH_RENDER_1 = 2;
|
||||||
|
|
||||||
|
private ViewRuntimeBridge<TTarget> 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<TTarget> resolvedRuntime = (ViewRuntimeBridge<TTarget>) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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<Request, Object> resolver) {}
|
||||||
+15
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-17
@@ -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.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutable view-model builder used by the SSR view runtime.
|
* Mutable per-request model builder.
|
||||||
*
|
|
||||||
* <p>Instances are typically created per request in {@code ViewHandler.render(...)} then passed
|
|
||||||
* to the renderer. The internal map preserves insertion order and is exposed as an immutable
|
|
||||||
* snapshot through {@link #toMap()}.
|
|
||||||
*/
|
*/
|
||||||
public final class ViewModel {
|
public final class ViewModel {
|
||||||
private final LinkedHashMap<String, Object> values;
|
private final LinkedHashMap<String, Object> values;
|
||||||
@@ -29,13 +25,11 @@ public final class ViewModel {
|
|||||||
return empty().with(key, value);
|
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) {
|
public ViewModel with(String key, Object value) {
|
||||||
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
|
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk variant of {@link #with(String, Object)}. */
|
|
||||||
public ViewModel withAll(Map<String, Object> values) {
|
public ViewModel withAll(Map<String, Object> values) {
|
||||||
if (values == null || values.isEmpty()) return this;
|
if (values == null || values.isEmpty()) return this;
|
||||||
for (Map.Entry<String, Object> e : values.entrySet()) {
|
for (Map.Entry<String, Object> e : values.entrySet()) {
|
||||||
@@ -44,31 +38,26 @@ public final class ViewModel {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a merged copy where {@code other} wins on key collisions.
|
|
||||||
* Both source models remain unchanged.
|
|
||||||
*/
|
|
||||||
public ViewModel merge(ViewModel other) {
|
public ViewModel merge(ViewModel other) {
|
||||||
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
|
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
|
||||||
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
|
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable view over current values. */
|
|
||||||
public Map<String, Object> toMap() {
|
public Map<String, Object> toMap() {
|
||||||
return Collections.unmodifiableMap(values);
|
return Collections.unmodifiableMap(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ViewModel copyOf(ViewModel source) {
|
public static ViewModel copyOf(ViewModel source) {
|
||||||
if (source == null || source.values.isEmpty()) return empty();
|
if (source == null || source.values.isEmpty()) return empty();
|
||||||
return new ViewModel(new LinkedHashMap<>(source.values));
|
return new ViewModel(new LinkedHashMap<>(source.values));
|
||||||
}
|
}
|
||||||
|
|
||||||
static ViewModel owned(LinkedHashMap<String, Object> values) {
|
public static ViewModel owned(LinkedHashMap<String, Object> values) {
|
||||||
return new ViewModel(values);
|
return new ViewModel(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Object unwrapValue(Object value) {
|
public static Object unwrapValue(Object value) {
|
||||||
if (value instanceof ViewModel vm) {
|
if (value instanceof ViewModel vm) {
|
||||||
return vm.toMap();
|
return vm.toMap();
|
||||||
}
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.relism.ext.view.core;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
public interface ViewRuntimeBridge<TTarget> {
|
||||||
|
TTarget resolve(Class<?> handlerClass);
|
||||||
|
RenderedView render(BaseViewHandler<TTarget> handler, TTarget target, Request req, Response res) throws Exception;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.ext.view;
|
package dev.relism.ext.view.core;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
BIN
Binary file not shown.
+29
@@ -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<String, Object> global) {
|
||||||
|
jteOutput.writeContent("\n<h1>");
|
||||||
|
jteOutput.setContext("h1", null);
|
||||||
|
jteOutput.writeUserContent(page.title());
|
||||||
|
jteOutput.writeContent("</h1>\n<p>");
|
||||||
|
jteOutput.setContext("p", null);
|
||||||
|
jteOutput.writeUserContent(page.author());
|
||||||
|
jteOutput.writeContent("</p>\n<small>");
|
||||||
|
jteOutput.setContext("small", null);
|
||||||
|
jteOutput.writeUserContent(build);
|
||||||
|
jteOutput.writeContent("</small>\n<small>");
|
||||||
|
jteOutput.setContext("small", null);
|
||||||
|
jteOutput.writeUserContent((String) global.get("appName"));
|
||||||
|
jteOutput.writeContent("</small>\n");
|
||||||
|
}
|
||||||
|
public static void renderMap(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, java.util.Map<String, Object> params) {
|
||||||
|
HomePage page = (HomePage)params.get("page");
|
||||||
|
String build = (String)params.get("build");
|
||||||
|
Map<String, Object> global = (Map<String, Object>)params.get("global");
|
||||||
|
render(jteOutput, jteHtmlInterceptor, page, build, global);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>1.1-indev6</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-view-jte</artifactId>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<jacoco.version>0.8.12</jacoco.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-view-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>gg.jte</groupId>
|
||||||
|
<artifactId>jte</artifactId>
|
||||||
|
<version>3.2.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>${jacoco.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>jacoco-prepare-agent</id>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>jacoco-report-and-check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<rules>
|
||||||
|
<rule>
|
||||||
|
<element>BUNDLE</element>
|
||||||
|
<limits>
|
||||||
|
<limit>
|
||||||
|
<counter>LINE</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>0.80</minimum>
|
||||||
|
</limit>
|
||||||
|
</limits>
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
+70
@@ -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<JteTarget> {
|
||||||
|
private final JteSettings settings;
|
||||||
|
|
||||||
|
static {
|
||||||
|
ensureJtePresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension() {
|
||||||
|
this(JteSettings.builder().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension(Consumer<JteSettings.Builder> 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<Request, Object> resolver) {
|
||||||
|
super.addGlobal(key, resolver);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ViewRuntimeBridge<JteTarget> createRuntime(List<GlobalValue> globals) {
|
||||||
|
return new JteRuntime(settings, globals);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateHandlerClass(Class<? extends RequestHandler> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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<JteTarget> {}
|
||||||
+113
@@ -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<JteTarget> {
|
||||||
|
private static final String GLOBAL_NAMESPACE = "global";
|
||||||
|
|
||||||
|
private final JteSettings settings;
|
||||||
|
private final List<GlobalValue> globals;
|
||||||
|
private final ConcurrentHashMap<Class<?>, JteTarget> targets = new ConcurrentHashMap<>();
|
||||||
|
private final TemplateEngine engine;
|
||||||
|
|
||||||
|
JteRuntime(JteSettings settings, List<GlobalValue> 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<JteTarget> handler,
|
||||||
|
JteTarget target,
|
||||||
|
Request req,
|
||||||
|
Response res) throws Exception {
|
||||||
|
ViewModel local = handler.renderInternal(req, res);
|
||||||
|
Map<String, Object> 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<String, Object> values = null;
|
||||||
|
|
||||||
|
if (!globals.isEmpty()) {
|
||||||
|
LinkedHashMap<String, Object> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.ext.view.jte;
|
||||||
|
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
|
||||||
|
record JteTarget(String template, ContentType contentType) {}
|
||||||
+42
@@ -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 extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
|
||||||
|
while (cls != null && !cls.equals(Object.class)) {
|
||||||
|
A a = cls.getAnnotation(type);
|
||||||
|
if (a != null) return a;
|
||||||
|
cls = cls.getSuperclass();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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();
|
||||||
|
}
|
||||||
+79
@@ -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<? extends RequestHandler> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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 {}
|
||||||
|
}
|
||||||
+69
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package dev.relism.ext.view.jte.model;
|
||||||
|
|
||||||
|
public record HomePage(String title, String author) {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@import dev.relism.ext.view.jte.model.HomePage
|
||||||
|
@import java.util.Map
|
||||||
|
@param HomePage page
|
||||||
|
@param String build
|
||||||
|
@param Map<String, Object> global
|
||||||
|
|
||||||
|
<h1>${page.title()}</h1>
|
||||||
|
<p>${page.author()}</p>
|
||||||
|
<small>${build}</small>
|
||||||
|
<small>${(String) global.get("appName")}</small>
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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`).
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Handlers
|
# Handlers
|
||||||
|
|
||||||
Use `ViewHandler` for class-based SSR routes.
|
Use `ThymeleafHandler` for class-based Thymeleaf routes.
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ Use `onViewInit()` to cache dependencies via `require(...)`.
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
@GET("/dashboard")
|
@GET("/dashboard")
|
||||||
@Page("pages/dashboard")
|
@Template("pages/dashboard")
|
||||||
public final class DashboardPage extends ViewHandler {
|
public final class DashboardPage extends ThymeleafHandler {
|
||||||
|
|
||||||
private DashboardService service;
|
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.
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Model and Globals
|
# Model and Globals
|
||||||
|
|
||||||
`ViewModel` is the per-request model builder.
|
`ViewModel` (from `flash-ext-view-core`) is the per-request model builder.
|
||||||
|
|
||||||
## Merge Order
|
## Merge Order
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Handlers cannot set top-level `global`; runtime throws fail-fast to prevent name
|
|||||||
Register globals on extension setup:
|
Register globals on extension setup:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
new ViewExtension(ViewEngineType.THYMELEAF)
|
new ThymeleafExtension()
|
||||||
.addGlobal("appName", req -> "Flash")
|
.addGlobal("appName", req -> "Flash")
|
||||||
.addGlobal("path", req -> req.path());
|
.addGlobal("path", req -> req.path());
|
||||||
```
|
```
|
||||||
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
# Performance
|
# 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
|
## Current runtime choices
|
||||||
|
|
||||||
- Handler view metadata resolved once and cached.
|
- Handler view metadata resolved once and cached.
|
||||||
- Global/local model merge done in a single pass.
|
- 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
|
## Best practices
|
||||||
|
|
||||||
+3
-10
@@ -7,10 +7,10 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>1.1-indev5</version>
|
<version>1.1-indev6</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-view</artifactId>
|
<artifactId>flash-ext-view-thymeleaf</artifactId>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
<jacoco.version>0.8.12</jacoco.version>
|
||||||
@@ -19,20 +19,13 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash-ext-view-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!--
|
|
||||||
Optional engine bridges — not transitive.
|
|
||||||
Users must add whichever engine they select via ViewEngineType
|
|
||||||
to their own pom.xml. If absent at runtime, ViewExtension throws
|
|
||||||
a descriptive IllegalStateException at boot time.
|
|
||||||
-->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.thymeleaf</groupId>
|
<groupId>org.thymeleaf</groupId>
|
||||||
<artifactId>thymeleaf</artifactId>
|
<artifactId>thymeleaf</artifactId>
|
||||||
<version>3.1.2.RELEASE</version>
|
<version>3.1.2.RELEASE</version>
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
+16
@@ -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 "";
|
||||||
|
}
|
||||||
+15
@@ -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();
|
||||||
|
}
|
||||||
+63
@@ -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<ThymeleafTarget> {
|
||||||
|
private final ThymeleafSettings settings;
|
||||||
|
|
||||||
|
static {
|
||||||
|
ensureThymeleafPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThymeleafExtension() {
|
||||||
|
this(ThymeleafSettings.builder().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThymeleafExtension(Consumer<ThymeleafSettings.Builder> 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<dev.relism.models.Request, Object> resolver) {
|
||||||
|
super.addGlobal(key, resolver);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ViewRuntimeBridge<ThymeleafTarget> createRuntime(List<GlobalValue> globals) {
|
||||||
|
return new ThymeleafRuntime(settings, globals);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateHandlerClass(Class<? extends RequestHandler> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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<ThymeleafTarget> {}
|
||||||
+159
@@ -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<ThymeleafTarget> {
|
||||||
|
private static final String GLOBAL_NAMESPACE = "global";
|
||||||
|
|
||||||
|
private final ThymeleafSettings settings;
|
||||||
|
private final List<GlobalValue> globals;
|
||||||
|
private final ConcurrentHashMap<Class<?>, ThymeleafTarget> targets = new ConcurrentHashMap<>();
|
||||||
|
private final TemplateEngine engine;
|
||||||
|
|
||||||
|
ThymeleafRuntime(ThymeleafSettings settings, List<GlobalValue> 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<ThymeleafTarget> 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<String, Object> 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<String, Object> values = null;
|
||||||
|
|
||||||
|
if (!globals.isEmpty()) {
|
||||||
|
LinkedHashMap<String, Object> 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<String, Object> 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<String, Object> params) {
|
||||||
|
if (base == null) return "";
|
||||||
|
String url = expandPathVars(base, params);
|
||||||
|
return appendQueryString(url, base, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String expandPathVars(String base, Map<String, Object> params) {
|
||||||
|
if (params == null || params.isEmpty() || !base.contains("{")) return base;
|
||||||
|
String result = base;
|
||||||
|
for (Map.Entry<String, Object> 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<String, Object> params) {
|
||||||
|
if (params == null || params.isEmpty()) return url;
|
||||||
|
StringBuilder qs = new StringBuilder();
|
||||||
|
for (Map.Entry<String, Object> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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 extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
|
||||||
|
while (cls != null && !cls.equals(Object.class)) {
|
||||||
|
A a = cls.getAnnotation(type);
|
||||||
|
if (a != null) return a;
|
||||||
|
cls = cls.getSuperclass();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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<? extends RequestHandler> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a id="plain" th:href="@{/static}">plain</a>
|
||||||
|
<a id="pathvar-null" th:href="@{/users/{id}(id=${null})}">pathvar-null</a>
|
||||||
|
<a id="query-null" th:href="@{/search(q=${null})}">query-null</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<span th:text="${global.appName}">app</span>
|
||||||
|
<span th:text="${global.path}">path</span>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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`
|
|
||||||
@@ -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<String, Object> model,
|
|
||||||
Request req,
|
|
||||||
Response res) {
|
|
||||||
String body = "...";
|
|
||||||
return RenderOutput.html(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adapter instances must be thread-safe after construction.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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.
|
|
||||||
-6
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package dev.relism.ext.view;
|
|
||||||
|
|
||||||
import dev.relism.models.Request;
|
|
||||||
|
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
record GlobalBinding(String key, Function<Request, Object> resolver) {}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Use on subclasses of {@link dev.relism.models.RequestHandler}, typically
|
|
||||||
* {@link ViewHandler}. The handler must also declare a route annotation
|
|
||||||
* ({@code @Route}, {@code @GET}, {@code @POST}, ...).
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface Page {
|
|
||||||
/** Template name/path (engine-specific). */
|
|
||||||
String value();
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Useful for progressive/fragment updates (e.g. HTMX). When {@link #slot()} is blank,
|
|
||||||
* the adapter default slot is used.
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface Partial {
|
|
||||||
/** Template containing the fragment slot. */
|
|
||||||
String template();
|
|
||||||
|
|
||||||
/** Optional fragment slot selector. */
|
|
||||||
String slot() default "";
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-160
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
|
|
||||||
*
|
|
||||||
* <h3>Default configuration</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>Prefix : {@code /templates/} (classpath-relative)</li>
|
|
||||||
* <li>Suffix : {@code .html}</li>
|
|
||||||
* <li>Mode : {@link TemplateMode#HTML}</li>
|
|
||||||
* <li>Encoding: UTF-8</li>
|
|
||||||
* <li>Cache : enabled in production, disabled in dev mode
|
|
||||||
* ({@code flash.env=dev} or {@code FLASH_ENV=dev})</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h3>Link building</h3>
|
|
||||||
* Thymeleaf's built-in {@code StandardLinkBuilder} requires an
|
|
||||||
* {@code IWebContext} (servlet context) to resolve context-relative paths
|
|
||||||
* ({@code @{/foo}}). Flash runs standalone, so this engine registers a custom
|
|
||||||
* {@link FlashLinkBuilder} that resolves {@code @{...}} expressions without a
|
|
||||||
* servlet context — path variables and query parameters are supported as usual.
|
|
||||||
*
|
|
||||||
* <h3>Model conventions</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link Map} model → each entry is a named Thymeleaf variable.</li>
|
|
||||||
* <li>Any other non-null value → registered under the key {@code "it"}.</li>
|
|
||||||
* <li>{@code null} model → empty context.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
final class ThymeleafEngine implements 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<String, Object> 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.
|
|
||||||
*
|
|
||||||
* <p>Thymeleaf's built-in {@code StandardLinkBuilder} requires an
|
|
||||||
* {@code IWebContext} (i.e. a servlet container) to resolve context-relative
|
|
||||||
* paths starting with {@code /}. This builder replicates that behaviour without
|
|
||||||
* the servlet dependency:
|
|
||||||
* <ul>
|
|
||||||
* <li>Path variables — {@code @{/posts/{id}(id=${post.id})}} → {@code /posts/abc}</li>
|
|
||||||
* <li>Query params — {@code @{/search(q=${term})}} → {@code /search?q=hello}</li>
|
|
||||||
* <li>Mixed — {@code @{/posts/{id}(id=x,p=2)}} → {@code /posts/x?p=2}</li>
|
|
||||||
* </ul>
|
|
||||||
* Registered at order {@link Integer#MIN_VALUE} so it takes precedence over
|
|
||||||
* {@code StandardLinkBuilder} ({@code Integer.MAX_VALUE}).
|
|
||||||
*/
|
|
||||||
private static final class FlashLinkBuilder implements ILinkBuilder {
|
|
||||||
|
|
||||||
static final FlashLinkBuilder INSTANCE = new FlashLinkBuilder();
|
|
||||||
|
|
||||||
@Override public String getName() { return "flash"; }
|
|
||||||
@Override public Integer getOrder() { return Integer.MIN_VALUE; }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String buildLink(IExpressionContext ctx,
|
|
||||||
String base,
|
|
||||||
Map<String, Object> params) {
|
|
||||||
if (base == null) return "";
|
|
||||||
String url = expandPathVars(base, params);
|
|
||||||
return appendQueryString(url, base, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Substitutes {@code {key}} placeholders in the path with their encoded values. */
|
|
||||||
private static String expandPathVars(String base, Map<String, Object> params) {
|
|
||||||
if (params == null || params.isEmpty() || !base.contains("{")) return base;
|
|
||||||
String result = base;
|
|
||||||
for (var e : params.entrySet()) {
|
|
||||||
String placeholder = '{' + e.getKey() + '}';
|
|
||||||
if (result.contains(placeholder) && e.getValue() != null) {
|
|
||||||
result = result.replace(placeholder, encode(String.valueOf(e.getValue())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends parameters that were NOT consumed as path variables as {@code ?k=v&…} pairs. */
|
|
||||||
private static String appendQueryString(String url, String base, Map<String, Object> params) {
|
|
||||||
if (params == null || params.isEmpty()) return url;
|
|
||||||
StringBuilder qs = new StringBuilder();
|
|
||||||
for (var e : params.entrySet()) {
|
|
||||||
if (base.contains('{' + e.getKey() + '}') || e.getValue() == null) continue;
|
|
||||||
qs.append(qs.isEmpty() ? '?' : '&')
|
|
||||||
.append(encode(e.getKey()))
|
|
||||||
.append('=')
|
|
||||||
.append(encode(String.valueOf(e.getValue())));
|
|
||||||
}
|
|
||||||
return qs.isEmpty() ? url : url + qs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String encode(String s) {
|
|
||||||
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-29
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Implementations must be thread-safe after construction because one instance is shared by
|
|
||||||
* all requests.
|
|
||||||
*/
|
|
||||||
public interface ViewEngineAdapter {
|
|
||||||
|
|
||||||
/** Engine feature flags used for boot-time route/view validation. */
|
|
||||||
EngineCapabilities capabilities();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a page/partial target with the merged model for the current request.
|
|
||||||
*
|
|
||||||
* @param target resolved rendering target
|
|
||||||
* @param model merged request model (globals first, local model last)
|
|
||||||
* @param req current request
|
|
||||||
* @param res current response
|
|
||||||
* @return response body + optional explicit content type
|
|
||||||
*/
|
|
||||||
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package dev.relism.ext.view;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Managed template engine types supported out-of-the-box by {@link ViewExtension}.
|
|
||||||
*
|
|
||||||
* <p>Pass one of these constants to {@link ViewExtension#ViewExtension(ViewEngineType)}
|
|
||||||
* for zero-boilerplate setup. The extension auto-configures the selected engine with
|
|
||||||
* sensible defaults and validates that the required library is on the runtime classpath,
|
|
||||||
* throwing a descriptive {@link IllegalStateException} at boot time if it is not.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Zero-boilerplate — Thymeleaf auto-configured with defaults
|
|
||||||
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Dev mode</h3>
|
|
||||||
* Template caching is <b>disabled</b> when either:
|
|
||||||
* <ul>
|
|
||||||
* <li>the JVM property {@code flash.env} equals {@code dev} (case-insensitive), or</li>
|
|
||||||
* <li>the environment variable {@code FLASH_ENV} equals {@code dev}.</li>
|
|
||||||
* </ul>
|
|
||||||
* In all other cases caching is enabled (production default).
|
|
||||||
*
|
|
||||||
* <h3>Adding your own engine</h3>
|
|
||||||
* For unsupported engines, implement {@link ViewEngineAdapter} and pass it to
|
|
||||||
* {@link ViewExtension#ViewExtension(ViewEngineAdapter)}.
|
|
||||||
*/
|
|
||||||
public enum ViewEngineType {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thymeleaf 3.x — natural HTML templates with server-side rendering.
|
|
||||||
*
|
|
||||||
* <p>Required dependency (add to your {@code pom.xml}):
|
|
||||||
* <pre>{@code
|
|
||||||
* <dependency>
|
|
||||||
* <groupId>org.thymeleaf</groupId>
|
|
||||||
* <artifactId>thymeleaf</artifactId>
|
|
||||||
* <version>3.1.2.RELEASE</version>
|
|
||||||
* </dependency>
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* Default resolver: classpath, prefix {@code /templates/}, suffix {@code .html},
|
|
||||||
* mode {@code HTML}, encoding UTF-8.
|
|
||||||
*/
|
|
||||||
THYMELEAF;
|
|
||||||
|
|
||||||
// ── Factory ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instantiates and configures the {@link 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:
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.thymeleaf</groupId>
|
|
||||||
<artifactId>thymeleaf</artifactId>
|
|
||||||
<version>3.1.2.RELEASE</version>
|
|
||||||
</dependency>
|
|
||||||
""", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Strict contract: only {@link ViewHandler} subclasses may declare {@link Page}/{@link Partial}.
|
|
||||||
* Annotating a plain {@link dev.relism.models.RequestHandler} fails fast at boot.
|
|
||||||
*/
|
|
||||||
public final class ViewExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final ViewEngineAdapter adapter;
|
|
||||||
private final List<GlobalBinding> 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.<key>}.
|
|
||||||
*
|
|
||||||
* <p>This is the only supported global registration API. Keep resolvers fast and side-effect free.
|
|
||||||
*/
|
|
||||||
public ViewExtension addGlobal(String key, Function<dev.relism.models.Request, Object> resolver) {
|
|
||||||
String k = Objects.requireNonNull(key, "global key must not be null").trim();
|
|
||||||
if (k.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("global key must not be blank");
|
|
||||||
}
|
|
||||||
if (k.equals("global") || k.contains(".")) {
|
|
||||||
throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key);
|
|
||||||
}
|
|
||||||
globals.add(new GlobalBinding(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>Subclass contract:
|
|
||||||
* <ol>
|
|
||||||
* <li>Declare exactly one of {@link Page} or {@link Partial} on the class hierarchy.</li>
|
|
||||||
* <li>Cache dependencies in {@link #onViewInit()} (one-time, boot-time).</li>
|
|
||||||
* <li>Build per-request model in {@link #render(Request)} or {@link #render(Request, Response)}.</li>
|
|
||||||
* </ol>
|
|
||||||
*/
|
|
||||||
public abstract class ViewHandler extends RequestHandler {
|
|
||||||
private ViewRuntime runtime;
|
|
||||||
private ViewTargetResolver.ResolvedView resolved;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-request model hook.
|
|
||||||
*
|
|
||||||
* <p>Override this method for request-only rendering. If you need to mutate response
|
|
||||||
* metadata while building the model, override {@link #render(Request, Response)}.
|
|
||||||
*/
|
|
||||||
public ViewModel render(Request req) throws Exception {
|
|
||||||
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-request model hook with response access.
|
|
||||||
*
|
|
||||||
* <p>Default implementation delegates to {@link #render(Request)}.
|
|
||||||
*/
|
|
||||||
public ViewModel render(Request req, Response res) throws Exception {
|
|
||||||
return render(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected final void onInit() {
|
|
||||||
runtime = require(ViewRuntime.class);
|
|
||||||
resolved = runtime.resolve(getClass());
|
|
||||||
onViewInit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time initialization hook invoked after view metadata resolution.
|
|
||||||
*
|
|
||||||
* <p>Use this to cache services via {@link #require(Class)}. Do not perform request-bound
|
|
||||||
* work here.
|
|
||||||
*/
|
|
||||||
protected void onViewInit() {}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public final Object handle(Request request, Response response) throws Exception {
|
|
||||||
return runtime.render(this, resolved, request, response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package dev.relism.ext.view;
|
|
||||||
|
|
||||||
/** Render mode for resolved handler view targets. */
|
|
||||||
public enum ViewKind {
|
|
||||||
PAGE,
|
|
||||||
PARTIAL
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>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<GlobalBinding> globals;
|
|
||||||
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
ViewRuntime(ViewEngineAdapter adapter, List<GlobalBinding> 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<String, Object> values = null;
|
|
||||||
|
|
||||||
if (!globals.isEmpty()) {
|
|
||||||
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
|
|
||||||
for (GlobalBinding binding : globals) {
|
|
||||||
Object resolved = binding.resolver().apply(req);
|
|
||||||
globalMap.put(binding.key(), ViewModel.unwrapValue(resolved));
|
|
||||||
}
|
|
||||||
if (!globalMap.isEmpty()) {
|
|
||||||
if (values == null) values = new LinkedHashMap<>();
|
|
||||||
values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local != null) {
|
|
||||||
var localMap = local.toMap();
|
|
||||||
if (localMap.containsKey(GLOBAL_NAMESPACE)) {
|
|
||||||
throw new IllegalStateException("ViewModel key 'global' is reserved for framework globals");
|
|
||||||
}
|
|
||||||
if (values == null) return ViewModel.copyOf(local);
|
|
||||||
values.putAll(localMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values == null || values.isEmpty()) return ViewModel.empty();
|
|
||||||
return ViewModel.owned(values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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
|
|
||||||
) {}
|
|
||||||
-70
@@ -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 extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> 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) {}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user