RESTful CRUD APIs Using Compojure-API and Toucan (Part-2)

Hi,

In the last blog post, we learned how to implement RESTful APIs using Compojure-API & Toucan. We are going to generalise that example by creating a little abstraction around it.

The abstraction that we are going to create is going to help us in creating similar RESTful endpoints for any domain entities with less code.

Let's dive in!

The Book Entity

To abstract what we did there, we need a few more specific implementation. So, let's repeat what we did there with another entity called "Book".

> psql -d restful-crud

restful-crud:> CREATE TABLE book (
                id SERIAL PRIMARY KEY,
                title VARCHAR(100) NOT NULL,
                year_published INTEGER NOT NULL
              );
CREATE TABLE

restful-crud:>

The next step after creating a book table is to create a Toucan model.

; src/restful_crud/models/book.clj
(ns resultful-crud.models.book
  (:require [toucan.models :refer [defmodel]]))

(defmodel Book :book)

Then create the schema for Book.

; src/restful_crud/book.clj
(ns resultful-crud.book
  (:require [schema.core :as s]
            [resultful-crud.string-util :as str]))

(defn valid-book-title? [title]
  (str/non-blank-with-max-length? 100 title))

(defn valid-year-published? [year]
  (<= 2000 year 2018))

(s/defschema BookRequestSchema
  {:title (s/constrained s/Str valid-book-title?)
   :year_published (s/constrained s/Int valid-year-published?)})

To expose the CRUD APIs let's repeat what we did for User.

; src/restful_crud/book.clj
(ns resultful-crud.book
  (:require ; ...
            [resultful-crud.models.book :refer [Book]]
            [toucan.db :as db]
            [ring.util.http-response :refer [ok not-found created]]
            [compojure.api.sweet :refer [GET POST PUT DELETE]]))

;; Create
(defn id->created [id]
  (created (str "/books/" id) {:id id}))

(defn create-book-handler [create-book-req]
  (-> (db/insert! Book create-book-req)
      :id
      id->created))

;; Get All
(defn get-books-handler []
  (ok (db/select Book)))

;; Get By Id
(defn book->response [book]
  (if book
    (ok book)
    (not-found)))

(defn get-book-handler [book-id]
  (-> (Book book-id)
      book->response))

;; Update
(defn update-book-handler [id update-book-req]
  (db/update! Book id update-book-req)
  (ok))

;; Delete
(defn delete-book-handler [book-id]
  (db/delete! Book :id book-id)
  (ok))

;; Routes
(def book-routes
  [(POST "/books" []
     :body [create-book-req BookRequestSchema]
     (create-book-handler create-book-req))
   (GET "/books" []
     (get-books-handler))
   (GET "/books/:id" []
     :path-params [id :- s/Int]
     (get-book-handler id))
   (PUT "/books/:id" []
     :path-params [id :- s/Int]
     :body [update-book-req BookRequestSchema]
     (update-book-handler id update-book-req))
   (DELETE "/books/:id" []
     :path-params [id :- s/Int]
     (delete-book-handler id))])

The last step is exposing these routes as HTTP endpoints.

; src/restful_crud/core.clj
(ns resultful-crud.core
  (:require 
+           [resultful-crud.book :refer [book-routes]]))
...
(def app (api {:swagger swagger-config} 
-             (apply routes user-routes)))
+             (apply routes (concat user-routes book-routes))))

The RESTful Abstraction

If we have a closer look at the routes & handlers of the CRUD operations of Book & User, there are many similarities that we can generalise so that we don't need to repeat the same for the other entities that we'll be adding in the system.

Create Handler

Let's start from create handler & route.

As we can see from the diagram, other than canonicalising the create request all the other things are similar across two implementations. We can view this implementation like a pipeline.

*-represents entity

We can take inspiration from Pedestal's interceptor, and model the pre-insert-hook as enter.

The abstracted version of create would look like

; src/restful_crud/restful.clj
(ns restful-crud.restful
  (:require [compojure.api.sweet :refer [POST]]
            [toucan.db :as db]
            [ring.util.http-response :refer [created]]))

(defn id->created [name id]
  (created (str "/" name "/" id) {:id id}))

(defn create-route [{:keys [name model req-schema enter]}] 1
  (let [enter-interceptor (or enter identity) 2
        path (str "/" name)]
    (POST path http-req
      :body [req-body req-schema]
      (->> (enter-interceptor req-body) 3
           (db/insert! model)
           :id
           (id->created name)))))

1 All the required parameters are received as a map and destructured using the :keys keyword

2 As enter interceptor is optional, we are using the identity function as a replacement if the enter interceptor doesn't exist.

3 In the request processing pipeline, we are transforming the incoming req-body using the enter-interceptor. Rest of the code is similar to our concrete implementation except that the actual domain entity related aspects are parameterised.

Get By Id Handler

