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.

deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.jetty      {:mvn/version "0.7.0-beta-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:

src/pedestal.clj
(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.

Before we go on, remember the test? function? It’s idiomatic in Pedestal services to include an :env key in the service map to communicate the environment of the service. This currently does not affect the behavior of the service, but it’s a useful marker. Our component will leverage this idiom and will not start/stop the server if the service is configured for the test environment. The implementation is included below.

(defn test?
  [service-map]
  (= :test (:env service-map)))

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.

src/routes.clj
(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.

src/system.clj
(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.

test/system_test.clj
(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.

src/pedestal.clj
(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 {}))
src/routes.clj
(ns routes)

(defn respond-hello [request]
  {:status 200 :body "Hello, world!"})

(def routes
  #{["/greet" :get respond-hello :route-name :greet]})
src/system.clj
(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)))
test/system_test.clj
(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>
deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.jetty      {:mvn/version "0.7.0-beta-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.


1. This is useful for REPL-driven development, where mistakes happen. Without it, you may end up with a "zombie" Pedestal component and Jetty service keeping the port bound.