Idiomatic integration

Talk pretty with external systems

Idiomatic integration

April 25, 2016
Oliver Hine

Camels, snakes and kebabs

Clojure's idiom of map keys being keywords where parts are separated by hyphens (so called "kebab-case", e.g. :book-title or :author-name) quickly starts to look normal even when coming from the wider world of camel casing (bookTitle) or snake casing (book_title) as used widely in other languages and data formats.

In fact, to my eyes at least, anything that is not kebab-cased starts to look a bit ugly. You'll see it with Java interop and you'll see it when consuming data from other systems via JSON data formats and the like.

(let [author-profile (:body (http/get ""
                                      {:as :json}))]
  {:author-name (:authorName some-data)
   :book-title (->> some-data
                    (filter #(= "scienceFiction" (:bookCategory %)))

Not beautiful, is it? Sometimes for a tiny integration like that we won't be too offended, but when you interact with another system in many different ways and transfer much larger data structures than that, it will quickly bloat your code with nothing more than workarounds for your preferred code style, obscuring the intent.

The camel-snake-kebab library is one of my favourites. It provides conversion functions from any style to any other style, returning keywords or strings as you prefer. I encourage you to have a look if you're not already familiar with it.

Walk all the keys!

One tempting way to convert your un-idiomatic data into lovely kebabs is by using clojure.walk/keywordize-keys, modified a bit to use camel-snake-kebab as follows:

(defn kebab-keys
  "Recursively transforms all map keys into kebab-cased keywords."
  (let [f (fn [[k v]] (if (string? k) [(->kebab-case-keyword k) v] [k v]))]
    ;; only apply to maps
    (postwalk (fn [x] (if (map? x) (into {} (map f x)) x)) m)))

Call it like (kebab-keys author-profile). Satisfyingly complicated, and does the job. Great!

Make it work, make it fast

While this works, the performance is actually pretty bad. There are three reasons why this might be the case: firstly, postwalk is generally expensive as it will create a lot of interim data structures. Secondly postwalk is expensive because it is needless - our JSON parser has already walked the data structure while marshalling it, and now we're doing it again. Finally, if your data structure contains sequences of maps with the same keys then ->kebab-case-keyword is performing the same expensive name/regex/format/keyword operations for the same keys over and over again.

The widely-adopted clj-http library, or clojure.jdbc as mentioned in a previous post allow you to provide coercing functions which are used within marshalling. This immediately eliminates the need for postwalk.

Participating in clj-http's coercion multimethod is easy:

(defmethod clj-http.client/coerce-response-body :json-kebab-keys [req resp]
  (clj-http.client/coerce-json-body req resp ->kebab-case-keyword false))

And lets us rewrite the example above using kebab keys:

(let [author-profile (:body (http/get ""
                                      {:as :json-kebab-keys}))]
  {:author-name (:author-name some-data)
   :book-title (->> some-data
                    (filter #(= "scienceFiction" (:book-category %)))

Finally, you can memoize the coercing function when you know a lot of the keys will be identical - in the example here, imagine the author has written hundreds of books, all of which have the same keys.

(defmethod clj-http.client/coerce-response-body :json-kebab-keys [req resp]
  (clj-http.client/coerce-json-body req resp (memoize ->kebab-case-keyword) false))

Job done. For large data structures, this will be much, much faster than the postwalk solution above.

Works both ways

Need to talk back to this external system? Convert back at the last moment:

(http/post ""
           {:body (json/encode new-author {:key-fn (memoize ->camelCaseString)})})

You never even have to see camels or snakes again!