This article describes how to build a web service based on Clojure and the Duct framework. It covers all the necessary details of every part of Duct needed for this task. On completion, the reader should be able to write a web service from scratch with tests, configurations, and components calling 3rd party services.
The article is for intermediate programmers with a basic knowledge of web services and Clojure.
Clojure is a really different programming language compared to conventional languages like Java, Kotlin, Javascript, or functional languages like F#. The very first thing that everybody spots are its parenthesized prefix notation. The notation may look odd, but it has a lot of advantages compared to C-like syntax:
- Compact syntax
- Simple syntax parser and highlighter
- No priority of operators struggle
- No breaking changes for new versions, due to new keyword/core function
- Easy to read any code, everything is a function
The second special thing about Clojure’s environment is that there is no standard framework like Django, Ruby On Rails, Spring in other languages. Clojure lets a programmer compose a framework from small libraries. I guess that this decision is based on the idea that there is no one hammer for all problems. This comes with a lot of consequences.
Pros
- A perfectly tailored framework to fit the problem.
- No limitation to replace marshalling, HTTP, DB, routing and other libraries.
- No overweight framework, only the parts used are in a project.
- Great for microservices.
Cons
- Hard at the beginning, experience with libraries needed.
- Very hard for beginners without architecture skills.
- Boilerplate code.
- No scaffolding (like in Ruby On Rails)
This post is about Duct. The framework is light and composed from other (well-known in the Clojure world) small libraries (as is almost everything in Clojure). These are the main parts that are covered by Duct:
- Configuration – local, production env., env. variables, …
- HTTP handler with an application server
- Database layer – a connection poll
- Prepared middlewares for common security, HTTP headers, Content negotiation, …
- Logging
- Error handling
- REPL – a code hot-swap
Architecture
The code
Let’s create a new project where we can see how to do general stuff with Duct. We are going to create a service for sending SMS messages.
We start by creating a project structure from Leiningen template by calling:
lein new duct sms +api +example
[lukas@hel:~/dev/flexiana]$ lein new duct sms +api +example
Generating a new Duct project named sms...
Run 'lein duct setup' in the project directory to create local config files.
[lukas@hel:~/dev/flexiana]$
Let’s describe the command and what was created:
- lein new generates a new Clojure project with Leiningen template
- duct is a name of a template
- sms is a name of a new project
- +api is an option that adds middleware for APIs
- +example is an option that adds some example code
You can find more options in Duct’s README file https://github.com/duct-framework/duct#quick-start
Leiningen created a folder called sms. As we can see in the result above, the command
lein duct setup
will create configuration files for a local development and these files should not be watched by a version control system. The command prints out what files have been created:
[lukas@hel:~/dev/flexiana/sms]$ lein duct setup
Created profiles.clj
Created .dir-locals.el
Created dev/resources/local.edn
Created dev/src/local.clj
[lukas@hel:~/dev/flexiana/sms]$
The template also generates the .gitignore file so you don’t have to alter the file manually.
The project structure
Leiningen generated a project from a template, let’s describe a project structure.
- README.md: This is the obvious one, this file describes a project, contains installation and other useful notes.
- dev: This folder contains files only for development mode. These files will not be part of a production JAR. It contains a configuration for the development and local environment (dev/resources/local.edn)
- profiles.clj: Allows to override profiles.
- project.clj: This is an important one. It contains project dependencies and plugins, build profiles, etc.
- resources: contains static files like: project configuration, images, Javascripts, CSS, SQL, etc. These files will be a part of the production JAR.
- src: contains all files that would be compiled: clj, cljc, cljs, cljx or java files
- test: All the tests. These files will not be part of the production JAR.
We should be able to run the project as it is right now, because we passed +example option when we were generating the project. Let’s check it if it’s working. We can start the REPL as usual (lein repl), load a development profile (call (dev) in the repl) and start the server (we can call (go) or (reset)). Both of these functions start the server, in the following steps we will use reset, because it refreshes the code and restarts the server.
[lukas@hel:~/dev/flexiana/sms]$ lein repl
nREPL server started on port 62063 on host 127.0.0.1 - nrepl://127.0.0.1:62063
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
Java HotSpot(TM) 64-Bit Server VM 10.0.1+10
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=> (dev)
:loaded
dev=> (reset)
:reloading (sms.handler.example sms.main sms.handler.example-test dev user)
:duct.server.http.jetty/starting-server {:port 3000}
:resumed
dev=>
If everything went well, the output should be almost the same. The interesting information in the output is that the server started on port 3000 (we can change this in resources/sms/config.edn). The leiningen’s template created the example handler, so we can hit this URL http://localhost:3000/example.
[lukas@hel:~/dev/flexiana/sms]$ curl localhost:3000/example
{"example":"data"}
[lukas@hel:~/dev/flexiana/sms]$
As we can see, it works.
The routes
By default the project template generates routes to <project-name>.handler/example. This is just a convention, technically you can put the routes anywhere you want. Our example route is in sms.handler.example namespace, when you open a file you should see something like:
(ns sms.handler.example
(:require [compojure.core :refer :all]
[integrant.core :as ig]))
(defmethod ig/init-key :sms.handler/example [_ options]
(context "/example" []
(GET "/" []
{:body {:example "data"}})))
The code is pretty small, but there are a few new things. Let’s describe them.
First, there is an Integrant component defined by defmethod ig/init-key. Integrant is a micro-framework that allows you to create components and their configuration, and compose them together (you can think about it as a small DI framework). A component has a life-cycle, but for now init-key would be enough for us. As we can see from its name, init-key is called when the component is being initialized. The name of the component is a namespaced keyword :sms.handler/example and it should follow the code namespace. Integrant tries to load both variants of namespaces: sms.handler.example and sms.handler you can find more about it in the documentation. The last thing for the component is its configuration/options (this is a Clojure map, it could contain other components), but this is not important for now.
Second thing is the route itself. The route is defined by the Compojure library. The usage of the library is pretty simple and probably the simplest for beginners. The route is defined by the macros context and GET. Both macros are imported from compojure.core namespace (A side note :using :all is probably not a good idea, it’s hard to say if a function is from the same namespace, imported by :refer or :all, see more).
The Context macro allows you to wrap more routes with the same prefix to remove a path redundancy. The GET macro simply takes a path segment to match (in our example just /example), a parameters vector (we take none currently), and a response body or function. The response must be a valid Ring response (the simplest example is a map with :body and :status keys).
Now we know how the routes are defined, but how does the framework know that there are any routes? Let’s open the project’s configuration resources/sms/config.edn.
{:duct.profile/base
{:duct.core/project-ns sms
:duct.router/cascading
[#ig/ref [:sms.handler/example]]
:sms.handler/example {}}
:duct.profile/dev #duct/include "dev"
:duct.profile/local #duct/include "local"
:duct.profile/prod {}
:duct.module/logging {}
:duct.module.web/api
{}}
As we said above :sms.handler/example is the route component. As you can see the component takes a Clojure map. We can pass another dependency to the component by referencing it (e.g. #ig/ref :duct.database/sql). #ig/ref is syntax sugar for referencing other components. In case you are curious about the details see the EDN documentation. If you want to see more details about it you can check the repository, but in short, it uses Hikari Connection Pool. We will not use a database in this article, so let’s move on.
On line 4 we can see a configuration for :duct.router/cascading, this component is a default router from the template and it takes a vector of references to other components. These components are route components. So the router component handles a connection between a request (Ring object) and the router itself.
The end of first part
We have briefly covered the Duct, how to create a project based on Duct, the project structure, and routing.
In the next part, we will discuss how to implement the API, how Duct helps with Dependency injection and we will introduce a concept of boundaries.