I wanted to use hal in one of my personal Clojure projects, but unfortunately the only library I could find (halresource) was somewhat out of date when compared to the current specification. So, I decided to roll my own, which works fine for me personally, but I'd like to make it publicly available. However, I don't have a good grasp on whether the code is good and the functions are straightforward and clear to others - hence this posting.
The code uses maps as representations of hal resources, and since json maps very well to Clojure, the maps can be directly serialized to JSON with Cheshire. The intended usage would be something like:
(-> (new-resource "somewhere.com")
(add-property :king "The King Of Somewhere")
(add-properties {:country "The Kingdom Of Somewhere"
:city "Some City"})
(add-link "embassy" "/embassy")
(add-embedded-resources :adjacent_countries
(new-resource "nowhere.com")
(-> (new-resource "azurecity.com")
(add-property :ruler "Lord Shojo"))))
which would produce:
{
"_embedded": {
"adjacent_countries": [
{
"_links": {
"self": {
"href": "nowhere.com"
}
}
},
{
"ruler": "Lord Shojo",
"_links": {
"self": {
"href": "azurecity.com"
}
}
}
]
},
"city": "Some City",
"country": "The Kingdom Of Somewhere",
"king": "The King Of Somewhere",
"_links": {
"embassy": {
"href": "\/embassy"
},
"self": {
"href": "somewhere.com"
}
}
}
Aside from general advice on best practices for writing Clojure code, I'd like to know:
- Is my usage of {:pre} sane and not abusive? Is there a better way to ensure that the input into my functions conforms to the hal specification without using defrecord or some other custom non-map data structure? Or, should I just go ahead and use defrecord, build a custom data types, and require you pass those in? Or, is it more idiomatic to just trust whatever input comes in and pass it through?
- Are the inputs to the functions well-chosen? For example, the add-properties function can be used with a map of key/value pairs, a collection of the form (:key value), or any number of inputs in the same form as map - for example, (add-properties res :a "b" :c "d"). Are there better function signatures I could expose?
- Are the docstrings helpful/clear?
- Does anything in here make you very confused and/or horrified?
I'm really liking Clojure, but I'm not entirely sure of how idiomatic/clear my code is, so any input you can give would be much appreciated.
(ns clj-hal.core
(:require [cheshire.core :as json]))
(defn templated-href-valid?
"Ensures that a link is minimally templated; full documentation is here:
http://tools.ietf.org/html/rfc6570"
[link]
(if (:templated (second (first link)))
(re-find #"\{.+\}" (:href (second (first link))))
true))
(def link-properties
#{:href :templated :type :deprecation :name :profile :title :hreflang})
(def reserved-resource-properties
#{:_links :_embedded})
(defn is-resource
[resource]
(-> resource :_links :self :href))
(defn new-resource
"Creates a new resource. Resources are maps with reserved keys _links and
_embedded, as well as a mandatory :self link."
[self]
{:pre [self
(not (empty? self))]}
{:_links {:self {:href self}}})
(defn new-link
"Creates a new link. Links are maps of the form {rel href & properties}.
Properties are keyword/value pairs. If the :templated property is true,
the href must be minimally templated."
[rel href & properties]
{:post [(not= (keyword (ffirst %)) :curies)
(every? link-properties (keys (second (first %))))
(templated-href-valid? %)]}
{(keyword rel) (apply hash-map :href href properties)})
;;; Creates a new curie
(defn new-curie
"Creates a new curie. Curies are a special form of links of the form
{:curies {:name name :href href :templated true & properties}}.
The properties :templated or :rel are fixed, and cannot be set."
[name-value href & properties]
{:pre [(not-any? #(= :rel (keyword %)) (take-nth 2 properties))
(not-any? #(= :templated (keyword %)) (take-nth 2 properties))]
:post [(every? link-properties (keys (second (first %))))
(templated-href-valid? %)]}
{:curies (apply hash-map :name (name name-value)
:href href
:templated true
properties)})
(defn add-link
"Adds a new link, optionally creating. If there already exists in links the
specified rel, it will turn it into a multi-link and add the new link.
Attempting to add a curie will cause an error."
([resource link]
{:pre [(not= :curies (keyword (ffirst link)))
(every? link-properties (keys (second (first link))))
(templated-href-valid? link)]}
(update-in resource
[:_links (ffirst link)]
#(let [contents (second (first link))]
(cond
(nil? %) contents
(map? %) (conj [] % contents)
:else (conj % contents)))))
([resource rel href & properties]
(add-link resource (apply new-link rel href properties))))
;;; Takes multiple links
(defn add-links
"Adds a variable number of links to the resource, merging links into rel.
Attempting to add a link with rel=\"curies\" will cause an error."
[resource & links]
(if (and (= 1 (count links)) (not (map? (first links))))
(reduce add-link resource (first links))
(reduce add-link resource links)))
(defn add-curie
"Creates and adds a new curie. Attempting to add a curie whose name already
exists will cause an error."
([resource curie]
{:pre [(= (ffirst curie) :curies)
(every? link-properties (keys (second (first curie))))
(:templated (second (first curie)))
(templated-href-valid? curie)
(not-any? #(= (:name (second (first curie))) (:name %))
(-> resource :_links :curies))]}
(update-in resource
[:_links :curies]
#((fnil conj []) % (second (first curie)))))
([resource name href & properties]
(add-curie resource (apply new-curie name href properties))))
(defn add-curies
"Adds multiple curies. Attempting to add a curie whose name already exists
will cause an error."
[resource & curies]
(if (and (= 1 (count curies)) (not (map? (first curies))))
(reduce add-curie resource (first curies))
(reduce add-curie resource curies)))
(defn add-property
"Adds a single property to the resource. If there already exists a property
with name, will overwrite the existing property. Attempting to add the
properties _links or _embedded will cause an error."
([resource [name value]]
(add-property resource name value))
([resource name value]
{:pre [(keyword name)
(not ((keyword name) reserved-resource-properties))]}
(conj resource [(keyword name) value])))
(defn add-properties
"Adds multiple properties to the resource. Can take a map of properties, a
collection, or a variable number of :key :value parameters. Existing
properties sharing names with the new properties will be overwritten.
Attempting to add the properties _links or _embedded will cause an error."
[resource & properties]
(if (= 1 (count properties))
(let [props (first properties)
pairs (if (map? props) props (partition 2 props))]
(reduce add-property resource pairs))
(add-properties resource properties)))
(defn add-embedded-resource
"Adds a single embedded resource mapped to the given rel in _embedded. If
there already exists one or more embedded resources mapped to rel, converts
the rel to a group of embedded resources and appends to it."
[resource rel embedded-resource]
{:pre [(keyword rel)
(is-resource resource)
(is-resource embedded-resource)]}
(update-in resource
[:_embedded (keyword rel)]
#(cond
(not %) embedded-resource
(map? %) (conj [] % embedded-resource)
:else (conj % embedded-resource))))
(defn add-embedded-resources
"Adds multiple embedded resources as members of a resource group mapped to
rel. If there already exist resources mapped to rel, adds the new resources
to the existing resource group. Takes the resources as:
* Single resource
* Collection of resources
* Variable-number arguments"
([resource rel embedded-resources]
{:pre [embedded-resources]}
(reduce #(add-embedded-resource %1 rel %2)
resource
(if (map? embedded-resources)
[embedded-resources]
embedded-resources)))
([resource rel embedded & more-embedded]
(add-embedded-resources resource rel (cons embedded more-embedded))))
(defn to-json [resource]
"Serializes to a json string using Cheshire."
(json/generate-string resource))