Repository: tipsy/javalin
Pull request #969 (2020-05-16 04:38:56)

Sec-WebSocket-Protocol support
for issue#957 support sec-WebSocket-Protocol, which enables us to use other protocols (such as mqtt,...) over websocket.

Adding setWsSubProtocols in JavalinConfig, and add example on how to use it.


Patch:
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index 483dd5d4..489a335e 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";
@@ -103,19 +104,22 @@ public class JavalinConfig {
     }
 
     public JavalinConfig enableWebjars() {
-        addStaticFiles("/webjars", Location.CLASSPATH);
-        return this;
+        return addStaticFiles("/webjars", Location.CLASSPATH);
     }
 
     public JavalinConfig addStaticFiles(@NotNull String classpathPath) {
-        addStaticFiles(classpathPath, Location.CLASSPATH);
-        return this;
+        return addStaticFiles(classpathPath, Location.CLASSPATH);
     }
 
     public JavalinConfig addStaticFiles(@NotNull String path, @NotNull Location location) {
+        return addStaticFiles("/", path, location);
+    }
+
+    public JavalinConfig addStaticFiles(@NotNull String urlPathPrefix, @NotNull String path, @NotNull Location location) {
         JettyUtil.disableJettyLogger();
-        if (inner.resourceHandler == null) inner.resourceHandler = new JettyResourceHandler();
-        inner.resourceHandler.addStaticFileConfig(new StaticFileConfig(path, location));
+        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/compression/CompressionStrategy.kt b/javalin/src/main/java/io/javalin/core/compression/CompressionStrategy.kt
index e116a4d9..8df0fc3f 100644
--- a/javalin/src/main/java/io/javalin/core/compression/CompressionStrategy.kt
+++ b/javalin/src/main/java/io/javalin/core/compression/CompressionStrategy.kt
@@ -23,6 +23,7 @@ class CompressionStrategy(brotli: Brotli? = null, gzip: Gzip? = null) {
     companion object {
         @JvmField
         val NONE = CompressionStrategy()
+
         @JvmField
         val GZIP = CompressionStrategy(null, Gzip())
     }
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/core/validation/BodyValidator.kt b/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
new file mode 100644
index 00000000..1b8d3495
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
@@ -0,0 +1,57 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.core.validation
+
+open class BodyValidator<T>(value: T?, messagePrefix: String = "Value") : Validator<T>(value, messagePrefix) {
+
+    private var errors: MutableMap<String, MutableList<String>>? = null
+
+    @JvmOverloads
+    open fun check(fieldName: String, predicate: (T) -> Boolean, errorMessage: String = "Failed check"): BodyValidator<T> {
+        rules.add(Rule(fieldName, predicate, errorMessage))
+        return this
+    }
+
+    fun isValid(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isEmpty()
+    }
+
+    fun hasError(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isNotEmpty()
+    }
+
+    fun errors(): Map<String, List<String>> {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!
+    }
+
+    private fun validate(): BodyValidator<T> {
+        errors = mutableMapOf()
+        rules.forEach { rule ->
+            if (value != null) {
+                try {
+                    if (!rule.test.invoke(value)) {
+                        if (!errors!!.containsKey(rule.fieldName)) {
+                            errors!![rule.fieldName] = mutableListOf()
+                        }
+                        errors!![rule.fieldName]?.add(rule.invalidMessage)
+                    }
+                } catch (ignore: NullPointerException) {
+                }
+            }
+        }
+        return this
+    }
+}
diff --git a/javalin/src/main/java/io/javalin/core/validation/Validator.kt b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
index 4327581d..a464ba30 100644
--- a/javalin/src/main/java/io/javalin/core/validation/Validator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
@@ -10,13 +10,13 @@ import io.javalin.http.BadRequestResponse
 
 open class Validator<T>(val value: T?, val messagePrefix: String = "Value") {
 
-    data class Rule<T>(val test: (T) -> Boolean, val invalidMessage: String)
+    data class Rule<T>(val fieldName: String, val test: (T) -> Boolean, val invalidMessage: String)
 
-    private val rules = mutableSetOf<Rule<T>>()
+    protected val rules = mutableSetOf<Rule<T>>()
 
     @JvmOverloads
     open fun check(predicate: (T) -> Boolean, errorMessage: String = "Failed check"): Validator<T> {
-        rules.add(Rule(predicate, "$messagePrefix invalid - $errorMessage"))
+        rules.add(Rule("_", predicate, "$messagePrefix invalid - $errorMessage"))
         return this
     }
 
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index 28c191b7..c89cc5e9 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -9,6 +9,7 @@ package io.javalin.http
 import io.javalin.Javalin
 import io.javalin.core.security.BasicAuthCredentials
 import io.javalin.core.util.Header
+import io.javalin.core.validation.BodyValidator
 import io.javalin.core.validation.Validator
 import io.javalin.http.util.ContextUtil
 import io.javalin.http.util.CookieStore
@@ -115,6 +116,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * JavalinJson can be configured to use any mapping library.
      * @return The mapped object
      */
+    @JvmSynthetic
     inline fun <reified T : Any> body(): T = bodyAsClass(T::class.java)
 
     /** Gets the request body as a [ByteArray].
@@ -138,7 +140,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Throws [BadRequestResponse] if validation fails.
      */
     fun <T> bodyValidator(clazz: Class<T>) = try {
-        Validator(JavalinJson.fromJson(body(), clazz), "Request body as ${clazz.simpleName}")
+        BodyValidator(JavalinJson.fromJson(body(), clazz), "Request body as ${clazz.simpleName}")
     } catch (e: Exception) {
         Javalin.log?.info("Couldn't deserialize body to ${clazz.simpleName}", e)
         throw BadRequestResponse("Couldn't deserialize body to ${clazz.simpleName}")
@@ -171,6 +173,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     fun <T> formParam(key: String, clazz: Class<T>, default: String? = null) = Validator.create(clazz, formParam(key, default), "Form parameter '$key' with value '${formParam(key, default)}'")
 
     /** Reified version of [formParam] (Kotlin only) */
+    @JvmSynthetic
     inline fun <reified T : Any> formParam(key: String, default: String? = null) = formParam(key, T::class.java, default)
 
     /** Gets a list of form params for the specified key, or empty list. */
@@ -197,6 +200,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     fun <T> pathParam(key: String, clazz: Class<T>) = Validator.create(clazz, pathParam(key), "Path parameter '$key' with value '${pathParam(key)}'")
 
     /** Reified version of [pathParam] (Kotlin only) */
+    @JvmSynthetic
     inline fun <reified T : Any> pathParam(key: String) = pathParam(key, T::class.java)
 
     /** Gets a map of all the [pathParam] keys and values. */
@@ -246,6 +250,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     fun <T> header(header: String, clazz: Class<T>): Validator<T> = Validator.create(clazz, header(header), "Request header '$header' with value '${header(header)}'")
 
     /** Reified version of [header] (Kotlin only) */
+    @JvmSynthetic
     inline fun <reified T : Any> header(header: String) = header(header, T::class.java)
 
     /** Gets a map with all the header keys and values on the request. */
@@ -291,6 +296,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     fun <T> queryParam(key: String, clazz: Class<T>, default: String? = null) = Validator.create(clazz, queryParam(key, default), "Query parameter '$key' with value '${queryParam(key, default)}'")
 
     /** Reified version of [queryParam] (Kotlin only) */
+    @JvmSynthetic
     inline fun <reified T : Any> queryParam(key: String, default: String? = null) = queryParam(key, T::class.java, default)
 
     /** Gets a list of query params for the specified key, or empty list. */
diff --git a/javalin/src/main/java/io/javalin/http/HttpResponseExceptionMapper.kt b/javalin/src/main/java/io/javalin/http/HttpResponseExceptionMapper.kt
index 1bf5747f..56346922 100644
--- a/javalin/src/main/java/io/javalin/http/HttpResponseExceptionMapper.kt
+++ b/javalin/src/main/java/io/javalin/http/HttpResponseExceptionMapper.kt
@@ -7,6 +7,7 @@
 package io.javalin.http
 
 import io.javalin.core.util.Header
+import io.javalin.http.util.JsonEscapeUtil
 import java.util.concurrent.CompletionException
 
 object HttpResponseExceptionMapper {
@@ -58,6 +59,6 @@ object HttpResponseExceptionMapper {
         else -> docsUrl + "error-responses"
     }
 
-    private fun String.jsonEscape() = this.replace("\"", "\\\"")
+    private fun String.jsonEscape() = JsonEscapeUtil.escape(this)
 
 }
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 30890ab6..c6bc3ca9 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
@@ -11,38 +11,47 @@ import io.javalin.core.util.Header
 import io.javalin.core.util.Util
 import io.javalin.http.JavalinResponseWrapper
 import org.eclipse.jetty.server.Request
-import org.eclipse.jetty.server.Server
 import org.eclipse.jetty.server.handler.ResourceHandler
+import org.eclipse.jetty.util.resource.EmptyResource
 import org.eclipse.jetty.util.resource.Resource
 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>()
 
-    // It would work without a server, but if none is set jetty will log a warning.
-    private val dummyServer = Server()
-
     override fun addStaticFileConfig(config: StaticFileConfig) {
-        val handler = if (config.path == "/webjars") WebjarHandler() else ResourceHandler().apply {
+        val handler = if (config.path == "/webjars") WebjarHandler() else PrefixableHandler(config.urlPathPrefix).apply {
             resourceBase = getResourcePath(config)
             isDirAllowed = false
             isEtags = true
-            Javalin.log?.info("Static file handler added with path=${config.path} and location=${config.location}. Absolute path: '${getResourcePath(config)}'.")
+            Javalin.log?.info("""Static file handler added:
+                |    {urlPathPrefix: "${config.urlPathPrefix}", path: "${config.path}", location: Location.${config.location}}
+                |    Resolved path: '${getResourcePath(config)}'
+                """.trimMargin())
         }
-        handlers.add(handler.apply {
-            server = dummyServer
-            start()
-        })
+        handlers.add(handler.apply { start() })
     }
 
     inner class WebjarHandler : ResourceHandler() {
         override fun getResource(path: String) = Resource.newClassPathResource("META-INF/resources$path") ?: super.getResource(path)
     }
 
-    fun getResourcePath(staticFileConfig: StaticFileConfig): String {
+    inner class PrefixableHandler(private var urlPathPrefix: String) : ResourceHandler() {
+        override fun getResource(path: String): Resource {
+            val targetResource by lazy { path.removePrefix(urlPathPrefix) }
+            return when {
+                urlPathPrefix == "/" -> super.getResource(path) // same as regular ResourceHandler
+                !path.startsWith(urlPathPrefix) -> EmptyResource.INSTANCE
+                !targetResource.startsWith("/") -> EmptyResource.INSTANCE
+                else -> super.getResource(targetResource)
+            }
+        }
+    }
+
+    private fun getResourcePath(staticFileConfig: StaticFileConfig): String {
         val nosuchdir = "Static resource directory with path: '${staticFileConfig.path}' does not exist."
         if (staticFileConfig.location == Location.CLASSPATH) {
             val classPathResource = Resource.newClassPathResource(staticFileConfig.path)
@@ -68,6 +77,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..55dd4618
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/PrecompressingResourceHandler.kt
@@ -0,0 +1,109 @@
+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.ByteArrayOutputStream
+import java.io.OutputStream
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+
+object PrecompressingResourceHandler {
+
+    val compressedFiles = HashMap<String, ByteArray>()
+    var resourceMaxSize: Int = 2 * 1024 * 1024 // the unit of resourceMaxSize is byte
+
+    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("Static file '$target' is larger than configured max size for pre-compression ($resourceMaxSize bytes).\n" +
+                    "You can configure the max size with `PrecompressingResourceHandler.resourceMaxSize = newMaxSize`.")
+            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
+            }
+            type == CompressType.BR && Util.dependencyIsPresent(OptionalDependency.JVMBROTLI) -> {
+                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
+            }
+        }
+    }
+}
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.kt b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.kt
index 1bd48c8d..7ee29acf 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.kt
@@ -4,7 +4,7 @@ import javax.servlet.http.HttpServletRequest
 import javax.servlet.http.HttpServletResponse
 
 enum class Location { CLASSPATH, EXTERNAL; }
-data class StaticFileConfig(val path: String, val location: Location)
+data class StaticFileConfig(val urlPathPrefix: String, val path: String, val location: Location)
 
 interface ResourceHandler {
     fun handle(httpRequest: HttpServletRequest, httpResponse: HttpServletResponse): Boolean
diff --git a/javalin/src/main/java/io/javalin/http/util/JsonEscapeUtil.kt b/javalin/src/main/java/io/javalin/http/util/JsonEscapeUtil.kt
new file mode 100644
index 00000000..ca95b885
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/http/util/JsonEscapeUtil.kt
@@ -0,0 +1,25 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.http.util
+
+object JsonEscapeUtil {
+    fun escape(str: String): String {
+        val builder = StringBuilder(str.length)
+        for (ch in str) {
+            builder.append(when (ch) {
+                '\"' -> "\\\""
+                '\n' -> "\\n"
+                '\r' -> "\\r"
+                '\\' -> "\\\\"
+                '\t' -> "\\t"
+                '\b' -> "\\b"
+                else -> ch
+            })
+        }
+        return builder.toString()
+    }
+}
diff --git a/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
index fae0e9ab..9ea47798 100644
--- a/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
+++ b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
@@ -1,25 +1,78 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2020 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
 package io.javalin.plugin.metrics
 
 import io.javalin.Javalin
 import io.javalin.core.plugin.Plugin
-import io.javalin.core.util.OptionalDependency
-import io.javalin.core.util.Util
+import io.javalin.http.Context
+import io.javalin.http.ExceptionHandler
+import io.javalin.http.HandlerEntry
+import io.javalin.http.HandlerType
 import io.micrometer.core.instrument.MeterRegistry
 import io.micrometer.core.instrument.Metrics
 import io.micrometer.core.instrument.Tag
+import io.micrometer.core.instrument.Tags
+import io.micrometer.core.instrument.binder.http.DefaultHttpServletRequestTagsProvider
+import io.micrometer.core.instrument.binder.jetty.JettyConnectionMetrics
 import io.micrometer.core.instrument.binder.jetty.JettyServerThreadPoolMetrics
-import io.micrometer.core.instrument.binder.jetty.JettyStatisticsMetrics
-import org.eclipse.jetty.server.handler.StatisticsHandler
+import io.micrometer.core.instrument.binder.jetty.TimedHandler
+import org.apache.commons.lang3.StringUtils
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
 
-class MicrometerPlugin @JvmOverloads constructor(val registry: MeterRegistry = Metrics.globalRegistry) : Plugin {
+class MicrometerPlugin @JvmOverloads constructor(private val registry: MeterRegistry = Metrics.globalRegistry,
+                                                 private val tags: Iterable<Tag> = Tags.empty(),
+                                                 private val tagExceptionName: Boolean = false) : Plugin {
     override fun apply(app: Javalin) {
         app.server()?.server()?.let { server ->
-            Util.ensureDependencyPresent(OptionalDependency.MICROMETER)
-            val statisticsHandler = StatisticsHandler()
-            server.insertHandler(statisticsHandler)
-            JettyStatisticsMetrics.monitor(this.registry, statisticsHandler)
-            val threadPoolMetrics = JettyServerThreadPoolMetrics(server.threadPool, emptyList<Tag>())
-            threadPoolMetrics.bindTo(this.registry)
+            if (tagExceptionName) {
+                app.exception(Exception::class.java, EXCEPTION_HANDLER)
+            }
+
+            server.insertHandler(TimedHandler(registry, tags, object : DefaultHttpServletRequestTagsProvider() {
+                override fun getTags(request: HttpServletRequest, response: HttpServletResponse): Iterable<Tag> {
+                    val exceptionName = if (tagExceptionName) {
+                        response.getHeader(EXCEPTION_HEADER)
+                    } else {
+                        "Unknown"
+                    }
+
+                    response.setHeader(EXCEPTION_HEADER, null)
+                    val uri = app.servlet()
+                            .matcher
+                            .findEntries(HandlerType.valueOf(request.method), request.pathInfo)
+                            .stream()
+                            .findAny()
+                            .map(HandlerEntry::path)
+                            .map { path: String -> if (path == "/" || StringUtils.isBlank(path)) "root" else path }
+                            .map { path: String -> if (response.status in 300..399) "REDIRECTION" else path }
+                            .map { path: String -> if (response.status == 404) "NOT_FOUND" else path }
+                            .orElse("NOT_FOUND")
+                    return Tags.concat(
+                            super.getTags(request, response),
+                            "uri", uri,
+                            "exception", exceptionName ?: "None"
+                    )
+                }
+            }))
+
+            JettyServerThreadPoolMetrics(server.threadPool, tags).bindTo(registry)
+        }
+
+        JettyConnectionMetrics(registry, tags)
+    }
+
+    companion object {
+        private const val EXCEPTION_HEADER = "__micrometer_exception_name"
+
+        var EXCEPTION_HANDLER = ExceptionHandler { e: Exception, ctx: Context ->
+            val simpleName = e.javaClass.simpleName
+            ctx.header(EXCEPTION_HEADER, if (StringUtils.isNotBlank(simpleName)) simpleName else e.javaClass.name)
+            ctx.status(500)
         }
     }
 }
diff --git a/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt b/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
index 5ea5cd0b..812d9f1a 100644
--- a/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
+++ b/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
@@ -13,6 +13,7 @@ import io.javalin.http.Context
 import io.javalin.http.JavalinServlet
 import io.javalin.http.UnauthorizedResponse
 import io.javalin.http.util.ContextUtil
+import org.eclipse.jetty.websocket.api.WebSocketConstants
 import org.eclipse.jetty.websocket.servlet.WebSocketCreator
 import org.eclipse.jetty.websocket.servlet.WebSocketServlet
 import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory
@@ -59,6 +60,15 @@ class JavalinWsServlet(val config: JavalinConfig, private val httpServlet: Javal
             config.inner.accessManager.manage({ ctx -> ctx.req.setAttribute(upgradeAllowedKey, true) }, upgradeContext, entry.permittedRoles)
             if (req.getAttribute(upgradeAllowedKey) != true) throw UnauthorizedResponse() // if set to true, the access manager ran the handler (== valid)
             req.setAttribute(upgradeContextKey, upgradeContext)
+
+            val wsProtocolHeader = req.getHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL)
+            if (wsProtocolHeader != null) {
+                val protocolNames = wsProtocolHeader.split(',').map { it.trim() }.filter { it != "" }
+                if (protocolNames.isNotEmpty()) {
+                    res.setHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, protocolNames.first())
+                }
+            }
+
             super.service(req, res) // everything is okay, perform websocket upgrade
         } catch (e: Exception) {
             res.sendError(401, "Unauthorized")
diff --git a/javalin/src/main/java/io/javalin/websocket/WsContext.kt b/javalin/src/main/java/io/javalin/websocket/WsContext.kt
index 7f701cf4..ff202b84 100644
--- a/javalin/src/main/java/io/javalin/websocket/WsContext.kt
+++ b/javalin/src/main/java/io/javalin/websocket/WsContext.kt
@@ -36,11 +36,15 @@ abstract class WsContext(val sessionId: String, @JvmField val session: Session)
     fun queryParam(key: String): String? = upgradeCtx.queryParam(key)
     fun queryParam(key: String, default: String? = null): String? = upgradeCtx.queryParam(key, default)
     fun <T> queryParam(key: String, clazz: Class<T>, default: String? = null) = upgradeCtx.queryParam(key, clazz, default)
+
+    @JvmSynthetic
     inline fun <reified T : Any> queryParam(key: String, default: String? = null) = queryParam(key, T::class.java, default)
 
     fun pathParamMap(): Map<String, String> = upgradeCtx.pathParamMap()
     fun pathParam(key: String): String = upgradeCtx.pathParam(key)
     fun <T> pathParam(key: String, clazz: Class<T>) = upgradeCtx.pathParam(key, clazz)
+
+    @JvmSynthetic
     inline fun <reified T : Any> pathParam(key: String) = pathParam(key, T::class.java)
 
     fun host() = upgradeReq.host // why can't we get this from upgradeCtx?
diff --git a/pom.xml b/pom.xml
index 9851054b..c9e5459b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,7 +72,7 @@
         <jtwig.version>5.87.0.RELEASE</jtwig.version>
         <jvmbrotli.version>0.2.0</jvmbrotli.version>
         <logback.version>1.2.3</logback.version>
-        <micrometer.version>1.3.6</micrometer.version>
+        <micrometer.version>1.5.1</micrometer.version>
         <mustache.version>0.9.6</mustache.version>
         <pebble.version>3.1.2</pebble.version>
         <redoc.version>2.0.0-rc.23</redoc.version>


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestExceptionMapper.kt b/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
index 5addacda..4f904bb9 100644
--- a/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
+++ b/javalin/src/test/java/io/javalin/TestExceptionMapper.kt
@@ -92,4 +92,10 @@ class TestExceptionMapper {
         assertThat(http.jsonGet("/").body).contains("""MY MESSAGE WITH \"QUOTES\"""")
     }
 
+    @Test
+    fun `exception title can contain newlines`() = TestUtil.test { app, http ->
+        app.get("/") { throw BadRequestResponse("MY MESSAGE WITH \nNEWLINES\n") }
+        assertThat(http.jsonGet("/").body).contains("""MY MESSAGE WITH \nNEWLINES\n""")
+    }
+
 }
diff --git a/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt b/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt
new file mode 100644
index 00000000..40efb883
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestMicrometerPlugin.kt
@@ -0,0 +1,95 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2020 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin
+
+import io.javalin.plugin.metrics.MicrometerPlugin
+import io.javalin.testing.HttpUtil
+import io.micrometer.core.instrument.MeterRegistry
+import io.micrometer.core.instrument.Tags
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class TestMicrometerPlugin {
+    private val meterRegistry: MeterRegistry = SimpleMeterRegistry()
+    val app: Javalin = Javalin.create { config -> config.registerPlugin(MicrometerPlugin(meterRegistry, Tags.empty(), true)) }.start(0);
+    val http = HttpUtil(app.port())
+
+    @Before
+    fun before() {
+        // must manually delegate to Micrometer exception handler for excepton tags to be correct
+        app.exception(IllegalArgumentException::class.java) { e, ctx ->
+            MicrometerPlugin.EXCEPTION_HANDLER.handle(e, ctx);
+            e.printStackTrace();
+        };
+    }
+
+    @After
+    fun after() {
+        app.stop()
+    }
+
+    @Test
+    fun `successful request`() {
+        app.get("/hello/:name") { ctx -> ctx.result("Hello: " + ctx.pathParam("name")) }
+        http.get("/hello/jon")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello/:name")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "200")
+                .tag("outcome", "SUCCESS")
+                .timer()
+    }
+
+    @Test
+    fun `request throwing exception`() {
+        app.get("/boom") { ctx -> throw IllegalArgumentException("boom") }
+        http.get("/boom")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/boom")
+                .tag("method", "GET")
+                .tag("exception", "IllegalArgumentException")
+                .tag("status", "500")
+                .tag("outcome", "SERVER_ERROR")
+                .timer()
+    }
+
+    @Test
+    fun redirect() {
+        app.get("/hello") { ctx -> ctx.result("Hello") }
+        app.get("/redirect") { ctx -> ctx.redirect("/hello") }
+        http.get("/redirect")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "REDIRECTION")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "302")
+                .tag("outcome", "REDIRECTION")
+                .timer()
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "/hello")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "200")
+                .tag("outcome", "SUCCESS")
+                .timer()
+    }
+
+    @Test
+    fun `not found`() {
+        http.get("/doesNotExist")
+        meterRegistry.get("jetty.server.requests")
+                .tag("uri", "NOT_FOUND")
+                .tag("method", "GET")
+                .tag("exception", "None")
+                .tag("status", "404")
+                .tag("outcome", "CLIENT_ERROR")
+                .timer()
+    }
+}
diff --git a/javalin/src/test/java/io/javalin/TestStaticFiles.kt b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
index b7a3ee62..6079bbc6 100644
--- a/javalin/src/test/java/io/javalin/TestStaticFiles.kt
+++ b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
@@ -156,4 +156,29 @@ class TestStaticFiles {
         assertThat(http.get("/html.html").status).isEqualTo(200)
         assertThat(http.get("/html.html").headers.getFirst(Header.CONTENT_TYPE)).contains("text/html")
     }
+
+    @Test
+    fun `serving from custom url path works`() {
+        TestUtil.test(Javalin.create { servlet ->
+            servlet.addStaticFiles("/public")
+            servlet.addStaticFiles("/url-prefix", "/public", Location.CLASSPATH)
+        }) { _, http ->
+            assertThat(http.get("/styles.css").status).isEqualTo(200)
+            assertThat(http.get("/url-prefix/styles.css").status).isEqualTo(200)
+        }
+    }
+
+    @Test
+    fun `urlPathPrefix filters requests to a specific subfolder`() {
+        TestUtil.test(Javalin.create { servlet ->
+            // effectively equivalent to servlet.addStaticFiles("/public", Location.CLASSPATH)
+            // but with benefit of additional "filtering": only requests matching /assets/* will be matched against static resources handler
+            servlet.addStaticFiles("/assets", "/public/assets", Location.CLASSPATH)
+        }) { _, http ->
+            assertThat(http.get("/assets/filtered-styles.css").status).isEqualTo(200) // access to urls matching /assets/* is allowed
+            assertThat(http.get("/filtered-styles.css").status).isEqualTo(404) // direct access to a file in the subfolder is not allowed
+            assertThat(http.get("/styles.css").status).isEqualTo(404) // access to other locations in /public is not allowed
+        }
+    }
+
 }
