Liking cljdoc? Tell your friends :D

Handlers In Depth

This guide explains how Liberator works and how Sweet Tooth uses it to create request handlers. It explains how to write handlers for your own apps.

It begins with a high-level discussion of the core Liberator concepts decisions and context. It then provides practical guidance on creating handlers in Sweet Tooth apps.

Liberator Decisions

By default, Sweet Tooth uses Liberator to construct request handlers. Liberator provides an abstraction for the common decisions that a request handler has to make, decisions like: Is this data valid? If not, return a 400 status. Is the user authorized? If not, return a 401 status. The following two snippets demonstrate two different approaches I’ve seen to handling this kind of logic within the common use-case of creating a new resource:

typical endpoint code
(defn create-todo
  [{:keys [params session] :as _req}]
  (if-not (authorized? session)
    (throw NotAuthorized "you do not have permission to do that"))
  (if-let [errors (validation-errors ::create-todo params)]
    {:status 400
     :body   {:errors errors}}
    (create-todo! params)))

(defn create-todo
  [{:keys [params session] :as _req}]
  (cond (not (authorized? session))
        {:status 401
         :body   {:errors "not authorized"}}

        (not (valid? ::create-todo params))
        {:status 400
         :body   {:errors (validation-errors ::create-todo params)}}

        :else
        (create-todo! params)))

The structure of this if/then logic to determine which status to return is always ways the same. You’ll always want to check whether the user is authorized before attempting to validate data, and you’ll always want to validate data before attempting to insert it in a db. If the request isn’t authorized, you should always return a 401 status, and if it’s not valid you should return a 400 status. You can visualize this structure as a decision graph:

diagram of a decision graph

Liberator provides a function that captures the the decision graph — the order that the decisions are traversed and the status codes associated with each final result — so that your code only has to focus on the logic specific to each decision.

Below is a simplified version of that function. It takes a map of decision functions and returns a request handler which can be used to return responses for ring requests.

Liberator uses :malformed? instead of :valid? and :handle-ok instead of :success because it’s actually structuring decisions regarding how to return a valid HTTP response, and the language of malformed and ok hews more closely to the HTTP spec.
simplified decision request generator
(def decision-graph
  {:authorized? {true  :malformed?
                 false 401}
   :malformed?  {true  400
                 false :handle-ok}
   :handle-ok   200})

(defn decisions->handler
  [decision-nodes]
  (fn request-handler [req]
    (loop [node :authorized?]
      (let [result              ((node decision-nodes) req)
            edges-or-status     (node decision-graph)
            next-node-or-status (get edges-or-status (boolean result) edges-or-status)]
        (if (keyword? next-node-or-status)
          (recur next-node-or-status) ;; it was a node; on to the next decision!
          {:status next-node-or-status
           :body   result})))))

The code might be a little dense and I welcome suggestions clearer :) decisions→handler returns a function that traverses decision-graph, starting with the :authorized? node. As it traverses the graph, it looks up decision functions in decision-nodes. It uses the return value of the decision function to retrieve either the next decision node or the response status. If a status is retrieved, the function returns.

Here’s how you would call this function to create a handler:

create and call a handler
(def handler (decisions->handler {:authorized? (constantly true)
                                  :malformed? (constantly false)
                                  :handle-ok (constantly "hi")}))

(handler {})
;; =>
{:status 200, body "hi"}

The simplified Liberator re-implementation differs from actual Liberator in a couple important ways. First, the real library provides sane defaults for :authorized?, :malformed? and all the rest of its decision functions. I didn’t include default handling in these examples to keep the code more focused.

Second, Liberator allows you to provide constants, so you can write :authorized? true instead of :authorized? (constantly true) and :handle-ok "hi" instead of :handle-ok (constantly "hi")).

Liberator Context

The above examples are incomplete: Liberator actually allows you to convey data from decision function to the next. For example, in the :authorized? decision you might look up the identity associated with a request. The :handle-ok function could use this data; rather than looking it up again, :authorized? can store it so that :handle-ok can access it.

Liberator accomplishes this by passing the request context in to each decision function. A Liberator context is just a map. It includes the ring request under the key :request. Your decision functions can append to the context by returning a vector like so:

append to a context
(defn authorized?
  [ctx]
  (when-let [user (find-user ctx)]
    [true {:auth-user user}]))

The first element of the vector, true, is the decision result, and it determines which node of the decision graph to visit next. The second element, {:auth-user user}, is a map that gets merged into context. The updated context is available to downstream decision handlers:

