Core concepts
The contract, and the libraries built on it — each shown with code from WebGlue’s own sources, tests and demo application, and from Lictor where the demo does not use a library.
The contract
A request is a map with :method (a lowercase keyword), :path (still percent-encoded), :query, :scheme, :host, :port, :remote, :version, :headers and :body. A response is a map with :status, :headers and :body. That is the whole of webglue_spec.
- Headers are a map from lowercase name to a list of values, on both sides — a list is the only shape that is right for
Set-Cookie. - A body is a string, a stream that can be read once, or
nil. - Middleware adds its own keys —
:session,:path-params,:query-params,:form-params,:csrf-token,:media-type,:current-user,:repo— and each is documented by the library that sets it.
Short-circuiting needs no marker. A middleware that should stop the request simply does not call the handler; a :halted flag was considered and rejected as a security hazard.
(defn wrap-auth [handler]
(fn [request]
(if (authorised? request)
(handler request)
{:status 403 :headers {} :body nil})))Handlers and middleware
Middleware is written by hand the same way the libraries write it. The demo’s own puts the signed-in user on the request:
(defn wrap-user
[handler] :spec [function -> function]
(fn [request]
(let [named (or (:user (:session request)) (spec/header request "x-demo-user"))]
(handler (if (nil? named)
request
(map/assoc request :current-user named))))))Routing
A route table is data: a vector of [method pattern handler], tried in order, first match wins. In a pattern a string matches itself, a keyword binds one segment and :* binds the rest; "/users/:id" is shorthand for ["users" :id]. Matches land on :path-params, decoded. A handler in the table may itself be wrapped — here three of them in content negotiation:
(defn routes [] :spec [-> vector]
[(router/get "/" page)
(router/get "/socket/tickets" watch)
(router/post "/session" sign-in)
(router/delete "/session" sign-out)
(router/group "/tickets"
[(router/post "/" raise)
(router/get "/" (negotiation/wrap index formats))
(router/get "/:id" (negotiation/wrap show formats))
(router/get "/:id/trail" (negotiation/wrap trail-of formats))
(router/post "/:id/assign" assign)
(router/post "/:id/hold" hold)
(router/post "/:id/resume" resume)
(router/post "/:id/close" close)])])(router/segments "/users/:id") ; => ["users" :id] (router/group "/users" [[:get [] :index]]) ; => [[:get ["users"] :index]] (router/allowed [[:get ["users"] :index]] "/users") ; => [:get :head :options]
HEAD falls through to GET, and OPTIONS is answered from the table with an Allow header. router/handler answers 404 when nothing matches; router/wrap falls through to the next handler instead. prx routes webglue.demo.web prints the table.
Templates
A template is an HTML file with holes in it. {{ … }} puts a value into markup, and an attribute with a leading colon — :if, :for — is a directive whose value is Praxis. Inside a hole is ordinary Praxis, read by the Praxis reader; there is no second expression language. A template declares its parameters, so a misspelled one is a compile error, not a nil on the page. Markup must be well-formed: a missing close tag is an error with a line number.
{{:params [tickets q]}}
<!doctype html>
<html lang="en">
<head><title>Tickets</title></head>
<body>
<h1>Tickets</h1>
<form method="get" action="/"><input type="search" name="q" value="{{ q }}"><button>Search</button></form>
<ul id="tickets" :if="(praxis.core/not (praxis.seq/empty? tickets))">{{ (for [t tickets] (ticket-row {:ticket t})) }}</ul>
<p :if="(praxis.core/and (praxis.seq/empty? tickets) (praxis.string/blank? q))">Nothing yet.</p>
<p :if="(praxis.core/and (praxis.seq/empty? tickets) (praxis.core/not (praxis.string/blank? q)))">Nothing matches.</p>
</body>
</html>deftemplates compiles every .html under a directory into the calling namespace at compile time, one function per file, the path folded into the name: templates/ticket-row.html becomes ticket-row. Nothing is generated or checked in. view/page answers a tree as an HTML response, and view/document does the same with a doctype in front — and refuses a tree that already has one.
(ns webglue.demo.templates
"Every page and fragment this app renders."
(:require [webglue.view :refer [deftemplates]]))
(deftemplates "templates")
(defn- page [request] :spec [map -> map]
(with [#(:ok query) (searched request)]
(view/page (templates/tickets {:tickets (store/search (db/repo-of request) query)
:q (or (:q query) "")}))
[#(:error failures) (text 400 (complaint failures))]))A template answers a markup tree, never a string, so a value is escaped by the position it lands in, not by somebody remembering to escape it. The parsing and compiling are not web concerns and live in two general Praxis libraries, quire (markup text read into a tree) and stencil (a template compiled into a function); only webglue_view, which finds a project’s templates and answers a response with one, is WebGlue’s.
Content negotiation
webglue_content_negotiation reads Accept once, by RFC 9110, and compares it with what a handler offers, in the handler’s own order of preference. The most specific matching range prices an offer, the highest quality wins, and at equal quality the server’s order decides — so a client sending */* gets the first offer. To this library a media type is only a string: it chooses, and the handler writes.
(negotiation/choose "application/json" ["application/dxn" "application/json"]) ; => "application/json" (negotiation/choose "*/*" ["application/dxn" "application/json"]) ; => "application/dxn" (negotiation/choose "image/png" ["application/dxn" "application/json"]) ; => nil
negotiation/wrap puts the choice on the request as :media-type, adds Vary: Accept to every response, and answers 406 itself, naming the offers, when nothing is acceptable — the handler never runs. The demo offers plain text first, because what reads it without asking is curl and a person:
(def- formats
["text/plain" "application/dxn" "application/json"])
(defn- answered [request status value said] :spec [map integer any function -> map]
(let [type (negotiation/media-type request)]
(cond
(= type "application/dxn") (encoded status type (dxn/encode value))
(= type "application/json") (encoded status type (json/generate value))
true (text status (said value)))))Form bodies
webglue_params reads what a request carries as text into maps: the query string onto :query-params, a urlencoded form body onto :form-params. Both are always there, empty rather than absent, and both map strings to strings — turning "2" into 2 is validation’s job, so that decoding a body and trusting it are never the same step. A broken escape is answered 400, a body over a megabyte (or :max-length) 413, before the handler is reached.
(defn register [request]
(let [form (:form-params request)]
(praxis.map/get form "username")))A repeated field keeps its last value, and JSON, DXN and multipart bodies are not read yet; both are in the backlog until a journey needs them.
CSRF protection
webglue_server_csrf is the synchronizer token: kept in the session, handed to every request as :csrf-token, and required back on every unsafe request — as the form field _csrf, or the header x-csrf-token for a script. A request without it is answered 403 and never reaches the handler. GET, HEAD, OPTIONS and TRACE are not checked, and a token is compared in constant time. The order of the three middlewares is the point: the session outermost, because it holds the token; params next, because the token arrives in the form; CSRF innermost, around what it protects.
(-> (router/handler (routes))
(csrf/wrap)
(params/wrap)
(session/wrap {:store (memory/store)}))
(csrf/field {:csrf-token "t"}) ; => [:input {:type "hidden" :name "_csrf" :value "t"}]Libraries that take one side of a security exchange are named side first — webglue_server_csrf, and later webglue_client_csrf for the browser half — so each side sorts together.
Servers
A server adapter is passed in as a function, not named by an atom — a server the project does not have is a name that does not compile. An adapter has three verbs: start, stop, and child, which returns the supervision child spec an application actually uses.
(defn init [_argument] :spec [any -> tuple]
(supervisor/tree (supervisor/flags :one-for-one)
[(pubsub/child)
(presence/child)
(supervisor/child webglue.session.memory nil)
(supervisor/child webglue.demo.store.memory nil)
(server/child (adapter) (web/app) {:port (port)})]))
(defn- adapter [] :spec [-> function]
(if (= (system/env "WEBGLUE_ADAPTER") "wrangler")
wrangler/child
bandit/child))There are two adapters. webglue_bandit runs on Bandit and Plug, and gives HTTP/1.1, HTTP/2 and WebSockets; all of Plug is confined to that one library. webglue_wrangler is an HTTP/1.1 server written in Praxis that speaks the contract directly, with keep-alive, pipelining, streamed bodies and WebSockets. It becomes the default only once it has earned it: a parity suite sends the same raw bytes through both adapters and expects the same answer — on its first run it found real bugs in Wrangler.
Sessions
A handler reads :session from the request — always a map, never nil. To write a session it sets :session on the response; nil ends it; leaving the key out changes nothing. Every write mints a new token, which rules out session fixation. The cookie defaults to HttpOnly, SameSite=Lax and Path=/, and is added beside any cookie the handler set itself; storage sits behind a store behaviour.
(defn- sign-in [request] :spec [map -> map]
(let [who (map/get (form-of request) "user")]
(if (praxis.string/blank? (or who ""))
(text 400 "a session needs a user")
(map/assoc (text 200 (str/join (list "signed in as " who) ""))
:session {:user who}))))
(defn- sign-out [_request] :spec [map -> map]
(map/assoc (text 200 "signed out") :session nil))Static files
static/wrap answers GET and HEAD for files under a directory, with the content type taken from the extension. Anything else falls through — a missing file becomes the application’s own 404. Path traversal is refused by checking where a path resolves, not which spellings it uses:
(deftest and-a-path-climbing-out-of-the-directory-is-refused (assert= (:status (serve (test/request :get "/assets/../../project.pxn"))) 404) (assert= (:status (serve (test/request :get "/assets/css/../../../../etc/passwd"))) 404))
Stylesheets with Tailwind
webglue_tailwind builds an application’s stylesheet with Tailwind’s standalone CLI. The binary is fetched for the machine at a pinned version (4.3.3 by default), checked against its SHA-256 before it is ever executable, and cached under .prx/tools/; nothing is committed. It hooks into the build through two prx steps: :prepare runs after every compile, :alongside runs beside prx run and stops with it. It rebuilds :always, or :on-change against a stamp over the stylesheet, its plugins and the sources. Lictor styles its pages with daisyUI this way.
:deps [[webglue_tailwind "~> 0.8"]]
:prepare ["tailwind build"]
:alongside ["tailwind watch"]
:tailwind {:input "assets/app.css"
:output "priv/static/assets/app.css"}Validation
One function, check, and rules as data. String keys go in, keyword keys come out, values are coerced from text, and every failure is returned — not just the first.
(v/check {:page {:type :integer}} {"page" "2"}) ; => #(:ok {:page 2})
(v/check {:debug {:type :boolean}} {"debug" "on"}) ; => #(:ok {:debug true})
(def- raising
{:subject {:type :string :required true :max 200}
:body {:type :string :default ""}
:reporter {:type :string}})
(defn- raise [request] :spec [map -> map]
(with [#(:ok asked) (v/check raising (form-of request))]
(let [repo (db/repo-of request)
made (store/raise repo (:subject asked) (:body asked) (:reporter asked))]
(recorded repo (:id made) :raised (:current-user request))
(text 201 (rendered (announced :raised made))))
[#(:error failures) (text 400 (complaint failures))]))The demo’s search uses the same library for a query string with no schema behind it: :status must be one of the four a ticket can be in, and anything else is a 400 naming the field.
The data layer
db/wrap puts a repository on the request as :repo, with optional checkout and transaction; db/repo-of reads it back. Rolling back is explicit — (db/rollback response) — and never guessed from a status code. WebGlue calls whatever module it is handed, so Ecto is the application’s dependency, not the framework’s. The demo uses the seam for an in-memory store behind a behaviour, and keeps an audit trail in it: the first change in the demo that is two writes, which is what :transaction is for once the store is a database.
Sockets
A socket is a response. A handler answers (socket/upgrading handlers state), a 101 response carrying a :socket key — so it has already passed through the session and every other middleware, which is how socket authentication is settled. There are four optional callbacks, :connected, :received, :informed (a message from elsewhere in the system) and :closed, and three answers: socket/keeping, socket/pushing, socket/closing.
(defn- watch [request] :spec [map -> map]
(socket/upgrading {:connected joined :informed told :received asked}
{:topic "tickets" :who (:current-user request) :sent 0}))
(defn- joined [state] :spec [map -> tuple]
(do
(pubsub/subscribe (:topic state))
(presence/track (:topic state) (or (:who state) "anonymous") {})
(socket/pushing (fragment/frame
(fragment/data (:topic state)
{:watching (:topic state)
:whom (praxis.map/keys
(presence/who (:topic state)))})
on-the-wire)
state)))Pub/sub and presence
A subscriber is a process. (pubsub/subscribe "topic") and (pubsub/broadcast "topic" message) run on Erlang’s pg, so they span the cluster as soon as nodes are connected; webglue_pubsub_redis is a second adapter behind the same seam. (presence/track topic key meta) records the calling process and monitors it — no heartbeat — and joins and leaves are broadcast on the same topic as {:presence-diff {:joined … :left …}}. The demo’s socket received presence diffs without a line of code for it.
(cluster/child {:strategy (cluster/static (list "web@10.0.0.4" "web@10.0.0.5"))})
(cluster/child {:strategy (cluster/by-dns "app.svc.cluster.local" "web")})Clustering discovers nodes through a strategy, which is just a function returning node names.
Server push
webglue_fragment pushes two kinds of envelope to connected clients: data, and HTML fragments addressed to a target. A fragment is a markup tree of plain vectors and maps, not a string; the serializer escapes by position, so a ticket titled <script>alert(1)</script> stays text. Frames go out as binary DXN by default, or as DXN text or JSON.
Because a template answers a tree, the row a page renders and the row a socket pushes are one file:
{{:params [ticket]}}
<li class="{{ (praxis.string/str (:status ticket)) }}" id="ticket-{{ (praxis.string/str (:id ticket)) }}"><span class="id">#{{ (praxis.string/str (:id ticket)) }}</span><span class="subject">{{ (:subject ticket) }}</span>{{ (praxis.core/if (praxis.core/nil? (:assignee ticket)) nil [:span {:class "who"} (:assignee ticket)]) }}</li>(defn- row [given] :spec [map -> any]
(templates/ticket-row {:ticket given}))
(defn- announced [event ticket] :spec [keyword map -> map]
(do
(fragment/push-data "tickets" {:event event :ticket ticket})
(fragment/push-html "tickets" "#tickets" (row ticket))
ticket))Testing
Because an application is a function, testing it needs no server. test/request builds a complete request map, test/answering is a stand-in handler with a fixed response, and test/echoing hands back the request it received — the way to see what a middleware did.
(deftest echoing-is-how-you-see-what-a-middleware-did
(let [wrap (fn [handler]
(fn [request] (handler (praxis.map/assoc request :locale "de"))))
app (wrap (test/echoing))]
(assert= (:locale (:request (app (test/request :get "/")))) "de")))Every example in a library’s documentation is run as a doctest, so a docstring that claims a result is checked like a test.
Configuration
Configuration is CASC. The main file imports every file of the current environment’s directory — last, so the environment wins — and each concern gets a file of its own. A secret comes from the environment, with a default that makes an unconfigured laptop work:
#@version = 1.0
webglue {
}
webglue_pubsub_redis {
url = "${WEBGLUE_REDIS_URL:redis://localhost:6379}"
}
# Last, so what an environment says wins over what is above it.
import "${PRX_ENV:dev}/*.casc"#@version = 1.0
praxis.logger {
level = debug
formatter = "praxis.logfmt"
}