Fork me on GitHub

All together now

Knowing how to handle different request methods and the extension points for a liberator resource is one thing, putting all together in a un-complected way is not trivial. Fortunately it’s not hard either as the following example shows.

Handling a collection of resources

A typical model when you want to make some entities available is to use one resource for the collection of entities and a second resource which represents a single entity. This maps perfectly with the semantics of GET, PUT, POST and DELETE:

Resource Method Comment
list GET List of entities
list POST Create new entity
entry GET Get entity
entry DELETE Delete entity
entry PUT Replace entity

List resource

The list resources accept and produces application/json in this example. The body is parsed in :malformed and stored in the context under the key ::data. To keeps things simple post! generates a random number for the id and stores id under :id. We enable redirect after post and :location picks up the id to create the url where a resource for the created entity can be found.

In case of a GET we return a simple list of URLs pointing to resources for all entries.

First come some helper functions which might some day find their way into liberator.

;; convert the body to a reader. Useful for testing in the repl
;; where setting the body to a string is much simpler.
(defn body-as-string [ctx]
  (if-let [body (get-in ctx [:request :body])]
    (condp instance? body
      java.lang.String body
      (slurp (io/reader body)))))

;; For PUT and POST parse the body as json and store in the context
;; under the given key.
(defn parse-json [ctx key]
  (when (#{:put :post} (get-in ctx [:request :request-method]))
      (if-let [body (body-as-string ctx)]
        (let [data (json/read-str body)]
          [false {key data}])
        {:message "No body"})
      (catch Exception e
        (.printStackTrace e)
        {:message (format "IOException: %s" (.getMessage e))}))))

;; For PUT and POST check if the content type is json.
(defn check-content-type [ctx content-types]
  (if (#{:put :post} (get-in ctx [:request :request-method]))
     (some #{(get-in ctx [:request :headers "content-type"])}
     [false {:message "Unsupported Content-Type"}])

Then comes the resource for the list of entries.

;; we hold a entries in this ref
(defonce entries (ref {}))

;; a helper to create a absolute url for the entry with the given id
(defn build-entry-url [request id]
  (URL. (format "%s://%s:%s%s/%s"
                (name (:scheme request))
                (:server-name request)
                (:server-port request)
                (:uri request)
                (str id))))

;; create and list entries
(defresource list-resource
  :available-media-types ["application/json"]
  :allowed-methods [:get :post]
  :known-content-type? #(check-content-type % ["application/json"])
  :malformed? #(parse-json % ::data)
  :post! #(let [id (str (inc (rand-int 100000)))]
            (dosync (alter entries assoc id (::data %)))
            {::id id})
  :post-redirect? true
  :location #(build-entry-url (get % :request) (get % ::id))
  :handle-ok #(map (fn [id] (str (build-entry-url (get % :request) id)))
                   (keys @entries)))

Entry resource

The entry-resource implements access to a single entry. It supports GET, PUT and DELETE. Like the list-resource it accepts json for update and generates a json response.

An entries exists if the stored value is not nil. If the stored value is nil, the entry is gone (status 410). If there is no value stored at all, the entries does not exist (status 404).

On delete, the entry is set to nil and thus marked as gone.

Put requests replace the current entry and are only allowed if the entries exists (:can-put-to-missing). The function for :handle-ok might surprise at first sight: the keyword ::entry is used as a function and will lookup itself in the context.

(defresource entry-resource [id]
  :allowed-methods [:get :put :delete]
  :known-content-type? #(check-content-type % ["application/json"])
  :exists? (fn [_]
             (let [e (get @entries id)]
                    (if-not (nil? e)
                      {::entry e})))
  :existed? (fn [_] (nil? (get @entries id ::sentinel)))
  :available-media-types ["application/json"]
  :handle-ok ::entry
  :delete! (fn [_] (dosync (alter entries assoc id nil)))
  :malformed? #(parse-json % ::data)
  :can-put-to-missing? false
  :put! #(dosync (alter entries assoc id (::data %)))
  :new? (fn [_] (nil? (get @entries id ::sentinel))))

Here we use the syntax to define parametrized resources: (defresource entry-resource [id]), these go hand-in-hand with compojure’s routing parameters:

(defroutes collection-example
    (ANY ["/collection/:id{[0-9]+}"] [id] (entry-resource id))
    (ANY "/collection" [] list-resource))

Sample curl session:

$ curl -i -XPOST -H 'Content-Type: application/json' -d '{"data" : "Smile :-)"}' http://localhost:3000/collection
HTTP/1.1 303 See Other
Date: Fri, 20 Mar 2015 19:05:11 GMT
Location: http://localhost:3000/collection/61479
Vary: Accept
Content-Type: application/json;charset=ISO-8859-1
Content-Length: 0
Server: Jetty(7.6.13.v20130916)

$ curl -i -XPUT -H 'Content-Type: application/json' -d '{"data" : "Update here"}' http://localhost:3000/collection/61479
HTTP/1.1 204 No Content
Date: Fri, 20 Mar 2015 20:42:38 GMT
Content-Type: text/plain
Server: Jetty(7.6.13.v20130916)

$ curl -i -XDELETE http://localhost:3000/collection/61479
HTTP/1.1 204 No Content
Date: Fri, 20 Mar 2015 20:50:01 GMT
Content-Type: text/plain
Server: Jetty(7.6.13.v20130916)

Possible extensions

This example is far from being feature complete. It can be extended to support conditional requests and more media types. You can use authorized? to restrict access to the resources.