Repository: tipsy/javalin
Pull request #1261 (2021-05-29 11:50:50)

[validation] Validation rework


Patch:
diff --git a/javalin/pom.xml b/javalin/pom.xml
index 2f1861f8..37921a3b 100644
--- a/javalin/pom.xml
+++ b/javalin/pom.xml
@@ -164,6 +164,11 @@
             <artifactId>swagger-ui</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.datatype</groupId>
+            <artifactId>jackson-datatype-jsr310</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/javalin/src/main/java/io/javalin/Javalin.java b/javalin/src/main/java/io/javalin/Javalin.java
index 75cb22ee..20e025c9 100644
--- a/javalin/src/main/java/io/javalin/Javalin.java
+++ b/javalin/src/main/java/io/javalin/Javalin.java
@@ -21,6 +21,7 @@ import io.javalin.core.security.AccessManager;
 import io.javalin.core.security.Role;
 import io.javalin.core.util.JavalinLogger;
 import io.javalin.core.util.Util;
+import io.javalin.core.validation.JavalinValidation;
 import io.javalin.http.Context;
 import io.javalin.http.ErrorMapperKt;
 import io.javalin.http.ExceptionHandler;
@@ -84,6 +85,7 @@ public class Javalin {
      */
     public static Javalin create(Consumer<JavalinConfig> config) {
         Javalin app = new Javalin();
+        JavalinValidation.addValidationExceptionMapper(app);
         JavalinConfig.applyUserConfig(app, app._conf, config); // mutates app.config and app (adds http-handlers)
         if (app._conf.logIfServerNotStarted) {
             Util.logIfServerNotStarted(app.server);
diff --git a/javalin/src/main/java/io/javalin/core/validation/BaseValidator.kt b/javalin/src/main/java/io/javalin/core/validation/BaseValidator.kt
new file mode 100644
index 00000000..0c1ede5d
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/validation/BaseValidator.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+
+typealias Check<T> = (T) -> Boolean
+
+data class Rule<T>(val fieldName: String, val check: Check<T?>, val error: String)
+data class ValidationError<T>(val message: String, val value: T?)
+class ValidationException(val errors: Map<String, List<ValidationError<Any>>>) : Exception()
+
+open class BaseValidator<T>(val value: T?, val fieldName: String) {
+
+    internal val rules = mutableListOf<Rule<T>>()
+    private val errors by lazy {
+        val errors = mutableMapOf<String, MutableList<ValidationError<T>>>()
+        if (value == null && this !is NullableValidator) {
+            errors[fieldName] = mutableListOf(ValidationError("NULLCHECK_FAILED", value))
+        }
+        rules.forEach { rule ->
+            if (value != null && !rule.check(value)) {
+                // the same validator can have multiple field names if it's a BodyValidator
+                errors.computeIfAbsent(rule.fieldName) { mutableListOf() }
+                errors[rule.fieldName]!!.add(ValidationError(rule.error, value))
+            }
+        }
+        errors.mapValues { it.value.toList() }.toMap() // make immutable
+    }
+
+    fun addRule(fieldName: String, check: Check<T?>, error: String): BaseValidator<T> {
+        rules.add(Rule(fieldName, check, error))
+        return this
+    }
+
+    open fun get(): T? = when {
+        errors.isEmpty() -> value
+        else -> throw ValidationException(errors as Map<String, List<ValidationError<Any>>>)
+    }
+
+    fun errors(): Map<String, List<ValidationError<T>>> = errors
+
+}
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 9b4e5b99..f776ca8a 100644
--- a/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
@@ -6,10 +6,10 @@
 
 package io.javalin.core.validation
 
-open class BodyValidator<T>(value: T?, messagePrefix: String = "Value") : Validator<T>(value, messagePrefix) {
-    @JvmOverloads
-    open fun check(fieldName: String, predicate: (T) -> Boolean, errorMessage: String = "Failed check"): BodyValidator<T> {
-        rules.add(Rule(fieldName, predicate, errorMessage))
-        return this
-    }
+open class BodyValidator<T>(value: T?) : BaseValidator<T>(value, value.fieldName()) {
+    fun check(check: Check<T>, error: String) = check(value.fieldName(), check, error)
+    fun check(fieldName: String, check: Check<T>, error: String) = addRule(fieldName, { check(it!!) }, error) as BodyValidator<T>
+    override fun get(): T = super.get()!!
 }
+
+fun Any?.fieldName() = this!!::class.java.simpleName
diff --git a/javalin/src/main/java/io/javalin/core/validation/JavalinValidation.kt b/javalin/src/main/java/io/javalin/core/validation/JavalinValidation.kt
index fb284595..c7e8ef2c 100644
--- a/javalin/src/main/java/io/javalin/core/validation/JavalinValidation.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/JavalinValidation.kt
@@ -6,9 +6,13 @@
 
 package io.javalin.core.validation
 
-class MissingConverterException(className: String) : IllegalArgumentException("Can't convert to $className. Register a converter using JavalinValidation#register.")
+import io.javalin.Javalin
+import java.lang.RuntimeException
+
+class MissingConverterException(val className: String) : RuntimeException()
 
 object JavalinValidation {
+
     val converters = mutableMapOf<Class<*>, (String) -> Any?>(
             java.lang.Boolean::class.java to { s -> s.toBoolean() },
             java.lang.Double::class.java to { s -> s.toDouble() },
@@ -24,6 +28,28 @@ object JavalinValidation {
             String::class.java to { s -> s }
     )
 
+    fun <T> convertValue(clazz: Class<T>, value: String?): T {
+        val converter = converters[clazz] ?: throw MissingConverterException(clazz.simpleName)
+        return (if (value != null) converter.invoke(value) else null) as T
+    }
+
     @JvmStatic
     fun register(clazz: Class<*>, converter: (String) -> Any?) = converters.put(clazz, converter)
+
+    @JvmStatic
+    fun collectErrors(vararg validators: Validator<*>) = collectErrors(validators.toList())
+
+    @JvmStatic
+    fun collectErrors(validators: Iterable<Validator<*>>): Map<String, List<ValidationError<out Any?>>> =
+        validators.flatMap { it.errors().entries }.associate { it.key to it.value }
+
+    @JvmStatic
+    fun addValidationExceptionMapper(app: Javalin) {
+        app.exception(ValidationException::class.java) { e, ctx ->
+            ctx.json(e.errors).status(400)
+        }
+    }
 }
+
+fun Iterable<Validator<*>>.collectErrors(): Map<String, List<ValidationError<out Any?>>> =
+    JavalinValidation.collectErrors(this)
diff --git a/javalin/src/main/java/io/javalin/core/validation/NullableValidator.kt b/javalin/src/main/java/io/javalin/core/validation/NullableValidator.kt
new file mode 100644
index 00000000..e667fdb6
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/validation/NullableValidator.kt
@@ -0,0 +1,11 @@
+/*
+ * 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 NullableValidator<T>(value: T?, fieldName: String) : BaseValidator<T>(value, fieldName) {
+    fun check(check: Check<T?>, error: String) = addRule(fieldName, check, error) as NullableValidator<T>
+}
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 9382753c..f10db821 100644
--- a/javalin/src/main/java/io/javalin/core/validation/Validator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
@@ -6,107 +6,35 @@
 
 package io.javalin.core.validation
 
-import io.javalin.http.BadRequestResponse
+import io.javalin.core.util.JavalinLogger
+import io.javalin.http.InternalServerErrorResponse
 
-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(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("$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()
-    }
+/**
+ * The non-nullable [Validator] uses [Rule] rules, but checks if value is null before calling them.
+ * The [check] method wraps its non-nullable predicate in a nullable predicate
+ */
+open class Validator<T>(value: T?, fieldName: String) : BaseValidator<T>(value, fieldName) {
 
-    open fun errors(): Map<String, List<String>> {
-        if (errors == null) {
-            validate()
-        }
-        return errors!!
+    fun allowNullable(): NullableValidator<T> {
+        if (this.rules.isEmpty()) return NullableValidator(value, fieldName)
+        throw IllegalStateException("Validator#allowNullable must be called before adding rules")
     }
 
-    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) {
-                }
-            }
-        }
-    }
+    fun check(check: Check<T>, error: String) = addRule(fieldName, { check(it!!) }, error) as Validator<T>
+    override fun get(): T = super.get()!!
 
     companion object {
         @JvmStatic
-        @JvmOverloads
-        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) {
-                    converter.invoke(value) ?: throw NullPointerException()
-                } else {
-                    null
-                }
-            } catch (e: Exception) {
-                if (e is MissingConverterException) throw e
-                throw BadRequestResponse("$messagePrefix is not a valid ${clazz.simpleName}")
-            } 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()
-                    }
-                }
+        fun <T> create(clazz: Class<T>, value: String?, fieldName: String) = try {
+            Validator(JavalinValidation.convertValue(clazz, value), fieldName)
+        } catch (e: Exception) {
+            if (e is MissingConverterException) {
+                JavalinLogger.info("Can't convert to ${e.className}. Register a converter using JavalinValidation#register.")
+                throw InternalServerErrorResponse()
             }
-            return allErrors
+            JavalinLogger.info("Parameter '${fieldName}' with value '${value}' is not a valid ${clazz.simpleName}")
+            throw ValidationException(mapOf(fieldName to listOf(ValidationError("TYPE_CONVERSION_FAILED", value))))
         }
     }
 }
 
