forked from metosin/reitit
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
271 additions
and
0 deletions.
There are no files selected for viewing
152 changes: 152 additions & 0 deletions
152
modules/reitit-interceptors/src/reitit/http/interceptors/exception.clj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)))))})) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"}))))))) |