Gift list dev diary: initial backend

All development for this project is shared on github at https://github.com/codonnell/mygiftlist-blog. I will endeavor to focus on the most relevant snippets of code rather than go over all of them on this blog. For interested parties, the github repo is available to survey all of the minor details.

In this post we’ll implement a clojure web server that serves our static assets and exposes an API endpoint. The API endpoint accepts EQL requests and responds with a server-side pathom parser’s resolution of that EQL query. Our pathom parser will have a couple of example resolvers so we can test the API endpoint. Much of the code we introduce comes from the fulcro template.

This implementation requires several more technology choices. We’ll use http-kit for our server, mount for component lifecycle management, and aero for configuration. I won’t go into the pros and cons of any of these; I chose them for this project because they do what I need them to and I am familiar with them.

Serve static assets

For convenience, we’ll use our http-kit server to serve static assets for the app. The most important detail is how we handle requests for URIs for which the server does not have a file. An example of such a request is http://localhost:3000/home. This request could come about if a user navigated to our homepage route and refreshed the page. In this case, we need to make sure our index.html page is served to the user. The same needs to be true for http://localhost:3000/about and any other valid route url. The simplest solution to this problem is to serve the index.html page in place of any missing asset, and that is what we’ll do. A more sophisticated solution might use a routing library like bidi or reitit that supports sharing routing logic between clojure and clojurescript.

The changes for the rest of this post can be found in this commit

(ns rocks.mygiftlist.server
  (:require [mount.core :refer [defstate]]
            [org.httpkit.server :as http-kit]
            [rocks.mygiftlist.config :as config]
            [ring.util.response :as resp]
            [ring.middleware.defaults :refer [wrap-defaults
                                              site-defaults]]
            [ring.middleware.gzip :as gzip]))

(defn- not-found-handler [_]
  (assoc-in (resp/resource-response "public/index.html")
    [:headers "Content-Type"] "text/html"))

(def handler
  (-> not-found-handler
    (wrap-defaults (assoc-in site-defaults
                     [:security :anti-forgery] false))
    gzip/wrap-gzip))

(defstate http-server
  :start
  (http-kit/run-server handler {:port config/port})
  :stop (http-server))

We’re not going to use session authentication, so it’s fine to disable anti-forgery detection.

At this point, we’re able to start the server by navigating to the rocks.mygiftlist.server namespace and evaluating (mount.core/start).

API endpoint

The next thing we’ll do is add an endpoint that accepts EQL queries. Fulcro has some convenience middleware built for exactly this use case that we’ll take advantage of.

;; These requires are added
[rocks.mygiftlist.parser :as parser]
[com.fulcrologic.fulcro.server.api-middleware
  :refer [handle-api-request
          wrap-transit-params
          wrap-transit-response]]

(defn- wrap-api [handler uri]
  (fn [request]
    (if (= uri (:uri request))
      {:status 200
       :body (parser/parser {:ring/request request}
               (:transit-params request))
       :headers {"Content-Type" "application/transit+json"}}
      (handler request))))

(def handler
  (-> not-found-handler
    (wrap-api "/api")
    wrap-transit-params
    wrap-transit-response
    (wrap-defaults (assoc-in site-defaults
                     [:security :anti-forgery] false))
    gzip/wrap-gzip))

The wrap-api middleware returns a response with the result of calling the parser for requests to the /api url, adding the ring request as the parser’s env. We’re completely omitting error handling for the moment; we’ll come back to that in a later post.

The parser

Most of the code for our parser comes from the fulcro template. We’re using a normal parallel parser, wrapped in a call to core.async’s <!! to make the result synchronous. We conditionally add pathom trace information when the "trace" property is set in our JVM. Lastly, we add a custom preprocess parser plugin that’s used to log the transactions before our parser resolves them.

(ns rocks.mygiftlist.parser
  (:require
   [taoensso.timbre :as log]
   [mount.core :refer [defstate]]
   [com.wsscode.pathom.connect :as pc :refer [defresolver]]
   [com.wsscode.pathom.core :as p]
   [clojure.core.async :refer [<!!]]
   [rocks.mygiftlist.type.user :as user]
   [rocks.mygiftlist.model.user :as m.user]))

