malli coercion + strip extra keys by default with spec

This commit is contained in:
Tommi Reiman 2019-12-28 00:51:41 +02:00
parent a7cd1cf398
commit 3c61824f1d
4 changed files with 162 additions and 93 deletions

View file

@ -12,33 +12,38 @@
(defrecord Coercer [decoder encoder validator explainer])
(def string-transformer
mt/string-transformer)
(mt/transformer
mt/strip-extra-keys-transformer
mt/string-transformer))
(def json-transformer
mt/json-transformer)
(mt/transformer
mt/strip-extra-keys-transformer
mt/json-transformer))
(def default-transformer
(mt/transformer {:name :default}))
mt/strip-extra-keys-transformer)
(defmulti coerce-response? identity :default ::default)
(defmethod coerce-response? ::default [_] true)
(defn- -coercer [schema type transformers f]
(defn- -coercer [schema type transformers f opts]
(if schema
(let [->coercer (fn [t] (if t (->Coercer (m/decoder schema t)
(m/encoder schema t)
(m/validator schema)
(m/explainer schema))))
(let [->coercer (fn [t] (if t (->Coercer (m/decoder schema opts t)
(m/encoder schema opts t)
(m/validator schema opts)
(m/explainer schema opts))))
{:keys [formats default]} (transformers type)
default-coercer (->coercer default)
format-coercers (->> (for [[f t] formats] [f (->coercer t)]) (into {}))
get-coercer (if (seq format-coercers)
(fn [format] (or (get format-coercers format) default-coercer))
(constantly default-coercer))]
(if default-coercer
format-coercers (some->> (for [[f t] formats] [f (->coercer t)]) (keep second) (seq) (into {}))
get-coercer (cond format-coercers (fn [format] (or (get format-coercers format) default-coercer))
default-coercer (constantly default-coercer))]
(if get-coercer
(if (= f :decode)
;; transform -> validate
(fn [value format]
(if-let [coercer (get-coercer format)]
(let [transform (f coercer)
(let [transform (:decoder coercer)
validator (:validator coercer)
transformed (transform value)]
(if (validator transformed)
@ -47,8 +52,23 @@
errors (explainer transformed)]
(coercion/map->CoercionError
{:schema schema
:value value
:transformed transformed
:errors errors}))))
value))))))
value))
;; validate -> transform
(fn [value format]
(if-let [coercer (get-coercer format)]
(let [transform (:encoder coercer)
validator (:validator coercer)
explainer (:explainer coercer)]
(if (validator value)
(transform value)
(coercion/map->CoercionError
{:schema schema
:value value
:errors (explainer value)})))
value)))))))
;;
;; swagger
@ -86,9 +106,13 @@
:transformers {:body {:default default-transformer
:formats {"application/json" json-transformer}}
:string {:default string-transformer}
:response {:default default-transformer}}})
:response {:default default-transformer
:formats {"application/json" json-transformer}}}
;; malli options
:options nil})
(defn create [{:keys [transformers coerce-response?] :as opts}]
(defn create [opts]
(let [{:keys [transformers coerce-response? options] :as opts} (merge default-options opts)]
^{:type ::coercion/coercion}
(reify coercion/Coercion
(-get-name [_] :malli)
@ -121,9 +145,9 @@
(-open-model [_ schema] schema)
(-encode-error [_ error] error)
(-request-coercer [_ type schema]
(-coercer schema type transformers :decoder))
(-coercer schema type transformers :decode options))
(-response-coercer [_ schema]
(if (coerce-response? schema)
(-coercer schema :response transformers :encoder)))))
(-coercer schema :response transformers :encode options))))))
(def coercion (create default-options))

View file

