Repository: tipsy/javalin
Pull request #960 (2020-05-12 13:43:09)

Add precompression handler
For issue #892 and PR #932.
I add a precompressStaticFiles config  and two parameters in JettyResourceHandler's construct.


vn7n24fzkq: But precompesssion doesn't support range request.
If needed I can pass the ResourceHandler in JettyResourceHandler to PrecompressionResourceHandler for handle range request.
tipsy: Nice! I think this looks like a nicer approach than the previous pull request. I'm a bit busy these days, but I'll try to have a look sometime during the next week.
tipsy: I merged a PR to prefix static files, so you'll have to merge that into your PR :)
vn7n24fzkq: Thank you. I just merged it.
tipsy: Great, will review this tomorrow!
tipsy: Nice work @vn7n24fzkq ! I like this a lot more than the previous version, but I had a few comments.
vn7n24fzkq: I accidentally push.
vn7n24fzkq: Now, it should be fine.
vn7n24fzkq: I modified every things except this line https://github.com/tipsy/javalin/pull/960#discussion_r429648795
(Not sure what I need do 😕)
vn7n24fzkq: Hi, @tipsy.
I added some commits, could you review this?

Thank you for your time!
Good day! :)
tipsy: Almost done, I think! Should we add a few more tests? If I remember correctly, one of the reasons you started on this work in the first place was because larger files did not contain content length. Maybe one test could serve a larger file (via WebJar?) and confirm that the length is set?
vn7n24fzkq: Yes, I will a few more tests.
vn7n24fzkq: Tests added. :)
vn7n24fzkq: Sure, the negative test added.
vn7n24fzkq: Sorry, the test is positive test.
I will rewrite and push again.  
vn7n24fzkq: Updated.
tipsy: Looks good, thanks for your hard work @vn7n24fzkq ! 