create and call a handler
(defn handle-ok
  [ctx]
  (create-todo! (merge (get-in ctx [:request :params])
                       {:user-id (get-in ctx [:auth-user :id])})))

Here’s an updated version of the decisions→handler function that implements this feature, along with a toy handler that makes use of it:

decisions→handler with context
(defn conform-decision-result
  [result]
  (if (vector? result)
    result
    [result {}]))

(defn decisions->handler
  [decision-nodes]
  (fn [req]
    (loop [ctx  {:request req}
           node :authorized?]
      (let [[result added-context] (conform-decision-result ((node decision-nodes) ctx))
            edges-or-status        (node decision-graph)
            next-node-or-status    (get edges-or-status (boolean result) edges-or-status)]
        (if (keyword? next-node-or-status)
          (recur (merge ctx added-context) next-node-or-status) ;; it was a node; on to the next decision!
          {:status next-node-or-status
           :body   result})))))

(def handler
  (decisions->handler
   {:authorized? (fn [ctx] [true {:auth-user {:user-id 1}}])
    :malformed?  (constantly false)
    :handle-ok   (fn [ctx] (str "Logged in as " (get-in ctx [:auth-user :user-id])))}))

(handler {})
;; =>
"Logged in as 1"

Liberator Decision Functions vs Status Handlers

So far I’ve been conflating decision functions and status handlers under the perhaps misguided notion that it would allow us to focus on one facet of Liberator at a time. Let’s correct that now.

While decision functions are used to determine which HTTP status code to return for a request, status handlers determine the response body. Status handlers are leave nodes in the decision graph. :handle-ok is one such function, but Liberator also makes use of :handle-malformed, :handle-unauthorized, and dozens more. A more accurate decision graph would look like this:

more accurate decision graph

Let’s update our example code to capture this distinction and add :handle-malformed and :handle-unauthorized handlers:

decisions→handler with context
(def decision-graph
  {:authorized?         {true  :malformed?
                         false :handle-unauthorized}
   :malformed?          {true  :handle-malformed
                         false :handle-ok}
   :handle-unauthorized 401
   :handle-malformed    400
   :handle-ok           200})

(defn decisions->handler
  [decision-nodes]
  (fn [req]
    (loop [ctx  {:request req}
           node :authorized?]
      (let [edges-or-status (node decision-graph)
            node-type       (if (map? edges-or-status)
                                  :decision
                                  :status-handler)]
        (case node-type
          :decision (let [[result added-context] (conform-decision-result ((node decision-nodes) ctx))
                          next-node              (get edges-or-status (boolean result))]
                      (recur (merge ctx added-context) next-node))
          :status   {:status edges-or-status
                     :body   ((node decision-nodes (constantly nil)) ctx)})))))

Here’s a more realistic example of how this could all work together. First we create a handler, then we call it with a couple different "requests":

more detailed decisions
(def create-todo-list-handler
  (decisions->handler
   {:authorized?      (fn [ctx]
                        (when-let [user (get-in ctx [:request :user])]
                          [true {:user user}]))
    :malformed?       (fn [ctx]
                        (if (get-in ctx [:request :params :todo-list/title])
                          false
                          [true {:errors ["No to-do list title"]}]))
    :handle-malformed (fn [ctx] (select-keys ctx [:errors]))
    :handle-ok        (fn [ctx]
                        (merge (get-in ctx [:request :params])
                               {:todo-list/owner (get-in ctx [:user :id])}))}))

(create-todo-list-handler {:user {:id 1}})
;; =>
{:status 400, :body {:errors ["No to-do list title"]}}


(create-todo-list-handler
 {:user   {:id 1}
  :params {:todo-list/title "write some docs this is your life now"}})
;; =>
{:status 200
 :body #:todo-list{:title "write some docs this is your life now"
                   :owner 1}}

Sweet Tooth handlers

Sweet Tooth uses Liberator to create request handlers from decision maps. Sweet Tooth’s approach differs from vanilla liberator in a few key ways:

  • It simplifies dispatching by request method (:get, :post, etc)

  • It uses an opinionated set of decision functions and status handlers

  • It’s meant to be used with Integrant, and it provides tools to make that easy

  • It expects responses to conform to a Sweet Tooth-specific response protocol, and automatically formats some values so that they’ll conform

Simpler HTTP method dispatching

In vanilla Liberator, you typically create a single handler for a given route. From Liberator’s docs:

