DEV Community

Cover image for Learning Clojure from Scratch - Make It Yours
Thiago Carvalho
Thiago Carvalho

Posted on Originally published at tcds.dev

Learning Clojure from Scratch - Make It Yours

This is part three of a series teaching Clojure to people with little or no programming experience.
In Part 1 you ran a web server on your computer.
In Part 2 you put it on the internet.

In this part you'll go from a website that says Hello, World! to a website with a home page, an about page, and a polite message for URLs that don't exist.

Step 1: Look at what the server receives

Before we start building the new pages, let's look at the information available to handler.

Temporarily change handler in src/app/core.clj so it sends the request back to you as text:

(defn handler [req]
  {:status  200
   :headers {"Content-Type" "text/plain"}
   :body    (pr-str req)})
Enter fullscreen mode Exit fullscreen mode

Two changes: _req is now req because handler is using it, and the body is now (pr-str req), which means "print this value as text".

Remember the rhythm from Part 1: edit, stop, start, refresh.
Stop the server if it's still running (Ctrl+C), start it again with clojure -M:run, and open http://localhost:8080/hello?name=carvalho.
You'll see something like this (shortened here):

{:remote-addr "0:0:0:0:0:0:0:1",
 :headers {"accept" "text/html,...", "host" "localhost:8080", "user-agent" "Mozilla/5.0 ..."},
 :server-port 8080,
 :uri "/hello",
 :query-string "name=carvalho",
 :request-method :get,
 ...}
Enter fullscreen mode Exit fullscreen mode

That's the request. It's a map, just like the response.
Two of its keys matter for this series:

  • :uri is the part of the address after the host name: /hello. We'll use it in this part to choose which page to show.
  • :query-string is whatever comes after the ?: name=carvalho. We'll use it in the next part.

Change the address to /about or /anything-you-like and watch :uri change.
Now that you know what a request contains, let's build the new pages.

Step 2: Send back HTML instead of plain text

Put handler back to saying hello (and req back to _req, since we're not looking at the request for now), but make it send HTML text.
Change Content-Type from text/plain to text/html and write HTML in the body:

(defn handler [_req]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    "<h1>Hello, World!</h1>"})
Enter fullscreen mode Exit fullscreen mode

Content-Type tells the browser how to read what it receives.
text/plain means "show it as text", text/html means "this is an HTML page, interpret the tags".
<h1>...</h1> is the HTML tag for a big heading.

Restart, refresh, and the greeting becomes a heading.

If you're new to HTML, a few tags will get you far:

  • <h1>Title</h1> for a heading.
  • <p>Some text</p> for a paragraph.
  • <a href="https://example.com">a link</a> for a link.
  • <ul> and <li> for a bulleted list: the <ul> is the list, and each <li> is one item in it.
  • <nav> for a group of navigation links. You'll see it in the next step.

Step 3: Create a reusable page template

Every page of a website needs the same frame: a title, some styling, a navigation bar, and so on.
Typing that for each page would get old quickly.
So let's write a function that takes the title and the content of a page and wraps them in a frame.

Add this above handler (Clojure reads a file from top to bottom, so a function has to be defined before the code that uses it):

(defn page [title body]
  (str "<!DOCTYPE html><html><head><title>" title "</title>"
       "<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px}"
       "nav a{margin-right:12px}</style></head><body>"
       "<nav><a href=\"/\">Home</a><a href=\"/about\">About</a></nav>"
       body
       "</body></html>"))
Enter fullscreen mode Exit fullscreen mode

page receives two things, title and body, the same way handler receives req.
Most of the text inside it is the frame itself. The <style> and <nav> lines are decoration, so you don't need to understand them yet. Just type them in.

Two things to notice:

  • str joins things together into one piece of text. title and body get dropped between the other pieces.
  • To put a double quote inside a piece of text you write \", which is why href=\"/\" looks so busy. The backslash means "literally print this next character, I'm not trying to end the text."

