diff --git a/modules/reitit-core/src/reitit/interceptor.cljc b/modules/reitit-core/src/reitit/interceptor.cljc index 1a030455..6f129621 100644 --- a/modules/reitit-core/src/reitit/interceptor.cljc +++ b/modules/reitit-core/src/reitit/interceptor.cljc @@ -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})))) + (map->Endpoint + {: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"}})) diff --git a/test/cljc/reitit/interceptor_test.cljc b/test/cljc/reitit/interceptor_test.cljc new file mode 100644 index 00000000..bb571c8c --- /dev/null +++ b/test/cljc/reitit/interceptor_test.cljc @@ -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")))))))