Data Validation in Clojure

When writing my first Clojure application, I came across a very common problem, validating user input. Even though there are a few libraries out there that do this already (valip, validateur, corroborate), I needed something that could handle a broader range of use cases. So, I built a library called Metis (pronounced mee'-tis).

Metis draws heavily from the features and terminology provided by Active Record Validations. Metis, however, is not coupled to any sort of persistence or presentation. It simply validates data. Let's take a look at what it can do.

A simple example

Metis is designed to validate maps of data. To show some simple functionality, let's validate some data that one might get from a HTML form when registering for a website. The example form accepts a user's email, password, and password confirmation.

1 (defvalidator reg-validator
2   [:email-address :email]
3     [:password [:length {:greater-than-or-equal-to 6} :confirmation {:confirm :password-confirm}]])

Using the defvalidator dsl, we can assign one or more validators to a key in the map. In this example, the :email validator is assigned to the :email-address key and the :length and :confirmation validators are assigned to the :password key. defvalidator produces a function, reg-validator, which can now be used to validate maps. Let's run it and see what the output looks like.

1 user=> (reg-validator {})
2 {:email-address ["must be a valid email"], :password ["must have length greater than or equal to 6"]}

If we give it invalid data, we are returned a map of errors. As you can see, each invalid key will have a collection of errors. In this case, there is only one error for both :email-address and :password. Let's try again with some more invalid data.

1 user=> (reg-validator {:email-address "bad-email" :password "here" :password-confirm "not here"})
2 {:email-address ["must be a valid email"], :password ["must have length greater than or equal to 6" "doesn't match confirmation"]}

Since the :password key and :password-confirm key no longer match (before they were both nil), we have tripped the :confirmation validator and produced another error.

1 user=> (reg-validator {:email-address "email@email.com" :password "123456" :password-confirm "123456"})
2 {}

If we give it valid data, the errors map will be empty.

Now that we have the basics down, let's move on to some cooler features.

Defining custom validators

Even though Metis has many built-in validators, you will probably need to define your own at some point. Custom validators are defined in the same way that the built-in validators are defined, as functions.

A validator is simply a function that takes in a map and returns an error or nil. As an example, let's look at the built-in presence validator.

1 (defn presence [map key _]
2   (when-not (present? (get map key))
3       "must be present")))

As you can see, this is a very simple validator. It checks if the value is present and returns an error if it is not. This is the structure of all the validators in Metis. Every validator takes in the map, the key to be validated, and a map of options. The presence validator, however, does not take in any options, so the third option is ignored.

Lets define a custom validator that checks if every charater is an 'a'.

 1 (defn all-a [map key options]
 2   (when-not (every? #(= "a" (str %)) (get map key))
 3     "not all a's"))
 4 
 5 user=> (all-a {:thing "aaa"} :thing {})
 6 nil
 7 
 8 user=> (all-a {:thing "abc"} :thing {})
 9 "not all a's"
10 
11 (defvalidator first-name-with-only-a
12   [:first-name :all-a])
13 
14 user=> (first-name-with-only-a {:first-name "aaa"})
15 {}
16 
17 user=> (first-name-with-only-a {:first-name "abc"})
18 {:first-name ["not all a's"]}

Composing validators

As I said before, validators are functions that accept a map, key and options. The function produced by the defvalidator macro also adheres to this interface, meaning that it can be reused in the same manner as custom validators. Let's take a look at how we can use this simple feature to validate nested maps.

 1 (defvalidator :country
 2   [[:code :name] :presence])
 3 
 4 (defvalidator :address
 5   [[:line-1 :line-2 :zipcode] :presence]
 6   [:nation :country])
 7 
 8 (defvalidator :person
 9   [:address :address]
10   [:first-name :presence])
11 
12 user=> (person {})
13 {:address {:zipcode ["must be present"], :line-2 ["must be present"], :line-1 ["must be present"], :nation {:name ["must be present"], :code ["must be present"]}}, :first-name ["must be present"]}
14 
15 user=> (person {:first-name "Myles" :address {:zipcode "60618" :line-1 "515 W Jackson Blvd." :line-2 "Floor 5" :nation {:code 1 :name "United States"}}})
16 {}

Conditional validation

Often times, the set of validations to run is not cut and dry. Consider a payment form in which the user can opt to input their credit card number or PayPal information. If they select credit card, we have to validate that the credit card number is formatted correctly. If they select PayPal, we have to validate the email address.

This can be accomplished using the :if and :if-not options. The :if option is used to specify when the validation should happen. The :if-not option is used to specify when the validation should not happen.

 1 (defn pay-with-credit-card? [attrs]
 2   (= (:payment-type attrs) "credit-card"))
 3 
 4 (defvalidator :payment
 5   [:card-number :length {:equal-to 16 :if pay-with-credit-card?}]
 6   [:pay-pal-email :email {:if-not pay-with-credit-card?}])
 7 
 8 user=> (payment {})
 9 {:pay-pal-email ["must be a valid email"]}
10 
11 user=> (payment {:payment-type "credit-card"})
12 {:card-number ["must have length equal to 16"]}
13 
14 user=> (payment {:payment-type "pay-pal" :pay-pal-email "test@test.com"})
15 {}
16 
17 user=> (payment {:payment-type "credit-card" :card-number "0123456789012345"})
18 {}

Contextual validation

Often times, a set of data, say a user's profile, will have multiple forms in an application; one form for creating the profile and another for updating. It can be useful to share the same validations across both of these forms, especially if there are many shared validations between them. However, there is always going to be some pesky field that is required for one form and not the other. To solve this, we can use contexts. The :only option is used to specify the contexts in which the validation should be run. The :except option is used to specify the contexts from which the validation should be excluded.

 1 (defvalidator user-validator
 2   [:first-name :presence {:only :creation}]
 3   [:last-name :formatted {:pattern #"some pattern" :only [:updating :saving]}]
 4   [:address :presence {:except [:updating]}])
 5 
 6 user=> (user-validator {}) ; when no context is specified, all validations are run
 7 {:first-name ["must be present"], :last-name ["has the incorrect format"], :address ["must be present"]}
 8 
 9 user=> (user-validator {} :creation)
10 {:first-name ["must be present"], :address ["must be present"]}
11 
12 user=> (user-validator {} :updating)
13 {:last-name ["has the incorrect format"]}
14 
15 user=> (user-validator {} :saving)
16 {:last-name ["has the incorrect format"], :address ["must be present"]}
17 
18 user=> (user-validator {} :somewhere-else)
19 {:address ["must be present"]}

Note: the context names here are arbitrary; they can be anything.

Conclusion

There you have it! Metis is a library designed to take maps and validate their structure and content. To see more examples and view the full API, check out the README on the project home page. To see a list of all the built-in validators, check out the wiki. Enjoy!

Myles Megyesi, Software Craftsman

Myles Megyesi loves design patterns, functional programming, and popcorn.