Enable closed schemas for RPC methods

This commit is contained in:
Andrey Antukh 2026-08-06 20:58:00 +02:00
parent b5bec4f983
commit 0eed852e2e
7 changed files with 1044 additions and 122 deletions

View File

@ -92,12 +92,12 @@
(handle-response-transformation request mdata) (handle-response-transformation request mdata)
(handle-before-comple-hook mdata)))) (handle-before-comple-hook mdata))))
(defn- make-rpc-handler (defn make-rpc-handler
"Ring handler that dispatches cmd requests and convert between "Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow." internal async flow into ring async flow."
[methods] [methods]
(let [methods (update-vals methods peek)] (let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}] (fn [{:keys [path-params method] :as request}]
(let [handler-name (:method-name path-params) (let [handler-name (:method-name path-params)
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
session-id (yreq/get-header request "x-session-id") session-id (yreq/get-header request "x-session-id")
@ -111,7 +111,7 @@
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
data (-> params data (-> {}
(assoc ::handler-name handler-name) (assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr) (assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now)) (assoc ::request-at (ct/now))
@ -218,17 +218,6 @@
f) f)
f)) f))
(defn- wrap-spec-conform
[_ f mdata]
;; NOTE: skip spec conform operation on rpc methods that already
;; uses malli validation mechanism.
(if (contains? mdata ::sm/params)
f
(if-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
(fn [cfg params]
(f cfg (us/conform spec params)))
f)))
(defn- wrap-params-validation (defn- wrap-params-validation
[_ f mdata] [_ f mdata]
(if-let [schema (::sm/params mdata)] (if-let [schema (::sm/params mdata)]
@ -237,16 +226,15 @@
decode (sm/decoder schema sm/json-transformer) decode (sm/decoder schema sm/json-transformer)
encode (sm/encoder schema sm/json-transformer)] encode (sm/encoder schema sm/json-transformer)]
(fn [cfg params] (fn [cfg params]
(let [params (decode params)] (let [request-params (-> params meta ::http/request :params decode)]
(if (validate params) (if (validate request-params)
(let [result (f cfg params)] (let [result (f cfg (merge params request-params))]
(if (instance? clojure.lang.IObj result) (if (instance? clojure.lang.IObj result)
(vary-meta result assoc :encode/json encode) (vary-meta result assoc :encode/json encode)
result)) result))
(let [params (d/without-qualified params)] (ex/raise :type :validation
(ex/raise :type :validation :code :params-validation
:code :params-validation ::sm/explain (explain request-params))))))
::sm/explain (explain params)))))))
f)) f))
@ -334,10 +322,9 @@
(wrap-metrics cfg $ mdata) (wrap-metrics cfg $ mdata)
(rlimit/wrap cfg $ mdata) (rlimit/wrap cfg $ mdata)
(wrap-audit cfg $ mdata) (wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata) (wrap-nitrate-sso cfg $ mdata)
(wrap-params-validation cfg $ mdata) (wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata) (wrap-authentication cfg $ mdata)))
(wrap-nitrate-sso cfg $ mdata)))
(defn- wrap-management (defn- wrap-management
[cfg f mdata] [cfg f mdata]
@ -347,12 +334,9 @@
(climit/wrap cfg $ mdata) (climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata) (wrap-metrics cfg $ mdata)
(wrap-audit cfg $ mdata) (wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata) (wrap-nitrate-sso cfg $ mdata)
(wrap-params-validation cfg $ mdata) (wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata) (wrap-authentication cfg $ mdata)))
(wrap-nitrate-sso cfg $ mdata)))
(defn- process-method (defn- process-method
[cfg wrap-fn [f mdata]] [cfg wrap-fn [f mdata]]

View File

@ -118,7 +118,7 @@
(def ^:private schema:import-binfile (def ^:private schema:import-binfile
[:and [:and
[:map {:title "import-binfile"} [:map {:title "import-binfile" :closed true}
[:name [:or [:string {:max 250}] [:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]] [:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid] [:project-id ::sm/uuid]

View File

@ -49,7 +49,9 @@
[promesa.core :as p] [promesa.core :as p]
[promesa.exec :as px] [promesa.exec :as px]
[ring.core.protocols :as rcp] [ring.core.protocols :as rcp]
[yetti.request :as yreq]
[yetti.request :as yrq] [yetti.request :as yrq]
[yetti.response :as yres]
[yetti.response :as yres]) [yetti.response :as yres])
(:import (:import
java.io.PipedInputStream java.io.PipedInputStream
@ -379,6 +381,82 @@
{:error (handle-error e#) {:error (handle-error e#)
:result nil}))) :result nil})))
(defrecord DummyRequest [headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name))
(method [_] method)
(body [_] body-stream)
(path [_] path)
(query [_] query)
(server-port [_] server-port)
(server-name [_] server-name)
(remote-addr [_] remote-addr)
(ssl-client-cert [_] ssl-client-cert)
(scheme [_] scheme)
(protocol [_] protocol))
(defn make-dummy-request
"Constructs a DummyRequest from an options map. Every key is
optional; missing values fall back to sensible defaults. New
fields added to DummyRequest won't break existing call sites
as long as this constructor keeps its `:or` defaults in sync.
Recognized keys:
:headers map of header name value
:cookies map of cookie name value
:method HTTP method keyword (default :get)
:body-stream InputStream for the body (used directly)
:body-bytes bytes or string for the body; wrapped in a
ByteArrayInputStream if :body-stream is not
given
:remote-addr string (default \"127.0.0.1\")
:server-name string (default \"test\")
:server-port long (default 0)
:scheme keyword (default :http)
:protocol string (default \"HTTP/1.1\")
:path string (default \"/test\")
:query string or nil (default nil)
:ssl-client-cert X509Certificate or nil (default nil)"
[& {:keys [headers cookies method body-stream body-bytes
remote-addr server-name server-port scheme protocol
path query ssl-client-cert]
:or {headers {} cookies {} method :get
body-stream nil
remote-addr "127.0.0.1" server-name "test" server-port 0
scheme :http protocol "HTTP/1.1" path "/test" query nil
ssl-client-cert nil}}]
(let [body-stream (or body-stream
(when body-bytes
(java.io.ByteArrayInputStream.
(if (string? body-bytes)
(.getBytes ^String body-bytes "UTF-8")
body-bytes))))]
(->DummyRequest headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert)))
(defn- prepare-rpc-params
[data]
(let [params (reduce-kv (fn [params k v]
(if (qualified-keyword? k)
(assoc params k v)
params))
{}
(dissoc data ::type))
request (-> (make-dummy-request)
(assoc :params (d/without-qualified data)))]
(-> params
(assoc :app.rpc/request-at (ct/now))
(with-meta {:app.http/request request}))))
(defn command! (defn command!
[{:keys [::type] :as data}] [{:keys [::type] :as data}]
(let [[mdata method-fn] (get-in *system* [:app.rpc/methods type])] (let [[mdata method-fn] (get-in *system* [:app.rpc/methods type])]
@ -387,10 +465,8 @@
:code :rpc-method-not-found :code :rpc-method-not-found
:hint (str/ffmt "rpc method '%' not found" (name type)))) :hint (str/ffmt "rpc method '%' not found" (name type))))
;; (app.common.pprint/pprint (:app.rpc/methods *system*)) (let [params (prepare-rpc-params data)]
(try-on! (method-fn (-> data (try-on! (method-fn params)))))
(dissoc ::type)
(assoc :app.rpc/request-at (ct/now)))))))
(defn management-command! (defn management-command!
[{:keys [::type] :as data}] [{:keys [::type] :as data}]
@ -399,9 +475,8 @@
(ex/raise :type :assertion (ex/raise :type :assertion
:code :rpc-method-not-found :code :rpc-method-not-found
:hint (str/ffmt "management rpc method '%' not found" (name type)))) :hint (str/ffmt "management rpc method '%' not found" (name type))))
(try-on! (method-fn (-> data (let [params (prepare-rpc-params data)]
(dissoc ::type) (try-on! (method-fn params)))))
(assoc :app.rpc/request-at (ct/now)))))))
(defn run-task! (defn run-task!
([name] ([name]

View File

@ -28,78 +28,17 @@
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name))
(method [_] method)
(body [_] body-stream)
(path [_] path)
(query [_] query)
(server-port [_] server-port)
(server-name [_] server-name)
(remote-addr [_] remote-addr)
(ssl-client-cert [_] ssl-client-cert)
(scheme [_] scheme)
(protocol [_] protocol))
(defn- make-dummy-request
"Constructs a DummyRequest from an options map. Every key is
optional; missing values fall back to sensible defaults. New
fields added to DummyRequest won't break existing call sites
as long as this constructor keeps its `:or` defaults in sync.
Recognized keys:
:headers map of header name value
:cookies map of cookie name value
:method HTTP method keyword (default :get)
:body-stream InputStream for the body (used directly)
:body-bytes bytes or string for the body; wrapped in a
ByteArrayInputStream if :body-stream is not
given
:remote-addr string (default \"127.0.0.1\")
:server-name string (default \"test\")
:server-port long (default 0)
:scheme keyword (default :http)
:protocol string (default \"HTTP/1.1\")
:path string (default \"/test\")
:query string or nil (default nil)
:ssl-client-cert X509Certificate or nil (default nil)"
[{:keys [headers cookies method body-stream body-bytes
remote-addr server-name server-port scheme protocol
path query ssl-client-cert]
:or {headers {} cookies {} method :get
body-stream nil
remote-addr "127.0.0.1" server-name "test" server-port 0
scheme :http protocol "HTTP/1.1" path "/test" query nil
ssl-client-cert nil}}]
(let [body-stream (or body-stream
(when body-bytes
(java.io.ByteArrayInputStream.
(if (string? body-bytes)
(.getBytes ^String body-bytes "UTF-8")
body-bytes))))]
(->DummyRequest headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert)))
(t/deftest auth-middleware-1 (t/deftest auth-middleware-1
(let [request (volatile! nil) (let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (make-dummy-request {})) (handler (th/make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (make-dummy-request {:headers {"authorization" "Token aaaa"}})) (handler (th/make-dummy-request {:headers {"authorization" "Token aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :token token-type)) (t/is (= :token token-type))
@ -112,10 +51,10 @@
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (make-dummy-request {})) (handler (th/make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}})) (handler (th/make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :bearer token-type)) (t/is (= :bearer token-type))
@ -128,10 +67,10 @@
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (make-dummy-request {})) (handler (th/make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (make-dummy-request {:cookies {"auth-token" "foobar"}})) (handler (th/make-dummy-request {:cookies {"auth-token" "foobar"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :cookie token-type)) (t/is (= :cookie token-type))
@ -143,16 +82,16 @@
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
{:test1 "secret-key"})] {:test1 "secret-key"})]
(let [response (handler (make-dummy-request {}))] (let [response (handler (th/make-dummy-request {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))] (let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))] (let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))] (let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz
@ -263,7 +202,7 @@
:user-agent "user agent"}) :user-agent "user agent"})
(#'session/assign-token cfg)) (#'session/assign-token cfg))
response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}})) response (handler (th/make-dummy-request {:cookies {"auth-token" (:token session)}}))
{:keys [token claims] token-type :type} {:keys [token claims] token-type :type}
(get response ::http/auth-data)] (get response ::http/auth-data)]
@ -290,7 +229,7 @@
;; value with a backslash followed by '}', which ;; value with a backslash followed by '}', which
;; clojure.data.json v0.5.x cannot handle. ;; clojure.data.json v0.5.x cannot handle.
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8") body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes body}) :body-bytes body})
@ -309,7 +248,7 @@
;; error. ;; error.
(let [handler (#'app.http.middleware/wrap-parse-request (let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RequestTooBigException. "too large")))) (fn [_] (throw (RequestTooBigException. "too large"))))
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")}) :body-bytes (.getBytes "{}" "UTF-8")})
@ -327,7 +266,7 @@
;; should convert it to a 400 :malformed-json validation error. ;; should convert it to a 400 :malformed-json validation error.
(let [handler (#'app.http.middleware/wrap-parse-request (let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.EOFException. "stream closed")))) (fn [_] (throw (java.io.EOFException. "stream closed"))))
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")}) :body-bytes (.getBytes "{}" "UTF-8")})
@ -350,7 +289,7 @@
(.initCause iae)) (.initCause iae))
handler (#'app.http.middleware/wrap-parse-request handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw wrapped))) (fn [_] (throw wrapped)))
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")}) :body-bytes (.getBytes "{}" "UTF-8")})
@ -368,7 +307,7 @@
;; :unexpected. This is the "true internal error" path. ;; :unexpected. This is the "true internal error" path.
(let [handler (#'app.http.middleware/wrap-parse-request (let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RuntimeException. "boom")))) (fn [_] (throw (RuntimeException. "boom"))))
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")}) :body-bytes (.getBytes "{}" "UTF-8")})
@ -388,7 +327,7 @@
;; with :code :io-exception. ;; with :code :io-exception.
(let [handler (#'app.http.middleware/wrap-parse-request (let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.IOException. "network gone")))) (fn [_] (throw (java.io.IOException. "network gone"))))
request (make-dummy-request request (th/make-dummy-request
{:method :post {:method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")}) :body-bytes (.getBytes "{}" "UTF-8")})

View File

@ -22,13 +22,21 @@
(let [schema @#'binfile/schema:import-binfile (let [schema @#'binfile/schema:import-binfile
validator (sm/lazy-validator schema) validator (sm/lazy-validator schema)
valid-params {:name "test" valid-params
:project-id (uuid/random) {:name "test"
:version 3 :project-id (uuid/random)
:upload-id (uuid/random)}] :version 3
:upload-id (uuid/random)}
params-with-file-id
(assoc valid-params :file-id (uuid/random))]
(t/is (true? (validator valid-params)) (t/is (true? (validator valid-params))
"params without file-id should be valid") "params without file-id should be valid")
(t/is (not (contains? (sm/keys (second schema)) :file-id)) (t/is (not (contains? (sm/keys (second schema)) :file-id))
"file-id should not be a declared parameter"))) "file-id should not be a declared parameter")
;; Params with file-id should fail (schema closed)
(t/is (false? (validator params-with-file-id))
"params with file-id should be rejected")))

View File

@ -1107,8 +1107,11 @@
body)) body))
(let [[_ method-fn] (get-in th/*system* [:app.rpc/methods :get-nitrate-activation-code-request]) (let [[_ method-fn] (get-in th/*system* [:app.rpc/methods :get-nitrate-activation-code-request])
result (method-fn {::rpc/profile-id (:id profile) request (assoc (th/make-dummy-request) :params {})
::rpc/request-at now}) params (-> {::rpc/profile-id (:id profile)
::rpc/request-at now}
(with-meta {:app.http/request request}))
result (method-fn params)
headers (::http/headers (meta result))] headers (::http/headers (meta result))]
(t/is (rph/wrapped? result)) (t/is (rph/wrapped? result))
(t/is (= "text/plain" (get headers "content-type"))) (t/is (= "text/plain" (get headers "content-type")))

View File

@ -0,0 +1,913 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-test
(:require
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.http :as http]
[app.http.access-token :as actoken]
[app.http.session :as session]
[app.rpc :as rpc]
[app.rpc.cond :as cond]
[app.rpc.helpers :as rph]
[app.util.inet :as inet]
[backend-tests.helpers :as th]
[clojure.test :as t]
[yetti.response :as yres]))
(defn- echo-handler
"Handler that returns the data map it receives, for inspection."
[data]
data)
(defn- make-handler
"Creates an RPC handler with a single :test-method backed by echo-handler.
Extra methods are merged in; each value is [metadata handler-fn]."
([] (make-handler {}))
([extra-methods]
(let [methods (merge {:test-method [{} echo-handler]}
extra-methods)]
(rpc/make-rpc-handler methods))))
(defn- make-request
"Builds a dummy request. Extra qualified-keyword keys are merged onto it."
[& {:keys [method path-params headers extras]
:or {method :post
path-params {:method-name "test-method"}
headers {}
extras {}}}]
(-> (th/make-dummy-request {:method method
:headers headers})
(assoc :path-params path-params
:params {})
(merge extras)))
(defn- call
"Invokes handler with a built request, returns the full response map."
([handler opts]
(handler (make-request opts)))
([handler]
(call handler {})))
(defn- call-body
"Invokes handler with a built request, returns ::yres/body from response."
([handler opts]
(::yres/body (call handler opts)))
([handler]
(::yres/body (call handler))))
(defn- call-ex
"Invokes handler, catches and returns the exception."
([handler opts]
(try (call handler opts) (catch Exception e e)))
([handler]
(call-ex handler {})))
(def fixed-time (ct/now))
(def fixed-uuid (uuid/custom 11111111 22222222))
;; --- BASIC HANDLER DISPATCH
(t/deftest handler-receives-handler-name
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (= "test-method" (::rpc/handler-name data))))))
(t/deftest handler-receives-ip-addr
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "10.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (= "10.0.0.1" (::rpc/ip-addr data))))))
(t/deftest handler-receives-request-at
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (= fixed-time (::rpc/request-at data))))))
(t/deftest handler-receives-request-id
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (= fixed-uuid (::rpc/request-id data))))))
(t/deftest unknown-handler-name-raises-not-found
(let [handler (make-handler)
ex (call-ex handler {:path-params {:method-name "unknown"}})]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :not-found))))
;; --- HANDLER-NAME EDGE CASES
(t/deftest nil-path-params-falls-to-default-handler
(let [handler (make-handler)
request (make-request {:path-params nil})
ex (try (handler request) (catch Exception e e))]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :not-found))))
(t/deftest missing-method-name-falls-to-default-handler
(let [handler (make-handler)
request (make-request {:path-params {}})
ex (try (handler request) (catch Exception e e))]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :not-found))))
(t/deftest empty-method-name-falls-to-default-handler
(let [handler (make-handler)
request (make-request {:path-params {:method-name ""}})
ex (try (handler request) (catch Exception e e))]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :not-found))))
(t/deftest get-with-exact-get-prefix-no-suffix-allowed
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:get- [{} echo-handler]})
data (call-body handler
{:method :get
:path-params {:method-name "get-"}})]
(t/is (= "get-" (::rpc/handler-name data))))))
;; --- AUTH: SESSION
(t/deftest session-auth-profile-id
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id pid}})]
(t/is (= pid (::rpc/profile-id data))))))
(t/deftest session-auth-type
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id pid}})]
(t/is (= :session (::rpc/auth-type data))))))
(t/deftest session-auth-no-token-perms
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id pid}})]
(t/is (not (contains? data ::rpc/token-perms))))))
(t/deftest nil-session-profile-id-not-used
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:extras {::session/profile-id nil}})]
(t/is (not (contains? data ::rpc/profile-id)))
(t/is (not (contains? data ::rpc/auth-type))))))
;; --- AUTH: TOKEN
(t/deftest token-auth-profile-id
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid}})]
(t/is (= pid (::rpc/profile-id data))))))
(t/deftest token-auth-type
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid}})]
(t/is (= :token (::rpc/auth-type data))))))
(t/deftest token-auth-with-perms
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid
::actoken/perms #{"view" "edit"}}})]
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
(t/deftest token-auth-perms-default-to-empty-set
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid}})]
(t/is (= #{} (::rpc/token-perms data))))))
(t/deftest token-auth-perms-coerced-from-vector
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid
::actoken/perms ["view" "edit"]}})]
(t/is (set? (::rpc/token-perms data)))
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
(t/deftest token-auth-perms-coerced-from-list
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid
::actoken/perms (list "view" "edit")}})]
(t/is (set? (::rpc/token-perms data)))
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
(t/deftest token-auth-with-nil-perms-defaults-to-empty-set
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid
::actoken/perms nil}})]
(t/is (= #{} (::rpc/token-perms data))))))
(t/deftest token-auth-with-empty-perms
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id pid
::actoken/perms #{}}})]
(t/is (= #{} (::rpc/token-perms data))))))
(t/deftest nil-actoken-profile-id-not-used
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id nil}})]
(t/is (not (contains? data ::rpc/profile-id)))
(t/is (not (contains? data ::rpc/auth-type))))))
;; --- AUTH: SHARED KEY
(t/deftest key-auth-profile-id-is-zero
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:extras {::http/auth-key-id "some-key"}})]
(t/is (= uuid/zero (::rpc/profile-id data))))))
(t/deftest key-auth-key-id-in-data
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:extras {::http/auth-key-id "some-key"}})]
(t/is (= "some-key" (::rpc/auth-key-id data))))))
(t/deftest nil-auth-key-id-not-used
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:extras {::http/auth-key-id nil}})]
(t/is (not (contains? data ::rpc/profile-id)))
(t/is (not (contains? data ::rpc/auth-key-id))))))
;; --- AUTH: UNAUTHENTICATED
(t/deftest unauthenticated-no-profile-id
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (not (contains? data ::rpc/profile-id))))))
(t/deftest unauthenticated-no-auth-type
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (not (contains? data ::rpc/auth-type))))))
;; --- AUTH: PRIORITY
(t/deftest session-overrides-token
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [session-pid (uuid/custom 1 1)
token-pid (uuid/custom 2 2)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id session-pid
::actoken/profile-id token-pid}})]
(t/is (= session-pid (::rpc/profile-id data)))
(t/is (= :session (::rpc/auth-type data))))))
(t/deftest session-overrides-key
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [session-pid (uuid/custom 1 1)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id session-pid
::http/auth-key-id "key-id"}})]
(t/is (= session-pid (::rpc/profile-id data)))
(t/is (= :session (::rpc/auth-type data)))
(t/is (= "key-id" (::rpc/auth-key-id data))))))
(t/deftest token-overrides-key
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [token-pid (uuid/custom 2 2)
handler (make-handler)
data (call-body handler
{:extras {::actoken/profile-id token-pid
::http/auth-key-id "key-id"}})]
(t/is (= token-pid (::rpc/profile-id data)))
(t/is (= :token (::rpc/auth-type data)))
(t/is (= "key-id" (::rpc/auth-key-id data))))))
(t/deftest session-overrides-token-and-key
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [session-pid (uuid/custom 1 1)
token-pid (uuid/custom 2 2)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id session-pid
::actoken/profile-id token-pid
::http/auth-key-id "key-id"}})]
(t/is (= session-pid (::rpc/profile-id data)))
(t/is (= :session (::rpc/auth-type data))))))
;; --- REQUEST METADATA: SESSION-ID
(t/deftest session-id-parsed-from-header
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [sid (uuid/next)
handler (make-handler)
data (call-body handler
{:headers {"x-session-id" (str sid)}})]
(t/is (= sid (::rpc/session-id data))))))
(t/deftest session-id-nil-when-no-header
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (nil? (::rpc/session-id data))))))
(t/deftest session-id-nil-when-invalid-uuid
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:headers {"x-session-id" "not-a-uuid"}})]
(t/is (nil? (::rpc/session-id data))))))
(t/deftest session-id-nil-for-empty-string
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:headers {"x-session-id" ""}})]
(t/is (nil? (::rpc/session-id data))))))
(t/deftest session-id-parses-uppercase-uuid
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [sid (uuid/next)
handler (make-handler)
upper (clojure.string/upper-case (str sid))
data (call-body handler
{:headers {"x-session-id" upper}})]
(t/is (= sid (::rpc/session-id data))))))
;; --- REQUEST METADATA: ETAG / COND KEY
(t/deftest cond-key-from-if-none-match
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler
{:headers {"if-none-match" "W/\"abc123\""}})]
(t/is (= "W/\"abc123\"" (::cond/key data))))))
(t/deftest cond-key-nil-when-no-header
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler)
data (call-body handler)]
(t/is (nil? (::cond/key data))))))
;; --- REQUEST METADATA: META
(t/deftest data-meta-contains-request
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [request (make-request {})
handler (make-handler)
data (call-body handler {})]
(t/is (= request (::http/request (meta data)))))))
;; --- DATA COMPLETENESS
(t/deftest data-contains-all-expected-keys
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [pid (uuid/next)
handler (make-handler)
data (call-body handler
{:extras {::session/profile-id pid
::http/auth-key-id "key"}})]
(t/is (contains? data ::rpc/handler-name))
(t/is (contains? data ::rpc/ip-addr))
(t/is (contains? data ::rpc/request-at))
(t/is (contains? data ::rpc/request-id))
(t/is (contains? data ::rpc/session-id))
(t/is (contains? data ::cond/key))
(t/is (contains? data ::rpc/profile-id))
(t/is (contains? data ::rpc/auth-type))
(t/is (contains? data ::rpc/auth-key-id)))))
;; --- GET/HEAD RESTRICTION
(t/deftest get-restriction-non-get-handler
(let [handler (make-handler)
ex (call-ex handler
{:method :get
:path-params {:method-name "foo"}})]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :restriction))
(t/is (th/ex-of-code? ex :method-not-allowed))))
(t/deftest head-restriction-non-get-handler
(let [handler (make-handler)
ex (call-ex handler
{:method :head
:path-params {:method-name "foo"}})]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :restriction))
(t/is (th/ex-of-code? ex :method-not-allowed))))
(t/deftest get-allowed-for-get-prefixed-handler
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:get-profile [{} echo-handler]})
data (call-body handler
{:method :get
:path-params {:method-name "get-profile"}})]
(t/is (= "get-profile" (::rpc/handler-name data))))))
(t/deftest head-allowed-for-get-prefixed-handler
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:get-files [{} echo-handler]})
data (call-body handler
{:method :head
:path-params {:method-name "get-files"}})]
(t/is (= "get-files" (::rpc/handler-name data))))))
(t/deftest post-always-allowed
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:any-name [{} echo-handler]})
data (call-body handler
{:method :post
:path-params {:method-name "any-name"}})]
(t/is (= "any-name" (::rpc/handler-name data))))))
(t/deftest put-always-allowed
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:any-name [{} echo-handler]})
data (call-body handler
{:method :put
:path-params {:method-name "any-name"}})]
(t/is (= "any-name" (::rpc/handler-name data))))))
(t/deftest delete-always-allowed
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:any-name [{} echo-handler]})
data (call-body handler
{:method :delete
:path-params {:method-name "any-name"}})]
(t/is (= "any-name" (::rpc/handler-name data))))))
(t/deftest patch-always-allowed
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [handler (make-handler {:any-name [{} echo-handler]})
data (call-body handler
{:method :patch
:path-params {:method-name "any-name"}})]
(t/is (= "any-name" (::rpc/handler-name data))))))
(t/deftest get-to-non-get-handler-restriction-checks-before-handler-execution
(let [called? (atom false)
handler (make-handler {:foo [{} (fn [data]
(reset! called? true)
data)]})
ex (call-ex handler
{:method :get
:path-params {:method-name "foo"}})]
(t/is (some? ex))
(t/is (false? @called?))))
;; --- RESPONSE: NIL -> 204
(t/deftest nil-response-returns-204
(let [handler (make-handler {:nil-method [{} (constantly nil)]})
response (call handler
{:path-params {:method-name "nil-method"}})]
(t/is (= 204 (::yres/status response)))))
(t/deftest nil-response-gets-stream-content-type
(let [handler (make-handler {:nil-method [{} (constantly nil)]})
response (call handler
{:path-params {:method-name "nil-method"}})]
(t/is (= "application/octet-stream"
(get (::yres/headers response) "content-type")))))
;; --- RESPONSE: MAP -> 200
(t/deftest map-response-returns-200
(let [handler (make-handler {:map-method [{} (constantly {:foo "bar"})]})
response (call handler
{:path-params {:method-name "map-method"}})]
(t/is (= 200 (::yres/status response)))
(t/is (= {:foo "bar"} (::yres/body response)))))
;; --- RESPONSE: FALSY NON-NIL VALUES
(t/deftest false-response-returns-200
(let [handler (make-handler {:false-method [{} (constantly false)]})
response (call handler
{:path-params {:method-name "false-method"}})]
(t/is (= 200 (::yres/status response)))
(t/is (false? (::yres/body response)))))
(t/deftest zero-response-returns-200
(let [handler (make-handler {:zero-method [{} (constantly 0)]})
response (call handler
{:path-params {:method-name "zero-method"}})]
(t/is (= 200 (::yres/status response)))
(t/is (= 0 (::yres/body response)))))
(t/deftest empty-map-response-returns-200
(let [handler (make-handler {:empty-method [{} (constantly {})]})
response (call handler
{:path-params {:method-name "empty-method"}})]
(t/is (= 200 (::yres/status response)))
(t/is (= {} (::yres/body response)))))
;; --- RESPONSE: CUSTOM STATUS
(t/deftest custom-status-from-metadata
(let [result (with-meta {:ok true} {::http/status 201})
handler (make-handler {:created [{} (constantly result)]})
response (call handler
{:path-params {:method-name "created"}})]
(t/is (= 201 (::yres/status response)))))
;; --- RESPONSE: CUSTOM HEADERS
(t/deftest custom-headers-from-metadata
(let [result (with-meta {:ok true} {::http/headers {"x-custom" "val"}})
handler (make-handler {:hdr-method [{} (constantly result)]})
response (call handler
{:path-params {:method-name "hdr-method"}})]
(t/is (= "val" (get (::yres/headers response) "x-custom")))))
;; --- RESPONSE: CUSTOM STATUS AND HEADERS TOGETHER
(t/deftest custom-status-and-headers-from-metadata
(let [result (with-meta {:ok true}
{::http/status 202
::http/headers {"x-request-id" "req-123"}})
handler (make-handler {:both [{} (constantly result)]})
response (call handler
{:path-params {:method-name "both"}})]
(t/is (= 202 (::yres/status response)))
(t/is (= "req-123" (get (::yres/headers response) "x-request-id")))))
;; --- RESPONSE: FUNCTION
(t/deftest function-response-called-with-request
(let [captured (atom nil)
resp-fn (fn [req]
(reset! captured req)
{::yres/status 200
::yres/body "from-fn"})
handler (make-handler {:fn-method [{} (constantly resp-fn)]})
request (make-request {:path-params {:method-name "fn-method"}})
response (handler request)]
(t/is (= request @captured))
(t/is (= 200 (::yres/status response)))
(t/is (= "from-fn" (::yres/body response)))))
(t/deftest function-response-returning-nil
(let [handler (make-handler {:fn-nil [{} (constantly (fn [_] nil))]})
response (call handler
{:path-params {:method-name "fn-nil"}})]
(t/is (nil? (::yres/body response)))))
(t/deftest function-response-uses-return-value-as-is
(let [resp-fn (fn [_req]
{:result "ok"})
handler (make-handler {:fn-meta [{} (constantly resp-fn)]})
response (call handler
{:path-params {:method-name "fn-meta"}})]
;; When handler returns a fn, handle-response calls (fn request)
;; and uses the return value directly as the response.
(t/is (= {:result "ok"} response))))
;; --- RESPONSE: METADATA WRAPPER
(t/deftest metadata-wrapper-unwrapped-in-response
(let [wrapped (rph/wrap {:data "value"})
handler (make-handler {:wrap-method [{} (constantly wrapped)]})
response (call handler
{:path-params {:method-name "wrap-method"}})]
(t/is (= {:data "value"} (::yres/body response)))))
(t/deftest metadata-wrapper-preserves-metadata
(let [wrapped (-> (rph/wrap {:data "value"})
(vary-meta assoc ::http/status 201))
handler (make-handler {:wrap-meta [{} (constantly wrapped)]})
response (call handler
{:path-params {:method-name "wrap-meta"}})]
(t/is (= 201 (::yres/status response)))))
(t/deftest metadata-wrapper-with-custom-headers
(let [wrapped (-> (rph/wrap {:data "value"})
(vary-meta assoc ::http/headers {"x-wrap" "yes"}))
handler (make-handler {:wrap-hdr [{} (constantly wrapped)]})
response (call handler
{:path-params {:method-name "wrap-hdr"}})]
(t/is (= "yes" (get (::yres/headers response) "x-wrap")))))
;; --- RESPONSE: STREAM BODY
(t/deftest stream-response-default-content-type
(let [stream (yres/stream-body (fn [_response _output] nil))
handler (make-handler {:stream-method [{} (constantly stream)]})
response (call handler
{:path-params {:method-name "stream-method"}})]
(t/is (= "application/octet-stream"
(get (::yres/headers response) "content-type")))))
(t/deftest stream-response-preserves-existing-content-type
(let [stream (with-meta
(yres/stream-body (fn [_response _output] nil))
{::http/headers {"content-type" "text/plain"}})
handler (make-handler {:stream-ct [{} (constantly stream)]})
response (call handler
{:path-params {:method-name "stream-ct"}})]
(t/is (= "text/plain"
(get (::yres/headers response) "content-type")))))
;; --- RESPONSE TRANSFORMATION
(t/deftest response-transform-fns-applied
(let [transform (fn [_req resp]
(assoc-in resp [::yres/headers "x-transformed"] "yes"))
result (with-meta {:ok true}
{::rpc/response-transform-fns [transform]})
handler (make-handler {:transform [{} (constantly result)]})
response (call handler
{:path-params {:method-name "transform"}})]
(t/is (= "yes" (get (::yres/headers response) "x-transformed")))))
(t/deftest response-transform-fns-applied-in-order
(let [t1 (fn [_req resp]
(assoc-in resp [::yres/headers "x-step"] "1"))
t2 (fn [_req resp]
(update-in resp [::yres/headers "x-step"] str "-2"))
result (with-meta {:ok true}
{::rpc/response-transform-fns [t1 t2]})
handler (make-handler {:multi-transform [{} (constantly result)]})
response (call handler
{:path-params {:method-name "multi-transform"}})]
(t/is (= "1-2" (get (::yres/headers response) "x-step")))))
(t/deftest transform-fn-can-modify-status
(let [transform (fn [_req _resp]
{::yres/status 418
::yres/body "teapot"
::yres/headers {}})
result (with-meta {:ok true}
{::rpc/response-transform-fns [transform]})
handler (make-handler {:teapot [{} (constantly result)]})
response (call handler
{:path-params {:method-name "teapot"}})]
(t/is (= 418 (::yres/status response)))))
(t/deftest empty-transform-fns-no-op
(let [result (with-meta {:ok true}
{::rpc/response-transform-fns []})
handler (make-handler {:empty-t [{} (constantly result)]})
response (call handler
{:path-params {:method-name "empty-t"}})]
(t/is (= 200 (::yres/status response)))))
(t/deftest transform-receives-request-and-response
(let [captured-req (atom nil)
captured-resp (atom nil)
transform (fn [req resp]
(reset! captured-req req)
(reset! captured-resp resp)
resp)
result (with-meta {:ok true}
{::rpc/response-transform-fns [transform]})
handler (make-handler {:cap-t [{} (constantly result)]})
request (make-request {:path-params {:method-name "cap-t"}})
response (handler request)]
(t/is (= request @captured-req))
(t/is (some? @captured-resp))
(t/is (= 200 (::yres/status @captured-resp)))))
;; --- BEFORE-COMPLETE HOOKS
(t/deftest before-complete-hooks-called
(let [called? (atom false)
hook (fn [] (reset! called? true))
result (with-meta {:ok true}
{::rpc/before-complete-fns [hook]})
handler (make-handler {:hook-method [{} (constantly result)]})]
(call handler {:path-params {:method-name "hook-method"}})
(t/is (true? @called?))))
(t/deftest multiple-hooks-all-called
(let [called-a (atom false)
called-b (atom false)
hook-a (fn [] (reset! called-a true))
hook-b (fn [] (reset! called-b true))
result (with-meta {:ok true}
{::rpc/before-complete-fns [hook-a hook-b]})
handler (make-handler {:multi-hook [{} (constantly result)]})]
(call handler {:path-params {:method-name "multi-hook"}})
(t/is (true? @called-a))
(t/is (true? @called-b))))
(t/deftest before-complete-hook-errors-ignored
(let [hook (fn [] (throw (Exception. "hook error")))
result (with-meta {:ok true}
{::rpc/before-complete-fns [hook]})
handler (make-handler {:hook-err [{} (constantly result)]})]
(let [response (call handler
{:path-params {:method-name "hook-err"}})]
(t/is (= 200 (::yres/status response))))))
(t/deftest hook-error-does-not-prevent-other-hooks
(let [called? (atom false)
hook-a (fn [] (throw (Exception. "boom")))
hook-b (fn [] (reset! called? true))
result (with-meta {:ok true}
{::rpc/before-complete-fns [hook-a hook-b]})
handler (make-handler {:hook-chain [{} (constantly result)]})]
(call handler {:path-params {:method-name "hook-chain"}})
(t/is (true? @called?))))
(t/deftest empty-hooks-no-op
(let [result (with-meta {:ok true}
{::rpc/before-complete-fns []})
handler (make-handler {:empty-hooks [{} (constantly result)]})
response (call handler
{:path-params {:method-name "empty-hooks"}})]
(t/is (= 200 (::yres/status response)))))
;; --- METHODS MAP FORMAT
(t/deftest methods-map-uses-peek-to-extract-handler
(with-redefs [ct/now (constantly fixed-time)
uuid/next (constantly fixed-uuid)
inet/parse-request (constantly "127.0.0.1")]
(let [metadata {:some-meta "value"}
methods {:peek-test [metadata echo-handler]}
handler (rpc/make-rpc-handler methods)
data (call-body handler
{:path-params {:method-name "peek-test"}})]
(t/is (= "peek-test" (::rpc/handler-name data))))))
;; --- COND/*ENABLED* BINDING
(t/deftest cond-enabled-bound-to-true-inside-handler
(let [captured (atom nil)
handler (make-handler
{:cond-test [{} (fn [data]
(reset! captured cond/*enabled*)
data)]})]
(call handler {:path-params {:method-name "cond-test"}})
(t/is (true? @captured))))
(t/deftest cond-enabled-false-outside-handler
(let [handler (make-handler)]
(call handler)
(t/is (false? cond/*enabled*))))
(t/deftest cond-enabled-restored-after-handler-throws
(let [handler (make-handler
{:throwing [{} (fn [_]
(ex/raise :type :test :code :x))]})]
(try (call handler {:path-params {:method-name "throwing"}})
(catch Exception _))
(t/is (false? cond/*enabled*))))
;; --- HANDLER EXCEPTION PROPAGATION
(t/deftest handler-exception-propagates-with-type-and-code
(let [handler (make-handler
{:fail [{} (fn [_]
(ex/raise :type :test
:code :boom))]})
ex (call-ex handler {:path-params {:method-name "fail"}})]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :test))
(t/is (th/ex-of-code? ex :boom))))
;; --- DEFAULT HANDLER
(t/deftest default-handler-for-missing-method
(let [handler (rpc/make-rpc-handler {})
ex (try (handler (make-request
{:path-params {:method-name "nonexistent"}}))
(catch Exception e e))]
(t/is (some? ex))
(t/is (th/ex-of-type? ex :not-found))))
;; --- RESPONSE: NIL BODY WITH CUSTOM HEADERS
(t/deftest nil-body-with-custom-headers
(let [wrapped (-> (rph/wrap nil)
(vary-meta assoc ::http/headers {"x-custom" "val"}))
handler (make-handler {:nil-hdr [{} (constantly wrapped)]})
response (call handler
{:path-params {:method-name "nil-hdr"}})]
(t/is (= 204 (::yres/status response)))
(t/is (= "val" (get (::yres/headers response) "x-custom")))))