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:
[: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