(defresolver index-explorer [env _]
  {::pc/input  #{:com.wsscode.pathom.viz.index-explorer/id}
   ::pc/output [:com.wsscode.pathom.viz.index-explorer/index]}
  {:com.wsscode.pathom.viz.index-explorer/index
   (-> (get env ::pc/indexes)
     (update ::pc/index-resolvers
       #(into {} (map (fn [[k v]] [k (dissoc v ::pc/resolve)])) %))
     (update ::pc/index-mutations
       #(into {} (map (fn [[k v]] [k (dissoc v ::pc/mutate)])) %)))})

(def all-resolvers [index-explorer
                    m.user/user-resolvers])

(defn preprocess-parser-plugin
  "Helper to create a plugin that can view/modify the env/tx of a
  top-level request.
  f - (fn [{:keys [env tx]}] {:env new-env :tx new-tx})
  If the function returns no env or tx, then the parser will not be
  called (aborts the parse)"
  [f]
  {::p/wrap-parser
   (fn transform-parser-out-plugin-external [parser]
     (fn transform-parser-out-plugin-internal [env tx]
       (let [{:keys [env tx] :as req} (f {:env env :tx tx})]
         (if (and (map? env) (seq tx))
           (parser env tx)
           {}))))})

(defn log-requests [{:keys [env tx] :as req}]
  (log/debug "Pathom transaction:" (pr-str tx))
  req)

(defstate parser
  :start
  (let [real-parser
        (p/parallel-parser
          {::p/mutate  pc/mutate-async
           ::p/env     {::p/reader               [p/map-reader
                                                  pc/parallel-reader
                                                  pc/open-ident-reader
                                                  p/env-placeholder-reader]
                        ::p/placeholder-prefixes #{">"}}
           ::p/plugins [(pc/connect-plugin {::pc/register all-resolvers})
                        (p/env-wrap-plugin
                          (fn [env]
                            ;; Here is where you can dynamically add
                            ;; things to the resolver/mutation
                            ;; environment, like the server config,
                            ;; database connections, etc.
                            env))
                        (preprocess-parser-plugin log-requests)
                        p/error-handler-plugin
                        p/request-cache-plugin
                        (p/post-process-parser-plugin p/elide-not-found)
                        p/trace-plugin]})
        ;; NOTE: Add -Dtrace to the server JVM to enable Fulcro
        ;; Inspect query performance traces to the network tab.
        ;; Understand that this makes the network responses much
        ;; larger and should not be used in production.
        trace? (some? (System/getProperty "trace"))]
    (fn wrapped-parser [env tx]
      (<!! (real-parser env (if trace?
                              (conj tx :com.wsscode.pathom/trace)
                              tx))))))

Another thing to note is the presence of m.user/all-resolvers in the vector of resolvers we register with pathom connect. This is a vector of example resolvers we’re adding for testing purposes.

We’re organizing our namespaces in a manner suggested in the fantastic fulcro introductory videos. We put our resolver and mutation definitions in the model directory and our domain attribute definitions in the type directory. Since resolvers and mutations serve different purposes in the frontend and backend, I prefer to write them in separate clj and cljs files. However, the domain attributes are a language shared between the frontend and backend, so I prefer to author them in cljc files.

With that code organization diversion out of the way, let’s take a quick look at the example resolvers.

(ns rocks.mygiftlist.model.user
  (:require
   [com.wsscode.pathom.connect :as pc :refer [defresolver defmutation]]
   [rocks.mygiftlist.type.user :as user]))

(defonce users (atom {}))

(defresolver user-by-id [env {::user/keys [id]}]
  {::pc/input #{::user/id}
   ::pc/output [::user/email]}
  (get @users id))

(defmutation insert-user [env {::user/keys [id] :as user}]
  {::pc/params #{::user/id ::user/email}
   ::pc/output [::user/id]}
  (swap! users assoc id user))

(def user-resolvers
  [user-by-id
   insert-user])

That’s it! One resolver that lets us add a user to an atom and another that allows us to fetch user data by id. We can test these at the repl. I usually keep a comment block at the bottom of my parser’s namespace with a number of test queries that I can evaluate from my editor (spacemacs). Here I’ll show a short clojure REPL session as if we were typing everything in manually, eliding results where they aren’t relevant.

user> (start)
user> (in-ns 'rocks.mygiftlist.parser)

rocks.mygiftlist.parser> (parser {} `[(insert-user {::user/id 1 ::user/email "me@example.com"})])
{rocks.mygiftlist.model.user/insert-user
 {1 #:rocks.mygiftlist.type.user{:id 1, :email "me@example.com"}}}

rocks.mygiftlist.parser> (parser {} [{[::user/id 1] 
                                      [::user/id ::user/email]}])
{[:rocks.mygiftlist.type.user/id 1]
 #:rocks.mygiftlist.type.user{:id 1, :email "me@example.com"}}

We should also be able to load up our app in the browser at http://localhost:3000, put the same query into fulcro-inspect’s query tab, and see the same results.

fulcro-inspect-user-query

And that concludes our backend implementation! We’re able to serve static assets from our clojure server, and we have an API endpoint to which we can submit EQL queries.

Prev: Gift list dev diary: routing Next: Gift list dev diary: backend persistence