some tidbits from my ongoing Yakread work. Somewhat long so I'll put them in a thread. Guess I could've written another blog post...
I was experimenting with ways to make it more ergonomic to work with routes, i.e. defining them and referencing them. I've settled on some things that feel pretty good so far. example of the old way:
(def do-something
["/do/something/:id"
{:name :
:post (lib.pipe/make
:start (fn ...)
...)}])
(def page-route
["/some/page"
{:name :app.some/page
:get (lib.pathom/handler
[:session/user ...]
(fn [{:keys [biff/router] :as ctx} {:session/keys [user] ...}]
...
[:button
{:hx-post (lib.route/path router : {:id "abc123"})}
...]))}])
First, each route gets a :name value which I then use as a reference from other routes, via (lib.route/path router <name> <path params>). That was starting to feel slightly tedious, and my :name values felt like something that should be inferred since they always mirrored the current namespace (e.g. the :name for com.yakread.app.subscriptions/page would be :app.subscriptions/page). It was also slightly annoying to always have to get (:biff/router ctx) whenever referencing a route.
I switched to using a new href function which takes the route itself, e.g. (lib.route/path router : {:id #uuid "..."}) becomes (lib.route/href do-something {:id #uuid "..."}). If you pass a symbol to href , it'll resolve the symbol. S for two routes in the same file that reference each other (e.g. a page that posts to a form which redirects back to the page), one of the routes can do (lib.route/href do-something ...)` . For referencing routes in other files, I have a https://github.com/jacobobryant/yakread/commit/465a7ac3b1d7cfe5971d9a5fc37b9633883414f4#diff-1644abf89a78ad160f5898dca4f1f62816e3a96ba96b0774483422b21046c5a9 file which I require throughout the app.
The next couple pieces are that I added https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/route.clj#L56 and https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/route.clj#L63 macros. defpost is the more interesting one because it generates the route path for you. e.g. if you've got a namespace with a (defpost do-something ...) in it, that'll get expanded to (def do-something ["/_biff/api/com.example.app/do-something" {:post ...}]).
Also, since my post/mutation handlers always use (lib.pipe/make ...) (i.e. they use the state machine approach described https://biffweb.com/p/structuring-large-codebases/) and my get handlers always use (lib.pathom/handler ...) (i.e. they use pathom to query for all their input data, also described in that post), defpost and defget build those in. https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/app/subscriptions/add.clj from yakread:
(defpost add-rss
:start
(fn [{{:keys [url]} :params}]
...)
:add-urls
(fn [{:keys [biff/db session biff.pipe.http/output]}]
(let [...]
(if (empty? feed-urls)
;; redirect is like href but it wraps the URL in {:status 303 :headers ...}
(redirect `page-route {:error "invalid-rss-feed" :url (:url output)})
...))))
(defget page-route "/dev/subscriptions/add"
[:app.shell/app-shell
{(? :user/current) [:xt/id
(? :user/email-username)
(? :user/suggested-email-username)]}]
(fn [{:keys [params] :as ctx}
{:keys [app.shell/app-shell] user :user/current}]
...
(biff/form
{:action (href add-rss)}
(ui/form-input ...))
...))
(def module
{:routes [page-route
["" {:middleware [lib.middle/wrap-signed-in]}
add-rss
...]]})
Also, href and defpost/`defget` do serialization/deserialization of the params so that e.g. if you pass a UUID in the path or query params, it'll get parsed back into a UUID for you.
The final bit: a lot of my defposts ended up having the form "(1) query some stuff with pathom, (2) run an xtdb transaction and return". So for this particular case I added a https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/route.clj#L72 macro which further compresses the code. e.g.:
;; With just defpost
(defpost do-something
; expands to `:start (fn [_] {:biff.pipe/next [:biff.pipe/pathom :end],
; :biff.pipe.pathom/query [:foo :bar]})`
:start (lib.pipe/pathom-query [:foo :bar] :end)
:end (fn [{{:keys [foo bar]} :biff.pipe.pathom/output :as ctx}]
...))
;; Same as the above
(defpost-pathom do-something
[:foo :bar]
(fn [ctx {:keys [foo bar]}]
...))
---
Besides the routing stuff, I also started being more organized about how I do https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/ui.clj. Each component takes an options map as the first param with two kinds of keys: (1) un-namespaced keys, which end up on the actual dom element, (2) :ui/... keys, which are options used by the custom component code. the :ui/... options are generally used to select which tailwind classes to should be applied, via a https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/ui.clj#L13 helper function. The https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/ui.clj#L61 is a good example. Maybe at some point I'll code up a bunch of default components like these, the idea being that you copy them all into a file in your project and then can make whatever styling changes you want.Some files to peruse for more examples of the above: • https://github.com/jacobobryant/yakread/blob/master/src/com/yakread/app/subscriptions.clj • https://github.com/jacobobryant/yakread/blob/master/src/com/yakread/app/subscriptions/add.clj • https://github.com/jacobobryant/yakread/blob/master/src/com/yakread/app/subscriptions/view.clj
In the actual feature work department, last week I wrote a https://github.com/jacobobryant/yakread/commit/2a726accf101522d30168d233390c56dcfb27a8e#diff-cb7696bf3ffe500266b7d659d588ecbad8bf513503712ace7357601ca50ec8c4 for receiving emails. Previously I've done that in a bespoke way, taking the https://docs.oracle.com/javaee/6/api/javax/mail/internet/MimeMessage.html and pulling out the bits I needed into a data structure in whatever format the app (Yakread / The Sample) needed. This time, I made the component turn the MimeMessage into a https://github.com/jacobobryant/yakread/blob/26747d0b2a2bed4acb3fc73ec9161dd86a972f89/src/com/yakread/lib/smtp.clj#L97 with everything in it, and then you pass https://github.com/jacobobryant/yakread/blob/39d444193aebb49ee9a23306a83f7954fda83719/src/com/yakread/smtp.clj#L38 to the component that's used to do your app-specific stuff.
There are also a few helper functions for various things, e.g. to test emails in local dev, you can download the raw text of an email from your actual email client and then give the path to https://github.com/jacobobryant/yakread/blob/39d444193aebb49ee9a23306a83f7954fda83719/src/com/yakread/lib/smtp.clj#L40 that will send it to localhost:2525.
And from the random shower thoughts department, I was thinking the other day it might be really fun as a side project sometime to try making a PaaS. e.g. it could have cheap managed kafka (via https://www.redpanda.com/ or similar) for xtdb, and maybe various other things built in like an email service and a captcha service so you don't have to set up three different accounts to deploy an app. Mainly would just be doing this for fun/learning; wouldn't plan on ever having someone deploy anything serious to it. Will get to it right after I write the "clojure web dev from scratch" book and the other 10 things I have on my biff todo list already.