Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New interceptors and updates to existing ones #254

Merged
merged 13 commits into from
Sep 14, 2023
6 changes: 5 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
piotr-yuxuan/closeable-map {:mvn/version "0.35.0"},
seancorfield/next.jdbc {:mvn/version "1.2.659"},
yogthos/config {:mvn/version "1.2.0"}
hikari-cp/hikari-cp {:mvn/version "3.0.1"}}
hikari-cp/hikari-cp {:mvn/version "3.0.1"}
org.slf4j/slf4j-simple {:mvn/version "2.0.7"}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}}

:aliases
{:dev
Expand Down Expand Up @@ -66,6 +68,8 @@
{:extra-paths ["config/test" "test"]
:extra-deps {clj-test-containers/clj-test-containers {:mvn/version "0.5.0"}
clj-http/clj-http {:mvn/version "3.12.3"}
ring/ring-mock {:mvn/version "0.4.0"}
peridot/peridot {:mvn/version "0.5.4"}
http.async.client/http.async.client {:mvn/version "1.3.1"}
com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner"
:sha "4e7e1c0dfd5291fa2134df052443dc29695d8cbe"}
Expand Down
21 changes: 11 additions & 10 deletions src/xiana/interceptor.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
[clojure.pprint :refer [pprint]]
[clojure.walk :refer [keywordize-keys]]
[malli.core :as m]
[malli.error :as me]
[malli.transform :as mt]
[ring.middleware.multipart-params :refer [multipart-params-request]]
[ring.middleware.params :as middleware.params]
[xiana.interceptor.muuntaja :as muuntaja]
[xiana.session :as session])
Expand Down Expand Up @@ -53,9 +55,9 @@
Leave: nil."
{:name ::params
:enter (fn [state]
(let [f #(keywordize-keys
((middleware.params/wrap-params identity) %))]
(update state :request f)))})
(let [wrap-params #(keywordize-keys
((middleware.params/wrap-params identity) %))]
(update state :request (comp wrap-params multipart-params-request))))})

(defn message ; TODO: remove, use logger
"This interceptor creates a function that prints predefined message.
Expand All @@ -68,7 +70,7 @@
(defn session-user-id
"This interceptor handles the session user id management.
Enter: Get the session id from the request header, if
that operation doesn't succeeds a new session is created an associated to the
that operation doesn't succeed a new session is created an associated to the
current state, otherwise the cached session data is used.
Leave: Verify if the state has a session id, if so add it to
the session instance and remove the new session property of the current state.
Expand Down Expand Up @@ -143,32 +145,32 @@
(update state :request dissoc :body :body-params)
state))})

(defn- valid? [?schema data]
(defn valid? [?schema data]
(let [value (m/decode ?schema data (mt/transformer
(mt/json-transformer)
(mt/string-transformer)
(mt/strip-extra-keys-transformer)))
details (m/explain ?schema value)]
(if (nil? details)
value
(throw (ex-info "Request schema validation error"
{:xiana/response {:status 400
:body (pr-str details)}})))))
(throw (ex-info "Request schema validation/coercion error"
{:xiana/response {:details (me/humanize details)}})))))