-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 82ab1c2c..358178fc 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -10,6 +10,8 @@ import io.javalin.core.security.BasicAuthCredentials
 import io.javalin.core.util.Header
 import io.javalin.core.util.JavalinLogger
 import io.javalin.core.validation.BodyValidator
+import io.javalin.core.validation.ValidationError
+import io.javalin.core.validation.ValidationException
 import io.javalin.core.validation.Validator
 import io.javalin.http.util.ContextUtil
 import io.javalin.http.util.ContextUtil.throwPayloadTooLargeIfPayloadTooLarge
@@ -125,10 +127,10 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Throws [BadRequestResponse] if validation fails.
      */
     fun <T> bodyValidator(clazz: Class<T>) = try {
-        BodyValidator(JavalinJson.fromJson(body(), clazz), "Request body as ${clazz.simpleName}")
+        BodyValidator(JavalinJson.fromJson(body(), clazz))
     } catch (e: Exception) {
         JavalinLogger.info("Couldn't deserialize body to ${clazz.simpleName}", e)
-        throw BadRequestResponse("Couldn't deserialize body to ${clazz.simpleName}")
+        throw ValidationException(mapOf(clazz.simpleName to listOf(ValidationError("DESERIALIZATION_FAILED", body()))))
     }
 
     /** Reified version of [bodyValidator] */
