diff --git a/modules/reitit-interceptors/src/reitit/http/interceptors/exception.clj b/modules/reitit-interceptors/src/reitit/http/interceptors/exception.clj new file mode 100644 index 00000000..2bced558 --- /dev/null +++ b/modules/reitit-interceptors/src/reitit/http/interceptors/exception.clj @@ -0,0 +1,152 @@ +(ns reitit.http.interceptors.exception + (:require [reitit.coercion :as coercion] + [reitit.ring :as ring] + [clojure.spec.alpha :as s] + [clojure.string :as str]) + (:import (java.time Instant) + (java.io PrintWriter))) + +(s/def ::handlers (s/map-of any? fn?)) +(s/def ::spec (s/keys :opt-un [::handlers])) + +;; +;; helpers +;; + +(defn- super-classes [^Class k] + (loop [sk (.getSuperclass k), ks []] + (if-not (= sk Object) + (recur (.getSuperclass sk) (conj ks sk)) + ks))) + +(defn- call-error-handler [handlers error request] + (let [type (:type (ex-data error)) + ex-class (class error) + error-handler (or (get handlers type) + (get handlers ex-class) + (some + (partial get handlers) + (descendants type)) + (some + (partial get handlers) + (super-classes ex-class)) + (get handlers ::default))] + (if-let [wrap (get handlers ::wrap)] + (wrap error-handler error request) + (error-handler error request)))) + +(defn print! [^PrintWriter writer & more] + (.write writer (str (str/join " " more) "\n"))) + +;; +;; handlers +;; + +(defn default-handler + "Default safe handler for any exception." + [^Exception e _] + {:status 500 + :body {:type "exception" + :class (.getName (.getClass e))}}) + +(defn create-coercion-handler + "Creates a coercion exception handler." + [status] + (fn [e _] + {:status status + :body (coercion/encode-error (ex-data e))})) + +(defn http-response-handler + "Reads response from Exception ex-data :response" + [e _] + (-> e ex-data :response)) + +(defn request-parsing-handler [e _] + {:status 400 + :headers {"Content-Type" "text/plain"} + :body (str "Malformed " (-> e ex-data :format pr-str) " request.")}) + +(defn wrap-log-to-console [handler e {:keys [uri request-method] :as req}] + (print! *out* (Instant/now) request-method (pr-str uri) "=>" (.getMessage e)) + (.printStackTrace e *out*) + (handler e req)) + +;; +;; public api +;; + +(def default-handlers + {::default default-handler + ::ring/response http-response-handler + :muuntaja/decode request-parsing-handler + ::coercion/request-coercion (create-coercion-handler 400) + ::coercion/response-coercion (create-coercion-handler 500)}) + +(defn exception-interceptor + "Creates an Interceptor that catches all exceptions. Takes a map + of `identifier => exception request => response` that is used to select + the exception handler for the thown/raised exception identifier. Exception + idenfier is either a `Keyword` or a Exception Class. + + The following handlers special handlers are available: + + | key | description + |------------------------|------------- + | `::exception/default` | a default exception handler if nothing else mathced (default [[default-handler]]). + | `::exception/wrap` | a 3-arity handler to wrap the actual handler `handler exception request => response` + + The handler is selected from the options map by exception idenfiter + in the following lookup order: + + 1) `:type` of exception ex-data + 2) Class of exception + 3) `:type` ancestors of exception ex-data + 4) Super Classes of exception + 5) The ::default handler + + Example: + + (require '[reitit.ring.interceptors.exception :as exception]) + + ;; type hierarchy + (derive ::error ::exception) + (derive ::failure ::exception) + (derive ::horror ::exception) + + (defn handler [message exception request] + {:status 500 + :body {:message message + :exception (str exception) + :uri (:uri request)}}) + + (exception/exception-interceptor + (merge + exception/default-handlers + {;; ex-data with :type ::error + ::error (partial handler \"error\") + + ;; ex-data with ::exception or ::failure + ::exception (partial handler \"exception\") + + ;; SQLException and all it's child classes + java.sql.SQLException (partial handler \"sql-exception\") + + ;; override the default handler + ::exception/default (partial handler \"default\") + + ;; print stack-traces for all exceptions + ::exception/wrap (fn [handler e request] + (.printStackTrace e) + (handler e request))}))" + ([] + (exception-interceptor default-handlers)) + ([handlers] + {:name ::exception + :spec ::spec + :error (fn [ctx] + (let [error (:error ctx) + request (:request ctx) + response (call-error-handler handlers error request)] + (if (instance? Exception response) + (-> ctx (assoc :error response) (dissoc :response)) + (-> ctx (assoc :response response) (dissoc :error)))))})) diff --git a/test/clj/reitit/http/interceptors/exception_test.clj b/test/clj/reitit/http/interceptors/exception_test.clj new file mode 100644 index 00000000..c50ce106 --- /dev/null +++ b/test/clj/reitit/http/interceptors/exception_test.clj @@ -0,0 +1,119 @@ +(ns reitit.http.interceptors.exception-test + (:require [clojure.test :refer [deftest testing is]] + [reitit.ring :as ring] + [reitit.http :as http] + [reitit.http.interceptors.exception :as exception] + [reitit.interceptor.sieppari :as sieppari] + [reitit.coercion.spec] + [reitit.http.coercion] + [muuntaja.core :as m]) + (:import (java.sql SQLException SQLWarning))) + +(derive ::kikka ::kukka) + +(deftest exception-test + (letfn [(create + ([f] + (create f nil)) + ([f wrap] + (http/ring-handler + (http/router + [["/defaults" + {:handler f}] + ["/coercion" + {:interceptors [(reitit.http.coercion/coerce-request-interceptor) + (reitit.http.coercion/coerce-response-interceptor)] + :coercion reitit.coercion.spec/coercion + :parameters {:query {:x int?, :y int?}} + :responses {200 {:body {:total pos-int?}}} + :handler f}]] + {:data {:interceptors [(exception/exception-interceptor + (merge + exception/default-handlers + {::kikka (constantly {:status 400, :body "kikka"}) + SQLException (constantly {:status 400, :body "sql"}) + ::exception/wrap wrap}))]}}) + {:executor sieppari/executor})))] + + (testing "normal calls work ok" + (let [response {:status 200, :body "ok"} + app (create (fn [_] response))] + (is (= response (app {:request-method :get, :uri "/defaults"}))))) + + (testing "unknown exception" + (let [app (create (fn [_] (throw (NullPointerException.))))] + (is (= {:status 500 + :body {:type "exception" + :class "java.lang.NullPointerException"}} + (app {:request-method :get, :uri "/defaults"})))) + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::invalid}))))] + (is (= {:status 500 + :body {:type "exception" + :class "clojure.lang.ExceptionInfo"}} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "::ring/response" + (let [response {:status 200, :body "ok"} + app (create (fn [_] (throw (ex-info "fail" {:type ::ring/response, :response response}))))] + (is (= response (app {:request-method :get, :uri "/defaults"}))))) + + (testing ":muuntaja/decode" + (let [app (create (fn [_] (m/decode m/instance "application/json" "{:so \"invalid\"}")))] + (is (= {:body "Malformed \"application/json\" request." + :headers {"Content-Type" "text/plain"} + :status 400} + (app {:request-method :get, :uri "/defaults"})))) + + (testing "::coercion/request-coercion" + (let [app (create (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200, :body {:total (+ x y)}}))] + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "1", "y" "2"}})] + (is (= 200 status)) + (is (= {:total 3} body))) + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "abba", "y" "2"}})] + (is (= 400 status)) + (is (= :reitit.coercion/request-coercion (:type body)))) + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "-10", "y" "2"}})] + (is (= 500 status)) + (is (= :reitit.coercion/response-coercion (:type body))))))) + + (testing "exact :type" + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::kikka}))))] + (is (= {:status 400, :body "kikka"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "parent :type" + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::kukka}))))] + (is (= {:status 400, :body "kikka"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "exact Exception" + (let [app (create (fn [_] (throw (SQLException.))))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "Exception SuperClass" + (let [app (create (fn [_] (throw (SQLWarning.))))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "::exception/wrap" + (let [calls (atom 0) + app (create (fn [_] (throw (SQLWarning.))) + (fn [handler exception request] + (if (< (swap! calls inc) 2) + (handler exception request) + {:status 500, :body "too many tries"})))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))) + (is (= {:status 500, :body "too many tries"} + (app {:request-method :get, :uri "/defaults"})))))))