Using Pedestal With Component
Component is a popular and non-intrusive library for organizing Clojure logic; it makes it easy to define components, as maps or Clojure records, and organize them, with intra-component dependencies, into a system map.
It’s not uncommon for Pedestal to be setup to operate as a component with a Component system.
What You Will Learn
After reading this guide you will be able to:
-
Create a Component-based service using Pedestal.
-
Test your service using Pedestal’s test helpers.
Guide Assumptions
This guide is for users who are familiar with:
-
Clojure
-
Pedestal
-
Clojure’s CLI tooling
-
Component
If you are new to Pedestal, you may want to go back to the Hello World guide.
If you’re new to Component, you should definitely check it out first.
Getting Help if You’re Stuck
We’ll take this in small steps. If you get stuck at any point in this guide, please submit an issue about this guide or, hop over to the Pedestal Users mailing list and raise your hand there. You can also get help from the #pedestal channel on the Clojurians Slack.
Where We Are Going
In this guide, we’re going to step through creating a Pedestal service using Component. We’ll start off by creating a Pedestal component and wire it into a Component system map. We’ll then proceed to testing our service.
Before We Begin
We’ll limit our component’s responsibilities to lifecycle management of the Pedestal HTTP server and provider. We’ll also expose the Pedestal service function (:io.pedestal.http/service-fn) upon component initialization as a convenience for testing.
Route or interceptor management will not be a component responsibility because the management of routes/interceptors is more of an application-specific concern. This may be familiar to you if you studied the way the Pedestal lein template lays out a Pedestal service - routes, interceptors and configuration is kept separate from service plumbing.
Finally, Pedestal service configuration, captured via a service map, will be a component dependency.
Now that we have a better idea of what we want our component to do, let’s go build it!
Initial Project
The first step is to create a project directory to contain the project sources,
then create a deps.edn
file, to capture the dependencies of our application.
{:paths ["src"]
:deps {io.pedestal/pedestal.jetty {:mvn/version "0.7.1"}
com.stuartsierra/component {:mvn/version "1.1.0"}
org.slf4j/slf4j-simple {:mvn/version "2.0.9"}
com.stuartsierra/component.repl {:mvn/version "0.2.0"}}
:aliases
{:test
{:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1"
:git/sha "dfb30dd"}}
:exec-fn cognitect.test-runner.api/test}}}
A Simple Pedestal Component
Next, create a src
directory, and a pedestal.clj
file within it:
(ns pedestal (1)
(:require [com.stuartsierra.component :as component] (2)
[io.pedestal.http :as http])) (3)
1 | Create a pedestal namespace to house the Pedestal component. |
2 | We need to require com.stuartsierra.component namespace to make the
start and stop Lifecycle methods available. |
3 | We need to require the io.pedestal.http namespace for server creation,
starting and stopping. |
Let’s start implementing the component:
(defrecord Pedestal [service-map (1) service] component/Lifecycle (2)
1 | Create a Pedestal record. This record will contain a service-map field, whose value
will be supplied from another component, and a server field, managed by the Pedestal component, which is
the server created from the service map. |
2 | Include the component/Lifecycle protocol since we’ll be implementing its methods next. |
We’ll first implement the start
method. It will contain our
component initialization code.
(start [this] (if service (1) this (assoc this :service (2) (cond-> (http/create-server service-map) (3) (not (test? service-map)) http/start)))) (4)
1 | If the service is already running (because you mistakenly invoked component/start-system
on an already started system), let it keep running[1]. |
2 | Update the service field of the Pedestal component with the newly created,
and optionally started, service. |
3 | create-server returns a server map, which can be started via start . |
4 | In test mode, don’t start the server (the HTTP part), but we’ll still be able to feed requests into the interceptor pipeline. |
If you’ve read some of the other guides, this implementation should look somewhat familiar. It’s a combination of the server-specific code used in the Hello World guide.
Now let’s implement the stop
method. It will contain our component
teardown code.
(stop [this] (when (and service (not (test? service-map))) (1) (http/stop service)) (assoc this :service nil))) (2)
1 | Like start , stop will be idempotent. If the component has been
initialized and we’re not working with the test environment, we’ll
pass the initialized service map to the stop function. |
2 | Return the component with the service field set to nil . You
can’t use dissoc here since it would return a plain map, breaking
the component by converting it from a Pedestal record to a plain Clojure map. |
Now that we’ve got our component, we need a way to create and initialize an instance of it. Let’s tackle that next:
(defn new-pedestal [] (map->Pedestal {}))
Our component constructor is just a wrapper around the map-specific
record constructor created by defrecord
. The defrecord
macro
creates a number of constructors and any of them could be used here.
It’s common to create a simple wrapper function, as shown here; quite often, components grow to need additional setup and initialization which can occur in this kind of creation function. |
Now that we’ve got our Pedestal component, let’s proceed to wiring it into a full-fledged system.
Wiring it up
Create a routes.clj
file. This file will contain our routes and
handlers.
(ns routes)
(defn respond-hello [request]
{:status 200 :body "Hello, world!"})
The respond-hello
handler returns a simple static response. It may
look familiar since it made its first appearance in
the Hello World guide.
Finally, let’s implement the routes.
(def routes #{["/greet" :get respond-hello :route-name :greet]})
We’ll implement a single route, GET /greet
, using Pedestal’s tabular
routing syntax.
In this simple example, we use def , as the routes are entirely static.
In many applications, some parts of the routes would be more dynamic, and routes
would be a function with arguments.
|
Now that we’ve got our Pedestal Component and routes, we can wire them up in a Component system map.
Create a system.clj
file. This file will contain our system map and
system constructor.
(ns system
(:require [com.stuartsierra.component :as component] (1)
[com.stuartsierra.component.repl
:refer [reset set-init start stop system]] (2)
[io.pedestal.http :as http] (3)
pedestal (4)
routes)) (5)
1 | Require com.stuartsierra.component . It will be used to
create the Component system map. |
2 | Require component.repl for its system management functions. You would
normally do this in a dev namespace. |
3 | Require io.pedestal.http for the server start and stop functions. |
4 | Require pedestal for the Pedestal component. |
5 | Require routes for the application routes. |
Let’s create a system initialization function named system.
(defn new-system [env] (1) (component/system-map :service-map (2) {:env env (3) ::http/routes routes/routes (4) ::http/type :jetty ::http/port 8890 ::http/join? false} :pedestal (5) (component/using (6) (pedestal/new-pedestal) [:service-map])))
1 | It will take the system environment as a single parameter. We’ll use keywords like :prod or :test for this. |
2 | The system map will contain a :service-map key whose value is a Pedestal service map. This is still a component, even though it is purely data. |
3 | The service map’s :env key will map to our environment keyword. |
4 | We’ll configure the service map with our app-specific routes. |
5 | The system map will contain a :pedestal key whose value is an uninitialized Pedestal component. |
6 | The Pedestal component depends on the service map, so we will capture
that dependency with component/using . |
The next step sets up the use of the component.repl library.
(set-init (fn [old-system] (new-system :prod)))
The set-init
function stores a function that is used to start (or restart) the system.
When start
or reset
is invoked, this function is passed the old system (or nil if there is no old system),
and the function returns the new, but unstarted, system.
Running It
We’ll use clj
tool to run our
example. This should be familiar to you if you read through the
Hello World.
From the project’s root directory, fire up a repl, and start the system.
> clj Clojure 1.11.1 user=> (require 'system) nil user=> (require '[com.stuartsierra.component.repl :as crepl]) nil user=> (crepl/start) [main] INFO org.eclipse.jetty.util.log - Logging initialized @43694ms 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@b1078f2{/,null,AVAILABLE} [main] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@70ab102c{HTTP/1.1, (http/1.1, h2c)}{localhost:8890} [main] INFO org.eclipse.jetty.server.Server - Started @43830ms :ok user=>
You can now interact with the started service.
> curl -i http://localhost:8890/greet HTTP/1.1 200 OK Date: Tue, 17 Oct 2023 22:22:25 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!% >
Let’s stop the system.
user=> (crepl/stop) [main] INFO org.eclipse.jetty.server.AbstractConnector - Stopped ServerConnector@49b7eb3{HTTP/1.1,[http/1.1, h2c]}{localhost:8890} [main] INFO org.eclipse.jetty.server.handler.ContextHandler - Stopped o.e.j.s.ServletContextHandler@17c4dcc6{/,null,UNAVAILABLE} :ok
Our service is no longer available.
$ curl -i http://localhost:8890/greet curl: (7) Failed to connect to localhost port 8890: Connection refused
Let’s start it again!
user=> (crepl/start) [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@185d151b{/,null,AVAILABLE} [main] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@5dce3e{HTTP/1.1, (http/1.1, h2c)}{localhost:8890} [main] INFO org.eclipse.jetty.server.Server - Started @150674ms :ok user=>
It’s available again.
> curl -i http://localhost:8890/greet HTTP/1.1 200 OK Date: Tue, 17 Oct 2023 22:23:22 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!% >
The Component design pattern ensures that the system is in the correct state no matter how many times we do this.
Testing
Let’s move on to testing our new service. Recall that our service contains one
route, GET /greet
. We’d like to verify that it returns the proper
greeting. Before we can jump in and do that, though, we need to create
some helpers. Some are just useful in general, while others are
specific to our component implementation. Don’t worry, you won’t have
to write too much code. Let’s do it!
First create a system_test.clj
file in the src
directory.
(ns system-test
(:require [io.pedestal.http :as http]
[io.pedestal.http.route :as route]
[io.pedestal.test :refer [response-for]]
[com.stuartsierra.component :as component]
[clojure.test :refer :all]
routes
system
pedestal))
The system-test
namespace requires all the dependencies
necessary for testing.
Now let’s get to those helpers.
The url-for
helper allows us to refer to routes by
route-name. This is very useful, and is almost always adapted into new projects.
(def url-for (route/url-for-routes (route/expand-routes routes/routes)))
We need to expand the routes before invoking Pedestal’s
url-for-routes
function.
The end result is that url-for
is a function
The service-fn
helper extracts the Pedestal ::http/service-fn from
the started system. This helper allows us to keep focus on our
tests rather than test initialization.
(defn service-fn [system] (get-in system [:pedestal :service ::http/service-fn]))
The with-system
macro allows us to start/stop systems between test
executions. We’ll model its design on macros like with-open
and
with-redefs
so that its shape and usage is familiar.
(defmacro with-system [[bound-var binding-expr] & body] `(let [~bound-var (component/start ~binding-expr)] (try ~@body (finally (component/stop ~bound-var)))))
Now that we’ve got our helpers implemented, let’s move on to our
test. Create a test named greeting-test
.
(deftest greeting-test (with-system [sut (system/new-system :test)] (1) (let [service (service-fn sut) (2) {:keys [status body]} (response-for service :get (url-for :greet))] (3) (is (= 200 status)) (4) (is (= "Hello, world!" body))))) (5)
1 | sut (for system under test) will be bound to the started
system by with-system . Notice how :test is passed as the system
environment key; this ensures that the server does not start, and no HTTP port is bound. |
2 | Use the service-fn helper to extract the Pedestal service
function from the started system. |
3 | Use Pedestal’s response-for test helper to make a test request
to the :greet route. Use the url-for helper to refer to the route
by name. |
4 | We should get back a '200' status. |
5 | We should get back a response body of 'Hello, world!' |
Now let’s restart run the tests from the command line:
> clj -X:test Running tests in #{"test"} Testing system-test [main] INFO io.pedestal.http - {:msg "GET /greet", :line 90} Ran 1 tests containing 2 assertions. 0 failures, 0 errors. >
That’s it! You now know the fundamentals necessary for implementing and testing your Component-based Pedestal services.
The Whole Shebang
For reference, here are the complete contents of all the files.
(ns pedestal ;; <1>
(:require [com.stuartsierra.component :as component] ;; <2>
[io.pedestal.http :as http])) ;; <3>
(defn test?
[service-map]
(= :test (:env service-map)))
(defrecord Pedestal [service-map ;; <1>
service]
component/Lifecycle ;; <2>
(start [this]
(if service ;; <1>
this
(assoc this :service ;; <2>
(cond-> (http/create-server service-map) ;; <3>
(not (test? service-map)) http/start)))) ;; <4>
(stop [this]
(when (and service (not (test? service-map))) ;; <1>
(http/stop service))
(assoc this :service nil))) ;; <2>
(defn new-pedestal
[]
(map->Pedestal {}))
(ns routes)
(defn respond-hello [request]
{:status 200 :body "Hello, world!"})
(def routes
#{["/greet" :get respond-hello :route-name :greet]})
(ns system
(:require [com.stuartsierra.component :as component] ;; <1>
[com.stuartsierra.component.repl
:refer [reset set-init start stop system]] ;; <2>
[io.pedestal.http :as http] ;; <3>
pedestal ;; <4>
routes)) ;; <5>
(defn new-system
[env] ;; <1>
(component/system-map
:service-map ;; <2>
{:env env ;; <3>
::http/routes routes/routes ;; <4>
::http/type :jetty
::http/port 8890
::http/join? false}
:pedestal ;; <5>
(component/using ;; <6>
(pedestal/new-pedestal)
[:service-map])))
(set-init (fn [old-system] (new-system :prod)))
(ns system-test
(:require [io.pedestal.http :as http]
[io.pedestal.http.route :as route]
[io.pedestal.test :refer [response-for]]
[com.stuartsierra.component :as component]
[clojure.test :refer :all]
routes
system
pedestal))
(def url-for (route/url-for-routes
(route/expand-routes routes/routes)))
(defn service-fn
[system]
(get-in system [:pedestal :service ::http/service-fn]))
(defmacro with-system
[[bound-var binding-expr] & body]
`(let [~bound-var (component/start ~binding-expr)]
(try
~@body
(finally
(component/stop ~bound-var)))))
(deftest greeting-test
(with-system [sut (system/new-system :test)] ;; <1>
(let [service (service-fn sut) ;; <2>
{:keys [status body]} (response-for service
:get
(url-for :greet))] ;; <3>
(is (= 200 status)) ;; <4>
(is (= "Hello, world!" body))))) ;; <5>
{:paths ["src"]
:deps {io.pedestal/pedestal.jetty {:mvn/version "0.7.1"}
com.stuartsierra/component {:mvn/version "1.1.0"}
org.slf4j/slf4j-simple {:mvn/version "2.0.9"}
com.stuartsierra/component.repl {:mvn/version "0.2.0"}}
:aliases
{:test
{:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1"
:git/sha "dfb30dd"}}
:exec-fn cognitect.test-runner.api/test}}}
The Path So Far
At the beginning of this guide, we set out to create a Pedestal component, demonstrate its usage as well as how to test it without starting the http server. In the process, we also introduced a few general purpose test helpers.
Keep in mind that Pedestal services are highly configurable. It’s important to separate that configuration from the core component implementation. By limiting our component’s responsibilities to http server and Pedestal provider life cycle support, we can use it in a wide variety of Pedestal implementations.