_MG_3797.jpg

Notes

Choosing Ruby or Clojure for a Toy Project

I have a project in mind that I want to build for the web. I know some Ruby and I know some Clojure. I'm not good at any of them. Which should I use?

I decided to make a simple script in both languages that queries Amazon's Product API. It does a search in the Books section of Amazon and returns the results. More technically it GETs from a URL that has a cryptographically signed set of queries: build the query, sign the query, add the signature to the query, then get the results. There are libraries in both languages to do this in a single line, but I wanted to do it with the as few third-party libraries as possible.

Intellectually I love Clojure. When it works it's beautiful. Clojure is more technically impressive. You can do incredible things with it. Plus there is a whole world of truly arcane and magical things you can get into with it and its Java interface, like Graal's Polyglot engine, where you can run R, Ruby, Python, and JavaScript all together. In theory. It doesn't really work with the most useful libraries in those languages.

But I'm faster at making things in Ruby. Ruby is more fun. There's a package for whatever you need. The documentation is wonderful. Ruby is an acceptable LISP. Then again, LISP is not an acceptable LISP.

If it were free and open source, Mathematica would probably be my only language. But if a frog had wings he wouldn't bump his ass a-hoppin'. It's not and never will be.

Ruby

Pretty straightforward. Everything I needed came with the language. The only thing I struggled with was figuring out URI objects, and how modifying its parse attribute modified the whole thing just fine. That, and I spent about an hour trying to figure out why my signing method didn't seem to work, when it boiled down to Amazon wanting spaces to be encoded as %20 instead of +.

Clojure

You can't really just start a Clojure project with an empty text editor. You have to create an empty Leiningen project. Which is fairly straightforward when you've done it a few times.

Clojure documentation is a circle-jerk of cleverness. Simple ain't easy, I know. Try explaining the following to someone learning functional programming for the first time. Clojure requires a quiet, focused mind, not a mind that just wants to get things done.

(assoc map key val)(assoc map key val & kvs) assoc(iate). When applied to a map, returns a new map of the same (hashed/sorted) type, that contains the mapping of key(s) to val(s). When applied to a vector, returns a new vector that contains val at index. Note - index must be <= data-preserve-html-node="true" (count vector).

In the REPL you can't just poke at an object by invoking it. It gets pissed. You have to print it, or do something to it, or not wrap it in parens.

(az-key)
ClassCastException java.lang.String cannot be cast to clojure.lang.IFn  amazon-api-test.core/eval3503 (form-init4298443338166278126.clj:1)

Infuriating.

Don't get me started on invoking methods on Java class instances. I spent more time in the Java docs than anyone would ever want to in 2018 just to figure out how to do some stuff without a library. I did everything in Ruby without a third-party library. You can do a bunch of stuff just with Java, but it took me longer to write my own method for generating an ISO8601 timestamp, using mostly Java's built-in libraries, than it did to literally write and debug the entire Ruby app. And almost none of this is because of Clojure directly, though I did struggle a bit with the Java method syntax. But discounting that, there's no HMac+SHA256 encoder. There's nothing of the sort. Even the Java example from Amazon requires a package from Amazon to do the signing. Which is not the point of this exercise. The conventional wisdom is to even disregard the god damn Java DateTime stuff and just use another library, which is a wrapper around a giant third-party Java date library.

The final count is 61 lines for Clojure and 39 for Ruby. This is an irrelevant metric; they're implemented differently, and assignment works differently in LISP. The Clojure code contains a DIY ISO8601 timestamp generator and a Hmisc+SHA256 encryption function, because the one in the normal cryptographic package buddy is broken or missing or whatever and wouldn't convert the hash to Base64, plus I was trying to reduce third-party dependencies.

All in all, Ruby won, I think. I ran at a steady clip the entire time had momentum, had low blood pressure, and was done in less than an hour excluding that damn encoding issue, where Amazon expects spaces to be encoded as %20 instead of +. Clojure's lack of helpers, and the obscene documentation required to get them from Java all contributed to my raised blood pressure. That's no way to work. I get why Matz and DHH talk about programmer happiness. Who knows if I'll ever actually publish the thing I have in mind, but this was still very interesting.

  • Ruby: 14:13 to 16:02.
  • Clojure: 16:14 to 19:29.

Post Script: The Front End

But, you might be saying: what about ClojureScript? In Clojure you can write the frontend and the backend in the same language! If you use Ruby (and Rails or Sinatra) you have to write JavaScript!

