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,43 +12,63 @@
(defrecord Coercer [decoder encoder validator explainer]) (defrecord Coercer [decoder encoder validator explainer])
(def string-transformer (def string-transformer
mt/string-transformer) (mt/transformer
mt/strip-extra-keys-transformer
mt/string-transformer))
(def json-transformer (def json-transformer
mt/json-transformer) (mt/transformer
mt/strip-extra-keys-transformer
mt/json-transformer))
(def default-transformer (def default-transformer
(mt/transformer {:name :default})) mt/strip-extra-keys-transformer)
(defmulti coerce-response? identity :default ::default) (defmulti coerce-response? identity :default ::default)
(defmethod coerce-response? ::default [_] true) (defmethod coerce-response? ::default [_] true)
(defn- -coercer [schema type transformers f] (defn- -coercer [schema type transformers f opts]
(if schema (if schema
(let [->coercer (fn [t] (if t (->Coercer (m/decoder schema t) (let [->coercer (fn [t] (if t (->Coercer (m/decoder schema opts t)
(m/encoder schema t) (m/encoder schema opts t)
(m/validator schema) (m/validator schema opts)
(m/explainer schema)))) (m/explainer schema opts))))
{:keys [formats default]} (transformers type) {:keys [formats default]} (transformers type)
default-coercer (->coercer default) default-coercer (->coercer default)
format-coercers (->> (for [[f t] formats] [f (->coercer t)]) (into {})) format-coercers (some->> (for [[f t] formats] [f (->coercer t)]) (keep second) (seq) (into {}))
get-coercer (if (seq format-coercers) get-coercer (cond format-coercers (fn [format] (or (get format-coercers format) default-coercer))
(fn [format] (or (get format-coercers format) default-coercer)) default-coercer (constantly default-coercer))]
(constantly default-coercer))] (if get-coercer
(if default-coercer (if (= f :decode)
(fn [value format] ;; transform -> validate
(if-let [coercer (get-coercer format)] (fn [value format]
(let [transform (f coercer) (if-let [coercer (get-coercer format)]
validator (:validator coercer) (let [transform (:decoder coercer)
transformed (transform value)] validator (:validator coercer)
(if (validator transformed) transformed (transform value)]
transformed (if (validator transformed)
(let [explainer (:explainer coercer) transformed
errors (explainer transformed)] (let [explainer (:explainer coercer)
errors (explainer transformed)]
(coercion/map->CoercionError
{:schema schema
:value value
:transformed transformed
:errors errors}))))
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 (coercion/map->CoercionError
{:schema schema {:schema schema
:errors errors})))) :value value
value)))))) :errors (explainer value)})))
value)))))))
;; ;;
;; swagger ;; swagger
@ -86,44 +106,48 @@
:transformers {:body {:default default-transformer :transformers {:body {:default default-transformer
:formats {"application/json" json-transformer}} :formats {"application/json" json-transformer}}
:string {:default string-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]
^{:type ::coercion/coercion} (let [{:keys [transformers coerce-response? options] :as opts} (merge default-options opts)]
(reify coercion/Coercion ^{:type ::coercion/coercion}
(-get-name [_] :malli) (reify coercion/Coercion
(-get-options [_] opts) (-get-name [_] :malli)
(-get-apidocs [_ specification {:keys [parameters responses]}] (-get-options [_] opts)
(case specification (-get-apidocs [_ specification {:keys [parameters responses]}]
:swagger (merge (case specification
(if parameters :swagger (merge
{:parameters (if parameters
(->> (for [[in schema] parameters {:parameters
parameter (extract-parameter in schema)] (->> (for [[in schema] parameters
parameter) parameter (extract-parameter in schema)]
(into []))}) parameter)
(if responses (into []))})
{:responses (if responses
(into {:responses
(empty responses) (into
(for [[status response] responses] (empty responses)
[status (as-> response $ (for [[status response] responses]
(set/rename-keys $ {:body :schema}) [status (as-> response $
(update $ :description (fnil identity "")) (set/rename-keys $ {:body :schema})
(if (:schema $) (update $ :description (fnil identity ""))
(update $ :schema swagger/transform {:type :schema}) (if (:schema $)
$))]))})) (update $ :schema swagger/transform {:type :schema})
(throw $))]))}))
(ex-info (throw
(str "Can't produce Schema apidocs for " specification) (ex-info
{:type specification, :coercion :schema})))) (str "Can't produce Schema apidocs for " specification)
(-compile-model [_ model _] (m/schema model)) {:type specification, :coercion :schema}))))
(-open-model [_ schema] schema) (-compile-model [_ model _] (m/schema model))
(-encode-error [_ error] error) (-open-model [_ schema] schema)
(-request-coercer [_ type schema] (-encode-error [_ error] error)
(-coercer schema type transformers :decoder)) (-request-coercer [_ type schema]
(-response-coercer [_ schema] (-coercer schema type transformers :decode options))
(if (coerce-response? schema) (-response-coercer [_ schema]
(-coercer schema :response transformers :encoder))))) (if (coerce-response? schema)
(-coercer schema :response transformers :encode options))))))
(def coercion (create default-options)) (def coercion (create default-options))

View file

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

View file

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

View file

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