Skip to content

Commit

Permalink
exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
ikitommi committed Sep 7, 2018
1 parent de3fc48 commit ca02680
Show file tree
Hide file tree
Showing 2 changed files with 271 additions and 0 deletions.
152 changes: 152 additions & 0 deletions modules/reitit-interceptors/src/reitit/http/interceptors/exception.clj
Original file line number Diff line number Diff line change
@@ -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)))))}))
119 changes: 119 additions & 0 deletions test/clj/reitit/http/interceptors/exception_test.clj
Original file line number Diff line number Diff line change
@@ -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"})))))))

0 comments on commit ca02680

Please sign in to comment.