Repository: tipsy/javalin
Pull request #976 (2020-05-21 10:47:22)

[javalin] Add methods to validate without exception
- retro-compatible with old version (non API breaking)
- Add method "isValid" to know if the data is valid
- Add method "hasError" to know if the data is not valid
- Add method "errors" to retrieve all error detected group by field name
- Add method check(String, Predicate, String) to add fieldName

**Code sample**
```kotlin
@JsonIgnoreProperties(ignoreUnknown = true)
data class Pojo(val firstName: String?, val email: String?)

fun test(ctx: Context): Context {
    val p = ctx.bodyValidator<Pojo>()
        .check("firstName", { it.firstName == null }, "This field is mandatory")
        .check("firstName", { it.firstName?.length!! < 3}, "Must have at least 3 characters")
        .check("firstName", { it.firstName?.length!! > 35}, "Cant be longer than 35 characters")
        .check("email", { it.email == null}, "This field is mandatory")
        .check("email", { !it.email?.contains("@")!! }, "Not an email")
        .check("email", { it.email?.length!! < 3}, "Must have at least 3 characters")
        .check("email", { it.email?.length!! > 125}, "Cant be longer than 125 characters")
        .errors()

    return ctx.Ok(p)
}
```

**query**
```http
POST http://127.0.0.1:7000/test
{
    "email": "sf"
}
```

**response**
```json
{
  "firstName": [
    "This field is mandatory"
  ],
  "email": [
    "Not an email",
    "Must have at least 3 characters"
  ]
}
```

tipsy: Thanks @thibaultmeyer, this looks nice! Could you also include a test or two?
thibaultmeyer: Hello,

No problem, I'm looking to add them. 
thibaultmeyer: Unit tests added
tipsy: Nice work @thibaultmeyer ! I'm reading through the tests, and I see that the new `fieldName` is being used: 

```kotlin
ctx.queryParam<Int>("id").check("id", { it > 10 }, "id was not greater than 10")
```

I'm having some trouble understanding the purpose of this, as you already give the field name in the `ctx.queryParam<Int>("id")` call. I guess it's intended for data-class/POJO validation? Could you update the test to use a class instead? 

Should we split this out so it's only possible to give the fieldname when using a `bodyValidator()` ?
thibaultmeyer: hello,