diff --git a/javalin/src/test/java/io/javalin/TestStaticFilesPrecompressor.kt b/javalin/src/test/java/io/javalin/TestStaticFilesPrecompressor.kt
new file mode 100644
index 00000000..2068a986
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestStaticFilesPrecompressor.kt
@@ -0,0 +1,75 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ *
+ */
+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.staticfiles.PrecompressingResourceHandler
+import io.javalin.testing.HttpUtil
+import io.javalin.testing.TestUtil
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+
+class TestStaticFilesPrecompressor {
+
+    private val swaggerBasePath = "/webjars/swagger-ui/${OptionalDependency.SWAGGERUI.version}"
+
+    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")
+        }
+    }
+
+    @Test
+    fun `content-length unavailable for large files if precompression not enabled`() = TestUtil.test(Javalin.create { config -> config.enableWebjars() }) { _, http ->
+        assertThat(http.getFile("$swaggerBasePath/swagger-ui-bundle.js", "gzip").contentLength()).isNull()
+        assertThat(http.getFile("$swaggerBasePath/swagger-ui.js.gz", "gzip").contentLength()).isNull()
+    }
+
+    @Test
+    fun `content-length available for large files if precompression enabled`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(http.getFile("/secret.html", "br, gzip").contentLength()).isNotBlank()
+        assertThat(http.getFile("/library-1.0.0.min.js", "br").contentLength()).isNotBlank()
+        assertThat(http.getFile("$swaggerBasePath/swagger-ui-bundle.js", "gzip").contentLength()).isNotBlank()
+    }
+
+    @Test
+    fun `compression works if precompression enabled`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        assertThat(http.getFile("/secret.html", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("/library-1.0.0.min.js", "br").contentEncoding()).isEqualTo("br")
+        assertThat(http.getFile("$swaggerBasePath/swagger-ui-bundle.js", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("$swaggerBasePath/swagger-ui.js.gz", "gzip").contentEncoding()).isNull()
+    }
+
+    @Test
+    fun `only creates one compressed version even if query params are present`() = TestUtil.test(configPrecompressionStaticResourceApp) { _, http ->
+        val oldSize = PrecompressingResourceHandler.compressedFiles.size
+        assertThat(http.getFile("/secret.html", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("/secret.html?qp=1", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(http.getFile("/secret.html?qp=2", "gzip").contentEncoding()).isEqualTo("gzip")
+        assertThat(PrecompressingResourceHandler.compressedFiles.size <= oldSize + 1)
+    }
+
+    private fun Response.contentLength() = this.headers().get(Header.CONTENT_LENGTH)
+    private fun Response.contentEncoding() = this.headers().get(Header.CONTENT_ENCODING)
+
+    private fun HttpUtil.getFile(url: String, encoding: String) =
+            OkHttpClient().newCall(Request.Builder()
+                    .url(this.origin + url)
+                    .header(Header.ACCEPT_ENCODING, encoding)
+                    .build())
+                    .execute()
+}
diff --git a/javalin/src/test/java/io/javalin/TestValidation.kt b/javalin/src/test/java/io/javalin/TestValidation.kt
index 9a239d50..c8378b3c 100644
--- a/javalin/src/test/java/io/javalin/TestValidation.kt
+++ b/javalin/src/test/java/io/javalin/TestValidation.kt
@@ -180,4 +180,95 @@ class TestValidation {
             assertThat(body).isEqualTo("")
         }
     }
+
+
+    @Test
+    fun `body validator with check and retrieve errors`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val errors = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .errors()
+
+            ctx.json(errors)
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{}")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{\"first_name\":[\"Too long\"]}")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{\"first_name\":[\"This field is mandatory\"]}")
+        }
+    }
+
+    @Test
+    fun `body validator with check and isValid`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val isValid = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .isValid()
+
+            ctx.result(isValid.toString())
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+    }
+
+    @Test
+    fun `body validator with check and hasError`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val hasError = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .hasError()
+
+            ctx.result(hasError.toString())
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+    }
 }
