/rop

Yet another Railway Oriented Programming in Clojure

Primary LanguageClojureMIT LicenseMIT

Railway Oriented Programming

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.

CircleCI Dependencies Status License

Leiningen/Boot

[rop "0.4.1"]

Documentation

(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 "FOO@BAR.COM", :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 "foo@bar.com", :new-user {:email "foo@bar.com", :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.

succeed

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)))

fail

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)))

switch

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 "FOO@BAR.COM", :new-user nil}
          (rop/switch format-email) ;; <-- I can wrap here
          validate-email
          (rop/switch create-user)
          (rop/dead send-email!)))

dead

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 "FOO@BAR.COM", :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 "FOO@BAR.COM", :new-user nil}
          (rop/switch format-email)
          validate-email
          (rop/switch create-user)
          (rop/dead send-email!)))
;; returns
{:email "foo@bar.com", :new-user {:email "foo@bar.com", :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 "FOO@BAR.COM", :new-user nil}
           (rop/switch format-email)
           validate-email
           (rop/switch create-user)
           (rop/dead send-email!)))
;; returns
{:body {:email "foo@bar.com", :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 "FOO@BAR.COM", :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 "FOO@BAR.COM", :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 "FOO@BAR.COM", :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"]}}}

=validate-request=

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 input
  • scheme a validation scheme
  • default default values as a hash-map, it will be merged into a validated input
  • request-key a key in a request that 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.

=merge-params=

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.