Clojure, Babashka, and Web CGI
》Why Bother?
My house is filled with all kinds of weird projects. Even moreso my computers are filled with homegrown projects. I self-host a good few services. Many of the projects need data and sometimes that data needs a simple web interface.
Also, I like to make generative art. But more specifically I like taking sources of data and making them "artful." Some of that data has to be maintained, and image data is not always a great fit for the command line.
》How About An Example?
Ok, so, you want an example. There’s a big screen on my study wall that shows me all sorts of data about my life. Part of that project shows icons that represent devices that show up on my network. Instead of hand-picking every icon for each device, the code fetches them from the Noun Project.
Even though the algorithm usually picks a good word, some choices need to be overridden. I thought a web page to show and manage the available icons would be handy, but did not want to write some constantly-running web service just adds overhead on my hosts for something that would only get occasional use.
Since I was already serving web pages from that host, the idea was to try to connect Apache HTTPD’s CGI capability with Babashka.
》CGI?
The Common Gateway Interface
describes how a web server can fire off a sub-process and return the output
from said process. In typical unixy fashion, a bunch of environment variables
are set up to pass to the sub-process and data is piped via stdin
and stdout
.
This means you can write a response in just about any language. You could even
do it in bash
! Which is kind of a great way to play around and really
understand what’s going on with CGI.
Setting that up with Apache is pretty easy, too. This is how I enabled hanlding
for .clj
files in my public_html
directory for development:
file: /etc/apache2/conf-available/serve-clj.conf:
<Directory "/home/*/public_html">
Options +ExecCGI
AddHandler cgi-script .clj
</Directory>
Make sure the cgid_module
is enabled by running a2enmod cgid
and then enable
the config with a2enconf serve-clj
.
If that isn’t enough, consult the Apache docs.
》What Does The Script Look Like?
Since this is a Babashka-based project, perhaps the most important thing is to ensure
the shebang line points to your bb
.
Something like #!/bin/env bb
or #!/usr/local/bin/bb
depending on how you installed it.
Then it’s handy to have some functions that deal with how CGI passes info in the
environment and data from stdin
. The namespace below handles basic parsing of the
QUERY_STRING
variable as well as reading any data POSTed
to the page via
stdin
.
(ns common)
(defn query-params
"reads and parses query parameters from the URL
via CGI QUERY_STRING environment variable"
[]
(if-not (empty? (System/getenv "QUERY_STRING"))
(let [pairs (-> (System/getenv "QUERY_STRING")
(clojure.string/split , #"&"))
kvs (map
#(apply hash-map (clojure.string/split % #"=" 2))
pairs)]
(into {} kvs))
{}))
(defn read-post-body
"reads the POST body from stdin"
[]
(try (let [body (slurp *in*)
parts (clojure.string/split body #"&")
pairs (mapv #(clojure.string/split % #"=") parts)]
(into {} pairs))
(catch Exception e {})))
》》HTMX?
Now you’re asking "but what about the main script?!" In this case, everything
is tied together with a main HTML page. Just a static page. Your normal, everyday
index.html
page. With the addition of HTMX! Explaining
HTMX is way out of scope here, so let me just say HTMX is a JavaScript library
that adds dynamic capabilites to normal HTML tags.
Adding HTMX to a page is easy - just one <script>
tag:
<script src="https://unpkg.com/htmx.org@1.9.2"
integrity="sha384-L6OqL9pRWyyFU3+/
bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h"
crossorigin="anonymous"></script>
Now imagine I have an iconlist.clj
which provides the list of icons saved on
the filesystem. HTMX allows for triggering it’s execution at load time. The output
HTML from my script replaces the HTML element in the page. It looks like this:
<!-- this becomes a GET to /iconlist.clj at page load time -->
<!-- returned HTML is inserted in place of the div tag -->
<div id="iconlist" hx-get="iconlist.clj" hx-trigger="load">
loading icons...</div>
》》About That iconlist.clj?
This is the fun Clojury bit. It relies on Hiccup
to simplify generating HTML as well as the common
namespace defined above. Babashka’s
fs
lib handles listing files and working with paths.
#!/bin/env bb
(ns iconlist)
(require '[hiccup.core :as h])
(require '[babashka.fs :as fs])
(require '[common :as c])
(defn get-icon-info [path]
(let [icondirs (filter fs/directory? (fs/list-dir path))]
(for [dir icondirs
:let [iconname (fs/file-name dir)
imgs (mapv str (fs/list-dir dir))]]
{iconname imgs})))
(defn fix-path [p]
(clojure.string/replace
(str p)
#"^/home/.*/public_html/iconkeep/" ""))
(defn show-icons [iconlist]
(for [icon iconlist]
(h/html [:img {:class "icon" :src (fix-path icon)
:width 200 :height 200}])))
(defn get-selector []
(if (= (System/getenv "REQUEST_METHOD") "GET")
#".*"
(re-pattern (str (get (c/read-post-body) "search")
".*"))))
(println "Content-type: text/html\n")
(let [params (c/query-params)
selector (get-selector)
root (System/getenv "CONTEXT_DOCUMENT_ROOT")
iconbase (str root "/iconkeep/iconbase")
iconinfo (into {} (get-icon-info iconbase)) ]
(doseq [[iconname icons] iconinfo]
(if (re-matches selector iconname)
(println (h/html
[:div {:class "icongroup"}
[:span iconname] [:span (show-icons icons)] ])))))
Notice the (println "Content-type: text/html\n")
line - CGI scripts have to return
HTTP headers and body. Which is printed to stdout
by the script, but returned
as the HTTP response to the browser.
After that, it just loops through icons it finds in the filesystem and prints
out a series of div
, span
, and img
tags using Hiccup. Here’s a sample
of what the client sees, but reformatted for easy reading:
<div class="icongroup">
<span>ultraviolet</span>
<span>
<img class="icon" height="200"
src="iconbase/ultraviolet/4604823-200.png" width="200" />
<img class="icon" height="200"
src="iconbase/ultraviolet/2148936-200.png" width="200" />
<img class="icon" height="200"
src="iconbase/ultraviolet/4431984-200.png" width="200" />
<img class="icon" height="200"
src="iconbase/ultraviolet/4431253-200.png" width="200" />
<img class="icon" height="200"
src="iconbase/ultraviolet/1894546-200.png" width="200" />
</span>
</div>
As you can see, that’s pretty simple. To explain a few other parts:
The common
namespace reads CONTEXT_DOCUMENT_ROOT
from the environment,
which the Apache CGI module provides. That lets the script "discover" the place
it resides in the filesystem without hardcoding a path.
The selector
is just a regex for matching which icons to return. HTMX allows
forms to POST
form data, and the common/read-post-body
function parses
that data. When the HTTP method is GET
a wildcard regex is returned. When
it’s POST
the search
field from the form is used to construct a regex.
All the rest is mainly about listing the files and mangling the path.
》That’s It?
Really, that’s all. No big backend frameworks. Only an http server. Nothing to occupy memory or CPU cycles until it’s needed.
Go forth and write your lightweight Clojure web apps!