Compare commits

...

7 commits

Author SHA1 Message Date
Daniel Compton
c3e3d77eb4
Merge 7a707f042b into 7c1544c3ce 2026-02-14 15:48:55 +13:00
Joel Kaasinen
7c1544c3ce
doc: document match-by-name :url-encode? option
Some checks failed
testsuite / Clojure 11 (Java 11) (push) Has been cancelled
testsuite / Clojure 11 (Java 17) (push) Has been cancelled
testsuite / Clojure 11 (Java 21) (push) Has been cancelled
testsuite / Clojure 11 (Java 25) (push) Has been cancelled
testsuite / Clojure 12 (Java 11) (push) Has been cancelled
testsuite / Clojure 12 (Java 17) (push) Has been cancelled
testsuite / Clojure 12 (Java 21) (push) Has been cancelled
testsuite / Clojure 12 (Java 25) (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
for #778 #519
2026-02-06 13:25:33 +02:00
Joel Kaasinen
0724c0c5a0
doc: update CHANGELOG.md 2026-02-06 13:20:51 +02:00
Joel Kaasinen
7051c99e99
Merge pull request #778 from lucacervello/master
feat: add url-encode? options to path-params
2026-02-06 13:13:49 +02:00
Luca Cervello
4c8a69c616 refactor: remove repetitions 2026-02-06 12:01:44 +01:00
Luca Cervello
97bfafa907 feat: add url-encode? options to path-params 2026-01-23 11:20:38 +01:00
Daniel Compton
7a707f042b Add regex based router
Reitit is very fast, but cannot support all routing structures that
other routers like Bidi support, particular regex-based routes.
2025-02-25 22:50:06 +13:00
8 changed files with 482 additions and 19 deletions

View file

@ -12,6 +12,12 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
[breakver]: https://github.com/ptaoussanis/encore/blob/master/BREAK-VERSIONING.md
## UNRELEASED
* **FIX** redirect-trailing-slash-handler won't make external redirects. [#776](https://github.com/metosin/reitit/pull/776)
* Allow colons in bracket parameter syntax. [#770](https://github.com/metosin/reitit/pull/770)
* Add `url-encode?` option to `match-by-name`. [#778](https://github.com/metosin/reitit/pull/778)
## 0.10.0 (2026-01-09)
* Improve & document how response schemas get picked in per-content-type coercion. See [docs](./doc/ring/coercion.md#per-content-type-coercion). [#745](https://github.com/metosin/reitit/issues/745).

View file

@ -75,6 +75,17 @@ Path-parameters are automatically coerced into strings, with the help of (curren
; :path-params {:id "1"}}
```
In case you want to do something like generate a template path for documentation, you can disable url-encoding:
```clj
(r/match-by-name router ::user {:id "<id goes here>"} {:url-encode? false})
; #reitit.core.Match{:template "/api/user/:id"
; :data {:name :user/user}
; :path "/api/user/<id goes here>"
; :result nil
; :path-params {:id "<id goes here>"}}
```
There is also an exception throwing version:
```clj
@ -97,5 +108,5 @@ It can take an optional map of query-parameters too:
(-> router
(r/match-by-name ::user {:id 1})
(r/match->path {:iso "möly"}))
; "/api/user/1?iso=m%C3%B6ly"
; "/api/user/1?iso=m%C3%B6ly"
```

View file

@ -46,7 +46,7 @@
(options [this])
(route-names [this])
(match-by-path [this path])
(match-by-name [this name] [this name path-params]))
(match-by-name [this name] [this name path-params] [this name path-params opts]))
(defn router? [x]
(satisfies? Router x))
@ -122,9 +122,11 @@
(match-by-name [_ name]
(if-let [match (impl/fast-get lookup name)]
(match nil)))
(match-by-name [_ name path-params]
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(if-let [match (impl/fast-get lookup name)]
(match (impl/path-params path-params))))))))
(match (impl/path-params path-params opts))))))))
(defn lookup-router
"Creates a lookup-router from resolved routes and optional
@ -161,9 +163,11 @@
(match-by-name [_ name]
(if-let [match (impl/fast-get lookup name)]
(match nil)))
(match-by-name [_ name path-params]
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(if-let [match (impl/fast-get lookup name)]
(match (impl/path-params path-params))))))))
(match (impl/path-params path-params opts))))))))
(defn trie-router
"Creates a special prefix-tree router from resolved routes and optional
@ -208,9 +212,11 @@
(match-by-name [_ name]
(if-let [match (impl/fast-get lookup name)]
(match nil)))
(match-by-name [_ name path-params]
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(if-let [match (impl/fast-get lookup name)]
(match (impl/path-params path-params))))))))
(match (impl/path-params path-params opts))))))))
(defn single-static-path-router
"Creates a fast router of 1 static route(s) and optional
@ -238,8 +244,10 @@
(if (#?(:clj .equals :cljs =) p path) match))
(match-by-name [_ name]
(if (= n name) match))
(match-by-name [_ name path-params]
(if (= n name) (impl/fast-assoc match :path-params (impl/path-params path-params))))))))
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(if (= n name) (impl/fast-assoc match :path-params (impl/path-params path-params opts))))))))
(defn mixed-router
"Creates two routers: [[lookup-router]] or [[single-static-path-router]] for
@ -268,9 +276,11 @@
(match-by-name [_ name]
(or (match-by-name static-router name)
(match-by-name wildcard-router name)))
(match-by-name [_ name path-params]
(or (match-by-name static-router name path-params)
(match-by-name wildcard-router name path-params)))))))
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(or (match-by-name static-router name path-params opts)
(match-by-name wildcard-router name path-params opts)))))))
(defn quarantine-router
"Creates two routers: [[mixed-router]] for non-conflicting routes
@ -299,9 +309,11 @@
(match-by-name [_ name]
(or (match-by-name mixed-router name)
(match-by-name linear-router name)))
(match-by-name [_ name path-params]
(or (match-by-name mixed-router name path-params)
(match-by-name linear-router name path-params)))))))
(match-by-name [r name path-params]
(match-by-name r name path-params nil))
(match-by-name [_ name path-params opts]
(or (match-by-name mixed-router name path-params opts)
(match-by-name linear-router name path-params opts)))))))
;;
;; Creating Routers

View file

@ -297,8 +297,11 @@
(defn path-params
"Convert parameters' values into URL-encoded strings, suitable for URL paths"
[params]
(maybe-map-values #(url-encode (into-string %)) params))
([params] (path-params params nil))
([params {:keys [url-encode?] :or {url-encode? true}}]
(if url-encode?
(maybe-map-values #(url-encode (into-string %)) params)
(maybe-map-values #(into-string %) params))))
(defn- query-parameter [k v]
(str (form-encode (into-string k))

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

View file

@ -33,6 +33,12 @@
:path "/api/ipa/large"
:path-params {:size "large"}})
(r/match-by-name router ::beer {:size "large"})))
(is (= (r/map->Match
{:template "/api/ipa/:size"
:data {:name ::beer}
:path "/api/ipa/:large"
:path-params {:size ":large"}})
(r/match-by-name router ::beer {:size ":large"} {:url-encode? false})))
(is (= (r/map->Match
{:template "/api/ipa/:size"
:data {:name ::beer}

View file

@ -41,7 +41,33 @@
:u #uuid "c2541900-17a7-4353-9024-db8ac258ba4e"
:k :kikka
:qk ::kikka
:nil nil}))))
:nil nil})))
(is (= {:n "1"
:n1 "-1"
:n2 "1"
:n3 "1"
:n4 "1"
:n5 "1"
:d "2.2"
:b "true"
:s "kikka"
:u "c2541900-17a7-4353-9024-db8ac258ba4e"
:k "kikka"
:qk "reitit.impl-test/kikka"
:nil nil}
(impl/path-params {:n 1
:n1 -1
:n2 (long 1)
:n3 (int 1)
:n4 (short 1)
:n5 (byte 1)
:d 2.2
:b true
:s "kikka"
:u #uuid "c2541900-17a7-4353-9024-db8ac258ba4e"
:k :kikka
:qk ::kikka
:nil nil} {:url-encode? false}))))
(deftest query-params-test
(are [x y]

View 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"))))