JavaScript is basically pseudocode at this point, every framework does it fundamentally differently from every other framework, and it's all a wild nonsense farce. I maintain an iOS app written in JavaScript. Depending on the tooling and the libraries you want to use, you can do whatever you want and write however you want. The same goes for ClojureScript: do I want to use reagent, om, om.next, fulcro, re-frame, or do my own lightweight thing with sprinkles of vanilla JavaScript here and there with shadow-cljs? They all work differently and might as well be fundamentally different languages. Go read some Reagent; seems straightforward, right? And Hiccup is better than HTML! Now go read some Om. Do you understand what's going on? Me neither. If I go with Rails I'll probably use Stimulus. I'm not a fan of SPAs.

The Ruby Implementation

require 'uri'
require 'net/http'
require 'Time' # for timestamp
require 'openssl' # for the signature
require 'Base64'

uri = URI.parse('https://webservices.amazon.com/onca/xml')

az_key = 'XXXXXXX'
az_secret = 'XXXXXXX'

query = 'godel escher bach'

# We make our parameters up front then have to make the timestamp & signature
params = { AWSAccessKeyId: az_key,
           AssociateTag: 'blatchdotnet-20',
           Keywords: query,
           Operation: 'ItemSearch',
           SearchIndex: 'Books',
           Service: 'AWSECommerceService',
           # SignatureMethod: 'HmacSHA256',
           # SignatureVersion: '2',
           Timestamp: Time.now.utc.iso8601,
           Version: '2013-08-01' }

# add params to uri
#  this last part took an hour to sub + w/ %20
uri.query = URI.encode_www_form(params).gsub('+', '%20')

# calculate signature from canonical uri string
encoded_string = "GET\nwebservices.amazon.com\n/onca/xml\n" + uri.query

sig = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest
      .new('sha256'), az_secret, encoded_string)).strip # strip end newline
params[:Signature] = sig

# again
uri.query = URI.encode_www_form(params).gsub('+', '%20')
puts Net::HTTP.get(uri)

The Clojure Implementation

project.clj:

(defproject amazon-api-test "0.1.0-SNAPSHOT"
  :description "Tests Amazon's Product API access through Clojure."
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [clj-http "3.7.0"]]
  :main ^:skip-aot amazon-api-test.core
  :target-path "target/%s"
  :profiles {:uberjar {:aot :all}})

core.clj:

(ns amazon-api-test.core
  (:require [clj-http.client :as client])
  (:import
    (java.net URLEncoder)
    javax.crypto.Mac
    javax.crypto.spec.SecretKeySpec
    org.apache.commons.codec.binary.Base64))

(def az-key "XXXXXXX")
(def az-secret "XXXXXXX")
(def search-query "godel escher bach")

(defn hmac
  "Generates a Base64 HMAC with the supplied key on a string of data.
  from https://stackoverflow.com/questions/31729163/clojure-or-java-equivalent-to-rubys-hmac-hexdigest"
  [^String data]
  (let [algo "HmacSHA256"
        signing-key (SecretKeySpec. (.getBytes az-secret) algo)
        mac (doto (Mac/getInstance algo) (.init signing-key))]
    (str ""
         (String. (Base64/encodeBase64 (.doFinal mac (.getBytes data)))
                  "UTF-8"))))

(defn maketimestamp
  "makes iso8601 timestamp manually

   (works pre java 8)
   from https://stackoverflow.com/questions/3914404/how-to-get-current-moment-in-iso-8601-format-with-date-hour-and-minute"
  []
  (let [tz (java.util.TimeZone/getTimeZone "UTC")
        df (new java.text.SimpleDateFormat "yyyy-MM-dd'T'HH:mm:ss'Z'")]
    (.setTimeZone df tz)
    (.format df (new java.util.Date))))


(defn get-ap
  "gets it"
  []
  (let [query-params (sorted-map
                       :AWSAccessKeyId az-key
                       :AssociateTag "blatchdotnet-20"
                       :Keywords search-query
                       :Operation "ItemSearch"
                       :SearchIndex "Books"
                       :Timestamp (maketimestamp)
                       :Version "2013-08-01")
        canon-encoding (->
                         (str "GET\nwebservices.amazon.com\n/onca/xml\n"
                              (client/generate-query-string query-params))
                         (clojure.string/replace "+" "%20"))
        signature (hmac canon-encoding)
        signed-params (assoc query-params :Signature signature)]
    (println
      (client/get
        "https://webservices.amazon.com/onca/xml"
        {:query-params signed-params}))))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (get-ap))