mirror of
https://github.com/metosin/reitit.git
synced 2025-12-22 10:31:12 +00:00
Merge pull request #341 from metosin/malli
Implement malli-based coercion
This commit is contained in:
commit
8b374678d9
23 changed files with 815 additions and 60 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -12,7 +12,7 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
|
|||
|
||||
[breakver]: https://github.com/ptaoussanis/encore/blob/master/BREAK-VERSIONING.md
|
||||
|
||||
## Unreleased
|
||||
## UNRELEASED
|
||||
|
||||
* Updated deps:
|
||||
|
||||
|
|
@ -29,6 +29,14 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
|
|||
* Added ability to mark individual routes as conflicting by using `:conflicting` route data. See [documentation](https://metosin.github.io/reitit/basics/route_conflicts.html). Fixes [#324](https://github.com/metosin/reitit/issues/324)
|
||||
* Encode sequential and set values as multi-valued query params (e.g. `{:foo ["bar", "baz"]}` ↦ `foo=bar&foo=baz`).
|
||||
|
||||
### `reitit-malli`
|
||||
|
||||
* Welcome [malli](https://github.com/metosin/malli)-based coercion! See [example project](./examples/ring-malli-swagger).
|
||||
|
||||
### `reitit-spec`
|
||||
|
||||
* `:body` coercion defaults to `spec-tools.core/strip-extra-keys-transformer`, so effectively all non-specced `s/keys` keys are stripped also for non-JSON formats.
|
||||
|
||||
### `reitit-frontend`
|
||||
|
||||
* **BREAKING**: Decode multi-valued query params correctly into seqs (e.g. `foo=bar&foo=baz` ↦ `{:foo ["bar", "baz"]}`).
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
# reitit [](https://circleci.com/gh/metosin/reitit) [](https://cljdoc.xyz/jump/release/metosin/reitit) [](https://clojurians.slack.com/messages/reitit/)
|
||||
|
||||
|
||||
A fast data-driven router for Clojure(Script).
|
||||
|
||||
* Simple data-driven [route syntax](https://metosin.github.io/reitit/basics/route_syntax.html)
|
||||
* Route [conflict resolution](https://metosin.github.io/reitit/basics/route_conflicts.html)
|
||||
* First-class [route data](https://metosin.github.io/reitit/basics/route_data.html)
|
||||
* Bi-directional routing
|
||||
* [Pluggable coercion](https://metosin.github.io/reitit/coercion/coercion.html) ([schema](https://github.com/plumatic/schema) & [clojure.spec](https://clojure.org/about/spec))
|
||||
* [Pluggable coercion](https://metosin.github.io/reitit/coercion/coercion.html) ([malli](https://github.com/metosin/malli), [schema](https://github.com/plumatic/schema) & [clojure.spec](https://clojure.org/about/spec))
|
||||
* Helpers for [ring](https://metosin.github.io/reitit/ring/ring.html), [http](https://metosin.github.io/reitit/http/interceptors.html), [pedestal](https://metosin.github.io/reitit/http/pedestal.html) & [frontend](https://metosin.github.io/reitit/frontend/basics.html)
|
||||
* Friendly [Error Messages](https://metosin.github.io/reitit/basics/error_messages.html)
|
||||
* Extendable
|
||||
|
|
@ -32,6 +31,7 @@ There is [#reitit](https://clojurians.slack.com/messages/reitit/) in [Clojurians
|
|||
* `reitit-ring` - a [ring router](https://metosin.github.io/reitit/ring/ring.html)
|
||||
* `reitit-middleware` - [common middleware](https://metosin.github.io/reitit/ring/default_middleware.html)
|
||||
* `reitit-spec` [clojure.spec](https://clojure.org/about/spec) coercion
|
||||
* `reitit-malli` [malli](https://github.com/metosin/malli) coercion
|
||||
* `reitit-schema` [Schema](https://github.com/plumatic/schema) coercion
|
||||
* `reitit-swagger` [Swagger2](https://swagger.io/) apidocs
|
||||
* `reitit-swagger-ui` Integrated [Swagger UI](https://github.com/swagger-api/swagger-ui)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ To enable parameter 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)
|
||||
|
||||
|
|
|
|||
45
doc/coercion/malli_coercion.md
Normal file
45
doc/coercion/malli_coercion.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Malli Coercion
|
||||
|
||||
[Malli](https://github.com/metosin/malli) is data-driven Schema library for Clojure/Script.
|
||||
|
||||
```clj
|
||||
(require '[reitit.coercion.malli])
|
||||
(require '[reitit.coercion :as coercion])
|
||||
(require '[reitit.core :as r])
|
||||
|
||||
(def router
|
||||
(r/router
|
||||
["/:company/users/:user-id" {:name ::user-view
|
||||
:coercion reitit.coercion.malli/coercion
|
||||
:parameters {:path [:map
|
||||
[:company string?]
|
||||
[:user-id int?]]}]
|
||||
{:compile coercion/compile-request-coercers}))
|
||||
|
||||
(defn match-by-path-and-coerce! [path]
|
||||
(if-let [match (r/match-by-path router path)]
|
||||
(assoc match :parameters (coercion/coerce! match))))
|
||||
```
|
||||
|
||||
Successful coercion:
|
||||
|
||||
```clj
|
||||
(match-by-path-and-coerce! "/metosin/users/123")
|
||||
; #Match{:template "/:company/users/:user-id",
|
||||
; :data {:name :user/user-view,
|
||||
; :coercion <<:malli>>
|
||||
; :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...
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
11
examples/ring-malli-swagger/.gitignore
vendored
Normal file
11
examples/ring-malli-swagger/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/target
|
||||
/classes
|
||||
/checkouts
|
||||
pom.xml
|
||||
pom.xml.asc
|
||||
*.jar
|
||||
*.class
|
||||
/.lein-*
|
||||
/.nrepl-port
|
||||
.hgignore
|
||||
.hg/
|
||||
23
examples/ring-malli-swagger/README.md
Normal file
23
examples/ring-malli-swagger/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
|
||||
<img src="https://raw.githubusercontent.com/metosin/reitit/master/examples/ring-spec-swagger/swagger.png" />
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2017-2019 Metosin Oy
|
||||
7
examples/ring-malli-swagger/project.clj
Normal file
7
examples/ring-malli-swagger/project.clj
Normal file
|
|
@ -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"]]}})
|
||||
BIN
examples/ring-malli-swagger/resources/reitit.png
Normal file
BIN
examples/ring-malli-swagger/resources/reitit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 494 KiB |
115
examples/ring-malli-swagger/src/example/server.clj
Normal file
115
examples/ring-malli-swagger/src/example/server.clj
Normal file
|
|
@ -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))
|
||||
BIN
examples/ring-malli-swagger/swagger.png
Normal file
BIN
examples/ring-malli-swagger/swagger.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
38
examples/ring-malli-swagger/test/example/server_test.clj
Normal file
38
examples/ring-malli-swagger/test/example/server_test.clj
Normal file
|
|
@ -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}")))))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
13
modules/reitit-malli/project.clj
Normal file
13
modules/reitit-malli/project.clj
Normal 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]])
|
||||
174
modules/reitit-malli/src/reitit/coercion/malli.cljc
Normal file
174
modules/reitit-malli/src/reitit/coercion/malli.cljc
Normal file
|
|
@ -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))
|
||||
19
modules/reitit-malli/src/reitit/ring/malli.cljc
Normal file
19
modules/reitit-malli/src/reitit/ring/malli.cljc
Normal file
|
|
@ -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?]]))
|
||||
|
|
@ -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}}})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ for ext in \
|
|||
reitit-core \
|
||||
reitit-dev \
|
||||
reitit-spec \
|
||||
reitit-malli \
|
||||
reitit-schema \
|
||||
reitit-ring \
|
||||
reitit-middleware \
|
||||
|
|
|
|||
|
|
@ -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))))))
|
||||
|
|
|
|||
|
|
@ -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))))))))
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
Loading…
Reference in a new issue