Increasingly Functional.
by Joshua Miller | on Twitter | on the web | on github

Serverless Web Applications on AWS Lambda with ClojureScript Part II

June 30th 2017

Tagged: clojure clojurescript

One of the common synonyms for "serverless" is "Function as a Service": there is a place in the cloud that we upload a function to, and it runs when some event is triggered. Web services are easily modeled as functions in Clojure/Script, so that's how we're going to set ours up.

Yesterday in part one we set up our ClojureScript environment to prepare for writing an AWS Lambda function that will use the Node.js runtime. Today, we need to do two things: write our JWT translation functions and route requests to them.

The JWT functions themselves are simple. In src/jwtify/jwt.cljs:

(ns jwtify.jwt
  (:require [cljs.core.async :refer [chan put!]]))

(def jwt (js/require "jsonwebtoken"))

(defn sign
  [{:keys [payload secret]}]
  (let [ch   (chan)
        cb   (fn [err token]
               (if token
                 (put! ch {:status 200
                           :body {:token token}})
                 (put! ch {:status 422
                           :body (js->clj err)})))
        opts #js {}]
    (.sign jwt (clj->js payload) secret opts cb)
    ch))

(defn unsign
  [{:keys [token secret]}]
  (let [ch   (chan)
        cb   (fn [err payload]
               (if payload
                 (put! ch {:status 200
                           :body (js->clj payload)})
                 (put! ch {:status 422
                           :body (js->clj err)})))
        opts #js {}]
    (.verify jwt token secret opts cb)
    ch))

Each function creates a core.async channel and uses jsonwebtoken's callback functions to put its value there. We'll be passing those channels back to the gateway function we defined in part one. Note that in this case jsonwebtoken does have synchronous versions of these functions, but I used the async versions since almost all the real work you end up doing will require this pattern.

Now lets build our routes using bidi. bidi routes are simple data structures that can be passed to the match-route function and resolve to whatever value you give them; in our case, symbols for our JWT functions.

In core.cljs:

(def routes
  ["/"
    {"sign"   {:post jwt/sign}
     "unsign" {:post jwt/unsign}}])

You can test the routes out in the REPL:

(require '[jwtify.core :refer [routes]] '[bidi.bidi :refer [match-route]])
;; nil
cljs.user=> (match-route routes "/sign" :request-method :post)
;; {:handler #object[jwtify$jwt$sign "function jwtify$jwt$sign(payload,secret){...

Let's add a little JS/CLJS/JSON plumbing:

(defn jsonify
  [s]
  (->> (clj->js s)
       (.stringify js/JSON)))

(defn parse
  [s]
  (-> (.parse js/JSON s)
      (js->clj :keywordize-keys true)))

And now we can alter our gateway function to handle input, output, and routes:

(defgateway jwtify
  [{:keys [path method body] :as event} ctx]
  (go
    (let [{handler :handler} (match-route routes path :request-method method)
          params             (when body (parse body))
          result             (<! (handler params))]
      (-> result
          (update :body jsonify)
          (assoc-in [:headers :content-type] "application/json")))))

We're destructuring the event map cljs-lambda will be passing our gateway into its path, request method, and body. We match the route, pass the JSON-decoded body to the correct handler, take the result off the returned channel, and format it to go back out into the API Gateway world. Note that instead of returning a map literal now, our gateway function returns the core.async channel generated by (go); cljs-lambda knows when it gets a channel back to take a result off of it and return that to the gateway.

Let's test it out in the REPL:

(require '[jwtify.core :refer [jwtify jsonify]] '[cljs.core.async :refer [take!]] '[cljs-lambda.local :refer [channel]])
;; nil
(def body (jsonify {:payload {:hi "there"} :secret "secret"}))
;; #'cljs.user/body
(channel jwtify {:path "/sign" :method :post :body body})
;; #object[cljs.core.async.impl.channels.ManyToManyChannel]
(take! *1 println)
;; [:succeed {:body {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoaSI6InRoZXJlIiwiaWF0IjoxNDk4ODQwNTczfQ.3-AXQfjOO30q-OsF2Kg3myqbglX5FxbFwF-JJySPUls"}, :headers {:content-type application/json}, :statusCode 200}]

So we've got everything we need for our simple web service. In part three, we'll deploy it to AWS and hook it up to the API Gateway and the wider web.