@@ -161,15 +163,15 @@ 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)}'", key)
+    fun <T> formParam(key: String, clazz: Class<T>, default: String? = null) = Validator.create(clazz, formParam(key, default), key)
 
     /** Gets a list of form params for the specified key, or empty list. */
     fun formParams(key: String): List<String> = formParamMap()[key] ?: emptyList()
 
     /** Gets a map with all the form param keys and values. */
     fun formParamMap(): Map<String, List<String>> =
-            if (isMultipartFormData()) MultipartUtil.getFieldMap(req)
-            else ContextUtil.splitKeyValueStringAndGroupByKey(body(), characterEncoding)
+        if (isMultipartFormData()) MultipartUtil.getFieldMap(req)
+        else ContextUtil.splitKeyValueStringAndGroupByKey(body(), characterEncoding)
 
     /**
      * Gets a path param by name (ex: pathParam("param").
@@ -184,7 +186,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)}'", key)
+    fun <T> pathParam(key: String, clazz: Class<T>) = Validator.create(clazz, pathParam(key), key)
 
     /** Gets a map of all the [pathParam] keys and values. */
     fun pathParamMap(): Map<String, String> = Collections.unmodifiableMap(pathParamMap)
@@ -230,7 +232,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)}'", header)
+    fun <T> header(header: String, clazz: Class<T>): Validator<T> = Validator.create(clazz, header(header), header)
 
     /** Gets a map with all the header keys and values on the request. */
     fun headerMap(): Map<String, String> = req.headerNames.asSequence().associate { it to header(it)!! }
@@ -272,7 +274,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)}'", key)
+    fun <T> queryParam(key: String, clazz: Class<T>, default: String? = null) = Validator.create(clazz, queryParam(key, default), key)
 
     /** Gets a list of query params for the specified key, or empty list. */
     fun queryParams(key: String): List<String> = queryParamMap()[key] ?: emptyList()
@@ -292,7 +294,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
     /** Gets specified attribute from the user session, or null. */
     @JvmOverloads
     fun <T> sessionAttribute(key: String, consume: Boolean = false): T? =
