diff --git a/examples/pedestal/src/example/server.clj b/examples/pedestal/src/example/server.clj index be006f15..dbae982c 100644 --- a/examples/pedestal/src/example/server.clj +++ b/examples/pedestal/src/example/server.clj @@ -1,7 +1,7 @@ (ns example.server (:require [io.pedestal.http] [clojure.core.async :as a] - [reitit.pedestal :as pedestal] + [reitit.interceptor.pedestal :as pedestal] [reitit.http :as http] [reitit.ring :as ring])) @@ -24,7 +24,7 @@ (http/router ["/api" {:interceptors [[interceptor :api] - [interceptor :apa]]} + [interceptor :ipa]]} ["/sync" {:interceptors [[interceptor :sync]] diff --git a/examples/pedestal/src/reitit/pedestal.clj b/examples/pedestal/src/reitit/interceptor/pedestal.clj similarity index 97% rename from examples/pedestal/src/reitit/pedestal.clj rename to examples/pedestal/src/reitit/interceptor/pedestal.clj index 7d8de821..362cbd5d 100644 --- a/examples/pedestal/src/reitit/pedestal.clj +++ b/examples/pedestal/src/reitit/interceptor/pedestal.clj @@ -1,4 +1,4 @@ -(ns reitit.pedestal +(ns reitit.interceptor.pedestal (:require [io.pedestal.interceptor.chain :as chain] [io.pedestal.interceptor :as interceptor] [io.pedestal.http :as http] diff --git a/modules/reitit-http/src/reitit/http.cljc b/modules/reitit-http/src/reitit/http.cljc index 1befcff7..c7a8f852 100644 --- a/modules/reitit-http/src/reitit/http.cljc +++ b/modules/reitit-http/src/reitit/http.cljc @@ -89,60 +89,63 @@ context))})) (defn ring-handler - "Creates a ring-handler out of a http-router, - a default ring-handler and options map, with the following keys: + "Creates a ring-handler out of a http-router, optional default ring-handler + and options map, with the following keys: | key | description | | ----------------|-------------| | `:executor` | `reitit.interceptor.Executor` for the interceptor chain | `:interceptors` | Optional sequence of interceptors that are always run before any other interceptors, even for the default handler" - [router default-handler {:keys [executor interceptors]}] - (let [default-handler (or default-handler (fn ([_]) ([_ respond _] (respond nil)))) - default-queue (->> [default-handler] - (concat interceptors) - (map #(interceptor/into-interceptor % nil (r/options router))) - (interceptor/queue executor)) - router-opts (-> (r/options router) - (assoc ::interceptor/queue (partial interceptor/queue executor)) - (cond-> (seq interceptors) - (update-in [:data :interceptors] (partial into (vec interceptors))))) - router (reitit.http/router (r/routes router) router-opts)] - (with-meta - (fn - ([request] - (if-let [match (r/match-by-path router (:uri request))] - (let [method (:request-method request) - path-params (:path-params match) - endpoint (-> match :result method) - interceptors (or (:queue endpoint) (:interceptors endpoint)) - request (-> request - (impl/fast-assoc :path-params path-params) - (impl/fast-assoc ::r/match match) - (impl/fast-assoc ::r/router router))] - (or (interceptor/execute executor interceptors request) - (interceptor/execute executor default-queue request))) - (interceptor/execute executor default-queue request))) - ([request respond raise] - (let [default #(interceptor/execute executor default-queue % respond raise)] - (if-let [match (r/match-by-path router (:uri request))] - (let [method (:request-method request) - path-params (:path-params match) - endpoint (-> match :result method) - interceptors (or (:queue endpoint) (:interceptors endpoint)) - request (-> request - (impl/fast-assoc :path-params path-params) - (impl/fast-assoc ::r/match match) - (impl/fast-assoc ::r/router router)) - respond' (fn [response] - (if response - (respond response) - (default request)))] - (if interceptors - (interceptor/execute executor interceptors request respond' raise) - (default request))) - (default request))) - nil)) - {::r/router router}))) + ([router opts] + (ring-handler router nil opts)) + ([router default-handler {:keys [executor interceptors]}] + (let [default-handler (or default-handler (fn ([_]) ([_ respond _] (respond nil)))) + default-queue (->> [default-handler] + (concat interceptors) + (map #(interceptor/into-interceptor % nil (r/options router))) + (interceptor/queue executor)) + router-opts (-> (r/options router) + (assoc ::interceptor/queue (partial interceptor/queue executor)) + (dissoc :data) ; data is already merged into routes + (cond-> (seq interceptors) + (update-in [:data :interceptors] (partial into (vec interceptors))))) + router (reitit.http/router (r/routes router) router-opts)] + (with-meta + (fn + ([request] + (if-let [match (r/match-by-path router (:uri request))] + (let [method (:request-method request) + path-params (:path-params match) + endpoint (-> match :result method) + interceptors (or (:queue endpoint) (:interceptors endpoint)) + request (-> request + (impl/fast-assoc :path-params path-params) + (impl/fast-assoc ::r/match match) + (impl/fast-assoc ::r/router router))] + (or (interceptor/execute executor interceptors request) + (interceptor/execute executor default-queue request))) + (interceptor/execute executor default-queue request))) + ([request respond raise] + (let [default #(interceptor/execute executor default-queue % respond raise)] + (if-let [match (r/match-by-path router (:uri request))] + (let [method (:request-method request) + path-params (:path-params match) + endpoint (-> match :result method) + interceptors (or (:queue endpoint) (:interceptors endpoint)) + request (-> request + (impl/fast-assoc :path-params path-params) + (impl/fast-assoc ::r/match match) + (impl/fast-assoc ::r/router router)) + respond' (fn [response] + (if response + (respond response) + (default request)))] + (if interceptors + (interceptor/execute executor interceptors request respond' raise) + (default request))) + (default request))) + nil)) + {::r/router router})))) (defn get-router [handler] (-> handler meta ::r/router)) diff --git a/modules/reitit-http/src/reitit/http/coercion.cljc b/modules/reitit-http/src/reitit/http/coercion.cljc index e14019a7..a4d6cbda 100644 --- a/modules/reitit-http/src/reitit/http/coercion.cljc +++ b/modules/reitit-http/src/reitit/http/coercion.cljc @@ -3,51 +3,54 @@ [reitit.spec :as rs] [reitit.impl :as impl])) -(def coerce-request-interceptor +(defn coerce-request-interceptor "Interceptor for pluggable request coercion. Expects a :coercion of type `reitit.coercion/Coercion` and :parameters from route data, otherwise does not mount." + [] {:name ::coerce-request :spec ::rs/parameters :compile (fn [{:keys [coercion parameters]} opts] (if (and coercion parameters) (let [coercers (coercion/request-coercers coercion parameters opts)] - {:enter - (fn [ctx] - (let [request (:request ctx) - coerced (coercion/coerce-request coercers request) - request (impl/fast-assoc request :parameters coerced)] - (assoc ctx :request request)))})))}) + {:enter (fn [ctx] + (let [request (:request ctx) + coerced (coercion/coerce-request coercers request) + request (impl/fast-assoc request :parameters coerced)] + (assoc ctx :request request)))})))}) -(def coerce-response-interceptor +(defn coerce-response-interceptor "Interceptor for pluggable response coercion. Expects a :coercion of type `reitit.coercion/Coercion` and :responses from route data, otherwise does not mount." + [] {:name ::coerce-response :spec ::rs/responses :compile (fn [{:keys [coercion responses]} opts] (if (and coercion responses) (let [coercers (coercion/response-coercers coercion responses opts)] - {:leave - (fn [ctx] - (let [response (coercion/coerce-response coercers (:request ctx) (:response ctx))] - (assoc ctx :response response)))})))}) + {:leave (fn [ctx] + (let [request (:request ctx) + response (:response ctx)] + (let [response (coercion/coerce-response coercers request response)] + (assoc ctx :response response))))})))}) -(def coerce-exceptions-interceptor - "Interceptor for handling coercion exceptions. - Expects a :coercion of type `reitit.coercion/Coercion` - and :parameters or :responses from route data, otherwise does not mount." - {:name ::coerce-exceptions - :compile (fn [{:keys [coercion parameters responses]} _] - (if (and coercion (or parameters responses)) - {:error (fn [ctx] - (let [data (ex-data (:error ctx))] - (if-let [status (case (:type data) - ::coercion/request-coercion 400 - ::coercion/response-coercion 500 - nil)] - (let [response {:status status, :body (coercion/encode-error data)}] - (-> ctx - (assoc :response response) - (assoc :error nil))) - ctx)))}))}) + (defn coerce-exceptions-interceptor + "Interceptor for handling coercion exceptions. + Expects a :coercion of type `reitit.coercion/Coercion` + and :parameters or :responses from route data, otherwise does not mount." + [] + {:name ::coerce-exceptions + :compile (fn [{:keys [coercion parameters responses]} _] + (if (and coercion (or parameters responses)) + {:error (fn [ctx] + (let [data (ex-data (:error ctx))] + (if-let [status (case (:type data) + ::coercion/request-coercion 400 + ::coercion/response-coercion 500 + nil)] + (let [response {:status status, :body (coercion/encode-error data)}] + (-> ctx + (assoc :response response) + (assoc :error nil))) + ctx)))}))}) diff --git a/project.clj b/project.clj index d16a8cad..29ea4a87 100644 --- a/project.clj +++ b/project.clj @@ -25,7 +25,7 @@ [metosin/spec-tools "0.7.1"] [metosin/schema-tools "0.10.3"] [metosin/ring-swagger-ui "2.2.10"] - [metosin/muuntaja "0.6.0-alpha4"] + [metosin/muuntaja "0.6.0-alpha5"] [metosin/jsonista "0.2.1"] [metosin/sieppari "0.0.0-alpha4"]] @@ -62,7 +62,7 @@ [ring "1.6.3"] [ikitommi/immutant-web "3.0.0-alpha1"] - [metosin/muuntaja "0.6.0-alpha4"] + [metosin/muuntaja "0.6.0-alpha5"] [metosin/ring-swagger-ui "2.2.10"] [metosin/sieppari "0.0.0-alpha4"] [metosin/jsonista "0.2.1"] diff --git a/test/clj/reitit/http_coercion_test.clj b/test/clj/reitit/http_coercion_test.clj new file mode 100644 index 00000000..4586eabb --- /dev/null +++ b/test/clj/reitit/http_coercion_test.clj @@ -0,0 +1,198 @@ +(ns reitit.http-coercion-test + (:require [clojure.test :refer [deftest testing is]] + [schema.core :as s] + [reitit.http :as http] + [reitit.http.coercion :as rrc] + [reitit.coercion.spec :as spec] + [reitit.coercion.schema :as schema] + [muuntaja.interceptor] + [jsonista.core :as j] + [reitit.interceptor.sieppari :as sieppari]) + (:import (clojure.lang ExceptionInfo) + (java.io ByteArrayInputStream))) + +(defn handler [{{{:keys [a]} :query + {:keys [b]} :body + {:keys [c]} :form + {:keys [d]} :header + {:keys [e]} :path} :parameters}] + (if (= 666 a) + {:status 500 + :body {:evil true}} + {:status 200 + :body {:total (+ a b c d e)}})) + +(def valid-request + {:uri "/api/plus/5" + :request-method :get + :query-params {"a" "1"} + :body-params {:b 2} + :form-params {:c 3} + :headers {"d" "4"}}) + +(def invalid-request + {:uri "/api/plus/5" + :request-method :get}) + +(def invalid-request2 + {:uri "/api/plus/5" + :request-method :get + :query-params {"a" "1"} + :body-params {:b 2} + :form-params {:c 3} + :headers {"d" "-40"}}) + +(deftest spec-coercion-test + (let [create (fn [interceptors] + (http/ring-handler + (http/router + ["/api" + ["/plus/:e" + {:get {:parameters {:query {:a int?} + :body {:b int?} + :form {:c int?} + :header {:d int?} + :path {:e int?}} + :responses {200 {:body {:total pos-int?}} + 500 {:description "fail"}} + :handler handler}}]] + {:data {:interceptors interceptors + :coercion spec/coercion}}) + {:executor sieppari/executor}))] + + (testing "without exception handling" + (let [app (create [(rrc/coerce-request-interceptor) + (rrc/coerce-response-interceptor)])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request))) + (is (= {:status 500 + :body {:evil true}} + (app (assoc-in valid-request [:query-params "a"] "666"))))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request)))) + + (testing "invalid response" + (is (thrown-with-msg? + ExceptionInfo + #"Response coercion failed" + (app invalid-request2)))))) + + (testing "with exception handling" + (let [app (create [(rrc/coerce-exceptions-interceptor) + (rrc/coerce-request-interceptor) + (rrc/coerce-response-interceptor)])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (let [{:keys [status]} (app invalid-request)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status]} (app invalid-request2)] + (is (= 500 status)))))))) + +(deftest schema-coercion-test + (let [create (fn [middleware] + (http/ring-handler + (http/router + ["/api" + ["/plus/:e" + {:get {:parameters {:query {:a s/Int} + :body {:b s/Int} + :form {:c s/Int} + :header {:d s/Int} + :path {:e s/Int}} + :responses {200 {:body {:total (s/constrained s/Int pos? 'positive)}} + 500 {:description "fail"}} + :handler handler}}]] + {:data {:interceptors middleware + :coercion schema/coercion}}) + {:executor sieppari/executor}))] + + (testing "withut exception handling" + (let [app (create [(rrc/coerce-request-interceptor) + (rrc/coerce-response-interceptor)])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request))) + (is (= {:status 500 + :body {:evil true}} + (app (assoc-in valid-request [:query-params "a"] "666"))))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request)))) + + (testing "invalid response" + (is (thrown-with-msg? + ExceptionInfo + #"Response coercion failed" + (app invalid-request2)))) + + (testing "with exception handling" + (let [app (create [(rrc/coerce-exceptions-interceptor) + (rrc/coerce-request-interceptor) + (rrc/coerce-response-interceptor)])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (let [{:keys [status]} (app invalid-request)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status]} (app invalid-request2)] + (is (= 500 status)))))))))) + +(deftest muuntaja-test + (let [app (http/ring-handler + (http/router + ["/api" + ["/plus" + {:post {:parameters {:body {:int int?, :keyword keyword?}} + :responses {200 {:body {:int int?, :keyword keyword?}}} + :handler (fn [{{:keys [body]} :parameters}] + {:status 200 + :body body})}}]] + {:data {:interceptors [(muuntaja.interceptor/format-interceptor) + (rrc/coerce-response-interceptor) + (rrc/coerce-request-interceptor)] + :coercion spec/coercion}}) + {:executor sieppari/executor}) + request (fn [content-type body] + (-> {:request-method :post + :headers {"content-type" content-type, "accept" content-type} + :uri "/api/plus" + :body body})) + data-edn {:int 1 :keyword :kikka} + data-json {:int 1 :keyword "kikka"}] + + (testing "json coercion" + (let [e2e #(-> (request "application/json" (ByteArrayInputStream. (j/write-value-as-bytes %))) + (app) :body (slurp) (j/read-value (j/object-mapper {:decode-key-fn true})))] + (is (= data-json (e2e data-edn))) + (is (= data-json (e2e data-json))))) + + (testing "edn coercion" + (let [e2e #(-> (request "application/edn" (pr-str %)) + (app) :body slurp (read-string))] + (is (= data-edn (e2e data-edn))) + (is (thrown? ExceptionInfo (e2e data-json)))))))