Validation

When handling a request, and especially when handling user input, you want to make sure that the incoming data is valid and as per your expectation. If it is not, you want to send appropriate error messages back to the user. You want to have the error messages available in your templates to format and display them nicely. All these need a lot of wiring. Fortunately, Alpas supports all these out-of-the-box!

Validation Rules

The simplest and quickest way to validate an HttpCall is by applying some validation rules on it for each of the input params you want to validate.


fun create(call: HttpCall) {
    call.applyRules("username") {
        required()
        min(8)
        max(32)
    }

    call.applyRules("email") {
        required()
        email()
    }

    // don't forget to call validate() once all the rules are applied
    call.validate()
    
    // program reaches here only if all of your validations pass
    call.reply("Everything is good!")
}

Instead of applying multiple rules, you can also pass a map of rules where the key is the name of the field, and the value is a list of rule objects for the field.


fun create(call: HttpCall) {
    val rules = mapOf("username" to listOf(Required(), Max(32), Min(8)))
    call.applyRules(rules).validate()

    call.reply("Everything is good!")
}

Failing Fast

Sometimes you may wish to not continue with the validation after the first validation fails - i.e., bail on the first error. To achieve this, set failfast to true when applying your rules.


fun create(call: HttpCall) {
    call.applyRules("username", failfast = true) {
        required()
        min(8) // this validation won't run if required() validation fails
        max(32) // this validation won't run if either required() or min(8) validations fail
    }.validate()

    call.reply("Everything looks good!")
}

If you want to apply and validate rules attribute-by-attribute, continuing to the next attribute only after each attribute passes a rule set, you can just call validate() after each set of rules.


fun create(call: HttpCall) {
    call.applyRules("username") {
        required()
        min(8)
        max(32)
    }.validate() // validates your call right away and only proceeds if the validation passes

    // username should be valid at this point
    call.applyRules("email") {
        required()
        email()
    }.validate()

    call.reply("Everything looks good!")
}

Validation Guard

Although the in-place application of validation rules within a controller may be good enough for simple validation rules, it is desirable to better organize validation rules to make them more manageable. You may also want to do more within the rules, such as caching an expensive database lookup to be retrieved later, or reuse the validation rules from other places.

You definitely don't want all this complex logic scattered all over the places in your controller. To facilitate this, Alpas allows you to validate a call using a dedicated ValidationGuard class.

To create a ValidationGuard class, you can use the make:guard Alpas command. This creates a new validation guard class under guards folder.


$ alpas make:guard CreatePageGuard

When you open the newly generated guard class, you'll see that a rules() method is already overriden for you. All you need to do is to return a map of rules where the key is the name of the field, and the value is a list of rule objects for the field.


import dev.alpas.validation.*

class CreatePageGuard : ValidationGuard() {
    override fun rules(): Map<String, Iterable<Rule>> {
        return mapOf(
            "username" to listOf(Required(), Max(32), Min(8)),
            "email" to listOf(Required(), Email())
        )
    }

    // feel free to add any additional methods and properties your need
    internal fun logSuccess() {
        call.logger.debug { "Validation was a success!" }
    }
}

All that is needed now is to validate a call using this guard.


fun create(call: HttpCall) {
    //...

    call.validateUsing(CreatePageGuard::class).logSuccess()
    call.reply("Everything is good!")
}

Bundled Validation Rules

Alpas comes bundled with some validation rules out of the box.

  • Max(var length: Int)

The value of the attribute under validation must be less than or equal to the given length.

  • Min(length: Int)

The value of the attribute must be greater than or equal to the given length.

  • Required

The attribute must be present and the value must not be null or empty.

  • NotNull

The attribute must be present and the value must not be null. However, it can be empty.

  • MustBeInteger

The value must be of type Integer or Long.

  • MustBeString

The value must be of type String.

  • MatchesRegularExpression(expression: String)

The value must match the given regular expression format.

  • Email

The value must be a properly formatted RFC compliant e-mail address.

Alpas uses email-rfc2822-validator for validating an e-mail address using RFC 2822 compliant criteria.

  • Confirm

Validates that there exists a matching confirm field and that the value matches the value of this attribute.

For example, if the attribute under validation is password, there must be an existing attribute, either password_confirm or confirm_password, and the value of it must match the value of password. The value of the confirm field can neither be null nor blank.

  • Unique(table: String, column: String? = null, ignore: String? = null)

The value must not exist in the given database table for the given column. If the column is null, the name of the attribute will be used for the column.


// ...

// Validates that `ssn` is unique in `users` table.
call.applyRules("ssn") {
    unique("users", "ssn")
}.validate()

// If the attribute under validation is same as that of the column 
// name of the table, you don't have to mention the column name.
call.applyRules("ssn") {
    unique("users")
}.validate()

// ...

Ignoring uniqueness validation for a value

Sometimes you may require to ignore a given value under a column during the unique validation check. Think of creating a user profile with an email column vs updating that same profile later. When creating the profile, you want to make sure no other user exists in the database with the same email address. Your validation rule for this would look something like:


// ...

call.applyRules("email") {
    required()
    unique("users")
}.validate()

// ...

On the other hand, when updating the profile, you want to let the user update their profile withor without updating the email address field. If the user only wants to update, say, the name field and not the email field, you do not want to apply email uniqueness validation rule for this user. Otherwise, validation would fail every time and the user won't be able to update their name without also updating their email address.

In this case, you need to ignore the uniqueness check. but only against the user's id. You can achieve this by setting the ignore parameter in the format <column_name>:<value>. Your rule for this will look something like:


// ...

call.applyRules("email") {
    required()
    unique("users", ignore="id:4")
}.validate()

// ...

