Compare commits

...

13 commits

Author SHA1 Message Date
Joshua Davey
a2c113fc29
Merge 019e7e3d98 into ef9dd495be 2025-10-13 15:41:54 +03:00
Joel Kaasinen
ef9dd495be doc: update CHANGELOG.md
Some checks failed
testsuite / Clojure (Java 11) (push) Has been cancelled
testsuite / Clojure (Java 17) (push) Has been cancelled
testsuite / Clojure (Java 21) (push) Has been cancelled
testsuite / ClojureScript (push) Has been cancelled
testsuite / Lint cljdoc.edn (push) Has been cancelled
testsuite / Check cljdoc analysis (push) Has been cancelled
2025-10-13 15:41:31 +03:00
Joel Kaasinen
9509e40dae
Merge pull request #756 from metosin/feat/defaults-for-optional-keys
setting default values for optional keys in malli coercion
2025-10-13 15:38:45 +03:00
Joel Kaasinen
67918a3f9c feat: reuse :default-values config key instead of adding a new one 2025-10-13 15:18:29 +03:00
Joel Kaasinen
45951aa82e doc: update CHANGELOG.md
Some checks are pending
testsuite / Clojure (Java 11) (push) Waiting to run
testsuite / Clojure (Java 17) (push) Waiting to run
testsuite / Clojure (Java 21) (push) Waiting to run
testsuite / ClojureScript (push) Waiting to run
testsuite / Lint cljdoc.edn (push) Waiting to run
testsuite / Check cljdoc analysis (push) Waiting to run
2025-10-13 09:17:33 +03:00
Joel Kaasinen
1cdca2e3d5
Merge pull request #739 from Ramblurr/fix/top-level-mw-registry
Apply router options to top-level middleware chain
2025-10-13 09:14:36 +03:00
Joel Kaasinen
2f22838820 doc: using middleware from registry at ring-handler level 2025-10-13 09:09:11 +03:00
Joel Kaasinen
d809291553 test: ring-handler middleware from registry inside router 2025-10-13 09:01:21 +03:00
Joel Kaasinen
4e572e86d6 Merge remote-tracking branch 'origin/master' into fix/top-level-mw-registry 2025-10-13 08:50:38 +03:00
Joel Kaasinen
f26dc1ab19 feat: :default-values-for-optional-keys for malli coercion 2025-10-10 08:51:05 +03:00
Joshua Davey
019e7e3d98 Return encoded value on response 2025-06-18 12:28:19 -04:00
Joshua Davey
3b5100d8a9 Add :on-invalid for custom handling of invalid values in coercer 2025-06-18 11:53:45 -04:00
Casey Link
0454e8914f Apply router options to top-level middleware chain
Middleware supplied to the `ring-handler` function could not be resolved
from the middleware registry, because the router options (which contain
the registry) were not being propagated.

Fixes #738
2025-04-29 14:52:09 +02:00
9 changed files with 157 additions and 21 deletions

View file

