mélangej · ABCL on Jooby and Netty
Routing and the request
Defining routes
(def-route (app "GET" "/api/v0/orgs") (ctx)
(respond-json ctx (json-encode (list-orgs))))
(def-route (app "POST" "/api/v0/repos") (ctx)
(let ((body (json-decode (ctx-body ctx))))
...))
def-route is a macro over route, which wraps the function in the generated Route$Handler. Path parameters use Jooby's {name} syntax and come back through ctx-param.
Routes are registered when add-ui-routes and add-api-routes run, which is before (java:jcall "start" app), and that ordering matters: Jooby's router is immutable once the server is up, and registering a route from inside a handler throws a NullPointerException from deep in the router with no Lisp in the message. If you ever see that, you have a paren in the wrong place and a def-route nested inside another one.
Routes as a list
def-route mounts one route where it is written. An application that is small enough to read in one screen can say all of them in one place instead, as data (mélangej 0.2.0):
(serve '((:get "/" front-page)
(:get "/hello/{name}" hello)
(:post "/notes" save-note)
(:get "/api/notes" list-notes :type "application/json"))
:port 8643 :io-threads 2 :worker-threads 4)
serve is make-app, mount and start-server, and it mounts every route before the server starts - which is the ordering the paragraph above is about, kept by construction. A handler given as a symbol is looked up per request, so a REPL redefinition is live; the answer is HTML unless the route's :type says otherwise. mount alone puts a list on an app you already have, which is how a list and def-routes share one server:
(let ((app (make-app)))
(mount app *pages*)
(mount app *api* :type "application/json")
(def-route (app "GET" "/ping") (ctx) "pong")
(start-server app :port 8643))
The context, wrapped
No application file calls Java directly. spice-http exports the whole surface as ordinary functions:
ctx-path ctx-method ctx-header ctx-query
ctx-param ctx-body ctx-body-bytes
ctx-form form-value ctx-cookie ctx-set-cookie
ctx-status ctx-set-type ctx-set-header ctx-send-bytes
ctx-secure-p redirect respond-json respond-problem
Four of these carry a rule about Jooby that is worth knowing, and the comments in the file say so:
ctx-form goes through Jooby's formMap, not the raw body. Jooby has already consumed the stream to parse a form POST, so the bytes are not there to read; the parsed map is.
ctx-set-cookie builds an io.jooby.Cookie. Context has no response-header collection to append to, so the obvious add-header "Set-Cookie" does not exist; and setResponseCookie is also the only thing that knows how to send more than one.
lisp-string sits at every boundary where a value comes out of a Java collection. ABCL leaves a java.lang.String from a generic container as a JAVA-OBJECT, and string= on one is silently false. That is how a correct CSRF token gets reported as a stale form.
ctx-secure-p reads X-Forwarded-Proto. Behind a terminator the socket is plain HTTP, so the terminator's header is the only thing that knows whether the client is on TLS. Cookies set Secure from it.
Errors
Every handler runs inside a handler-case installed by %make-handler. An unhandled Lisp error becomes an RFC 9457 problem document:
{"status":500,"title":"internal error","detail":"..."}
That is deliberate for an API and wrong for a page, which is why page handlers catch what they can themselves — a repository whose engine cannot answer gets a row saying "timeline unavailable" rather than a 500, because the clone URL above it still works.
The wire mount
The interesting route in spicemine is not a page. Fossil, git and Mercurial all speak their own protocol over HTTP, and the server authorises the request itself before handing the bytes to an engine:
(scm-wants-write-p engine ctx-plist) ; does this need repo:sync?
(scm-serve engine repo-dir ctx-plist) ; -> status, type, location, bytes
scm-wants-write-p is a CLOS generic per engine because the answer is protocol-specific and not guessable: git says so in the URL (git-receive-pack), Mercurial in a query parameter (cmd=unbundle), and Fossil only inside the compressed payload — so the Fossil method actually parses xfer cards, and an unreadable payload counts as a write, which is the safe default.
The shape to take away: authorise in Lisp, delegate the bytes. The engine never learns who the user was, and the rules never learn what a pack file is.
Middleware, such as it is
There is no middleware object, no filter chain and no use(). Jooby has all three — before, after, and decorators — and mélangej does not wrap any of them.
What it has instead is macros, which compose lexically:
(defmacro def-page ((app method path) (ctx) &body body)
"Language, then session, then skin - in that order because each needs the
one before it: the skin may be stored on the account, and the account comes
from the session."
`(def-route (,app ,method ,path) (,ctx)
(with-language (,ctx)
(with-session (,ctx)
(with-skin (,ctx :user-id (let ((u (current-user))) (and u (user-id u))))
,@body)))))
def-page is the middleware stack for a page, and there is a second one for a form:
(defmacro def-post ((app path) (ctx form) &body body)
`(def-route (,app "POST" ,path) (,ctx)
(with-language (,ctx)
(with-session (,ctx)
(with-skin (,ctx ...)
(let ((,form (ctx-form ,ctx)))
(cond ((null (current-user)) (redirect ,ctx "/login"))
((not (csrf-ok-p ,ctx ,form))
(fail-to ,ctx "/settings" :msg-form-stale))
(t ,@body))))))))
There is a third, def-fragment, which binds the same three things and then answers with Datastar events or with a full page depending on one request header. It is chapter 6, because what is interesting about it is not the middleware.
Three named stacks, each a handful of lines, all readable in full. A route is one of the three, and which one it is tells you what has already happened before the body runs.
Why macros rather than a chain
It is a let, so the bindings are lexical and the unwinding is free. with-language binds lang for the dynamic extent of the request and nothing has to put it back. A chain of filters that sets and clears thread-local state has to be correct on the error path too; a handler-case inside a let is correct because that is what let means.
You can read the whole stack at the call site. def-page expands to a nested form you can print at the REPL. There is no registration order, no priority number, and no question about whether a filter installed in one file runs before one installed in another — the answer is the indentation.
The compiler sees through it. The wrappers are macros, so the body is compiled inside them rather than called through them. There is no per-layer function call and nothing to look up at request time.
And the ordering comment is in the code. The docstring above says why language comes before session comes before skin. In a filter chain that reasoning lives in whatever file does the registering, if anywhere.
What this costs
You cannot add a cross-cutting concern to routes you do not own. Every route has to opt in by being defined with the macro that includes it. A decorator applied to a path prefix could cover routes added later; a macro cannot. For thirty-three files and 213 routes that is a feature — nothing happens to a request that is not visible where the request is handled — and at ten times the size it would be a chore.
Adding a layer means editing two macros and recompiling the routes. Not a restart-the-process problem (chapter 10), but not a configuration change either.
Jooby's own before/after are still there if you need them, including for things that genuinely belong below Lisp: access logging, tracing headers, metrics. Nothing stops you wrapping them in spice-http; the one rule is that the wrapper goes in spice-http and not in a page.