@ -19,6 +19,9 @@
st/strip-extra-keys-transformer
st/json-transformer))
(def strip-extra-keys-transformer
st/strip-extra-keys-transformer)
(def no-op-transformer
(reify
st/Transformer
@ -72,7 +75,7 @@
(def default-options
{:coerce-response? coerce-response?
:transformers {:body {:default no-op-transformer
:transformers {:body {:default strip-extra-keys-transformer
:formats {"application/json" json-transformer}}
:string {:default string-transformer}
:response {:default no-op-transformer}}})

View file

@ -36,8 +36,8 @@
(is (= {:path {:keyword :abba, :number 1}, :query nil}
(coercion/coerce! m))))
(let [m (r/match-by-path r "/schema/1/abba")]
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1,2,3], :map {1 1}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}}))))))
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1, 2, 3], :map {1 1, 2 2}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1", "2" "2"}}))))))
(testing "throws with invalid input"
(let [m (r/match-by-path r "/schema/kikka/abba")]
(is (thrown? ExceptionInfo (coercion/coerce! m))))))
@ -48,20 +48,21 @@
(is (= {:path {:keyword :abba, :number 1}, :query nil}
(coercion/coerce! m))))
(let [m (r/match-by-path r "/malli/1/abba")]
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1,2,3], :map {1 1}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}}))))))
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1, 2, 3], :map {1 1, 2 2}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1", "2" "2"}}))))))
(testing "throws with invalid input"
(let [m (r/match-by-path r "/malli/kikka/abba")]
(is (thrown? ExceptionInfo (coercion/coerce! m))))))
;; TODO: :map-of fails with string-keys
(testing "spec-coercion"
(testing "succeeds"
(let [m (r/match-by-path r "/spec/1/abba")]
(is (= {:path {:keyword :abba, :number 1}, :query nil}
(coercion/coerce! m))))
(let [m (r/match-by-path r "/schema/1/abba")]
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1,2,3], :map {1 1}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}}))))))
(is (= {:path {:keyword :abba, :number 1}, :query {:int 10, :ints [1, 2, 3], :map {1 1, #_#_2 2}}}
(coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}, #_#_"2" "2"}))))))
(testing "throws with invalid input"
(let [m (r/match-by-path r "/spec/kikka/abba")]
(is (thrown? ExceptionInfo (coercion/coerce! m))))))

View file

@ -17,22 +17,44 @@
{:keys [b]} :body
{:keys [c]} :form
{:keys [d]} :header
{:keys [e]} :path} :parameters}]
{:keys [e]} :path :as parameters} :parameters}]
;; extra keys are stripped off
(assert (every? #{0 1} (map (comp count val) parameters)))
(if (= 666 a)
{:status 500
:body {:evil true}}
{:status 200
:body {:total (+ a b c d e)}}))
:body {:total (+ (or a 101) b c d e)}}))
(def valid-request
(def valid-request1
{:uri "/api/plus/5"
:request-method :get
:muuntaja/request {:format "application/json"}
:query-params {"a" "1"}
:body-params {:b 2}
:form-params {:c 3}
:headers {"d" "4"}})
(def invalid-request
(def valid-request2
{:uri "/api/plus/5"
:request-method :get
:muuntaja/request {:format "application/json"}
:query-params {}
:body-params {:b 2}
:form-params {:c 3}
:headers {"d" "4"}})
(def valid-request3
{:uri "/api/plus/5"
:request-method :get
:muuntaja/request {:format "application/edn"}
:query-params {"a" "1", "EXTRA" "VALUE"}
:body-params {:b 2, :EXTRA "VALUE"}
:form-params {:c 3, :EXTRA "VALUE"}
:headers {"d" "4", "EXTRA" "VALUE"}})
(def invalid-request1
{:uri "/api/plus/5"
:request-method :get})
@ -68,16 +90,22 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request)))
(app valid-request1)))
(is (= {:status 200
:body {:total 115}}
(app valid-request2)))
(is (= {:status 200
:body {:total 15}}
(app valid-request3)))
(is (= {:status 500
:body {:evil true}}
(app (assoc-in valid-request [:query-params "a"] "666")))))
(app (assoc-in valid-request1 [:query-params "a"] "666")))))
(testing "invalid request"
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(app invalid-request))))
(app invalid-request1))))
(testing "invalid response"
(is (thrown-with-msg?
@ -93,10 +121,10 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request))))
(app valid-request1))))
(testing "invalid request"
(let [{:keys [status body]} (app invalid-request)
(let [{:keys [status body]} (app invalid-request1)
problems (:problems body)]
(is (= 1 (count problems)))
(is (= 400 status))))
@ -111,7 +139,7 @@
(ring/router
["/api"
["/plus/:e"
{:get {:parameters {:query {:a s/Int}
{:get {:parameters {:query {(s/optional-key :a) s/Int}
:body {:b s/Int}
:form {:c s/Int}
:header {:d s/Int}
@ -129,16 +157,23 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request)))
(app valid-request1)))
(is (= {:status 200
:body {:total 115}}
(app valid-request2)))
(is (= {:status 500
:body {:evil true}}
(app (assoc-in valid-request [:query-params "a"] "666")))))
(app (assoc-in valid-request1 [:query-params "a"] "666")))))
(testing "invalid request"
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(app invalid-request))))
(app invalid-request1)))
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(app valid-request3))))
(testing "invalid response"
(is (thrown-with-msg?
@ -154,10 +189,10 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request))))
(app valid-request1))))
(testing "invalid request"
(let [{:keys [status]} (app invalid-request)]
(let [{:keys [status]} (app invalid-request1)]
(is (= 400 status))))
(testing "invalid response"
@ -170,7 +205,7 @@
(ring/router
["/api"
["/plus/:e"
{:get {:parameters {:query [:map [:a int?]]
{:get {:parameters {:query [:map [:a {:optional true} int?]]
:body [:map [:b int?]]
:form [:map [:c int?]]
:header [:map [:d int?]]
@ -188,16 +223,22 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request)))
(app valid-request1)))
(is (= {:status 200
:body {:total 115}}
(app valid-request2)))
(is (= {:status 200
:body {:total 15}}
(app valid-request3)))
(is (= {:status 500
:body {:evil true}}
(app (assoc-in valid-request [:query-params "a"] "666")))))
(app (assoc-in valid-request1 [:query-params "a"] "666")))))
(testing "invalid request"
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(app invalid-request))))
(app invalid-request1))))
(testing "invalid response"
(is (thrown-with-msg?
@ -213,10 +254,10 @@
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app valid-request))))
(app valid-request1))))
(testing "invalid request"
(let [{:keys [status]} (app invalid-request)]
(let [{:keys [status]} (app invalid-request1)]
(is (= 400 status))))
(testing "invalid response"
@ -249,11 +290,11 @@
(testing "json coercion"
(let [e2e #(-> (request "application/json" (ByteArrayInputStream. (j/write-value-as-bytes %)))
(app) :body (slurp) (j/read-value (j/object-mapper {:decode-key-fn true})))]
(is (= data-json (e2e data-edn)))
(is (= data-json (e2e data-json)))))
(is (= data-json (e2e (assoc data-edn :EXTRA "VALUE"))))
(is (= data-json (e2e (assoc data-json :EXTRA "VALUE"))))))
(testing "edn coercion"
(let [e2e #(-> (request "application/edn" (pr-str %))
(app) :body slurp (read-string))]
(is (= data-edn (e2e data-edn)))
(is (= data-edn (e2e (assoc data-edn :EXTRA "VALUE"))))
(is (thrown? ExceptionInfo (e2e data-json))))))))