In the above example, the uniqueness check for the user's email is ignored for the row where id is 4. But, if them email matches for any other rows, then the validation fails.

Custom Rules

If none of the rules that come bundled with Alpas satisfy your needs, you can easily create your own. To start quickly, use alpas make:rule command to create a new rule under rules folder.


$ alpas make:rule above

Let's say that our new Above rule makes sure that the attribute under validation is a number and that it is above a given threshold.


import dev.alpas.validation.*

class Above(private val threshold: Number, private val message: ErrorMessage = null) : Rule() {
    override fun check(attribute: String, value: Any?): Boolean {
        val valueStr = value?.toString()
        val isValid = valueStr?.toDoubleOrNull()?.let { it > threshold.toDouble() } ?: false

        if (!isValid) {
            error = message?.let { it(attribute, value) } ?: "Above validation failed."
        }

        return isValid
    }
}

// Always provide a ValidationGuard extension method with the same name to make it 
// easy to quickly apply this rule to a call without resorting to a guard class.
fun ValidationGuard.above(threshold: Number, message: ErrorMessage = null): Rule {
    return rule(Above(threshold, message))
}

Now let's apply our new Above validation rule to a call.


fun create(call: HttpCall) {
    call.applyRules("age") {
        required()
        above(18)
    }.validate()

    call.reply("Welcome adult visitor!")
}

/tip/ If you need more power and flexibility in your custom validation rule, override check(attribute: String, call: HttpCall): Boolean method instead.

Customizing Error Messages

All the rules that come bundled with Alpas have some sensible error messages set for each validation rule. If you want to customize a message, you can easily do so by passing a callback for the message parameter when applying a rule. The message is of type ErrorMessage, which is a typealias for ((String, Any?) -> String)?. It is a function that takes two parameters — the name of the attribute and the actual value — and returns an error message.


// ...
call.applyRules("username") {
    max(32) { attr, value ->
        val length = value?.toString()?.length
        "$attr is $length characters long. It should be no more than 32."
    }.validate()
}
// ...

JSON Validation

Checking in JSON Body

Alpas doesn't merge a JSON body with the request parameters but instead makes it available as a jsonBody property. During validation, by default, Alpas looks in the query + form + route parameters. If you want to validate a field that is within the JSON body, you can do so by wrapping your rules in inJsonBody() instead.


fun create(call: HttpCall) {
    // the age value is retrieved from the params; possibly passed as a query param
    call.applyRules("age") {
        required()
        above(18)
    }


    call.applyRules("name") {
        // the name value is retrieved from the JSON body
        inJsonBody {
            notNull()
            mustBeString()
        }
    }

    call.validate()

    call.reply("Welcome adult visitor with a name!")
}

JSON Field Rules

Alpas also provides a JSONField rule class that takes a list of rules for which you want to check the values in the JSON body. This is more convenient when applying a map of rules to a call or when returning a map of rules from a ValidationGuard class.


import dev.alpas.validation.*

class CreatePageGuard : ValidationGuard() {
    override fun rules(): Map<String, Iterable<Rule>> {

        return mapOf(
            // Validate both Max(32) and Min(8) rules by 
            // looking up the username value in the JSON body
            "username" to listOf(JsonField(Max(32), Min(8))),

            // Validate both Required() and Email() rules by 
            // looking up the email value in the params list
            "email" to listOf(Required(), Email())
        )
    }
}

Errors Management

Intercepting Validation Errors

By default, when validation fails, Alpas throws a ValidationException. If you want to customize this behavior, ValidationGuard has an open method with signature: handleError(errorBag: ErrorBag): Boolean that you can override and handle error the way you want it.

From this method, returning a true or Nothing means you handled the error and thus Alpas will continue. Returning false means you want Alpas to do what it usually does after validation fails — throw a validation exception.


//...

override fun handleError(errorBag: ErrorBag): Boolean {
    // Let's say we do not want to redirect the user back to previous location 
    // with errors but just want to tell them that we couldn't find the page.

    // `abort()` throws an exception and so returns `nothing`.
    // That's why we don't need to return `true` here.
    call.abort(404)
}

//...

Rendering Validation Errors

Alpas internally catches the ValidationException thrown and renders it appropriately based on whether the caller wants a JSON response or not.

If it wants a JSON response, the validation errors are returned as a JSON object with 422 status code. If the JSON response is not wanted, it flashes both the validation errors and the current inputs; except, for critical fields such as password, confirm_password, password_confirm etc.—and redirects the user back to the previous page.

You can get the old inputs from the previous request by calling old() method and the validation errors by calling errors() method on HttpCall's session object. These are also available from your Pebble templates.

Displaying Errors and Old Values

For an improved user experience, you would want to display all the validation errors from the user's previous request. You'd also want to pre-fill all the fields with previous values so not to punish users by making them fill the form again. Alpas comes bundled with some great template helpers to make this a cinch and hassle-free for you!

  • old(key, default)

If exists, returns an old input value for the given key. If not, returns the given default value. If multiple values exist for the given key, it returns a list of all values.


<form action="/users" method="post">
    {% csrf %}
    <input type="text" name="username" value="{{ old('username') }}" />
    <input type="email" name="email" value="{{ old('email', 'johndoe@example.com') }}" />
    <button type="submit">Create</button>
</form>

  • errors(key, default)

If exists, returns an error or a list of errors for the given key. If not, returns the given default value. key is optional when calling this function. If you don't pass it, it just returns the errors map.

  • firstError(key, default)

If exists, returns the first error for the given key. If not, return the given default value. Unlike the errors() method, the key here is required.

  • hasError(key)

Returns true if an error exists for the given key. Otherwise, returns false.

  • whenError(key, value, default)

If an error exists for the given key, it returns the given value. If not, it returns the default value.