A Tutorial Of Prismatic's Schema Library For Clojure
I haven't had many good ideas for blog posts, but I've gotten this bright idea to do tutorials/overviews of the libraries I use in Clojure.
The first one is Prismatic's Schema.
Schema is an awesome library once you understand it.
Let's go over some of the more important concepts via the schema readme.
The first thing that jumps out when you first see Schema is that it allows you to validate functions and data. You can think of it as optional type checking in a way.
(def Data
"A schema for a nested data type"
{:a {:b s/Str
:c s/Int}
:d [{:e s/Keyword
:f [s/Num]}]})
The above Data schema validates a map. It says the key :a should be a map with keys :b as a string and :c as an integer. Additionally, key :d should have a seq of maps with :e as a keyword and :f as a seq of numbers.
(s/validate
Data
{:a {:b "abc"
:c 123}
:d [{:e :bc
:f [12.2 13 100]}
{:e :bc
:f [-1]}]})
;; Success!
(s/validate
Data
{:a {:b 123
:c "ABC"}})
;; Exception -- Value does not match schema:
;; {:a {:b (not (instance? java.lang.String 123)),
;; :c (not (integer? "ABC"))},
;; :d missing-required-key}
Once you've defined a schema, you can validate it, by calling the validate function in the schema.core namespace.
If it's a success, you'll get your data structure back. If not, you get an exception.
The idea is that you can generate schemas to make sure the 'shape' of your data is the way you wanted. For instance, if you wanted to make sure your web server's requests parameters had only these keys of these types, you can do that.
And there are lots of ways to create schemas. You can create schemas that validate on a predicate, meaning you can just pass in a function that returns a boolean and have it validate:
(require '[schema.core :as s])
(defn divisible-by-two?
[x]
(= (mod x 2) 0))
(def DivisibleByTwo (s/pred divisible-by-two? 'divisible-by-two?))
(s/validate DivisibleByTwo 2)
;; => 2
The second most important concept to nail down is coercers.
Coercion took me a long time tho grasp, but once you have it, you'll be amazed at how modular and reusable the code becomes.
Prismatic talks a lot of custom walkers for coercions, but I've found in practice that's not really necessary most of the time. It's there for more advanced users. For almost every use case, the default walker is fine. I would ignore that part for now, otherwise it will confused you like me.
Essentially what coercion means is you're able to take a type and transform it into the type you want.
Let's be more concrete. I won't use the sample from the readme because I feel like doesn't explain it well enough to beginners.
Say we had these schemas:
(require '[schema.core :as s]
'[schema.coerce :as coerce])
(def RequestCommentId s/Str)
(s/defschema RequestParams
{;; Path param /comments/:comment-id -> /comments/4
:comment-id RequestCommentId
;; Form params
:comment-body s/Str
})
(s/defschema DatabaseParams
{:id s/Int
:body s/Str})
The RequestCommentId is our representation of a string. You'll see why we need this wrapper in a moment, but it's also important that we have some semantics to our representation, other than strings, integers, etc.
RequestParams are the parameters that come in from a web request. For example, in this case, let's say we have a simple blog API. Somebody sends a PUT request to update the blog post with the id they specify to "/comments/:comment-id". If you're using compojure, that :comment-id is going to be a string and you would need to coerce to an integer.
Then we have our DatabaseParams. In our little scenario, we're getting an updated blog post from our API and coercing it to something we can insert into our database. In this case, that means renaming the keys from :comment-id/:comment-body to just :id and :body. Additionally, it means we want an integer for our :id.
Here's the coercion part:
(defn RequestParams->DatabaseParams
[{:keys [comment-id comment-body] :as request-params}]
{:id comment-id
:body comment-body})
(def RequestParams->DatabaseParams-coercer
(coerce/coercer DatabaseParams {DatabaseParams RequestParams->DatabaseParams
s/Int #(Integer/parseInt %)}))
(RequestParams->DatabaseParams-coercer {:comment-id "123"
:comment-body "Hello World!"})
;; => {:body "Hello World!", :id 123}
I start by defining a function that renames the keys. This function affects the entire request-params map. In my experience, it's much better to put separate out the functions that go into the coercion map, as they're easily re-useable elsewhere.
Then I define the coercer.
hThe first parameter to coerce/coercer is the schema I'm coercing into. The second is a map that tells the coercer how to do its job.
The map's keys are the types the schema wants, and its values are the functions that do the coercion.
Recall we made a schema for s/Str called RequestCommentId. We did that for two reasons:
1. We want our own type for it instead of just using s/Str.
2. The body is also an s/Str. There's no way for schema to know how to differentiate between an s/Str that's a comment body and an s/Str that's a comment id. Since we called it RequestCommentId instead, we can have two different schema types that at the lower level mean String.
Back to the coercion map.
DatabaseParams is what we want and a key in the map. This means that when we want DatabaseParams, we get the entire parameter passed to the coercion function, in this case, RequestParams. Think about it as a way to grab the entire thing you're coercion. It's useful so you can massage the keys of the map into the shape you want before the types get coerced.
Finally, the other key, s/Int says, whenever an s/Int is specified in our schema map, apply this function to the value in that map. Recall the DatabaseParams map has an :id key with the value s/Int. Since we've renamed the key from :comment-id to :id, the coercion walker sees that :id should be an s/Int, looks into the coercion map for the function to do it, and returns the result under the key :id.
That's it! An ultra quick overview of Schema, one of the libraries that we use a lot at RentPath. We're hiring senior clojure developers by the way (100% remote), send me an email if you're interested and are a U.S. citizen.
It might've been a little too quick so if there are any questions/confusions, please leave a comment.
Master GitHub Actions with a Senior Infrastructure Engineer
As a senior staff infrastructure engineer, I share exclusive, behind-the-scenes insights that you won't find anywhere else. Get the strategies and techniques I've used to save companies $500k in CI costs and transform teams with GitOps best practices—delivered straight to your inbox.
Not sure yet? Check out the archive.
Unsubscribe at any time.