diff --git a/CHANGELOG.md b/CHANGELOG.md index 25476740..a337ea77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ We use [Break Versioning][breakver]. The version numbers follow a `..> +; :parameters {:path [:map +; [:company string?] +; [:user-id int?]]}}, +; :result {:path #object[reitit.coercion$request_coercer$]}, +; :path-params {:company "metosin", :user-id "123"}, +; :parameters {:path {:company "metosin", :user-id 123}} +; :path "/metosin/users/123"} +``` + +Failing coercion: + +```clj +(match-by-path-and-coerce! "/metosin/users/ikitommi") +; => ExceptionInfo Request coercion failed... +``` diff --git a/doc/ring/coercion.md b/doc/ring/coercion.md index 50086ac6..a8ddae6f 100644 --- a/doc/ring/coercion.md +++ b/doc/ring/coercion.md @@ -25,6 +25,7 @@ To enable coercion, the following things need to be done: Reitit ships with the following coercion modules: +* `reitit.coercion.malli/coercion` for [malli](https://github.com/metosin/malli) * `reitit.coercion.schema/coercion` for [plumatic schema](https://github.com/plumatic/schema) * `reitit.coercion.spec/coercion` for both [clojure.spec](https://clojure.org/about/spec) and [data-specs](https://github.com/metosin/spec-tools#data-specs) diff --git a/examples/ring-malli-swagger/.gitignore b/examples/ring-malli-swagger/.gitignore new file mode 100644 index 00000000..c53038ec --- /dev/null +++ b/examples/ring-malli-swagger/.gitignore @@ -0,0 +1,11 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ diff --git a/examples/ring-malli-swagger/README.md b/examples/ring-malli-swagger/README.md new file mode 100644 index 00000000..3a2144c2 --- /dev/null +++ b/examples/ring-malli-swagger/README.md @@ -0,0 +1,23 @@ +# reitit-ring, malli, swagger + +## Usage + +```clj +> lein repl +(start) +``` + +To test the endpoints using [httpie](https://httpie.org/): + +```bash +http GET :3000/math/plus x==1 y==20 +http POST :3000/math/plus x:=1 y:=20 + +http GET :3000/swagger.json +``` + + + +## License + +Copyright © 2017-2019 Metosin Oy diff --git a/examples/ring-malli-swagger/project.clj b/examples/ring-malli-swagger/project.clj new file mode 100644 index 00000000..b7d76aa5 --- /dev/null +++ b/examples/ring-malli-swagger/project.clj @@ -0,0 +1,7 @@ +(defproject ring-example "0.1.0-SNAPSHOT" + :description "Reitit Ring App with Swagger" + :dependencies [[org.clojure/clojure "1.10.0"] + [ring/ring-jetty-adapter "1.7.1"] + [metosin/reitit "0.3.10"]] + :repl-options {:init-ns example.server} + :profiles {:dev {:dependencies [[ring/ring-mock "0.3.2"]]}}) diff --git a/examples/ring-malli-swagger/resources/reitit.png b/examples/ring-malli-swagger/resources/reitit.png new file mode 100644 index 00000000..c89c3654 Binary files /dev/null and b/examples/ring-malli-swagger/resources/reitit.png differ diff --git a/examples/ring-malli-swagger/src/example/server.clj b/examples/ring-malli-swagger/src/example/server.clj new file mode 100644 index 00000000..27260bd1 --- /dev/null +++ b/examples/ring-malli-swagger/src/example/server.clj @@ -0,0 +1,115 @@ +(ns example.server + (:require [reitit.ring :as ring] + [reitit.coercion.malli] + [reitit.ring.malli] + [reitit.swagger :as swagger] + [reitit.swagger-ui :as swagger-ui] + [reitit.ring.coercion :as coercion] + [reitit.dev.pretty :as pretty] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.exception :as exception] + [reitit.ring.middleware.multipart :as multipart] + [reitit.ring.middleware.parameters :as parameters] + ; [reitit.ring.middleware.dev :as dev] + ; [reitit.ring.spec :as spec] + ; [spec-tools.spell :as spell] + [ring.adapter.jetty :as jetty] + [muuntaja.core :as m] + [clojure.java.io :as io] + [malli.util :as mu])) + +(def app + (ring/ring-handler + (ring/router + [["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "my-api" + :description "with reitit-ring"}} + :handler (swagger/create-swagger-handler)}}] + + ["/files" + {:swagger {:tags ["files"]}} + + ["/upload" + {:post {:summary "upload a file" + :parameters {:multipart [:map [:file reitit.ring.malli/temp-file-part]]} + :responses {200 {:body [:map [:name string?] [:size int?]]}} + :handler (fn [{{{:keys [file]} :multipart} :parameters}] + {:status 200 + :body {:name (:filename file) + :size (:size file)}})}}] + + ["/download" + {:get {:summary "downloads a file" + :swagger {:produces ["image/png"]} + :handler (fn [_] + {:status 200 + :headers {"Content-Type" "image/png"} + :body (-> "reitit.png" + (io/resource) + (io/input-stream))})}}]] + + ["/math" + {:swagger {:tags ["math"]}} + + ["/plus" + {:get {:summary "plus with spec query parameters" + :parameters {:query [:map [:x int?] [:y int?]]} + :responses {200 {:body [:map [:total int?]]}} + :handler (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200 + :body {:total (+ x y)}})} + :post {:summary "plus with spec body parameters" + :parameters {:body [:map [:x int?] [:y int?]]} + :responses {200 {:body [:map [:total int?]]}} + :handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]]] + + {;;:reitit.middleware/transform dev/print-request-diffs ;; pretty diffs + ;;:validate spec/validate ;; enable spec validation for route data + ;;:reitit.spec/wrap spell/closed ;; strict top-level validation + :exception pretty/exception + :data {:coercion (reitit.coercion.malli/create + {;; set of keys to include in error messages + :error-keys #{#_:type :coercion :in :schema :value :errors :humanized #_:transformed} + ;; schema identity function (default: close all map schemas) + :compile mu/closed-schema + ;; strip-extra-keys (effects only predefined transformers) + :strip-extra-keys true + ;; add/set default values + :default-values true + ;; malli options + :options nil}) + :muuntaja m/instance + :middleware [;; swagger feature + swagger/swagger-feature + ;; query-params & form-params + parameters/parameters-middleware + ;; content-negotiation + muuntaja/format-negotiate-middleware + ;; encoding response body + muuntaja/format-response-middleware + ;; exception handling + exception/exception-middleware + ;; decoding request body + muuntaja/format-request-middleware + ;; coercing response bodys + coercion/coerce-response-middleware + ;; coercing request parameters + coercion/coerce-request-middleware + ;; multipart + multipart/multipart-middleware]}}) + (ring/routes + (swagger-ui/create-swagger-ui-handler + {:path "/" + :config {:validatorUrl nil + :operationsSorter "alpha"}}) + (ring/create-default-handler)))) + +(defn start [] + (jetty/run-jetty #'app {:port 3000, :join? false}) + (println "server running in port 3000")) + +(comment + (start)) diff --git a/examples/ring-malli-swagger/swagger.png b/examples/ring-malli-swagger/swagger.png new file mode 100644 index 00000000..9d5a55b8 Binary files /dev/null and b/examples/ring-malli-swagger/swagger.png differ diff --git a/examples/ring-malli-swagger/test/example/server_test.clj b/examples/ring-malli-swagger/test/example/server_test.clj new file mode 100644 index 00000000..e35726b8 --- /dev/null +++ b/examples/ring-malli-swagger/test/example/server_test.clj @@ -0,0 +1,38 @@ +(ns example.server-test + (:require [clojure.test :refer :all] + [example.server :refer [app]] + [ring.mock.request :refer [request json-body]])) + +(deftest example-server + + (testing "GET" + (is (= (-> (request :get "/math/plus?x=20&y=3") + app :body slurp) + (-> {:request-method :get :uri "/math/plus" :query-string "x=20&y=3"} + app :body slurp) + (-> {:request-method :get :uri "/math/plus" :query-params {:x 20 :y 3}} + app :body slurp) + "{\"total\":23}"))) + + (testing "POST" + (is (= (-> (request :post "/math/plus") (json-body {:x 40 :y 2}) + app :body slurp) + (-> {:request-method :post :uri "/math/plus" :body-params {:x 40 :y 2}} + app :body slurp) + "{\"total\":42}"))) + + (testing "Download" + (is (= (-> {:request-method :get :uri "/files/download"} + app :body (#(slurp % :encoding "ascii")) count) ;; binary + (.length (clojure.java.io/file "resources/reitit.png")) + 506325))) + + (testing "Upload" + (let [file (clojure.java.io/file "resources/reitit.png") + multipart-temp-file-part {:tempfile file + :size (.length file) + :filename (.getName file) + :content-type "image/png;"}] + (is (= (-> {:request-method :post :uri "/files/upload" :multipart-params {:file multipart-temp-file-part}} + app :body slurp) + "{\"name\":\"reitit.png\",\"size\":506325}"))))) diff --git a/modules/reitit-core/src/reitit/coercion.cljc b/modules/reitit-core/src/reitit/coercion.cljc index 737847dc..bcb7dfc9 100644 --- a/modules/reitit-core/src/reitit/coercion.cljc +++ b/modules/reitit-core/src/reitit/coercion.cljc @@ -21,7 +21,7 @@ #?(:clj (defmethod print-method ::coercion [coercion ^Writer w] - (.write w (str "<<" (-get-name coercion) ">>")))) + (.write w (str "#Coercion{:name " (-get-name coercion) "}")))) (defrecord CoercionError []) @@ -92,7 +92,7 @@ (defn response-coercer [coercion body {:keys [extract-response-format] :or {extract-response-format extract-response-format-default}}] (if coercion - (let [coercer (-response-coercer coercion body)] + (if-let [coercer (-response-coercer coercion body)] (fn [request response] (let [format (extract-response-format request response) value (:body response) @@ -130,13 +130,14 @@ (defn response-coercers [coercion responses opts] (->> (for [[status {:keys [body]}] responses :when body] [status (response-coercer coercion body opts)]) + (filter second) (into {}))) ;; ;; api-docs ;; -(defn get-apidocs [this specification data] +(defn get-apidocs [coercion specification data] (let [swagger-parameter {:query :query :body :body :form :formData @@ -152,7 +153,7 @@ (map (fn [[k v]] [(swagger-parameter k) v])) (filter first) (into {})))) - (-get-apidocs this specification))))) + (-get-apidocs coercion specification))))) ;; ;; integration 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..411843da --- /dev/null +++ b/modules/reitit-malli/src/reitit/coercion/malli.cljc @@ -0,0 +1,174 @@ +(ns reitit.coercion.malli + (:require [reitit.coercion :as coercion] + [malli.transform :as mt] + [malli.edn :as edn] + [malli.error :as me] + [malli.util :as mu] + [malli.swagger :as swagger] + [malli.core :as m] + [clojure.set :as set] + [clojure.walk :as walk])) + +;; +;; coercion +;; + +(defrecord Coercer [decoder encoder validator explainer]) + +(defprotocol TransformationProvider + (-transformer [this options])) + +(defn- -provider [transformer] + (reify TransformationProvider + (-transformer [_ {:keys [strip-extra-keys default-values]}] + (mt/transformer + (if strip-extra-keys (mt/strip-extra-keys-transformer)) + transformer + (if default-values (mt/default-value-transformer)))))) + +(def string-transformer-provider (-provider (mt/string-transformer))) +(def json-transformer-provider (-provider (mt/json-transformer))) +(def default-transformer-provider (-provider nil)) + +(defn- -coercer [schema type transformers f encoder opts] + (if 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) + encode (or encoder (fn [value _format] value)) + format-coercers (some->> (for [[f t] formats] [f (->coercer t)]) (filter 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) + ;; decode -> validate + (fn [value format] + (if-let [coercer (get-coercer format)] + (let [decoder (:decoder coercer) + validator (:validator coercer) + transformed (decoder value)] + (if (validator transformed) + transformed + (let [explainer (:explainer coercer) + error (explainer transformed)] + (coercion/map->CoercionError + (assoc error :transformed transformed))))) + value)) + ;; decode -> validate -> encode + (fn [value format] + (if-let [coercer (get-coercer format)] + (let [decoder (:decoder coercer) + validator (:validator coercer) + transformed (decoder value)] + (if (validator transformed) + (encode transformed format) + (let [explainer (:explainer coercer) + error (explainer transformed)] + (coercion/map->CoercionError + (assoc error :transformed transformed))))) + value))))))) + +;; +;; swagger +;; + +(defmulti extract-parameter (fn [in _] in)) + +(defmethod extract-parameter :body [_ schema] + (let [swagger-schema (swagger/transform schema {:in :body, :type :parameter})] + [{:in "body" + :name (:title swagger-schema "") + :description (:description swagger-schema "") + :required (not= :maybe (m/name schema)) + :schema swagger-schema}])) + +(defmethod extract-parameter :default [in schema] + (let [{:keys [properties required]} (swagger/transform schema {:in in, :type :parameter})] + (mapv + (fn [[k {:keys [type] :as schema}]] + (merge + {:in (name in) + :name k + :description (:description schema "") + :type type + :required (contains? (set required) k)} + schema)) + properties))) + +;; +;; public api +;; + +(def default-options + {:transformers {:body {:default default-transformer-provider + :formats {"application/json" json-transformer-provider}} + :string {:default string-transformer-provider} + :response {:default default-transformer-provider}} + ;; set of keys to include in error messages + :error-keys #{:type :coercion :in :schema :value :errors :humanized #_:transformed} + ;; schema identity function (default: close all map schemas) + :compile mu/closed-schema + ;; strip-extra-keys (effects only predefined transformers) + :strip-extra-keys true + ;; add/set default values + :default-values true + ;; malli options + :options nil}) + +(defn create + ([] + (create nil)) + ([opts] + (let [{:keys [transformers compile options error-keys] :as opts} (merge default-options opts) + show? (fn [key] (contains? error-keys key)) + transformers (walk/prewalk #(if (satisfies? TransformationProvider %) (-transformer % opts) %) transformers)] + ^{:type ::coercion/coercion} + (reify coercion/Coercion + (-get-name [_] :malli) + (-get-options [_] opts) + (-get-apidocs [_ specification {:keys [parameters responses]}] + (case specification + :swagger (merge + (if parameters + {:parameters + (->> (for [[in schema] parameters + parameter (extract-parameter in (compile schema))] + parameter) + (into []))}) + (if responses + {:responses + (into + (empty responses) + (for [[status response] responses] + [status (as-> response $ + (set/rename-keys $ {:body :schema}) + (update $ :description (fnil identity "")) + (if (:schema $) + (-> $ + (update :schema compile) + (update :schema swagger/transform {:type :schema})) + $))]))})) + (throw + (ex-info + (str "Can't produce Schema apidocs for " specification) + {:type specification, :coercion :schema})))) + (-compile-model [_ model _] (compile model)) + (-open-model [_ schema] schema) + (-encode-error [_ error] + (cond-> error + (show? :humanized) (assoc :humanized (me/humanize error {:wrap :message})) + (show? :schema) (update :schema edn/write-string opts) + (show? :errors) (-> (me/with-error-messages opts) + (update :errors (partial map #(update % :schema edn/write-string opts)))) + (seq error-keys) (select-keys error-keys))) + (-request-coercer [_ type schema] + (-coercer (compile schema) type transformers :decode nil options)) + (-response-coercer [_ schema] + (let [schema (compile schema) + encoder (-coercer schema :body transformers :encode nil options)] + (-coercer schema :response transformers :encode encoder options))))))) + +(def coercion (create default-options)) diff --git a/modules/reitit-malli/src/reitit/ring/malli.cljc b/modules/reitit-malli/src/reitit/ring/malli.cljc new file mode 100644 index 00000000..a7987625 --- /dev/null +++ b/modules/reitit-malli/src/reitit/ring/malli.cljc @@ -0,0 +1,19 @@ +(ns reitit.ring.malli + #?(:clj (:import (java.io File)))) + +#?(:clj + (def temp-file-part + "Schema for file param created by ring.middleware.multipart-params.temp-file store." + [:map {:json-schema {:type "file"}} + [:filename string?] + [:content-type string?] + [:size int?] + [:tempfile [:fn (partial instance? File)]]])) + +#?(:clj + (def bytes-part + "Schema for file param created by ring.middleware.multipart-params.byte-array store." + [:map {:json-schema {:type "file"}} + [:filename string?] + [:content-type string?] + [:bytes bytes?]])) diff --git a/modules/reitit-spec/src/reitit/coercion/spec.cljc b/modules/reitit-spec/src/reitit/coercion/spec.cljc index 41e64fe6..bac09a25 100644 --- a/modules/reitit-spec/src/reitit/coercion/spec.cljc +++ b/modules/reitit-spec/src/reitit/coercion/spec.cljc @@ -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}}}) diff --git a/perf-test/clj/reitit/coercion_perf_test.clj b/perf-test/clj/reitit/coercion_perf_test.clj index e91e660e..45d5b3d5 100644 --- a/perf-test/clj/reitit/coercion_perf_test.clj +++ b/perf-test/clj/reitit/coercion_perf_test.clj @@ -6,11 +6,11 @@ [spec-tools.core :as st] [muuntaja.middleware :as mm] [muuntaja.core :as m] - [muuntaja.format.jsonista :as jsonista-format] [jsonista.core :as j] [reitit.ring.coercion :as rrc] [reitit.coercion.spec :as spec] [reitit.coercion.schema :as schema] + [reitit.coercion.malli :as malli] [reitit.coercion :as coercion] [reitit.ring :as ring])) @@ -173,15 +173,14 @@ (defn json-perf-test [] (title "json") - (let [m (m/create (jsonista-format/with-json-format m/default-options)) - app (ring/ring-handler + (let [app (ring/ring-handler (ring/router ["/plus" {:post {:handler (fn [request] (let [body (:body-params request) x (:x body) y (:y body)] {:status 200, :body {:result (+ x y)}}))}}] - {:data {:middleware [[mm/wrap-format m]]}})) + {:data {:middleware [mm/wrap-format]}})) request {:request-method :post :uri "/plus" :headers {"content-type" "application/json"} @@ -196,15 +195,14 @@ (defn schema-json-perf-test [] (title "schema-json") - (let [m (m/create (jsonista-format/with-json-format m/default-options)) - app (ring/ring-handler + (let [app (ring/ring-handler (ring/router ["/plus" {:post {:responses {200 {:body {:result Long}}} :parameters {:body {:x Long, :y Long}} :handler (fn [request] (let [body (-> request :parameters :body)] {:status 200, :body {:result (+ (:x body) (:y body))}}))}}] - {:data {:middleware [[mm/wrap-format m] + {:data {:middleware [mm/wrap-format rrc/coerce-request-middleware rrc/coerce-response-middleware] :coercion schema/coercion}})) @@ -234,6 +232,7 @@ :coercion schema/coercion}})) request {:request-method :post :uri "/plus" + :muuntaja/request {:format "application/json"} :body-params {:x 1, :y 2}} call (fn [] (-> request app :body))] (assert (= {:result 3} (call))) @@ -241,6 +240,7 @@ ;; 0.23µs (no coercion) ;; 12.8µs ;; 1.9µs (cached coercers) + ;; 2.5µs (real json) (cc/quick-bench (call)))) @@ -258,11 +258,13 @@ :coercion spec/coercion}})) request {:request-method :post :uri "/plus" + :muuntaja/request {:format "application/json"} :body-params {:x 1, :y 2}} call (fn [] (-> request app :body))] (assert (= {:result 3} (call))) ;; 6.0µs + ;; 30.0µs (real json) (cc/quick-bench (call)))) @@ -287,17 +289,45 @@ :coercion spec/coercion}})) request {:request-method :post :uri "/plus" + :muuntaja/request {:format "application/json"} :body-params {:x 1, :y 2}} call (fn [] (-> request app :body))] (assert (= {:result 3} (call))) ;; 3.2µs + ;; 13.0µs (real json) + (cc/quick-bench + (call)))) + +(defn malli-perf-test [] + (title "malli") + (let [app (ring/ring-handler + (ring/router + ["/plus" {:post {:responses {200 {:body [:map [:result int?]]}} + :parameters {:body [:map [:x int?] [:y int?]]} + :handler (fn [request] + (let [body (-> request :parameters :body)] + {:status 200, :body {:result (+ (:x body) (:y body))}}))}}] + {:data {:middleware [rrc/coerce-request-middleware + rrc/coerce-response-middleware] + :coercion malli/coercion}})) + request {:request-method :post + :uri "/plus" + :muuntaja/request {:format "application/json"} + :body-params {:x 1, :y 2}} + call (fn [] (-> request app :body))] + (assert (= {:result 3} (call))) + + ;; 1.2µs (real json) (cc/quick-bench (call)))) (comment (json-perf-test) (schema-json-perf-test) - (schema-perf-test) - (data-spec-perf-test) - (spec-perf-test)) + + (do + (schema-perf-test) + (data-spec-perf-test) + (spec-perf-test) + (malli-perf-test))) diff --git a/project.clj b/project.clj index 510db486..b076ed68 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.6"] [metosin/jsonista "0.2.5"] [metosin/sieppari "0.0.0-alpha7"] + [metosin/malli "0.0.1-20200108.194558-11"] [meta-merge "1.0.0"] [fipp "0.6.22" :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..93b067ec 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?} @@ -30,20 +36,33 @@ (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)))))) + (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, 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)))))) diff --git a/test/cljc/reitit/ring_coercion_test.cljc b/test/cljc/reitit/ring_coercion_test.cljc index c152c00b..a0a6cdaf 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]])) @@ -16,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}) @@ -67,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? @@ -92,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)))) @@ -110,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} @@ -128,40 +157,180 @@ (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? ExceptionInfo #"Response coercion failed" - (app invalid-request2)))) + (app invalid-request2)))))) - (testing "with exception handling" - (let [app (create [rrc/coerce-exceptions-middleware - rrc/coerce-request-middleware - rrc/coerce-response-middleware])] + (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 "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request1)))) - (testing "invalid request" - (let [{:keys [status]} (app invalid-request)] - (is (= 400 status)))) + (testing "invalid request" + (let [{:keys [status]} (app invalid-request1)] + (is (= 400 status)))) - (testing "invalid response" - (let [{:keys [status]} (app invalid-request2)] - (is (= 500 status)))))))))) + (testing "invalid response" + (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 {:optional true} int?]] + :body [:map [:b int?]] + :form [:map [:c [int? {:default 3}]]] + :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-request1))) + (is (= {:status 200 + :body {:total 115}} + (app valid-request2))) + (is (= {:status 200 + :body {:total 15}} + (app valid-request3))) + (testing "default values work" + (is (= {:status 200 + :body {:total 15}} + (app (update valid-request3 :form-params dissoc :c))))) + (is (= {:status 500 + :body {:evil true}} + (app (assoc-in valid-request1 [:query-params "a"] "666"))))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request1)))) + + (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-request1)))) + + (testing "invalid request" + (let [{:keys [status]} (app invalid-request1)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status]} (app invalid-request2)] + (is (= 500 status)))))) + + (testing "open & closed schemas" + (let [endpoint (fn [schema] + {:get {:parameters {:body schema} + :responses {200 {:body schema}} + :handler (fn [{{:keys [body]} :parameters}] + {:status 200, :body (assoc body :response true)})}}) + ->app (fn [options] + (ring/ring-handler + (ring/router + ["/api" + ["/default" (endpoint [:map [:x int?]])] + ["/closed" (endpoint [:map {:closed true} [:x int?]])] + ["/open" (endpoint [:map {:closed false} [:x int?]])]] + {:data {:middleware [rrc/coerce-exceptions-middleware + rrc/coerce-request-middleware + rrc/coerce-response-middleware] + :coercion (malli/create options)}}))) + ->request (fn [uri] {:uri (str "/api/" uri) + :request-method :get + :muuntaja/request {:format "application/json"} + :body-params {:x 1, :request true}})] + + (testing "with defaults" + (let [app (->app nil)] + + (testing "default: keys are stripped" + (is (= {:status 200, :body {:x 1}} + (app (->request "default"))))) + + (testing "closed: keys are stripped" + (is (= {:status 200, :body {:x 1}} + (app (->request "closed"))))) + + (testing "open: keys are NOT stripped" + (is (= {:status 200, :body {:x 1, :request true, :response true}} + (app (->request "open"))))))) + + (testing "when schemas are not closed" + (let [app (->app {:compile identity})] + + (testing "default: keys are stripped" + (is (= {:status 200, :body {:x 1}} + (app (->request "default"))))) + + (testing "closed: keys are stripped" + (is (= {:status 200, :body {:x 1}} + (app (->request "closed"))))) + + (testing "open: keys are NOT stripped" + (is (= {:status 200, :body {:x 1, :request true, :response true}} + (app (->request "open"))))))) + + (testing "when schemas are not closed and extra keys are not stripped" + (let [app (->app {:compile identity, :strip-extra-keys false})] + (testing "default: keys are NOT stripped" + (is (= {:status 200, :body {:x 1, :request true, :response true}} + (app (->request "default"))))) + + (testing "closed: FAILS for extra keys" + (is (= 400 (:status (app (->request "closed")))))) + + (testing "open: keys are NOT stripped" + (is (= {:status 200, :body {:x 1, :request true, :response true}} + (app (->request "open"))))))))))) #?(:clj (deftest muuntaja-test @@ -189,11 +358,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)))))))) diff --git a/test/cljc/reitit/swagger_test.clj b/test/cljc/reitit/swagger_test.clj index 75305021..6214cf74 100644 --- a/test/cljc/reitit/swagger_test.clj +++ b/test/cljc/reitit/swagger_test.clj @@ -5,9 +5,11 @@ [reitit.swagger-ui :as swagger-ui] [reitit.ring.coercion :as rrc] [reitit.coercion.spec :as spec] + [reitit.coercion.malli :as malli] [reitit.coercion.schema :as schema] [schema.core :refer [Int]] - [muuntaja.core :as m])) + [muuntaja.core :as m] + [spec-tools.data-spec :as ds])) (def app (ring/ring-handler @@ -33,7 +35,7 @@ {:keys [z]} :path} :parameters}] {:status 200, :body {:total (+ x y z)}})} :post {:summary "plus with body" - :parameters {:body [int?] + :parameters {:body (ds/maybe [int?]) :path {:z int?}} :swagger {:responses {400 {:schema {:type "string"} :description "kosh"}}} @@ -43,6 +45,29 @@ xs :body} :parameters}] {:status 200, :body {:total (+ (reduce + xs) z)}})}}]] + ["/malli" {:coercion malli/coercion} + ["/plus/*z" + {:get {:summary "plus" + :parameters {:query [:map [:x int?] [:y int?]] + :path [:map [:z int?]]} + :swagger {:responses {400 {:schema {:type "string"} + :description "kosh"}}} + :responses {200 {:body [:map [:total int?]]} + 500 {:description "fail"}} + :handler (fn [{{{:keys [x y]} :query + {:keys [z]} :path} :parameters}] + {:status 200, :body {:total (+ x y z)}})} + :post {:summary "plus with body" + :parameters {:body [:maybe [:vector int?]] + :path [:map [:z int?]]} + :swagger {:responses {400 {:schema {:type "string"} + :description "kosh"}}} + :responses {200 {:body [:map [:total int?]]} + 500 {:description "fail"}} + :handler (fn [{{{:keys [z]} :path + xs :body} :parameters}] + {:status 200, :body {:total (+ (reduce + xs) z)}})}}]] + ["/schema" {:coercion schema/coercion} ["/plus/*z" {:get {:summary "plus" @@ -115,6 +140,56 @@ :description "kosh"} 500 {:description "fail"}} :summary "plus"}} + "/api/malli/plus/{z}" {:get {:parameters [{:description "" + :format "int64" + :in "query" + :name :x + :required true + :type "integer"} + {:description "" + :format "int64" + :in "query" + :name :y + :required true + :type "integer"} + {:in "path" + :name :z + :description "" + :type "integer" + :required true + :format "int64"}] + :responses {200 {:description "" + :schema {:properties {:total {:format "int64" + :type "integer"}} + :required [:total] + :type "object"}} + 400 {:schema {:type "string"} + :description "kosh"} + 500 {:description "fail"}} + :summary "plus"} + :post {:parameters [{:in "body", + :name "", + :description "", + :required false, + :schema {:type "array", + :items {:type "integer", + :format "int64"} + :x-nullable true}} + {:in "path" + :name :z + :description "" + :type "integer" + :required true + :format "int64"}] + :responses {200 {:description "" + :schema {:properties {:total {:format "int64" + :type "integer"}} + :required [:total] + :type "object"}} + 400 {:schema {:type "string"} + :description "kosh"} + 500 {:description "fail"}} + :summary "plus with body"}} "/api/spec/plus/{z}" {:get {:parameters [{:description "" :format "int64" :in "query" @@ -145,10 +220,11 @@ :post {:parameters [{:in "body", :name "", :description "", - :required true, + :required false, :schema {:type "array", :items {:type "integer", - :format "int64"}}} + :format "int64"} + :x-nullable true}} {:in "path" :name "z" :description ""