add static asset serving support with configurable options
This commit is contained in:
@@ -15,6 +15,59 @@ The extension mirrors `gg.jte.ContentType` into Flash HTTP content type:
|
|||||||
- `gg.jte.ContentType.Html` -> `text/html`
|
- `gg.jte.ContentType.Html` -> `text/html`
|
||||||
- `gg.jte.ContentType.Plain` -> `text/plain`
|
- `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
|
## Quick Start
|
||||||
|
|
||||||
```java
|
```java
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
- Runtime injects globals under reserved `global` namespace and merges local model.
|
- Runtime injects globals under reserved `global` namespace and merges local model.
|
||||||
- jte renders template into `StringOutput`.
|
- 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
|
## Handler Contract
|
||||||
|
|
||||||
- Must extend `JteHandler`.
|
- Must extend `JteHandler`.
|
||||||
@@ -27,3 +33,7 @@ Invalid configurations fail fast at startup.
|
|||||||
- `developmentMode`: `Flash.DEV`
|
- `developmentMode`: `Flash.DEV`
|
||||||
- `usePrecompiled`: derived from `!developmentMode` unless explicitly set
|
- `usePrecompiled`: derived from `!developmentMode` unless explicitly set
|
||||||
- `binaryStaticContent`: `false`
|
- `binaryStaticContent`: `false`
|
||||||
|
- `serveStatics`: `true`
|
||||||
|
- `staticPrefix`: `/static`
|
||||||
|
- `largeFileThresholdBytes`: `65536`
|
||||||
|
- static CORS: disabled (`false`)
|
||||||
|
|||||||
BIN
Binary file not shown.
+5
-5
@@ -6,19 +6,19 @@ public final class JtehomeGenerated {
|
|||||||
public static final String JTE_NAME = "pages/home.jte";
|
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 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) {
|
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.setContext("h1", null);
|
||||||
jteOutput.writeUserContent(page.title());
|
jteOutput.writeUserContent(page.title());
|
||||||
jteOutput.writeContent("</h1>\n<p>");
|
jteOutput.writeContent("</h1>\r\n<p>");
|
||||||
jteOutput.setContext("p", null);
|
jteOutput.setContext("p", null);
|
||||||
jteOutput.writeUserContent(page.author());
|
jteOutput.writeUserContent(page.author());
|
||||||
jteOutput.writeContent("</p>\n<small>");
|
jteOutput.writeContent("</p>\r\n<small>");
|
||||||
jteOutput.setContext("small", null);
|
jteOutput.setContext("small", null);
|
||||||
jteOutput.writeUserContent(build);
|
jteOutput.writeUserContent(build);
|
||||||
jteOutput.writeContent("</small>\n<small>");
|
jteOutput.writeContent("</small>\r\n<small>");
|
||||||
jteOutput.setContext("small", null);
|
jteOutput.setContext("small", null);
|
||||||
jteOutput.writeUserContent((String) global.get("appName"));
|
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) {
|
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");
|
HomePage page = (HomePage)params.get("page");
|
||||||
|
|||||||
+86
-1
@@ -3,8 +3,11 @@ package dev.relism.ext.view.jte;
|
|||||||
import dev.relism.ext.view.core.BaseViewExtension;
|
import dev.relism.ext.view.core.BaseViewExtension;
|
||||||
import dev.relism.ext.view.core.GlobalValue;
|
import dev.relism.ext.view.core.GlobalValue;
|
||||||
import dev.relism.ext.view.core.ViewRuntimeBridge;
|
import dev.relism.ext.view.core.ViewRuntimeBridge;
|
||||||
|
import dev.relism.extension.FlashContext;
|
||||||
|
import dev.relism.extension.FlashRegistrar;
|
||||||
import dev.relism.models.Request;
|
import dev.relism.models.Request;
|
||||||
import dev.relism.models.RequestHandler;
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -14,7 +17,7 @@ import java.util.function.Function;
|
|||||||
* Opinionated jte SSR extension for Flash.
|
* Opinionated jte SSR extension for Flash.
|
||||||
*/
|
*/
|
||||||
public final class JteExtension extends BaseViewExtension<JteTarget> {
|
public final class JteExtension extends BaseViewExtension<JteTarget> {
|
||||||
private final JteSettings settings;
|
private JteSettings settings;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
ensureJtePresent();
|
ensureJtePresent();
|
||||||
@@ -34,6 +37,38 @@ public final class JteExtension extends BaseViewExtension<JteTarget> {
|
|||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public JteExtension templateRoot(String templateRoot) {
|
||||||
|
settings = settings.toBuilder().templateRoot(templateRoot).build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension serveStatics(boolean enabled) {
|
||||||
|
settings = settings.toBuilder().serveStatics(enabled).build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension withStaticServing() {
|
||||||
|
return serveStatics(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension staticPrefix(String staticPrefix) {
|
||||||
|
settings = settings.toBuilder().staticPrefix(staticPrefix).build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension staticCors(Consumer<JteSettings.Builder> customizer) {
|
||||||
|
java.util.Objects.requireNonNull(customizer, "customizer must not be null");
|
||||||
|
JteSettings.Builder b = settings.toBuilder();
|
||||||
|
customizer.accept(b);
|
||||||
|
settings = b.build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JteExtension withStaticCors() {
|
||||||
|
settings = settings.toBuilder().enableStaticCors(true).build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JteExtension addGlobal(String key, Function<Request, Object> resolver) {
|
public JteExtension addGlobal(String key, Function<Request, Object> resolver) {
|
||||||
super.addGlobal(key, resolver);
|
super.addGlobal(key, resolver);
|
||||||
@@ -60,6 +95,56 @@ public final class JteExtension extends BaseViewExtension<JteTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
if (!settings.serveStatics()) return;
|
||||||
|
|
||||||
|
JteStaticServing staticServing = JteStaticServing.load(settings);
|
||||||
|
if (staticServing == null) return;
|
||||||
|
|
||||||
|
String prefix = settings.staticPrefix();
|
||||||
|
String wildcard = "/".equals(prefix) ? "/**" : prefix + "/**";
|
||||||
|
Middleware cors = staticCorsMiddleware();
|
||||||
|
|
||||||
|
if (settings.staticCorsEnabled()) {
|
||||||
|
app.options(wildcard, (req, res) -> {
|
||||||
|
applyCors(req, res);
|
||||||
|
res.status(204);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cors != null) {
|
||||||
|
app.get(wildcard, (req, res) -> staticServing.serve(req, res, false) ? null : notFound(res), cors);
|
||||||
|
app.head(wildcard, (req, res) -> staticServing.serve(req, res, true) ? null : notFound(res), cors);
|
||||||
|
} else {
|
||||||
|
app.get(wildcard, (req, res) -> staticServing.serve(req, res, false) ? null : notFound(res));
|
||||||
|
app.head(wildcard, (req, res) -> staticServing.serve(req, res, true) ? null : notFound(res));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Middleware staticCorsMiddleware() {
|
||||||
|
if (!settings.staticCorsEnabled()) return null;
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
applyCors(req, res);
|
||||||
|
return next.handle(req, res);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object notFound(dev.relism.models.Response res) {
|
||||||
|
res.status(404);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCors(dev.relism.models.Request req, dev.relism.models.Response res) {
|
||||||
|
String origin = req.header("Origin");
|
||||||
|
if (origin == null || origin.isBlank()) return;
|
||||||
|
res.header("Access-Control-Allow-Origin", settings.staticCorsAllowOrigin());
|
||||||
|
res.header("Access-Control-Allow-Methods", settings.staticCorsAllowMethods());
|
||||||
|
res.header("Access-Control-Allow-Headers", settings.staticCorsAllowHeaders());
|
||||||
|
res.header("Access-Control-Max-Age", Integer.toString(settings.staticCorsMaxAge()));
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureJtePresent() {
|
private static void ensureJtePresent() {
|
||||||
try {
|
try {
|
||||||
Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader());
|
Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader());
|
||||||
|
|||||||
+125
@@ -8,6 +8,8 @@ import java.nio.file.Path;
|
|||||||
* jte runtime settings with Flash-sensitive defaults.
|
* jte runtime settings with Flash-sensitive defaults.
|
||||||
*/
|
*/
|
||||||
public final class JteSettings {
|
public final class JteSettings {
|
||||||
|
private static final long DEFAULT_LARGE_FILE_THRESHOLD_BYTES = 64L * 1024L;
|
||||||
|
|
||||||
private final String templateRoot;
|
private final String templateRoot;
|
||||||
private final gg.jte.ContentType contentType;
|
private final gg.jte.ContentType contentType;
|
||||||
private final boolean developmentMode;
|
private final boolean developmentMode;
|
||||||
@@ -15,6 +17,14 @@ public final class JteSettings {
|
|||||||
private final boolean binaryStaticContent;
|
private final boolean binaryStaticContent;
|
||||||
private final Path dynamicClassesPath;
|
private final Path dynamicClassesPath;
|
||||||
private final Path precompiledClassesPath;
|
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) {
|
private JteSettings(Builder b) {
|
||||||
this.templateRoot = b.templateRoot;
|
this.templateRoot = b.templateRoot;
|
||||||
@@ -24,6 +34,14 @@ public final class JteSettings {
|
|||||||
this.binaryStaticContent = b.binaryStaticContent;
|
this.binaryStaticContent = b.binaryStaticContent;
|
||||||
this.dynamicClassesPath = b.dynamicClassesPath;
|
this.dynamicClassesPath = b.dynamicClassesPath;
|
||||||
this.precompiledClassesPath = b.precompiledClassesPath;
|
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() {
|
public static Builder builder() {
|
||||||
@@ -37,6 +55,34 @@ public final class JteSettings {
|
|||||||
boolean binaryStaticContent() { return binaryStaticContent; }
|
boolean binaryStaticContent() { return binaryStaticContent; }
|
||||||
Path dynamicClassesPath() { return dynamicClassesPath; }
|
Path dynamicClassesPath() { return dynamicClassesPath; }
|
||||||
Path precompiledClassesPath() { return precompiledClassesPath; }
|
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 {
|
public static final class Builder {
|
||||||
private String templateRoot = "/templates";
|
private String templateRoot = "/templates";
|
||||||
@@ -46,6 +92,14 @@ public final class JteSettings {
|
|||||||
private boolean binaryStaticContent;
|
private boolean binaryStaticContent;
|
||||||
private Path dynamicClassesPath = Path.of("jte-classes");
|
private Path dynamicClassesPath = Path.of("jte-classes");
|
||||||
private Path precompiledClassesPath = 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) {
|
public Builder templateRoot(String templateRoot) {
|
||||||
String root = templateRoot == null ? "" : templateRoot.trim();
|
String root = templateRoot == null ? "" : templateRoot.trim();
|
||||||
@@ -84,6 +138,52 @@ public final class JteSettings {
|
|||||||
return this;
|
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() {
|
public JteSettings build() {
|
||||||
boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV;
|
boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV;
|
||||||
boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev;
|
boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev;
|
||||||
@@ -95,6 +195,14 @@ public final class JteSettings {
|
|||||||
resolved.binaryStaticContent = this.binaryStaticContent;
|
resolved.binaryStaticContent = this.binaryStaticContent;
|
||||||
resolved.dynamicClassesPath = this.dynamicClassesPath;
|
resolved.dynamicClassesPath = this.dynamicClassesPath;
|
||||||
resolved.precompiledClassesPath = this.precompiledClassesPath;
|
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);
|
return new JteSettings(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,5 +213,22 @@ public final class JteSettings {
|
|||||||
if (normalized.isEmpty()) return "/";
|
if (normalized.isEmpty()) return "/";
|
||||||
return normalized;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+205
@@ -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) {}
|
||||||
|
}
|
||||||
+106
@@ -7,6 +7,11 @@ import dev.relism.routing.GET;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
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.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@@ -64,6 +69,69 @@ class JteExtensionTest {
|
|||||||
assertTrue(ex.getMessage().contains("must declare @Template"));
|
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 {
|
private static void invokeValidate(JteExtension ext, Class<? extends RequestHandler> type) throws Exception {
|
||||||
Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class);
|
Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class);
|
||||||
m.setAccessible(true);
|
m.setAccessible(true);
|
||||||
@@ -76,4 +144,42 @@ class JteExtensionTest {
|
|||||||
throw new RuntimeException(cause);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -20,6 +20,14 @@ class JteSettingsTest {
|
|||||||
assertFalse(settings.binaryStaticContent());
|
assertFalse(settings.binaryStaticContent());
|
||||||
assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath());
|
assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath());
|
||||||
assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath());
|
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
|
@Test
|
||||||
@@ -32,6 +40,14 @@ class JteSettingsTest {
|
|||||||
.binaryStaticContent(true)
|
.binaryStaticContent(true)
|
||||||
.dynamicClassesPath(Path.of("var", "jte-dev"))
|
.dynamicClassesPath(Path.of("var", "jte-dev"))
|
||||||
.precompiledClassesPath(Path.of("var", "jte-prod"))
|
.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();
|
.build();
|
||||||
|
|
||||||
assertEquals("/src/main/jte", settings.templateRoot());
|
assertEquals("/src/main/jte", settings.templateRoot());
|
||||||
@@ -41,6 +57,14 @@ class JteSettingsTest {
|
|||||||
assertTrue(settings.binaryStaticContent());
|
assertTrue(settings.binaryStaticContent());
|
||||||
assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath());
|
assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath());
|
||||||
assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath());
|
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
|
@Test
|
||||||
@@ -60,10 +84,22 @@ class JteSettingsTest {
|
|||||||
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" "));
|
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
|
@Test
|
||||||
void nulls_areRejectedForRequiredObjects() {
|
void nulls_areRejectedForRequiredObjects() {
|
||||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null));
|
assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null));
|
||||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null));
|
assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null));
|
||||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().precompiledClassesPath(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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -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
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package dev.relism.http;
|
package dev.relism.http;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
import dev.relism.routing.GET;
|
||||||
|
import dev.relism.routing.Route;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of standard HTTP status lines.
|
* Pre-compiled byte representations of standard HTTP status lines.
|
||||||
|
|||||||
Reference in New Issue
Block a user