Command Query Responsability Segregation (CQRS)
ShipClojure uses CQRS which is just a fancy way of saying we like to have clear separation between queries and mutations. It works very well with replicant and creating pure handlers.
By definition, commands are used to mutate something (the DB, filesystem, or perform an action - sending emails, etc) while queries are just about retrieving data.
Both commands & queries are defined in cqrs.clj
Commands
Commands are defined in commands map like so:
(def commands
{:command/sign-up
{:command/kind :command/sign-up
:command/description "Create an account"
:command/parameters s/create-account
:command/handler auth-handlers/register
:command/responses {201 {:body s/sign-up-response}}}
...}
To call this command through the API, you would do:
POST /cqrs/command {:command/kind :command/sign-up
:command/data {...}}
If you want a command to be protected, a.k.a the user would need to be authenticated to issue a request, add :command/requires-authentication? true to the command definition.
Role-Based Authorization
Commands and queries can be restricted to specific membership roles using :command/required-roles or :query/required-roles:
{:command/kind :command/delete-organisation
:command/requires-authentication? true
:command/required-roles #{:membership.role/owner}
:command/parameters [:map [:organisation/id :uuid]]
:command/handler org-handlers/delete-organisation}
The CQRS middleware automatically checks the user's role before executing the handler. If the user's role isn't in the required set, a 403 Forbidden is returned. For the full details on how role-based access control works across the stack, see Role-Based Access Control.
Pure commands
Usually, you'd prefer to use ring-nexus to write handlers as they are easier to test and they are pure functions. Here's a real example from shipclojure that confirms a user verification:
(defn confirm-user-verification
[req]
(let [type (:verification/type (:params req))
code-param (:verification/code (:params req))
code (if (string? code-param) (parse-long code-param) code-param)
target (:verification/target (:params req))
db (get-in req [:nexus/state :db])
v (user-account/get-verification db {:verification/type type :verification/target target})]
(cond
(not v)
[[:http-response/not-found {:message (str "No verification for target " target)
:cause :verification/not-found}]]
(or (nil? code) (not (auth/is-valid-totp-token? code (verification->totp-config v))))
[[:http-response/bad-request {:message "Invalid code"
:cause :verification/invalid-code}]]
:else
(let [verification-id (user-account/verification-id db {:verification/type type
:verification/target target})]
[[:db/transact [{:db/id verification-id
:verification/confirmed? true}]]
[:http-response/ok {:message "Verification confirmed"}]]))))
The logic from above is this:
- Find the verification with that specific type (
onboardingorreset-password) and email in the DB. - If there is no such verification entry, return 404 (
[:http-response/not-found ...]) - If there is no code or the code is invalid, return 400 (
[:http-response/bad-request ..]) - Finally if the code is valid, mark the verification in the DB as being confirmed and respond with 200 verification confirmed
[:http-response/ok "Verification confirmed"]
What is interesting here is that the function is still pure. It describes the intent and the logic, but doesn't actually execute it. The execution of the effects like :db/transact and http responses are left to nexus which is an action dispatch system.
ring-nexus will add a snapshot of the datomic DB on the request under nexus/state on the request map so you can do all of your required queries.
This type of handler is seamless to test. Let's look at the tests for it:
(deftest confirm-user-verification-test
(testing "Returns not found when verification is not found"
(let [req (tu/unit-req {:body {:verification/code "123456"
:verification/target "test@example.com"
:verification/type :verification.type/onboarding}})]
(is (= (handlers/confirm-user-verification req)
[[:http-response/not-found {:message "No verification for target test@example.com"
:cause :verification/not-found}]]))))
(testing "Returns bad request when TOTP code is invalid"
(let [{:keys [secret time-step hmac-sha-type]} (totp-config)
req (tu/unit-req {:body {:verification/code "999999"
:verification/target "test@example.com"
:verification/type :verification.type/onboarding}
;; :txs defines what is the initial state of the test datomic DB.
;; Here we insert the would-be verification entry
:txs [{:verification/type :verification.type/onboarding
:verification/target "test@example.com"
:verification/confirmed? false
:verification/secret secret
:verification/algorithm (name hmac-sha-type)
:verification/period time-step
:verification/digits "123456"}]})]
(is (= (handlers/confirm-user-verification req)
[[:http-response/bad-request {:message "Invalid code"
:cause :verification/invalid-code}]]))))
(testing "Successfully confirms verification with valid TOTP code"
(let [{:keys [secret time-step hmac-sha-type]} (totp-config)
;; Generate a valid TOTP code
totp-config {:time-step time-step :secret secret :hmac-sha-type (keyword (name hmac-sha-type))}
valid-code (format "%06d" (auth/get-totp-token totp-config))
req (tu/unit-req {:body {:verification/code valid-code
:verification/target "test@example.com"
:verification/type :verification.type/onboarding}
:txs [{:verification/type :verification.type/onboarding
:verification/target "test@example.com"
:verification/confirmed? false
:verification/secret secret
:verification/algorithm (name hmac-sha-type)
:verification/period time-step
:verification/digits valid-code}]})
{:keys [nexus/state]} req
db (:db state)
verification-id (user-account/verification-id db {:verification/type :verification.type/onboarding
:verification/target "test@example.com"})
[[transact-verb [confirmation-payload]] [http-verb response]]
(handlers/confirm-user-verification req)]
;; Verify action structure
(is (= transact-verb :db/transact))
(is (= http-verb :http-response/ok))
;; Verify confirmation transaction
(is (= confirmation-payload {:db/id verification-id
:verification/confirmed? true}))
;; Verify HTTP response
(is (= response {:message "Verification confirmed"}))))
...
)
There are more tests for this particular handler but the above show the main 3 branches. tu/unit-req is a helper that creates a request map mock and helps to add a snapshot of the db to nexus/state based on :txs:
(tu/unit-req {:body request-input
:txs transactions-representing-the-db-state})
You can go a very long way with this style of handlers, as a matter of fact, all of shipclojure's authentication related handlers are done this way.
However there are situations where you need to get your hands dirty and write classic ring handlers that have side effects baked in:
Commands with side-effects
To write commands that don't need nexus, first off you'll need to inject the integrant system. To do this, you'll need to define your command like this
(def commands
...
:command/cool-cmd {:command/kind :cool-cmd
;; inject integrant system of dependencies defined for
;; handlers. This includes the datomic conn, email and
;; stripe clients and anything you define there
:command/inject-system? true
:command/handler
(fn [req system]
(d/transact (:conn system) '[..]
{:status 200
:body "Success"})
N.B: When you add :command/inject-system? true to the command definition, the handler for it should take the system as a second argument. If you don't do that, you'll receive an arity exception.
For more details see Using integrant components in handlers
Here's an example of a handler that creates a stripe checkout session
(def session-command
{:command/kind :command/create-checkout-session
:command/description "Create a stripe checkout session for purchasing a product."
:command/inject-system? true
:command/parameters
[:map
[:checkout-session/price-id :string]
[:checkout-session/product-id :string]
[:checkout-session/success-url {:optional true} :string]
[:checkout-session/cancel-url {:optional true} :string]
[:checkout-session/user-id {:optional true} :string]
[:checkout-session/custom-fields {:optional true} :map]]
:command/handler payment-handlers/create-checkout-session}
;; payment-handlers
(defn create-checkout-session
"Create a Stripe checkout session for a one-time payment.
Returns a URL that the user should be redirected to for completing the payment."
[request {:keys [stripe-client] :as system}]
(let [price-id (get-in request [:params :checkout-session/price-id])
product-id (get-in request [:params :checkout-session/product-id])
user-id (or (get-in request [:params :checkout-session/user-id])
(get-in request [:claims :sub]))
success-url (get-in request [:params :checkout-session/success-url] "http://localhost:8080/checkout/success?session_id={CHECKOUT_SESSION_ID}")
cancel-url (get-in request [:params :checkout-session/cancel-url] "http://localhost:8080/checkout/cancel")
custom-fields (get-in request [:params :checkout-session/custom-fields])
checkout-session (stripe-checkout/create-session
stripe-client
{:metadata {:price-id price-id
:product-id product-id
:user-id user-id}
:mode "payment"
:allow-promotion-codes true
:tax-id-collection {:enabled true}
:success-url success-url
:cancel-url cancel-url
:line-items [{:price-id price-id
:quantity 1}]
:custom-fields custom-fields})]
(http-response/ok {:url (:url checkout-session)})))
Queries
Queries are almost the same as commands, except they are prepended with query/*.
See all defined queries here.
Frontend Request
To better understand Replicant and how you issue request, please go through the Backend APIs and network tutorials - all 3 of them please. ShipClojure follows queries & command handling as defined in those tutorials almost to a T.
Example issuing command
[:ui/button
{:on {:click [[:data/command
{:command/kind :command/log-in
:command/data {:user/email "test@shipclojure.com"
:user/password "shipclojureI$C00l"}}
{:on-success [[:router/navigate {:to ::routes/dashboard}]]}]]}}
"Log in"]
As you can see, this is all data, and it conveys the entire intent here: Log in with that user and password and if the request is succesfull, navigate to dashboard