Image for post
Image for post
Using malli library to validate POST body data in the REST API.

Introduction

What is Malli?

Why Data Validation?

Malli, Spec, or Schema?

Experimenting with Malli Using the Clojure REPL

(ns malli
(:require [malli.core :as m]))
(m/validate
[:map {:closed true} [:ping string?]]
{:ping "hello"}) ; => true
(m/validate
[:map {:closed true} [:ping string?]]
{:wrong-key "hello"}) ; => false
(m/validate
[:map {:closed true} [:ping string?]]
{:ping "hello" :extra-key "hello"}) ; => false

Malli with Reitit

["/api"
{:swagger {:tags ["api"]}}
; For development purposes. Try curl http://localhost:6161/api/ping
["/ping" {:get {:summary "ping get"
; Don't allow any query parameters.
:parameters {:query [:map]}
:responses {200 {:description "Ping success"}}
:handler (constantly (make-response {:ret :ok, :reply "pong"}))}
:post {:summary "ping post"
:responses {200 {:description "Ping success"}}
;; reitit adds mt/strip-extra-keys-transformer - probably changes in reitit 1.0,
;; and therefore {:closed true} is not used with reitit < 1.0.
:parameters {:body [:map {:closed true} [:ping string?]]}
:handler (fn [req]
(let [body (get-in req [:parameters :body])
myreq (:ping body)]
(-> {:ret :ok :request myreq :reply "pong"}
(make-response))))}}]
...
(deftest ping-get-test
(log/debug "ENTER ping-get-test")
(testing "GET: /api/ping"
(let [ret (ss-tc/-call-api :get "ping" nil nil)]
(is (= (ret :status) 200))
(is (= (ret :body) {:reply "pong" :ret "ok"})))))
(deftest failed-ping-get-extra-query-params-test
(log/debug "ENTER failed-ping-get-extra-query-params-test")
(testing "GET: /api/ping"
(let [ret (ss-tc/-call-api :get "ping?a=1" nil nil)]
(is (= (ret :status) 400))
(is (= (ret :body) {:coercion "malli"
:humanized {:a ["disallowed key"]}
:in ["request"
"query-params"]
:type "reitit.coercion/request-coercion"})))))
(re-ring/ring-handler
(re-ring/router routes {
:data {:muuntaja mu-core/instance
:coercion (reitit.coercion.malli/create
{;; set of keys to include in error messages
:error-keys #{:type :coercion :in :humanized ...
(deftest ping-post-test
(log/debug "ENTER ping-post-test")
(testing "POST: /api/ping"
(let [ret (ss-tc/-call-api :post "ping" nil {:ping "hello"})]
(is (= (ret :status) 200))
(is (= (ret :body) {:reply "pong" :request "hello" :ret "ok"})))))
(deftest failed-ping-post-missing-key-test
(log/debug "ENTER failed-ping-post-missing-key-test")
(testing "POST: /api/ping"
(let [ret (ss-tc/-call-api :post "ping" nil {:wrong-key "hello"})]
(is (= (ret :status) 400))
(is (= (ret :body) {:coercion "malli"
:humanized {:ping ["missing required key"]}
:in ["request"
"body-params"]
:type "reitit.coercion/request-coercion"})))))
["/signin" {:post {:summary "Sign-in to get an account"
:responses {200 {:description "Sign-in success"}}
:parameters {:body [:map
[:first-name string?]
[:last-name string?]
[:email string?]
[:password string?]]}
:handler (fn [req]
(let [body (get-in req [:parameters :body])
{:keys [first-name last-name password email]} body]
(-signin env first-name last-name password email)))}}]
...
(def name-schema [:map {:closed true} [:first-name string?] [:last-name string?]])
(def sign-in-schema [:map [:name name-schema] [:email string?] [:password string?]])
...
;; and then in the routes:
["/signin" {:post {:summary "Sign-in to get an account"
:responses {200 {:description "Sign-in success"}}
:parameters {:body sign-in-schema}
...
(ns malli
(:require [malli.core :as m]
[malli.error :as me]))
(-> name-schema
(m/explain {:first-name "mikko" :last-name "mikkonen"})
(me/humanize)) ; => nil (ok)
(-> name-schema
(m/explain {:first-name "mikko" :middle-name "pekka" :last-name "mikkonen"})
(me/humanize)) ; => {:middle-name ["disallowed key"]}
(-> sign-in-schema
(m/explain {:name {:first-name "mikko" :last-name "mikkonen"} :password "salainen" :email "mikko.mikkonen@foo.com"})
(me/humanize)) ; => nil (ok)
(-> sign-in-schema
(m/explain {:name {:first-name "mikko" :last-name "mikkonen"} :id 1 :password "salainen" :email "mikko.mikkonen@foo.com"})
(me/humanize)) ; => nil (also ok)
(ns malli
(:require [malli.core :as m]
[malli.error :as me]
[malli.util :as mu]
[clojure.data :as data]))
(data/diff (m/schema sign-in-schema) (mu/closed-schema sign-in-schema))
;; =>
;[[:map
; [:name [:map {:closed true} [:first-name string?] [:last-name string?]]]
; [:email string?]
; [:password string?]]
; [:map
; {:closed true}
; [:name [:map {:closed true} [:first-name string?] [:last-name string?]]]
; [:email string?]
; [:password string?]]
; nil]

Conclusions

Written by

I’m a Software architect and developer. Currently implementing systems on AWS / GCP / Azure / Docker / Kubernetes using Java, Python, Go and Clojure.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store