-            (req.session.getAttribute(key) as? T).also { if (consume) this.sessionAttribute(key, null) }
+        (req.session.getAttribute(key) as? T).also { if (consume) this.sessionAttribute(key, null) }
 
     /** Sets an attribute for the user session, and caches it on the request */
     fun cachedSessionAttribute(key: String, value: Any?) = ContextUtil.cacheAndSetSessionAttribute(key, value, req)
@@ -477,5 +479,8 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
 
     /** Gets a list of all the [splat] values. */
     fun splats(): List<String> = Collections.unmodifiableList(splatList)
+
+    /** Gets the handler type of the current handler */
+    fun handlerType() : HandlerType = handlerType
 }
 
diff --git a/pom.xml b/pom.xml
index 60e84aa8..fec153de 100644
--- a/pom.xml
+++ b/pom.xml
@@ -172,6 +172,11 @@
                 <artifactId>jackson-module-kotlin</artifactId>
                 <version>${jackson.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.fasterxml.jackson.datatype</groupId>
+                <artifactId>jackson-datatype-jsr310</artifactId>
+                <version>${jackson.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.velocity</groupId>
                 <artifactId>velocity-engine-core</artifactId>


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestContextHandlerType.java b/javalin/src/test/java/io/javalin/TestContextHandlerType.java
new file mode 100644
index 00000000..d871d27e
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestContextHandlerType.java
@@ -0,0 +1,30 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2021 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+package io.javalin;
+
+import io.javalin.http.HandlerType;
+import io.javalin.testing.TestUtil;
+import org.assertj.core.api.Assertions;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TestContextHandlerType {
+
+    @Test
+    public void testHandlerTypeCanBeAccessedInContext() {
+        TestUtil.test(Javalin.create(), (app, http) -> {
+            List<HandlerType> handlerTypes = new ArrayList<>();
+            app.before(ctx -> handlerTypes.add(ctx.handlerType()));
+            app.after(ctx -> handlerTypes.add(ctx.handlerType()));
+            app.get("/", ctx -> handlerTypes.add(ctx.handlerType()));
+
+            Assertions.assertThat(http.get("/").getStatus()).isEqualTo(200);
+            Assertions.assertThat(handlerTypes).containsExactly(HandlerType.BEFORE, HandlerType.GET, HandlerType.AFTER);
+        });
+    }
+}
diff --git a/javalin/src/test/java/io/javalin/TestJson.kt b/javalin/src/test/java/io/javalin/TestJson.kt
index 775c7391..72ad0eff 100644
--- a/javalin/src/test/java/io/javalin/TestJson.kt
+++ b/javalin/src/test/java/io/javalin/TestJson.kt
@@ -74,7 +74,8 @@ class TestJson {
     fun `invalid json is mapped to BadRequestResponse`() = TestUtil.test { app, http ->
         app.get("/hello") { it.body<NonSerializableObject>() }
         assertThat(http.get("/hello").status).isEqualTo(400)
-        assertThat(http.getBody("/hello")).isEqualTo("Couldn't deserialize body to NonSerializableObject")
+        val response = http.getBody("/hello").replace("\\s".toRegex(), "")
+        assertThat(response).isEqualTo("""{"NonSerializableObject":[{"message":"DESERIALIZATION_FAILED","value":""}]}""")
     }
 
     @Test
diff --git a/javalin/src/test/java/io/javalin/TestValidation.kt b/javalin/src/test/java/io/javalin/TestValidation.kt
index b3b93aa2..0d05d7b5 100644
--- a/javalin/src/test/java/io/javalin/TestValidation.kt
+++ b/javalin/src/test/java/io/javalin/TestValidation.kt
@@ -6,15 +6,20 @@
 
 package io.javalin
 
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import com.fasterxml.jackson.module.kotlin.readValue
+import io.javalin.core.util.JavalinLogger
 import io.javalin.core.validation.JavalinValidation
+import io.javalin.core.validation.ValidationException
 import io.javalin.core.validation.Validator
 import io.javalin.core.validation.collectErrors
 import io.javalin.http.BadRequestResponse
 import io.javalin.http.context.formParam
 import io.javalin.http.context.pathParam
 import io.javalin.http.context.queryParam
+import io.javalin.plugin.json.JavalinJackson
 import io.javalin.plugin.json.JavalinJson
 import io.javalin.testing.SerializeableObject
 import io.javalin.testing.TestUtil
@@ -29,36 +34,47 @@ class TestValidation {
     @Test
     fun `pathParam gives correct error message`() = TestUtil.test { app, http ->
         app.get("/:param") { ctx -> ctx.pathParam<Int>("param").get() }
-        assertThat(http.get("/abc").body).isEqualTo("Path parameter 'param' with value 'abc' is not a valid Integer")
+        assertThat(http.get("/abc").body).isEqualTo("""{"param":[{"message":"TYPE_CONVERSION_FAILED","value":"abc"}]}""")
     }
 
     @Test
     fun `queryParam gives correct error message`() = TestUtil.test { app, http ->
         app.get("/") { ctx -> ctx.queryParam<Int>("param").get() }
-        assertThat(http.get("/?param=abc").body).isEqualTo("Query parameter 'param' with value 'abc' is not a valid Integer")
+        assertThat(http.get("/?param=abc").body).isEqualTo("""{"param":[{"message":"TYPE_CONVERSION_FAILED","value":"abc"}]}""")
     }
 
     @Test
     fun `formParam gives correct error message`() = TestUtil.test { app, http ->
         app.post("/") { ctx -> ctx.formParam<Int>("param").get() }
-        assertThat(http.post("/").body("param=abc").asString().body).isEqualTo("Form parameter 'param' with value 'abc' is not a valid Integer")
+        assertThat(http.post("/").body("param=abc").asString().body).isEqualTo("""{"param":[{"message":"TYPE_CONVERSION_FAILED","value":"abc"}]}""")
+        JavalinLogger.enabled = true
+        val log = TestUtil.captureStdOut { http.post("/").body("param=abc").asString().body }
+        assertThat(log).contains("Parameter 'param' with value 'abc' is not a valid Integer")
+
     }
 
     @Test
-    fun `notNullOrEmpty works`() = TestUtil.test { app, http ->
+    fun `notNullOrEmpty works for Validator`() = TestUtil.test { app, http ->
         app.get("/") { ctx -> ctx.queryParam<String>("my-qp").get() }
-        assertThat(http.get("/").body).isEqualTo("Query parameter 'my-qp' with value 'null' cannot be null or empty")
+        assertThat(http.get("/").body).isEqualTo("""{"my-qp":[{"message":"NULLCHECK_FAILED","value":null}]}""")
         assertThat(http.get("/").status).isEqualTo(400)
     }
 
+    @Test
+    fun `notNullOrEmpty works for NullableValidator`() = TestUtil.test { app, http ->
+        app.get("/") { ctx -> ctx.queryParam<String>("my-qp").allowNullable().get() }
+        assertThat(http.get("/").body).isEqualTo("")
+        assertThat(http.get("/").status).isEqualTo(200)
+    }
+
     @Test
     fun `getAs clazz works`() = TestUtil.test { app, http ->
         app.get("/int") { ctx ->
             val myInt = ctx.queryParam<Int>("my-qp").get()
             ctx.result((myInt * 2).toString())
         }
-        assertThat(http.get("/int").body).isEqualTo("Query parameter 'my-qp' with value 'null' cannot be null or empty")
-        assertThat(http.get("/int?my-qp=abc").body).isEqualTo("Query parameter 'my-qp' with value 'abc' is not a valid Integer")
+        assertThat(http.get("/int").body).isEqualTo("""{"my-qp":[{"message":"NULLCHECK_FAILED","value":null}]}""")
+        assertThat(http.get("/int?my-qp=abc").body).isEqualTo("""{"my-qp":[{"message":"TYPE_CONVERSION_FAILED","value":"abc"}]}""")
         assertThat(http.get("/int?my-qp=123").body).isEqualTo("246")
     }
 
@@ -67,7 +83,7 @@ class TestValidation {
         app.get("/") { ctx ->
             ctx.queryParam<String>("my-qp").check({ it.length > 5 }, "Length must be more than five").get()
         }
-        assertThat(http.get("/?my-qp=1").body).isEqualTo("Query parameter 'my-qp' with value '1' invalid - Length must be more than five")
+        assertThat(http.get("/?my-qp=1").body).isEqualTo("""{"my-qp":[{"message":"Length must be more than five","value":"1"}]}""")
     }
 
     @Test
@@ -76,7 +92,7 @@ class TestValidation {
             val myInt = ctx.queryParam<Int>("my-qp", "788").get()
             ctx.result(myInt.toString())
         }
-        assertThat(http.get("/?my-qp=a").body).isEqualTo("Query parameter 'my-qp' with value 'a' is not a valid Integer")
+        assertThat(http.get("/?my-qp=a").body).isEqualTo("""{"my-qp":[{"message":"TYPE_CONVERSION_FAILED","value":"a"}]}""")
         assertThat(http.get("/?my-qp=1").body).isEqualTo("1")
         assertThat(http.get("/").body).isEqualTo("788")
     }
@@ -89,16 +105,32 @@ class TestValidation {
 
     @Test
     fun `custom converter works`() = TestUtil.test { app, http ->
+        JavalinJackson.configure(ObjectMapper().apply { registerModule(JavaTimeModule()) })
         JavalinValidation.register(Instant::class.java) { Instant.ofEpochMilli(it.toLong()) }
         app.get("/instant") { ctx ->
             val fromDate = ctx.queryParam<Instant>("from").get()
             val toDate = ctx.queryParam<Instant>("to")
-                    .check({ it.isAfter(fromDate) }, "'to' has to be after 'from'")
-                    .get()
+                .check({ it.isAfter(fromDate) }, "'to' has to be after 'from'")
+                .get()
             ctx.json(toDate.isAfter(fromDate))
         }
         assertThat(http.get("/instant?from=1262347200000&to=1262347300000").body).isEqualTo("true")
-        assertThat(http.get("/instant?from=1262347200000&to=1262347100000").body).isEqualTo("Query parameter 'to' with value '1262347100000' invalid - 'to' has to be after 'from'")
+        assertThat(http.get("/instant?from=1262347200000&to=1262347100000").body).isEqualTo("""{"to":[{"message":"'to' has to be after 'from'","value":1262347100.000000000}]}""")
+    }
+
+    @Test
+    fun `custom converter works for null when nullable`() = TestUtil.test { app, http ->
+        JavalinValidation.register(Instant::class.java) { Instant.ofEpochMilli(it.toLong()) }
+        app.get("/instant") { ctx ->
+            val fromDate = ctx.queryParam<Instant>("from").get()
+            val toDate = ctx.queryParam<Instant>("to")
+                .allowNullable()
+                .check({ it == null || it.isAfter(fromDate) }, "'to' has to null or after 'from'")
+                .get()
+            ctx.json(toDate == null || toDate.isAfter(fromDate))
+        }
+        assertThat(http.get("/instant?from=1262347200000").body).isEqualTo("true")
+        assertThat(http.get("/instant?from=1262347200000&to=1262347300000").body).isEqualTo("true")
     }
 
     @Test
@@ -110,36 +142,59 @@ class TestValidation {
 
     @Test
     fun `default converters work`() {
-        assertThat(Validator.create(Boolean::class.java, "true").get() is Boolean).isTrue()
-        assertThat(Validator.create(Double::class.java, "1.2").get() is Double).isTrue()
-        assertThat(Validator.create(Float::class.java, "1.2").get() is Float).isTrue()
-        assertThat(Validator.create(Int::class.java, "123").get() is Int).isTrue()
-        assertThat(Validator.create(Long::class.java, "123").get() is Long).isTrue()
+        assertThat(Validator.create(Boolean::class.java, "true", "?").get() is Boolean).isTrue()
+        assertThat(Validator.create(Double::class.java, "1.2", "?").get() is Double).isTrue()
+        assertThat(Validator.create(Float::class.java, "1.2", "?").get() is Float).isTrue()
+        assertThat(Validator.create(Int::class.java, "123", "?").get() is Int).isTrue()
+        assertThat(Validator.create(Long::class.java, "123", "?").get() is Long).isTrue()
     }
 
     @Test
     fun `validatedBody works`() = TestUtil.test { app, http ->
         app.post("/json") { ctx ->
             val obj = ctx.bodyValidator<SerializeableObject>()
-                    .check({ it.value1 == "Bananas" }, "value1 must be 'Bananas'")
-                    .get()
+                .check({ it.value1 == "Bananas" }, "value1 must be 'Bananas'")
+                .get()
             ctx.result(obj.value1)
         }
         val invalidJson = JavalinJson.toJson(SerializeableObject())
         val validJson = JavalinJson.toJson(SerializeableObject().apply {
             value1 = "Bananas"
         })
-        assertThat(http.post("/json").body("not-json").asString().body).isEqualTo("Couldn't deserialize body to SerializeableObject")
-        assertThat(http.post("/json").body(invalidJson).asString().body).isEqualTo("Request body as SerializeableObject invalid - value1 must be 'Bananas'")
+
+        """{"SerializeableObject":[{"message":"DESERIALIZATION_FAILED","value":"not-json"}]}""".let { expected ->
+            assertThat(http.post("/json").body("not-json").asString().body).isEqualTo(expected)
+        }
+        """{"SerializeableObject":[{"message":"value1 must be 'Bananas'","value":{"value1":"FirstValue","value2":"SecondValue"}}]}""".let { expected ->
+            assertThat(http.post("/json").body(invalidJson).asString().body).isEqualTo(expected)
+        }
+
         assertThat(http.post("/json").body(validJson).asString().body).isEqualTo("Bananas")
     }
 
     @Test
-    fun `custom treatment for BadRequestResponse exception response works`() = TestUtil.test { app, http ->
+    fun `multiple checks and named fields work when validating class`() = TestUtil.test { app, http ->
+        app.post("/json") { ctx ->
+            val obj = ctx.bodyValidator<SerializeableObject>()
+                .check({ false }, "UnnamedFieldCheck1")
+                .check({ false }, "UnnamedFieldCheck2")
+                .check("named_field", { false }, "NamedFieldCheck3")
+                .get()
+        }
+        val expected = """{"SerializeableObject":[
+            {"message":"UnnamedFieldCheck1","value":{"value1":"FirstValue","value2":"SecondValue"}},
+            {"message":"UnnamedFieldCheck2","value":{"value1":"First Value","value2":"SecondValue"}}],
+            "named_field":[{"message":"NamedFieldCheck3","value":{"value1":"FirstValue","value2":"SecondValue"}}]}""".replace("\\s".toRegex(), "")
+        val response = http.post("/json").body(JavalinJson.toJson(SerializeableObject())).asString().body
+        assertThat(response).isEqualTo(expected)
+    }
+
+    @Test
+    fun `custom treatment for ValidationException exception response works`() = TestUtil.test { app, http ->
         app.get("/") { ctx ->
             val myString = ctx.queryParam<String>("my-qp").get()
         }
-        app.exception(BadRequestResponse::class.java) { e, ctx ->
+        app.exception(ValidationException::class.java) { e, ctx ->
             ctx.status(HttpStatus.EXPECTATION_FAILED_417)
             ctx.result("Error Expected!")
         }
@@ -147,10 +202,16 @@ class TestValidation {
         assertThat(http.get("/").status).isEqualTo(HttpStatus.EXPECTATION_FAILED_417)
     }
 
+    @Test
+    fun `allowNullable throws if called after check`() = TestUtil.test { app, http ->
+        app.get("/") { it.queryParam<Int>("my-qp").check({ false }, "Irrelevant").allowNullable() }
+        assertThat(http.get("/").status).isEqualTo(500)
+    }
+
     @Test
     fun `optional query param value works`() = TestUtil.test { app, http ->
         app.get("/") { ctx ->
-            val myInt: Int? = ctx.queryParam<Int>("my-qp").getOrNull()
+            val myInt: Int? = ctx.queryParam<Int>("my-qp").allowNullable().get()
             assertThat(myInt).isEqualTo(null)
         }
         assertThat(http.get("/").status).isEqualTo(200)
@@ -160,8 +221,9 @@ class TestValidation {
     fun `optional query param value with check works`() = TestUtil.test { app, http ->
         app.get("/") { ctx ->
             val id: Int? = ctx.queryParam<Int>("id")
-                    .check({ it > 10 }, "id was not greater than 10")
-                    .getOrNull()
+                .allowNullable()
+                .check({ if (it != null) it > 10 else true }, "id was not greater than 10")
+                .get()
 
             if (id != null) {
                 ctx.result(id.toString())
@@ -170,20 +232,20 @@ class TestValidation {
 
         // Test valid param
         http.get("/?id=20").apply {
-            assertThat(status).isEqualTo(200)
             assertThat(body).isEqualTo("20")
+            assertThat(status).isEqualTo(200)
         }
 
         // Test invalid param
         http.get("/?id=4").apply {
+            assertThat(body).isEqualTo("""{"id":[{"message":"id was not greater than 10","value":4}]}""")
             assertThat(status).isEqualTo(400)
-            assertThat(body).isEqualTo("Query parameter 'id' with value '4' invalid - id was not greater than 10")
         }
 
         // test valid missing param
         http.get("/").apply {
-            assertThat(status).isEqualTo(200)
             assertThat(body).isEqualTo("")
+            assertThat(status).isEqualTo(200)
         }
     }
 
@@ -192,12 +254,12 @@ class TestValidation {
 
         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.")
+                .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.")
+                .check({ !it.contains("-") }, "cannot contain hyphens.")
+                .check({ it.length < 10 }, "cannot be longer than 10 characters.")
 
             ctx.json(listOf(numberValidator, stringValidator).collectErrors())
         }
@@ -222,9 +284,9 @@ class TestValidation {
     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()
+                .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)
         }
@@ -238,73 +300,13 @@ class TestValidation {
         // 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")
+            assertThat(body).isEqualTo("""{"first_name":[{"message":"Too long","value":{"first_name":"Mathilde"}}]}""")
         }
 
         // Test invalid empty param
         http.post("/").body("{}").asString().apply {
             assertThat(status).isEqualTo(200)
-            assertThat(body).isEqualTo("true")
+            assertThat(body).isEqualTo("""{"first_name":[{"message":"This field is mandatory","value":{}}]}""")
         }
     }
 }
diff --git a/javalin/src/test/java/io/javalin/TestValidation_Java.java b/javalin/src/test/java/io/javalin/TestValidation_Java.java
index bb3b5b81..05f2aa40 100644
--- a/javalin/src/test/java/io/javalin/TestValidation_Java.java
+++ b/javalin/src/test/java/io/javalin/TestValidation_Java.java
@@ -18,20 +18,20 @@ public class TestValidation_Java {
     public void validator_works_from_java_too() {
         JavalinValidation.register(Instant.class, v -> Instant.ofEpochMilli(Long.parseLong(v)));
         String intString = "123";
-        int myInt = Validator.create(Integer.class, intString).get();
+        int myInt = Validator.create(Integer.class, intString, "?").get();
         assertThat(myInt).isEqualTo(123);
 
-        Instant fromDate = Validator.create(Instant.class, "1262347200000").get();
-        Instant toDate = Validator.create(Instant.class, "1262347300000")
+        Instant fromDate = Validator.create(Instant.class, "1262347200000", "?").get();
+        Instant toDate = Validator.create(Instant.class, "1262347300000", "?")
             .check(it -> it.isAfter(fromDate), "'to' has to be after 'from'")
             .get();
 
         assertThat(toDate.getEpochSecond()).isEqualTo(1262347300L);
-        assertThat(Validator.create(Boolean.class, "true").get()).isInstanceOf(Boolean.class);
-        assertThat(Validator.create(Double.class, "1.2").get()).isInstanceOf(Double.class);
-        assertThat(Validator.create(Float.class, "1.2").get()).isInstanceOf(Float.class);
-        assertThat(Validator.create(Integer.class, "123").get()).isInstanceOf(Integer.class);
-        assertThat(Validator.create(Long.class, "123").get()).isInstanceOf(Long.class);
+        assertThat(Validator.create(Boolean.class, "true", "?").get()).isInstanceOf(Boolean.class);
+        assertThat(Validator.create(Double.class, "1.2", "?").get()).isInstanceOf(Double.class);
+        assertThat(Validator.create(Float.class, "1.2", "?").get()).isInstanceOf(Float.class);
+        assertThat(Validator.create(Integer.class, "123", "?").get()).isInstanceOf(Integer.class);
+        assertThat(Validator.create(Long.class, "123", "?").get()).isInstanceOf(Long.class);
     }
 
 }
diff --git a/javalin/src/test/java/io/javalin/testing/SerializeableObject.java b/javalin/src/test/java/io/javalin/testing/SerializeableObject.java
index b1b52352..3dedc108 100644
--- a/javalin/src/test/java/io/javalin/testing/SerializeableObject.java
+++ b/javalin/src/test/java/io/javalin/testing/SerializeableObject.java
@@ -3,6 +3,6 @@ package io.javalin.testing;
 import java.io.Serializable;
 
 public class SerializeableObject implements Serializable {
-    public String value1 = "First value";
-    public String value2 = "Second value";
+    public String value1 = "FirstValue";
+    public String value2 = "SecondValue";
 }