mirror of
https://github.com/metosin/reitit.git
synced 2026-02-22 01:59:09 +00:00
Compare commits
19 commits
8c4cff30a0
...
e8d67f2d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d67f2d1a | ||
|
|
ef9dd495be | ||
|
|
9509e40dae | ||
|
|
67918a3f9c | ||
|
|
45951aa82e | ||
|
|
1cdca2e3d5 | ||
|
|
2f22838820 | ||
|
|
d809291553 | ||
|
|
4e572e86d6 | ||
|
|
10700e0ca2 | ||
|
|
3e0f1d3188 | ||
|
|
f26dc1ab19 | ||
|
|
01766ee211 | ||
|
|
79627aea7b | ||
|
|
4d284385ec | ||
|
|
31fa0376cf | ||
|
|
05bc331397 | ||
|
|
0454e8914f | ||
|
|
7a707f042b |
13 changed files with 521 additions and 25 deletions
|
|
@ -12,6 +12,13 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
|
||||||
|
|
||||||
[breakver]: https://github.com/ptaoussanis/encore/blob/master/BREAK-VERSIONING.md
|
[breakver]: https://github.com/ptaoussanis/encore/blob/master/BREAK-VERSIONING.md
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
* 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)
|
## 0.9.1 (2025-05-27)
|
||||||
|
|
||||||
* **FIX**: response coercion threw an exception for unlisted HTTP status codes if there was no `:default`. Broken in 0.9.0. [#742](https://github.com/metosin/reitit/issues/742)
|
* **FIX**: response coercion threw an exception for unlisted HTTP status codes if there was no `:default`. Broken in 0.9.0. [#742](https://github.com/metosin/reitit/issues/742)
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,10 @@ Using `create` with options to create the coercion instead of `coercion`:
|
||||||
{:transformers {:body {:default reitit.coercion.malli/default-transformer-provider
|
{:transformers {:body {:default reitit.coercion.malli/default-transformer-provider
|
||||||
:formats {"application/json" reitit.coercion.malli/json-transformer-provider}}
|
:formats {"application/json" reitit.coercion.malli/json-transformer-provider}}
|
||||||
:string {:default reitit.coercion.malli/string-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
|
;; 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?
|
;; support lite syntax?
|
||||||
:lite true
|
:lite true
|
||||||
;; schema identity function (default: close all map schemas)
|
;; 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 (affects only predefined transformers)
|
||||||
:strip-extra-keys true
|
: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
|
:default-values true
|
||||||
|
;; encode-error
|
||||||
|
:encode-error nil
|
||||||
;; malli options
|
;; malli options
|
||||||
:options nil})
|
:options nil})
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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:
|
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 |
|
;| :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?
|
## 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`.
|
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`.
|
||||||
|
|
|
||||||
121
modules/reitit-core/src/reitit/regex.cljc
Normal file
121
modules/reitit-core/src/reitit/regex.cljc
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
(ns reitit.regex
|
||||||
|
(:require [clojure.set :as set]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[reitit.core :as r]))
|
||||||
|
|
||||||
|
(defn compile-regex-route
|
||||||
|
"Given a route vector [path route-data], returns a map with:
|
||||||
|
- :pattern: a compiled regex pattern built from the path segments,
|
||||||
|
- :group-keys: vector of parameter keys in order,
|
||||||
|
- :route-data: the provided route data,
|
||||||
|
- :original-segments: original path segments for path generation,
|
||||||
|
- :template: the original path template for Match objects."
|
||||||
|
[[path route-data]]
|
||||||
|
(let [;; Normalize route-data to ensure it's a map with :name
|
||||||
|
route-data (if (keyword? route-data)
|
||||||
|
{:name route-data}
|
||||||
|
route-data)
|
||||||
|
|
||||||
|
;; Store the original path template for Match objects
|
||||||
|
template (if (str/starts-with? path "/")
|
||||||
|
path
|
||||||
|
(str "/" path))
|
||||||
|
|
||||||
|
;; Handle paths with or without leading slashes
|
||||||
|
normalized-path (cond-> path
|
||||||
|
(str/starts-with? path "/") (subs 1))
|
||||||
|
|
||||||
|
;; Split into segments, handling empty paths
|
||||||
|
segments (if (empty? normalized-path)
|
||||||
|
[]
|
||||||
|
(str/split normalized-path #"/"))
|
||||||
|
|
||||||
|
;; Store original segments for path generation
|
||||||
|
original-segments segments
|
||||||
|
|
||||||
|
compiled-segments
|
||||||
|
(map (fn [seg]
|
||||||
|
(if (str/starts-with? seg ":")
|
||||||
|
(let [param-key (keyword (subs seg 1))
|
||||||
|
param-regex (get-in route-data [:parameters :path param-key])]
|
||||||
|
(if (and param-regex (instance? java.util.regex.Pattern param-regex))
|
||||||
|
(str "(" (.pattern ^java.util.regex.Pattern param-regex) ")")
|
||||||
|
;; Fallback: match any non-slash characters.
|
||||||
|
"([^/]+)"))
|
||||||
|
(java.util.regex.Pattern/quote seg)))
|
||||||
|
segments)
|
||||||
|
|
||||||
|
;; Create the pattern string, handling special case for root path
|
||||||
|
pattern-str (if (empty? segments)
|
||||||
|
"^/?$" ;; Match root path with optional trailing slash
|
||||||
|
(str "^/" (str/join "/" compiled-segments) "$"))
|
||||||
|
|
||||||
|
group-keys (->> segments
|
||||||
|
(filter #(str/starts-with? % ":"))
|
||||||
|
(map #(keyword (subs % 1)))
|
||||||
|
(vec))]
|
||||||
|
|
||||||
|
{:pattern (re-pattern pattern-str)
|
||||||
|
:group-keys group-keys
|
||||||
|
:route-data route-data
|
||||||
|
:original-segments original-segments
|
||||||
|
:template template}))
|
||||||
|
|
||||||
|
(defn- generate-path
|
||||||
|
"Generate a path from a route and path parameters."
|
||||||
|
[route path-params]
|
||||||
|
(if (empty? (:original-segments route))
|
||||||
|
"/"
|
||||||
|
(str "/" (str/join "/"
|
||||||
|
(map (fn [segment]
|
||||||
|
(if (str/starts-with? segment ":")
|
||||||
|
(let [param-key (keyword (subs segment 1))]
|
||||||
|
(get path-params param-key ""))
|
||||||
|
segment))
|
||||||
|
(:original-segments route))))))
|
||||||
|
|
||||||
|
(defrecord RegexRouter [compiled-routes]
|
||||||
|
r/Router
|
||||||
|
(router-name [_] :regex-router)
|
||||||
|
|
||||||
|
(routes [_]
|
||||||
|
(mapv (fn [{:keys [route-data original-segments]}]
|
||||||
|
[(str "/" (str/join "/" original-segments)) route-data])
|
||||||
|
compiled-routes))
|
||||||
|
|
||||||
|
(compiled-routes [_] compiled-routes)
|
||||||
|
|
||||||
|
(options [_] {})
|
||||||
|
|
||||||
|
(route-names [_]
|
||||||
|
(keep (comp :name :route-data) compiled-routes))
|
||||||
|
|
||||||
|
(match-by-path [_ path]
|
||||||
|
(some (fn [{:keys [pattern group-keys route-data template]}]
|
||||||
|
(when-let [matches (re-matches pattern path)]
|
||||||
|
(let [params (zipmap group-keys (rest matches))]
|
||||||
|
(r/->Match template route-data nil params path))))
|
||||||
|
compiled-routes))
|
||||||
|
|
||||||
|
(match-by-name [this name]
|
||||||
|
(r/match-by-name this name {}))
|
||||||
|
|
||||||
|
(match-by-name [router name path-params]
|
||||||
|
(when-let [{:keys [group-keys route-data template] :as route}
|
||||||
|
(first (filter #(= name (get-in % [:route-data :name])) (r/compiled-routes router)))]
|
||||||
|
;; Check if all required params are provided
|
||||||
|
(let [required-params (set group-keys)
|
||||||
|
provided-params (set (keys path-params))]
|
||||||
|
(if (every? #(contains? provided-params %) required-params)
|
||||||
|
;; All required params provided, return a Match
|
||||||
|
(let [path (generate-path route path-params)]
|
||||||
|
(r/->Match template route-data nil path-params path))
|
||||||
|
;; Some required params missing, return a PartialMatch
|
||||||
|
(let [missing (set/difference required-params provided-params)]
|
||||||
|
(r/->PartialMatch template route-data nil path-params missing)))))))
|
||||||
|
|
||||||
|
(defn create-regex-router
|
||||||
|
"Create a RegexRouter from a vector of routes.
|
||||||
|
Each route should be a vector [path route-data]."
|
||||||
|
[routes]
|
||||||
|
(->RegexRouter (mapv compile-regex-route routes)))
|
||||||
|
|
@ -37,8 +37,11 @@
|
||||||
;; Default data
|
;; Default data
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
(defn -multi? [x]
|
||||||
|
(instance? #?(:clj clojure.lang.MultiFn :cljs cljs.core.MultiFn) x))
|
||||||
|
|
||||||
(s/def ::name keyword?)
|
(s/def ::name keyword?)
|
||||||
(s/def ::handler (s/or :fn fn? :var var?))
|
(s/def ::handler (s/or :fn fn? :var var? :multi -multi?))
|
||||||
(s/def ::no-doc boolean?)
|
(s/def ::no-doc boolean?)
|
||||||
(s/def ::conflicting boolean?)
|
(s/def ::conflicting boolean?)
|
||||||
(s/def ::default-data
|
(s/def ::default-data
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@
|
||||||
[malli.swagger :as swagger]
|
[malli.swagger :as swagger]
|
||||||
[malli.transform :as mt]
|
[malli.transform :as mt]
|
||||||
[malli.util :as mu]
|
[malli.util :as mu]
|
||||||
[reitit.coercion :as coercion]
|
[reitit.coercion :as coercion]))
|
||||||
[clojure.string :as string]))
|
|
||||||
|
|
||||||
;;
|
;;
|
||||||
;; coercion
|
;; coercion
|
||||||
|
|
@ -31,7 +30,7 @@
|
||||||
(mt/transformer
|
(mt/transformer
|
||||||
(if strip-extra-keys (mt/strip-extra-keys-transformer))
|
(if strip-extra-keys (mt/strip-extra-keys-transformer))
|
||||||
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 string-transformer-provider (-provider (mt/string-transformer)))
|
||||||
(def json-transformer-provider (-provider (mt/json-transformer)))
|
(def json-transformer-provider (-provider (mt/json-transformer)))
|
||||||
|
|
@ -116,7 +115,9 @@
|
||||||
:enabled true
|
:enabled true
|
||||||
;; strip-extra-keys (affects only predefined transformers)
|
;; strip-extra-keys (affects only predefined transformers)
|
||||||
:strip-extra-keys true
|
: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
|
:default-values true
|
||||||
;; encode-error
|
;; encode-error
|
||||||
:encode-error nil
|
:encode-error nil
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,10 @@
|
||||||
"Creates a Middleware to handle the multipart params, based on
|
"Creates a Middleware to handle the multipart params, based on
|
||||||
ring.middleware.multipart-params, taking same options. Mounts only
|
ring.middleware.multipart-params, taking same options. Mounts only
|
||||||
if endpoint has `[:parameters :multipart]` defined. Publishes coerced
|
if endpoint has `[:parameters :multipart]` defined. Publishes coerced
|
||||||
parameters into `[:parameters :multipart]` under request."
|
parameters into `[:parameters :multipart]` under request.
|
||||||
|
|
||||||
|
Note! You want to have multipart-middleware after coerce-request-middleware,
|
||||||
|
because coerce-request-middleware overwrites `:parameters`."
|
||||||
([]
|
([]
|
||||||
(create-multipart-middleware nil))
|
(create-multipart-middleware nil))
|
||||||
([options]
|
([options]
|
||||||
|
|
@ -69,5 +72,8 @@
|
||||||
"Middleware to handle the multipart params, based on
|
"Middleware to handle the multipart params, based on
|
||||||
ring.middleware.multipart-params, taking same options. Mounts only
|
ring.middleware.multipart-params, taking same options. Mounts only
|
||||||
if endpoint has `[:parameters :multipart]` defined. Publishes coerced
|
if endpoint has `[:parameters :multipart]` defined. Publishes coerced
|
||||||
parameters into `[:parameters :multipart]` under request."
|
parameters into `[:parameters :multipart]` under request.
|
||||||
|
|
||||||
|
Note! You want to have multipart-middleware after coerce-request-middleware,
|
||||||
|
because coerce-request-middleware overwrites `:parameters`."
|
||||||
(create-multipart-middleware))
|
(create-multipart-middleware))
|
||||||
|
|
|
||||||
|
|
@ -206,20 +206,23 @@
|
||||||
accept-route (fn [route]
|
accept-route (fn [route]
|
||||||
(-> route second :openapi :id (or ::default) (trie/into-set) (set/intersection ids) seq))
|
(-> route second :openapi :id (or ::default) (trie/into-set) (set/intersection ids) seq))
|
||||||
definitions (volatile! {})
|
definitions (volatile! {})
|
||||||
transform-endpoint (fn [[method {{:keys [coercion no-doc openapi] :as data} :data
|
transform-endpoint (fn [path [method {{:keys [coercion no-doc openapi] :as data} :data
|
||||||
middleware :middleware
|
middleware :middleware
|
||||||
interceptors :interceptors}]]
|
interceptors :interceptors}]]
|
||||||
(if (and data (not no-doc))
|
(try
|
||||||
[method
|
(if (and data (not no-doc))
|
||||||
(meta-merge
|
[method
|
||||||
(apply meta-merge (keep (comp :openapi :data) middleware))
|
(meta-merge
|
||||||
(apply meta-merge (keep (comp :openapi :data) interceptors))
|
(apply meta-merge (keep (comp :openapi :data) middleware))
|
||||||
(if coercion
|
(apply meta-merge (keep (comp :openapi :data) interceptors))
|
||||||
(-get-apidocs-openapi coercion data definitions))
|
(if coercion
|
||||||
(select-keys data [:tags :summary :description])
|
(-get-apidocs-openapi coercion data definitions))
|
||||||
(strip-top-level-keys openapi))]))
|
(select-keys data [:tags :summary :description])
|
||||||
|
(strip-top-level-keys openapi))])
|
||||||
|
(catch Throwable t
|
||||||
|
(throw (ex-info "While building openapi docs" {:path path :method method} t)))))
|
||||||
transform-path (fn [[p _ c]]
|
transform-path (fn [[p _ c]]
|
||||||
(if-let [endpoint (some->> c (keep transform-endpoint) (seq) (into {}))]
|
(if-let [endpoint (some->> c (keep (partial transform-endpoint p)) (seq) (into {}))]
|
||||||
[(openapi-path p (r/options router)) endpoint]))
|
[(openapi-path p (r/options router)) endpoint]))
|
||||||
map-in-order #(->> % (apply concat) (apply array-map))
|
map-in-order #(->> % (apply concat) (apply array-map))
|
||||||
paths (->> router (r/compiled-routes) (filter accept-route) (map transform-path) map-in-order)]
|
paths (->> router (r/compiled-routes) (filter accept-route) (map transform-path) map-in-order)]
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@
|
||||||
([router default-handler {:keys [middleware inject-match? inject-router?]
|
([router default-handler {:keys [middleware inject-match? inject-router?]
|
||||||
:or {inject-match? true, inject-router? true}}]
|
:or {inject-match? true, inject-router? true}}]
|
||||||
(let [default-handler (or default-handler (fn ([_]) ([_ respond _] (respond nil))))
|
(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-request (create-enrich-request inject-match? inject-router?)
|
||||||
enrich-default-request (create-enrich-default-request inject-router?)]
|
enrich-default-request (create-enrich-default-request inject-router?)]
|
||||||
(with-meta
|
(with-meta
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,36 @@
|
||||||
(let [m (r/match-by-path r "/none/kikka/abba")]
|
(let [m (r/match-by-path r "/none/kikka/abba")]
|
||||||
(is (= nil (coercion/coerce! m))))))))
|
(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]
|
(defn match-by-path-and-coerce! [router path]
|
||||||
(if-let [match (r/match-by-path router path)]
|
(if-let [match (r/match-by-path router path)]
|
||||||
(assoc match :parameters (coercion/coerce! match))))
|
(assoc match :parameters (coercion/coerce! match))))
|
||||||
|
|
|
||||||
278
test/cljc/reitit/regex_test.cljc
Normal file
278
test/cljc/reitit/regex_test.cljc
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
(ns reitit.regex-test
|
||||||
|
(:require [clojure.test :refer [deftest is testing]]
|
||||||
|
[reitit.core :as r]
|
||||||
|
[reitit.regex :as rt.regex]))
|
||||||
|
|
||||||
|
(defn re-=
|
||||||
|
"A custom equality function that handles regex patterns specially.
|
||||||
|
Returns true if a and b are equal, with special handling for regex patterns.
|
||||||
|
Also handles comparing records by their map representation."
|
||||||
|
[a b]
|
||||||
|
(cond
|
||||||
|
;; Handle record comparison by using their map representation
|
||||||
|
(and (instance? clojure.lang.IRecord a)
|
||||||
|
(instance? clojure.lang.IRecord b))
|
||||||
|
(re-= (into {} a) (into {} b))
|
||||||
|
|
||||||
|
;; If both are regex patterns, compare their string representations
|
||||||
|
(and (instance? java.util.regex.Pattern a)
|
||||||
|
(instance? java.util.regex.Pattern b))
|
||||||
|
(= (str a) (str b))
|
||||||
|
|
||||||
|
;; If one is a regex and the other isn't, they're not equal
|
||||||
|
(or (instance? java.util.regex.Pattern a)
|
||||||
|
(instance? java.util.regex.Pattern b))
|
||||||
|
false
|
||||||
|
|
||||||
|
;; For maps, compare each key-value pair using regex-aware-equals
|
||||||
|
(and (map? a) (map? b))
|
||||||
|
(and (= (set (keys a)) (set (keys b)))
|
||||||
|
(every? #(re-= (get a %) (get b %)) (keys a)))
|
||||||
|
|
||||||
|
;; For sequences, compare each element using regex-aware-equals
|
||||||
|
(and (sequential? a) (sequential? b))
|
||||||
|
(and (= (count a) (count b))
|
||||||
|
(every? identity (map re-= a b)))
|
||||||
|
|
||||||
|
;; For sets, convert to sequences and compare
|
||||||
|
(and (set? a) (set? b))
|
||||||
|
(re-= (seq a) (seq b))
|
||||||
|
|
||||||
|
;; For everything else, use regular equality
|
||||||
|
:else
|
||||||
|
(= a b)))
|
||||||
|
|
||||||
|
(def routes
|
||||||
|
(rt.regex/create-regex-router
|
||||||
|
[["" ::home]
|
||||||
|
[":item-id" {:name ::item
|
||||||
|
:parameters {:path {:item-id #"[a-z]{16,20}"}}}]
|
||||||
|
["inbox" ::inbox]
|
||||||
|
["teams" ::teams]
|
||||||
|
["teams/:team-id-b58/members" {:name ::->members
|
||||||
|
:parameters {:path {:team-id-b58 #"[a-z]"}}}]]))
|
||||||
|
|
||||||
|
(deftest regex-match-by-path-test
|
||||||
|
(testing "Basic path matching"
|
||||||
|
(is (= (r/map->Match {:path "/"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::home}
|
||||||
|
:template "/"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-path routes "/")))
|
||||||
|
|
||||||
|
(is (= (r/map->Match {:path "/inbox"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::inbox}
|
||||||
|
:template "/inbox"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-path routes "/inbox")))
|
||||||
|
|
||||||
|
(is (= (r/map->Match {:path "/teams"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::teams}
|
||||||
|
:template "/teams"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-path routes "/teams"))))
|
||||||
|
|
||||||
|
(testing "Path with regex parameter"
|
||||||
|
(let [valid-id "abcdefghijklmnopq"] ; 17 lowercase letters
|
||||||
|
(is (re-= (r/map->Match {:path (str "/" valid-id)
|
||||||
|
:path-params {:item-id valid-id}
|
||||||
|
:data {:name ::item
|
||||||
|
:parameters {:path {:item-id #"[a-z]{16,20}"}}},
|
||||||
|
:template "/:item-id"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-path routes (str "/" valid-id)))))
|
||||||
|
|
||||||
|
;; Invalid parameter cases
|
||||||
|
(is (nil? (r/match-by-path routes "/abcdefg")) "Too short")
|
||||||
|
(is (nil? (r/match-by-path routes "/abcdefghijklmnopqRST")) "Contains uppercase")
|
||||||
|
(is (nil? (r/match-by-path routes "/abcdefghijklmn1234")) "Contains digits"))
|
||||||
|
|
||||||
|
(testing "Nested path with parameter"
|
||||||
|
(is (re-= (r/map->Match {:path "/teams/a/members"
|
||||||
|
:path-params {:team-id-b58 "a"}
|
||||||
|
:data {:name ::->members
|
||||||
|
:parameters {:path {:team-id-b58 #"[a-z]"}}}
|
||||||
|
:template "/teams/:team-id-b58/members"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-path routes "/teams/a/members")))
|
||||||
|
|
||||||
|
(is (nil? (r/match-by-path routes "/teams/abc/members")) "Multiple characters")
|
||||||
|
(is (nil? (r/match-by-path routes "/teams/1/members")) "Digit instead of letter"))
|
||||||
|
|
||||||
|
(testing "Non-matching paths"
|
||||||
|
(is (nil? (r/match-by-path routes "/unknown")))
|
||||||
|
(is (nil? (r/match-by-path routes "/team"))) ; 'team' not 'teams'
|
||||||
|
(is (nil? (r/match-by-path routes "/teams/extra/segments/here")))))
|
||||||
|
|
||||||
|
(deftest regex-match-by-name-test
|
||||||
|
(testing "Basic match-by-name functionality"
|
||||||
|
;; Root path
|
||||||
|
(is (re-= (r/map->Match {:path "/"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::home}
|
||||||
|
:template "/"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-name routes ::home)))
|
||||||
|
|
||||||
|
;; Static paths
|
||||||
|
(is (re-= (r/map->Match {:path "/inbox"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::inbox}
|
||||||
|
:template "/inbox"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-name routes ::inbox)))
|
||||||
|
|
||||||
|
(is (re-= (r/map->Match {:path "/teams"
|
||||||
|
:path-params {}
|
||||||
|
:data {:name ::teams}
|
||||||
|
:template "/teams"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-name routes ::teams)))
|
||||||
|
|
||||||
|
;; Path with parameter
|
||||||
|
(let [valid-id "abcdefghijklmnopq"]
|
||||||
|
(is (re-= (r/map->Match {:path (str "/" valid-id)
|
||||||
|
:path-params {:item-id valid-id}
|
||||||
|
:data {:name ::item
|
||||||
|
:parameters {:path {:item-id #"[a-z]{16,20}"}}}
|
||||||
|
:template "/:item-id"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-name routes ::item {:item-id valid-id}))))
|
||||||
|
|
||||||
|
;; Nested path with parameter
|
||||||
|
(is (re-= (r/map->Match {:path "/teams/a/members"
|
||||||
|
:path-params {:team-id-b58 "a"}
|
||||||
|
:data {:name ::->members
|
||||||
|
:parameters {:path {:team-id-b58 #"[a-z]"}}}
|
||||||
|
:template "/teams/:team-id-b58/members"
|
||||||
|
:result nil})
|
||||||
|
(r/match-by-name routes ::->members {:team-id-b58 "a"}))))
|
||||||
|
|
||||||
|
(testing "Path round-trip matching"
|
||||||
|
;; Test that paths generated by match-by-name can be successfully matched by match-by-path
|
||||||
|
(let [valid-id "abcdefghijklmnopq"
|
||||||
|
match (r/match-by-name routes ::item {:item-id valid-id})
|
||||||
|
path (:path match)]
|
||||||
|
|
||||||
|
(is (some? path) "Should generate a valid path")
|
||||||
|
(is (re-= match (r/match-by-path routes path))
|
||||||
|
"match-by-path should find the same route that generated the path"))
|
||||||
|
|
||||||
|
(let [match (r/match-by-name routes ::->members {:team-id-b58 "a"})
|
||||||
|
path (:path match)]
|
||||||
|
|
||||||
|
(is (some? path) "Should generate a valid path")
|
||||||
|
(is (re-= match (r/match-by-path routes path))
|
||||||
|
"match-by-path should find the same route that generated the path")))
|
||||||
|
|
||||||
|
(testing "Partial match with missing parameters"
|
||||||
|
;; Test that routes with missing parameters return PartialMatch
|
||||||
|
(let [partial-match (r/match-by-name routes ::item {})]
|
||||||
|
(is (instance? reitit.core.PartialMatch partial-match)
|
||||||
|
"Should return a PartialMatch when params are missing")
|
||||||
|
(is (= #{:item-id} (:required partial-match))
|
||||||
|
"PartialMatch should indicate the required parameters")
|
||||||
|
(is (re-= (r/map->PartialMatch {:template "/:item-id"
|
||||||
|
:data {:name ::item
|
||||||
|
:parameters {:path {:item-id #"[a-z]{16,20}"}}}
|
||||||
|
:path-params {}
|
||||||
|
:required #{:item-id}
|
||||||
|
:result nil})
|
||||||
|
partial-match)))
|
||||||
|
|
||||||
|
;; Test for a nested path with missing parameters
|
||||||
|
(let [partial-match (r/match-by-name routes ::->members {})]
|
||||||
|
(is (instance? reitit.core.PartialMatch partial-match)
|
||||||
|
"Should return a PartialMatch for nested paths too")
|
||||||
|
(is (= #{:team-id-b58} (:required partial-match))
|
||||||
|
"PartialMatch should indicate the required parameters")))
|
||||||
|
|
||||||
|
(testing "Match with invalid parameters"
|
||||||
|
;; Invalid parameters (that don't match the regex) still produce a Match
|
||||||
|
(let [match (r/match-by-name routes ::item {:item-id "too-short"})
|
||||||
|
path (:path match)]
|
||||||
|
|
||||||
|
(is (instance? reitit.core.Match match)
|
||||||
|
"Should produce a Match even with invalid parameters")
|
||||||
|
(is (= "/too-short" path)
|
||||||
|
"Path should contain the provided parameter value")
|
||||||
|
(is (nil? (r/match-by-path routes path))
|
||||||
|
"Path with invalid parameter shouldn't be matchable by match-by-path")))
|
||||||
|
|
||||||
|
(testing "Non-existent routes"
|
||||||
|
(is (nil? (r/match-by-name routes ::non-existent))
|
||||||
|
"Should return nil for non-existent routes")))
|
||||||
|
|
||||||
|
(deftest regex-router-edge-cases-test
|
||||||
|
(testing "Empty router"
|
||||||
|
(let [empty-router (rt.regex/create-regex-router [])]
|
||||||
|
(is (nil? (r/match-by-path empty-router "/any/path")))))
|
||||||
|
|
||||||
|
(testing "Handling trailing slashes"
|
||||||
|
(is (nil? (r/match-by-path routes "/inbox/")))
|
||||||
|
|
||||||
|
(let [router-with-trailing-slash (rt.regex/create-regex-router [["inbox/" ::inbox-with-slash]])]
|
||||||
|
(is (nil? (r/match-by-path router-with-trailing-slash "/inbox/")))
|
||||||
|
(is (some? (r/match-by-path router-with-trailing-slash "/inbox")))))
|
||||||
|
|
||||||
|
(testing "Complex path patterns"
|
||||||
|
(let [complex-router (rt.regex/create-regex-router
|
||||||
|
[["articles/:year/:month/:slug"
|
||||||
|
{:name ::article
|
||||||
|
:parameters {:path {:year #"\d{4}"
|
||||||
|
:month #"\d{2}"
|
||||||
|
:slug #"[a-z0-9\-]+"}}}]
|
||||||
|
["files/:path*"
|
||||||
|
{:name ::file-path}]])]
|
||||||
|
|
||||||
|
;; Test article route with valid params
|
||||||
|
(let [match (r/match-by-name complex-router ::article
|
||||||
|
{:year "2023" :month "02" :slug "test-article"})]
|
||||||
|
(is (instance? reitit.core.Match match)
|
||||||
|
"Should return a Match for complex routes with valid params")
|
||||||
|
(is (= "/articles/2023/02/test-article" (:path match))
|
||||||
|
"Path should be constructed correctly"))
|
||||||
|
|
||||||
|
;; Test match-by-path with the generated path
|
||||||
|
(let [match (r/match-by-path complex-router "/articles/2023/02/test-article")]
|
||||||
|
(is (some? match)
|
||||||
|
"Should match a valid article path")
|
||||||
|
(is (= {:year "2023", :month "02", :slug "test-article"}
|
||||||
|
(:path-params match))
|
||||||
|
"Should extract all parameters correctly"))
|
||||||
|
|
||||||
|
;; Test invalid path
|
||||||
|
(is (nil? (r/match-by-path complex-router "/articles/202/02/test-article"))
|
||||||
|
"Should not match an invalid year (3 digits)")
|
||||||
|
|
||||||
|
;; Test partial params
|
||||||
|
(let [partial-match (r/match-by-name complex-router ::article {:year "2023"})]
|
||||||
|
(is (instance? reitit.core.PartialMatch partial-match)
|
||||||
|
"Should return PartialMatch when some params are missing")
|
||||||
|
(is (= #{:month :slug} (:required partial-match))
|
||||||
|
"Should indicate which params are missing")))))
|
||||||
|
|
||||||
|
(deftest custom-router-features-test
|
||||||
|
(testing "Router information access"
|
||||||
|
;; Test that router information methods work properly
|
||||||
|
(is (= :regex-router (r/router-name routes))
|
||||||
|
"Should return the correct router name")
|
||||||
|
|
||||||
|
(is (seq (r/routes routes))
|
||||||
|
"Should return the list of routes")
|
||||||
|
|
||||||
|
(is (= (set [::home ::item ::inbox ::teams ::->members])
|
||||||
|
(set (r/route-names routes)))
|
||||||
|
"Should return all route names"))
|
||||||
|
|
||||||
|
(testing "Compiled routes access"
|
||||||
|
(let [compiled (r/compiled-routes routes)]
|
||||||
|
(is (seq compiled)
|
||||||
|
"Should return compiled routes")
|
||||||
|
(is (every? :pattern compiled)
|
||||||
|
"Every compiled route should have a pattern")
|
||||||
|
(is (every? :route-data compiled)
|
||||||
|
"Every compiled route should have route data"))))
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
(s/def ::role #{:admin :user})
|
(s/def ::role #{:admin :user})
|
||||||
(s/def ::roles (s/and (s/coll-of ::role :into #{}) set?))
|
(s/def ::roles (s/and (s/coll-of ::role :into #{}) set?))
|
||||||
|
|
||||||
|
(defmulti my-multi (constantly :default))
|
||||||
|
(defmethod my-multi :default [x] x)
|
||||||
|
|
||||||
(deftest route-data-validation-test
|
(deftest route-data-validation-test
|
||||||
(testing "validation is turned off by default"
|
(testing "validation is turned off by default"
|
||||||
(is (r/router?
|
(is (r/router?
|
||||||
|
|
@ -85,6 +88,12 @@
|
||||||
(ring/router
|
(ring/router
|
||||||
["/api" {:handler identity
|
["/api" {:handler identity
|
||||||
:middleware '()}]
|
:middleware '()}]
|
||||||
|
{:validate rrs/validate}))))
|
||||||
|
|
||||||
|
(testing "handler can be a multimethod"
|
||||||
|
(is (r/router?
|
||||||
|
(ring/router
|
||||||
|
["/api" {:get {:handler my-multi}}]
|
||||||
{:validate rrs/validate})))))
|
{:validate rrs/validate})))))
|
||||||
|
|
||||||
(deftest coercion-spec-test
|
(deftest coercion-spec-test
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,25 @@
|
||||||
(is (= {:status 200, :body [:top :api :ok]}
|
(is (= {:status 200, :body [:top :api :ok]}
|
||||||
(app {:uri "/api/get" :request-method :get}))))))
|
(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"
|
(testing "named routes"
|
||||||
(let [router (ring/router
|
(let [router (ring/router
|
||||||
[["/api"
|
[["/api"
|
||||||
|
|
@ -743,7 +762,7 @@
|
||||||
(is (= (redirect "/docs/index.html") response)))
|
(is (= (redirect "/docs/index.html") response)))
|
||||||
(let [response (app (request "/foobar"))]
|
(let [response (app (request "/foobar"))]
|
||||||
(is (= 404 (:status response)))))))
|
(is (= 404 (:status response)))))))
|
||||||
|
|
||||||
(testing "with additional mime types"
|
(testing "with additional mime types"
|
||||||
(let [app (ring/ring-handler
|
(let [app (ring/ring-handler
|
||||||
(ring/router
|
(ring/router
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue