Merge remote-tracking branch 'origin/master'

# Conflicts:
#	.idea/workspace.xml
#	flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class
#	flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java
This commit is contained in:
Relism
2026-04-26 12:36:26 +02:00
15 changed files with 612 additions and 18 deletions
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash Route Viewer</title>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash Route Viewer</title>
<script type="module" crossorigin src="/routeviewer/app.js"></script>
<link rel="stylesheet" crossorigin href="/routeviewer/app.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -15,6 +15,59 @@ The extension mirrors `gg.jte.ContentType` into Flash HTTP content type:
- `gg.jte.ContentType.Html` -> `text/html`
- `gg.jte.ContentType.Plain` -> `text/plain`
## Static serving (opinionated)
Static serving is enabled by default and serves classpath assets from `/static/**`
(`src/main/resources/static/**`) on an HTTP wildcard route.
Defaults:
- `serveStatics = true`
- `staticPrefix = "/static"`
- ETag via CRC32 + length
- `304 Not Modified` on `If-None-Match`
- pre-compressed `.gz` support when client sends `Accept-Encoding: gzip`
- MIME auto-resolution for common web/media/font/wasm types
- cache policy:
- versioned assets (`file.<hash>.ext` or query `?v=...` / `?hash=...`) -> `public, max-age=31536000, immutable`
- non-versioned -> `no-cache`
- byte-range support for large files (threshold configurable, default 64 KiB)
If classpath `/static` is missing, extension logs a warning and disables static routes.
### Configuration examples
```java
// option 1: customizer constructor
app.install(new JteExtension(cfg -> cfg
.templateRoot("/templates")
.serveStatics(true)
.staticPrefix("/assets")));
// option 2: fluent style
app.install(new JteExtension()
.templateRoot("/templates")
.withStaticServing()
.staticPrefix("/assets"));
```
Disable static serving:
```java
app.install(new JteExtension().serveStatics(false));
```
Enable static CORS (default disabled):
```java
app.install(new JteExtension().staticCors(cfg -> cfg
.enableStaticCors(true)
.staticCorsAllowOrigin("*")
.staticCorsAllowMethods("GET,HEAD,OPTIONS")
.staticCorsAllowHeaders("*")
.staticCorsMaxAge(3600)));
```
## Quick Start
```java
@@ -12,6 +12,12 @@
- Runtime injects globals under reserved `global` namespace and merges local model.
- jte renders template into `StringOutput`.
3. **Static assets (optional, enabled by default)**
- Extension mounts wildcard routes under `staticPrefix` (default `/static/**`).
- Assets are served from classpath `/static/**`.
- Uses Flash `Response.body(...)` for small payloads and `Response.stream(...)` for large payloads.
- Supports ETag/304, gzip precompressed assets, MIME auto-resolution, and byte ranges.
## Handler Contract
- Must extend `JteHandler`.
@@ -27,3 +33,7 @@ Invalid configurations fail fast at startup.
- `developmentMode`: `Flash.DEV`
- `usePrecompiled`: derived from `!developmentMode` unless explicitly set
- `binaryStaticContent`: `false`
- `serveStatics`: `true`
- `staticPrefix`: `/static`
- `largeFileThresholdBytes`: `65536`
- static CORS: disabled (`false`)
@@ -6,19 +6,19 @@ 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.writeContent("\r\n<h1>");
jteOutput.setContext("h1", null);
jteOutput.writeUserContent(page.title());
jteOutput.writeContent("</h1>\n<p>");
jteOutput.writeContent("</h1>\r\n<p>");
jteOutput.setContext("p", null);
jteOutput.writeUserContent(page.author());
jteOutput.writeContent("</p>\n<small>");
jteOutput.writeContent("</p>\r\n<small>");
jteOutput.setContext("small", null);
jteOutput.writeUserContent(build);
jteOutput.writeContent("</small>\n<small>");
jteOutput.writeContent("</small>\r\n<small>");
jteOutput.setContext("small", null);
jteOutput.writeUserContent((String) global.get("appName"));
jteOutput.writeContent("</small>\n");
jteOutput.writeContent("</small>\r\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");
@@ -0,0 +1,205 @@
package dev.relism.ext.view.jte;
import dev.relism.http.HttpStatus;
import dev.relism.models.Request;
import dev.relism.models.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.zip.CRC32;
final class JteStaticServing {
private static final Logger log = LoggerFactory.getLogger(JteStaticServing.class);
private static final String STATIC_ROOT = "static";
private static final Pattern VERSIONED_FILE = Pattern.compile(".*\\.[a-fA-F0-9]{8,}\\..*");
private static final Map<String, String> MIME = Map.ofEntries(
Map.entry("css", "text/css"),
Map.entry("js", "text/javascript"),
Map.entry("mjs", "text/javascript"),
Map.entry("html", "text/html"),
Map.entry("txt", "text/plain"),
Map.entry("json", "application/json"),
Map.entry("xml", "application/xml"),
Map.entry("wasm", "application/wasm"),
Map.entry("svg", "image/svg+xml"),
Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"),
Map.entry("ico", "image/x-icon"),
Map.entry("avif", "image/avif"),
Map.entry("woff", "font/woff"),
Map.entry("woff2", "font/woff2"),
Map.entry("ttf", "font/ttf"),
Map.entry("otf", "font/otf"),
Map.entry("map", "application/json"),
Map.entry("pdf", "application/pdf"),
Map.entry("zip", "application/zip")
);
private final String prefix;
private final long largeThreshold;
private JteStaticServing(JteSettings settings) {
this.prefix = settings.staticPrefix();
this.largeThreshold = settings.largeFileThresholdBytes();
}
static JteStaticServing load(JteSettings settings) {
if (JteStaticServing.class.getClassLoader().getResource(STATIC_ROOT) == null) {
log.warn("flash-ext-view-jte static serving enabled but '/static' classpath root is missing; static routes disabled");
return null;
}
return new JteStaticServing(settings);
}
boolean serve(Request req, Response res, boolean headOnly) {
String requestPath = req.path();
if (!requestPath.startsWith(prefix)) return false;
String relative = requestPath.substring(prefix.length());
if (relative.isEmpty() || "/".equals(relative)) return false;
if (!relative.startsWith("/") || relative.contains("..") || relative.contains("\\")) return false;
String routePath = relative;
byte[] raw = read(routePath);
if (raw == null) return false;
String etag = etag(raw);
String inm = req.header("If-None-Match");
if (etag.equals(inm)) {
res.status(HttpStatus.NOT_MODIFIED);
res.header("ETag", etag);
res.header("Vary", "Accept-Encoding");
res.header("Cache-Control", cacheControl(isVersioned(routePath, req)));
return true;
}
boolean gzipAccepted = accepts(req, "gzip");
byte[] gz = gzipAccepted ? read(routePath + ".gz") : null;
byte[] selected = gz != null ? gz : raw;
res.type(mime(routePath));
res.header("ETag", etag);
res.header("Cache-Control", cacheControl(isVersioned(routePath, req)));
res.header("Vary", "Accept-Encoding");
if (gz != null) res.header("Content-Encoding", "gzip");
if (headOnly) {
res.body(new byte[0]);
return true;
}
if (selected.length >= largeThreshold) {
String range = req.header("Range");
if (gz == null && range != null) {
RangeSlice slice = parseRange(range, selected.length);
if (slice == null) {
res.status(416);
res.header("Content-Range", "bytes */" + selected.length);
res.body(new byte[0]);
return true;
}
res.status(HttpStatus.PARTIAL_CONTENT);
res.header("Accept-Ranges", "bytes");
res.header("Content-Range", "bytes " + slice.start + "-" + slice.end + "/" + selected.length);
res.stream(new ByteArrayInputStream(selected, slice.start, slice.length), slice.length);
return true;
}
res.stream(new ByteArrayInputStream(selected), selected.length);
return true;
}
res.body(selected);
return true;
}
static String etagFor(byte[] raw) { return etag(raw); }
static String mimeFor(String routePath) { return mime(routePath); }
static boolean versionedPath(String routePath) { return VERSIONED_FILE.matcher(routePath).matches(); }
static int rangeLength(String rangeHeader, int size) {
RangeSlice slice = parseRange(rangeHeader, size);
return slice == null ? -1 : slice.length;
}
private static String cacheControl(boolean versioned) {
return versioned ? "public, max-age=31536000, immutable" : "no-cache";
}
private static boolean accepts(Request req, String encoding) {
String value = req.header("Accept-Encoding");
return value != null && value.contains(encoding);
}
private static byte[] read(String routePath) {
String cp = STATIC_ROOT + routePath;
try (InputStream in = resource(cp.startsWith("/") ? cp.substring(1) : cp)) {
return in == null ? null : in.readAllBytes();
} catch (Exception e) {
throw new IllegalStateException("Failed to read static asset: " + routePath, e);
}
}
private static InputStream resource(String path) {
return JteStaticServing.class.getClassLoader().getResourceAsStream(path);
}
private static String etag(byte[] raw) {
CRC32 crc = new CRC32();
crc.update(raw, 0, raw.length);
return '"' + Long.toHexString(crc.getValue()) + '-' + raw.length + '"';
}
private static String mime(String routePath) {
int dot = routePath.lastIndexOf('.');
if (dot < 0 || dot == routePath.length() - 1) return "application/octet-stream";
String ext = routePath.substring(dot + 1).toLowerCase();
return MIME.getOrDefault(ext, "application/octet-stream");
}
private static boolean isVersioned(String routePath, Request req) {
if (VERSIONED_FILE.matcher(routePath).matches()) return true;
return req.query("v") != null || req.query("hash") != null;
}
private static RangeSlice parseRange(String rangeHeader, int size) {
if (!rangeHeader.startsWith("bytes=")) return null;
String value = rangeHeader.substring(6).trim();
int comma = value.indexOf(',');
if (comma >= 0) value = value.substring(0, comma).trim();
int dash = value.indexOf('-');
if (dash < 0) return null;
String startPart = value.substring(0, dash).trim();
String endPart = value.substring(dash + 1).trim();
try {
int start;
int end;
if (startPart.isEmpty()) {
int suffixLen = Integer.parseInt(endPart);
if (suffixLen <= 0) return null;
if (suffixLen > size) suffixLen = size;
start = size - suffixLen;
end = size - 1;
} else {
start = Integer.parseInt(startPart);
if (start < 0 || start >= size) return null;
end = endPart.isEmpty() ? size - 1 : Integer.parseInt(endPart);
if (end < start) return null;
if (end >= size) end = size - 1;
}
return new RangeSlice(start, end, end - start + 1);
} catch (NumberFormatException ex) {
return null;
}
}
private record RangeSlice(int start, int end, int length) {}
}
@@ -8,6 +8,8 @@ import java.nio.file.Path;
* jte runtime settings with Flash-sensitive defaults.
*/
public final class JteSettings {
private static final long DEFAULT_LARGE_FILE_THRESHOLD_BYTES = 64L * 1024L;
private final String templateRoot;
private final gg.jte.ContentType contentType;
private final boolean developmentMode;
@@ -15,6 +17,14 @@ public final class JteSettings {
private final boolean binaryStaticContent;
private final Path dynamicClassesPath;
private final Path precompiledClassesPath;
private final boolean serveStatics;
private final String staticPrefix;
private final long largeFileThresholdBytes;
private final boolean staticCorsEnabled;
private final String staticCorsAllowOrigin;
private final String staticCorsAllowMethods;
private final String staticCorsAllowHeaders;
private final int staticCorsMaxAge;
private JteSettings(Builder b) {
this.templateRoot = b.templateRoot;
@@ -24,6 +34,14 @@ public final class JteSettings {
this.binaryStaticContent = b.binaryStaticContent;
this.dynamicClassesPath = b.dynamicClassesPath;
this.precompiledClassesPath = b.precompiledClassesPath;
this.serveStatics = b.serveStatics;
this.staticPrefix = b.staticPrefix;
this.largeFileThresholdBytes = b.largeFileThresholdBytes;
this.staticCorsEnabled = b.staticCorsEnabled;
this.staticCorsAllowOrigin = b.staticCorsAllowOrigin;
this.staticCorsAllowMethods = b.staticCorsAllowMethods;
this.staticCorsAllowHeaders = b.staticCorsAllowHeaders;
this.staticCorsMaxAge = b.staticCorsMaxAge;
}
public static Builder builder() {
@@ -37,6 +55,34 @@ public final class JteSettings {
boolean binaryStaticContent() { return binaryStaticContent; }
Path dynamicClassesPath() { return dynamicClassesPath; }
Path precompiledClassesPath() { return precompiledClassesPath; }
boolean serveStatics() { return serveStatics; }
String staticPrefix() { return staticPrefix; }
long largeFileThresholdBytes() { return largeFileThresholdBytes; }
boolean staticCorsEnabled() { return staticCorsEnabled; }
String staticCorsAllowOrigin() { return staticCorsAllowOrigin; }
String staticCorsAllowMethods() { return staticCorsAllowMethods; }
String staticCorsAllowHeaders() { return staticCorsAllowHeaders; }
int staticCorsMaxAge() { return staticCorsMaxAge; }
Builder toBuilder() {
Builder b = new Builder();
b.templateRoot = templateRoot;
b.contentType = contentType;
b.developmentMode = developmentMode;
b.usePrecompiled = usePrecompiled;
b.binaryStaticContent = binaryStaticContent;
b.dynamicClassesPath = dynamicClassesPath;
b.precompiledClassesPath = precompiledClassesPath;
b.serveStatics = serveStatics;
b.staticPrefix = staticPrefix;
b.largeFileThresholdBytes = largeFileThresholdBytes;
b.staticCorsEnabled = staticCorsEnabled;
b.staticCorsAllowOrigin = staticCorsAllowOrigin;
b.staticCorsAllowMethods = staticCorsAllowMethods;
b.staticCorsAllowHeaders = staticCorsAllowHeaders;
b.staticCorsMaxAge = staticCorsMaxAge;
return b;
}
public static final class Builder {
private String templateRoot = "/templates";
@@ -46,6 +92,14 @@ public final class JteSettings {
private boolean binaryStaticContent;
private Path dynamicClassesPath = Path.of("jte-classes");
private Path precompiledClassesPath = Path.of("jte-classes");
private boolean serveStatics = true;
private String staticPrefix = "/static";
private long largeFileThresholdBytes = DEFAULT_LARGE_FILE_THRESHOLD_BYTES;
private boolean staticCorsEnabled;
private String staticCorsAllowOrigin = "*";
private String staticCorsAllowMethods = "GET,HEAD,OPTIONS";
private String staticCorsAllowHeaders = "*";
private int staticCorsMaxAge = 3600;
public Builder templateRoot(String templateRoot) {
String root = templateRoot == null ? "" : templateRoot.trim();
@@ -84,6 +138,52 @@ public final class JteSettings {
return this;
}
public Builder serveStatics(boolean serveStatics) {
this.serveStatics = serveStatics;
return this;
}
public Builder staticPrefix(String staticPrefix) {
this.staticPrefix = normalizeStaticPrefix(staticPrefix);
return this;
}
public Builder largeFileThresholdBytes(long largeFileThresholdBytes) {
if (largeFileThresholdBytes <= 0) {
throw new IllegalArgumentException("largeFileThresholdBytes must be > 0");
}
this.largeFileThresholdBytes = largeFileThresholdBytes;
return this;
}
public Builder enableStaticCors(boolean enabled) {
this.staticCorsEnabled = enabled;
return this;
}
public Builder staticCorsAllowOrigin(String allowOrigin) {
this.staticCorsAllowOrigin = requireCorsValue(allowOrigin, "staticCorsAllowOrigin");
return this;
}
public Builder staticCorsAllowMethods(String allowMethods) {
this.staticCorsAllowMethods = requireCorsValue(allowMethods, "staticCorsAllowMethods");
return this;
}
public Builder staticCorsAllowHeaders(String allowHeaders) {
this.staticCorsAllowHeaders = requireCorsValue(allowHeaders, "staticCorsAllowHeaders");
return this;
}
public Builder staticCorsMaxAge(int maxAgeSeconds) {
if (maxAgeSeconds < 0) {
throw new IllegalArgumentException("staticCorsMaxAge must be >= 0");
}
this.staticCorsMaxAge = maxAgeSeconds;
return this;
}
public JteSettings build() {
boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV;
boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev;
@@ -95,6 +195,14 @@ public final class JteSettings {
resolved.binaryStaticContent = this.binaryStaticContent;
resolved.dynamicClassesPath = this.dynamicClassesPath;
resolved.precompiledClassesPath = this.precompiledClassesPath;
resolved.serveStatics = this.serveStatics;
resolved.staticPrefix = this.staticPrefix;
resolved.largeFileThresholdBytes = this.largeFileThresholdBytes;
resolved.staticCorsEnabled = this.staticCorsEnabled;
resolved.staticCorsAllowOrigin = this.staticCorsAllowOrigin;
resolved.staticCorsAllowMethods = this.staticCorsAllowMethods;
resolved.staticCorsAllowHeaders = this.staticCorsAllowHeaders;
resolved.staticCorsMaxAge = this.staticCorsMaxAge;
return new JteSettings(resolved);
}
@@ -105,5 +213,22 @@ public final class JteSettings {
if (normalized.isEmpty()) return "/";
return normalized;
}
private static String normalizeStaticPrefix(String prefix) {
String normalized = prefix == null ? "" : prefix.trim().replace('\\', '/');
if (normalized.isEmpty()) throw new IllegalArgumentException("staticPrefix must not be blank");
if (!normalized.startsWith("/")) normalized = '/' + normalized;
while (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private static String requireCorsValue(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.trim();
}
}
}
@@ -0,0 +1,50 @@
package dev.relism.ext.view.jte;
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.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JteStaticServingTest {
@Test
void etag_is_stable_for_same_payload_and_changes_for_different() {
byte[] a = "hello".getBytes();
byte[] b = "hello".getBytes();
byte[] c = "hello!".getBytes();
String etagA = JteStaticServing.etagFor(a);
String etagB = JteStaticServing.etagFor(b);
String etagC = JteStaticServing.etagFor(c);
assertEquals(etagA, etagB);
assertNotEquals(etagA, etagC);
}
@Test
void mime_resolution_supports_common_extensions_and_defaults() {
assertEquals("text/css", JteStaticServing.mimeFor("/sample.css"));
assertEquals("text/javascript", JteStaticServing.mimeFor("/sample.js"));
assertEquals("application/wasm", JteStaticServing.mimeFor("/sample.wasm"));
assertEquals("application/octet-stream", JteStaticServing.mimeFor("/sample.unknown"));
assertEquals("application/octet-stream", JteStaticServing.mimeFor("/sample"));
}
@Test
void versioned_path_detection_by_filename_hash() {
assertTrue(JteStaticServing.versionedPath("/app.4f3a2c1b.js"));
assertTrue(JteStaticServing.versionedPath("/app.abcdefabcdef.js"));
assertFalse(JteStaticServing.versionedPath("/app.js"));
}
@Test
void range_parsing_supports_basic_cases() {
assertEquals(10, JteStaticServing.rangeLength("bytes=0-9", 100));
assertEquals(100, JteStaticServing.rangeLength("bytes=0-", 100));
assertEquals(10, JteStaticServing.rangeLength("bytes=-10", 100));
assertEquals(-1, JteStaticServing.rangeLength("bytes=100-120", 100));
assertEquals(-1, JteStaticServing.rangeLength("invalid", 100));
}
}
@@ -8,6 +8,11 @@ import dev.relism.flash.routing.GET;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@@ -65,6 +70,69 @@ class JteExtensionTest {
assertTrue(ex.getMessage().contains("must declare @Template"));
}
@Test
void fluent_static_settings_are_applied() {
JteExtension ext = new JteExtension()
.templateRoot("templates")
.serveStatics(false)
.staticPrefix("assets")
.withStaticCors()
.staticCors(cfg -> cfg
.staticCorsAllowOrigin("https://cdn.example.com"));
JteSettings settings = readSettings(ext);
assertEquals("/templates", settings.templateRoot());
assertFalse(settings.serveStatics());
assertEquals("/assets", settings.staticPrefix());
assertTrue(settings.staticCorsEnabled());
assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin());
}
@Test
void routes_register_static_wildcard_when_enabled() {
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.extension.FlashContext());
assertTrue(app.routes.containsKey("GET /assets/**"));
assertTrue(app.routes.containsKey("HEAD /assets/**"));
}
@Test
void routes_do_not_register_static_when_disabled() {
JteExtension ext = new JteExtension().serveStatics(false);
TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.extension.FlashContext());
assertTrue(app.routes.isEmpty());
}
@Test
void static_handler_serves_existing_asset() throws Exception {
JteExtension ext = new JteExtension(cfg -> cfg
.staticPrefix("/assets")
.largeFileThresholdBytes(1));
TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.extension.FlashContext());
Request req = request("/assets/sample.css", null, null, null);
Response res = new Response(200, dev.relism.http.ContentType.TEXT_PLAIN);
Object out = app.routes.get("GET /assets/**").handle(req, res);
assertEquals(null, out);
assertEquals("text/css", new String(res.getContentType(), StandardCharsets.UTF_8));
assertTrue(res.isStreaming() || res.getBody() != null);
assertEquals(200, res.getStatusCode());
}
private static JteSettings readSettings(JteExtension ext) {
try {
var f = JteExtension.class.getDeclaredField("settings");
f.setAccessible(true);
return (JteSettings) f.get(ext);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static void invokeValidate(JteExtension ext, Class<? extends RequestHandler> type) throws Exception {
Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class);
m.setAccessible(true);
@@ -77,4 +145,42 @@ class JteExtensionTest {
throw new RuntimeException(cause);
}
}
private static Request request(String path, String query, String acceptEncoding, String origin) {
dev.relism.models.HeaderMap headers = new dev.relism.models.HeaderMap();
String raw = "Host: localhost\r\n";
if (acceptEncoding != null) raw += "Accept-Encoding: " + acceptEncoding + "\r\n";
if (origin != null) raw += "Origin: " + origin + "\r\n";
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
headers.reset(bytes, 0, bytes.length);
dev.relism.models.RequestLine line = new dev.relism.models.RequestLine(
dev.relism.http.HttpMethod.GET,
new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView(path),
query == null ? null : new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView(query),
new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView("HTTP/1.1"),
headers
);
return new Request(line, new byte[0]);
}
private static final class TestRegistrar extends dev.relism.extension.FlashRegistrar<TestRegistrar> {
private final Map<String, RequestHandler> routes = new HashMap<>();
private final List<dev.relism.routing.Middleware> mws = new ArrayList<>();
@Override
public dev.relism.extension.FlashContext ctx() {
return new dev.relism.extension.FlashContext();
}
@Override
protected void addRoute(dev.relism.http.HttpMethod method, String path, RequestHandler handler, List<dev.relism.routing.Middleware> mw) {
routes.put(method.name() + " " + path, handler);
}
@Override
protected void addMiddleware(dev.relism.routing.Middleware mw) {
mws.add(mw);
}
}
}
@@ -20,6 +20,14 @@ class JteSettingsTest {
assertFalse(settings.binaryStaticContent());
assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath());
assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath());
assertTrue(settings.serveStatics());
assertEquals("/static", settings.staticPrefix());
assertEquals(64L * 1024L, settings.largeFileThresholdBytes());
assertFalse(settings.staticCorsEnabled());
assertEquals("*", settings.staticCorsAllowOrigin());
assertEquals("GET,HEAD,OPTIONS", settings.staticCorsAllowMethods());
assertEquals("*", settings.staticCorsAllowHeaders());
assertEquals(3600, settings.staticCorsMaxAge());
}
@Test
@@ -32,6 +40,14 @@ class JteSettingsTest {
.binaryStaticContent(true)
.dynamicClassesPath(Path.of("var", "jte-dev"))
.precompiledClassesPath(Path.of("var", "jte-prod"))
.serveStatics(false)
.staticPrefix("assets")
.largeFileThresholdBytes(1024)
.enableStaticCors(true)
.staticCorsAllowOrigin("https://cdn.example.com")
.staticCorsAllowMethods("GET,HEAD")
.staticCorsAllowHeaders("Origin,Content-Type")
.staticCorsMaxAge(120)
.build();
assertEquals("/src/main/jte", settings.templateRoot());
@@ -41,6 +57,14 @@ class JteSettingsTest {
assertTrue(settings.binaryStaticContent());
assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath());
assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath());
assertFalse(settings.serveStatics());
assertEquals("/assets", settings.staticPrefix());
assertEquals(1024, settings.largeFileThresholdBytes());
assertTrue(settings.staticCorsEnabled());
assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin());
assertEquals("GET,HEAD", settings.staticCorsAllowMethods());
assertEquals("Origin,Content-Type", settings.staticCorsAllowHeaders());
assertEquals(120, settings.staticCorsMaxAge());
}
@Test
@@ -60,10 +84,22 @@ class JteSettingsTest {
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" "));
}
@Test
void staticPrefix_normalizes_andRejectsBlank() {
assertEquals("/static", JteSettings.builder().staticPrefix("static/").build().staticPrefix());
assertEquals("/assets", JteSettings.builder().staticPrefix("/assets/").build().staticPrefix());
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticPrefix(" "));
}
@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));
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().largeFileThresholdBytes(0));
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowOrigin(" "));
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowMethods(" "));
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowHeaders(" "));
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsMaxAge(-1));
}
}
@@ -0,0 +1 @@
body { color: #111; }
@@ -0,0 +1 @@
gzipped-sample