Fancy form posts in Biff

One of Biff's core features is authorization rules (patterned after Firebase Security Rules) which allow you to send transactions to the database[1] without going through a custom endpoint. For each type of document in your database, you define rules which specify the shape of that document and who is allowed to write to it. Then you can submit arbitrary transactions from the front end, and Biff will make sure they satisfy the rules.

Right now, Biff only includes this feature in its SPA project template. You write ClojureScript code which sends transactions as EDN over a websocket. However, since October, Findka is no longer a SPA. I pivoted and built a new, simpler website from scratch, and a SPA would've been overkill. So now I'm using regular form posts for write operations, not websocket events. Despite that, it would still be nice to use authorization rules and a single endpoint to handle the posts instead of having a proliferation of endpoints.

For reference, here's how you would normally send a Biff transaction from the front end:

(defn set-display-name [user-id display-name]
  (send-websocket-event
    [:biff/tx
     {[:users {:user/id user-id}]
      {:db/update true
       :display-name display-name}}]))

The ident, [:users {:user/id user-id}], means that we're writing a document of type :users and the document ID is {:user/id user-id}. We set the :display-name key on that document, and :db/update true means we merge that into the existing document instead of overwriting it completely. See the docs for more info.

Even though Biff doesn't come with an endpoint for receiving a transaction like this via form post, it's pretty easy to set one up:

(defn form-data->tx [params] ...)

(defn form-data->redirect [params] ...)

(defn submit-tx
  [{:keys [biff/node biff/base-url params]
    :as sys}]
  (let [biff-tx (form-data->tx params)
        crux-tx (biff.crux/authorize-tx
                  (assoc sys :tx biff-tx))]
    (if (biff.util/anomaly? crux-tx)
      (pprint [:transaction-rejected tx])
      (crux/await-tx node
        (crux/submit-tx node crux-tx))))
  {:status 302
   :headers/Location
   (str base-url (form-data->redirect params))})

(def routes
  [["/api/tx" {:middleware [wrap-anti-forgery]
               :post submit-tx
               :name ::submit-tx}]])

First, submit-tx takes the form parameters and converts them to Biff's transaction format. biff.crux/authorize-tx checks if the transaction conforms to your authorization rules and returns a Crux transaction if so. After you submit that transaction, you'll need to redirect the user back to whatever page they were on.

form-data->tx is where things get messy, so let's get form-data->redirect out of the way first. Here's a form from Findka's main page:

A form with two select inputs.
[:form {:method "post"
        :action "/api/tx"}
 [:input {:type "hidden"
          :name "redirect"
          :value "home"}]
 ; ...
 ]

My form-data->redirect looks kind of like this:

(defn form-data->redirect [{:keys [redirect]}]
  (case redirect
    "home" "/home"
    ...))

(Be sure to read the relevant OWASP page). I also have some logic for setting query parameters, which are used to display messages like "URL submitted successfully", etc.

Before we go on to form-data->tx, let me show you the complete form:

(let [uid ...
      n-links ...
      n-days ...]
  [:form {:method "post"
          :action "/api/tx"}
   ; anti-forgery token goes here
   (for [[k v] {"redirect" "home"
                "docs[0][table]" "users"
                "docs[0][id]" (pr-str {:user/id uid})
                "docs[0][update]" "true"}]
     [:input {:type "hidden"
              :name k
              :value v}])
   [:div "We'll send you "
    [:select {:name "docs[0][n-links]"}
     (for [i (range 1 11)]
       [:option {:value i
                 :selected (when (= i n-links)
                             "selected")}
        i])]
    " links every "
    [:select {:name "docs[0][n-days]"}
     (for [i (range 1 8)]
       [:option {:value i
                 :selected (when (= i n-days)
                             "selected")}
        i])]
    " day(s)."]
   [:button {:type "submit"} "Update"]])

So we need to map this form data to Biff's transaction format. Again, a Biff transaction is a collection of documents (and their idents). This form submits a transaction for only one document, so we can store it under "docs[0]".

And now we come to the messy part: we must convert the form fields' values from strings to their respective data types. I specify a coercion function for each key. For example, "docs[0][table]" is coerced with keyword and "docs[0][id]" is coerced with clojure.edn/read-string. I have a k->coerce-fn map which I use for the rest of the fields. Whenever I add a new form, I make sure to add new coercion functions as needed.

(def k->coerce-fn
  {:n-links #(Long/parseLong %)
   :n-days #(Long/parseLong %)
   :user edn/read-string
   ;...
   })

See the appendix for the implementation of form-data->tx.

This solution is good enough for my purposes, but it doesn't feel great. Hard-coded fields (like hidden form fields or select fields) are fine, since you can always just set the value to an EDN string. Although I'm using Long/parseLong for :n-links and :n-days above, I guess I could use clojure.edn/read-string instead, for consistency's sake. But text fields need to be left as-is, and check boxes need special treatment:

:some-checkbox-key #(or (= % "on") (= (last %) "on"))

Maybe that's not so horrible. But it might be nice to add some metadata via hidden fields that specify the types of any non-EDN fields, so you wouldn't have to maintain a global k->coerce-fn map.

I'll probably add this into Biff at some point. I like to test out new features in Findka for a while first. Let me know if you've got any suggestions.




Appendix

(defn form-data->tx [{:keys [params/docs]}]
  (for [{:keys [table id] :as doc} (vals docs)]
    [(cond-> [(keyword table)]
       id (conj (edn/read-string id)))
     (when-not (:delete doc)
       (->> (dissoc doc :table :id)
         (map (fn [[k v]]
                [(case k
                   :update :db/update
                   :merge :db/merge
                   k)
                 ((get k->coerce-fn k identity) v)]))
         (into {})))]))



Notes

[1] They also let you query the database from the front end, but that's not relevant to this post.

Published 21 Jan 2021

I write an occasional newsletter
about my work and ideas.

RSS feed · Archive

𝔗𝔥𝔦𝔰 𝔰𝔦𝔱𝔢 𝔦𝔰 𝔭𝔯𝔬𝔱𝔢𝔠𝔱𝔢𝔡 𝔟𝔶 𝔯𝔢𝔠𝔞𝔭𝔱𝔠𝔥𝔞 𝔞𝔫𝔡 𝔱𝔥𝔢 𝔊𝔬𝔬𝔤𝔩𝔢 𝔓𝔯𝔦𝔳𝔞𝔠𝔶 𝔓𝔬𝔩𝔦𝔠𝔶 𝔞𝔫𝔡 𝔗𝔢𝔯𝔪𝔰 𝔬𝔣 𝔖𝔢𝔯𝔳𝔦𝔠𝔢 𝔞𝔭𝔭𝔩𝔶.