Now use page to write two pages. Each one is a function with no inputs (that's the empty []), that returns HTML text:

(defn home-page []
  (page "Home"
        (str "<h1>Hi, I'm Carvalho</h1>"
             "<p>Welcome to my corner of the internet.</p>"
             "<ul>"
             "<li><a href=\"https://github.com\">GitHub</a></li>"
             "</ul>")))

(defn about-page []
  (page "About"
        (str "<h1>About Me</h1>"
             "<p>I'm learning Clojure and building my first website!</p>")))
Enter fullscreen mode Exit fullscreen mode

str shows up again here, joining the lines of each body into one piece of text before page wraps it.

Put your real name and real links in home-page.
Nothing changes in the browser yet. In the next step we'll connect these pages to addresses so you can see them.

Step 4: Show the right page

You have two pages and one handler. The handler needs to look at :uri, which you saw in Step 1, and choose what page to show.

Replace handler with:

(defn handler [req]
  (case (:uri req)
    "/"      {:status  200
              :headers {"Content-Type" "text/html"}
              :body    (home-page)}
    "/about" {:status  200
              :headers {"Content-Type" "text/html"}
              :body    (about-page)}))
Enter fullscreen mode Exit fullscreen mode

(:uri req) looks up the key :uri in the request map. (It's a Clojure shortcut: a keyword in the first position looks itself up in the map that follows.)
case then picks the option that matches that value: "/" gets the home page and "/about" gets the about page.

Restart and visit / and /about. You have a two-page website, with a navigation bar that works.

It works, but look at the two response maps: they're identical except for the :body.
Add a third page and you'd copy it a third time, and a fourth, and so on.
Whenever you catch yourself repeating the same work, consider moving it to a function so it can be reused.
Here's a small helper function that builds the response map.
The only thing that changes between pages, the body, becomes its input.
Add it above handler:

(defn html-response [status body]
  {:status  status
   :headers {"Content-Type" "text/html"}
   :body    body})
Enter fullscreen mode Exit fullscreen mode

Now handler can shrink to this:

(defn handler [req]
  (case (:uri req)
    "/"      (html-response 200 (home-page))
    "/about" (html-response 200 (about-page))))
Enter fullscreen mode Exit fullscreen mode

Restart and visit both pages again: nothing changed for the visitor, and your code is less repetitive.
The status is a parameter too, which is going to be handy in the next step.

Checkpoint: your website now has more than one page.

Step 5: Handle pages that don't exist

Visit http://localhost:8080/nothing-here.
Instead of something friendly, the browser shows No matching clause: /nothing-here.
case needs a match, and there wasn't one, so the server gave up (and answered with status 500, the web's code for "something broke on the server").

The fix is a catch-all as the last option: a page for "I don't know that one", with status 404, the web's code for "not found".
Add not-found-page above handler, and replace handler with the new version:

(defn not-found-page []
  (page "Not Found" "<h1>404 - Page Not Found</h1>"))

(defn handler [req]
  (case (:uri req)
    "/"      (html-response 200 (home-page))
    "/about" (html-response 200 (about-page))
    (html-response 404 (not-found-page))))
Enter fullscreen mode Exit fullscreen mode

Look at the last line of case: it has only a result, with no match value in front of it.
case treats that as the default, the answer when nothing else matched.

Restart and try /nothing-here again.

The 404 page looks like any other page, so how do you know the status really is 404? Your browser keeps track.
Right-click the page, choose Inspect, open the Network tab and reload. The Status column shows 404 for /nothing-here, and 200 for / and /about.
Status codes are mostly for machines, like browsers and search engines, which need to know what happened without reading the page.

Checkpoint: your website now answers every address: with a page when it knows one, and with a friendly 404 when it doesn't.

The whole file

Here is the full src/app/core.clj at this point. If something isn't working, compare yours to this one:

(ns app.core
  (:require [org.httpkit.server :as http])
  (:gen-class))

(defn page [title body]
  (str "<!DOCTYPE html><html><head><title>" title "</title>"
       "<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px}"
       "nav a{margin-right:12px}</style></head><body>"
       "<nav><a href=\"/\">Home</a><a href=\"/about\">About</a></nav>"
       body
       "</body></html>"))

(defn home-page []
  (page "Home"
        (str "<h1>Hi, I'm Carvalho</h1>"
             "<p>Welcome to my corner of the internet.</p>"
             "<ul>"
             "<li><a href=\"https://github.com\">GitHub</a></li>"
             "</ul>")))

(defn about-page []
  (page "About"
        (str "<h1>About Me</h1>"
             "<p>I'm learning Clojure and building my first website!</p>")))

(defn not-found-page []
  (page "Not Found" "<h1>404 - Page Not Found</h1>"))

(defn html-response [status body]
  {:status  status
   :headers {"Content-Type" "text/html"}
   :body    body})

(defn handler [req]
  (case (:uri req)
    "/"      (html-response 200 (home-page))
    "/about" (html-response 200 (about-page))
    (html-response 404 (not-found-page))))

(defn -main [& _args]
  (let [port (Integer/parseInt (or (System/getenv "PORT") "8080"))]
    (println (str "Server running on port " port))
    (http/run-server handler {:port port})))
Enter fullscreen mode Exit fullscreen mode

Deploy it

Same command as in Part 2:

vercel deploy --prod
Enter fullscreen mode Exit fullscreen mode

Vercel remembers your project from last time, so it shouldn't ask you the setup questions again.
Open the link and click around. Visit /about, and then /nothing-here to see your 404 page live.
You didn't need to change Dockerfile.vercel from Part 2: it copies the whole src folder, so your new code goes along automatically.
Your website is getting more interesting!

If something went wrong

  • The browser shows old content: the running program still has the old code. Stop the server with Ctrl+C, start it again, and refresh.
  • Unable to resolve symbol: html-response (or home-page, page...): Clojure reads the file from top to bottom, so a function has to be defined above the code that uses it. Move it above handler, and check the spelling of the name.
  • Syntax error reading source or Unmatched delimiter: a parenthesis is missing or extra. Remember the "Break it on purpose" section in Part 1: the line number in the error is where to start looking.
  • The page shows <h1> and other tags as text: the Content-Type is still text/plain. It has to be text/html.
  • No matching clause: /something: case didn't find a match and has no default. Add the last line from Step 5.

What you learned

  • The request is a map too: :uri tells handler which page the visitor asked for, and :query-string holds what comes after the ?.
  • Content-Type tells the browser how to read the response. text/html makes it interpret the HTML tags.
  • str joins pieces of text, and page uses it to wrap any content in the same frame (title, styling, and navigation), so every page of your website looks similar.
  • home-page, about-page and not-found-page are functions that return HTML text, and handler uses case to pick one based on :uri.
  • When you catch yourself repeating the same work, move it into a function. html-response did that for the response map.
  • A function has to be defined above the code that uses it.
  • Status codes are part of the response: 200 means OK and 404 means not found. Without a default, case fails and the server answers with a 500.

Next

In the next part we'll make a page that responds to what a user types, and you'll finally put :query-string from Step 1 to work.

Top comments (0)