handle name conflicts in router

This commit is contained in:
Tommi Reiman 2018-07-21 08:49:20 +03:00
parent 73f0f355eb
commit 16856749b1
7 changed files with 130 additions and 63 deletions

View file

@ -5,6 +5,7 @@
* **BREAKING**: the router option key to extract body format has been renamed: `:extract-request-format` => `:reitit.coercion/extract-request-format`
* should only concern you if you are not using [Muuntaja](https://github.com/metosin/muuntaja).
* the `r/routes` returns just the path + data tuples as documented, not the compiled route results. To get the compiled results, use `r/compiled-routes` instead.
* welcome route name conflict resolution! If router has routes with same names, router can't be created. fix 'em.
## `reitit-swagger`

View file

@ -1,10 +1,12 @@
# Route Conflicts
Most routing libraries allow conflicting paths within a router. On lookup, the first match is used making rest of the matching routes effecively unreachable. This is not good, especially if route tree is merged from multiple sources.
We should fast if a router contains conflicting paths or route names.
Reitit resolves this by running explicit conflicit resolution when a Router is created. Conflicting routes are passed into a `:conflicts` callback. Default implementation throws `ex-info` with a descriptive message.
When a `Router` is created via `reitit.core/router`, both path and route name conflicts are checked automatically. By default, in case of conflict, an `ex-info` is thrown with a descriptive message. Is some (legacy api) cases, path conflicts are should be allowed and one can override the path conflict resolution via `:conflicts` router option.
Examples router with conflicting routes:
## Path Conflicts
Routes with path conflicts:
```clj
(require '[reitit.core :as r])
@ -17,11 +19,11 @@ Examples router with conflicting routes:
["/:version/status"]])
```
By default, `ExceptionInfo` is thrown:
Creating router with defaults:
```clj
(r/router routes)
; CompilerException clojure.lang.ExceptionInfo: Router contains conflicting routes:
; CompilerException clojure.lang.ExceptionInfo: Router contains conflicting route paths:
;
; /:user-id/orders
; -> /public/*path
@ -35,22 +37,58 @@ By default, `ExceptionInfo` is thrown:
;
```
Just logging the conflicts:
To ignore the conflicts:
```clj
(r/router
routes
{:conflicts (comp println reitit/conflicts-str)})
; Router contains conflicting routes:
{:conflicts nil})
; => #object[reitit.core$linear_router$reify]
```
To just log the conflicts:
```clj
(r/router
routes
{:conflicts (fn [conflicts]
(println (r/path-conflicts-str conflicts)))})
; Router contains conflicting route paths:
;
; /:user-id/orders
; -> /public/*path
; -> /bulk/:bulk-id
;
; /bulk/:bulk-id
; /bulk/:bulk-id
; -> /:version/status
;
; /public/*path
; /public/*path
; -> /:version/status
;
; => #object[reitit.core$linear_router$reify]
```
## Name conflicts
Routes with name conflicts:
```clj
(def routes
[["/ping" ::ping]
["/admin" ::admin]
["/admin/ping" ::ping]])
```
Creating router with defaults:
```clj
(r/router routes)
;CompilerException clojure.lang.ExceptionInfo: Router contains conflicting route names:
;
;:reitit.core/ping
;-> /ping
;-> /admin/ping
;
```
There is no way to disable the name conflict resolution.

View file

@ -63,7 +63,7 @@
coerce (into [] (keep #(coerce % opts)))))
;; This whole function might be more efficient and easier to understand with transducers.
(defn conflicting-routes [routes]
(defn path-conflicting-routes [routes]
(some->>
(loop [[r & rest] routes, acc {}]
(if (seq rest)
@ -74,17 +74,33 @@
(seq)
(into {})))
(defn conflicts-str [conflicts]
(apply str "Router contains conflicting routes:\n\n"
(defn path-conflicts-str [conflicts]
(apply str "Router contains conflicting route paths:\n\n"
(mapv
(fn [[[path] vals]]
(str " " path "\n-> " (str/join "\n-> " (mapv first vals)) "\n\n"))
conflicts)))
(defn throw-on-conflicts! [conflicts]
(defn name-conflicting-routes [routes]
(some->> routes
(group-by (comp :name second))
(remove (comp nil? first))
(filter (comp pos? count butlast second))
(seq)
(map (fn [[k v]] [k (set v)]))
(into {})))
(defn name-conflicts-str [conflicts]
(apply str "Router contains conflicting route names:\n\n"
(mapv
(fn [[name vals]]
(str name "\n-> " (str/join "\n-> " (mapv first vals)) "\n\n"))
conflicts)))
(defn throw-on-conflicts! [f conflicts]
(throw
(ex-info
(conflicts-str conflicts)
(f conflicts)
{:conflicts conflicts})))
(defn name-lookup [[_ {:keys [name]}] _]
@ -144,7 +160,7 @@
:expand expand
:coerce (fn [route _] route)
:compile (fn [[_ {:keys [handler]}] _] handler)
:conflicts throw-on-conflicts!})
:conflicts (partial throw-on-conflicts! path-conflicts-str)})
(defn linear-router
"Creates a linear-router from resolved routes and optional
@ -370,16 +386,17 @@
([raw-routes]
(router raw-routes {}))
([raw-routes opts]
(let [{:keys [router] :as opts} (meta-merge default-router-options opts)
(let [{:keys [router] :as opts} (merge default-router-options opts)
routes (resolve-routes raw-routes opts)
conflicting (conflicting-routes routes)
path-conflicting (path-conflicting-routes routes)
name-conflicting (name-conflicting-routes routes)
compiled-routes (compile-routes routes opts)
wilds? (boolean (some impl/wild-route? compiled-routes))
all-wilds? (every? impl/wild-route? compiled-routes)
router (cond
router router
(and (= 1 (count compiled-routes)) (not wilds?)) single-static-path-router
conflicting linear-router
path-conflicting linear-router
(not wilds?) lookup-router
all-wilds? segment-router
:else mixed-router)]
@ -388,6 +405,9 @@
(validate compiled-routes opts))
(when-let [conflicts (:conflicts opts)]
(when conflicting (conflicts conflicting)))
(when path-conflicting (conflicts path-conflicting)))
(when name-conflicting
(throw-on-conflicts! name-conflicts-str name-conflicting))
(router compiled-routes opts))))

View file

@ -51,7 +51,7 @@
(s/def :reitit.router/expand fn?)
(s/def :reitit.router/coerce fn?)
(s/def :reitit.router/compile fn?)
(s/def :reitit.router/conflicts fn?)
(s/def :reitit.router/conflicts (s/nilable fn?))
(s/def :reitit.router/router fn?)
(s/def ::opts

View file

@ -12,18 +12,15 @@
(deftest coercion-test
(let [r (r/router
[["/schema" {:coercion reitit.coercion.schema/coercion}
["/:number/:keyword" {:name ::user
:parameters {:path {:number s/Int
["/:number/:keyword" {:parameters {:path {:number s/Int
:keyword s/Keyword}
:query (s/maybe {:int s/Int})}}]]
["/spec" {:coercion reitit.coercion.spec/coercion}
["/:number/:keyword" {:name ::user
:parameters {:path {:number int?
["/:number/:keyword" {:parameters {:path {:number int?
:keyword keyword?}
:query (ds/maybe {:int int?})}}]]
["/none"
["/:number/:keyword" {:name ::user
:parameters {:path {:number int?
["/:number/:keyword" {:parameters {:path {:number int?
:keyword keyword?}}}]]]
{:compile coercion/compile-request-coercers})]

View file

@ -197,55 +197,66 @@
(r/match-by-path router "/api/user/1/2"))))))
(deftest conflicting-routes-test
(are [conflicting? data]
(let [routes (r/resolve-routes data {})
conflicts (-> routes (r/resolve-routes {}) (r/conflicting-routes))]
(if conflicting? (seq conflicts) (nil? conflicts)))
(testing "path conflicts"
(are [conflicting? data]
(let [routes (r/resolve-routes data {})
conflicts (-> routes
(r/resolve-routes {})
(r/path-conflicting-routes))]
(if conflicting? (seq conflicts) (nil? conflicts)))
true [["/a"]
["/a"]]
true [["/a"]
["/a"]]
true [["/a"]
["/:b"]]
true [["/a"]
["/:b"]]
true [["/a"]
["/*b"]]
true [["/a"]
["/*b"]]
true [["/a/1/2"]
["/*b"]]
true [["/a/1/2"]
["/*b"]]
false [["/a"]
["/a/"]]
false [["/a"]
["/a/"]]
false [["/a"]
["/a/1"]]
false [["/a"]
["/a/1"]]
false [["/a"]
["/a/:b"]]
false [["/a"]
["/a/:b"]]
false [["/a"]
["/a/*b"]]
false [["/a"]
["/a/*b"]]
true [["/v2/public/messages/dataset/bulk"]
["/v2/public/messages/dataset/:dataset-id"]])
true [["/v2/public/messages/dataset/bulk"]
["/v2/public/messages/dataset/:dataset-id"]])
(testing "all conflicts are returned"
(is (= {["/a" {}] #{["/*d" {}] ["/:b" {}]},
["/:b" {}] #{["/c" {}] ["/*d" {}]},
["/c" {}] #{["/*d" {}]}}
(-> [["/a"] ["/:b"] ["/c"] ["/*d"]]
(r/resolve-routes {})
(r/conflicting-routes)))))
(testing "all conflicts are returned"
(is (= {["/a" {}] #{["/*d" {}] ["/:b" {}]},
["/:b" {}] #{["/c" {}] ["/*d" {}]},
["/c" {}] #{["/*d" {}]}}
(-> [["/a"] ["/:b"] ["/c"] ["/*d"]]
(r/resolve-routes {})
(r/path-conflicting-routes)))))
(testing "router with conflicting routes"
(testing "throws by default"
(testing "router with conflicting routes"
(testing "throws by default"
(is (thrown-with-msg?
ExceptionInfo
#"Router contains conflicting route paths"
(r/router
[["/a"] ["/a"]]))))
(testing "can be configured to ignore"
(is (not (nil? (r/router [["/a"] ["/a"]] {:conflicts nil})))))))
(testing "name conflicts"
(testing "router with conflicting routes always throws"
(is (thrown-with-msg?
ExceptionInfo
#"Router contains conflicting routes"
#"Router contains conflicting route names"
(r/router
[["/a"] ["/a"]]))))
(testing "can be configured to ignore"
(is (not (nil? (r/router [["/a"] ["/a"]] {:conflicts (constantly nil)})))))))
[["/1" ::1] ["/2" ::1]]))))))
(deftest match->path-test
(let [router (r/router ["/:a/:b" ::route])]

View file

@ -79,7 +79,7 @@
{:expand nil}
{:coerce nil}
{:compile nil}
{:conflicts nil}
{:conflicts "invalid"}
{:router nil}))))
(deftest route-data-validation-test