Malli + coercion

This commit is contained in:
Tommi Reiman 2019-12-01 20:42:12 +02:00
parent 9ae4083270
commit 10be520a0d
7 changed files with 216 additions and 5 deletions

View file

@ -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]])

View file

@ -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))

View file

@ -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}))))

View file

@ -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"]

View file

@ -7,6 +7,7 @@ for ext in \
reitit-core \
reitit-dev \
reitit-spec \
reitit-malli \
reitit-schema \
reitit-ring \
reitit-middleware \

View file

@ -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))))))

View file

@ -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