This is part four of a series teaching Clojure to people with little or no programming experience. In Part 3, you built a few static pages that look the same for every visitor.
Now we'll build a small dynamic page, one that changes depending on what the visitor asks for. It converts temperatures in Celsius to Fahrenheit. You'll also meet the REPL, the "smarter way of working" mentioned in Part 1.
Step 1: Pull values out of the URL
First, bring in Clojure's string functions. Update the ns at the top of src/app/core.clj:
Now we can use str/split, str/blank?, and friends.
Then add these two functions above handler:
Save the file. Let's take it apart:
- URLs can't contain spaces or most symbols, so browsers encode them. A space becomes
+and<becomes%3C.url-decodeputs them back to their original form. parse-querysplits the text on&to get one chunk per parameter (a=1&b=2becomesa=1andb=2).#"&"is a pattern: it means "wherever you find an&".forgoes through the chunks one at a time and builds a new collection from them. Think of it as "for each chunk, do this".- For each chunk it splits on the first
=to get a key and a value.[k v]is a shortcut that takes the two pieces apart and names themkandv. If the chunk has no=at all (as in?celsius), there is nov, so(or v "")uses empty text instead. - It decodes both, and
into {}collects the key/value pairs into a map. when-notruns its body only if the condition is false. If the query string is blank, there is nothing to parse, soparse-queryreturnsnil, which means "nothing here". That's fine: looking something up inniljust gives younilback.
Try it in the REPL
How do you know parse-query works? You could wire it into handler, restart the server and visit a URL. Or you can just call it.
Open a new terminal (Terminal > New Terminal) and run:
You'll get a prompt that looks like user=>.
This is a REPL (Read, Evaluate, Print, Loop): you type a Clojure expression, it runs it and shows you the result.
Type or paste each expression below into the REPL and press Enter. The ;; lines show what you should see back, so you don't need to type them.
require loads your file. The ' in front of app.core tells Clojure "don't try to look it up right now". To call one of your functions, put its namespace in front: app.core/parse-query is the parse-query from src/app/core.clj.
No server, no browser and no restart. REPL-driven development lets you iterate and explore your code with instant feedback. In this series we'll use the REPL directly by typing text in a terminal window, but if you installed Calva in Part 1, it can also run code straight from your editor.
Leave the REPL open: we'll come back to it in the next step. Later, when you're done with it, close it by pressing Ctrl+D.
Step 2: Convert Celsius to Fahrenheit
The formula to convert from Celsius to Fahrenheit is F = C × 9/5 + 32.
Add it above handler:
(* c 9/5) means "c times 9/5", and (+ ... 32) adds 32 to that. In Clojure the operation comes first, just like any other function: (+ 1 2) is 1 plus 2.
Save the file and go back to the REPL. It doesn't notice that the file changed, so ask it to load the file again (note the :reload), then try a few temperatures:
It works! -40 is the point where the temperature is the same in both scales.
Wire it to the URL
On the website, the number won't come from you typing 20.0. It will come from the URL, through parse-query.
Let's try that. get looks up a key in a map, so this pulls the value of "celsius" out of what parse-query returned:
Now give that value to celsius->fahrenheit:
The error tells you what happened (String cannot be cast to Number) and where (celsius->fahrenheit).
Look closely at the result of the get above: it's "20", with quotes. That's text, not the number 20.
celsius->fahrenheit expects a number, and parse-query returns text even when it looks like a number. We need to convert it.
parse-double converts text to a number. It comes with Clojure, so it doesn't need the app.core/ prefix:
Now it should work:
Step 3: Run the server from the REPL
So far you've run the server in its own terminal with clojure -M:run, and restarting it meant Ctrl+C and typing the command again.
You already have a REPL open, so let's run the server from there instead and do everything in one place.
If the server from Part 3 is still running in another terminal, stop it with Ctrl+C. Two programs can't both use port 8080 at the same time.
At the bottom of src/app/core.clj, replace -main with:
There is a lot of code here, so let's go slowly:
atomis a box that holds a value that can change. Ours starts out holdingnil(nothing), and it will hold the running server.defonceis likedef, but it only happens once. A:reloadruns every line of the file again, and withoutdefoncethe box would be replaced by an empty one, and we'd lose track of a server that is still running.@serverlooks inside the box, andreset!puts a new value in it. The!at the end of a name is a convention that warns "this may change something".http/run-serverstarts the server and gives back a function that stops it.stop-serveruseswhen-let: if the box holds something, name itstop, call it, and empty the box. If the box is empty, there's nothing to stop, so it does nothing.start-serveralways callsstop-serverfirst. That's what makes it safe to run as many times as you like: every call is a restart.- The
letwithportis the same code that used to be in-main, moved intostart-server. -mainnow just callsstart-server, soclojure -M:runand Vercel still work exactly as before.
Save the file. Then, in the REPL, load the new code and start the server:
Open http://localhost:8080: it's your website, running from the REPL.
From now on, this is the rhythm: edit, save, reload, restart. Reloading is the require with :reload, and restarting is (app.core/start-server). You can run both in one go by pasting them on the same line:
To stop the server, run (app.core/stop-server). Closing the REPL with Ctrl+D stops it too.
Step 4: Build the page
Let's put this conversion to work in a page. Add this function above handler:
letgives a name to a value:cis the numberparse-doublegot from the text.(get params "celsius")is the same lookup you just tried in the REPL. In Part 3,(:uri req)worked because:uriis a keyword. The keys inparamsare text ("celsius"), and text can't look itself up, so we useget.°is HTML's way of writing the degree sign.
And add a route to handler, before the 404 at the end:
Save the file, then reload and restart in the REPL:
Then visit http://localhost:8080/convert?celsius=20.
You should see 20.0°C is 68.0°F. Try celsius=100 and celsius=-40 too.
Now try two addresses a visitor could easily go to:
- Visit
/convertwith nothing after it. - Visit
/convert?celsius=banana.
Both of these produce an error page, with messages like Expected string, got nil and Cannot invoke "Object.getClass()" because "x" is null. The REPL will show you the full error message.
Step 5: Fail gracefully
There are two different problems here:
- No value at all.
/converthas nocelsius, sogetreturnsnil, andparse-doublerefuses it: that's theExpected string, got nilerror. - A value that isn't a number.
parse-doubledoesn't crash on"banana". It returnsnil, its way of saying "this isn't a number". The crash comes one step later, whencelsius->fahrenheittries to do math withnil: that's theObject.getClass()error.
In both cases there's nothing to convert, so the page should simply show no result. Replace convert-page with:
(get params "celsius" "")has a third part: a default. If there's no"celsius"in the map,getreturns""(empty text) instead ofnil.parse-doubleis happy with empty text: it isn't a number, so it returnsnil. That turns the first problem into the second one.when-let, which you saw instop-server, handles the second problem. It runs(parse-double celsius-str), and if the result is a number, it names itcand builds the result paragraph. If the result isnil, it skips its body and returnsnil, andstrtreatsnilas empty text, so the page shows no result at all.
Notice how nil did the work here. We didn't stop it from showing up, we only did the math when there was something.
Save the file, then reload and restart in the REPL ((require 'app.core :reload) (app.core/start-server)) and try the two broken URLs again. Now both give you the page, without a result.
Step 6: Add a form
Nobody wants to edit URLs by hand. Let's add a form so visitors can type a value and press a button.
Replace convert-page with:
The <form> sends the visitor to /convert?celsius=whatever-they-typed, which is the same address you've been typing by hand.
The box comes back empty after they press the button, but the result line shows the number they converted.
Save the file, then reload and restart in the REPL ((require 'app.core :reload) (app.core/start-server)) and use the form.
Checkpoint: you've now got an interactive page, one that responds differently depending on what a visitor types.
The whole file
Here is the full src/app/core.clj at this point. If something isn't working, compare yours to this one:
Deploy it
Same command as before:
Open /convert on your live link and try the form. Then throw some bad input at it: no value at all, and banana.
Both should give you a normal page.
If something went wrong
Could not locate app/corein the REPL: you startedclojureoutside yourmy-sitefolder. PressCtrl+D, close that terminal, and open a new one with Terminal > New Terminal. VS Code starts it insidemy-site.- The browser shows old content: the REPL keeps its own copy of the code. Save the file, then reload and restart (
(require 'app.core :reload) (app.core/start-server)). Address already in usewhen you run(app.core/start-server): another program is using port 8080, most likely aclojure -M:runstill running in another terminal. Stop it withCtrl+Cand try again.Syntax error (ClassNotFoundException)mentioningapp.core: this REPL hasn't loaded your file yet. Run(require 'app.core)first. A new REPL always needs it.No such var: app.core/start-server: the REPL hasn't loaded your latest code. Save the file and run therequirewith:reloadagain.Unable to resolve symbol: url-decode(orparse-query,celsius->fahrenheit...): the function is defined below the code that uses it. Move it above.No such namespace: str: the[clojure.string :as str]line is missing from thensat the top of the file.Expected string, got nilon the page:parse-doublegotnil. Check thatconvert-pageuses(get params "celsius" ""), with the""default.Cannot invoke "Object.getClass()"on the page:convert-pageis doing math onnil. Check that the result paragraph is inside thewhen-letfrom Step 5.
What you learned
- Everything that comes from a URL is text.
parse-queryturnscelsius=20into a map, andparse-doubleturns the text"20"into a number. when-letruns some code only when a value is there.getcan take a default, like(get params "celsius" ""), for when the key isn't in the map.- The REPL lets you test your functions without having to wire them to the rest of the app and restart the server.
- An
atomholds a value that changes, like the running server.start-serverstops any old server before starting a new one, so restarting from the REPL is one line you can run again and again.
Next
So far, every page is a function you wrote. In the next part we'll build pages out of data: a blog that lists whatever files happen to be in a folder.