diff --git a/javalin/src/test/java/io/javalin/TestWebSocket.kt b/javalin/src/test/java/io/javalin/TestWebSocket.kt
index c51b7126..35310a98 100644
--- a/javalin/src/test/java/io/javalin/TestWebSocket.kt
+++ b/javalin/src/test/java/io/javalin/TestWebSocket.kt
@@ -8,6 +8,7 @@ package io.javalin
 
 import com.mashape.unirest.http.Unirest
 import io.javalin.apibuilder.ApiBuilder.ws
+import io.javalin.core.util.Header
 import io.javalin.http.UnauthorizedResponse
 import io.javalin.plugin.json.JavalinJson
 import io.javalin.testing.SerializeableObject
@@ -17,6 +18,7 @@ import io.javalin.websocket.WsContext
 import org.assertj.core.api.Assertions.assertThat
 import org.eclipse.jetty.websocket.api.MessageTooLargeException
 import org.eclipse.jetty.websocket.api.StatusCode
+import org.eclipse.jetty.websocket.api.WebSocketConstants
 import org.java_websocket.client.WebSocketClient
 import org.java_websocket.drafts.Draft_6455
 import org.java_websocket.handshake.ServerHandshake
@@ -529,6 +531,16 @@ class TestWebSocket {
         assertThat(app.logger().log).containsExactly("TypedException handler called")
     }
 
+    @Test
+    fun `websocket subprotocol is set if included`() = TestUtil.test { app, http ->
+        app.ws("/ws") {}
+        val response = Unirest.get("http://localhost:${app.port()}/ws")
+                .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+                .header(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, "mqtt")
+                .asString()
+        assertThat(response.headers.getFirst(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL)).isEqualTo("mqtt")
+    }
+
     // ********************************************************************************************
     // Helpers
     // ********************************************************************************************
diff --git a/javalin/src/test/resources/public/assets/filtered-styles.css b/javalin/src/test/resources/public/assets/filtered-styles.css
new file mode 100644
index 00000000..9c0e6d04
--- /dev/null
+++ b/javalin/src/test/resources/public/assets/filtered-styles.css
@@ -0,0 +1,3 @@
+.css-test:before {
+    content: "CSS works"
+}