vanilla liberator
;; create and list entries
(defresource list-resource
  :available-media-types ["application/json"]
  :allowed-methods [:get :post]
  :known-content-type? #(check-content-type % ["application/json"])
  :malformed? #(parse-json % ::data)
  :post! #(let [id (str (inc (rand-int 100000)))]
            (dosync (alter entries assoc id (::data %)))
            {::id id})
  :post-redirect? true
  :location #(build-entry-url (get % :request) (get % ::id))
  :handle-ok #(map (fn [id] (str (build-entry-url (get % :request) id)))
                   (keys @entries)))

In this snippet, defresource is a Liberator macro that creates a request handler function, list-resource, from the given decision key/value pairs. From :allowed-methods, you can see that it handles both :get and :post requests.

I personally find it confusing to combine two different workflows within the same function like this. In Sweet Tooth, the decision maps used to generate handlers look like this:

example Sweet Tooth decisions, taken from the To-Do example
(def decisions
  {:collection
   {:get  {:handle-ok (comp tl/todo-lists ed/db)}
    :post {:malformed?     (v/validate-describe v/todo-list-rules)
           :post!          ed/create->:result
           :handle-created ed/created-pull}}})

Decision maps are keyed first by route type (see Routes in Depth for an explanation of route types), then by request method. As developers working on RESTful APIs, we categorize units of work by request method, so I think it’s useful to unambiguously distinguish handlers for different methods.

Slightly opinionated default decisions

Liberator is very flexible, very cool. You can use it for content negotiation, for example, serving different responses based on a request’s media type and returning the appropriate HTTP status code when a request’s media types don’t match what the server provides. You saw an example of this in the last section:

media types
(defresource list-resource
  :available-media-types ["application/json"]
  :known-content-type? #(check-content-type % ["application/json"]))

By default, Sweet Tooth endpoints expect to receive and return Transit. It also has conventions for error handling, expecting errors to be placed under :errors in the context map. Here are all of Sweet Tooth’s defaults:

sweet tooth decision defaults
(def decision-defaults
  "A base set of liberator resource decisions"
  (let [errors-in-ctx (fn [ctx] [:errors (:errors ctx)])
        base          {:available-media-types ["application/transit+json"
                                               "application/transit+msgpack"
                                               "application/json"]
                       :allowed-methods       [:get]
                       :authorized?           true
                       :handle-unauthorized   errors-in-ctx
                       :handle-malformed      errors-in-ctx
                       :respond-with-entity?  true
                       :new?                  false}]
    {:get    base
     :post   (merge base {:allowed-methods [:post]
                          :new?            true
                          :handle-created  record})
     :put    (merge base {:allowed-methods [:put]})
     :patch  (merge base {:allowed-methods [:patch]})
     :head   (merge base {:allowed-methods [:head]})
     :delete (merge base {:allowed-methods      [:delete]
                          :respond-with-entity? false})}))

Integrant integration

Sweet Tooth is built on top of Integrant, a dependency injection framework. When you use Integrant, you create components for interacting with external services like databases, then pass in those components in as arguments to the functions that need them.

So how does one pass in components to handlers in a Sweet Tooth app? One does this in the route definition. Here’s the route definition for the to-do example app:

routes passing in components
(ns sweet-tooth.todo-example.cross.endpoint-routes
  (:require [sweet-tooth.endpoint.routes.reitit :as serr]
            [integrant.core :as ig]))

(def routes
  (serr/expand-routes
   [{:ctx               {:db (ig/ref :sweet-tooth.endpoint.datomic/connection)}
     :id-key            :db/id
     :auth-id-key       :db/id
     ::serr/path-prefix "/api/v1"}
    [:sweet-tooth.todo-example.backend.endpoint.todo-list]
    [:sweet-tooth.todo-example.backend.endpoint.todo]]))

(defmethod ig/init-key ::routes [_ _]
  routes)

(See Routes in Depth if you’re not familiar with serr/expand-routes.)

We pass expand-routes a vector where the first element is a map. The map is a set of route options that gets applied to every route that follows. The :ctx key defines a map that should get merged into the context of every liberator handler for those routes.

In this case, the map is {:db (ig/ref :sweet-tooth.endpoint.datomic/connection)}. The function ig/ref returns an Integrant reference to the specified component. When the system is initialized, it will be replaced with the initialized :sweet-tooth.endpoint.datomic/connection component, and decision functions can access the component like so:

routes passing in components
(fn [ctx]
  (d/transact! (:db ctx) [...]))

TODO go into more detail about how this actually works

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close