Repository: tipsy/javalin
Pull request #983 (2020-05-27 20:51:52)

fixes/upgrades OpenAPI field annotation
* addresses issue #956 (OpenAPI annotations of Java non-static class fields)
* added unit-tests for various class/field-type combinations

Hope others find this also useful. Enjoy!

MrThreepwood: It's worth mentioning that the open API annotations are supposed to be applied to a handler. This pull request updates it to allow the annotation to be placed somewhere in the same file as the handler.

In the case of a lambda, the lambda IS the handler. Java does not allow you to annotate the handler, so you have chosen to place an annotation kinda closeish to the thing you want to annotate. It doesn't seem reasonable for the code to go searching for an annotation for a lambda when it knows a lambda can't be annotated. It also doesn't seem reasonable that a person would expect the library to be able to find it.

Additionally, in the case that I am wrapping my actual controllers (which don't implement the interface of a handler) in a well defined converter, this won't work. For example:

```kotlin
class MyClass() {
   
    @OpenApi(path = "somepath", method = Method.Get) // <--- This can't be found
    val someHandler: Handler = wrapMyFunction(MyControllerThatDoesntKnowJavalin()::getSomething)

    init {
        ApiBuilder.get("somepath", someHandler )
    }
}
```
In the above example, your code still won't find this annotation, because it's not placed on the handler. It's placed on a field that holds the handler temporarily, but the lambda it's holding isn't defined in this class.

Similarly, the field I'm using to hold this handler doesn't have to hold ONLY this handler, it could hold anything else at any point. In fact, if the only use case for this working is when you have something more like:

```kotlin
val myHandler = { context -> return SomeObject() }
```
Then what's the point? The value of my handler can only ever be one thing, that's functionally equivalent to just turning it into a function/method and referencing that function or method when calling ApiBuilder.get(). And since a method can be a handler, and a method can be annotated it is necessary that any annotation would have to be in the class it's defined in.

Long story short, I don't think it's reasonable for a person writing the code to say, I can't annotate My Handler, so I'll place the annotation somewhere and hope the library finds it. If there's a bug with this annotation that needs to be fixed, I'd argue it's that it even allows you to annotate a field at all.
tipsy: Thanks for your comment on both this PR and the linked issue @MrThreepwood, I appreciate you taking the time! While I see your point, we opened up for annotating method reference and fields when we first published the plugin (including a workaround for manual linking by using `path`/`method` parameters when calling the plugin from Java).

I understand that you might find this approach counter-intuitive, but in the years I've maintained this project (and during my time working on SparkJava) I've seen people implement the `Handler` interface in every way imaginable, and I don't think there has to be an "official" way (or even a best practice). People have strong preferences, and some companies have code styles that require them to implement things a certain way. You could argue that we've gone a bit far in attempting to keep the framework unopinionated (I agree that the whole setup for this isn't exactly elegant), but this PR appears to be a definite improvement that fixes an inconsistency in the intended design.

While it might not make sense to put an annotation on a `Handler` field from the compiler's point of view, it does from (some) people's point of view. For some people just having the documentation in proximity to the code block is very valuable, and they're willing to add manual fields to link it up.

This all came out a bit less well articulated than I'd have wanted, but it's pretty late here and I wanted to write it down before heading to bed.

(Disclaimer: I have written very little of the OpenAPI code myself).
tipsy: I read through the code now @RalphSteinhagen, and it all looks very neat (all things considered). I don't fully understand all of the magic going on, but you have a good amount of logging and a lot of new tests, so I'm not really too worried about merging this. As @TobiasWalle is the original author of the plugin I usually ask him to give his blessing, but he's been a bit busy lately. I'll let the PR sit for a couple of days just in case he has time, but if he doesn't respond I will merge this in the weekend.

It seems that you have a good overview of what works and what doesn't in the plugin now. Could I ask you to also have a look at the documentation? https://github.com/javalin/javalin.github.io/blob/master/pages/plugins/openapi.md 

Patch:
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
index 4ecb3a07..6f4ee715 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
@@ -93,6 +93,7 @@ class OpenApiOptions constructor(val initialConfigurationCreator: InitialConfigu
         openApiExamples = examples.mapValues { it.value.toMutableMap() }.toMutableMap()
     }
 
+    @JvmSynthetic
     inline fun <reified T : Any> addExample(name: String, example: Example) = addExample(T::class.java, name, example)
 
     fun <T> addExample(clazz: Class<T>, name: String, example: Example) {
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
index eeac36dd..e6ea3e80 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
@@ -32,6 +32,7 @@ fun documentedContent(
 }
 
 /** Kotlin factory for documented content */
+@JvmSynthetic
 inline fun <reified T> documentedContent(
         contentType: String? = ContentType.AUTODETECT,
         isArray: Boolean = false
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
index d52ba489..8a586e11 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
@@ -38,6 +38,7 @@ class OpenApiDocumentation {
     }
 
     // --- PATH PARAM ---
+    @JvmSynthetic
     inline fun <reified T> pathParam(name: String, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
         pathParam(name, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -49,6 +50,7 @@ class OpenApiDocumentation {
     }
 
     // --- QUERY PARAM ---
+    @JvmSynthetic
     inline fun <reified T> queryParam(name: String, isRepeatable: Boolean = false, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
         queryParam(name, T::class.java, isRepeatable, createUpdaterIfNotNull(applyUpdates))
     }
@@ -60,6 +62,7 @@ class OpenApiDocumentation {
     }
 
     // --- HEADER ---
+    @JvmSynthetic
     inline fun <reified T> header(name: String, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
         header(name, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -71,6 +74,7 @@ class OpenApiDocumentation {
     }
 
     // --- COOKIE ---
+    @JvmSynthetic
     inline fun <reified T> cookie(name: String, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
         cookie(name, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -100,6 +104,7 @@ class OpenApiDocumentation {
     }
 
     // --- FORM PARAM ---
+    @JvmSynthetic
     inline fun <reified T> formParam(name: String, required: Boolean = false): OpenApiDocumentation = apply {
         formParam(name, T::class.java, required)
     }
@@ -113,10 +118,12 @@ class OpenApiDocumentation {
     }
 
     // --- FORM PARAM BODY ---
+    @JvmSynthetic
     inline fun <reified T> formParamBody(noinline applyUpdates: ApplyUpdates<RequestBody>? = null) = apply {
         formParamBody(T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
 
+    @JvmSynthetic
     inline fun <reified T> formParamBody(contentType: String? = null, noinline applyUpdates: ApplyUpdates<RequestBody>? = null) = apply {
         formParamBody(T::class.java, contentType, createUpdaterIfNotNull(applyUpdates))
     }
@@ -153,10 +160,12 @@ class OpenApiDocumentation {
     }
 
     // --- BODY ---
+    @JvmSynthetic
     inline fun <reified T> body(noinline applyUpdates: ApplyUpdates<RequestBody>? = null) = apply {
         body(T::class.java, null, applyUpdates)
     }
 
+    @JvmSynthetic
     inline fun <reified T> body(contentType: String? = null, noinline applyUpdates: ApplyUpdates<RequestBody>? = null) = apply {
         body(T::class.java, contentType, applyUpdates)
     }
@@ -211,6 +220,7 @@ class OpenApiDocumentation {
     }
 
     // --- JSON ARRAY ---
+    @JvmSynthetic
     inline fun <reified T> jsonArray(status: String, noinline applyUpdates: ApplyUpdates<ApiResponse>? = null) = apply {
         jsonArray(status, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -223,6 +233,7 @@ class OpenApiDocumentation {
     }
 
     // --- JSON ---
+    @JvmSynthetic
     inline fun <reified T> json(status: String, noinline applyUpdates: ApplyUpdates<ApiResponse>? = null) = apply {
         json(status, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -247,6 +258,7 @@ class OpenApiDocumentation {
     }
 
     // --- RESULT ---
+    @JvmSynthetic
     inline fun <reified T> result(status: String, noinline applyUpdates: ApplyUpdates<ApiResponse>? = null) = apply {
         result(status, T::class.java, createUpdaterIfNotNull(applyUpdates))
     }
@@ -261,6 +273,7 @@ class OpenApiDocumentation {
         result(status, documentedContent, openApiUpdater)
     }
 
+    @JvmSynthetic
     inline fun <reified T> result(status: String, contentType: String?, noinline applyUpdates: ApplyUpdates<ApiResponse>? = null) = apply {
         result(status, T::class.java, contentType, createUpdaterIfNotNull(applyUpdates))
     }
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
index 57b47ee5..a82cb9b8 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
@@ -1,24 +1,28 @@
 package io.javalin.plugin.openapi.dsl
 
-import io.javalin.Javalin
 import io.javalin.apibuilder.CrudFunctionHandler
 import io.javalin.core.event.HandlerMetaInfo
 import io.javalin.core.util.OptionalDependency
 import io.javalin.core.util.Reflection.Companion.rfl
 import io.javalin.core.util.Util
 import io.javalin.core.util.methodReferenceReflectionMethodName
+import io.javalin.http.Handler
+import io.javalin.http.HandlerType
 import io.javalin.plugin.openapi.CreateSchemaOptions
-import io.javalin.plugin.openapi.annotations.OpenApi
-import io.javalin.plugin.openapi.annotations.asOpenApiDocumentation
-import io.javalin.plugin.openapi.annotations.pathInfo
-import io.javalin.plugin.openapi.annotations.scanForAnnotations
-import io.javalin.plugin.openapi.annotations.warnUserAboutPotentialBugs
+import io.javalin.plugin.openapi.annotations.*
+import org.slf4j.LoggerFactory
 import java.lang.reflect.Field
 import java.lang.reflect.Method
 import java.util.logging.Logger
 import kotlin.reflect.KFunction
 import kotlin.reflect.jvm.javaMethod
 
+class ExtractDocumentation {
+    // needed for logging purposes only, since most of the Kotlin functions below are defined globally and
+    // we want to have source code/file localised messages to ease debugging
+}
+private val localLogger = LoggerFactory.getLogger(ExtractDocumentation::class.java)
+
 fun HandlerMetaInfo.extractDocumentation(options: CreateSchemaOptions): OpenApiDocumentation {
     val documentation = document()
 
@@ -28,7 +32,10 @@ fun HandlerMetaInfo.extractDocumentation(options: CreateSchemaOptions): OpenApiD
         is DocumentedHandler -> (handler as DocumentedHandler).documentation
         is CrudFunctionHandler -> extractDocumentationFromCrudHandler(handler as CrudFunctionHandler)
         else -> {
-            val openApiAnnotation: OpenApi? = getOpenApiAnnotationFromReference() ?: getOpenApiAnnotationFromHandler()
+            val openApiAnnotation: OpenApi? = getOpenApiAnnotationFromReference()
+                    ?: methodReferenceOfHandler?.getOpenApiAnnotation()
+                    ?: fieldReferenceOfHandler?.getOpenApiAnnotation()
+                    ?: getOpenApiAnnotationFromHandler()
             openApiAnnotation?.asOpenApiDocumentation()
                     ?: extractDocumentationWithPathScanning(options)
                     ?: document()
@@ -87,22 +94,54 @@ private fun HandlerMetaInfo.getOpenApiAnnotationFromHandler(): OpenApi? {
         val method = rfl(handler).getMethodByName("handle")!!
         method.getOpenApiAnnotation()
     } catch (e: NullPointerException) {
-        null
+        localLogger.error("cannot match handler: $httpMethod path: $path --  NullPointerException", e)
+        return null
     }
 }
 
 private val HandlerMetaInfo.methodReferenceOfHandler: Method?
     get() = rfl(handler).let { handlerReflection ->
-        when {
-            handlerReflection.isClass -> (handler as Class<*>).methods[0]
-            handlerReflection.isKotlinMethodReference -> {
-                val functionValue = handlerReflection.getFieldValue("function") as KFunction<*>
-                functionValue.javaMethod
+            when {
+                handlerReflection.isClass -> (handler as Class<*>).methods[0]
+                handlerReflection.isKotlinMethodReference -> {
+                    val functionValue = handlerReflection.getFieldValue("function") as KFunction<*>
+                    functionValue.javaMethod
+                }
+                handlerReflection.isKotlinAnonymousLambda -> null // Cannot be parsed
+                handlerReflection.isJavaNonStaticMethodReference -> methodReferenceOfNonStaticJavaHandler
+                handlerReflection.isJavaAnonymousLambda -> null // taken care of by field-type handler
+                else -> {
+                    localLogger.debug("methodReferenceOfHandler else branch called for handler: "
+                            +"$httpMethod path '$path' and parent class = " + handlerReflection.parentClass.name)
+                    null
+                }
+            }
+    }
+
+private val HandlerMetaInfo.fieldReferenceOfHandler: Field?
+    get() {
+        if (handler !is Handler) {
+            localLogger.error("fieldReferenceOfHandler $httpMethod path: $path -- incompatible type: "
+                    + handler.javaClass.canonicalName)
+            return null // early return since handler does not implement Handler interface
+        }
+
+        return rfl(handler).let { handlerReflection ->
+            when {
+                // handlerReflection.isClass -> (handler as Class<*>).methods[0]
+                handlerReflection.isClass -> null // case handled in @see methodReferenceOfHandler
+                handlerReflection.isKotlinMethodReference -> null // case handled in @see methodReferenceOfHandler
+                handlerReflection.isKotlinAnonymousLambda -> null // Cannot be parsed // N.B. possibly add similar treatment as for Java
+                handlerReflection.isJavaNonStaticMethodReference -> null // case handled in @see methodReferenceOfHandler
+                handlerReflection.isJavaAnonymousLambda -> findFieldByOpenApiAnnotation(handlerReflection.parentClass.declaredFields)
+                handlerReflection.isJavaAnonymousClass -> findFieldByOpenApiAnnotation(handlerReflection.parentClass.declaredFields)
+                handlerReflection.isJavaMemberClass -> findFieldByOpenApiAnnotation(handlerReflection.parentClass.declaredFields)
+                else -> {
+                    localLogger.debug("fieldReferenceOfHandler else branch called for handler: "
+                            +"$httpMethod path '$path' and parent class = " + handlerReflection.parentClass.name)
+                    null
+                }
             }
-            handlerReflection.isKotlinAnonymousLambda -> null // Cannot be parsed
-            handlerReflection.isJavaNonStaticMethodReference -> methodReferenceOfNonStaticJavaHandler
-            handlerReflection.isJavaAnonymousLambda -> null // Cannot be parsed
-            else -> null
         }
     }
 
@@ -129,7 +168,7 @@ private val HandlerMetaInfo.methodReferenceOfNonStaticJavaHandler: Method?
 
                 val hasAnyMethodTheOpenApiAnnotation = methods.any { it.getOpenApiAnnotation() != null }
                 if (hasAnyMethodTheOpenApiAnnotation && handlerParentClass != null) {
-                    Javalin.log.warn("Unfortunately it is not possible to match the @OpenApi annotations to the handler in ${handlerParentClass.canonicalName}. " +
+                    localLogger.warn("Unfortunately it is not possible to match the @OpenApi annotations to the handler in ${handlerParentClass.canonicalName}. " +
                             "Please add the `path` and the `method` information to the annotation, so the handler can be matched.")
                 }
                 null
@@ -154,14 +193,169 @@ private fun HandlerMetaInfo.findMethodByOpenApiAnnotation(methods: Array<Method>
     }
 }
 
+private fun HandlerMetaInfo.findFieldByOpenApiAnnotation(allFields: Array<Field>): Field? {
+    if (allFields.isEmpty()) {
+        localLogger.warn("cannot match handler: $httpMethod path: $path -- no fields declared")
+        return null
+    }
+    // filter only implementations that are derived from the Handler interface
+    val handlerFields = allFields.filter { Handler::class.java.isAssignableFrom(it.type) }
+    if (handlerFields.isEmpty()) {
+        localLogger.warn("cannot match handler: $httpMethod path: $path -- no Handler-type fields declared in class "
+                + allFields[0].declaringClass.canonicalName)
+        return null
+    }
+
+    // case 1: exact match -- nominal case of OpenAPI path and HttpMethod declaration
+    val fieldsExactMatch = handlerFields.filter {
+        val annotation = it.getOpenApiAnnotation() ?: return@filter false
+        path.contentEquals(annotation.path) && httpMethod.compatible(annotation.method)
+    }
+    when (fieldsExactMatch.size) {
+        0 -> { /* empty */ } // no exact or partial-only match, continue with code below
+        1 -> return fieldsExactMatch[0] // exact path and HttpMethod match
+        else -> { // more than one exact match
+            localLogger.error("cannot match handler: $httpMethod path: $path -- multiple equal OpenAPI fields declarations")
+            for ((index, value) in fieldsExactMatch.withIndex()) {
+                localLogger.error("  - candidate $index: $value")
+            }
+            return null
+        }
+    }
+
+    // case 2: only one Handler-type field declared
+    if (handlerFields.size == 1) {
+        val annotation = handlerFields[0].getOpenApiAnnotation()
+        if (annotation == null) {
+            localLogger.warn("cannot match handler: $httpMethod path: $path -- missing OpenAPI field annotation for field: "
+                    + handlerFields[0].declaringLocation())
+            return null
+        }
+
+        if (annotation.path == NULL_STRING && httpMethod.compatible(annotation.method)) {
+            // trivial match: only one field with OpenAPI
+        } else if (annotation.path == NULL_STRING || path.contentEquals(annotation.path)) {
+            val annotationMethod = annotation.method
+            localLogger.warn("partial match for path $path and single Handler-type field -- handler type $httpMethod defined"
+                    +" but only available OpenAPI annotation declared for '$annotationMethod' for field: "
+                    + handlerFields[0].declaringLocation())
+        } else {
+            localLogger.warn("next best match for path $path and handler type $httpMethod defined -" +
+                    " imperfect match between annotation '$annotation' and field: "
+                    + handlerFields[0].declaringLocation())
+        }
+        return handlerFields[0]
+    }
+
+    // case 3: matching Path
+    val fieldsThatMatchPath = handlerFields.filter {
+        val annotation = it.getOpenApiAnnotation() ?: return@filter false
+        annotation.path == this.path
+    }
+    when (fieldsThatMatchPath.size) {
+        0 -> { /* empty */ } // no exact or partial-only match, continue with code below
+        1 -> {
+            val annotation = fieldsThatMatchPath[0].getOpenApiAnnotation() ?: return null
+            if (!httpMethod.compatible(annotation.method)) {
+                val annotationMethod = annotation.method
+                localLogger.warn("partial match for path '$path' -- handler method '$httpMethod' mismatch "
+                        +"with OpenAPI method '$annotationMethod' for field: "
+                        + fieldsThatMatchPath[0].declaringLocation())
+            }
+            return fieldsThatMatchPath[0]
+        }
+        else -> {
+            // fieldsThatMatchesPath.size > 1 ->
+            return fieldsThatMatchPath
+            .find {
+                it.getOpenApiAnnotation()
+                        ?.let { annotation -> annotation.pathInfo == pathInfo }
+                        ?: false
+            }
+        }
+    }
+
+    // case 4: matching HttpMethod
+    val fieldsMatchingHttpMethod = handlerFields.filter {
+        val annotation = it.getOpenApiAnnotation() ?: return@filter false
+        httpMethod.compatible(annotation.method)
+    }
+    when (fieldsMatchingHttpMethod.size) {
+        0 -> { /* empty */ } // no exact or partial-only match, continue with code below
+        1 -> {
+            val annotation = fieldsMatchingHttpMethod[0].getOpenApiAnnotation() ?: return null
+            if (annotation.path != NULL_STRING && !path.contentEquals(annotation.path)) {
+                val annotationPath = annotation.path
+                localLogger.warn("partial match for method $httpMethod -- handler path '$path' mismatch "
+                        +"with OpenAPI path '$annotationPath' for field: "
+                        + fieldsMatchingHttpMethod[0].declaringLocation())
+            }
+            return fieldsMatchingHttpMethod[0] // exact HttpMethod match
+        }
+        else -> { // more than one exact match
+            localLogger.error("cannot match handler: $httpMethod path: $path -- multiple equal OpenAPI fields method declarations")
+            for ((index, value) in fieldsMatchingHttpMethod.withIndex()) {
+                localLogger.error("  - candidate $index: $value")
+            }
+            return null
+        }
+    }
+
+    localLogger.warn("cannot match handler: $httpMethod path: $path -- no matching OpenAPI field annotation for eligible fields: ")
+    for ((index, value) in handlerFields.withIndex()) {
+        localLogger.error("  - candidate $index: $value")
+    }
+    return null
+}
+
 fun Method.getOpenApiAnnotation(): OpenApi? {
-    val result = getAnnotation(OpenApi::class.java) ?: return null
+    val result = getAnnotation(OpenApi::class.java)
+    if (result == null) {
+        localLogger.warn("cannot match handler -- missing OpenAPI method annotation: " + declaringLocation())
+        return null
+    }
     result.warnUserAboutPotentialBugs(this.declaringClass)
     return result
 }
 
 fun Field.getOpenApiAnnotation(): OpenApi? {
-    val result = getAnnotation(OpenApi::class.java) ?: return null
+    val result = getAnnotation(OpenApi::class.java)
+    if (result == null) {
+        localLogger.warn("cannot match handler -- missing OpenAPI field annotation: " + declaringLocation())
+        return null
+    }
     result.warnUserAboutPotentialBugs(this.declaringClass)
     return result
 }
+
+fun HandlerType.compatible(other: Any?) : Boolean {
+    if (other == null) {
+        return false
+    }
+    if (equals(other)) {
+        return true
+    }
+    if (other !is HttpMethod) {
+        return false
+    }
+    val httpMethod : HttpMethod = other
+    return when (this) { // N.B. this strange comparison is needed because HttpMethod and HandlerType enums differ
+        HandlerType.GET -> httpMethod == HttpMethod.GET
+        HandlerType.POST -> httpMethod == HttpMethod.POST
+        HandlerType.PUT -> httpMethod == HttpMethod.PUT
+        HandlerType.PATCH -> httpMethod == HttpMethod.PATCH
+        HandlerType.DELETE -> httpMethod == HttpMethod.DELETE
+        HandlerType.HEAD -> httpMethod == HttpMethod.HEAD
+        HandlerType.OPTIONS -> httpMethod == HttpMethod.OPTIONS
+        HandlerType.TRACE -> httpMethod == HttpMethod.TRACE
+        HandlerType.CONNECT -> false // not mapped/no equivalent in HttpMethod
+        HandlerType.BEFORE -> false // not mapped/no equivalent in HttpMethod
+        HandlerType.AFTER -> false // not mapped/no equivalent in HttpMethod
+        HandlerType.INVALID -> false // not mapped/no equivalent in HttpMethod
+        else -> false
+    }
+}
+
+private fun Method.declaringLocation() : String = returnType.toGenericString() + " " + declaringClass.name+"::"+ name
+
+private fun Field.declaringLocation() : String = declaringClass.canonicalName+"::"+ type +" "+ name
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index cef739ae..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,23 +104,21 @@ 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) {
-        addStaticFiles("/", path, location);
-        return this;
+        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();
+        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/JavalinConfigExt.kt b/javalin/src/main/java/io/javalin/core/JavalinConfigExt.kt
index 465d427e..5e08d3e2 100644
--- a/javalin/src/main/java/io/javalin/core/JavalinConfigExt.kt
+++ b/javalin/src/main/java/io/javalin/core/JavalinConfigExt.kt
@@ -2,4 +2,5 @@ package io.javalin.core
 
 import io.javalin.core.plugin.Plugin
 
+@JvmSynthetic
 inline fun <reified T : Plugin> JavalinConfig.getPlugin(): T = getPlugin(T::class.java)
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/ReflectionUtil.kt b/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
index c1f71c80..8ddafc1d 100644
--- a/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
+++ b/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
@@ -42,6 +42,10 @@ internal val Any.isKotlinMethodReference: Boolean get() = this.javaClass.declare
 
 internal val Any.isKotlinField: Boolean get() = this.javaClass.fields.any { it.name == "INSTANCE" }
 
+internal val Any.isJavaAnonymousClass: Boolean get() = this.javaClass.isAnonymousClass
+
+internal val Any.isJavaMemberClass: Boolean get() = this.javaClass.isMemberClass
+
 internal val Any.isJavaAnonymousLambda: Boolean get() = this.javaClass.isSynthetic
 
 internal val Any.hasMethodName: Boolean get() = methodName != null
@@ -102,7 +106,9 @@ class Reflection(val obj: Any) {
     val isKotlinAnonymousLambda: Boolean get() = obj.isKotlinAnonymousLambda
     val isKotlinMethodReference: Boolean get() = obj.isKotlinMethodReference
     val isKotlinField: Boolean get() = obj.isKotlinField
+    val isJavaAnonymousClass: Boolean get() = obj.isJavaAnonymousClass
     val isJavaAnonymousLambda: Boolean get() = obj.isJavaAnonymousLambda
+    val isJavaMemberClass: Boolean get() = obj.isJavaMemberClass
     val hasMethodName: Boolean get() = obj.hasMethodName
     val isJavaNonStaticMethodReference: Boolean get() = obj.isJavaNonStaticMethodReference
     val isJavaField: Boolean get() = obj.isJavaField
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
index c0565c60..9b4e5b99 100644
--- a/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
@@ -7,51 +7,9 @@
 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 a464ba30..ae442bd8 100644
--- a/javalin/src/main/java/io/javalin/core/validation/Validator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
@@ -8,29 +8,70 @@ package io.javalin.core.validation
 
 import io.javalin.http.BadRequestResponse
 
-open class Validator<T>(val value: T?, val messagePrefix: String = "Value") {
+open class Validator<T>(val value: T?, val messagePrefix: String = "Value", val key: String = "Parameter") {
 
     data class Rule<T>(val fieldName: String, val test: (T) -> Boolean, val invalidMessage: String)
 
+    protected open var errors: MutableMap<String, MutableList<String>>? = null
+
     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(key, predicate, errorMessage))
         return this
     }
 
+    //These two options will fail fast but only provide the first failure.
     fun get(): T = getOrNull() ?: throw BadRequestResponse("$messagePrefix cannot be null or empty")
 
     fun getOrNull(): T? {
         if (value == null) return null
-        return rules.find { !it.test.invoke(value) }?.let { throw BadRequestResponse(it.invalidMessage) } ?: value
+        return rules.find { !it.test.invoke(value) }?.let { throw BadRequestResponse("$messagePrefix invalid - ${it.invalidMessage}") } ?: value
+    }
+
+    open fun isValid(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isEmpty()
+    }
+
+    open fun hasError(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isNotEmpty()
+    }
+
+    open fun errors(): Map<String, List<String>> {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!
+    }
+
+    protected open fun validate() {
+        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) {
+                }
+            }
+        }
     }
 
     companion object {
         @JvmStatic
         @JvmOverloads
-        fun <T> create(clazz: Class<T>, value: String?, messagePrefix: String = "Value"): Validator<T> {
+        fun <T> create(clazz: Class<T>, value: String?, messagePrefix: String = "Value", key: String = "Parameter"): Validator<T> {
             return Validator(try {
                 val converter = JavalinValidation.converters[clazz] ?: throw MissingConverterException(clazz.simpleName)
                 if (value != null && value.isNotEmpty()) {
@@ -41,8 +82,31 @@ open class Validator<T>(val value: T?, val messagePrefix: String = "Value") {
             } catch (e: Exception) {
                 if (e is MissingConverterException) throw e
                 throw BadRequestResponse("$messagePrefix is not a valid ${clazz.simpleName}")
-            } as T, messagePrefix)
+            } as T, messagePrefix, key)
+        }
+
+        @JvmStatic
+        fun collectErrors(vararg validators: Validator<*>): Map<String, List<String>> {
+            return collectErrors(validators.toList())
+        }
+
+        @JvmStatic
+        fun collectErrors(validators: Iterable<Validator<*>>): Map<String, List<String>> {
+            val allErrors = mutableMapOf<String, MutableList<String>>()
+            validators.forEach { validator ->
+                validator.errors().forEach { (fieldName, errorMessages) ->
+                    if (allErrors[fieldName] != null) {
+                        allErrors[fieldName]?.addAll(errorMessages)
+                    } else {
+                        allErrors[fieldName] = errorMessages.toMutableList()
+                    }
+                }
+            }
+            return allErrors
         }
     }
+}
 
+fun Iterable<Validator<*>>.collectErrors(): Map<String, List<String>> {
+    return Validator.collectErrors(this)
 }
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index c89cc5e9..c9ecb5db 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -147,6 +147,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     }
 
     /** Reified version of [bodyValidator] */
+    @JvmSynthetic
     inline fun <reified T : Any> bodyValidator() = bodyValidator(T::class.java)
 
     /** Gets first [UploadedFile] for the specified name, or null. */
@@ -170,7 +171,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Throws [BadRequestResponse] if validation fails.
      */
     @JvmOverloads
-    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)}'")
+    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)}'", key)
 
     /** Reified version of [formParam] (Kotlin only) */
     @JvmSynthetic
@@ -197,7 +198,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Creates a [Validator] for the pathParam() value, with the prefix "Path parameter '$key' with value '$value'"
      * Throws [BadRequestResponse] if validation fails.
      */
-    fun <T> pathParam(key: String, clazz: Class<T>) = Validator.create(clazz, pathParam(key), "Path parameter '$key' with value '${pathParam(key)}'")
+    fun <T> pathParam(key: String, clazz: Class<T>) = Validator.create(clazz, pathParam(key), "Path parameter '$key' with value '${pathParam(key)}'", key)
 
     /** Reified version of [pathParam] (Kotlin only) */
     @JvmSynthetic
@@ -247,7 +248,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     fun header(header: String): String? = req.getHeader(header)
 
     /** Creates a [Validator] for the header() value, with the prefix "Request header '$header' with the value '$value'" */
-    fun <T> header(header: String, clazz: Class<T>): Validator<T> = Validator.create(clazz, header(header), "Request header '$header' with value '${header(header)}'")
+    fun <T> header(header: String, clazz: Class<T>): Validator<T> = Validator.create(clazz, header(header), "Request header '$header' with value '${header(header)}'", header)
 
     /** Reified version of [header] (Kotlin only) */
     @JvmSynthetic
@@ -293,7 +294,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Throws [BadRequestResponse] if validation fails.
      */
     @JvmOverloads
-    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)}'")
+    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)}'", key)
 
     /** Reified version of [queryParam] (Kotlin only) */
     @JvmSynthetic
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..c6bc3ca9 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/JettyResourceHandler.kt
@@ -18,7 +18,7 @@ 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>()
 
@@ -32,9 +32,7 @@ class JettyResourceHandler : io.javalin.http.staticfiles.ResourceHandler {
                 |    Resolved path: '${getResourcePath(config)}'
                 """.trimMargin())
         }
-        handlers.add(handler.apply {
-            start()
-        })
+        handlers.add(handler.apply { start() })
     }
 
     inner class WebjarHandler : ResourceHandler() {
@@ -79,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/plugin/metrics/MicrometerPlugin.kt b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
index b21311aa..9ea47798 100644
--- a/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
+++ b/javalin/src/main/java/io/javalin/plugin/metrics/MicrometerPlugin.kt
@@ -29,13 +29,13 @@ class MicrometerPlugin @JvmOverloads constructor(private val registry: MeterRegi
                                                  private val tagExceptionName: Boolean = false) : Plugin {
     override fun apply(app: Javalin) {
         app.server()?.server()?.let { server ->
-            if(tagExceptionName) {
+            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) {
+                    val exceptionName = if (tagExceptionName) {
                         response.getHeader(EXCEPTION_HEADER)
                     } else {
                         "Unknown"
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 8966b274..1cd398da 100644
--- a/javalin/src/main/java/io/javalin/websocket/WsContext.kt
+++ b/javalin/src/main/java/io/javalin/websocket/WsContext.kt
@@ -36,12 +36,14 @@ 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)
 
@@ -84,5 +86,7 @@ class WsBinaryMessageContext(sessionId: String, session: Session, private val da
 class WsMessageContext(sessionId: String, session: Session, private val message: String) : WsContext(sessionId, session) {
     fun message(): String = message
     fun <T> message(clazz: Class<T>): T = JavalinJson.fromJson(message, clazz)
+
+    @JvmSynthetic
     inline fun <reified T : Any> message(): T = message(T::class.java)
 }


Test patch:
diff --git a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApiAnnotations_Java.java b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApiAnnotations_Java.java
index 08fec7e9..4602f9d0 100644
--- a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApiAnnotations_Java.java
+++ b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApiAnnotations_Java.java
@@ -152,7 +152,7 @@ class JavaStaticMethodReference {
     }
 }
 
-class JavaFieldReference {
+class JavaStaticFieldReference {
     @OpenApi(responses = {@OpenApiResponse(status = "200")})
     public static Handler handler = new Handler() {
         @Override
@@ -161,6 +161,103 @@ class JavaFieldReference {
     };
 }
 
+class JavaFieldReference {
+    public final int irrelevantField = 0;
+
+    @OpenApi(
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler = new Handler() {
+        @Override
+        public void handle(@NotNull Context ctx) throws Exception {
+        }
+    };
+}
+
+class JavaLambdaFieldReference {
+    @OpenApi(
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler = ctx -> { };
+}
+
+class JavaMultipleFieldReferences {
+    @OpenApi(
+        path = "/test1", // parameter needed to due ambiguity
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler1 = ctx -> { };
+
+    @OpenApi(
+        path = "/test2", // parameter needed to due ambiguity
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler2 = ctx -> { };
+
+    @OpenApi(
+        method = HttpMethod.PUT,
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler putHandler = ctx -> { };
+
+    @OpenApi(
+        method = HttpMethod.DELETE,
+        responses = {@OpenApiResponse(status = "200")})
+    public final Handler deleteHandler = ctx -> { };
+}
+
+class JavaStaticLambdaFieldReference {
+    @OpenApi(
+        path = "/test",
+        method = HttpMethod.GET,
+        responses = {@OpenApiResponse(status = "200")})
+    public static final Handler handler = ctx -> { };
+}
+
+class CustomOuterClassHandler implements Handler {
+    public final int irrelevantField;
+
+    CustomOuterClassHandler() {
+        irrelevantField = 2;
+    }
+
+    @Override
+    public void handle(@NotNull Context ctx) throws Exception {
+        // some custom user code
+    }
+}
+
+class JavaOuterClassFieldReference {
+    @OpenApi(responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler = new CustomOuterClassHandler(){};
+    // N.B. curly brackets '{}' are important to make the above a pseudo-anonymous class
+}
+
+class JavaInnerClassFieldReference {
+    @OpenApi(responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler = new CustomInnerClassHandler(ctx -> {});
+
+    private class CustomInnerClassHandler implements Handler {
+        public final int irrelevantField;
+        public final Handler userHandler;
+
+        public CustomInnerClassHandler(final Handler userHandler) {
+            this.userHandler = userHandler;
+            irrelevantField = 2;
+        }
+
+        @Override
+        public void handle(@NotNull Context ctx) throws Exception {
+            // derived custom handler
+        }
+    };
+}
+
+class JavaAnonymousClassFieldReference {
+    @OpenApi(responses = {@OpenApiResponse(status = "200")})
+    public final Handler handler = new Handler() {
+        @Override
+        public void handle(@NotNull Context ctx) throws Exception {
+            // derived custom handler
+        }
+    };
+}
+
 class ClassHandlerWithInvalidPath {
     @OpenApi(
         method = HttpMethod.GET,
@@ -275,10 +372,99 @@ public class TestOpenApiAnnotations_Java {
         OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExampleWithDescription());
     }
 
+    @Test
+    public void testWithStaticJavaFieldReference() {
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", JavaStaticFieldReference.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
     @Test
     public void testWithJavaFieldReference() {
+        final JavaFieldReference instance = new JavaFieldReference();
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", instance.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
+    @Test
+    public void testWithJavaLambdaFieldReference() {
+        final JavaLambdaFieldReference instance = new JavaLambdaFieldReference();
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", instance.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
+    @Test
+    public void testWithMultipleFieldReferences() {
+        final JavaMultipleFieldReferences instance = new JavaMultipleFieldReferences();
+
+        OpenAPI schemaPut = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.put("/put", instance.putHandler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schemaPut, JsonKt.getSimplePutExample());
+
+        OpenAPI schemaDelete = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.put("/delete", instance.deleteHandler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schemaDelete, JsonKt.getSimpleDeleteExample());
+
+        OpenAPI schemaTestCase1 = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test1", instance.handler1);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schemaTestCase1, JsonKt.getSimpleExample1());
+
+        OpenAPI schemaTestCase2 = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test2", instance.handler2);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schemaTestCase2, JsonKt.getSimpleExample2());
+    }
+
+
+    @Test
+    public void testWithJavaOuterClassFieldReference() {
+        final JavaOuterClassFieldReference instance = new JavaOuterClassFieldReference();
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", instance.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
+    @Test
+    public void testWithJavaInnerClassFieldReference() {
+        final JavaInnerClassFieldReference instance = new JavaInnerClassFieldReference();
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", instance.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
+    @Test
+    public void testWithAnonymousJavaClassFieldReference() {
+        final JavaAnonymousClassFieldReference instance = new JavaAnonymousClassFieldReference();
+        OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
+            app.get("/test", instance.handler);
+            return Unit.INSTANCE;
+        });
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+    }
+
+    @Test
+    public void testWithJavaStaticLambdaFieldReference() {
         OpenAPI schema = OpenApiTestUtils.extractSchemaForTest(app -> {
-            app.get("/test", JavaFieldReference.handler);
+            app.get("/test", JavaStaticLambdaFieldReference.handler);
             return Unit.INSTANCE;
         });
         OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
diff --git a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/json.kt b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/json.kt
index c338bd07..c7d378ee 100644
--- a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/json.kt
+++ b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/json.kt
@@ -175,6 +175,106 @@ val simpleExample = """
   }
 """.formatJson()
 
+@Language("JSON")
+val simpleExample1 = """
+  {
+    "openapi": "3.0.1",
+    "info": {
+      "title": "Example",
+      "version": "1.0.0"
+    },
+    "paths": {
+      "/test1": {
+        "get": {
+          "summary": "Get test1",
+          "operationId": "getTest1",
+          "responses" : {
+            "200" : {
+              "description" : "OK"
+            }
+          }
+        }
+      }
+    },
+    "components": {}
+  }
+""".formatJson()
+
+@Language("JSON")
+val simpleExample2 = """
+  {
+    "openapi": "3.0.1",
+    "info": {
+      "title": "Example",
+      "version": "1.0.0"
+    },
+    "paths": {
+      "/test2": {
+        "get": {
+          "summary": "Get test2",
+          "operationId": "getTest2",
+          "responses" : {
+            "200" : {
+              "description" : "OK"
+            }
+          }
+        }
+      }
+    },
+    "components": {}
+  }
+""".formatJson()
+
+@Language("JSON")
+val simplePutExample = """
+  {
+    "openapi" : "3.0.1",
+    "info" : {
+      "title" : "Example",
+      "version" : "1.0.0"
+    },
+    "paths" : {
+      "/put" : {
+        "put" : {
+          "summary" : "Put put",
+          "operationId" : "putPut",
+          "responses" : {
+            "200" : {
+            "description" : "OK"
+            }
+          }
+        }
+      }
+    },
+    "components" : { }
+  }
+""".formatJson()
+
+@Language("JSON")
+val simpleDeleteExample = """
+  {
+    "openapi" : "3.0.1",
+    "info" : {
+      "title" : "Example",
+      "version" : "1.0.0"
+    },
+    "paths" : {
+      "/delete" : {
+        "put" : {
+          "summary" : "Put delete",
+          "operationId" : "putDelete",
+          "responses" : {
+            "200" : {
+              "description" : "OK"
+            }
+          }
+        }
+      }
+    },
+    "components" : { }
+  }
+""".formatJson()
+
 @Language("JSON")
 val simpleExampleWithDescription = """
   {
diff --git a/javalin/src/test/java/io/javalin/TestMultipartForms.kt b/javalin/src/test/java/io/javalin/TestMultipartForms.kt
index 35e9c6fa..6f6de8da 100644
--- a/javalin/src/test/java/io/javalin/TestMultipartForms.kt
+++ b/javalin/src/test/java/io/javalin/TestMultipartForms.kt
@@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets
 
 class TestMultipartForms {
 
-    private val EOL = System.getProperty("line.separator")
-    private val TEXT_FILE_CONTENT = "This is my content." + EOL + "It's two lines." + EOL
+    private val LF = "\n"
+    private val CRLF = "\r\n"
+    private val TEXT_FILE_CONTENT_LF = "This is my content." + LF + "It's two lines." + LF
+    private val TEXT_FILE_CONTENT_CRLF = "This is my content." + CRLF + "It's two lines." + CRLF
 
     // Using OkHttp because Unirest doesn't allow to send non-files as form-data
     private val okHttp = OkHttpClient()
@@ -34,21 +36,25 @@ class TestMultipartForms {
         val response = http.post("/test-upload")
                 .field("upload", File("src/test/resources/upload-test/text.txt"))
                 .asString()
-        assertThat(response.body).isEqualTo(TEXT_FILE_CONTENT)
+        if (CRLF in response.body) {
+            assertThat(response.body).isEqualTo(TEXT_FILE_CONTENT_CRLF)
+        } else {
+            assertThat(response.body).isEqualTo(TEXT_FILE_CONTENT_LF)
+        }
     }
 
     @Test
     fun `mp3s are uploaded correctly`() = TestUtil.test { app, http ->
         app.post("/test-upload") { ctx ->
             val uf = ctx.uploadedFile("upload")!!
-            ctx.json(UploadInfo(uf.filename, uf.contentLength, uf.contentType, uf.extension))
+            ctx.json(UploadInfo(uf.filename, uf.size, uf.contentType, uf.extension))
         }
         val uploadFile = File("src/test/resources/upload-test/sound.mp3")
         val response = http.post("/test-upload")
                 .field("upload", uploadFile)
                 .asString()
         val uploadInfo = JavalinJackson.fromJson(response.body, UploadInfo::class.java)
-        assertThat(uploadInfo.contentLength).isEqualTo(uploadFile.length())
+        assertThat(uploadInfo.size).isEqualTo(uploadFile.length())
         assertThat(uploadInfo.filename).isEqualTo(uploadFile.name)
         assertThat(uploadInfo.contentType).isEqualTo("application/octet-stream")
         assertThat(uploadInfo.extension).isEqualTo(".mp3")
@@ -58,18 +64,18 @@ class TestMultipartForms {
     fun `pngs are uploaded correctly`() = TestUtil.test { app, http ->
         app.post("/test-upload") { ctx ->
             val uf = ctx.uploadedFile("upload")
-            ctx.json(UploadInfo(uf!!.filename, uf.contentLength, uf.contentType, uf.extension))
+            ctx.json(UploadInfo(uf!!.filename, uf.size, uf.contentType, uf.extension))
         }
         val uploadFile = File("src/test/resources/upload-test/image.png")
         val response = http.post("/test-upload")
                 .field("upload", uploadFile, "image/png")
                 .asString()
         val uploadInfo = JavalinJackson.fromJson(response.body, UploadInfo::class.java)
-        assertThat(uploadInfo.contentLength).isEqualTo(uploadFile.length())
+        assertThat(uploadInfo.size).isEqualTo(uploadFile.length())
         assertThat(uploadInfo.filename).isEqualTo(uploadFile.name)
         assertThat(uploadInfo.contentType).isEqualTo("image/png")
         assertThat(uploadInfo.extension).isEqualTo(".png")
-        assertThat(uploadInfo.contentLength).isEqualTo(6690)
+        assertThat(uploadInfo.size).isEqualTo(6690L)
     }
 
     @Test
@@ -170,7 +176,12 @@ class TestMultipartForms {
                                 .build()
                 ).build()
         ).execute().body()!!.string()
-        assertThat(responseAsString).isEqualTo(prefix + TEXT_FILE_CONTENT)
+
+        if (CRLF in responseAsString) {
+            assertThat(responseAsString).isEqualTo(prefix + TEXT_FILE_CONTENT_CRLF)
+        } else {
+            assertThat(responseAsString).isEqualTo(prefix + TEXT_FILE_CONTENT_LF)
+        }
     }
 
 }
diff --git a/javalin/src/test/java/io/javalin/TestStaticFiles.kt b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
index 53bca4e7..6079bbc6 100644
--- a/javalin/src/test/java/io/javalin/TestStaticFiles.kt
+++ b/javalin/src/test/java/io/javalin/TestStaticFiles.kt
@@ -180,4 +180,5 @@ class TestStaticFiles {
             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 c8378b3c..bf1bbe5e 100644
--- a/javalin/src/test/java/io/javalin/TestValidation.kt
+++ b/javalin/src/test/java/io/javalin/TestValidation.kt
@@ -6,8 +6,11 @@
 
 package io.javalin
 
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
 import io.javalin.core.validation.JavalinValidation
 import io.javalin.core.validation.Validator
+import io.javalin.core.validation.collectErrors
 import io.javalin.http.BadRequestResponse
 import io.javalin.plugin.json.JavalinJson
 import io.javalin.testing.SerializeableObject
@@ -181,6 +184,36 @@ class TestValidation {
         }
     }
 
+    @Test
+    fun `All errors can be collected from multiple validators`() = TestUtil.test { app, http ->
+
+        app.get("/") { ctx ->
+            val numberValidator = ctx.queryParam<Int>("number")
+                    .check({ it > 12 }, "must be greater than 12.")
+                    .check({ it.rem(2) == 0}, "must be even.")
+
+            val stringValidator = ctx.queryParam<String>("first_name")
+                    .check({ !it.contains("-") }, "cannot contain hyphens.")
+                    .check({ it.length < 10 }, "cannot be longer than 10 characters.")
+
+            ctx.json(listOf(numberValidator, stringValidator).collectErrors())
+        }
+
+        http.get("/?number=7&first_name=my-overly-long-first-name").apply {
+            assertThat(status).isEqualTo(200)
+
+            val errors = jacksonObjectMapper().readValue<Map<String, List<String>>>(body)
+
+            assertThat(errors).size().isEqualTo(2)
+            assertThat(errors.keys).contains("number", "first_name")
+
+            assertThat(errors["number"]).size().isEqualTo(2)
+            assertThat(errors["number"]).contains("must be greater than 12.", "must be even.")
+
+            assertThat(errors["first_name"]).size().isEqualTo(2)
+            assertThat(errors["first_name"]).contains("cannot contain hyphens.", "cannot be longer than 10 characters.")
+        }
+    }
 
     @Test
     fun `body validator with check and retrieve errors`() = TestUtil.test { app, http ->
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/java/io/javalin/testing/UploadInfo.kt b/javalin/src/test/java/io/javalin/testing/UploadInfo.kt
index 54d8d40f..cb9680cf 100644
--- a/javalin/src/test/java/io/javalin/testing/UploadInfo.kt
+++ b/javalin/src/test/java/io/javalin/testing/UploadInfo.kt
@@ -6,4 +6,4 @@
 
 package io.javalin.testing
 
-class UploadInfo(val filename: String = "", val contentLength: Int = 0, val contentType: String = "", val extension: String = "")
+class UploadInfo(val filename: String = "", val size: Long = 0L, val contentType: String = "", val extension: String = "")