This is another Clojure implementation of Railway Oriented Programming. It's based on this gist.
The reason for another implementation is to provide more pleasant usage for common cases. See example of usage below.
[rop "0.4.1"]
(require [rop.core :as rop])
Let's define "bussiness logic" functions like this
(defn format-email
[input]
(update input :email lower-case))
(defn validate-email
[input]
(if (-> input :email blank?)
(rop/fail {:status 400, :body {:errors {:email ["Invalid format"]}}})
(rop/succeed input)))
(defn create-user
[input]
(assoc input :new-user {:email (:email input), :id 1})
(defn send-email!
[input]
;; send e-mail here
(println "Sending e-mail"))
A simple use case looks like this
(rop/>>= {:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
A result of the use case is
{:email "[email protected]", :new-user {:email "[email protected]", :id 1}}
An input hash-map flows through functions defined in >>=
, until any function returns rop/fail
.
Otherwise the input hash-map is returned at the end. Internally >>=
uses funcool/cats
library, but the result is
extracted for you.
It marks a result of a function as a success result. Thus >>=
will call another function.
(defn format-email
[input]
(rop/succeed (update input :email lower-case)))
It marks a result of a function as a fail result. It stops computation.
(defn validate-email
[input]
(if (-> input :email blank?)
(rop/fail {:errors {:email ["Invalid format"]}})
(rop/succeed input)))
Makes a normal function to be tautological (always returns a success result). It's a shortcut for wrapping functions.
(defn format-email
[input]
(update input :email lower-case)) ;; <-- see not marking a result as a success
(rop/>>= {:email "[email protected]", :new-user nil}
(rop/switch format-email) ;; <-- I can wrap here
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
A wrapper for deadend functions. Any side effect can be done here and I don't need to care about returning succeed
.
(defn send-email!
[input]
;; send e-mail here
(println "Sending e-mail")) ;; <-- See no `succeed` here
(rop/>>= {:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!))) ;; <-- I can wrap here
An infix version of bind for piping two-track values into switch fns. Can be used to pipe two-track values through a series of switch fns. First is an input hash-map it will be passed throgh switch fns. Rest parameters as switch fns.
It's advanced >>=
function. Returning Ring's response from >>=
is a common use case and this function helps with it.
First parameter is a success key (it will be used as :body in result hash-map) or a tuple with success-key and
output-keys (at the end select-keys
will be applied on a success result with these output-keys
).
Second is an input hash-map it will be passed throgh switch fns. Rest parameters as switch fns.
Above use case can be improved via >>=*
(rop/>>= {:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
;; returns
{:email "[email protected]", :new-user {:email "[email protected]", :id 1}}
But it's common that we want to take just one key and return it as a Ring's response. We can do it with >>=*
by
passing :new-user
as a first argument.
(rop/>>=* :new-user
{:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
;; returns
{:body {:email "[email protected]", :id 1}, :status 200, :headers {}}
Also it's common that not all keys of hash-map can be exposed. Output keys can be limited like this
(rop/>>=* [:new-user #{:id}]
{:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
;; returns
{:body {:id 1}, :status 200, :headers {}}
Of course it also works with sequences.
(rop/>>=* [:new-users #{:id}]
{:email "[email protected]", :new-user nil, :new-users nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/switch #(assoc % :new-users [(:new-user %)]))
(rop/dead send-email!))))))
;; returns
{:body [{:id 1}], :status 200, :headers {}} ;; <-- See :body
Sure HTTP headers and status code can be defined with >>=*
too
(defn create-user
[input]
(-> input
(assoc :new-user {:email (:email input), :id 1})
(assoc-in [:response :status] 201)
(assoc-in [:response :headers] {:content-type :application/json})))
(rop/>>=* [:new-user #{:id}]
{:email "[email protected]", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
;; returns
{:body {:id 1}, :status 201, :headers {:content-type :application/json}}
Same when a failure needs to define status of headers
(defn validate-email
[input]
(if (-> input :email blank?)
(rop/fail {:status 400, :body {:errors {:email ["Invalid format"]}}}) ;; <-- a whole Ring's response here
(rop/succeed input)))
(rop/>>=* [:new-user #{:id}]
{:email "", :new-user nil}
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
;; returns
{:status 400, :body {:errors {:email ["Invalid format"]}}}
A railway function that validates a request by a given scheme. If data are valid it updates them in the request (with coerced data), otherwise returns Bad Requests within errors.
Parameters:
validate
a function that takes an input and a validation scheme, it should return a tuple of errors and validated inputscheme
a validation schemedefault
default values as ahash-map
, it will be merged into a validated inputrequest-key a key in a
requestthat holds the input data
input
a ROP input"
An example usage with Struct library:
(require '[struct.core :as st])
(rop/>>=* [:new-user #{:id}]
{:email "", :new-user nil}, :request request)
(partial rop/=validate-request= st/validate {:id [st/number-str]} {} :params
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
Function =validate-request=
will fail when st/validate
returns any error it creates Ring Bad Request response:
{:status 400, :body {:errors {:id "mut be a number"}}}))))
Or it continues in a flow and updates an input by a coerced input returned by Struct.
A railway function that merges a given source
key into a target
key in a request.
It's useful when route params and body params are validated together."
(rop/>>=* [:new-user #{:id}]
{:email "", :new-user nil}, :request request
(partial rop/=merge-params= :body-params :params)
(partial rop/=validate-request= st/validate {:id [st/number-str]} {} :params)
(rop/switch format-email)
validate-email
(rop/switch create-user)
(rop/dead send-email!)))
Function =merge-params=
updates :params
within :body-params
in a request. It's useful when combining query
parameter within body parameters before a validation.