Adding Custom Transit Handlers to re-frame-http-fx AJAX requests

Table of Contents

img

Setting the Scene

Transit is a seamless alternative to JSON (actually an extension of JSON). Why use Transit when JSON is so prevalent? For me, the simple reason was that it allows me to preserve my data types (mine are Clojure, but they are not necessarily such) over the wire. For me in particular this was my time types, and my uuids (which I wanted to standardize so they stop alternating between UUIDs, strings, and keywords, for matching purposes). Yes, I’m feeling a little guilty because here I am propogating Java’s static typing, but that seemed like a consequence of using times (ALWAYS stateful) and UUIDs from my database. The promise? That at no point in my front-end code would my times and uuids be coerced into anything other than what they are.

With the help of Muuntaja, I’ve written my own Transit encoders for my back-end that can handle Instants (and Transit natively handles UUIDs). What I wasn’t sure about was how to make the parallel decoders on the front-end, where I’m using re-frame-http-fx for my ajax requests. I lifted the base code for transit time handlers from @yogthos Luminus, but it took some major figuring to understand how to connect my Transit handlers (simple to make) into re-frame’s (getting more complex) http-fx AJAX functionality (now officially complex), which wraps cljs-ajax (are you losing track yet? I sure did). Here, to save you all the time I spent figuring out which maps to build and where to put them, is the solution that finally got me there.

Clojure and Clojurescript in match-time with CLJC: Tick, Reitit, Transit

I build a full-stack Clojure apps, and so CLJC is a secret weapon to get code on both the front and the back-end. Transit uses a lot of CLJC, and I utilize data-driven routing with Reitit for both my SPA front-end solution and my back-end routing, once again taking the complicated problem of routing an SPA and the API and consolidating the approach. So also does my go-to for time handling, tick. Dealing with times is complicated enough without having to code-switch between Google (Javascript) times and any of the flavors of Java times. Tick does a great job of using the same API for most of front- and back-end.

The Back-end: Clojure and Muuntaja

Transit on the back-end was fairly easy. I have an ns like this

(ns centrifuge.middleware.formats
  (:require [muuntaja.core :as m]
            [luminus-transit.time :as time]
            [cognitect.transit :as transit]
            [tick.alpha.api :as t])
  (:import [java.time
            Instant]
           [java.time.format
            DateTimeFormatter]))

(def instant-encoders
  {:handlers
   {Instant
    (transit/write-handler
     (constantly "Instant")
     str)}})

(def instant-decoders
  {:handlers
   {"Instant" (transit/read-handler t/parse)}})

(def instance
  (m/create
    (-> m/default-options
        (update-in
          [:formats "application/transit+json" :decoder-opts]
          (partial merge time/time-deserialization-handlers))
        (update-in
          [:formats "application/transit+json" :encoder-opts]
          (partial merge
                   time/time-serialization-handlers
                   instant-encoders)))))

Note that when interacting with JSON/front-end, Instants will at some point be flagged with a string by transit; I told it to use string “Instant” when writing them into Transit. The back-end just matches off a type, hence the map {Instant (transit/write-handler...)}. This is how it will be encoded. The json+transit that goes over the wire will look like this:

[ "~#Instant", "2020-05-02T06:00:00Z" ]

Similarly when it receives a Transit Instant (remember, I chose “Instant”; I could have chosen “jiffy” or any other arbitrary string) over the wire, it will read it into the Tick formatting. This is plugged into my middleware as follows:

(ns centrifuge.middleware
  (:require [muuntaja.middleware :refer [wrap-format wrap-params]]
            [centrifuge.middleware.formats :as formats]))

(defn wrap-formats [handler]
  (let [wrapped (-> handler wrap-params (wrap-format formats/instance))]
    (fn [request]
      ;; there is another case omitted here that determines whether to return wrapped, justifying the let
      wrapped)))

And then the middleware is applied in my reitit routes:

(defn admin-routes
  "Admin routes"
  []
  [""
   {:middleware [middleware/wrap-base
                 middleware/wrap-formats ;; <--- HERE
]}
   ["/admin"
    ["/sources" {:get request-get-sources}]
    ["/source" { ;:get request-get-source
                :post request-add-source
                :delete request-delete-source
                                        ;:patch
                }]
    ["/material" {:get request-get-material}]
    ["/dashboard"
     ["/new-material" {:get request-new-material}]]
    ["/source-tags" {:get request-source-tags}]]])

The Front-end: Clojurescript