yes it could be nice to split this to only have this new behaviour with the `bodyValidator()`. if you're OK with it, I'm modifying my PR to implement this new behaviour.
tipsy: Please go ahead! 
tipsy: @thibaultmeyer Nice! One comment: This new `BodyValidator` appears to be standalone, To maintain backwards compatibility I think you have to make it a subclass of `Validator`.
thibaultmeyer: Ok i commit this
tipsy: Perfect @thibaultmeyer, thank you!

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
new file mode 100644
index 00000000..c0565c60
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/validation/BodyValidator.kt
@@ -0,0 +1,57 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.core.validation
+
+open class BodyValidator<T>(value: T?, messagePrefix: String = "Value") : Validator<T>(value, messagePrefix) {
+
+    private var errors: MutableMap<String, MutableList<String>>? = null
+
+    @JvmOverloads
+    open fun check(fieldName: String, predicate: (T) -> Boolean, errorMessage: String = "Failed check"): BodyValidator<T> {
+        rules.add(Rule(fieldName, predicate, errorMessage))
+        return this
+    }
+
+    fun isValid(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isEmpty()
+    }
+
+    fun hasError(): Boolean {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!.isNotEmpty()
+    }
+
+    fun errors(): Map<String, List<String>> {
+        if (errors == null) {
+            validate()
+        }
+        return errors!!
+    }
+
+    private fun validate(): BodyValidator<T> {
+        errors = mutableMapOf()
+        rules.forEach { rule ->
+            if (value != null) {
+                try {
+                    if (!rule.test.invoke(value)) {
+                        if (!errors!!.containsKey(rule.fieldName)) {
+                            errors!![rule.fieldName] = mutableListOf()
+                        }
+                        errors!![rule.fieldName]?.add(rule.invalidMessage)
+                    }
+                } catch (ignore : NullPointerException) {
+                }
+            }
+        }
+        return this
+    }
+}
diff --git a/javalin/src/main/java/io/javalin/core/validation/Validator.kt b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
index 4327581d..a464ba30 100644
--- a/javalin/src/main/java/io/javalin/core/validation/Validator.kt
+++ b/javalin/src/main/java/io/javalin/core/validation/Validator.kt
@@ -10,13 +10,13 @@ import io.javalin.http.BadRequestResponse
 
 open class Validator<T>(val value: T?, val messagePrefix: String = "Value") {
 
-    data class Rule<T>(val test: (T) -> Boolean, val invalidMessage: String)
+    data class Rule<T>(val fieldName: String, val test: (T) -> Boolean, val invalidMessage: String)
 
-    private val rules = mutableSetOf<Rule<T>>()
+    protected val rules = mutableSetOf<Rule<T>>()
 
     @JvmOverloads
     open fun check(predicate: (T) -> Boolean, errorMessage: String = "Failed check"): Validator<T> {
-        rules.add(Rule(predicate, "$messagePrefix invalid - $errorMessage"))
+        rules.add(Rule("_", predicate, "$messagePrefix invalid - $errorMessage"))
         return this
     }
 
diff --git a/javalin/src/main/java/io/javalin/http/Context.kt b/javalin/src/main/java/io/javalin/http/Context.kt
index 28c191b7..3c40c58f 100644
--- a/javalin/src/main/java/io/javalin/http/Context.kt
+++ b/javalin/src/main/java/io/javalin/http/Context.kt
@@ -9,6 +9,7 @@ package io.javalin.http
 import io.javalin.Javalin
 import io.javalin.core.security.BasicAuthCredentials
 import io.javalin.core.util.Header
+import io.javalin.core.validation.BodyValidator
 import io.javalin.core.validation.Validator
 import io.javalin.http.util.ContextUtil
 import io.javalin.http.util.CookieStore
@@ -138,7 +139,7 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
      * Throws [BadRequestResponse] if validation fails.
      */
     fun <T> bodyValidator(clazz: Class<T>) = try {
-        Validator(JavalinJson.fromJson(body(), clazz), "Request body as ${clazz.simpleName}")
+        BodyValidator(JavalinJson.fromJson(body(), clazz), "Request body as ${clazz.simpleName}")
     } catch (e: Exception) {
         Javalin.log?.info("Couldn't deserialize body to ${clazz.simpleName}", e)
         throw BadRequestResponse("Couldn't deserialize body to ${clazz.simpleName}")


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestValidation.kt b/javalin/src/test/java/io/javalin/TestValidation.kt
index 9a239d50..c8378b3c 100644
--- a/javalin/src/test/java/io/javalin/TestValidation.kt
+++ b/javalin/src/test/java/io/javalin/TestValidation.kt
@@ -180,4 +180,95 @@ class TestValidation {
             assertThat(body).isEqualTo("")
         }
     }
+
+
+    @Test
+    fun `body validator with check and retrieve errors`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val errors = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .errors()
+
+            ctx.json(errors)
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{}")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{\"first_name\":[\"Too long\"]}")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("{\"first_name\":[\"This field is mandatory\"]}")
+        }
+    }
+
+    @Test
+    fun `body validator with check and isValid`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val isValid = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .isValid()
+
+            ctx.result(isValid.toString())
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+    }
+
+    @Test
+    fun `body validator with check and hasError`() = TestUtil.test { app, http ->
+        app.post("/") { ctx ->
+            val hasError = ctx.bodyValidator<Map<String, String>>()
+                    .check("first_name", { it.containsKey("first_name") }, "This field is mandatory")
+                    .check("first_name", { (it["first_name"]?.length ?: 0) < 6 }, "Too long")
+                    .hasError()
+
+            ctx.result(hasError.toString())
+        }
+
+        // Test valid param
+        http.post("/").body("{\"first_name\":\"John\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("false")
+        }
+
+        // Test invalid param
+        http.post("/").body("{\"first_name\":\"Mathilde\"}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+
+        // Test invalid empty param
+        http.post("/").body("{}").asString().apply {
+            assertThat(status).isEqualTo(200)
+            assertThat(body).isEqualTo("true")
+        }
+    }
 }