Skip to content
Peter Ahlstöm edited this page Dec 24, 2019 · 13 revisions

compojure-api provides a powerful and easy to use way to build web APIs declaratively, with all kinds of fun stuff for free, like Schema validation and interactive Swagger docs. We're going to get started by building a simple API for rolling dice on the internet.

Getting Started

compojure-api provides a handy Leiningen template, so to start off with, let's run lein:

lein new compojure-api dice-api

If we navigate into our newly created dice-api directory, it should look something like this:

.
├── README.md
├── project.clj
└── src
    └── dice_api
        └── handler.clj

Let's open up handler.clj in our editor and take a look at what we start off with.

(ns dice-api.handler
  (:require [compojure.api.sweet :refer :all]
            [ring.util.http-response :refer :all]
            [schema.core :as s]))

(s/defschema Pizza
  {:name s/Str
   (s/optional-key :description) s/Str
   :size (s/enum :L :M :S)
   :origin {:country (s/enum :FI :PO)
            :city s/Str}})

(def app
  (api
    {:swagger
     {:ui "/"
      :spec "/swagger.json"
      :data {:info {:title "Dice-api"
                    :description "Compojure Api example"}
             :tags [{:name "api", :description "some apis"}]}}}

    (context "/api" []
      :tags ["api"]

      (GET "/plus" []
        :return {:result Long}
        :query-params [x :- Long, y :- Long]
        :summary "adds two numbers together"
        (ok {:result (+ x y)}))

      (POST "/echo" []
        :return Pizza
        :body [pizza Pizza]
        :summary "echoes a Pizza"
        (ok pizza)))))

That's a lot of stuff, and a good starting example in and of itself. We can see already an example of a lot of the stuff compojure-api can do. You can see, for instance, the Pizza schema, which we've used to define both an incoming and outgoing query schema for out /echo path.

Let's see how this all works. In your shell, run the following command to fire up the server:

lein ring server

Once it's started, it should open up a browser tab right to the Swagger docs, but if not, you can go to http://localhost:3000/index.html in your browser. It should look something like this:

Not very exciting yet, so we click "Show/Hide", and voila, it's our API!

This is helpfully autogenerated from us from the API we declared. You can click around to play with the template API, in particular, try calling the /echo path with the example input, then try again with one of the keys removed. Hooray for schema validated APIs! Next, we'll get to making our own API.

Making a route

To get started on making our own API, let's start by clearing away some of the sample code, so we have a clean slate to work with. remove the Pizza schema and the two GET/POST routes, so that you have a bare frame of our API context, like so:

(ns dice-api.handler
  (:require [compojure.api.sweet :refer :all]
            [ring.util.http-response :refer :all]
            [schema.core :as s]))

(def app
  (api
    {:swagger
     {:ui "/"
      :spec "/swagger.json"
      :data {:info {:title "Dice-api"
                    :description "Compojure Api example"}
             :tags [{:name "api", :description "some apis"}]}}}

    (context "/api" []
      :tags ["api"]

      )))

Now since we're creating a server to roll dice, the simplest starting route should be that: a route that returns the roll of a die. Now there's lots of different ways we could structure our data model for this, but let's follow the principle of "start simple, then grow."

Since we're in a JSON world now, let's make a simple schema for our return values:

(s/defschema Result
  {:result s/Int})

Now under our /api context, we'll make a simple base route, /roll that rolls a six-sided die and returns it as a Result.

(GET "/roll" []
  :return Result
  :summary "Rolls a six sided die"
  (ok {:result (inc (rand-int 6))}))

Now you can go over to your browser and give it a try, just head to http://localhost:3000/api/roll and you should see in your browser a JSON response containing a random number. You can also open up the Swagger UI and try out different response types like EDN or transit+json.

But what does it all mean?

Let's walk through the components of a route declaration line by line:

