Implement Interceptors like Middlewar

This commit is contained in:
Tommi Reiman 2017-12-12 22:27:50 +02:00
parent 927d4d4389
commit fd6a39aefc
2 changed files with 259 additions and 45 deletions

View file

@ -9,22 +9,19 @@
(defrecord Interceptor [name enter leave error])
(defrecord Endpoint [data interceptors])
(defn create [{:keys [name wrap compile] :as m}]
(when (and wrap compile)
(throw
(ex-info
(str "Interceptor can't have both :wrap and :compile defined " m) m)))
(map->Interceptor m))
(def ^:dynamic *max-compile-depth* 10)
(extend-protocol IntoInterceptor
#?(:clj clojure.lang.APersistentVector
:cljs cljs.core.PersistentVector)
(into-interceptor [[f & args] data opts]
(if-let [{:keys [wrap] :as mw} (into-interceptor f data opts)]
(assoc mw :wrap #(apply wrap % args))))
(into-interceptor [[f & args :as form] data opts]
(when (and (seq args) (not (fn? f)))
(throw
(ex-info
(str "Invalid Interceptor form: " form "")
{:form form})))
(into-interceptor (apply f args) data opts))
#?(:clj clojure.lang.Fn
:cljs function)
@ -35,12 +32,12 @@
#?(:clj clojure.lang.PersistentArrayMap
:cljs cljs.core.PersistentArrayMap)
(into-interceptor [this data opts]
(into-interceptor (create this) data opts))
(into-interceptor (map->Interceptor this) data opts))
#?(:clj clojure.lang.PersistentHashMap
:cljs cljs.core.PersistentHashMap)
(into-interceptor [this data opts]
(into-interceptor (create this) data opts))
(into-interceptor (map->Interceptor this) data opts))
Interceptor
(into-interceptor [{:keys [compile] :as this} data opts]
@ -70,24 +67,37 @@
(merge {:path path, :data data}
(if scope {:scope scope}))))))
(defn expand [interceptors data opts]
(defn- expand-and-transform
[interceptors data {:keys [::transform] :or {transform identity} :as opts}]
(->> interceptors
(keep #(into-interceptor % data opts))
(transform)
(keep #(into-interceptor % data opts))
(into [])))
(defn interceptor-chain [interceptors handler data opts]
(expand (conj interceptors handler) data opts))
;;
;; public api
;;
(defn chain
"Creates a Interceptor chain out of sequence of IntoInterceptor
and optionally a handler. Optionally takes route data and (Router) opts."
([interceptors handler data]
(chain interceptors handler data nil))
([interceptors handler data opts]
(let [interceptor (some-> (into-interceptor handler data opts)
(assoc :name (:name data)))]
(-> (expand-and-transform interceptors data opts)
(cond-> interceptor (conj interceptor))))))
(defn compile-result
([route opts]
(compile-result route opts nil))
([[path {:keys [interceptors handler] :as data}]
{:keys [::transform] :or {transform identity} :as opts} scope]
([[path {:keys [interceptors handler] :as data}] opts scope]
(ensure-handler! path data scope)
(let [interceptors (expand (transform (expand interceptors data opts)) data opts)]
(map->Endpoint
{:interceptors (interceptor-chain interceptors handler data opts)
:data data}))))
{:interceptors (chain interceptors handler data opts)
:data data})))
(defn router
"Creates a [[reitit.core/Router]] from raw route data and optionally an options map with
@ -96,10 +106,16 @@
Example:
(router
[\"/api\" {:interceptors [i/format i/oauth2]}
[\"/users\" {:interceptors [i/delete]
[\"/api\" {:interceptors [format-body oauth2]}
[\"/users\" {:interceptors [delete]
:handler get-user}]])
Options:
| key | description |
| --------------------------------|-------------|
| `:reitit.interceptor/transform` | Function of [Interceptor] => [Interceptor] to transform the expanded Interceptors (default: identity).
See router options from [[reitit.core/router]]."
([data]
(router data nil))
@ -110,28 +126,7 @@
(defn interceptor-handler [router]
(with-meta
(fn [path]
(some->> path
(r/match-by-path router)
(some->> (r/match-by-path router path)
:result
:interceptors))
{::router router}))
(comment
(defn execute [r {{:keys [uri]} :request :as ctx}]
(if-let [interceptors (-> (r/match-by-path r uri)
:result
:interceptors)]
(as-> ctx $
(reduce #(%2 %1) $ (keep :enter interceptors))
(reduce #(%2 %1) $ (keep :leave interceptors)))))
(def r
(router
["/api" {:interceptors [{:name ::add
:enter (fn [ctx]
(assoc ctx :enter true))
:leave (fn [ctx]
(assoc ctx :leave true))}]}
["/ping" (fn [ctx] (assoc ctx :response "ok"))]]))
(execute r {:request {:uri "/api/ping"}}))

View file

@ -0,0 +1,219 @@
(ns reitit.interceptor-test
(:require [clojure.test :refer [deftest testing is are]]
[reitit.interceptor :as interceptor]
[reitit.core :as r])
#?(:clj
(:import (clojure.lang ExceptionInfo))))
(defn execute [interceptors ctx]
(as-> ctx $
(reduce #(%2 %1) $ (keep :enter interceptors))
(reduce #(%2 %1) $ (reverse (keep :leave interceptors)))))
(def ctx [])
(defn interceptor [value]
{:name value
:enter #(conj % value)
:leave #(conj % value)})
(defn enter [value]
{:name value
:enter #(conj % value)})
(defn handler [ctx]
(conj ctx :ok))
(defn create [interceptors]
(let [chain (interceptor/chain
interceptors
handler :data nil)]
(partial execute chain)))
(deftest expand-interceptor-test
(testing "interceptor records"
(testing "interceptor"
(let [calls (atom 0)
enter (fn [value]
(swap! calls inc)
(fn [ctx]
(conj ctx value)))]
(testing "as function"
(reset! calls 0)
(let [app (create [(enter :value)])]
(dotimes [_ 10]
(is (= [:value :ok] (app ctx)))
(is (= 1 @calls)))))
(testing "as interceptor vector"
(reset! calls 0)
(let [app (create [[enter :value]])]
(dotimes [_ 10]
(is (= [:value :ok] (app ctx)))
(is (= 1 @calls)))))
(testing "as map"
(reset! calls 0)
(let [app (create [{:enter (enter :value)}])]
(dotimes [_ 10]
(is (= [:value :ok] (app ctx)))
(is (= 1 @calls)))))
(testing "as Interceptor"
(reset! calls 0)
(let [app (create [(interceptor/map->Interceptor {:enter (enter :value)})])]
(dotimes [_ 10]
(is (= [:value :ok] (app ctx)))
(is (= 1 @calls)))))))
(testing "compiled interceptor"
(let [calls (atom 0)
i1 (fn [value]
{:compile (fn [data _]
(swap! calls inc)
(fn [ctx]
(into ctx [data value])))})
i3 (fn [value]
{:compile (fn [data _]
(swap! calls inc)
{:compile (fn [data _]
(swap! calls inc)
(i1 value))})})]
(testing "as function"
(reset! calls 0)
(let [app (create [[i1 :value]])]
(dotimes [_ 10]
(is (= [:data :value :ok] (app ctx)))
(is (= 2 @calls)))))
(testing "as interceptor"
(reset! calls 0)
(let [app (create [(i1 :value)])]
(dotimes [_ 10]
(is (= [:data :value :ok] (app ctx)))
(is (= 2 @calls)))))
(testing "deeply compiled interceptor"
(reset! calls 0)
(let [app (create [[i3 :value]])]
(dotimes [_ 10]
(is (= [:data :value :ok] (app ctx)))
(is (= 4 @calls)))))
(testing "too deeply compiled interceptor fails"
(binding [interceptor/*max-compile-depth* 2]
(is (thrown?
ExceptionInfo
#"Too deep Interceptor compilation"
(create [[i3 :value]])))))
(testing "nil unmounts the interceptor"
(let [app (create [{:compile (constantly nil)}
{:compile (constantly nil)}])]
(dotimes [_ 10]
(is (= [:ok] (app ctx))))))))))
(defn create-app [router]
(let [handler (interceptor/interceptor-handler router)]
(fn [path]
(when-let [interceptors (handler path)]
(execute interceptors [])))))
(deftest interceptor-handler-test
(testing "all paths should have a handler"
(is (thrown-with-msg?
ExceptionInfo
#"path \"/ping\" doesn't have a :handler defined"
(interceptor/router ["/ping"]))))
(testing "interceptor-handler"
(let [api-interceptor (interceptor :api)
router (interceptor/router
[["/ping" handler]
["/api" {:interceptors [api-interceptor]}
["/ping" handler]
["/admin" {:interceptors [[interceptor :admin]]}
["/ping" handler]]]])
app (create-app router)]
(testing "not found"
(is (= nil (app "/favicon.ico"))))
(testing "normal handler"
(is (= [:ok] (app "/ping"))))
(testing "with interceptor"
(is (= [:api :ok :api] (app "/api/ping"))))
(testing "with nested interceptor"
(is (= [:api :admin :ok :admin :api] (app "/api/admin/ping"))))
(testing ":compile interceptor can be unmounted at creation-time"
(let [i1 {:name ::i1, :compile (constantly (interceptor ::i1))}
i2 {:name ::i2, :compile (constantly nil)}
i3 (interceptor ::i3)
router (interceptor/router
["/api" {:name ::api
:interceptors [i1 i2 i3 i2]
:handler handler}])
app (create-app router)]
(is (= [::i1 ::i3 :ok ::i3 ::i1] (app "/api")))
(testing "routes contain list of actually applied interceptors"
(is (= [::i1 ::i3 ::api] (->> (r/routes router)
first
last
:interceptors
(map :name)))))
(testing "match contains list of actually applied interceptors"
(is (= [::i1 ::i3 ::api] (->> "/api"
(r/match-by-path router)
:result
:interceptors
(map :name))))))))))
(deftest chain-test
(testing "chain can produce interceptor chain of any IntoInterceptor"
(let [i1 {:compile (constantly (interceptor ::i1))}
i2 {:compile (constantly nil)}
i3 (interceptor ::i3)
i4 (interceptor ::i4)
i5 {:compile (fn [{:keys [mount?]} _]
(when mount?
(interceptor ::i5)))}
chain1 (interceptor/chain [i1 i2 i3 i4 i5] handler {:mount? true})
chain2 (interceptor/chain [i1 i2 i3 i4 i5] handler {:mount? false})
chain3 (interceptor/chain [i1 i2 i3 i4 i5] nil {:mount? false})]
(is (= [::i1 ::i3 ::i4 ::i5 :ok ::i5 ::i4 ::i3 ::i1] (execute chain1 [])))
(is (= [::i1 ::i3 ::i4 :ok ::i4 ::i3 ::i1] (execute chain2 [])))
(is (= [::i1 ::i3 ::i4 ::i4 ::i3 ::i1] (execute chain3 []))))))
(deftest interceptor-transform-test
(let [debug-i (enter ::debug)
create (fn [options]
(create-app
(interceptor/router
["/ping" {:interceptors [(enter ::olipa)
(enter ::kerran)
(enter ::avaruus)]
:handler handler}]
options)))]
(testing "by default, all interceptors are applied in order"
(let [app (create nil)]
(is (= [::olipa ::kerran ::avaruus :ok] (app "/ping")))))
(testing "interceptors can be re-ordered"
(let [app (create {::interceptor/transform (partial sort-by :name)})]
(is (= [::avaruus ::kerran ::olipa :ok] (app "/ping")))))
(testing "adding debug interceptor between interceptors"
(let [app (create {::interceptor/transform #(interleave % (repeat debug-i))})]
(is (= [::olipa ::debug ::kerran ::debug ::avaruus ::debug :ok] (app "/ping")))))))