Now that you’ve gone through the basics in the Hello World guide it’s time to go one level deeper. We’re going to add two features. That will be our vehicle to talk about some important concepts in Pedestal.
After reading this guide, you will be able to:
Accept query parameters.
Use the request map.
Apply logic in your handler.
Conditionally return an error.
Return HTML in your response.
Like hello-world.adoc, this guide is for beginners who are new to Pedestal and may be new to Clojure. It doesn’t assume any prior experience with a Clojure-based web framework. You should be familiar with the basics of HTTP: URLs, response codes, and content types.
If you’ve already done some of those other things, you might want to skip ahead to your-first-api.adoc to start building some logic and multiple routes.
If you like to jump straight in to the deep end, you might be interested in the Pedestal Crash Course which assumes you know quite a bit about Clojure and web frameworks.
This guide also assumes that you are in a Unix-like development environment, with Java installed. We’ve tested it on Mac OS X and Linux (any flavor) with great results. We haven’t yet tried it on the Windows Subsystem for Linux, but would love to hear from you if you’ve succeeded with it there.
In this guide, we will build on the hello.clj
example from
the previous guide.
We will enhance it to take a single parameter in the URL and return a friendly greeting to that person.
There are some people we don’t want to meet, though. So we’ll also include a list of names that should not be said. Then we can make our handler function a bit smarter. If any forbidden name is uttered, our handler will return a 404 response instead of a cordial greeting.
If you worked through hello-world.adoc, then you already have all the files you need. If not, take a moment to grab the sources from that guide. That will be our starting point for enhancements this time.
HTTP offers a few different ways for the client to send data up to our service. The most basic is probably the URL query parameter. We usually see these on GET requests, but nothing stops a client from using them on other request types.
Because query parameters are so common, Pedestal handles them automatically. You can try that URL with your service right now.
As a reminder, we’re running the server by using the clj tool to start an interactive session (called a REPL, rhymes with ripple) then starting the service in that session. Before we do that, though, let’s discuss interactive development.
It’s really not optimal to be restarting your REPL in order to restart the service every time you need to make a change. In real Clojure development, we rarely restart the REPL. Instead, we make our system friendly to interactive development. The main problem so far is that starting the HTTP service doesn’t return. We can fix that by adding a key to the map that says "return instead of waiting for the server to stop."
link:example$hello-query/src/hello.clj[role=include]
1 | defonce here means we can recompile this file in the same REPL
without overwriting the value in this atom. |
2 | reset! replaces the current value in the atom with the new value. |
3 | Tell Pedestal not to wait for the server to exit. You set ::http/join? to true when running in production so that your main function does not exit and terminate the process. |
4 | This is a quick way to bounce the server. |
The key ::http/join? does the trick. We can now run start-dev
in
our REPL sessions. Instead of waiting forever, the REPL thread now
returns, prints the value of the server (an ugly mess!) and lets us
continue interacting. This is how we are going to start things in dev
from now on. Let’s try it out.
First, let’s start the service.
$ clj > clj Clojure 1.11.1 user=> (require 'hello) nil user=> (hello/start-dev) [main] INFO org.eclipse.jetty.util.log - Logging initialized @16010ms to org.eclipse.jetty.util.log.Slf4jLog [main] INFO org.eclipse.jetty.server.Server - jetty-9.4.52.v20230823; built: 2023-08-23T19:29:37.669Z; git: abdcda73818a1a2c705da276edb0bf6581e7997e; jvm 11.0.19+7-LTS [main] INFO org.eclipse.jetty.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@1227013b{/,null,AVAILABLE} [main] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@4ab4f6d7{HTTP/1.1, (http/1.1, h2c)}{localhost:8890} [main] INFO org.eclipse.jetty.server.Server - Started @16147ms #:io.pedestal.http{:port 8890, :service-fn #object[io.pedestal.http.impl.servlet_interceptor$interceptor_service_fn$fn__14835 0x4e27c500 "io.pedestal.http.impl.servlet_interceptor$interceptor_service_fn$fn__14835@4e27c500"], :host "localhost", :type :jetty, :start-fn #object[io.pedestal.http.jetty$server$fn__15089 0x6dbd38e8 "io.pedestal.http.jetty$server$fn__15089@6dbd38e8"], :interceptors [#Interceptor{:name :io.pedestal.http/log-request} #Interceptor{:name :io.pedestal.http/not-found} #Interceptor{:name :io.pedestal.http.ring-middlewares/content-type-interceptor} #Interceptor{:name :io.pedestal.http.route/query-params} #Interceptor{:name :io.pedestal.http.route/method-param} #Interceptor{:name :io.pedestal.http.secure-headers/secure-headers} #Interceptor{:name :io.pedestal.http.route/router} #Interceptor{:name :io.pedestal.http.route/path-params-decoder}], :routes ({:path "/greet", :method :get, :path-re #"/\Qgreet\E", :path-parts ["greet"], :interceptors [#Interceptor{}], :route-name :greet, :path-params []}), :servlet #object[io.pedestal.http.servlet.FnServlet 0x1a183473 "io.pedestal.http.servlet.FnServlet@1a183473"], :server #object[org.eclipse.jetty.server.Server 0x351bfccc "Server@351bfccc{STARTED}[9.4.52.v20230823]"], :join? false, :stop-fn #object[io.pedestal.http.jetty$server$fn__15091 0x2d7956fb "io.pedestal.http.jetty$server$fn__15091@2d7956fb"]} user=>
Whatever happened to "no news is good news?" Believe it or not, that is all successful output. The big glob at the end is the value of the server. It’s a big map with all the state in it. The most important thing to do with it is hang on to it so we can stop the server later. We do that by putting it in an atom.
Now we can use start-dev
, stop-dev
, and restart
as we make changes.
From another window, let’s send a request with a query parameter.
$ curl -i http://localhost:8890/greet\?name=Michael HTTP/1.1 200 OK Date: Thu, 12 Oct 2023 17:45:24 GMT Strict-Transport-Security: max-age=31536000; includeSubdomains X-Frame-Options: DENY X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block X-Download-Options: noopen X-Permitted-Cross-Domain-Policies: none Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; Content-Type: text/plain Transfer-Encoding: chunked Hello, world!%
What we’ve demonstrated is that Pedestal can route the incoming request, GET /greet?name=Michael
and isn’t
confused by the presence of the query parameter. Ultimately, the respond-hello
function was invoked,
and did its job. To update respond-hello
to match our goal, we first have to discuss the
request
argument passed to it.
Also, if you look in the console of your REPL, you’ll see that Pedestal logged the request:
[qtp81639554-16] INFO io.pedestal.http - {:msg "GET /greet", :line 80}
You’ll see slightly different results; the qtp
part is the thread name, for example. The point is, you can look
in the REPL session to verify that requests are being processed by your server.
Here’s a reminder of what respond-hello
currently looks like:
link:example$hello/src/hello.clj[role=include]
1 | The function takes one argument, which we call request |
2 | And it returns a map. |
We aren’t using the request argument right now, so we don’t really know what it is. (Well, it’s probably not too hard to guess that it has to do with the HTTP request that Pedestal received. From the title of this section, you probably also deduced that it’s a map!)
Let’s take a look at that request by echoing it back to the client. This is a common debugging trick that you can use.
link:example$hello-query/src/hello.clj[role=include]
1 | Instead of returning the string "Hello, world!" we return the entire request as the response body. |
Make sure you’ve update the hello.clj
file, then restart your service with that new definition:
user=> (require :reload 'hello)
nil
user=> (hello/restart)
[main] INFO org.eclipse.jetty.server.AbstractConnector - Stopped ServerConnector@4ab4f6d7{HTTP/1.1, (http/1.1, h2c)}{localhost:8890}
[main] INFO org.eclipse.jetty.server.handler.ContextHandler - Stopped o.e.j.s.ServletContextHandler@1227013b{/,null,STOPPED}
[main] INFO org.eclipse.jetty.server.Server - jetty-9.4.52.v20230823; built: 2023-08-23T19:29:37.669Z; git: abdcda73818a1a2c705da276edb0bf6581e7997e; jvm 11.0.19+7-LTS
[main] INFO org.eclipse.jetty.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@699a53d4{/,null,AVAILABLE}
[main] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@62832e24{HTTP/1.1, (http/1.1, h2c)}{localhost:8890}
[main] INFO org.eclipse.jetty.server.Server - Started @667023ms
#:io.pedestal.http{:port 8890, :service-fn #object[io.pedestal.http.impl.servlet_interceptor$interceptor_service_fn$fn__14835 0x1adcd53b "io.pedestal.http.impl.servlet_interceptor$interceptor_service_fn$fn__14835@1adcd53b"], :host "localhost", :type :jetty, :start-fn #object[io.pedestal.http.jetty$server$fn__15089 0x4474a287 "io.pedestal.http.jetty$server$fn__15089@4474a287"], :interceptors [#Interceptor{:name :io.pedestal.http/log-request} #Interceptor{:name :io.pedestal.http/not-found} #Interceptor{:name :io.pedestal.http.ring-middlewares/content-type-interceptor} #Interceptor{:name :io.pedestal.http.route/query-params} #Interceptor{:name :io.pedestal.http.route/method-param} #Interceptor{:name :io.pedestal.http.secure-headers/secure-headers} #Interceptor{:name :io.pedestal.http.route/router} #Interceptor{:name :io.pedestal.http.route/path-params-decoder}], :routes ({:path "/greet", :method :get, :path-re #"/\Qgreet\E", :path-parts ["greet"], :interceptors [#Interceptor{}], :route-name :greet, :path-params []}), :servlet #object[io.pedestal.http.servlet.FnServlet 0x47c90585 "io.pedestal.http.servlet.FnServlet@47c90585"], :server #object[org.eclipse.jetty.server.Server 0x50091386 "Server@50091386{STARTED}[9.4.52.v20230823]"], :join? false, :stop-fn #object[io.pedestal.http.jetty$server$fn__15091 0x7cf6ea5b "io.pedestal.http.jetty$server$fn__15091@7cf6ea5b"]}
user=>
Again, this is in two steps: (require :reload …)
to update all the Clojure code, then (hello/restart)
to stop the service and start a replacement, using the update functions.
With the update code live, you can use curl
again to see the change in the service’s behavior:
> curl -i http://localhost:8890/greet\?name=Michael HTTP/1.1 200 OK Date: Thu, 12 Oct 2023 17:54:46 GMT Strict-Transport-Security: max-age=31536000; includeSubdomains X-Frame-Options: DENY X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block X-Download-Options: noopen X-Permitted-Cross-Domain-Policies: none Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; Content-Type: application/edn Transfer-Encoding: chunked {:protocol "HTTP/1.1", :async-supported? true, :remote-addr "127.0.0.1", :params {:name "Michael"}, :servlet-response #object[org.eclipse.jetty.server.Response 0x702ce13 "HTTP/1.1 200 \nDate: Thu, 12 Oct 2023 17:54:46 GMT\r\nStrict-Transport-Security: max-age=31536000; includeSubdomains\r\nX-Frame-Options: DENY\r\nX-Content-Type-Options: nosniff\r\nX-XSS-Protection: 1; mode=block\r\nX-Download-Options: noopen\r\nX-Permitted-Cross-Domain-Policies: none\r\nContent-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;\r\nContent-Type: application/edn\r\n\r\n"], :servlet #object[io.pedestal.http.servlet.FnServlet 0x47c90585 "io.pedestal.http.servlet.FnServlet@47c90585"], :headers {"accept" "*/*", "user-agent" "curl/8.1.2", "host" "localhost:8890"}, :server-port 8890, :servlet-request #object[org.eclipse.jetty.server.Request 0x74cc13e0 "Request(GET //localhost:8890/greet?name=Michael)@74cc13e0"], :query-params {:name "Michael"}, :path-info "/greet", :url-for #object[clojure.lang.Delay 0x340f2def {:status :pending, :val nil}], :uri "/greet", :server-name "localhost", :query-string "name=Michael", :path-params {}, :body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x20486b9d "HttpInputOverHTTP@20486b9d[c=0,q=0,[0]=null,s=STREAM]"], :scheme :http, :request-method :get, :context-path ""}%
Yikes!
We need to unpack that a bit to make sense out of it. It’s a Clojure map, just printed all in one long line. I’ll reformat it with some line breaks. Syntax highlighting will help us read it too. Feel free to paste this map into your favorite text editor with Clojure syntax highlighting to pick it apart yourself.
I’m also going to omit several keys. They will be relevant later, but they’re not interesting now. And anyway, they don’t print very readably because they’re objects rather than data.
Here’s what we are left with.
{
:protocol "HTTP/1.1",
:async-supported? true,
:remote-addr "127.0.0.1",
:path-info "/greet",
:uri "/greet",
:query-string "name=Michael",
:query-params {:name "Michael"},
:params {:name "Michael"},
:headers {"user-agent" "curl/7.47.0", "accept" "*/*", "host" "localhost:8890"},
:server-port 8890,
:server-name "localhost",
:path-params [],
:scheme :http,
:request-method :get
}
This is a reference:request-map.adoc.
It really is just a Clojure map.
You access its values with get
and get-in
.
For now, we’ll look at three interesting keys.
The whole query string appears under the :query-string key. That’s the portion of the URL after, but not including, the '?' character. The query string can be a pain to deal with. It is URL-encoded and may have multiple parameters with delimiters. Pedestal slices that up into a map of parameters for you, which is attached to the :query-params key. Each parameter name gets turned into a keyword. Parameter values are always strings, though, so you may still have to parse them a bit.
You see that :params duplicates the same map as :query-params. Pedestal merges all the different kinds of parameters into one map for convenience. Most of the time, I know when I want a query parameter so I just use :query-params, though.
Inside our handler function, the :name parameter is available inside a nested map. Let’s dig it out and add it to our response.
link:example$hello-query/src/hello.clj[role=include]
1 | Extract the parameter, bind it to the name nm . |
2 | For the response body, concatenate "Hello," and the parameter into a string. |
Here we can just grab the value right out of the map and use it in the response.
Do the restart/reload dance again, and then use curl
to try a few different requests with parameters.
$ curl http://localhost:8890/greet\?name=Michael Hello, Michael $ curl http://localhost:8890/greet\?name=Pankaj Hello, Pankaj $ curl http://localhost:8890/greet\?name=Geeta Hello, Geeta $ curl http://localhost:8890/greet\?name=ไอรีนซีพีชาไอที Hello, ไอรีนซีพีชาไอที $ curl http://localhost:8890/greet\?name=No%20One%20To%20Be%20Trifled%20With Hello, No One To Be Trifled With $ curl http://localhost:8890/greet\?name= Hello, $ curl http://localhost:8890/greet Hello,
Dang. We were doing really well right up until the end there. Obviously we need to do something smarter when the input is missing. Bad user input is a way of life on the web, so let’s not make an error out of this. Instead, we just won’t personalize the greeting.
link:example$hello-query/src/hello.clj[role=include]
1 | Both nil and a zero-length string count as empty. |
2 | This is our fallback response. |
3 | And our personalized greeting. |
4 | Generating the response. |
Again, reload and restart, then try new requests with curl
:
$ curl http://localhost:8890/greet Hello, world! $ curl http://localhost:8890/greet\?name= Hello, world! $ curl http://localhost:8890/greet\?name=ไอรีนซีพีชาไอที Hello, ไอรีนซีพีชาไอที
Much better! But our handler function is getting a bit unwieldy. I don’t mean that it is a whole six lines long, but rather that it mixes too many concerns. This function parses inputs, applies "domain" logic, and deals with creating a response body. It would be better to refactor this to separate those concerns.
link:example$hello-query/src/hello.clj[role=include]
1 | This function can generate any "OK" result. |
2 | Our logic is now separated. |
3 | The handler now coordinates the rest. |
This version separates the concerns nicely. One big benefit is that
the greeting-for
function is now easier to test. It’s just a pure
function - no side effects - that doesn’t require any HTTP or Pedestal machinery to exercise.
Likewise, that ok
function is side-effect free and quite easy to test.
Both functions are likely to get more complex, but each one will only deal with its own
complexity.
In this example, we’re treating the case of an empty name the same as if the name parameter just isn’t included. See if you can make the handler more strict so it will return a "400 Bad Request" response if the name parameter is present but zero length.
There are some names that should not be spoken. Let’s enhance our service to avoid them. This will be a relatively simple change. We can hardcode the names as a set, then use that set as a function. (Clojure sets are functions that test for the presence of their argument in the set itself.)
This part needs to go before greeting-for
because we’ll use it to
make a decision in that function.
link:example$hello-query/src/hello.clj[role=include]
Now we need to think about our greeting function. Right now it returns
a string. We want to make sure the user gets a 404 if the query names
one of the forbidden ones. We could have greeting-for
directly
return the response map, but that starts to entangle the concerns we
just factored out. We’ll do the simplest thing that could possibly
work: is greeting-for
returns nil
, that means the request failed.
link:example$hello-query/src/hello.clj[role=include]
Because there are now three possible conditions that can happen in
respond-hello
, I’ve changed from an clj:if[] to clj:cond[]
expression. cond
uses pairs of "condition / value" clauses. So if
(unmentionables nm)
returns any truthy value, then cond
returns
nil
and stops evaluating. If (empty? nm)
returns true, then cond
returns the literal string "Hello, world!\n" and nothing else. If
neither of those happened, then cond
sees the keyword :else as a
condition[1]and the code continues to build the string the same way as before.
The second thing you’ll notice is that the handler now makes a
decision whether to call our ok
helper or the new function
not-found
. I do hope you weren’t expecting much in not-found
! It’s
as simple as it gets.
Restart your service and give it a try:
$ curl http://localhost:8890/greet\?name=Michael Hello, Michael $ curl http://localhost:8890/greet\?name=Rumplestiltskin Not found $ curl http://localhost:8890/greet\?name=曹操 Not found $ curl http://localhost:8890/greet\?name=voldemort Hello, voldemort $ curl http://localhost:8890/greet\?name=He%20who%20must%20not%20be%20named Hello, He who must not be named
Looks like it mostly works, though it has some trouble with different
capitalization. Take a look at the docs for clojure.string
and see
if you can figure out how to make the comparison case-insensitive.
Once again, we built this thing in small steps, so it may seem like there was a lot to deal with. The final product is still pretty small though. Here’s all the final code.
link:example$hello-query/src/hello.clj[role=include]
link:example$hello-query/src/hello.clj[role=include]
link:example$hello-query/src/hello.clj[role=include]
link:example$hello-query/src/hello.clj[role=include]
link:example$hello-query/src/hello.clj[role=include]
link:example$hello-query/deps.edn[role=include]
In this guide, we built upon hello-world.adoc to add:
Query parameters
General response functions
"Domain" logic
Conditional responses
We also learned about the [reference:request-map], which is central to any non-trivial handler, as well as one approach to dealing with invalid user input in the request.
So far, almost all our responses have been plain text. (When we
returned the request map in the body, the content type was actually
application/edn
but we can ignore that for now.)
Plain text is ugly, so we want to be able to return HTML. Rich clients don’t like HTML so much, so we also want to return JSON sometimes. The next stop on this trail looks at how Pedestal handles content types and response bodies. This will also be our first taste of interceptors, Pedestal’s core unit of work.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close