Request destructuring & forms
Inspect path parameters, query strings, repeated form fields, nested parameters and a raw streaming body.
Build and run
Requires Java 21+, Clojure CLI and the runtime named below. The example pins the compiler that supports native ^:async and await. Run these commands in a separate terminal from other examples.
git clone https://github.com/OUTCASTGEEK-TECH/fast-twitch.git
cd fast-twitch
git checkout a4eb73f96836d034c7a24fe09dc7d51e4c581f64
cd examples/03-request-destructuring
npm install
bb run-node
Try each representation
curl -i -H 'X-Learner: first-day' 'http://127.0.0.1:6464/hello/Ada?loud=true&topic=fast-twitch'
curl -i --data 'color=blue&color=green&level=beginner' http://127.0.0.1:6464/form
curl -i --data 'user[name]=Ada&user[role]=newbie&user[language]=ClojureScript' http://127.0.0.1:6464/profile
curl -i -H 'Content-Type: text/plain' --data 'hello stream' http://127.0.0.1:6464/body-text
| Request | Values to inspect in the response |
|---|---|
/hello/Ada |
:greeting is "HELLO, ADA", :topic is "fast-twitch", and :learner-header is "first-day" |
/form |
Repeated color becomes a vector containing "blue" and "green"; :level is "beginner" |
/profile |
:nested-user is a map whose keyword keys include :name, :role and :language |
/body-text |
:body is "hello stream", and :character-count is 12 |
These are printed ClojureScript values, not JSON responses. The browser pages at /, /form, /profile and /body-text also provide forms.
Why the wrappers are in this order
The thread expression wraps routes with keyword conversion, nested parsing, then parameter parsing. The last wrapper runs first on the request: wrap-params parses the body/query; wrap-nested-params turns bracketed names into maps; wrap-keyword-params converts string keys; routing adds path parameters; the handler destructures those maps.
Without wrap-params, a query string is just :query-string. Without nested parsing, user[name] remains a flat field name. Destructuring does not perform decoding or type conversion: loud remains a string, so the handler compares it with "true".
The raw-body callback path
body-text-handler takes [request respond raise] and is registered as :async-handler. It consumes the body stream once through Response.text, awaits the result and calls respond. Read failures go to raise. Returning a Promise from this callback handler does not replace calling respond.
Exercise
Add an optional language query field and include it in the greeting output. Send the same parameter twice and inspect its representation before deciding whether the handler accepts one value or a sequence. Do not read /body-text's body a second time: decode once and pass the decoded value to downstream functions.
Request map fields · Middleware order.
Complete source and build files
The files below are copied without abbreviation from Fast-Twitch revision a4eb73f96836d034c7a24fe09dc7d51e4c581f64. The walkthrough and API reference use the same revision.
Source: examples/03-request-destructuring/src/destructure/server.cljs
View this exact file on GitHub.
(ns destructure.server
[:require-macros [fast-twitch.macros :refer [env-var]]]
[:require [cljs.core :refer [await]]
[cljs.pprint :as pprint]
[fast-twitch.middlewares.keyword-params :as keyword-params]
[fast-twitch.middlewares.nested-params :as nested-params]
[fast-twitch.middlewares.params :as params]
[fast-twitch.routing :as routing :refer [start-server! stop-server!]]
[integrant.core :as ig]
[replicant.string :as html]
[destructure.views :as views]]
[:refer-global :only
[Number Response console globalThis]])
(def request-options
{:protocol "HTTP/1.1" :remote-addr "127.0.0.1"})
(defn pretty
[value]
(with-out-str (pprint/pprint value)))
(defn text-response
[status value]
{:status status
:headers {"Content-Type" "text/plain; charset=utf-8"}
:body (pretty value)})
(defn html-response
[page]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (html/render page)})
(defn log-and-respond
[label value]
(console/log (str "\n" label "\n" (pretty value)))
(text-response 200 value))
(defn hello-handler
"Destructure common request-map keys, then destructure those smaller maps."
[{:keys [request-method uri path-params query-params headers]}]
(let [{:keys [name]} path-params
{:keys [loud topic]} query-params
learner-header (:x-learner headers)
greeting (cond-> (str "Hello, " name) (= "true" loud) (.toUpperCase))]
(log-and-respond "GET /hello/:name destructured data"
{:greeting greeting
:topic topic
:learner-header learner-header
:request-method request-method
:uri uri
:path-params path-params
:query-params query-params})))
(defn form-handler
"URL-encoded form bodies become :form-params and are also merged into :params."
[{:keys [request-method form-params params]}]
(let [{:keys [color level]} form-params]
(log-and-respond "POST /form destructured data"
{:request-method request-method
:favorite-colors color
:level level
:form-params form-params
:all-params params})))
(defn profile-handler
"Names like user[name] become nested maps after nested-params middleware runs."
[{:keys [params form-params]}]
(let [{:keys [user]} params
{:keys [name role language]} user]
(log-and-respond "POST /profile destructured data"
{:name name
:role role
:language language
:nested-user user
:raw-form-params form-params})))
(defn body-text-handler
"Raw bodies are streams. This async handler reads the stream into text."
[{:keys [headers body]} respond raise]
(if body
((^:async fn
[]
(try (let [text (await (.text (Response. body)))]
(await (respond (log-and-respond "POST /body-text destructured data"
{:content-type (:content-type headers)
:character-count (count text)
:body text}))))
(catch :default error (raise error)))))
(respond (text-response 400 {:error "No request body was sent."}))))
(defn default-handler
[{:keys [uri]}]
(text-response 404 {:error "No matching route" :uri uri}))
(defmethod ig/init-key ::settings
[_ options]
options)
(defmethod ig/init-key ::pages
[_ {:keys [settings]}]
{:home (fn [_request]
(html-response (views/demo-page settings :all)))
:form (fn [_request]
(html-response (views/demo-page settings :form)))
:profile (fn [_request]
(html-response (views/demo-page settings :profile)))
:body-text (fn [_request]
(html-response (views/demo-page settings :body-text)))})
(defmethod ig/init-key ::routes
[_ {:keys [pages]}]
[{:pattern "/" :method :get :handler (:home pages)}
{:pattern "/hello/:name" :method :get :handler hello-handler}
{:pattern "/form" :method :get :handler (:form pages)}
{:pattern "/form" :method :post :handler form-handler}
{:pattern "/profile" :method :get :handler (:profile pages)}
{:pattern "/profile" :method :post :handler profile-handler}
{:pattern "/body-text" :method :get :handler (:body-text pages)}
{:pattern "/body-text" :method :post :async-handler body-text-handler}])
(defmethod ig/init-key ::app
[_ {:keys [routes]}]
(-> (routing/routes routes default-handler)
(keyword-params/wrap-keyword-params)
(nested-params/wrap-nested-params)
(params/wrap-params)))
(def config
{::settings {:title "Request destructuring"
:bulma-css "https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css"}
::pages {:settings (ig/ref ::settings)}
::routes {:pages (ig/ref ::pages)}
::app {:routes (ig/ref ::routes)}})
(defonce system
(ig/init config))
(def handler
(routing/ft-handler (::app system) request-options))
(defn shutdown
[]
(stop-server! :force true
:callback (fn []
(console.log "Server stopped"))))
(defn run
[]
(start-server! handler
{:port (Number (or (env-var "PORT") 6464))
:hostname (or (env-var "HOSTNAME") "127.0.0.1")
:onListen (fn [addr]
(console/log
(str "Request destructuring server listening at http://"
(aget addr "hostname")
":" (aget addr "port"))))}))
(set! *main-cli-fn* run)
(defn ^:export fetch
[request]
(handler request))
Source: examples/03-request-destructuring/src/destructure/views.cljs
View this exact file on GitHub.
(ns destructure.views)
(defn layout
[{:keys [title bulma-css]} & body]
[:html
[:head [:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:title title] [:link {:rel "stylesheet" :href bulma-css}]]
[:body
[:section.section
[:div.container [:h1.title title]
[:p.subtitle "Use these forms, then compare the response with the handler source."]
body]]]])
(defn demo-nav
[]
[:div.buttons [:a.button.is-light {:href "/"} "All forms"]
[:a.button.is-light {:href "/hello/Ada?loud=true&topic=fast-twitch"} "GET /hello/Ada"]
[:a.button.is-light {:href "/form"} "Form route"]
[:a.button.is-light {:href "/profile"} "Profile route"]
[:a.button.is-light {:href "/body-text"} "Body route"]])
(defn form-demo
[]
[:section.box [:h2.title.is-4 "POST /form"] [:p "Repeated field names become a vector."]
[:form {:method "post" :action "/form"}
[:div.field [:label.label "Favorite colors"]
[:label.checkbox
[:input {:type "checkbox" :name "color" :value "blue" :checked true}] " blue"] " "
[:label.checkbox
[:input {:type "checkbox" :name "color" :value "green" :checked true}] " green"]]
[:div.field [:label.label {:for "level"} "Level"]
[:input.input {:id "level" :name "level" :value "beginner"}]]
[:button.button.is-link {:type "submit"} "Submit form params"]]])
(defn profile-demo
[]
[:section.box [:h2.title.is-4 "POST /profile"]
[:p "Names like " [:code "user[name]"] " become nested maps."]
[:form {:method "post" :action "/profile"}
[:div.field [:label.label {:for "profile-name"} "Name"]
[:input.input {:id "profile-name" :name "user[name]" :value "Ada"}]]
[:div.field [:label.label {:for "profile-role"} "Role"]
[:input.input {:id "profile-role" :name "user[role]" :value "newbie"}]]
[:div.field [:label.label {:for "profile-language"} "Language"]
[:input.input
{:id "profile-language" :name "user[language]" :value "ClojureScript"}]]
[:button.button.is-link {:type "submit"} "Submit nested params"]]])
(defn body-text-demo
[]
[:section.box [:h2.title.is-4 "POST /body-text"]
[:p "This form sends " [:code "text/plain"]
", so the async handler can read the raw body stream."]
[:form {:method "post" :action "/body-text" :enctype "text/plain"}
[:div.field [:label.label {:for "body-message"} "Message"]
[:textarea.textarea {:id "body-message" :name "message"}
"plain request bodies are streams until you read them"]]
[:button.button.is-link {:type "submit"} "Submit raw body"]]])
(defn demo-page
[settings active]
(layout settings
(demo-nav)
(case active
:form (form-demo)
:profile (profile-demo)
:body-text (body-text-demo)
[:div (form-demo) (profile-demo) (body-text-demo)])))
Source: examples/03-request-destructuring/deps.edn
View this exact file on GitHub.
{:aliases {:nREPL {:extra-deps {cider/cider-nrepl {:mvn/version "0.62.2"}
cider/piggieback {:mvn/version "0.7.0"}
nrepl/nrepl {:mvn/version "1.7.0"}}
:main-opts
["-m" "nrepl.cmdline" "--middleware"
"[cider.nrepl/cider-middleware, cider.piggieback/wrap-cljs-repl]"]}
:test {:extra-paths ["test"]}}
:deps {fast-twitch/fast-twitch {:local/root "../.."}
integrant/integrant {:mvn/version "1.0.1"}
no.cjohansen/replicant {:mvn/version "2026.07.1"}
org.clojure/clojurescript {:git/sha "56a94ac13bfac22108e4d868dfd4763edaceb864"
:git/url "https://github.com/clojure/clojurescript"}}
:paths ["src"]}
;; Start the example nREPL server
;; clj -M:nREPL
Source: examples/03-request-destructuring/app.edn
View this exact file on GitHub.
{:aot-cache true
:cache-analysis true
:dump-core true
:fn-invoke-direct true
:infer-externs :all
:language-in :es-next
:language-out :es-next
:main destructure.server
:optimizations :simple
:optimize-constants true
:output-dir "build"
:output-to "server.cjs"
:output-wrapper "%s\nexports.fetch = destructure.server.fetch;\n"
:parallel-build true
:pretty-print true
:source-map "server.cjs.map"
:stable-names true
:static-fns true
:target :nodejs}
Source: examples/03-request-destructuring/bb.edn
View this exact file on GitHub.
{:tasks {build {:doc "Compile ClojureScript to server.cjs"
:task (shell "clj" "-M" "-m" "cljs.main" "-co" "app.edn" "-c")}
deno-serve {:doc "Build and serve the exported Fetch handler with Deno"
:task (let [port (or (System/getenv "PORT") "6464")
jobs (or (System/getenv "DENO_JOBS") "1")]
(shell "clj"
"-M"
"-m"
"cljs.main"
"-co"
"app.edn"
"-co"
(pr-str {:nodejs-rt false
:output-dir "build/serve"
:output-to "build/server-serve.cjs"
:source-map "build/server-serve.cjs.map"})
"-c")
(shell {:extra-env {"DENO_JOBS" jobs "PORT" port}}
"deno"
"serve"
"--parallel"
"-A"
"--port"
port
"build/server-serve.cjs"))}
run-bun {:depends [build]
:doc "Build and run server.cjs with Bun"
:task (shell "bun server.cjs")}
run-deno {:depends [build]
:doc "Build and run server.cjs with Deno"
:task (shell "deno run -A server.cjs")}
run-node {:depends [build]
:doc "Build and run server.cjs with Node"
:task (shell "node server.cjs")}}}
Source: examples/03-request-destructuring/package.json
View this exact file on GitHub.
{
"name": "fast-twitch-02-request-destructuring",
"private": true,
"type": "commonjs",
"scripts": {
"build": "bb build",
"node": "bb run-node",
"deno": "bb run-deno",
"bun": "bb run-bun"
},
"devDependencies": {
"@cljs-oss/module-deps": "^1.1.1"
}
}
Source: examples/03-request-destructuring/deno.jsonc
View this exact file on GitHub.
{
"tasks": {
"build": "bb build",
"start": "bb run-deno",
"serve": "bb deno-serve"
}
}