(def coercion
"On enter: validates request parameters
On leave: validates response body
on request error: responds {:status 400, :body \"Request coercion failed\"}
on response error: responds {:status 400, :body \"Response validation failed\"}"
{:enter (fn [state]
(if (= :options (-> state :request :request-method))
(if (= :options (get-in state [:request :request-method]))
state
(let [path (get-in state [:request-data :match :path-params])
query (get-in state [:request :query-params])
form-params (or (not-empty (get-in state [:request :form-params]))
(not-empty (get-in state [:request :multipart-params]))
(not-empty (get-in state [:request :body-params])))
method (get-in state [:request :request-method])

schemas (merge (get-in state [:request-data :match :data :parameters])
(get-in state [:request-data :match :data method :parameters]))
cc (cond-> {}
Expand All @@ -180,5 +182,4 @@

(:form schemas)
(assoc :form (valid? (:form schemas) form-params)))]

(update-in state [:request :params] merge cc))))})
26 changes: 26 additions & 0 deletions src/xiana/interceptor/cors.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
(ns xiana.interceptor.cors)

(defn cors-headers [origin]
{"Access-Control-Allow-Origin" origin
"Access-Control-Allow-Credentials" "true"
"Access-Control-Allow-Methods" "GET,PUT,POST,DELETE"
"Access-Control-Allow-Headers" "Origin, X-Requested-With, Content-Type, Accept"})

(defn preflight?
"Returns true if the request is a preflight request"
[request]
(= (request :request-method) :options))

(def interceptor
{:name ::cross-origin-headers
:leave (fn [state]
(let [request (:request state)
headers (cors-headers (get-in state [:deps :cors-origin]))]
(if (preflight? request)
(update-in state [:response]
merge
{:status 200
:headers headers
:body "preflight complete"})
(update-in state [:response :headers]
merge headers))))})
2 changes: 1 addition & 1 deletion src/xiana/interceptor/error.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
(def response
"Handles the exception if there's `ex-info` exception with non-empty
`:xiana/response` key."
{:name ::response
{:name ::error-response
:error (fn [state]
(if-let [resp (-> state :error ex-data :xiana/response)]
(-> state
Expand Down
20 changes: 20 additions & 0 deletions src/xiana/interceptor/kebab_camel.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
(ns xiana.interceptor.kebab-camel
(:require
[camel-snake-kebab.core :as csk]
[camel-snake-kebab.extras :as cske]
[clojure.core.memoize :as mem]))

(def interceptor
"The purpose is to make Js request compatible with clojure, and response compatible with Javascript.
:request - {:params { "
{:name ::camel-to-kebab-case
:enter (fn [state]
(update-in state [:request :params]
(fn [resp]
(cske/transform-keys
(mem/fifo csk/->kebab-case {} :fifo/threshold 512) resp))))
:leave (fn [state]
(update-in state [:response :body]
(fn [resp]
(cske/transform-keys
(mem/fifo csk/->camelCase {} :fifo/threshold 512) resp))))})
33 changes: 21 additions & 12 deletions src/xiana/jwt/interceptors.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(ns xiana.jwt.interceptors
(:require
[clojure.string :as cstr]
[clojure.string :as str]
[xiana.jwt :as jwt]
[xiana.route.helpers :as helpers])
(:import
Expand All @@ -11,17 +11,26 @@
{:name ::jwt-authentication
:enter
(fn [{request :request :as state}]
(let [auth (-> request
(get-in [:headers :authorization])
(cstr/split #" ")
second)
cfg (get-in state [:deps :xiana/jwt :auth])]
(try
(->>
(jwt/verify-jwt :claims auth cfg)
(assoc-in state [:session-data :jwt-authentication]))
(catch ExceptionInfo e
(assoc state :error e)))))
(let [auth (get-in request [:headers :authorization])]
(cond
(= :options (:request-method request))
state

auth
(let [auth (-> request
(get-in [:headers :authorization])
(str/split #" ")
second)
cfg (get-in state [:deps :xiana/jwt :auth])]
(try
(->>
(jwt/verify-jwt :claims auth cfg)
(assoc-in state [:session-data :jwt-authentication]))
(catch ExceptionInfo e
(assoc state :error e))))

(nil? auth)
(assoc state :error (ex-info "Authorization header not provided" {:type :authorization})))))
:error
(fn [state]
(let [error (:error state)
Expand Down
5 changes: 5 additions & 0 deletions test/resources/multipart.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
first_name,last_name,age
John,Doe,25
Jane,Doe,24
Alice,Smith,22
Bob,Johnson,30
29 changes: 29 additions & 0 deletions test/xiana/interceptor/cors_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
(ns xiana.interceptor.cors-test
(:require
[clojure.test :refer [deftest is testing]]
[xiana.interceptor.cors :refer [cors-headers interceptor]]))

(deftest cors-interceptor-test
(testing "cross-origin-headers interceptor"
(let [leave (:leave interceptor)
origin "http://localhost:3001"]

(testing "when request is a preflight request"
(let [state {:request {:request-method :options}
:deps {:cors-origin origin}}
expected (update-in state [:response]
merge
{:status 200
:headers (cors-headers origin)
:body "preflight complete"})
result (leave state)]
(is (= expected result))))

(testing "when request is not a preflight request"
(let [state {:request {:request-method :get}
:deps {:cors-origin origin}
:response {:headers {}}}
expected (update-in state [:response :headers]
merge (cors-headers origin))
result (leave state)]
(is (= expected result)))))))
20 changes: 20 additions & 0 deletions test/xiana/interceptor/error_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
(ns xiana.interceptor.error-test
(:require
[clojure.test :refer [deftest is testing]]
[xiana.interceptor.error :refer [response]]))

(defn make-error-state [response]
{:error (ex-info "Test exception" {:xiana/response response})})

(deftest response-test
(testing "Error response interceptor"
(let [resp {:error "Test error"}
error-state (make-error-state resp)
f (:error response)
result (f error-state)]
(is (= {:response resp} result))

(testing "When :error is nil"
(let [error-state {:error nil}
result (f error-state)]
(is (= {:error nil} result)))))))
20 changes: 20 additions & 0 deletions test/xiana/interceptor/kebab_camel_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
(ns xiana.interceptor.kebab-camel-test
(:require
[clojure.test :refer [deftest is testing]]
[xiana.interceptor.kebab-camel :as kc]))

(deftest req->kebab-resp->camel-test
(testing "Transforms keys of request params to kebab case"
(let [state {:request {:params {:paramKey1 1 :paramKey2 2 :paramKey3 3}}}
expected {:request {:params {:param-key-1 1 :param-key-2 2 :param-key-3 3}}}
enter (:enter kc/interceptor)
result (enter state)]
(is (= expected result))))

(testing "Transform keys of response body to Camel case"
(let [state {:response {:body {:param-key-1 1 :param-key-2 2 :param-key-3 3}}}
expected {:response {:body {:paramKey1 1 :paramKey2 2 :paramKey3 3}}}
leave (:leave kc/interceptor)
result (leave state)]
(is (= expected result)))))

27 changes: 27 additions & 0 deletions test/xiana/interceptor/multipart_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
(ns xiana.interceptor.multipart-test
(:require
[clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[peridot.multipart :as p]
[ring.mock.request :as mock]
[xiana.interceptor :as interceptor])
(:import
(java.io
File)))

(defn state []
{:request
(merge (mock/request :post "/upload")
(p/build {:data (io/file "test/resources/multipart.csv")}))})

(deftest multipart-test
(testing "Multipart support in the Framework interceptor chain"
(let [f (:enter interceptor/params)
r (f (state))
data-params (get-in r [:request :params :data])]
(is (= "multipart.csv" (:filename data-params)))
(is (= "text/csv" (:content-type data-params)))
(is (instance? File (:tempfile data-params)))
(is (pos? (:size data-params)))
(is (= "first_name,last_name,age\nJohn,Doe,25\nJane,Doe,24\nAlice,Smith,22\nBob,Johnson,30\n"
(slurp (:tempfile data-params)))))))
6 changes: 3 additions & 3 deletions test/xiana/interceptor/wrap_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,17 @@
(wrap/middleware->enter middleware/wrap-format-request)
(wrap/middleware->leave middleware/wrap-format-response)))

(deftest contains-midleware-enter
(deftest contains-middleware-enter
(let [enter (:enter (wrap/middleware->enter middleware/wrap-format-request))
result (enter sample-state)]
;; verify middleware identity
(is (= result sample-state))))

(deftest contains-midleware-leave
(deftest contains-middleware-leave
(let [leave (:leave (wrap/middleware->leave middleware/wrap-format-request))]
(is (function? leave))))

(deftest contains-middleware-formated-response-body
(deftest contains-middleware-formatted-response-body
(is (= ByteArrayInputStream
(-> ((:leave middleware-interceptor) sample-state)
:response
Expand Down
3 changes: 2 additions & 1 deletion test/xiana/interceptor_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@
request (fetch-execute state interceptor/params :enter)
expected {:request {:form-params {},
:params {},
:query-params {}}}]
:query-params {}
:multipart-params {}}}]
;; expected request value?
(is (= request expected))))

Expand Down
58 changes: 58 additions & 0 deletions test/xiana/jwt/interceptors_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
(ns xiana.jwt.interceptors-test
(:require
[clojure.test :refer [deftest is testing]]
[xiana.jwt :as jwt]
[xiana.jwt.interceptors :refer [jwt-auth]]
[xiana.route.helpers :as helpers]))

(defn mock-jwt-verify [_ _ _] "mocky")

(defn mock-unauthorized [state _]
(assoc state :unauthorized true))

(deftest jwt-auth-test
(with-redefs [jwt/verify-jwt mock-jwt-verify
helpers/unauthorized mock-unauthorized]

(testing "jwt-authentication interceptor"
(let [enter-fn (:enter jwt-auth)
error-fn (:error jwt-auth)
auth-token "your-auth-token"
jwt-cfg {:auth "auth-config"}]

(testing "enter function - with :options request method"
(let [state {:request {:request-method :options}}
result (enter-fn state)]
(is (= state result))))

(testing "enter function - with authorization in headers"
(let [state {:request {:request-method :get
:headers {:authorization (str "Bearer " auth-token)}}}
expected (assoc-in state [:session-data :jwt-authentication] (jwt/verify-jwt :claims auth-token jwt-cfg))
result (enter-fn state)]
(is (= expected result))))

(testing "enter function - without authorization in headers"
(let [state {:request {:request-method :get
:headers {:authorization nil}}}
expected "Authorization header not provided"
result (enter-fn state)]
(is (= expected (.getMessage (:error result))))))

(testing "error function - with :exp cause"
(let [state {:error (ex-info "Error message" {:cause :exp})}
expected (helpers/unauthorized state "JWT Token expired.")
result (error-fn state)]
(is (= expected result))))

(testing "error function - with :validation type"
(let [state {:error (ex-info "Error message" {:type :validation})}
expected (helpers/unauthorized state "One or more Claims were invalid.")
result (error-fn state)]
(is (= expected result))))

(testing "error function - with other error type"
(let [state {:error (ex-info "Error message" {:type :other})}
expected (helpers/unauthorized state "Signature could not be verified.")
result (error-fn state)]
(is (= expected result))))))))
Loading
Loading