From 10be520a0dcba0d820b8dbbeb17c5891459af311 Mon Sep 17 00:00:00 2001 From: Tommi Reiman Date: Sun, 1 Dec 2019 20:42:12 +0200 Subject: [PATCH] Malli + coercion --- modules/reitit-malli/project.clj | 13 +++ .../src/reitit/coercion/malli.cljc | 89 +++++++++++++++++++ .../reitit-malli/src/reitit/ring/schema.cljc | 29 ++++++ project.clj | 7 +- scripts/lein-modules | 1 + test/cljc/reitit/coercion_test.cljc | 22 ++++- test/cljc/reitit/ring_coercion_test.cljc | 60 +++++++++++++ 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 modules/reitit-malli/project.clj create mode 100644 modules/reitit-malli/src/reitit/coercion/malli.cljc create mode 100644 modules/reitit-malli/src/reitit/ring/schema.cljc diff --git a/modules/reitit-malli/project.clj b/modules/reitit-malli/project.clj new file mode 100644 index 00000000..7649f787 --- /dev/null +++ b/modules/reitit-malli/project.clj @@ -0,0 +1,13 @@ +(defproject metosin/reitit-malli "0.3.10" + :description "Reitit: Malli coercion" + :url "https://github.com/metosin/reitit" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :scm {:name "git" + :url "https://github.com/metosin/reitit" + :dir "../.."} + :plugins [[lein-parent "0.3.2"]] + :parent-project {:path "../../project.clj" + :inherit [:deploy-repositories :managed-dependencies]} + :dependencies [[metosin/reitit-core] + [metosin/malli]]) diff --git a/modules/reitit-malli/src/reitit/coercion/malli.cljc b/modules/reitit-malli/src/reitit/coercion/malli.cljc new file mode 100644 index 00000000..50878ccb --- /dev/null +++ b/modules/reitit-malli/src/reitit/coercion/malli.cljc @@ -0,0 +1,89 @@ +(ns reitit.coercion.malli + (:require [reitit.coercion :as coercion] + [malli.transform :as mt] + [malli.core :as m])) + +(defrecord Coercer [decoder encoder validator explainer]) + +(def string-transformer + mt/string-transformer) + +(def json-transformer + mt/json-transformer) + +(def default-transformer + (mt/transformer {:name :default})) + +(defmulti coerce-response? identity :default ::default) +(defmethod coerce-response? ::default [_] true) + +(def default-options + {:coerce-response? coerce-response? + :transformers {:body {:default default-transformer + :formats {"application/json" json-transformer}} + :string {:default string-transformer} + :response {:default default-transformer}}}) + +(defn create [{:keys [transformers coerce-response?] :as opts}] + ^{:type ::coercion/coercion} + (reify coercion/Coercion + (-get-name [_] :malli) + (-get-options [_] opts) + (-get-apidocs [this specification {:keys [parameters responses]}] + ;; TODO: this looks identical to spec, refactor when schema is done. + #_(case specification + :swagger (swagger/swagger-spec + (merge + (if parameters + {:swagger/parameters + (into + (empty parameters) + (for [[k v] parameters] + [k (coercion/-compile-model this v nil)]))}) + (if responses + {:swagger/responses + (into + (empty responses) + (for [[k response] responses] + [k (as-> response $ + (set/rename-keys $ {:body :schema}) + (if (:schema $) + (update $ :schema #(coercion/-compile-model this % nil)) + $))]))}))) + (throw + (ex-info + (str "Can't produce Schema apidocs for " specification) + {:type specification, :coercion :schema})))) + (-compile-model [_ model _] (m/schema model)) + (-open-model [_ schema] schema) + (-encode-error [_ error] error) + (-request-coercer [_ type schema] + (if schema + (let [->coercer (fn [t] (->Coercer (m/decoder schema t) + (m/encoder schema t) + (m/validator schema) + (m/explainer schema))) + {: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))] + (fn [value format] + (if-let [coercer (get-coercer format)] + (let [decoder (:decoder coercer) + validator (:validator coercer) + decoded (decoder value)] + (if (validator decoded) + decoded + (let [explainer (:explainer coercer) + errors (explainer decoded)] + (coercion/map->CoercionError + {:schema schema + :errors errors})))) + value))))) + (-response-coercer [this schema] + (if (coerce-response? schema) + (coercion/-request-coercer this :response schema))))) + +(def coercion (create default-options)) diff --git a/modules/reitit-malli/src/reitit/ring/schema.cljc b/modules/reitit-malli/src/reitit/ring/schema.cljc new file mode 100644 index 00000000..0b3b45b6 --- /dev/null +++ b/modules/reitit-malli/src/reitit/ring/schema.cljc @@ -0,0 +1,29 @@ +(ns reitit.ring.malli) + +(comment + + (defrecord Upload [m] + s/Schema + (spec [_] + (s/spec m)) + (explain [_] + (list 'file m)) + + swagger/SwaggerSchema + (-transform [_ _] + {:type "file"})) + + #?(:clj + (def TempFilePart + "Schema for file param created by ring.middleware.multipart-params.temp-file store." + (->Upload {:filename s/Str + :content-type s/Str + :size s/Int + :tempfile File}))) + + #?(:clj + (def BytesPart + "Schema for file param created by ring.middleware.multipart-params.byte-array store." + (->Upload {:filename s/Str + :content-type s/Str + :bytes s/Any})))) diff --git a/project.clj b/project.clj index e1e1e975..89a3dbcb 100644 --- a/project.clj +++ b/project.clj @@ -16,6 +16,7 @@ [metosin/reitit-core "0.3.10"] [metosin/reitit-dev "0.3.10"] [metosin/reitit-spec "0.3.10"] + [metosin/reitit-malli "0.3.10"] [metosin/reitit-schema "0.3.10"] [metosin/reitit-ring "0.3.10"] [metosin/reitit-middleware "0.3.10"] @@ -32,6 +33,7 @@ [metosin/muuntaja "0.6.5"] [metosin/jsonista "0.2.5"] [metosin/sieppari "0.0.0-alpha7"] + [metosin/malli "0.0.1-SNAPSHOT"] [meta-merge "1.0.0"] [fipp "0.6.21" :exclusions [org.clojure/core.rrb-vector]] @@ -60,6 +62,7 @@ "modules/reitit-http/src" "modules/reitit-middleware/src" "modules/reitit-interceptors/src" + "modules/reitit-malli/src" "modules/reitit-spec/src" "modules/reitit-schema/src" "modules/reitit-swagger/src" @@ -79,6 +82,7 @@ [metosin/muuntaja] [metosin/sieppari] [metosin/jsonista] + [metosin/malli] [lambdaisland/deep-diff] [meta-merge] [com.bhauman/spell-spec] @@ -91,9 +95,6 @@ [ikitommi/immutant-web "3.0.0-alpha1"] [metosin/ring-http-response "0.9.1"] [metosin/ring-swagger-ui "2.2.10"] - [metosin/muuntaja] - [metosin/sieppari] - [metosin/jsonista] [criterium "0.4.5"] [org.clojure/test.check "0.10.0"] diff --git a/scripts/lein-modules b/scripts/lein-modules index ff25d3cb..24a87858 100755 --- a/scripts/lein-modules +++ b/scripts/lein-modules @@ -7,6 +7,7 @@ for ext in \ reitit-core \ reitit-dev \ reitit-spec \ + reitit-malli \ reitit-schema \ reitit-ring \ reitit-middleware \ diff --git a/test/cljc/reitit/coercion_test.cljc b/test/cljc/reitit/coercion_test.cljc index 73c8c7a9..2cdda88c 100644 --- a/test/cljc/reitit/coercion_test.cljc +++ b/test/cljc/reitit/coercion_test.cljc @@ -5,6 +5,7 @@ [reitit.core :as r] [reitit.coercion :as coercion] [reitit.coercion.spec] + [reitit.coercion.malli] [reitit.coercion.schema]) #?(:clj (:import (clojure.lang ExceptionInfo)))) @@ -15,6 +16,11 @@ ["/:number/:keyword" {:parameters {:path {:number s/Int :keyword s/Keyword} :query (s/maybe {:int s/Int, :ints [s/Int], :map {s/Int s/Int}})}}]] + ["/malli" {:coercion reitit.coercion.malli/coercion} + ["/:number/:keyword" {:parameters {:path [:map [:number int?] [:keyword keyword?]] + :query [:maybe [:map [:int int?] + [:ints [:vector int?]] + [:map [:map-of int? int?]]]]}}]] ["/spec" {:coercion reitit.coercion.spec/coercion} ["/:number/:keyword" {:parameters {:path {:number int? :keyword keyword?} @@ -31,11 +37,23 @@ (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}})))))) + (coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}})))))) (testing "throws with invalid input" (let [m (r/match-by-path r "/schema/kikka/abba")] (is (thrown? ExceptionInfo (coercion/coerce! m)))))) + (testing "malli-coercion" + (testing "succeeds" + (let [m (r/match-by-path r "/malli/1/abba")] + (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"}})))))) + (testing "throws with invalid input" + (let [m (r/match-by-path r "/malli/kikka/abba")] + (is (thrown? ExceptionInfo (coercion/coerce! m)))))) + (testing "spec-coercion" (testing "succeeds" (let [m (r/match-by-path r "/spec/1/abba")] @@ -43,7 +61,7 @@ (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}})))))) + (coercion/coerce! (assoc m :query-params {"int" "10", "ints" ["1" "2" "3"], "map" {:1 "1"}})))))) (testing "throws with invalid input" (let [m (r/match-by-path r "/spec/kikka/abba")] (is (thrown? ExceptionInfo (coercion/coerce! m)))))) diff --git a/test/cljc/reitit/ring_coercion_test.cljc b/test/cljc/reitit/ring_coercion_test.cljc index c152c00b..494d3c36 100644 --- a/test/cljc/reitit/ring_coercion_test.cljc +++ b/test/cljc/reitit/ring_coercion_test.cljc @@ -5,6 +5,7 @@ [reitit.ring :as ring] [reitit.ring.coercion :as rrc] [reitit.coercion.spec :as spec] + [reitit.coercion.malli :as malli] [reitit.coercion.schema :as schema] #?@(:clj [[muuntaja.middleware] [jsonista.core :as j]])) @@ -163,6 +164,65 @@ (let [{:keys [status]} (app invalid-request2)] (is (= 500 status)))))))))) +(deftest malli-coercion-test + (let [create (fn [middleware] + (ring/ring-handler + (ring/router + ["/api" + ["/plus/:e" + {:get {:parameters {:query [:map [:a int?]] + :body [:map [:b int?]] + :form [:map [:c int?]] + :header [:map [:d int?]] + :path [:map [:e int?]]} + :responses {200 {:body [:map [:total pos-int?]]} + 500 {:description "fail"}} + :handler handler}}]] + {:data {:middleware middleware + :coercion malli/coercion}})))] + + (testing "withut exception handling" + (let [app (create [rrc/coerce-request-middleware + rrc/coerce-response-middleware])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request))) + (is (= {:status 500 + :body {:evil true}} + (app (assoc-in valid-request [:query-params "a"] "666"))))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request)))) + + (testing "invalid response" + (is (thrown-with-msg? + ExceptionInfo + #"Response coercion failed" + (app invalid-request2)))) + + (testing "with exception handling" + (let [app (create [rrc/coerce-exceptions-middleware + rrc/coerce-request-middleware + rrc/coerce-response-middleware])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (let [{:keys [status]} (app invalid-request)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status]} (app invalid-request2)] + (is (= 500 status)))))))))) + #?(:clj (deftest muuntaja-test (let [app (ring/ring-handler