(GET "/roll" []

Here we start by calling GET, which is a macro that creates a route handler function. GET takes a variable series of arguments, starting with a string that defines the URI of the route within the current context (/api in our case, inherited from the parent context). This string can also contain identifiers for route parameters, but we'll get to that later.

The second argument that appears to be a map is actually a destructuring of the request body. We don't need to do any of this manually right now, but you can find out more about this in Routes in Detail.

:return Result
:summary "Rolls a six sided die"

After the URI and requests, a route takes a series of key-value arguments that let us do useful things like describe return and request schemas, and so forth. Here we have :return, which tells the route what Schema to check against our response body (in our case, the Result we defined earlier), and a :summary, which lets us give a helpful doc string description that's used by Swagger UI.

(ok {:result (inc (rand-int 6))}))

Finally the last part of a route is a body expression which needs to return a HTTP response. In our case, we're returning a simple OK, with a body containing a result map with our random die roll.

Notice also many of the things we didn't have to write: there's little boilerplate, the code is almost declarative, and things like JSON parsing/rendering, request mangling, error-checking of inputs/outputs, etc. are unnecesssary. We just tell compojure-api what we want our route to look like and what we expect, and it handles the rest. Simples!

Error checking and Handling parameters

So our existing route is pretty simple stuff, but it's not gonna do a whole lot or be very useful without being able to take parameters to tell it what kind of dice we want, how many, and so forth. We'll go through a couple of the many handy ways that compojure-api lets to handle parameters, but first let's throw in a quick function to make things easier for our needs:

(defn roll-dice [n s]
  (let [roll (take n (repeatedly #(inc (rand-int s))))]
    {:total (reduce + roll)
     :roll roll}))

We'll also want to update the result schema to match the return of our new helper function:

(s/defschema Result
  {:total s/Int
   :roll [s/Int]})

Now you can probably spot that we've also forgotten one more thing in our app, but before we fix it, go have another try at a request from our /api/roll route and see what happens. You should get something like this:

{
  "errors": {
    "total": "missing-required-key",
    "roll": "missing-required-key",
    "result": "disallowed-key"
  }
}

Look at that! Schema and compojure-api caught our bug: the /roll route hasn't been updated, so the response body doesn't match our new schema. Hooray for run-time error checking! Let's fix that real quick by updating the body of our route:

(ok (roll-dice 1 6))

Now, let's add a new route above our old GET route, with some path parameters to let us get multiple dice in different sizes:

(GET "/roll/:n/:s" []
  :path-params [n :- s/Int
                s :- s/Int]
  :return Result
  :summary "Rolls :n dice of sides :s"
  (ok (roll-dice n s)))

There are two steps to working with path params: the URI, and the :path-params key. Within the URI we can indicate which segments are to be values by preceding their names with a :. We then match those names to input schema with the argument to the :path-params key, which expects a vector of names assigned to schema.

You can now give this a try by going to http://localhost:3000/api/roll/4/6, which should return a lovely JSON response of both the dice it rolled and a total. We also have error-checking here too. Try using a string instead of an int in the url and see what happens: http://localhost:3000/api/roll/4/dave

We can also do body parameters, with schema checking. First we'll add a new schema for the body request:

(s/defschema Request
  {:num s/Int
   :sides s/Int})

Now let's add a POST route where we can send a Request and get a Result back:

(POST "/roll" []
  :body [{:keys [num sides]} Request]
  :return Result
  :summary "Given a correct request body with keys :num and :sides, returns result of roll"
  (ok (roll-dice num sides)))

At the :body key we can see also another useful feature, beloved by all Clojurists: destructuring! The :body key uses let syntax, so we can destructure our incoming body right there alongside the schema. As expected, the route also performs Schema checking on the incoming request body. Have a poke with Swagger UI and try what happens if you leave out a key or add one to the body that shouldn't be there.

Conclusion

This is of course only barely scratching the surface of what compojure-api is capable of, from other forms of parameter handling, middlewares, multipart params, clojure.spec support, and more, but this should be enough to get you started on how to work with the basics. You can find the complete code from this tutorial here.

Happy Clojuring!