@ -16,6 +16,8 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
* Allow multimethods as handlers when validating [#755](https://github.com/metosin/reitit/pull/755)
* Improve error reporting when generating OpenAPI fails [#754](https://github.com/metosin/reitit/pull/754)
* Allow middleware registry to be used when defining middleware in `ring-handler`. See [docs](./doc/ring/middleware_registry.md). [#739](https://github.com/metosin/reitit/pull/739)
* Allow passing options (eg. `:malli.transform/add-optional-keys`) to malli's `default-value-transformer`. See [docs](./doc/coercion/malli_coercion.md). [#756](https://github.com/metosin/reitit/pull/756)
## 0.9.1 (2025-05-27)

View file

@ -73,9 +73,10 @@ Using `create` with options to create the coercion instead of `coercion`:
{:transformers {:body {:default reitit.coercion.malli/default-transformer-provider
:formats {"application/json" reitit.coercion.malli/json-transformer-provider}}
:string {:default reitit.coercion.malli/string-transformer-provider}
:response {:default reitit.coercion.malli/default-transformer-provider}}
:response {:default reitit.coercion.malli/default-transformer-provider
:formats {"application/json" reitit.coercion.malli/json-transformer-provider}}}
;; set of keys to include in error messages
:error-keys #{:type :coercion :in :schema :value :errors :humanized #_:transformed}
:error-keys #{:type :coercion :in #_:schema :value #_:errors :humanized #_:transformed}
;; support lite syntax?
:lite true
;; schema identity function (default: close all map schemas)
@ -87,7 +88,11 @@ Using `create` with options to create the coercion instead of `coercion`:
;; strip-extra-keys (affects only predefined transformers)
:strip-extra-keys true
;; add/set default values
;; Can be false, true or a map of options to pass to malli.transform/default-value-transformer,
;; for example {:malli.transform/add-optional-keys true}
:default-values true
;; encode-error
:encode-error nil
;; malli options
:options nil})
```

View file

@ -2,7 +2,7 @@
The `:middleware` syntax in `reitit-ring` also supports Keywords. Keywords are looked up from the Middleware Registry, which is a map of `keyword => IntoMiddleware`. Middleware registry should be stored under key `:reitit.middleware/registry` in the router options. If a middleware keyword isn't found in the registry, router creation fails fast with a descriptive error message.
## Examples
## Examples
Application using middleware defined in the Middleware Registry:
@ -52,6 +52,20 @@ Router creation fails fast if the registry doesn't contain the middleware:
;| :bonus | reitit.ring_test$wrap_bonus@59fddabb |
```
Middleware defined in the registry can also be used on the `ring-handler` level:
```clj
(def app
(ring/ring-handler
(ring/router
["/api"
["/bonus" {:get (fn [{:keys [bonus]}]
{:status 200, :body {:bonus bonus}})}]]
{::middleware/registry {:bonus wrap-bonus}})
nil
{:middleware [[:bonus 15]]}))
```
## When to use the registry?
Middleware as Keywords helps to keep the routes (all but handlers) as literal data (i.e. data that evaluates to itself), enabling the routes to be persisted in external formats like EDN-files and databases. Duct is a good example, where the [middleware can be referenced from EDN-files](https://github.com/duct-framework/duct/wiki/Configuration). It should be easy to make Duct configuration a Middleware Registry in `reitit-ring`.

View file

@ -9,8 +9,7 @@
[malli.swagger :as swagger]
[malli.transform :as mt]
[malli.util :as mu]
[reitit.coercion :as coercion]
[clojure.string :as string]))
[reitit.coercion :as coercion]))
;;
;; coercion
@ -20,7 +19,8 @@
(-decode [this value])
(-encode [this value])
(-validate [this value])
(-explain [this value]))
(-explain [this value])
(-on-invalid [this value explained]))
(defprotocol TransformationProvider
(-transformer [this options]))
@ -31,24 +31,30 @@
(mt/transformer
(if strip-extra-keys (mt/strip-extra-keys-transformer))
transformer
(if default-values (mt/default-value-transformer))))))
(if default-values (mt/default-value-transformer (if (map? default-values) default-values {})))))))
(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 {:keys [validate enabled options]}]
(defn- -return-coercion-error
[value error]
(coercion/map->CoercionError (assoc error :transformed value)))
(defn- -coercer [schema type transformers f {:keys [validate enabled options on-invalid]}]
(if schema
(let [->coercer (fn [t]
(let [decoder (if t (m/decoder schema options t) identity)
encoder (if t (m/encoder schema options t) identity)
validator (if validate (m/validator schema options) (constantly true))
explainer (m/explainer schema options)]
explainer (m/explainer schema options)
report (or on-invalid -return-coercion-error)]
(reify Coercer
(-decode [_ value] (decoder value))
(-encode [_ value] (encoder value))
(-validate [_ value] (validator value))
(-explain [_ value] (explainer value)))))
(-explain [_ value] (explainer value))
(-on-invalid [_ value explained] (report value explained)))))
{:keys [formats default]} (transformers type)
default-coercer (->coercer default)
format-coercers (some->> (for [[f t] formats] [f (->coercer t)]) (filter second) (seq) (into {}))
@ -63,8 +69,7 @@
(if (-validate coercer transformed)
transformed
(let [error (-explain coercer transformed)]
(coercion/map->CoercionError
(assoc error :transformed transformed)))))
(-on-invalid coercer transformed error))))
value))
;; encode: decode -> validate -> encode
(fn [value format]
@ -72,9 +77,9 @@
(if-let [coercer (get-coercer format)]
(if (-validate coercer transformed)
(-encode coercer transformed)
(let [error (-explain coercer transformed)]
(coercion/map->CoercionError
(assoc error :transformed transformed))))
(let [error (-explain coercer transformed)
encoded (-encode coercer transformed)]
(-on-invalid coercer encoded error)))
value))))))))
(defn- -query-string-coercer
@ -116,10 +121,14 @@
:enabled true
;; strip-extra-keys (affects only predefined transformers)
:strip-extra-keys true
;; add/set default values
;; add/set default values.
;; Can be false, true or a map of options to pass to malli.transform/default-value-transformer,
;; for example {:malli.transform/add-optional-keys true}
:default-values true
;; encode-error
:encode-error nil
;; custom handler for validation errors (vs returning them)
:on-invalid nil
;; malli options
:options nil})

