Building REST APIs with compojure-api

Kiran Karkera

Datacraft Sciences

Experience report

Options for building REST APIs in Clojure

Liberator

Compojure

Problem statement: Implementing an API Specification

API Groups

User / Dataset / Service provider management

User management

JSON encoded input

Possibly defined in Avro

{
    "name" : "actorInfo",
    "fields" : [{"name" : "actorid", "type" : "string"},

                {"name" : "name", "type" : "string"},

                {"name" : "age", "type" : "int"},

                {"name" : "phone", "type" : "string"}]
}

JSON encoded output

{
    "name" : "actorInfoResponse",
    "fields" : [{"name" : "actorid", "type" : "string"},

                {"name" : "privateKey", "type" : "string"},

                {"name" : "state", "type" : "string"},

                {"name" : "creationTime", "type" : "long"}]
}

Start hacking..

Issues

  • Manual schema validation
  • Type validation conversion
  • Content negotiation (e.g JSON vs EDN vs YAML vs ..)
  • Discoverability (e.g. Swagger)

Compojure-api

compojure-api.png

Definitions

Specify a ring handler

(def app
  (api
   ;;configuration

   (context "/some/path"
            ;;API Groups or Resources

            (GET "/someresource"
                 ;;GET/POST/PUT methods
                 ;;
                 ))))

Configuration

{:coercion :schema
 :swagger
 {:ui "/"
  :spec "/swagger.json"
  :data {:info {:title "Aquarium API"
                :description "API methods for Aquarium"}
         :tags [{:name "api",
                 :description "Manage assets, actors "}]
         :consumes ["application/json"]
         :produces ["application/json"]
         }}}

Specifying schemas

(s/defschema RegisterActorReq
  {:actorId s/Str
   (s/optional-key :name) s/Str
   (s/optional-key :privateKey) s/Str}
  )
(s/defschema RegisterActorResp
  {:actorId s/Str
   (s/optional-key :state) s/Str
   (s/optional-key :privateKey) s/Str
   (s/optional-key :creationTime) s/Num
   }
  )

Responding to HTTP methods: GET

(GET "/:actorId" []
     :return RegisterActorResp
     :path-params [actorId :- s/Str]
     :summary "Returns data on actor"
     (ok (get-actor actorId)))

Responding to HTTP methods: POST

(POST "/" []
      :return RegisterActorResp
      :body [user RegisterActorReq]
      :summary "Register an actor"
      (let [res (register-actor user)]
        (created nil res)))

Handling query parameters

(GET "/" [] :return AssetListResp
     :query-params [from :- s/Str,
                    to :- s/Str]
     (ok (some-function from to)

Gotchas

file download: tries to encode file as json disable response encoding

(GET "/:assetId" []
     :return File
     :path-params [assetId :- s/Str]
     :summary "Downloads asset from provider"
     (let [{:keys [path content-type]} (get-asset-map assetId)]
       (-> (io/file path)
           (io/input-stream)
           (ok)
           (header "Content-type" content-type)
           (muuntaja/disable-response-encoding)
           )))

Testing

Using Ring mock

(let [user {:actorId "actorid123"}
      response (app (-> (mock/request :post "http://server/api/v1/actors/")
                        (mock/content-type "application/json")
                        (mock/body  (cheshire/generate-string user))))
      body (cheshire/parse-string (slurp (:body response)) true)]
  (is (= (:status response) 201))
  (-> (s/check AssetResp body) nil? is)
  )

Helpful libs

[ring.util.http-response :refer [ok header created not-found]]
[ring.middleware.multipart-params :refer [wrap-multipart-params]]
[ring.swagger.upload :as upload]
[muuntaja.core :as muuntaja]

What I learned

  • Speed
  • Nice to demo & test

Open questions:

  • Argument tying: Should be REST API and business logic share arguments (such as map keys)
  • Messy mapping namespaced keywords
  • Bubbling up the right error messages
    • 4 mandatory arguments: 2 are missing or wrong
    • HTTP 400 Bad Request is insufficient.

Source

Tweet at @kaaldaari