Repository: tipsy/javalin
Pull request #990 (2020-05-31 20:23:44)

Provide Easier Progromatic Access to all Errors on Validators
The goal of this PR is to make it easier to grab all errors on all validators in order to programmatically display errors (on user filled out forms for example). The body validator class already seemed to do most of the work necessary for this, so I moved it's code into the validator.

Similarly, the message prefix auto generated for the validators on accessing parameters on context were not especially helpful to be able to get the errors back easily, so I have added a Key that more closely matches the body validator.

Because new mappers can be provided for these validator casts we still run into problems when a parameter is a complex object that may have multiple parameters on it itself. I haven't addressed this. Adding an overloaded check method that more closely matches the body validator (so that you can specify a key) could help with that.

MrThreepwood: I'm not sure where the documentation for validators live so that I can update them, but if you like the way this works I can update them.
tipsy: They live in this document: https://github.com/javalin/javalin.github.io/blob/master/pages/docs/docs.md#validation

I have not looked at the PR yet though, will do tomorrow!
tipsy: Looks good @MrThreepwood, please create a PR for the docs too :)

Patch:
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 1b8d3495..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 719e963e..c9ecb5db 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -171,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
@@ -198,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
@@ -248,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
@@ -294,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


Test patch:
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 ->