Transit just worked as labeled on the back-end, and I plugged it into my framework. The front-end, however, is always complicated by nature as we negotiate browsers, Single Page Applications, and AJAX asychnronicity. In this case our routing isn’t part of the handling, so I won’t show you any of our front-end reitit. However, we utilize the esteemed React.js wrapper re-frame for our SPAs, which means using its re-frame-http-fx add-on for AJAX calls to our back-end. This is where our transit handlers live. I won’t elaborate here on the complexity I went through as I found the solution for adding custom transit handlers to http-fx; I’ll just present you with my solution in hopes you don’t have to go through the same treacherous journey. Note that just using out-of-the-box Transit is automatically supported with re-frame-http-fx; it was the adding a handler for Instant that was tough.

First, my CLJS formats file. It produces a map with all the substance in a Transit-style map under the :handlers key. This is now front-end javascript land, so we are matching types with whatever Strings we have the back-end marking them in with Transit (remember we could have used “jiffy” above?). I’m extending the Luminus time handlers, which provide a CLJC solution for many Transit time handlers but omit Instants. We utilize Transit to create a read-handler and specify which function that handler should wrap.

(ns my.formats
  (:require [tick.alpha.api :as t]
            [luminus-transit.time :as time]
            [cognitect.transit :as transit]))

(def instant-deserialization-handlers
  (assoc-in time/time-deserialization-handlers
            [:handlers "Instant"] (transit/read-handler t/parse)))
;; => {:handlers {"LocalTime" #object[Function]
;; "LocalDate" #object[Function]
;; "LocalDateTime" #object[Function]
;; "ZonedDateTime" #object[Function]
;; "Instant" #object[tick$core$parse]}}

Here is my http-xhrio call to do AJAX with re-frame (a re-frame “event”). We extend the cljs-ajax transit-response-format, which takes as an arg a map with a :handlers key like the one we created above:

(rfc/reg-event-fx
 :handler-with-http
 (fn handler-with-http [db [_ {:keys [uri method params on-success-kvec]
                               :or {method :get
                                    on-success-kvec [:on-success]}}]]
   {:http-xhrio {:method method
                 :uri             uri
                 :params          params
                 :timeout         8000
                 :format (ajax/transit-request-format)
                 :response-format (ajax/transit-response-format
                                   my.formats/instant-deserialization-handlers)
                 :on-success      on-success-kvec
                 :on-failure      [:on-failure]}}))

Results

At the end of the day the data that proceeds to my front-end comes in like this:

({:material-id #uuid "bd1c58d3-aa33-42cb-8a78-c06128955c83"
  :material-title "Halley's Comet is bringing a meteor shower to light up the night sky on Cinco de Mayo"
  :publication-date #time/instant2020-05-02T06:00:00Z
  :source-name "CBS News"
  :source-id #uuid "c9ca77f4-2506-487b-b738-4493768dfe88"}
 {:material-id #uuid "c5c79fde-6498-4d77-8683-3a32e54f13c2"
  :material-title "Astronomers discover the closest black hole to Earth — and you can see it with the naked eye"
  :publication-date #time/instant2020-05-06T06:00:00Z
  :source-name "CBS News"
  :source-id #uuid "c9ca77f4-2506-487b-b738-4493768dfe88"} 
 {:material-id #uuid "86a69107-eaf1-49d0-996b-0ea1137677a8"
  :material-title "Tom Cruise to shoot next movie at International Space Station"
  :publication-date #time/instant2020-05-06T06:00:00Z
  :source-name "CBS News"
  :source-id #uuid "c9ca77f4-2506-487b-b738-4493768dfe88"} 
 {:material-id #uuid "5e7891d2-5cde-4060-8365-bc920499d323"
  :material-title "SpaceX and United Launch Alliance prepare back-to-back launches this weekend"
  :publication-date #time/instant2020-05-15T06:00:00Z
  :source-name "CBS News"
  :source-id #uuid "c9ca77f4-2506-487b-b738-4493768dfe88"} 
 {:material-id #uuid "f7902469-04c9-4c26-a9b0-e880f73828ff"
  :material-title "Astronomers detect \"beating hearts\" of mysterious pulsating stars for the first time"
  :publication-date #time/instant2020-05-15T06:00:00Z
  :source-name "CBS News"
  :source-id #uuid "c9ca77f4-2506-487b-b738-4493768dfe88"})

Note that Transit has brought in my UUIDs as UUIDs, and my publication-dates as Tick instants. Mission success!

Resources

Tory Anderson avatar
Tory Anderson
Web App Engineer, Digital Humanist, Researcher, Computer Psychologist