├── .travis.yml ├── .gitignore ├── src └── octohipster │ ├── handlers │ ├── edn.clj │ ├── json.clj │ ├── yaml.clj │ ├── middleware.clj │ ├── core.clj │ ├── cj.clj │ ├── hal.clj │ └── util.clj │ ├── params │ ├── edn.clj │ ├── json.clj │ ├── yaml.clj │ ├── cj.clj │ └── core.clj │ ├── json.clj │ ├── host.clj │ ├── link │ ├── middleware.clj │ ├── util.clj │ └── header.clj │ ├── problems.clj │ ├── documenters │ ├── schema.clj │ └── swagger.clj │ ├── validator.clj │ ├── pagination.clj │ ├── routes.clj │ ├── util.clj │ ├── core.clj │ └── mixins.clj ├── spec └── octohipster │ ├── link_spec.clj │ ├── documenters │ ├── schema_spec.clj │ └── swagger_spec.clj │ ├── validator_spec.clj │ ├── params_spec.clj │ ├── mixins_spec.clj │ ├── core_spec.clj │ ├── pagination_spec.clj │ └── handlers_spec.clj ├── UNLICENSE ├── CODE_OF_CONDUCT.md ├── project.clj └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: clojure 2 | lein: lein2 3 | script: lein2 spec 4 | jdk: 5 | - openjdk6 6 | - openjdk7 7 | - oraclejdk7 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /junk 2 | /target 3 | /lib 4 | /classes 5 | /checkouts 6 | /doc 7 | /src/example.clj 8 | pom.xml 9 | *.jar 10 | *.class 11 | .lein-deps-sum 12 | .lein-failures 13 | .lein-plugins 14 | .lein-repl-history 15 | -------------------------------------------------------------------------------- /src/octohipster/handlers/edn.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.edn 2 | (:use [octohipster.handlers util])) 3 | 4 | (defhandler wrap-handler-edn 5 | "Wraps a handler with a EDN handler." 6 | ["application/edn"] 7 | (make-handler-fn pr-str)) 8 | -------------------------------------------------------------------------------- /src/octohipster/params/edn.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params.edn 2 | (:require [clojure.edn :as edn])) 3 | 4 | (def edn-params 5 | "EDN params support" 6 | ^{:ctype-re #"^application/(vnd.+)?edn"} 7 | (fn [body] 8 | (edn/read-string body))) 9 | -------------------------------------------------------------------------------- /src/octohipster/params/json.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params.json 2 | (:require [cheshire.core :as json])) 3 | 4 | (def json-params 5 | "JSON params support" 6 | ^{:ctype-re #"^application/(vnd.+)?json"} 7 | (fn [body] 8 | (json/parse-string body true))) 9 | -------------------------------------------------------------------------------- /src/octohipster/params/yaml.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params.yaml 2 | (:require [clj-yaml.core :as yaml])) 3 | 4 | (def yaml-params 5 | "YAML params support" 6 | ^{:ctype-re #"^(application|text)/(vnd.+)?(x-)?yaml"} 7 | (fn [body] 8 | (yaml/parse-string body))) 9 | -------------------------------------------------------------------------------- /src/octohipster/handlers/json.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.json 2 | (:use [octohipster.handlers util] 3 | [octohipster json])) 4 | 5 | (defhandler wrap-handler-json 6 | "Wraps a handler with a JSON handler." 7 | ["application/json"] 8 | (make-handler-fn jsonify)) 9 | -------------------------------------------------------------------------------- /src/octohipster/handlers/yaml.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.yaml 2 | (:require [clj-yaml.core :as yaml]) 3 | (:use [octohipster.handlers util])) 4 | 5 | (defhandler wrap-handler-yaml 6 | "Wraps a handler with a YAML handler." 7 | ["application/yaml" "application/x-yaml" "text/yaml" "text/x-yaml"] 8 | (make-handler-fn yaml/generate-string)) 9 | -------------------------------------------------------------------------------- /src/octohipster/params/cj.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params.cj 2 | (:require [cheshire.core :as json])) 3 | 4 | (defn- into-kv [{:keys [name value]}] 5 | [(keyword name) value]) 6 | 7 | (def collection-json-params 8 | "Collection+JSON params support" 9 | ^{:ctype-re #"^application/vnd\.collection\+json"} 10 | (fn [body] 11 | (->> (json/parse-string body true) 12 | :template :data 13 | (map into-kv) 14 | (into {})))) 15 | -------------------------------------------------------------------------------- /src/octohipster/handlers/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.middleware 2 | "Ring middleware for modifying the response before it gets 3 | encoded into the serialization format passed by handlers.") 4 | 5 | (defn wrap-response-envelope [handler] 6 | (fn [req] 7 | (let [rsp (handler req)] 8 | (if-let [k (:data-key rsp)] 9 | (if-not (:body-no-envelope? rsp) 10 | (assoc rsp :body {k (:body rsp)}) 11 | rsp) 12 | rsp)))) 13 | -------------------------------------------------------------------------------- /src/octohipster/params/core.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params.core) 2 | 3 | (defn wrap-params-formats [handler formats] 4 | (fn [req] 5 | (if-let [body (:body req)] 6 | (if-let [#^String ctype (:content-type req)] 7 | (if-let [f (->> formats 8 | (filter #(not (empty? (re-find (:ctype-re (meta %)) ctype)))) 9 | first)] 10 | (let [params (-> body slurp f) 11 | req* (assoc req 12 | :non-query-params (merge (:non-query-params req) params) 13 | :params (merge (:params req) params))] 14 | (handler req*)) 15 | (handler req)) 16 | (handler req)) 17 | (handler req)))) 18 | -------------------------------------------------------------------------------- /src/octohipster/json.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.json 2 | (:require [cheshire.core :as json]) 3 | (:use [octohipster util])) 4 | 5 | (defn jsonify [x] (json/generate-string x)) 6 | 7 | (defn unjsonify [x] (json/parse-string x true)) 8 | 9 | (defn serve-json [x] 10 | (fn [req] 11 | {:status 200 12 | :headers {"Content-Type" "application/json;charset=UTF-8"} 13 | :body (jsonify x)})) 14 | 15 | (defn serve-json-schema [x] 16 | (fn [req] 17 | {:status 200 18 | :headers {"Content-Type" "application/schema+json;charset=UTF-8"} 19 | :body (jsonify x)})) 20 | 21 | (defn serve-hal-json [x] 22 | (fn [req] 23 | {:status 200 24 | :headers {"Content-Type" "application/hal+json;charset=UTF-8"} 25 | :body (-> x 26 | (assoc :_links (assoc (or (:_links x) {}) :self {:href (context-relative-uri req)})) 27 | jsonify)})) 28 | -------------------------------------------------------------------------------- /src/octohipster/host.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.host) 2 | 3 | (def ^:dynamic *host* 4 | "Current HTTP Host" 5 | "") 6 | 7 | (defn wrap-host-bind 8 | "Ring middleware that wraps the handler in a binding 9 | that sets *host* to the HTTP Host header or :server-name 10 | if there's no Host header." 11 | [handler] 12 | (fn [req] 13 | (binding [*host* (str (-> req :scheme name) 14 | "://" 15 | (or (get-in req [:headers "host"]) 16 | (-> req :server-name)))] 17 | (handler req)))) 18 | 19 | (def ^:dynamic *context* 20 | "Current URL prefix (:context)" 21 | "") 22 | 23 | (defn wrap-context-bind 24 | "Ring middleware that wraps the handler in a binding 25 | that sets *context*." 26 | [handler] 27 | (fn [req] 28 | (binding [*context* (:context req)] 29 | (handler req)))) 30 | -------------------------------------------------------------------------------- /src/octohipster/link/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.link.middleware 2 | (:use [octohipster util] 3 | [octohipster.link util])) 4 | 5 | (defn wrap-add-links-1 [handler links k] 6 | (fn [req] 7 | (handler (assoc req k (concatv (or (k req) []) links))))) 8 | 9 | (defn wrap-add-link-templates 10 | "Ring middleware that adds specified templates to :link-templates." 11 | [handler tpls] (wrap-add-links-1 handler tpls :link-templates)) 12 | 13 | (defn wrap-add-links 14 | "Ring middleware that adds specified links to :links." 15 | [handler links] (wrap-add-links-1 handler links :links)) 16 | 17 | (defn wrap-add-self-link 18 | "Ring middleware that adds a link to the requested URI as rel=self to :links." 19 | [handler] 20 | (fn [req] 21 | (handler 22 | (assoc req :links 23 | (concatv (or (:links req) []) 24 | [{:href (context-relative-uri req) 25 | :rel "self"}]))))) 26 | -------------------------------------------------------------------------------- /src/octohipster/problems.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.problems 2 | (:require [clojure.string :as string]) 3 | (:use [octohipster host])) 4 | 5 | (defn wrap-expand-problems [handler problems] 6 | (fn [req] 7 | (let [rsp (handler req)] 8 | (if-let [prob (:problem rsp)] 9 | (let [{:keys [status title]} (prob problems)] 10 | (-> rsp 11 | (assoc :status status) 12 | (assoc :problem-type true) 13 | (assoc :body (-> rsp :body 14 | (assoc :title title) 15 | (assoc :problemType (str *host* *context* "/problems/" (name prob))))) 16 | (dissoc :problem))) 17 | rsp)))) 18 | 19 | (defn problemify-ctype [x] 20 | (string/replace x #"/([^\+]+\+)?" "/api-problem+")) 21 | 22 | (defn wrap-expand-problem-ctype [handler] 23 | (fn [req] 24 | (let [rsp (handler req)] 25 | (if (:problem-type rsp) 26 | (update-in rsp [:headers "content-type"] problemify-ctype) 27 | rsp)))) 28 | -------------------------------------------------------------------------------- /spec/octohipster/link_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.link-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster.link header])) 5 | 6 | (describe "make-link-header" 7 | (it "makes the link header" 8 | (should= (make-link-header []) nil) 9 | (should= (make-link-header [{:href "/hello" :rel "next"}]) 10 | "; rel=\"next\"") 11 | (should= (make-link-header [{:href "/hello" :rel "next"} 12 | {:href "/test" :rel "root" :title "thingy"}]) 13 | "; rel=\"next\", ; title=\"thingy\" rel=\"root\""))) 14 | 15 | (describe "wrap-link-header" 16 | (it "does not return the header when there are no :links" 17 | (let [app (wrap-link-header (fn [req] {:status 200 :headers {} :links [] :body ""}))] 18 | (should= (get (-> (request :get "/") app :headers) "Link") nil))) 19 | (it "returns the header when there are :links" 20 | (let [app (wrap-link-header (fn [req] {:status 200 :headers {} :links [{:rel "a" :href "b"}] :body ""}))] 21 | (should= (get (-> (request :get "/") app :headers) "Link") "; rel=\"a\"")))) 22 | -------------------------------------------------------------------------------- /src/octohipster/documenters/schema.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.documenters.schema 2 | (:use [octohipster core host mixins]) 3 | (:require [clojure.string :as string])) 4 | 5 | (defn- schema-from-res [res] 6 | (let [schema (:schema res)] 7 | [(-> schema :id keyword) schema])) 8 | 9 | (defn- schema-from-options [options] 10 | (->> options :resources 11 | (map schema-from-res) 12 | (into {}))) 13 | 14 | (defn schema-doc [options] 15 | (resource 16 | :url "/schema" 17 | :mixins [handled-resource] 18 | :exists? (fn [ctx] {:data (schema-from-options options)}))) 19 | 20 | (defn- links-for-groups [options] 21 | (->> options :groups 22 | (map :url) 23 | (map (fn [c] {:rel (string/replace c "/" "") :href (str *context* c)})))) 24 | 25 | (defn schema-root-doc [options] 26 | (resource 27 | :url "/" 28 | :mixins [handled-resource] 29 | :exists? (fn [ctx] 30 | {:links (links-for-groups options) 31 | :_embedded {:schema (assoc (schema-from-options options) 32 | :_links {:self {:href (str *context* "/schema")}})}}))) 33 | -------------------------------------------------------------------------------- /src/octohipster/handlers/core.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.core 2 | (:use [octohipster.link util] 3 | [octohipster util])) 4 | 5 | (defn wrap-handler-request-links [handler] 6 | (fn [ctx] 7 | (-> ctx 8 | (update-in [:links] concatv (-> ctx :request :links)) 9 | (update-in [:link-templates] concatv (-> ctx :request :link-templates)) 10 | handler))) 11 | 12 | (defn wrap-default-handler 13 | "Wraps a handler with default data transformers" 14 | [handler] 15 | (-> handler 16 | wrap-handler-request-links)) 17 | 18 | (defn collection-handler 19 | "Makes a handler that maps a presenter over data that is retrieved 20 | from the Liberator context by given data key (by default :data)." 21 | ([presenter] (collection-handler presenter :data)) 22 | ([presenter k] 23 | (fn [ctx] 24 | (-> ctx 25 | (assoc :data-key k) 26 | (assoc k (mapv presenter (k ctx))))))) 27 | 28 | (defn item-handler 29 | "Makes a handler that applies a presenter to data that is retrieved 30 | from the Liberator context by given data key (by default :data)." 31 | ([presenter] (item-handler presenter :data)) 32 | ([presenter k] 33 | (fn [ctx] 34 | (-> ctx 35 | (assoc :data-key k) 36 | (assoc k (presenter (k ctx))))))) 37 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/octohipster/link/util.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.link.util 2 | (:use [octohipster util] 3 | [org.bovinegenius.exploding-fish :only [normalize-path]]) 4 | (:require [clojure.string :as string])) 5 | 6 | (defn prepend-to-href [uri-context l] 7 | (assoc l :href (normalize-path (str uri-context (:href l))))) 8 | 9 | (defn response-links-and-templates [rsp] 10 | (concatv 11 | (:links rsp) 12 | (map #(assoc % :templated true) (:link-templates rsp)))) 13 | 14 | (defn links-as-map [l] 15 | (into {} 16 | (map (fn [x] [(:rel x) (-> x (dissoc :rel))]) l))) 17 | 18 | (defn links-as-seq [l] 19 | (mapv (fn [[k v]] (assoc v :rel k)) l)) 20 | 21 | (defn clinks-as-map [l] 22 | (->> l 23 | (apply concat) 24 | (apply hash-map) 25 | (map (fn [[k v]] [k {:href v}])))) 26 | 27 | (defn params-rel 28 | "Returns a function that expands a URI Template for a specified rel 29 | with request params and the item (determined by :item-key) added in post!, 30 | suitable for use as the :see-other parameter in a resource." 31 | [rel] 32 | (fn [ctx] 33 | (let [tpl (uri-template-for-rel {:link-templates (links-as-seq (clinks-as-map ((:clinks (:resource ctx)))))} rel) 34 | {:keys [params]} (:request ctx) 35 | item-key ((-> ctx :resource :item-key)) 36 | vars (merge params (item-key ctx))] 37 | (expand-uri-template tpl vars)))) 38 | -------------------------------------------------------------------------------- /spec/octohipster/documenters/schema_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.documenters.schema-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster core routes json] 5 | [octohipster.documenters schema])) 6 | 7 | (def contact-schema 8 | {:id "Contact" 9 | :type "object" 10 | :properties {:guid {:type "string"}}}) 11 | 12 | (defresource contact-collection) 13 | (defresource contact-entry :url "/{id}") 14 | (defgroup contact-group 15 | :url "/contacts" 16 | :add-to-resources {:schema contact-schema} 17 | :resources [contact-collection contact-entry]) 18 | (defroutes site 19 | :groups [contact-group] 20 | :documenters [schema-doc schema-root-doc]) 21 | 22 | (describe "schema-doc" 23 | (it "exposes schemas at /schema" 24 | (should= {:_links {:self {:href "/schema"}} 25 | :Contact contact-schema} 26 | (-> (request :get "/schema") 27 | (header "Accept" "application/hal+json") 28 | site :body unjsonify)))) 29 | 30 | (describe "schema-root-doc" 31 | (it "exposes schemas and groups at /" 32 | (should= {:_links {:self {:href "/"} 33 | :contacts {:href "/contacts"}} 34 | :_embedded {:schema {:_links {:self {:href "/schema"}} 35 | :Contact contact-schema}}} 36 | (-> (request :get "/") 37 | (header "Accept" "application/hal+json") 38 | site :body unjsonify)))) 39 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 12 | 13 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 14 | 15 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.1.0, available at [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/) 16 | -------------------------------------------------------------------------------- /src/octohipster/link/header.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.link.header 2 | (:use [octohipster util] 3 | [octohipster.link util])) 4 | 5 | (defn- make-link-header-field [[k v]] 6 | (format "%s=\"%s\"" (name k) v)) 7 | 8 | (defn- make-link-header-element [link] 9 | (let [fields (map make-link-header-field (dissoc link :href))] 10 | (format "<%s>%s" 11 | (:href link) 12 | (if (not (empty? fields)) 13 | (->> fields 14 | (interpose " ") 15 | (apply str "; ")) 16 | "")))) 17 | 18 | (defn make-link-header 19 | "Compiles a collection of links into the RFC 5988 format. 20 | Links are required to be maps. The :href key going into the <> part. 21 | eg. {:href \"/hello\" :rel \"self\" :title \"Title\"} 22 | -> ; rel=\"self\" title=\"Title\"" 23 | [links] 24 | (when-not (empty? links) 25 | (->> links 26 | (map make-link-header-element) 27 | (interpose ", ") 28 | (apply str)))) 29 | 30 | (defn- wrap-link-header-1 [handler k h] 31 | (fn [req] 32 | (let [rsp (-> req 33 | (assoc k (or (k req) [])) 34 | handler)] 35 | (-> rsp 36 | (assoc-in [:headers h] (-> rsp k make-link-header)) 37 | (dissoc k))))) 38 | 39 | (defn wrap-link-header 40 | "Ring middleware that compiles :links and :link-templates into 41 | Link and Link-Template headers using octohipster.link.header/make-link-header." 42 | [handler] 43 | (-> handler 44 | (wrap-link-header-1 :links "Link") 45 | (wrap-link-header-1 :link-templates "Link-Template"))) 46 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject octohipster "0.2.1-SNAPSHOT" 2 | :description "A hypermedia REST HTTP API library for Clojure" 3 | :url "https://github.com/myfreeweb/octohipster" 4 | :license {:name "Apache License 2.0" 5 | :url "http://www.apache.org/licenses/LICENSE-2.0"} 6 | :dependencies [[org.clojure/clojure "1.5.1"] 7 | [ring/ring-core "1.2.0-beta3"] 8 | [ring.middleware.jsonp "0.1.2"] 9 | [liberator "0.8.0"] 10 | [clout "1.1.0"] 11 | [cheshire "5.2.0"] 12 | [clj-yaml "0.4.0"] 13 | [inflections "0.8.1"] 14 | [org.bovinegenius/exploding-fish "0.3.3"] 15 | [com.github.fge/json-schema-validator "2.1.3"] 16 | [com.damnhandy/handy-uri-templates "1.1.7"]] 17 | :profiles {:dev {:dependencies [[speclj "2.7.2" :exclusions [org.clojure/clojure]] 18 | [com.novemberain/monger "1.4.2" :exclusions [org.clojure/clojure]] 19 | [http-kit "2.1.1" :exclusions [org.clojure/clojure]] 20 | [ring-mock "0.1.3" :exclusions [org.clojure/clojure]]]}} 21 | :plugins [[speclj "2.7.2"] 22 | [codox "0.6.4"] 23 | [lein-release "1.0.0"]] 24 | :bootclasspath true 25 | :lein-release {:deploy-via :lein-deploy} 26 | :repositories [["snapshots" {:url "https://clojars.org/repo" :creds :gpg}] 27 | ["releases" {:url "https://clojars.org/repo" :creds :gpg}]] 28 | :warn-on-reflection true 29 | :jar-exclusions [#"example.clj"] 30 | :codox {:exclude example 31 | :src-dir-uri "https://github.com/myfreeweb/octohipster/blob/master" 32 | :src-linenum-anchor-prefix "L"} 33 | :test-paths ["spec/"]) 34 | -------------------------------------------------------------------------------- /src/octohipster/handlers/cj.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.cj 2 | (:use [octohipster.handlers util] 3 | [octohipster.link util] 4 | [octohipster json util] 5 | [org.bovinegenius.exploding-fish :only [normalize-path]])) 6 | 7 | (defn- transform-map [[k v]] 8 | {:name k, :value (if (map? v) (mapv transform-map v) v)}) 9 | 10 | (defn- cj-wrap [ctx rel m] 11 | {:href (normalize-path (str (or (-> ctx :request :context) "") (self-link ctx rel m))) 12 | :data (mapv transform-map m)}) 13 | 14 | (defhandler wrap-handler-collection-json 15 | "Wraps handler with a Collection+JSON handler. Note: consumes links; 16 | requires wrapping the Ring handler with octohipster.handlers/wrap-collection-json." 17 | ["application/vnd.collection+json"] 18 | (fn [hdlr ctx] 19 | (let [rsp (hdlr ctx) 20 | links (response-links-and-templates rsp) 21 | dk (:data-key rsp) 22 | result (dk rsp) 23 | items (if (map? result) 24 | [(-> (cj-wrap ctx (name (or (:item-key ctx) :item)) result) 25 | (assoc :links (-> links 26 | links-as-map 27 | (dissoc "self") 28 | (dissoc "listing") 29 | links-as-seq)) 30 | (assoc :href (:href (get links "self"))))] 31 | (map (partial cj-wrap ctx dk) result)) 32 | coll {:version "1.0" 33 | :href (if-let [up (get links "listing")] 34 | (:href up) 35 | (-> ctx :request :uri)) 36 | :links (if (map? result) [] links) 37 | :items items}] 38 | (-> ctx resp-common 39 | (assoc :encoder jsonify) 40 | (assoc :body-no-envelope? true) 41 | (assoc :body {:collection coll}))))) 42 | -------------------------------------------------------------------------------- /src/octohipster/validator.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.validator 2 | (:import [com.github.fge.jsonschema.main JsonValidator JsonSchemaFactory] 3 | [com.github.fge.jsonschema.report ProcessingReport] 4 | [com.github.fge.jackson JsonLoader] 5 | [com.fasterxml.jackson.core JsonFactory] 6 | [com.fasterxml.jackson.databind JsonNode ObjectMapper] 7 | [java.io StringWriter]) 8 | (:require [cheshire.core :as json] 9 | [cheshire.factory :as factory]) 10 | (:use [octohipster json util] 11 | [octohipster.handlers util])) 12 | 13 | (def mapper (ObjectMapper.)) 14 | 15 | (defn ^JsonNode clojure->jsonnode [x] 16 | (JsonLoader/fromString (json/generate-string x))) ; any better way of doing this? 17 | 18 | (defn ^JsonValidator make-validator-object [] 19 | (.getValidator (JsonSchemaFactory/byDefault))) 20 | 21 | (defn make-validator [schema] 22 | (let [v (make-validator-object)] 23 | (fn [x] 24 | (.validate v (clojure->jsonnode schema) (clojure->jsonnode x))))) 25 | 26 | (defn is-success? [^ProcessingReport report] 27 | (.isSuccess report)) 28 | 29 | (defn to-clojure [^ProcessingReport report] 30 | (let [sw (StringWriter.) 31 | jgen (.createJsonGenerator (or factory/*json-factory* factory/json-factory) sw)] 32 | (.writeTree ^ObjectMapper mapper jgen (.asJson report)) 33 | (unjsonify (.toString sw)))) 34 | 35 | (defn wrap-json-schema-validator 36 | "Ring middleware that validates any POST/PUT requests 37 | (:non-query-params) against a given JSON Schema." 38 | [handler schema] 39 | (let [v (make-validator schema)] 40 | (fn [req] 41 | (if (#{:post :put} (-> req :request-method)) 42 | (let [result (-> req :non-query-params v)] 43 | (if (is-success? result) 44 | (handler req) 45 | {:body {:errors (to-clojure result)} 46 | :problem :invalid-data})) 47 | (handler req))))) 48 | -------------------------------------------------------------------------------- /src/octohipster/pagination.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.pagination 2 | (:use [octohipster util] 3 | [org.bovinegenius.exploding-fish :only [param]])) 4 | 5 | (def ^:dynamic *skip* 6 | "Skip parameter for database queries" 0) 7 | (def ^:dynamic *limit* 8 | "Limit parameter for database queries" 0) 9 | 10 | (defn wrap-pagination 11 | "Ring middleware that calculates skip and limit database parameters based on 12 | a counter function and request parameters, sets *skip* and *limit* to these values, 13 | adds first/prev/next/last links to the :links parameter in the response (for 14 | octohipster.link/wrap-link-header, octohipster.handlers/wrap-hal-json 15 | or any other middleware that consument :links)." 16 | [handler {counter :counter 17 | default-pp :default-per-page}] 18 | (fn [req] 19 | (let [page (if-let [pparam (get-in req [:query-params "page"])] 20 | (Integer/parseInt pparam) 21 | 1) 22 | per-page (if-let [pparam (get-in req [:query-params "per_page"])] 23 | (Integer/parseInt pparam) 24 | default-pp) 25 | last-page (-> (/ (counter req) per-page) Math/ceil int) 26 | last-page (if (== 0 last-page) 1 last-page) 27 | url (uri req) 28 | prev-links (if (not= 1 page) 29 | [{:rel "first" :href (param url "page" 1)} 30 | {:rel "prev" :href (param url "page" (- page 1))}] 31 | []) 32 | next-links (if (not= last-page page) 33 | [{:rel "next" :href (param url "page" (+ page 1))} 34 | {:rel "last" :href (param url "page" last-page)}] 35 | [])] 36 | (binding [*skip* (* per-page (- page 1)) 37 | *limit* per-page] 38 | (handler (assoc req :links 39 | (concatv (:links req) prev-links next-links))))))) 40 | -------------------------------------------------------------------------------- /src/octohipster/handlers/hal.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.hal 2 | (:use [octohipster.handlers util] 3 | [octohipster.link util] 4 | [octohipster json util])) 5 | 6 | (defn- add-self-link [ctx rel x] 7 | (assoc x :_links {:self {:href (self-link ctx rel x)}})) 8 | 9 | (defn- add-nest-link [ctx rel x y] 10 | (let [tpl (uri-template-for-rel ctx rel) 11 | href (expand-uri-template tpl (merge x y))] 12 | (-> y 13 | (assoc :_links {:self {:href href}})))) 14 | 15 | (defn- embedify [ctx x] 16 | (if-let [mapping (-> ctx :resource :embed-mapping)] 17 | (let [mapping (mapping)] 18 | (-> x 19 | (select-keys (filter #(not (mapping %)) (keys x))) 20 | (assoc :_embedded 21 | (into {} 22 | (map (fn [[k rel]] [k (mapv #(add-nest-link ctx rel x %) (x k))]) 23 | mapping))))) 24 | x)) 25 | 26 | (defhandler wrap-handler-hal-json 27 | "Wraps handler with a HAL+JSON handler. Note: consumes links; 28 | requires wrapping the Ring handler with octohipster.handlers/wrap-hal-json." 29 | ["application/hal+json"] 30 | (fn [hdlr ctx] 31 | (let [rsp (hdlr ctx) 32 | dk (:data-key rsp) 33 | result (dk rsp) 34 | links (-> rsp response-links-and-templates links-as-map) 35 | ik (if-let [from-ctx (-> ctx :resource :item-key)] 36 | (from-ctx) 37 | :item) 38 | result (cond 39 | (map? result) (embedify ctx result) 40 | (nil? result) {:_embedded (:_embedded rsp)} 41 | :else {:_embedded {dk (map (partial embedify ctx) 42 | (map (partial add-self-link ctx (name ik)) 43 | result))}})] 44 | (-> ctx resp-common 45 | (assoc :encoder jsonify) 46 | (assoc :body-no-envelope? true) 47 | (assoc :body (assoc result :_links links)))))) 48 | -------------------------------------------------------------------------------- /src/octohipster/documenters/swagger.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.documenters.swagger 2 | (:use [octohipster core host mixins]) 3 | (:require [clojure.string :as string])) 4 | 5 | (def api-version "1.0") 6 | (def swagger-version "1.1") 7 | 8 | (defn swagger-root [groups] 9 | {:apiVersion api-version 10 | :swaggerVersion swagger-version 11 | :basePath (str *host* *context*) 12 | :apis (map (fn [g] {:path (:url g), :description (:desc g)}) groups)}) 13 | 14 | (defn swagger-root-doc [options] 15 | (resource 16 | :url "/api-docs.json" 17 | :mixins [handled-resource] 18 | :exists? (fn [ctx] {:data (swagger-root (:groups options))}))) 19 | 20 | (defn doc->operation [res [k v]] 21 | (-> v 22 | (assoc :httpMethod (string/upper-case (name k))) 23 | (assoc :responseClass 24 | (or (:responseClass v) 25 | (let [id (-> res :schema :id)] 26 | (if (and (= k :get) (:is-multiple? res)) 27 | (str "Array[" id "]") 28 | id)))))) 29 | 30 | (defn resource->api [group res] 31 | {:path (str (:url group) (:url res)) 32 | :description (:desc res) 33 | :operations (map (partial doc->operation res) (:doc res))}) 34 | 35 | (defn resource->model [{:keys [schema]}] 36 | [(keyword (:id schema)) schema]) 37 | 38 | (defn swagger-api-decl [groups path] 39 | (let [group (->> groups 40 | (filter #(= (:url %) path)) 41 | first) 42 | resources (:resources group)] 43 | {:apiVersion api-version 44 | :swaggerVersion swagger-version 45 | :basePath (str *host* *context*) 46 | :resourcePath (:url group) 47 | :apis (map (partial resource->api group) resources) 48 | :models (into {} (map resource->model resources))})) 49 | 50 | (defn swagger-doc [options] 51 | (resource 52 | :url "/api-docs.json/{path}" 53 | :mixins [handled-resource] 54 | :exists? (fn [ctx] {:data (swagger-api-decl (:groups options) 55 | (str "/" (-> ctx :request :route-params :path)))}))) 56 | -------------------------------------------------------------------------------- /spec/octohipster/validator_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.validator-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster.params core json] 5 | [octohipster.handlers json edn] 6 | [octohipster.handlers.util :only [wrap-fallback-negotiation wrap-apply-encoder]] 7 | [octohipster routes json problems validator])) 8 | 9 | (defn handler [req] {:status 200}) 10 | (def schema 11 | {:id "Contact" 12 | :type "object" 13 | :properties {:name {:type "string"}} 14 | :required [:name]}) 15 | (def app (-> handler 16 | (wrap-json-schema-validator schema) 17 | (wrap-params-formats [json-params]) 18 | (wrap-expand-problems {:invalid-data {:status 422 19 | :title "Invalid data"}}) 20 | (wrap-fallback-negotiation [wrap-handler-edn wrap-handler-json]) 21 | wrap-apply-encoder 22 | )) 23 | 24 | (describe "wrap-json-schema-validator" 25 | (it "validates POST and PUT requests" 26 | (should= 200 27 | (-> (request :post "/") 28 | (content-type "application/json") 29 | (body (jsonify {:name "aaa"})) 30 | app :status)) 31 | (should= 200 32 | (-> (request :put "/") 33 | (content-type "application/json") 34 | (body (jsonify {:name "aaa"})) 35 | app :status)) 36 | (should= 422 37 | (-> (request :put "/") 38 | (content-type "application/json") 39 | (body (jsonify {:name 1234})) 40 | app :status)) 41 | (should= 422 42 | (-> (request :post "/") 43 | (content-type "application/json") 44 | (body (jsonify {:name 1234})) 45 | app :status))) 46 | (it "uses content negotiation" 47 | (should= "/problems/invalid-data" ; note: not using host binding in test -> no localhost 48 | (-> (request :post "/") 49 | (header "Accept" "application/edn") 50 | (content-type "application/json") 51 | (body (jsonify {:name 1234})) 52 | app :body read-string :problemType)))) 53 | -------------------------------------------------------------------------------- /spec/octohipster/params_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.params-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster.params core json cj yaml edn])) 5 | 6 | (defn app [req] (select-keys req [:non-query-params :params])) 7 | 8 | (describe "json-params" 9 | (it "appends params to :non-query-params and :params" 10 | (should= {:non-query-params {:a 1} 11 | :params {:a 1}} 12 | ((wrap-params-formats app [json-params]) 13 | (-> (request :post "/") 14 | (content-type "application/json") 15 | (body "{\"a\":1}")))))) 16 | 17 | (describe "collection-json-params" 18 | (it "appends params to :non-query-params and :params" 19 | (should= {:non-query-params {:a 1} 20 | :params {:a 1}} 21 | ((wrap-params-formats app [collection-json-params]) 22 | (-> (request :post "/") 23 | (content-type "application/vnd.collection+json") 24 | (body "{\"template\":{\"data\":[{\"name\":\"a\",\"value\":1}]}}")))))) 25 | 26 | (describe "yaml-params" 27 | (it "appends params to :non-query-params and :params" 28 | (doseq [ctype ["application/yaml" "application/x-yaml" 29 | "text/yaml" "text/x-yaml"]] 30 | (should= {:non-query-params {:a 1} 31 | :params {:a 1}} 32 | ((wrap-params-formats app [yaml-params]) 33 | (-> (request :post "/") 34 | (content-type ctype) 35 | (body "{a: 1}"))))))) 36 | 37 | (describe "edn-params" 38 | (it "appends params to :non-query-params and :params" 39 | (should= {:non-query-params {:a 1} 40 | :params {:a 1}} 41 | ((wrap-params-formats app [edn-params]) 42 | (-> (request :post "/") 43 | (content-type "application/edn") 44 | (body "{:a 1}"))))) 45 | 46 | (it "does not evaluate clojure" 47 | (should= {:non-query-params {:a '(+ 1 2)} 48 | :params {:a '(+ 1 2)}} 49 | ((wrap-params-formats app [edn-params]) 50 | (-> (request :post "/") 51 | (content-type "application/edn") 52 | (body "{:a (+ 1 2)}")))))) 53 | -------------------------------------------------------------------------------- /src/octohipster/routes.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.routes 2 | (:use [ring.middleware params keyword-params nested-params jsonp] 3 | [octohipster.documenters schema] 4 | [octohipster.params core json cj edn yaml] 5 | [octohipster.link header middleware] 6 | [octohipster.handlers util json edn yaml] 7 | [octohipster core problems host util])) 8 | 9 | (defn routes 10 | "Creates a Ring handler that routes requests to provided groups 11 | and documenters." 12 | [& body] 13 | (let [defaults {:params [json-params collection-json-params yaml-params edn-params] 14 | :documenters [schema-doc schema-root-doc] 15 | :groups [] 16 | :problems {:resource-not-found {:status 404 17 | :title "Resource not found"} 18 | :invalid-data {:status 422 19 | :title "Invalid data"}}} 20 | options (merge defaults (apply hash-map body)) 21 | {:keys [documenters groups params]} options 22 | problems (merge (:problems defaults) (:problems options)) 23 | resources (mapcat :resources (gen-groups groups)) 24 | raw-resources (mapcat :resources groups) 25 | docgen (partial gen-doc-resource 26 | (-> options 27 | (dissoc :documenters) 28 | (assoc :resources raw-resources))) 29 | resources (concat resources (map docgen documenters))] 30 | (-> resources gen-handler 31 | ; Links 32 | wrap-add-self-link 33 | wrap-link-header 34 | ; Params 35 | (wrap-params-formats params) 36 | wrap-keyword-params 37 | wrap-nested-params 38 | wrap-params 39 | ; Response 40 | (wrap-expand-problems problems) 41 | (wrap-fallback-negotiation [wrap-handler-json wrap-handler-edn wrap-handler-yaml]) 42 | wrap-apply-encoder 43 | wrap-expand-problem-ctype 44 | ; Headers, bindings, etc. 45 | wrap-cors 46 | wrap-json-with-padding 47 | wrap-context-bind 48 | wrap-host-bind))) 49 | 50 | (defmacro defroutes 51 | "Creates a Ring handler (see routes) and defines a var with it." 52 | [n & body] `(def ~n (routes ~@body))) 53 | -------------------------------------------------------------------------------- /src/octohipster/util.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.util 2 | (:require [clojure.string :as string]) 3 | (:import [com.damnhandy.uri.template UriTemplate] 4 | [java.net URLEncoder])) 5 | 6 | (defn concatv [& xs] (into [] (apply concat xs))) 7 | 8 | (defn assoc-map [x k f] (assoc x k (mapv f (k x)))) 9 | 10 | (defn unwrap [x y] (reduce #(%2 %1) x y)) 11 | 12 | (defn uri [req] (or (:path-info req) (:uri req))) 13 | 14 | (defn uri-template-for-rel [ctx rel] 15 | (-> (filter #(= (:rel %) rel) (or (-> ctx :link-templates) [])) 16 | first 17 | :href)) 18 | 19 | (defn uri-template->clout 20 | "Turns a URI Template into a Clout route, eg. /things/{name} -> /things/:name" 21 | [x] (string/replace x #"\{([^\}]+)\}" ":$1")) 22 | 23 | (defn expand-uri-template 24 | "Expands an RFC 6570 URI Template with a map of arguments." 25 | [tpl x] 26 | (let [tpl ^UriTemplate (UriTemplate/fromTemplate ^String tpl)] 27 | (doseq [[k v] x] 28 | (.set tpl (name k) (str v))) 29 | (.expand tpl))) 30 | 31 | (defn context-relative-uri 32 | "Returns the full context-relative URI of a Ring request (ie. includes the query string)." 33 | [req] 34 | (str (:context req) 35 | (uri req) 36 | (if-let [qs (:query-string req)] 37 | (str "?" qs) 38 | ""))) 39 | 40 | (defn wrap-handle-options-and-head 41 | "Ring middleware that takes care of OPTIONS and HEAD requests." 42 | [handler] 43 | (fn [req] 44 | (case (:request-method req) 45 | (:head :options) (-> req 46 | (assoc :request-method :get) 47 | handler 48 | (assoc :body nil)) 49 | (handler req)))) 50 | 51 | (defn wrap-cors 52 | "Ring middleware that adds CORS headers." 53 | [handler] 54 | (fn [req] 55 | (let [rsp (handler req)] 56 | (assoc rsp :headers 57 | (merge (-> rsp :headers) 58 | {"Access-Control-Allow-Origin" "*" 59 | "Access-Control-Allow-Headers" "Accept, Authorization, Content-Type" 60 | "Access-Control-Allow-Methods" "GET, HEAD, POST, DELETE, PUT"}))))) 61 | 62 | ; https://groups.google.com/d/msg/clojure-dev/9ctJC-LXNps/JwqpqzkgPyIJ 63 | (defn apply-kw 64 | "Like apply, but f takes keyword arguments and the last argument is 65 | not a seq but a map with the arguments for f" 66 | [f & args] 67 | {:pre [(map? (last args))]} 68 | (apply f (apply concat (butlast args) (last args)))) 69 | -------------------------------------------------------------------------------- /src/octohipster/core.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.core 2 | "Functions and macros for building REST APIs through 3 | creating resources, groups and routes." 4 | (:require [liberator.core :as lib] 5 | [clout.core :as clout] 6 | [clojure.string :as string]) 7 | (:use [octohipster util])) 8 | 9 | (defn resource 10 | "Creates a resource. Basically, compiles a map from arguments." 11 | [& body] (apply hash-map body)) 12 | 13 | (defmacro defresource 14 | "Creates a resource and defines a var with it, 15 | adding the var under :id as a namespace-qualified keyword." 16 | [n & body] `(def ~n (resource ~@body :id ~(keyword (str *ns* "/" n))))) 17 | 18 | (defn group 19 | "Creates a group, adding everything from :add-to-resources to all 20 | resources and applying mixins to them." 21 | [& body] 22 | (let [c (apply hash-map body)] 23 | (-> c 24 | (assoc-map :resources 25 | (comp (fn [r] (unwrap (dissoc r :mixins) (:mixins r))) 26 | (partial merge (:add-to-resources c)))) 27 | (dissoc :add-to-resources)))) 28 | 29 | (defmacro defgroup 30 | "Creates a group and defines a var with it." 31 | [n & body] `(def ~n (group ~@body))) 32 | 33 | (defn gen-resource [r] 34 | {:url (:url r) 35 | :handler (unwrap (apply-kw lib/resource r) (:middleware r))}) 36 | 37 | (defn- make-url-combiner [u] 38 | (fn [x] (assoc x :url (str u (:url x))))) 39 | 40 | (defn all-resources [cs] 41 | (mapcat #(map (make-url-combiner (:url %)) (:resources %)) cs)) 42 | 43 | (defn gen-group [resources c] 44 | (assoc-map c :resources 45 | (fn [r] 46 | (-> r 47 | (assoc-map :clinks 48 | (fn [[k v]] 49 | [k (->> resources (filter #(= v (:id %))) first :url)])) 50 | gen-resource 51 | (assoc :route (-> (str (:url c) (:url r)) uri-template->clout clout/route-compile)) 52 | (dissoc :url))))) 53 | 54 | (defn gen-groups [c] 55 | (map (partial gen-group (all-resources c)) c)) 56 | 57 | (defn gen-handler [resources] 58 | (fn [req] 59 | (if-let [h (->> resources 60 | (map #(assoc % :match (clout/route-matches (:route %) req))) 61 | (filter :match) 62 | first)] 63 | (let [{:keys [handler match]} h] 64 | (handler (assoc req :route-params match))) 65 | {:body {} 66 | :problem :resource-not-found}))) 67 | 68 | (defn gen-doc-resource [options d] 69 | (->> (group :url "", :resources [(d options)]) 70 | (gen-group (:groups options)) 71 | :resources first)) 72 | -------------------------------------------------------------------------------- /spec/octohipster/mixins_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.mixins-spec 2 | (:use [octohipster mixins core routes json] 3 | [ring.mock request] 4 | [speclj core])) 5 | 6 | (def post-bin (atom nil)) 7 | 8 | (defresource test-coll 9 | :mixins [collection-resource] 10 | :clinks {:item ::test-item} 11 | :data-key :things 12 | :exists? (fn [ctx] {:things [{:name "a"} {:name "b"}]}) 13 | :post! (fn [ctx] (->> ctx :request :non-query-params (reset! post-bin))) 14 | :count (constantly 2)) 15 | 16 | (defresource test-item 17 | :mixins [item-resource] 18 | :clinks {:collection ::test-coll} 19 | :url "/{name}" 20 | :data-key :thing 21 | :exists? (fn [ctx] {:thing {:name (-> ctx :request :route-params :name)}})) 22 | 23 | (defgroup test-ctrl 24 | :url "/test" 25 | :add-to-resources {:schema {:id "Test" 26 | :properties {:name {:type "string"}}}} 27 | :resources [test-coll test-item]) 28 | 29 | (defroutes test-app 30 | :groups [test-ctrl]) 31 | 32 | (describe "collection-resource" 33 | (it "outputs data using the presenter and handlers" 34 | (let [rsp (-> (request :get "/test") 35 | (header "Accept" "application/hal+json") 36 | test-app)] 37 | (should= {:_links {:item {:href "/test/{name}" 38 | :templated true} 39 | :self {:href "/test"}} 40 | :_embedded {:things [{:_links {:self {:href "/test/a"}} 41 | :name "a"} 42 | {:_links {:self {:href "/test/b"}} 43 | :name "b"}]}} 44 | (unjsonify (:body rsp))) 45 | (should= "application/hal+json" (-> rsp :headers (get "Content-Type"))))) 46 | 47 | (it "creates items" 48 | (let [rsp (-> (request :post "/test") 49 | (header "Accept" "application/json") 50 | (content-type "application/json") 51 | (body "{\"name\":\"1\"}") 52 | test-app)] 53 | (should= "/test/1" (-> rsp :headers (get "Location"))) 54 | (should= {:name "1"} @post-bin)))) 55 | 56 | (describe "item-resource" 57 | (it "outputs data using the presenter and handlers" 58 | (let [rsp (-> (request :get "/test/hello") 59 | (header "Accept" "application/hal+json") 60 | test-app)] 61 | (should= {:_links {:collection {:href "/test"} 62 | :self {:href "/test/hello"}} 63 | :name "hello"} 64 | (unjsonify (:body rsp)))))) 65 | -------------------------------------------------------------------------------- /src/octohipster/mixins.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.mixins 2 | (:require [liberator.core :as lib]) 3 | (:use [octohipster pagination problems validator util] 4 | [octohipster.handlers core json edn yaml hal cj util] 5 | [octohipster.link util])) 6 | 7 | (defn validated-resource [r] 8 | (update-in r [:middleware] conj #(-> % 9 | (wrap-json-schema-validator (:schema r)) 10 | (wrap-expand-problems (:handlers r))))) 11 | 12 | (defn handled-resource 13 | ([r] (handled-resource r item-handler)) 14 | ([r handler] 15 | (let [r (merge {:handlers [wrap-handler-json wrap-handler-edn wrap-handler-yaml 16 | wrap-handler-hal-json wrap-handler-collection-json] 17 | :data-key :data 18 | :presenter identity} 19 | r) 20 | {:keys [presenter data-key handlers]} r 21 | h (-> (handler presenter data-key) 22 | (unwrap handlers) 23 | (wrap-handler-add-clinks) 24 | wrap-default-handler)] 25 | (-> r 26 | (assoc :handle-ok h) 27 | (assoc :available-media-types 28 | (mapcat (comp :ctypes meta) (:handlers r))))))) 29 | 30 | (defn item-resource 31 | "Mixin that includes all boilerplate for working with single items: 32 | - validation (using JSON schema in :schema for PUT requests) 33 | - format handling 34 | - linking to the item's collection" 35 | [r] 36 | (let [r (merge {:method-allowed? (lib/request-method-in :get :put :delete) 37 | :collection-key :collection 38 | :respond-with-entity? true 39 | :new? false 40 | :can-put-to-missing? false} 41 | r)] 42 | (-> r 43 | validated-resource 44 | (handled-resource item-handler)))) 45 | 46 | (defn collection-resource 47 | "Mixin that includes all boilerplate for working with collections of items: 48 | - validation (using JSON schema in :schema for POST requests) 49 | - format handling 50 | - linking to the individual items 51 | - pagination" 52 | [r] 53 | (let [r (merge {:method-allowed? (lib/request-method-in :get :post) 54 | :data-key :data 55 | :item-key :item 56 | :post-redirect? true 57 | :is-multiple? true 58 | :default-per-page 25} 59 | r) 60 | {:keys [item-key count default-per-page]} r] 61 | (-> r 62 | (assoc :see-other (params-rel item-key)) 63 | (update-in [:middleware] conj 64 | #(wrap-pagination % {:counter count 65 | :default-per-page default-per-page})) 66 | validated-resource 67 | (handled-resource collection-handler)))) 68 | -------------------------------------------------------------------------------- /spec/octohipster/core_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.core-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster core routes mixins json])) 5 | 6 | (describe "defresource" 7 | (it "adds the id" 8 | (defresource aaa :a 1) 9 | (should= {:a 1 :id ::aaa} aaa))) 10 | 11 | (describe "group" 12 | (it "adds stuff to resources" 13 | (should= {:resources [{:a 1, :global 0} 14 | {:a 2, :global 0}]} 15 | (group :resources [{:a 1} {:a 2}] 16 | :add-to-resources {:global 0}))) 17 | 18 | (it "applies mixins to resources" 19 | (should= {:resources [{:a 1, :b 2, :c 2}]} 20 | (group :resources [{:a 1, :mixins [#(assoc % :b (:c %))]}] 21 | :add-to-resources {:c 2})))) 22 | 23 | (describe "routes" 24 | (it "assembles the ring handler" 25 | (let [rsrc {:url "/{name}", :handle-ok (fn [ctx] (str "Hello " (-> ctx :request :route-params :name)))} 26 | cntr {:url "/hello", :resources [rsrc]} 27 | r (routes :groups [cntr])] 28 | (should= "Hello me" 29 | (-> (request :get "/hello/me") r :body)))) 30 | 31 | (it "replaces clinks" 32 | (defresource clwhat 33 | :url "/what") 34 | (defresource clhome 35 | :url "/home" 36 | :clinks {:wat ::clwhat} 37 | :handle-ok (fn [ctx] (last (first ((-> ctx :resource :clinks)))))) 38 | (defgroup clone 39 | :url "/one" 40 | :resources [clhome]) 41 | (defgroup cltwo 42 | :url "/two" 43 | :resources [clwhat]) 44 | (defroutes clsite :groups [clone cltwo]) 45 | (should= "/two/what" 46 | (-> (request :get "/one/home") clsite :body))) 47 | 48 | (it "wraps with middleware" 49 | (defresource mwhello 50 | :url "/what" 51 | :middleware [(fn [handler] (fn [req] (handler (assoc req :from-middleware "hi"))))] 52 | :handle-ok (fn [ctx] (-> ctx :request :from-middleware))) 53 | (defroutes mwsite :groups [{:url "", :resources [mwhello]}]) 54 | (should= "hi" 55 | (-> (request :get "/what") mwsite :body))) 56 | 57 | (it "calls documenters" 58 | (defn dcdocumenter [options] 59 | (resource 60 | :url "/test-doc" 61 | :handle-ok (fn [ctx] (jsonify {:things (map (fn [r] {:url (:url r)}) (:resources options))})))) 62 | (defresource dchello 63 | :url "/what") 64 | (defroutes dcsite 65 | :groups [{:url "", :resources [dchello]}] 66 | :documenters [dcdocumenter]) 67 | (should= {:things [{:url "/what"}]} 68 | (-> (request :get "/test-doc") dcsite :body unjsonify))) 69 | 70 | (it "returns 404 as a problem" 71 | (let [{:keys [status headers body]} (dcsite (request :get "/whatever123"))] 72 | (should= 404 status) 73 | (should= "application/api-problem+json" (get headers "content-type")) 74 | (should= {:problemType "http://localhost/problems/resource-not-found" 75 | :title "Resource not found"} 76 | (unjsonify body))))) 77 | -------------------------------------------------------------------------------- /spec/octohipster/pagination_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.pagination-spec 2 | (:use [octohipster pagination] 3 | [octohipster.link header] 4 | [ring.middleware params] 5 | [ring.mock request] 6 | [speclj core])) 7 | 8 | (defn app [req] 9 | {:status 200 10 | :headers {} 11 | :links (:links req) 12 | :body (str {:limit *limit* :skip *skip*})}) 13 | 14 | (def app-with-zero 15 | (-> app 16 | (wrap-pagination {:counter (constantly 0) 17 | :default-per-page 5}) 18 | wrap-link-header 19 | wrap-params)) 20 | 21 | (def app-1-page 22 | (-> app 23 | (wrap-pagination {:counter (constantly 4) 24 | :default-per-page 5}) 25 | wrap-link-header 26 | wrap-params)) 27 | 28 | (def app-2-pages 29 | (-> app 30 | (wrap-pagination {:counter (constantly 20) 31 | :default-per-page 10}) 32 | wrap-link-header 33 | wrap-params)) 34 | 35 | (def app-4-pages 36 | (-> app 37 | (wrap-pagination {:counter (constantly 20) 38 | :default-per-page 5}) 39 | wrap-link-header 40 | wrap-params)) 41 | 42 | (describe "wrap-pagination" 43 | (it "gives skip and limit parameters to the app" 44 | (let [rsp (-> (request :get "/") app-1-page :body)] 45 | (should= rsp "{:limit 5, :skip 0}")) 46 | (let [rsp (-> (request :get "/") app-2-pages :body)] 47 | (should= rsp "{:limit 10, :skip 0}")) 48 | (let [rsp (-> (request :get "/?page=2") app-2-pages :body)] 49 | (should= rsp "{:limit 10, :skip 10}")) 50 | (let [rsp (-> (request :get "/?page=2") app-4-pages :body)] 51 | (should= rsp "{:limit 5, :skip 5}")) 52 | (let [rsp (-> (request :get "/?page=3") app-4-pages :body)] 53 | (should= rsp "{:limit 5, :skip 10}")) 54 | (let [rsp (-> (request :get "/?page=4") app-4-pages :body)] 55 | (should= rsp "{:limit 5, :skip 15}"))) 56 | 57 | (it "returns correct link headers" 58 | (let [rsp (-> (request :get "/") app-with-zero)] 59 | (should= (get-in rsp [:headers "Link"]) nil)) 60 | (let [rsp (-> (request :get "/") app-1-page)] 61 | (should= (get-in rsp [:headers "Link"]) nil)) 62 | (let [rsp (-> (request :get "/") app-2-pages)] 63 | (should= (get-in rsp [:headers "Link"]) "; rel=\"next\", ; rel=\"last\"")) 64 | (let [rsp (-> (request :get "/?page=2") app-2-pages)] 65 | (should= (get-in rsp [:headers "Link"]) "; rel=\"first\", ; rel=\"prev\"")) 66 | (let [rsp (-> (request :get "/?page=2") app-4-pages)] 67 | (should= (get-in rsp [:headers "Link"]) "; rel=\"first\", ; rel=\"prev\", ; rel=\"next\", ; rel=\"last\"")) 68 | (let [rsp (-> (request :get "/?page=3") app-4-pages)] 69 | (should= (get-in rsp [:headers "Link"]) "; rel=\"first\", ; rel=\"prev\", ; rel=\"next\", ; rel=\"last\"")) 70 | (let [rsp (-> (request :get "/?page=4") app-4-pages)] 71 | (should= (get-in rsp [:headers "Link"]) "; rel=\"first\", ; rel=\"prev\"")))) 72 | -------------------------------------------------------------------------------- /src/octohipster/handlers/util.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers.util 2 | "Tools for creating handlers from presenters. 3 | 4 | Presenters are functions that process data from the database 5 | before sending it to the client. The simplest presenter is 6 | clojure.core/identity - ie. changing nothing. 7 | 8 | Handlers are functions that produce Ring responses from 9 | Liberator contexts. You pass handlers to resource parameters, 10 | usually :handle-ok. 11 | 12 | Handlers are composed like Ring middleware, but 13 | THEY ARE NOT RING MIDDLEWARE. They take a Liberator 14 | context as an argument, not a Ring request. 15 | When you create your own, follow the naming convention: 16 | wrap-handler-*, not wrap-*." 17 | (:require [liberator.conneg :as neg]) 18 | (:use [octohipster util host] 19 | [octohipster.handlers core])) 20 | 21 | (defn resp-common [ctx] 22 | {:data-key (:data-key ctx)}) 23 | 24 | (defn resp-linked [ctx] 25 | (-> ctx resp-common 26 | (assoc :links (:links ctx)) 27 | (assoc :link-templates (:link-templates ctx)))) 28 | 29 | (defn self-link [ctx rel x] 30 | (when-let [tpl (uri-template-for-rel ctx rel)] 31 | (expand-uri-template tpl x))) 32 | 33 | (defn templated? [[k v]] 34 | (.contains v "{")) 35 | 36 | (defn expand-clinks [x] 37 | (map (fn [[k v]] {:rel (name k), :href (str *context* v)}) x)) 38 | 39 | (def process-clinks 40 | (memoize 41 | (fn [clinks] 42 | (let [clinks (apply hash-map (apply concat clinks))] 43 | [(expand-clinks (filter (complement templated?) clinks)) 44 | (expand-clinks (filter templated? clinks))])))) 45 | 46 | (defn wrap-handler-add-clinks [handler] 47 | (fn [ctx] 48 | (let [clinks (process-clinks ((:clinks (:resource ctx))))] 49 | (-> ctx 50 | (update-in [:links] concat (first clinks)) 51 | (update-in [:link-templates] concat (last clinks)) 52 | handler)))) 53 | 54 | (defn handler [ctypes fun] 55 | (let [ctypes-set (set ctypes)] 56 | (fn [hdlr] 57 | (fn [ctx] 58 | (if (contains? ctypes-set (-> ctx :representation :media-type)) 59 | (fun hdlr ctx) 60 | (hdlr ctx)))))) 61 | 62 | (defmacro defhandler "Defines a handler." [n doc ctypes fun] 63 | `(def ~n ~doc (with-meta (handler ~ctypes ~fun) {:ctypes ~ctypes}))) 64 | 65 | (defn data-from-result [result] 66 | ((:data-key result) result)) 67 | 68 | (defn make-handler-fn [f] 69 | (fn [hdlr ctx] 70 | (-> ctx resp-linked 71 | (assoc :encoder f) 72 | (assoc :body (-> ctx hdlr data-from-result))))) 73 | 74 | (defn wrap-apply-encoder [handler] 75 | ; used as ring middleware in apps, as handler wrapper in unit tests 76 | (fn [req] 77 | (let [rsp (handler req)] 78 | (if-let [enc (:encoder rsp)] 79 | (assoc rsp :body ((:encoder rsp) (:body rsp))) 80 | rsp)))) 81 | 82 | (defn wrap-fallback-negotiation [handler default-handlers] 83 | (fn [req] 84 | (let [rsp (handler req)] 85 | (if (or (:encoder rsp) (string? (:body rsp))) 86 | rsp 87 | (let [h (unwrap identity default-handlers) 88 | available-types (mapcat (comp :ctypes meta) default-handlers) 89 | accept (get-in req [:headers "accept"] "application/json") 90 | selected-type (neg/stringify (neg/best-allowed-content-type accept available-types)) 91 | r (h {:representation {:media-type selected-type} 92 | :data-key :data})] 93 | (-> rsp 94 | (assoc :encoder (:encoder r)) 95 | (assoc-in [:headers "content-type"] selected-type))))))) 96 | -------------------------------------------------------------------------------- /spec/octohipster/documenters/swagger_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.documenters.swagger-spec 2 | (:use [speclj core] 3 | [ring.mock request] 4 | [octohipster core mixins routes json] 5 | [octohipster.documenters swagger])) 6 | 7 | (def contact-schema 8 | {:id "Contact" 9 | :type "object" 10 | :properties {:guid {:type "string"}}}) 11 | 12 | (def name-param 13 | {:name "name" 14 | :dataType "string" 15 | :paramType "path" 16 | :required "true" 17 | :description "The name of the contact" 18 | :allowMultiple false}) 19 | 20 | (def body-param 21 | {:dataType "Contact" 22 | :paramType "body" 23 | :required true 24 | :allowMultiple false}) 25 | 26 | (defresource contact-collection 27 | :mixins [collection-resource] 28 | :desc "Operations with multiple contacts" 29 | :doc {:get {:nickname "getContacts" 30 | :summary "Get all contacts"} 31 | :post {:nickname "createContact" 32 | :summary "Create a contact" 33 | :parameters [body-param]}}) 34 | 35 | (defresource contact-item 36 | :mixins [item-resource] 37 | :url "/{id}" 38 | :desc "Operations with individual contacts" 39 | :doc {:get {:nickname "getContact" 40 | :summary "Get a contact" 41 | :parameters [name-param]} 42 | :put {:nickname "updateContact" 43 | :summary "Update a contact" 44 | :parameters [name-param body-param]} 45 | :delete {:nickname "deleteContact" 46 | :summary "Delete a contact" 47 | :parameters [name-param]}}) 48 | 49 | (defgroup contact-group 50 | :url "/contacts" 51 | :desc "Contacts" 52 | :add-to-resources {:schema contact-schema} 53 | :resources [contact-collection contact-item]) 54 | 55 | (defroutes site 56 | :groups [contact-group] 57 | :documenters [swagger-doc swagger-root-doc]) 58 | 59 | (defn nested-site [req] (site (assoc req :context "/api"))) 60 | 61 | (describe "swagger-doc" 62 | (it "exposes swagger api declarations" 63 | (should= {:apiVersion "1.0" 64 | :swaggerVersion "1.1" 65 | :basePath "http://localhost" 66 | :resourcePath "/contacts" 67 | :apis [{:path "/contacts" 68 | :description "Operations with multiple contacts" 69 | :operations [{:httpMethod "GET" 70 | :nickname "getContacts" 71 | :summary "Get all contacts" 72 | :responseClass "Array[Contact]"} 73 | {:httpMethod "POST" 74 | :nickname "createContact" 75 | :summary "Create a contact" 76 | :responseClass "Contact" 77 | :parameters [body-param]}]} 78 | {:path "/contacts/{id}" 79 | :description "Operations with individual contacts" 80 | :operations [{:httpMethod "GET" 81 | :nickname "getContact" 82 | :summary "Get a contact" 83 | :responseClass "Contact" 84 | :parameters [name-param]} 85 | {:httpMethod "PUT" 86 | :nickname "updateContact" 87 | :summary "Update a contact" 88 | :responseClass "Contact" 89 | :parameters [name-param body-param]} 90 | {:httpMethod "DELETE" 91 | :nickname "deleteContact" 92 | :summary "Delete a contact" 93 | :responseClass "Contact" 94 | :parameters [name-param]}]}] 95 | :models {:Contact contact-schema}} 96 | (-> (request :get "/api-docs.json/contacts") 97 | (header "Accept" "application/json") 98 | site :body unjsonify)))) 99 | 100 | (describe "swagger-root-doc" 101 | (it "exposes swagger resource listing at /api-docs.json" 102 | (should= {:apiVersion "1.0" 103 | :swaggerVersion "1.1" 104 | :basePath "http://localhost" 105 | :apis [{:path "/contacts" 106 | :description "Contacts"}]} 107 | (-> (request :get "/api-docs.json") 108 | (header "Accept" "application/json") 109 | site :body unjsonify))) 110 | (it "supports context nesting" 111 | (should= "http://localhost/api" 112 | (-> (request :get "/api-docs.json") 113 | (header "Accept" "application/json") 114 | nested-site :body unjsonify :basePath)))) 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Current [semantic](http://semver.org/) version: 2 | 3 | ```clojure 4 | [octohipster "0.2.1-SNAPSHOT"] 5 | ``` 6 | 7 | # octohipster [![Build Status](https://travis-ci.org/myfreeweb/octohipster.png?branch=master)](https://travis-ci.org/myfreeweb/octohipster) [![unlicense](https://img.shields.io/badge/un-license-green.svg?style=flat)](http://unlicense.org) 8 | 9 | Octohipster is 10 | 11 | - a REST library/toolkit/microframework for Clojure 12 | - that allows you to build HTTP APIs 13 | - in a declarative [Webmachine](https://github.com/basho/webmachine/wiki/Overview)-like style, using [Liberator](https://github.com/clojure-liberator/liberator) 14 | - powered by [Ring](https://github.com/ring-clojure/ring); you can add [rate limiting](https://github.com/myfreeweb/ring-ratelimit), [authentication](https://github.com/cemerick/friend), [metrics](http://metrics-clojure.readthedocs.org/en/latest/ring.html), [URL rewriting](https://github.com/ebaxt/ring-rewrite) and more with just middleware 15 | 16 | It allows you to make APIs that 17 | 18 | - support hypermedia ([HAL+JSON](http://stateless.co/hal_specification.html), [Collection+JSON](http://amundsen.com/media-types/collection/) and Link/Link-Template HTTP headers; works with [Frenetic](http://dlindahl.github.com/frenetic/)) 19 | - support multiple output formats (JSON, EDN, YAML and any custom format) 20 | - have [Swagger](https://github.com/wordnik/swagger-core/wiki) documentation 21 | - use [JSON Schema](http://json-schema.org) for validation *and* documentation 22 | - have pagination 23 | 24 | ## Concepts 25 | 26 | - a **resource** is a single endpoint that accepts requests and returns responses 27 | - a **group** is a collection of resources with a single URL prefix (eg. a group /things contains resources /things/ and /things/{id}) and zero or more shared properties (usually the schema) 28 | - a **documenter** is a function that returns a resource which documents regular resources (Swagger, HAL root, etc) 29 | - a **mixin** is a function that is applied to multiple resources to give them shared behavior (eg. collection or entry behavior) 30 | - a **response handler** is a function that is used to encode response data to a particular content-type (JSON, EDN, YAML, etc.) 31 | - a **params handler** is a function that is used to decode incoming data from a particular content-type (JSON, EDN, YAML, etc.) 32 | 33 | ## Usage 34 | 35 | ```clojure 36 | (ns example 37 | (:use [octohipster core routes mixins pagination] 38 | [octohipster.documenters swagger schema] 39 | org.httpkit.server) 40 | (:import org.bson.types.ObjectId) 41 | (:require [monger.core :as mg] 42 | [monger.query :as mq] 43 | [monger.collection :as mc] 44 | monger.json)) 45 | 46 | (mg/connect!) 47 | (mg/set-db! (mg/get-db "octohipster-example")) 48 | 49 | ;;;; The "model" 50 | ;;;; tip: make it a separate namespace, eg. app.models.contact 51 | (def contact-schema 52 | {:id "Contact" 53 | :type "object" 54 | :properties {:name {:type "string"} 55 | :phone {:type "integer"}} 56 | :required [:name]}) 57 | 58 | (defn contacts-count [] (mc/count "contacts")) 59 | (defn contacts-all [] 60 | (mq/with-collection "contacts" 61 | (mq/find {}) 62 | (mq/skip *skip*) 63 | (mq/limit *limit*))) 64 | (defn contacts-find-by-id [x] (mc/find-map-by-id "contacts" (ObjectId. x))) 65 | (defn contacts-insert! [x] 66 | (let [id (ObjectId.)] 67 | (mc/insert "contacts" (assoc x :_id id)) 68 | (mc/find-map-by-id "contacts" id))) 69 | (defn contacts-update! [x old] (mc/update "contacts" old x :multi false)) 70 | (defn contacts-delete! [x] (mc/remove "contacts" x)) 71 | 72 | ;;;; The resources 73 | ;; with shared pieces of documentation 74 | (def name-param 75 | {:name "name", :dataType "string", :paramType "path", :required "true", :description "The name of the contact", :allowMultiple false}) 76 | 77 | (def body-param 78 | {:dataType "Contact", :paramType "body", :required true, :allowMultiple false}) 79 | 80 | (defresource contact-collection 81 | :desc "Operations with multiple contacts" 82 | :mixins [collection-resource] 83 | :clinks {:item ::contact-item} 84 | :data-key :contacts 85 | :exists? (fn [ctx] {:contacts (contacts-all)}) 86 | :post! (fn [ctx] {:item (-> ctx :request :non-query-params contacts-insert!)}) 87 | :count (fn [req] (contacts-count)) 88 | :doc {:get {:nickname "getContacts", :summary "Get all contacts"} 89 | :post {:nickname "createContact", :summary "Create a contact"}}) 90 | 91 | (defresource contact-item 92 | :desc "Operations with individual contacts" 93 | :url "/{_id}" 94 | :mixins [item-resource] 95 | :clinks {:collection ::contact-collection} 96 | :data-key :contact 97 | :exists? (fn [ctx] 98 | (if-let [doc (-> ctx :request :route-params :_id contacts-find-by-id)] 99 | {:contact doc})) 100 | :put! (fn [ctx] 101 | (-> ctx :request :non-query-params (contacts-update! (:contact ctx))) 102 | {:contact (-> ctx :request :route-params :_id contacts-find-by-id)}) 103 | :delete! (fn [ctx] 104 | (-> ctx :contact contacts-delete!) 105 | {:contact nil}) 106 | :doc {:get {:nickname "getContact", :summary "Get a contact", :parameters [name-param]} 107 | :put {:nickname "updateContact", :summary "Overwrite a contact", :parameters [name-param body-param]} 108 | :delete {:nickname "deleteContact", :summary "Delete a contact", :parameters [name-param]}}) 109 | 110 | ;;;; The group 111 | (defgroup contact-group 112 | :url "/contacts" 113 | :add-to-resources {:schema contact-schema} ; instead of typing the same for all resources in the group 114 | :resources [contact-collection contact-item]) 115 | 116 | ;;;; The handler 117 | (defroutes site 118 | :groups [contact-group] 119 | :documenters [schema-doc schema-root-doc swagger-doc swagger-root-doc]) 120 | 121 | (defn -main [] (run-server site {:port 8080})) 122 | ``` 123 | 124 | Also, [API Documentation](http://myfreeweb.github.com/octohipster) is available. 125 | 126 | ## Contributing 127 | 128 | By participating in this project you agree to follow the [Contributor Code of Conduct](http://contributor-covenant.org/version/1/1/0/). 129 | 130 | Please take over the whole project! 131 | I don't use Clojure a lot nowadays. 132 | Talk to me: . 133 | 134 | ## License 135 | 136 | This is free and unencumbered software released into the public domain. 137 | For more information, please refer to the `UNLICENSE` file or [unlicense.org](http://unlicense.org). 138 | -------------------------------------------------------------------------------- /spec/octohipster/handlers_spec.clj: -------------------------------------------------------------------------------- 1 | (ns octohipster.handlers-spec 2 | (:use [speclj core] 3 | [octohipster json] 4 | [octohipster.handlers core util json edn yaml hal cj middleware])) 5 | 6 | (defn wrap-handler-test [handler] 7 | (fn [ctx] "hello")) 8 | 9 | (describe "wrap-handler-json" 10 | (it "outputs json for json requests" 11 | (let [h (-> identity wrap-handler-json wrap-apply-encoder) 12 | ctx {:representation {:media-type "application/json"} 13 | :data-key :things 14 | :things {:a 1}}] 15 | (should= (:body (h ctx)) "{\"a\":1}"))) 16 | 17 | (it "does not touch non-json requests" 18 | (let [h (-> identity wrap-handler-json wrap-apply-encoder) 19 | ctx {:representation {:media-type "text/plain"}}] 20 | (should= (h ctx) ctx)))) 21 | 22 | (describe "wrap-handler-edn" 23 | (it "outputs edn for edn requests" 24 | (let [h (-> identity wrap-handler-edn wrap-apply-encoder) 25 | ctx {:representation {:media-type "application/edn"} 26 | :data-key :things 27 | :things {:a 1}}] 28 | (should= (:body (h ctx)) "{:a 1}"))) 29 | 30 | (it "does not touch non-edn requests" 31 | (let [h (-> identity wrap-handler-edn wrap-apply-encoder) 32 | ctx {:representation {:media-type "text/plain"}}] 33 | (should= (h ctx) ctx)))) 34 | 35 | (describe "wrap-handler-yaml" 36 | (it "outputs yaml for yaml requests" 37 | (let [h (-> identity wrap-handler-yaml wrap-apply-encoder) 38 | ctx {:representation {:media-type "application/yaml"} 39 | :data-key :things 40 | :things {:a 1}}] 41 | (should= (:body (h ctx)) "{a: 1}\n"))) 42 | 43 | (it "does not touch non-yaml requests" 44 | (let [h (-> identity wrap-handler-yaml wrap-apply-encoder) 45 | ctx {:representation {:media-type "text/plain"}}] 46 | (should= (h ctx) ctx)))) 47 | 48 | (describe "wrap-handler-hal-json" 49 | (it "consumes links for hal+json requests" 50 | (let [h (-> identity wrap-handler-hal-json wrap-apply-encoder) 51 | ctx {:representation {:media-type "application/hal+json"} 52 | :data-key :things 53 | :things {:a 1}}] 54 | (should= (unjsonify (:body (h ctx))) 55 | {:_links {} 56 | :a 1}))) 57 | 58 | (it "creates an _embedded wrapper for non-map content and adds templated self links" 59 | (let [h (-> identity wrap-handler-hal-json wrap-handler-add-clinks wrap-apply-encoder) 60 | ctx {:representation {:media-type "application/hal+json"} 61 | ; liberator does this constantly thing 62 | :resource {:clinks (constantly {:entry "/things/{a}"}) 63 | :item-key (constantly :entry)} 64 | :data-key :things 65 | :things [{:a 1}]}] 66 | (should= (unjsonify (:body (h ctx))) 67 | {:_links {:entry {:templated true 68 | :href "/things/{a}"}} 69 | :_embedded {:things [{:a 1 70 | :_links {:self {:href "/things/1"}}}]}}))) 71 | 72 | (it "creates an _embedded wrapper for embed-mapping" 73 | (let [h (-> identity wrap-handler-hal-json wrap-handler-add-clinks wrap-apply-encoder) 74 | ctx {:representation {:media-type "application/hal+json"} 75 | :resource {:embed-mapping (constantly {:things "thing"}) 76 | :clinks (constantly {:thing "/yo/{a}/things/{b}"})} 77 | :data-key :yo 78 | :yo {:a 1 :things [{:b 2}]}}] 79 | (should= (unjsonify (:body (h ctx))) 80 | {:_links {:thing {:templated true 81 | :href "/yo/{a}/things/{b}"}} 82 | :_embedded {:things [{:b 2 83 | :_links {:self {:href "/yo/1/things/2"}}}]} 84 | :a 1}))) 85 | 86 | (it "does not touch non-hal+json requests" 87 | (let [h (-> identity wrap-handler-hal-json wrap-apply-encoder) 88 | ctx {:representation {:media-type "application/json"}}] 89 | (should= (h ctx) ctx)))) 90 | 91 | (describe "wrap-handler-collection-json" 92 | (it "consumes links for collection+json requests, to the item if data is a map" 93 | (let [h (-> identity wrap-handler-collection-json wrap-apply-encoder) 94 | ctx {:representation {:media-type "application/vnd.collection+json"} 95 | :links [{:rel "test", :href "/hello"}] 96 | :data-key :things 97 | :things {:a 1}} 98 | ctx2 (assoc ctx :things [{:a 1}])] 99 | (should= (-> ctx h :body unjsonify :collection :items first :links) 100 | [{:rel "test", :href "/hello"}]) 101 | (should= (-> ctx2 h :body unjsonify :collection :links) 102 | [{:rel "test", :href "/hello"}]))) 103 | 104 | (it "converts nested maps into collection+json format" 105 | (let [h (-> identity wrap-handler-collection-json wrap-apply-encoder) 106 | ctx {:representation {:media-type "application/vnd.collection+json"} 107 | :data-key :things 108 | :things {:hello {:world 1}}}] 109 | (should= (-> ctx h :body unjsonify :collection :items first :data) 110 | [{:name "hello" 111 | :value [{:name "world" 112 | :value 1}]}]))) 113 | 114 | (it "does not touch non-collection+json requests" 115 | (let [h (-> identity wrap-handler-collection-json wrap-apply-encoder) 116 | ctx {:representation {:media-type "application/json"}}] 117 | (should= (h ctx) ctx)))) 118 | 119 | (describe "item-handler" 120 | (it "uses the presenter on the data" 121 | (let [h (item-handler (partial + 1) :data)] 122 | (should= (h {:data 1}) {:data-key :data 123 | :data 2})))) 124 | 125 | (describe "collection-handler" 126 | (it "maps the presenter over the data" 127 | (let [h (collection-handler (partial + 1) :data)] 128 | (should= (h {:data [1 2]}) {:data-key :data 129 | :data [2 3]})))) 130 | 131 | (describe "wrap-response-envelope" 132 | (it "creates the envelope" 133 | (let [h (-> identity wrap-handler-json wrap-response-envelope wrap-apply-encoder)] 134 | (should= (-> {:representation {:media-type "application/json"} 135 | :data-key :things 136 | :things [1 2]} h :body unjsonify) 137 | {:things [1 2]}))) 138 | (it "does not touch non-envelope-able types" 139 | (let [h (-> identity wrap-handler-hal-json wrap-response-envelope wrap-apply-encoder)] 140 | (should= (-> {:representation {:media-type "application/hal+json"} 141 | :data-key :things 142 | :things {:a 1}} h :body unjsonify) 143 | {:a 1, :_links {}})))) 144 | --------------------------------------------------------------------------------