├── CHANGELOG.md ├── src └── happy │ ├── headers.cljc │ ├── mime.cljc │ ├── interceptors.cljc │ ├── representor │ ├── edn.cljc │ ├── json.cljc │ └── transit.cljc │ ├── representors.cljc │ ├── client │ ├── xmlhttprequest.cljs │ └── okhttp.clj │ └── core.cljc ├── test └── happy │ ├── client │ ├── okhttp_test.clj │ ├── specs.cljc │ └── xmlhttprequest_test.cljs │ ├── runner.cljs │ └── representors_test.cljc ├── examples └── src │ └── happy │ └── examples.clj ├── project.clj └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.2 2 | 3 | * Added edn representor 4 | * Allow to override mime types used to lookup representors 5 | * Normalize headers keys 6 | 7 | ## 0.5.0 8 | 9 | * Initial release 10 | -------------------------------------------------------------------------------- /src/happy/headers.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.headers 2 | (:require [clojure.string :as string])) 3 | 4 | (defn content-type 5 | [hm] 6 | (if-let [s (get hm "content-type")] 7 | (string/split (string/trim s) #"[ ]*;[ ]*"))) -------------------------------------------------------------------------------- /test/happy/client/okhttp_test.clj: -------------------------------------------------------------------------------- 1 | (ns happy.client.okhttp-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [happy.client.okhttp :as ok] 4 | [happy.client.specs :as sp])) 5 | 6 | (deftest client-specs 7 | (sp/specs (ok/create))) -------------------------------------------------------------------------------- /test/happy/runner.cljs: -------------------------------------------------------------------------------- 1 | (ns happy.runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | [happy.client.xmlhttprequest-test] 4 | [happy.representors-test])) 5 | 6 | (doo-tests 'happy.client.xmlhttprequest-test 7 | 'happy.representors-test) -------------------------------------------------------------------------------- /examples/src/happy/examples.clj: -------------------------------------------------------------------------------- 1 | (ns happy.examples 2 | (:require [happy.core :as h :refer [GET]] 3 | [happy.client.okhttp :as ok])) 4 | 5 | (h/merge-options! {:report-progress? true 6 | :handler #(println %)}) 7 | 8 | (h/set-default-client! (ok/create)) 9 | 10 | (defn -main 11 | [] 12 | (GET "http://www.google.com" {} {:timeout 10})) -------------------------------------------------------------------------------- /src/happy/mime.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.mime 2 | (:require [clojure.string :as string])) 3 | 4 | (defn top-level 5 | [s] 6 | (first (string/split s #"/"))) 7 | 8 | (defn subtype 9 | [s] 10 | (second (string/split s #"/"))) 11 | 12 | (defn suffix 13 | [s] 14 | (second (string/split s #"\+"))) 15 | 16 | (defn wildcard? 17 | [s] 18 | (= "*" (subtype s))) 19 | 20 | (defn generic 21 | [s] 22 | (if-let [su (suffix s)] 23 | (str (top-level s) "/" su) 24 | s)) -------------------------------------------------------------------------------- /src/happy/interceptors.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.interceptors) 2 | 3 | (defn default-headers-interceptor 4 | [[req om :as v]] 5 | (if-let [hm (get-in om [:default-headers (:method req)])] 6 | (assoc v 0 (update req :headers #(merge hm %))) 7 | v)) 8 | 9 | (defn now 10 | [] 11 | #?(:clj (System/currentTimeMillis) 12 | :cljs (.now js/window.performance))) 13 | 14 | (defn timing-interceptor 15 | [[_ om :as v]] 16 | (let [i (now)] 17 | (assoc v 1 (update om :response-interceptors #(cons %2 %1) (fn [resp _] (assoc resp :timing (- (now) i))))))) 18 | -------------------------------------------------------------------------------- /src/happy/representor/edn.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.representor.edn 2 | (:require #?(:clj [clojure.edn :as edn] :cljs [cljs.reader :as reader]) 3 | [happy.representors :refer [Representator]])) 4 | 5 | (defn create 6 | ([] (create nil)) 7 | ([m] 8 | (reify Representator 9 | (-mime-types [_] #{"application/edn"}) 10 | (-serialize [_ o] (pr-str o)) 11 | (-unserialize [_ o] 12 | (if (string? o) 13 | #?(:clj (edn/read-string m o) :cljs (reader/read-string o)) 14 | #?(:clj (edn/read m o) :cljs (reader/read o nil nil nil))))))) 15 | -------------------------------------------------------------------------------- /test/happy/client/specs.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.client.specs 2 | (:require #?(:clj [clojure.test :refer [deftest is testing]] 3 | :cljs [cljs.test :as t]) 4 | [happy.core :as h]) 5 | #?(:cljs (:require-macros [cljs.test :refer [deftest is testing]]))) 6 | 7 | (defn specs 8 | [c] 9 | (testing "Simple request" 10 | (is (not (nil? (h/send! {:method "GET" :url "http://google.com"} {:client c})))) 11 | (is (not (nil? (h/send! {:method "PUT" :url "http://www.mocky.io/v2/5185415ba171ea3a00704eed" 12 | :body "payload" :headers {"content-type" "text/text"}} {:client c})))))) -------------------------------------------------------------------------------- /test/happy/client/xmlhttprequest_test.cljs: -------------------------------------------------------------------------------- 1 | (ns happy.client.xmlhttprequest-test 2 | (:require [cljs.test :as t] 3 | [happy.client.xmlhttprequest :as xhr] 4 | [happy.client.specs :as sp] 5 | ) 6 | (:require-macros [cljs.test :refer [deftest is]])) 7 | 8 | (deftest parse-headers 9 | (is (= {"content-type" "application/json" "origin" "localhost"} 10 | (xhr/parse-headers "content-type: application/json\n origin: localhost"))) 11 | (is (= {"vary" ["content-type" "content-encoding"]} 12 | (xhr/parse-headers "vary: content-type\n vary: content-encoding")))) 13 | 14 | (sp/specs (xhr/create)) -------------------------------------------------------------------------------- /src/happy/representor/json.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.representor.json 2 | (:require [happy.core :as core] 3 | [happy.representors :refer [Representator]] 4 | #?(:clj [cheshire.core :as che]))) 5 | 6 | (defn serialize 7 | [m] 8 | #?(:clj (che/generate-string m) 9 | :cljs (.stringify js/JSON (clj->js m)))) 10 | 11 | (defn unserialize 12 | [s keywordize-keys?] 13 | #?(:clj (che/parse-string s keywordize-keys?) 14 | :cljs (js->clj (.parse js/JSON s) {:keywordize-keys keywordize-keys?}))) 15 | 16 | (defn create 17 | ([] (create false)) 18 | ([keywordize-keys?] 19 | (reify Representator 20 | (-mime-types [_] #{"application/json"}) 21 | (-serialize [_ o] (serialize o)) 22 | (-unserialize [_ s] (unserialize s keywordize-keys?))))) 23 | 24 | (defn merge-representors! 25 | [keywordize-keys?] 26 | (core/merge-representors! 27 | [(create keywordize-keys?)])) 28 | -------------------------------------------------------------------------------- /src/happy/representor/transit.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.representor.transit 2 | (:require [clojure.string :as string] 3 | [happy.representors :refer [Representator]] 4 | [cognitect.transit :as t]) 5 | #?(:clj (:import [java.io ByteArrayInputStream ByteArrayOutputStream] 6 | [java.nio.charset Charset]))) 7 | 8 | #?(:cljs (def w (t/writer :json))) 9 | 10 | #?(:cljs (def r (t/reader :json))) 11 | 12 | #?(:clj (def ^:private charset (Charset/forName "UTF-8"))) 13 | 14 | (defn serialize 15 | [o] 16 | #?(:clj (let [os (ByteArrayOutputStream. 4096) 17 | w (t/writer os :json)] 18 | (t/write w o) 19 | (.toString os)) 20 | :cljs (t/write w o))) 21 | 22 | (defn unserialize 23 | [s] 24 | #?(:clj (t/read (t/reader (ByteArrayInputStream. (.getBytes ^String s ^Charset charset)) :json)) 25 | :cljs (when-not (string/blank? s) (t/read r s)))) 26 | 27 | (defn create 28 | [] 29 | (reify Representator 30 | (-mime-types [_] #{"application/transit+json"}) 31 | (-serialize [_ o] (serialize o)) 32 | (-unserialize [_ s] (unserialize s)))) 33 | 34 | (defn merge-representors! 35 | [] 36 | (core/merge-representors! 37 | [(create)])) 38 | -------------------------------------------------------------------------------- /test/happy/representors_test.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.representors-test 2 | (:require #?(:clj [clojure.test :refer [deftest is]] 3 | :cljs [cljs.test :as t]) 4 | [happy.representors :as repr]) 5 | #?(:cljs (:require-macros [cljs.test :refer [deftest is]]))) 6 | 7 | (deftest valid? 8 | (is (true? (repr/valid? "application/json" "application/json"))) 9 | (is (true? (repr/valid? "application/json" "application/vnd.api+json"))) 10 | (is (false? (repr/valid? "application/json" "application/not-json"))) 11 | (is (true? (repr/valid? "image/png" "image/png"))) 12 | (is (true? (repr/valid? "image/*" "image/png")))) 13 | 14 | (deftest matching-representor 15 | (is (= repr/text-representor (repr/matching-representor {:headers {"content-type" "text/html"}} [repr/text-representor]))) 16 | (is (= repr/text-representor (repr/matching-representor {:headers {"content-type" "text/text"}} [repr/text-representor]))) 17 | (is (= repr/binary-representor (repr/matching-representor {:headers {"content-type" "audio/snd"}} [repr/binary-representor]))) 18 | (is (nil? (repr/matching-representor {:headers {"content-type" "application/unknow"}} []))) 19 | (is (nil? (repr/matching-representor {} [repr/text-representor])))) -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject happy "0.5.3-SNAPSHOT" 2 | :description "Clojure(Script) HTTP async client library" 3 | :url "http://github.com/jeluard/happy" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[org.clojure/clojure "1.7.0"] 7 | ; Optional dependencies 8 | [com.squareup.okhttp/okhttp "2.5.0" :scope "provided"] 9 | [cheshire "5.5.0" :scope "provided"] 10 | [com.cognitect/transit-clj "0.8.285" :scope "provided" :exclusions [org.msgpack/msgpack]] 11 | [com.cognitect/transit-cljs "0.8.237" :scope "provided"]] 12 | :profiles {:dev 13 | {:dependencies [[org.clojure/clojurescript "1.7.170"]] 14 | :source-paths ["src" "examples/src"] 15 | :plugins [[lein-cljsbuild "1.1.2"] 16 | [lein-doo "0.1.6"]]}} 17 | :cljsbuild 18 | {:builds 19 | {:test {:source-paths ["src" "test"] 20 | :compiler {:output-to "target/unit-test.js" 21 | :main 'happy.runner 22 | :optimizations :whitespace 23 | :pretty-print true}}}} 24 | :aliases {"clean-test" ["do" "clean," "test," "doo" "phantom" "test" "once"] 25 | "clean-install" ["do" "clean," "install"] 26 | "run-examples" ["do" "clean" ["run" "-m" "happy.examples"]]} 27 | :min-lein-version "2.5.0") 28 | -------------------------------------------------------------------------------- /src/happy/representors.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.representors 2 | (:require [happy.headers :as hea] 3 | [happy.mime :as mim])) 4 | 5 | (defprotocol Representator 6 | (-mime-types [_]) 7 | (-serialize [_ o]) 8 | (-unserialize [_ o])) 9 | 10 | (defn valid? 11 | [s t] 12 | (if (mim/wildcard? s) 13 | (= (mim/top-level s) (mim/top-level t)) 14 | (= s (mim/generic t)))) 15 | 16 | (defn valid-for-mime? 17 | [s r] 18 | (some #(if (valid? % s) r) (-mime-types r))) 19 | 20 | (defn matching-representor 21 | [req v mt] 22 | (if-let [ct (or mt (first (hea/content-type (:headers req))))] 23 | (some #(valid-for-mime? ct %) v))) 24 | 25 | (defn as-request-interceptor 26 | [v] 27 | (fn [[req om :as o]] 28 | (if (contains? req :body) 29 | (if-let [r (matching-representor req v (:override-request-mime-type om))] 30 | (assoc o 0 (update req :body #(-serialize r %))))))) 31 | 32 | (defn as-response-interceptor 33 | [v] 34 | (fn [resp om] 35 | (if (contains? resp :body) 36 | (if-let [r (matching-representor resp v (:override-response-mime-type om))] 37 | (update resp :body #(-unserialize r %)))))) 38 | 39 | (def binary-representor 40 | (reify Representator 41 | (-mime-types [_] #{"image/*" "audio/*" "video/*"}) 42 | (-serialize [_ s] s) 43 | (-unserialize [_ s] s))) 44 | 45 | (def text-representor 46 | (reify Representator 47 | (-mime-types [_] #{"text/*"}) 48 | (-serialize [_ s] s) 49 | (-unserialize [_ s] s))) 50 | -------------------------------------------------------------------------------- /src/happy/client/xmlhttprequest.cljs: -------------------------------------------------------------------------------- 1 | (ns happy.client.xmlhttprequest 2 | (:require [clojure.string :as string] 3 | [happy.core :as h :refer [Client RequestHandler ResponseHandler]])) 4 | 5 | ; A Client implementation for browsers based on https://xhr.spec.whatwg.org 6 | 7 | (defn reduce-headers 8 | [m line] 9 | (let [[k v] (string/split line #":" 2) 10 | n (string/lower-case (string/trim k)) 11 | v (string/trim v)] 12 | (if-let [ov (get m n)] 13 | (assoc m n (conj (if (vector? ov) ov (vector ov)) v)) 14 | (assoc m n v)))) 15 | 16 | (defn parse-headers 17 | [s] 18 | (let [headers (string/replace s #"\n$" "")] 19 | (reduce reduce-headers {} (string/split-lines headers)))) 20 | 21 | (defn progress-details 22 | [evt] 23 | (if (.-lengthComputable evt) 24 | {:loaded (.-loaded evt) :total (.-total evt)})) 25 | 26 | (deftype XHRRequestHandler 27 | [xhr] 28 | RequestHandler 29 | (-abort [_] (.abort xhr))) 30 | 31 | (deftype XHRResponseHandler 32 | [xhr] 33 | ResponseHandler 34 | (-status [_] (.-status xhr)) 35 | (-body [_] (.-response xhr)) 36 | (-header [_ s] (.getResponseHeader xhr s)) 37 | (-headers [_] (parse-headers (.getAllResponseHeaders xhr)))) 38 | 39 | (defn response-type 40 | [s] 41 | (case s 42 | :array-buffer "arraybuffer" 43 | :blob "blob")) 44 | 45 | (defn send! 46 | [{:keys [url method headers body]} {:keys [handler with-credentials? timeout report-progress? response-body-as] :as m}] 47 | (let [xhr (js/XMLHttpRequest.) 48 | rh (XHRResponseHandler. xhr)] 49 | (if with-credentials? (set! (.-withCredentials xhr) true)) 50 | (if (and response-body-as (not= response-body-as :string)) 51 | (set! (.-responseType xhr) (response-type response-body-as))) 52 | (if timeout (set! (.-timeout xhr) timeout)) 53 | (.open xhr method url true) 54 | (doseq [[k v] headers] 55 | (.setRequestHeader xhr k v)) 56 | (when handler 57 | ; load, abort, error and timeout are mutually exclusive 58 | (set! (.-onload xhr) #(h/finalize handler (h/response rh) m)) 59 | (set! (.-onabort xhr) #(h/finalize handler (h/failure :abort) m)) 60 | (set! (.-onerror xhr) #(h/finalize handler (h/failure :network) m)) 61 | (if timeout 62 | (set! (.-ontimeout xhr) #(h/finalize handler (h/failure :timeout) m))) 63 | (when report-progress? 64 | (set! (.-onprogress xhr) #(handler (h/progress :receiving (merge {:response rh} (progress-details %))))) 65 | (set! (.-onreadystatechange xhr) #(let [i (.. % -target -readyState)] (if (= 2 i) (handler (h/progress :headers-received))))) 66 | (if body 67 | (set! (.. xhr -upload -onprogress) #(handler (h/progress :sending (progress-details %))))))) 68 | (if body 69 | (.send xhr body) 70 | (.send xhr)) 71 | (XHRRequestHandler. xhr))) 72 | 73 | (defn create 74 | [] 75 | (reify Client 76 | (-supports [_] 77 | {:progress true 78 | :timeout true 79 | :request-body-as #{:string :blob :buffer-source} 80 | :response-body-as #{:string :blob :array-buffer} 81 | :extra-options #{:with-credentials?}}) 82 | (-send! [_ req m] 83 | (send! req m)))) -------------------------------------------------------------------------------- /src/happy/client/okhttp.clj: -------------------------------------------------------------------------------- 1 | (ns happy.client.okhttp 2 | (:require [clojure.string :as string] 3 | [happy.core :as h :refer [Client RequestHandler ResponseHandler]] 4 | [happy.headers :as hea]) 5 | (:import [com.squareup.okhttp 6 | Call Callback 7 | OkHttpClient 8 | Headers 9 | MediaType 10 | Request Request$Builder RequestBody 11 | Response ResponseBody] 12 | [java.io File IOException InterruptedIOException] 13 | [java.util.concurrent TimeUnit])) 14 | 15 | ; TODO add progress support 16 | ; request: https://gist.github.com/lnikkila/d1a4446b93a0185b0969 17 | ; response: https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/com/squareup/okhttp/recipes/Progress.java 18 | 19 | (def ^:const ba (type (byte-array []))) 20 | (defn- byte-array? [o] (instance? ba o)) 21 | 22 | (defn create-body 23 | [m o] 24 | (if-let [ct (hea/content-type m)] 25 | (if-let [^MediaType mt (MediaType/parse (first ct))] 26 | (cond 27 | (string? o) (RequestBody/create mt ^String o) 28 | (byte-array? o) (RequestBody/create mt ^bytes o) 29 | (instance? File o) (RequestBody/create mt ^File o)) 30 | (throw (ex-info "Unsupported body type" {:body o}))) 31 | (throw (ex-info "Can't have a body without content-type" {:body o})))) 32 | 33 | (defn ^Request create-request 34 | [{:keys [url method body headers]}] 35 | (let [b (Request$Builder.)] 36 | (.url b ^String url) 37 | (doseq [[^String k ^String v] headers] 38 | (.addHeader b k v)) 39 | (.method b method (if body (create-body headers body))) 40 | (.build b))) 41 | 42 | (defn exception->termination 43 | [^IOException ioe] 44 | (cond 45 | (instance? InterruptedIOException ioe) (h/failure :timeout (.toString ioe)) 46 | :else (h/failure :network (.toString ioe)))) 47 | 48 | (defn headers 49 | [^Headers o] 50 | (reduce #(assoc %1 %2 (let [l (.values o %2)] (if (= 1 (count l)) (first l) (vec l)))) {} (.names o))) 51 | 52 | (deftype OkHTTPResponse 53 | [^Response resp body-as] 54 | ResponseHandler 55 | (-status [_] (.code resp)) 56 | (-body [_] 57 | (with-open [^ResponseBody b (.body resp)] 58 | (cond 59 | (or (nil? body-as) (= :string body-as)) (.string b) 60 | (= :byte-array body-as) (.bytes b) 61 | (= :stream body-as) (.byteStream b)))) 62 | (-header [_ s] (.header resp s)) 63 | (-headers [_] (headers (.headers resp)))) 64 | 65 | (defn create-callback 66 | [_ {:keys [handler response-body-as] :as m}] 67 | (reify Callback 68 | (onResponse [_ resp] 69 | (h/finalize handler (h/response (OkHTTPResponse. resp response-body-as)) m)) 70 | (onFailure [_ _ ioe] 71 | (h/finalize handler (exception->termination ioe) m)))) 72 | 73 | (deftype OkHTTPRequestHandler 74 | [^Call ca f m] 75 | RequestHandler 76 | (-abort [_] (.cancel ca) (h/finalize f (h/failure :abort) m))) 77 | 78 | (defn send! 79 | [req {:keys [okhttp-client timeout connect-timeout read-timeout write-timeout] :as m}] 80 | (let [^OkHttpClient c (or okhttp-client (OkHttpClient.)) 81 | o (create-request req) 82 | ^Call ca (.newCall c o)] 83 | (if-let [i (or timeout connect-timeout)] 84 | (.setConnectTimeout c i TimeUnit/MILLISECONDS)) 85 | (if read-timeout 86 | (.setReadTimeout c read-timeout TimeUnit/MILLISECONDS)) 87 | (if write-timeout 88 | (.setWriteTimeout c write-timeout TimeUnit/MILLISECONDS)) 89 | (.enqueue ca (create-callback req m)) 90 | (OkHTTPRequestHandler. ca (:handler m) m))) 91 | 92 | (defn create 93 | [] 94 | (reify Client 95 | (-supports [_] 96 | {:timeout #{:connect :read :write} 97 | :request-body-as #{:string :byte-array :file} 98 | :response-body-as #{:string :byte-array :stream} 99 | :extra-options #{:okhttp-client}}) 100 | (-send! [_ req m] 101 | (send! req m)))) -------------------------------------------------------------------------------- /src/happy/core.cljc: -------------------------------------------------------------------------------- 1 | (ns happy.core 2 | (:require [clojure.string :as string] 3 | [happy.representors :as repr] 4 | [happy.representor.edn :as repre])) 5 | 6 | (defprotocol Client 7 | (-supports [_]) 8 | (-send! [_ req m] "Returns a RequestHandler")) 9 | 10 | (defprotocol RequestHandler 11 | (-abort [_])) 12 | 13 | (defprotocol ResponseHandler 14 | (-status [_]) 15 | (-body [_]) 16 | (-header [_ s]) 17 | (-headers [_])) 18 | 19 | ; Options handling 20 | 21 | (def default-options (atom nil)) 22 | 23 | (defn swap-options! 24 | [f & args] 25 | (apply swap! default-options f args)) 26 | 27 | (defn default-option-combiner 28 | [r l] 29 | "Combines seq by concatening them and map by merging them. 30 | For all others types the new value takes precedence." 31 | (cond 32 | (sequential? l) (concat l r) 33 | (map? l) (merge-with default-option-combiner l r) 34 | :else (or l r))) 35 | 36 | (defn merge-options! 37 | ([m] (merge-options! m default-option-combiner)) 38 | ([m f] 39 | (swap-options! #(merge-with f %1 %2) m))) 40 | 41 | (defn merge-representors! 42 | [v] 43 | (merge-options! 44 | {:request-interceptors [(repr/as-request-interceptor v)] 45 | :response-interceptors [(repr/as-response-interceptor v)]})) 46 | 47 | (defn reset-options! 48 | [] 49 | (reset! default-options nil)) 50 | 51 | (defn set-default-client! 52 | [c] 53 | (swap-options! assoc :client c)) 54 | 55 | ; Utils methods for Client implementations 56 | 57 | (defn apply-interceptors 58 | [f o v] 59 | (if v 60 | (reduce #(let [r (f %1 %2)] (or r %1)) o v) 61 | o)) 62 | 63 | (defn apply-request-interceptors 64 | [req om] 65 | (apply-interceptors (fn [o f] (f o)) [req om] (:request-interceptors om))) 66 | 67 | (defn apply-response-interceptors 68 | [resp om] 69 | (apply-interceptors (fn [o f] (f o om)) resp (:response-interceptors om))) 70 | 71 | (defn progress 72 | ([t] (progress t nil)) 73 | ([t m] 74 | (let [b {:type :progress :direction t}] 75 | (if m 76 | (merge b m) 77 | b)))) 78 | 79 | (defn finalize 80 | [f resp m] 81 | (if f 82 | (f (apply-response-interceptors resp m)))) 83 | 84 | (defn response 85 | [r] 86 | {:type :response 87 | :status (-status r) 88 | :body (-body r) 89 | :headers (-headers r)}) 90 | 91 | (defn failure 92 | ([t] (failure t nil)) 93 | ([t s] 94 | (let [m {:type :failure 95 | :termination t}] 96 | (if s 97 | (assoc m :reason s) 98 | m)))) 99 | 100 | (defn validate-request! 101 | [req] 102 | (let [met (:method req)] 103 | (if (and (#{"POST" "PUT" "PATCH"} met) (nil? (:body req))) 104 | (throw (ex-info (str "Method " met " requires a body" ) req))) 105 | (if (and (#{"GET" "HEAD" "OPTIONS"} met) (not (nil? (:body req)))) 106 | (throw (ex-info (str "Method " met " requires no body" ) {})))) 107 | (if-not (every? #(and (string? (key %)) (string? (val %))) (:headers req)) 108 | (throw (ex-info "Headers must be a String / String map" {})))) 109 | 110 | (defn validate-options! 111 | [m] 112 | (if-let [c (:client m)] 113 | (let [sm (-supports c)] 114 | (if-let [as (:request-body-as m)] 115 | (if-not ((:request-body-as sm) as) 116 | (throw (ex-info (str "Unsupported :request-body-as : " as) {:m m})))) 117 | (if-let [as (:response-body-as m)] 118 | (if-not ((:response-body-as sm) as) 119 | (throw (ex-info (str "Unsupported :response-body-as : " as) {:m m}))))) 120 | (throw (ex-info "No :client set" {:m m})))) 121 | 122 | (defn normalize-request 123 | [req] 124 | (if (contains? req :headers) 125 | (update req :headers 126 | #(into {} 127 | (for [[k v] %] 128 | [(string/lower-case k) v]))) 129 | req)) 130 | 131 | (defn send! 132 | [req m] 133 | (let [req (normalize-request req) 134 | f (or (:default-option-combiner m) (:default-option-combiner @default-options) default-option-combiner) 135 | [req m] (apply-request-interceptors req (merge-with f @default-options m))] 136 | (validate-request! req) 137 | (validate-options! m) 138 | (-send! (:client m) req m))) 139 | 140 | (defn GET 141 | ([url] (GET url {})) 142 | ([url hm] (GET url hm nil)) 143 | ([url hm m] 144 | (send! {:method "GET" :url url :headers hm} m))) 145 | 146 | (defn HEAD 147 | ([url] (HEAD url {})) 148 | ([url hm] (HEAD url hm nil)) 149 | ([url hm m] 150 | (send! {:method "HEAD" :url url :headers hm} m))) 151 | 152 | (defn POST 153 | ([url b] (POST url {} b)) 154 | ([url hm b] (POST url hm b nil)) 155 | ([url hm b m] 156 | (send! {:method "POST" :url url :headers hm :body b} m))) 157 | 158 | (defn PUT 159 | ([url b] (PUT url {} b)) 160 | ([url hm b] (PUT url hm b nil)) 161 | ([url hm b m] 162 | (send! {:method "PUT" :url url :headers hm :body b} m))) 163 | 164 | (defn PATCH 165 | ([url b] (PATCH url {} b)) 166 | ([url hm b] (PATCH url hm b nil)) 167 | ([url hm b m] 168 | (send! {:method "PATCH" :url url :headers hm :body b} m))) 169 | 170 | (defn DELETE 171 | ([url] (DELETE url {})) 172 | ([url hm] (DELETE url hm nil)) 173 | ([url hm m] 174 | (send! {:method "DELETE" :url url :headers hm} m))) 175 | 176 | (defn OPTIONS 177 | ([url] (OPTIONS url {})) 178 | ([url hm] (OPTIONS url hm nil)) 179 | ([url hm m] 180 | (send! {:method "OPTIONS" :url url :headers hm} m))) 181 | 182 | ; Setup default options 183 | (merge-representors! 184 | [(repre/create) repr/text-representor repr/binary-representor]) 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Happy [![License](http://img.shields.io/badge/license-EPL-blue.svg?style=flat)](https://www.eclipse.org/legal/epl-v10.html) 2 | 3 | [Usage](#usage) | [Interceptor](#interceptor) | [Representor](#representor) 4 | 5 | [![Clojars Project](http://clojars.org/happy/latest-version.svg)](http://clojars.org/happy). 6 | 7 | A Clojure(Script) HTTP async client library with swappable implementation. 8 | 9 | `happy` ships with a Clojure client based on [OkHTTP](http://square.github.io/okhttp/) and a ClojureScript client based on XMLHttpRequest. 10 | 11 | ## Usage 12 | 13 | `happy` main function is `happy.core/send!`. It allows to send an HTTP call represented as a map and receive via a handler function a response map. 14 | 15 | ```clojure 16 | (ns my.app 17 | (:require [happy.core :as h] 18 | [happy.client.xmlhttprequest :as hc])) 19 | 20 | (h/set-default-client! (hc/create)) 21 | 22 | (let [c (h/send! {:method "GET" :url "http://google.com"} {:handler #(println "received " %)})] 23 | ; an HTTP call can be aborted 24 | (h/-abort c)) 25 | ``` 26 | 27 | A request map has the following shape: 28 | 29 | ```clojure 30 | {:method "GET" ; an uppercase String identifying the HTTP method used 31 | :headers {} ; a String/String map of key/value headers 32 | :body "" ; a body payload whose type must match client implementation capacities} 33 | ``` 34 | 35 | When called, the `:handler` function will receive as single argument a response map with the following shape: 36 | 37 | ```clojure 38 | {:type :response ; a keyword identifying the response 39 | ; can be `:response`, `:progress` or `:failure` 40 | 41 | ; if :type = :response 42 | :status 200 ; an integer of the HTTP status code 43 | :headers {} ; a String/(String or seq) map of key/value headers 44 | :body "" ; a payload whose type depends on client implementation 45 | 46 | ; if :type = :progress 47 | :direction :sending ; a keyword identifying if this is a `:receiving` or `:sending` progress 48 | :loaded 10 ; an integer of the count of currently loaded bytes, optional 49 | :total 150 ; an integer of total bytes, optional 50 | 51 | ; if :type = :failure 52 | :termination :abort ; a keyword whose value can be `:abort`, `:timeout` or `:network` 53 | :reason "" ; a String detailing the failure, optional 54 | } 55 | ``` 56 | 57 | A handler is called only once per request with a `:type` of `:response` or `:failure`. 58 | If `:report-progress?` option is provided and the client implementation supports it `:handler` can be called a number of times with type `:progress`. 59 | 60 | For simplicity both request and response are modeled after the ring [SPEC](https://github.com/ring-clojure/ring/blob/master/SPEC). 61 | 62 | Helper functions for common verbs are provided to simplify common calls. 63 | 64 | ```clojure 65 | (ns my.app 66 | (:require [happy.core :as h :refer [GET PUT]] 67 | [happy.client.xmlhttprequest :as hc])) 68 | 69 | (h/set-default-client! (hc/create)) 70 | 71 | (GET "http://google.com" {} {:handler #(println "received " %)}) 72 | (PUT "http://my-app.com" {:data "some payload"}) 73 | ``` 74 | 75 | ### Options 76 | 77 | The second parameter to `happy.core/send!` is a map of options that will affect an HTTP call. 78 | 79 | Each client can accept any option. Those must be advertised in the `happy.core/-supports` map as `extra-options`. 80 | 81 | Common options are available (optional unless specified): 82 | 83 | * `:client` to define the client implementation, mandatory 84 | * `:handler` the callback function called when the HTTP call is executed 85 | * `:timeout` the maximum time allowed for the HTTP call to finish, in milliseconds 86 | * `:request-body-as` the type of `:body` send by the client. Client specific, default to :string 87 | * `:response-body-as` the type of `:body` received by the client. Client specific, default to :string 88 | * `:report-progress?` if `:progress` event are provided to the callback `:handler` 89 | * `:request-interceptors` the sequence of [interceptors](#interceptor) applied to a request 90 | * `:response-interceptors` the sequence of [interceptors](#interceptor) applied to a response 91 | 92 | Options can also be set globally (stored in `happy.core/default-options`) using `happy.core/swap-options!`, `happy.core/merge-options!` and `happy.core/set-default-client!`. 93 | 94 | ```clojure 95 | (ns my.app 96 | (:require [happy.core :as h :refer [GET]] 97 | [happy.client.xmlhttprequest :as hc])) 98 | 99 | (h/set-default-client! (hc/create)) 100 | (h/merge-options! {:report-progress? true 101 | :handler #(println %)}) 102 | 103 | (GET "http://google.com") 104 | ``` 105 | 106 | ## Interceptor 107 | 108 | Interceptors allow users to modify request and response part of an HTTP call. Interceptors are simple function returning their argument eventually modified and are applied in order. 109 | 110 | `happy` bundles a couple [interceptors](https://github.com/jeluard/happy/blob/master/src/happy/interceptors.cljc). 111 | 112 | A request interceptor is specified via `:request-interceptors` and receive as argument a sequence of the request map and the options map. 113 | A response interceptor is specified via `:response-interceptors` and receive as argument the response map. 114 | 115 | ```clojure 116 | (ns my.app 117 | (:require [happy.core :as h :refer [GET]] 118 | [happy.client.xmlhttprequest :as hc])) 119 | 120 | (defn dump-request 121 | [[req om :as m]] 122 | (println "Request: " req) 123 | m) 124 | 125 | (h/set-default-client! (hc/create)) 126 | (h/merge-options! {:request-interceptors [dump-request]}) 127 | 128 | (GET "http://google.com") 129 | ``` 130 | 131 | `options` can be modified in a request interceptor. This allows for instance to generate per call response interceptor, like in this timing interceptor: 132 | 133 | ```clojure 134 | (ns my.app 135 | (:require [happy.core :as h :refer [GET]] 136 | [happy.client.xmlhttprequest :as hc])) 137 | 138 | (defn now [] (System/currentTimeMillis)) 139 | (defn timing-interceptor 140 | [[_ om :as v]] 141 | (let [i (now)] 142 | (assoc v 1 (update om :response-interceptors #(cons %2 %1) (fn [m _] (assoc m :timing (- (now) i))))))) 143 | 144 | (h/set-default-client! (hc/create)) 145 | (h/merge-options! {:request-interceptors [timing-interceptor]}) 146 | 147 | (GET "http://google.com" {} {:handler #(println "Executed in " (:timing %) "ms")}) 148 | ``` 149 | 150 | ## Representor 151 | 152 | Representors encapsulate the logic of converting HTTP body between the user and the client implementation. Custom representors can be provided by implementing the `happy.core/Representor` protocol. 153 | 154 | To have a representor used automatically as part of the HTTP call it must be defined using respectively the `request-interceptors` and `response-interceptors` options. 155 | Representors as interceptors are automatically applied based on request / response `content-type` and will replace `:body` with the result of their invocation. By specifying a mime-type via `override-request-mime-type` or `override-response-mime-type` a user can control with representor will be used. 156 | 157 | Default representor for `edn`, `json`, `transit` and other common mime types are [available](https://github.com/jeluard/happy/tree/master/src/happy/representor) and can be setup using the `merge-representors!` function defined in their respective namespace. 158 | 159 | ## License 160 | 161 | Copyright (C) 2015-2016 Julien Eluard 162 | 163 | Distributed under the Eclipse Public License, the same as Clojure. 164 | --------------------------------------------------------------------------------