add static asset serving support with configurable options

This commit is contained in:
Relism
2026-04-26 12:27:59 +02:00
parent 6ef11ca595
commit c2e3f98ea2
15 changed files with 697 additions and 19 deletions
@@ -7,6 +7,11 @@ import dev.relism.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.*;
@@ -64,6 +69,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);
@@ -76,4 +144,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,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));
}
}
@@ -0,0 +1 @@
body { color: #111; }
@@ -0,0 +1 @@
gzipped-sample