Patch:
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index cef739ae..ba552d63 100644
--- a/javalin/src/main/java/io/javalin/core/JavalinConfig.java
+++ b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
@@ -48,6 +48,7 @@ public class JavalinConfig {
     public boolean autogenerateEtags = false;
     public boolean prefer405over404 = false;
     public boolean enforceSsl = false;
+    public boolean precompressStaticFiles = false;
     public boolean showJavalinBanner = true;
     public boolean logIfServerNotStarted = true;
     @NotNull public String defaultContentType = "text/plain";
@@ -119,7 +120,8 @@ public class JavalinConfig {
 
     public JavalinConfig addStaticFiles(@NotNull String urlPathPrefix, @NotNull String path, @NotNull Location location) {
         JettyUtil.disableJettyLogger();
-        if (inner.resourceHandler == null) inner.resourceHandler = new JettyResourceHandler();
+        if (inner.resourceHandler == null)
+            inner.resourceHandler = new JettyResourceHandler(precompressStaticFiles);
         inner.resourceHandler.addStaticFileConfig(new StaticFileConfig(urlPathPrefix, path, location));
         return this;
     }
diff --git a/javalin/src/main/java/io/javalin/core/util/Util.kt b/javalin/src/main/java/io/javalin/core/util/Util.kt
index 48114fe0..ce40cc85 100644
--- a/javalin/src/main/java/io/javalin/core/util/Util.kt
+++ b/javalin/src/main/java/io/javalin/core/util/Util.kt
@@ -45,6 +45,13 @@ object Util {
         false
     }
 
+    fun dependencyIsPresent(dependency: OptionalDependency) = try {
+        ensureDependencyPresent(dependency)
+        true
+    } catch (e: Exception) {
+        false
+    }
+
     private val dependencyCheckCache = HashMap<String, Boolean>()
 
     fun ensureDependencyPresent(dependency: OptionalDependency, startupCheck: Boolean = false) {
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt b/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
index b7b05822..067a5855 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
@@ -7,8 +7,10 @@
 package io.javalin.http.staticfiles
 
 import io.javalin.Javalin
+import io.javalin.core.compression.CompressionStrategy
 import io.javalin.core.util.Header
 import io.javalin.core.util.Util
+import io.javalin.http.Context
 import io.javalin.http.JavalinResponseWrapper
 import org.eclipse.jetty.server.Request
 import org.eclipse.jetty.server.handler.ResourceHandler
@@ -18,10 +20,12 @@ import java.io.File
 import javax.servlet.http.HttpServletRequest
 import javax.servlet.http.HttpServletResponse
 
-class JettyResourceHandler : io.javalin.http.staticfiles.ResourceHandler {
+class JettyResourceHandler(val precompressStaticFiles: Boolean = false) : io.javalin.http.staticfiles.ResourceHandler {
 
     val handlers = mutableListOf<ResourceHandler>()
 
+    val precompressingResourceHandler = PrecompressingResourceHandler()
+
     override fun addStaticFileConfig(config: StaticFileConfig) {
         val handler = if (config.path == "/webjars") WebjarHandler() else PrefixableHandler(config.urlPathPrefix).apply {
             resourceBase = getResourcePath(config)
@@ -79,6 +83,9 @@ class JettyResourceHandler : io.javalin.http.staticfiles.ResourceHandler {
                     httpResponse.setHeader(Header.CACHE_CONTROL, "max-age=$maxAge")
                     // Remove the default content type because Jetty will not set the correct one
                     // if the HTTP response already has a content type set
+                    if(precompressStaticFiles && precompressingResourceHandler.handle(resource,httpRequest,httpResponse)) {
+                        return true
+                    }
                     httpResponse.contentType = null
                     handler.handle(target, baseRequest, httpRequest, httpResponse)
                     httpRequest.setAttribute("handled-as-static-file", true)
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/PrecompressingResourceHandler.kt b/javalin/src/main/java/io/javalin/http/staticfiles/PrecompressingResourceHandler.kt
new file mode 100644
index 00000000..05ed264d
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/PrecompressingResourceHandler.kt
@@ -0,0 +1,110 @@
+package io.javalin.http.staticfiles
+
+import io.javalin.Javalin
+import io.javalin.core.util.Header
+import io.javalin.core.util.OptionalDependency
+import io.javalin.core.util.Util
+import io.javalin.http.LeveledBrotliStream
+import io.javalin.http.LeveledGzipStream
+import org.eclipse.jetty.http.MimeTypes
+import org.eclipse.jetty.util.resource.Resource
+import java.io.*
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+
+class PrecompressingResourceHandler {
+
+    val compressedFiles = HashMap<String, ByteArray>()
+    val resourceMaxSize: Int = 2 * 1024 * 1024 // the unit of resourceMaxSize is byte
+
+    companion object {
+        val excludedMimeTypes = setOf(
+                "image/",
+                "audio/",
+                "video/",
+                "application/compress",
+                "application/zip",
+                "application/gzip",
+                "application/bzip2",
+                "application/brotli",
+                "application/x-xz",
+                "application/x-rar-compressed")
+    }
+
+    fun handle(resource: Resource, req: HttpServletRequest, res: HttpServletResponse): Boolean {
+        if (resource.exists() && !resource.isDirectory) {
+            val target = req.getAttribute("jetty-target") as String
+            var acceptCompressType = CompressType.getByAcceptEncoding(req.getHeader(Header.ACCEPT_ENCODING) ?: "")
+            val contentType = MimeTypes.getDefaultMimeByExtension(target)//get content type by file extension
+            if (excludedMimeType(contentType))
+                acceptCompressType = CompressType.NONE
+            val resultByteArray = getStaticResourceByteArray(resource, target, acceptCompressType) ?: return false
+            res.setContentLength(resultByteArray.size)
+            res.setHeader(Header.CONTENT_TYPE, contentType)
+
+            if (acceptCompressType != CompressType.NONE)
+                res.setHeader(Header.CONTENT_ENCODING, acceptCompressType.typeName)
+
+            val weakETag = resource.weakETag //jetty resource use weakETag too
+            req.getHeader(Header.IF_NONE_MATCH)?.let { etag ->
+                if (etag == weakETag) {
+                    res.status = 304
+                    return true
+                }
+            }
+            res.setHeader(Header.ETAG, weakETag)
+            resultByteArray.inputStream().copyTo(res.outputStream)
+            res.outputStream.close()
+            return true
+        }
+        return false
+    }
+
+    private fun getStaticResourceByteArray(resource: Resource, target: String, type: CompressType): ByteArray? {
+        if (resource.length() > resourceMaxSize) {
+            Javalin.log?.warn("PrecompressingResourceHandler can't handle " +
+                    "the resource of path : $target with size : ${resource.length()} byte, " +
+                    "because it is larger than resourceMaxSize : $resourceMaxSize byte")
+            return null
+        }
+        return compressedFiles.computeIfAbsent(target + type.extension) { getCompressedByteArray(resource, type) }
+    }
+
+    private fun getCompressedByteArray(resource: Resource, type: CompressType): ByteArray {
+        val fileInput = resource.inputStream
+        val byteArrayOutputStream = ByteArrayOutputStream()
+        val outputStream: OutputStream = when {
+            type == CompressType.GZIP -> {
+                LeveledGzipStream(byteArrayOutputStream, 9) // use max-level compression
+            }
+            Util.dependencyIsPresent(OptionalDependency.JVMBROTLI) && type == CompressType.BR -> {
+                LeveledBrotliStream(byteArrayOutputStream, 11) // use max-level compression
+            }
+            else -> byteArrayOutputStream
+        }
+        fileInput.copyTo(outputStream)
+        fileInput.close()
+        outputStream.close()
+        return byteArrayOutputStream.toByteArray()
+    }
+
+    private fun excludedMimeType(mimeType: String) =
+            if (mimeType == "") false else excludedMimeTypes.any { excluded -> mimeType.contains(excluded, ignoreCase = true) }
+
+    enum class CompressType(val typeName: String, val extension: String) {
+        GZIP("gzip", ".gz"),
+        BR("br", ".br"),
+        NONE("", "");
+
+        fun acceptEncoding(acceptEncoding: String): Boolean {
+            return acceptEncoding.contains(typeName, ignoreCase = true)
+        }
+
+        companion object {
+            fun getByAcceptEncoding(acceptEncoding: String): CompressType {
+                return values().find { it.acceptEncoding(acceptEncoding) } ?: NONE
+            }
+        }
+    }
+}


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestStaticFiles.kt b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
index 53bca4e7..9ebd7ad2 100644
--- a/javalin/src/test/java/io/javalin/TestStaticFiles.kt
+++ b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
@@ -7,11 +7,15 @@
 
 package io.javalin
 
+import io.javalin.core.compression.Brotli
+import io.javalin.core.compression.Gzip
 import io.javalin.core.util.Header
 import io.javalin.core.util.OptionalDependency
 import io.javalin.http.UnauthorizedResponse
 import io.javalin.http.staticfiles.Location
 import io.javalin.testing.TestUtil
+import okhttp3.OkHttpClient
+import okhttp3.Request
 import org.assertj.core.api.Assertions.assertThat
 import org.eclipse.jetty.server.ServletResponseHttpWrapper
 import org.eclipse.jetty.servlet.FilterHolder
@@ -63,6 +67,24 @@ class TestStaticFiles {
         }
     }
 
+    private val configPrecompressionStaticResourceApp: Javalin by lazy{
+        Javalin.create { config->
+            config.precompressStaticFiles = true
+            config.compressionStrategy(Brotli(), Gzip())
+            config.enableWebjars()
+            config.addStaticFiles("/public/immutable")
+            config.addStaticFiles("/public/protected")
+        }
+    }
+
+    private val precompressionNotEnableApp: Javalin by lazy{
+        Javalin.create { config->
+            config.precompressStaticFiles = false
+            config.compressionStrategy(Brotli(), Gzip())
+            config.enableWebjars()
+        }
+    }
+
     @Test
     fun `serving HTML from classpath works`() = TestUtil.test(defaultStaticResourceApp) { _, http ->
         assertThat(http.get("/html.html").status).isEqualTo(200)
@@ -157,6 +179,27 @@ class TestStaticFiles {
         assertThat(http.get("/html.html").headers.getFirst(Header.CONTENT_TYPE)).contains("text/html")
     }
 
+    @Test
+    fun `response header always contains content-length if precompressStaticFiles is true`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(getResponse(http.origin, "/secret.html", "br, gzip").headers().get(Header.CONTENT_LENGTH)).isNotBlank()
+        assertThat(getResponse(http.origin, "/library-1.0.0.min.js", "br").headers().get(Header.CONTENT_LENGTH)).isNotBlank()
+        assertThat(getResponse(http.origin, "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}/swagger-ui-bundle.js", "gzip").headers().get(Header.CONTENT_LENGTH)).isNotBlank()
+    }
+
+    @Test
+    fun `compression works when precompressStaticFiles is true`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(getResponse(http.origin, "/secret.html", "gzip").headers().get(Header.CONTENT_ENCODING)).isEqualTo("gzip")
+        assertThat(getResponse(http.origin, "/library-1.0.0.min.js", "br").headers().get(Header.CONTENT_ENCODING)).isEqualTo("br")
+        assertThat(getResponse(http.origin, "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}/swagger-ui-bundle.js", "gzip").headers().get(Header.CONTENT_ENCODING)).isEqualTo("gzip")
+        assertThat(getResponse(http.origin, "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}/swagger-ui.js.gz", "gzip").headers().get(Header.CONTENT_ENCODING)).isNull()
+    }
+
+    @Test(expected = NullPointerException::class)
+    fun `content-length does not works with large static file when precompressStaticFiles is false`() = TestUtil.test(precompressionNotEnableApp) { _, http ->
+        getResponse(http.origin, "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}/swagger-ui-bundle.js", "gzip").headers().get(Header.CONTENT_LENGTH)!!.toInt()
+        getResponse(http.origin, "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}/swagger-ui.js.gz", "gzip").headers().get(Header.CONTENT_LENGTH)!!.toInt()
+    }
+
     @Test
     fun `serving from custom url path works`() {
         TestUtil.test(Javalin.create { servlet ->
@@ -180,4 +223,12 @@ class TestStaticFiles {
             assertThat(http.get("/styles.css").status).isEqualTo(404) // access to other locations in /public is not allowed
         }
     }
+
+    // we need to use okhttp, because unirest omits the content-encoding and content-length header
+    private fun getResponse(origin: String, url: String, encoding: String) = OkHttpClient()
+            .newCall(Request.Builder()
+                    .url(origin + url)
+                    .header(Header.ACCEPT_ENCODING, encoding)
+                    .build())
+            .execute()
 }