The get-by-id handlers of user & book differ on what we do after we fetch it from the database.

As depicted in the image, in get-user-handler we dissoc the password_hash from the user instance. Again this can be viewed as a pipeline, where need a hook to transform the instance retrieved from the database.

This post-fetch-hook can be viewed as a leave interceptor and implemented as below.

; src/restful_crud/restful.clj
(ns restful-crud.restful
  (:require ; ...
            [schema.core :as s]
            [compojure.api.sweet :refer [... GET]]
            [ring.util.http-response :refer [... ok]]))
; ...

(defn resource-id-path [name]
  (str "/" name "/:id"))

(defn entity->response [entity]
  (if entity (ok entity) (not-found)))

(defn get-by-id-route [{:keys [name model leave]}]
  (let [leave-interceptor (or leave identity)
        path (resource-id-path name)]
    (GET path []
      :path-params [id :- s/Int]
      (-> (model id)
          leave-interceptor
          entity->response))))

We can do the same for other handlers as below.

Get All Handler

; src/restful_crud/restful.clj
; ...

(defn get-all-route [{:keys [name model leave]}]
  (let [leave-interceptor (or leave identity)
        path (str "/" name)]
    (GET path []
      (->> (db/select model)
           (map leave-interceptor)
           ok))))

Update Handler

; src/restful_crud/restful.clj
(ns restful-crud.restful
  (:require ; ...
            [compojure.api.sweet :refer [... PUT]]
            ...))
; ...

(defn update-route [{:keys [name model req-schema enter]}]
  (let [enter-interceptor (or enter identity)
        path (resource-id-path name)]
    (PUT path http-req
      :path-params [id :- s/Int]
      :body [req-body req-schema]
      (db/update! model id (enter-interceptor req-body))
      (ok))))

Delete Handler

; src/restful_crud/restful.clj
(ns restful-crud.restful
  (:require ; ...
            [compojure.api.sweet :refer [... DELETE]]
            ...))
; ...

(defn delete-route [{:keys [name model]}]
  (let [path (resource-id-path name)]
    (DELETE path []
      :path-params [id :- s/Int]
      (db/delete! model :id id)
      (ok))))

Combining All the Handlers

The last piece that we need to implement is a function that put all the above handlers together. By making use of the routes function from Compojure-Api, we can achieve it as below.

; src/restful_crud/restful.clj
(ns restful-crud.restful
  (:require ; ...
            [compojure.api.sweet :refer [... routes]]
            ...))
; ...
(defn resource [resource-config]
  (routes
   (create-route resource-config)
   (get-by-id-route resource-config)
   (get-all-route resource-config)
   (update-route resource-config)
   (delete-route resource-config)))

Using the RESTful abstraction

Now we have the functionality in-place for exposing CRUD endpoints for any domain entity. We can leverage it for the user & the book entity.

; src/restful_crud/user.clj
(ns restful-crud.book
  (:require ; ...
            [restful-crud.restful :as restful]))
; ...

(def user-entity-route
  (restful/resource {:model User
                     :name "users"
                     :req-schema UserRequestSchema
                     :leave #(dissoc % :password_hash)
                     :enter canonicalize-user-req}))
; src/restful_crud/book.clj
(ns restful-crud.book
  (:require ; ...
            [restful-crud.restful :as restful]))
; ...

(def book-entity-route
  (restful/resource {:model Book
                     :name "books"
                     :req-schema BookRequestSchema}))

Then expose them in the app.

; src/restful_crud/core.clj
(ns restful-crud.core
  (:require ; ...
            [restful-crud.user :refer [user-entity-route]]
            [restful-crud.book :refer [book-entity-route]])
  (:gen-class))

; (def app (api {:swagger swagger-config} 
;               (apply routes (concat user-routes book-routes))))
(def app (api {:swagger swagger-config} 
              (apply routes book-entity-route user-entity-route)))

If we want to expose RESTful CRUD APIs for a future entity, the steps that we need to follow are

  • Create a table
  • Add the Toucan model
  • Create a Schema for the request body
  • Create enter & leave interceptor functions (if required)
  • Expose the routes by calling the resource function with appropriate parameters.

That's it!

Summary

We have followed this approach in our production codebase and exposed APIs for a significant number of domain entities. When we started the project, we didn't have this abstraction. After exposing CRUD APIs for some entities, we realised the repetitions in the code and derived this approach.

The sample implementation in this blog post not covers certain aspects like error-handling, audit-entries(created-by, updated-by), pagination for brevity.

IMHO there is no perfect abstraction, and it applies to the one that we just saw as well. It was just perfect enough and enabled us to move faster.

*Credits- Alex Martelli's Tower of abstractions talk

The sample code is available on GitHub.

Related

If you like my content, you can extend your support by buying me a coffee. Thanks!
Buy Me A Coffee
comments powered by Disqus