View file

@ -370,7 +370,7 @@
([router default-handler {:keys [middleware inject-match? inject-router?]
:or {inject-match? true, inject-router? true}}]
(let [default-handler (or default-handler (fn ([_]) ([_ respond _] (respond nil))))
wrap (if middleware (partial middleware/chain middleware) identity)
wrap (if middleware #(middleware/chain middleware % nil (r/options router)) identity)
enrich-request (create-enrich-request inject-match? inject-router?)
enrich-default-request (create-enrich-default-request inject-router?)]
(with-meta

View file

@ -192,7 +192,8 @@
(is (= 500 status))))))))))
(deftest malli-coercion-test
(let [create (fn [interceptors]
(let [most-recent (atom nil)
create (fn [interceptors]
(http/ring-handler
(http/router
["/api"
@ -213,6 +214,16 @@
{:status 200
:body (-> req :parameters :body)})}}]
["/warn" {:summary "log and return original"
:coercion (reitit.coercion.malli/create {:transformers {},
:on-invalid (fn [value error]
(reset! most-recent error)
value)})
:post {:parameters {:body [:map [:x int?]]}
:responses {200 {:body [:map [:x int?]]}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :body)})}}]
["/skip" {:summary "skip"
:coercion (reitit.coercion.malli/create {:enabled false})
:post {:parameters {:body [:map [:x int?]]}
@ -290,6 +301,19 @@
:reitit.interceptor/handler]
(mounted-interceptor app "/api/validate" :post))))
(testing "validation, log on invalid"
(is (= 123 (:body (app {:uri "/api/warn"
:request-method :post
:muuntaja/request {:format "application/edn"}
:body-params 123}))))
(is (= [:reitit.http.coercion/coerce-exceptions
:reitit.http.coercion/coerce-request
:reitit.http.coercion/coerce-response
:reitit.interceptor/handler]
(mounted-interceptor app "/api/warn" :post)))
(let [received @most-recent]
(is (= 123 (-> @most-recent :errors first :value)))))
(testing "no tranformation & validation"
(is (= 123 (:body (app {:uri "/api/no-op"
:request-method :post

View file

@ -140,6 +140,36 @@
(let [m (r/match-by-path r "/none/kikka/abba")]
(is (= nil (coercion/coerce! m))))))))
(deftest malli-query-parameter-coercion-test
(let [router (fn [coercion]
(r/router ["/test"
{:coercion coercion
:parameters {:query [:map
[:a [:string {:default "a"}]]
[:x {:optional true} [:keyword {:default :a}]]]}}]
{:compile coercion/compile-request-coercers}))]
(testing "default values for :optional query keys do not get added"
(is (= {:query {:a "a"}}
(-> (r/match-by-path (router reitit.coercion.malli/coercion) "/test")
(assoc :query-params {})
(coercion/coerce!)))))
(testing "default values for :optional query keys get added when :malli.transform/add-optional-keys is set"
(is (= {:query {:a "a" :x :a}}
(-> (r/match-by-path (router (reitit.coercion.malli/create
(assoc reitit.coercion.malli/default-options
:default-values {:malli.transform/add-optional-keys true}))) "/test")
(assoc :query-params {})
(coercion/coerce!)))))
(testing "default values can be disabled"
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(-> (r/match-by-path (router (reitit.coercion.malli/create
(assoc reitit.coercion.malli/default-options
:default-values false))) "/test")
(assoc :query-params {})
(coercion/coerce!)))))))
(defn match-by-path-and-coerce! [router path]
(if-let [match (r/match-by-path router path)]
(assoc match :parameters (coercion/coerce! match))))

View file

@ -245,7 +245,8 @@
(reduce custom-meta-merge-checking-parameters left (cons right more))))
(deftest malli-coercion-test
(let [create (fn [middleware routes]
(let [most-recent (atom nil)
create (fn [middleware routes]
(ring/ring-handler
(ring/router
routes
@ -270,6 +271,17 @@
{:status 200
:body (-> req :parameters :body)})}}]
["/warn" {:summary "log and return original"
:coercion (reitit.coercion.malli/create {:transformers {},
:on-invalid (fn [value error]
(reset! most-recent error)
value)})
:post {:parameters {:body [:map [:x int?]]}
:responses {200 {:body [:map [:x int?]]}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :body)})}}]
["/skip" {:summary "skip"
:coercion (reitit.coercion.malli/create {:enabled false})
:post {:parameters {:body [:map [:x int?]]}
@ -311,7 +323,16 @@
:handler (fn [req]
{:status 200
:body (-> req :parameters :body)})}}]
["/warn" {:summary "log and return original"
:coercion (reitit.coercion.malli/create {:transformers {}
:on-invalid (fn [value error]
(reset! most-recent error)
value)})
:post {:parameters {:body {:x int?}}
:responses {200 {:body {:x int?}}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :body)})}}]
["/skip" {:summary "skip"
:coercion (reitit.coercion.malli/create {:enabled false})
:post {:parameters {:body {:x int?}}
@ -397,6 +418,18 @@
:reitit.ring.coercion/coerce-response]
(mounted-middleware app "/api/no-op" :post))))
(testing "validate and log when invalid"
(reset! most-recent nil)
(is (= 123 (:body (app {:uri "/api/warn"
:request-method :post
:muuntaja/request {:format "application/edn"}
:body-params 123}))))
(is (= 123 (-> @most-recent :errors first :value)))
(is (= [:reitit.ring.coercion/coerce-exceptions
:reitit.ring.coercion/coerce-request
:reitit.ring.coercion/coerce-response]
(mounted-middleware app "/api/warn" :post))))
(testing "skipping coercion"
(is (= nil (:body (app {:uri "/api/skip"
:request-method :post

View file

@ -114,6 +114,25 @@
(is (= {:status 200, :body [:top :api :ok]}
(app {:uri "/api/get" :request-method :get}))))))
(testing "middleware from registry"
(let [router (ring/router
["/api" {:middleware [:mw-foo]}
["/get" {:middleware [[:mw :inner]]
:get handler}]]
{::middleware/registry {:mw mw
:mw-foo #(mw % :foo)}})
app (ring/ring-handler router nil {:middleware [[:mw :top]]})]
(testing "router can be extracted"
(is (= router (ring/get-router app))))
(testing "not found"
(is (= nil (app {:uri "/favicon.ico"}))))
(testing "on match"
(is (= {:status 200, :body [:top :foo :inner :ok]}
(app {:uri "/api/get" :request-method :get}))))))
(testing "named routes"
(let [router (ring/router
[["/api"
@ -743,7 +762,7 @@
(is (= (redirect "/docs/index.html") response)))
(let [response (app (request "/foobar"))]
(is (= 404 (:status response)))))))
(testing "with additional mime types"
(let [app (ring/ring-handler
(ring/router