diff --git a/doc/cljdoc.edn b/doc/cljdoc.edn index 50d5b8cf..6e4014dd 100644 --- a/doc/cljdoc.edn +++ b/doc/cljdoc.edn @@ -25,6 +25,7 @@ ["Ring-router" {:file "doc/ring/ring.md"}] ["Reverse-routing" {:file "doc/ring/reverse_routing.md"}] ["Default handler" {:file "doc/ring/default_handler.md"}] + ["Slash handler" {:file "doc/ring/slash_handler.md"}] ["Static Resources" {:file "doc/ring/static.md"}] ["Dynamic Extensions" {:file "doc/ring/dynamic_extensions.md"}] ["Data-driven Middleware" {:file "doc/ring/data_driven_middleware.md"}] diff --git a/doc/ring/slash_handler.md b/doc/ring/slash_handler.md new file mode 100644 index 00000000..76c0ad49 --- /dev/null +++ b/doc/ring/slash_handler.md @@ -0,0 +1,64 @@ +# Slash handler + +The router works with precise matches. If a route is defined without a trailing slash, for example, it won't match a request with a slash. + +```clj +(require '[reitit.ring :as ring]) + +(def app + (ring/ring-handler + (ring/router + ["/ping" (constantly {:status 200, :body ""})]))) + +(app {:uri "/ping/"}) +; nil +``` + +Sometimes it is desirable that paths with and without a trailing slash are recognized as the same. + +Setting the `redirect-trailing-slash-handler` as a second argument to `ring-handler`: + +```clj +(def app + (ring/ring-handler + (ring/router + [["/ping" (constantly {:status 200, :body ""})] + ["/pong/" (constantly {:status 200, :body ""})]]) + (ring/redirect-trailing-slash-handler))) + +(app {:uri "/ping/"}) +; {:status 308, :headers {"Location" "/ping"}, :body ""} +(app {:uri "/pong"}) +; {:status 308, :headers {"Location" "/pong/"}, :body ""} +``` + +`redirect-trailing-slash-handler` accepts an optional `:method` parameter that allows configuring how (whether) to handle missing/extra slashes. The default is to handle both. + +```clj +(def app + (ring/ring-handler + (ring/router + [["/ping" (constantly {:status 200, :body ""})] + ["/pong/" (constantly {:status 200, :body ""})]]) + ; only handle extra trailing slash + (ring/redirect-trailing-slash-handler {:method :strip}))) + +(app {:uri "/ping/"}) +; {:status 308, :headers {"Location" "/ping"}, :body ""} +(app {:uri "/pong"}) +; nil +``` +```clj +(def app + (ring/ring-handler + (ring/router + [["/ping" (constantly {:status 200, :body ""})] + ["/pong/" (constantly {:status 200, :body ""})]]) + ; only handle missing trailing slash + (ring/redirect-trailing-slash-handler {:method :add}))) + +(app {:uri "/ping/"}) +; nil +(app {:uri "/pong"}) +; {:status 308, :headers {"Location" "/pong/"}, :body ""} +``` diff --git a/modules/reitit-ring/src/reitit/ring.cljc b/modules/reitit-ring/src/reitit/ring.cljc index e8fb737b..fb00875a 100644 --- a/modules/reitit-ring/src/reitit/ring.cljc +++ b/modules/reitit-ring/src/reitit/ring.cljc @@ -110,6 +110,37 @@ (respond nil)))] (f handlers)))))) +(defn redirect-trailing-slash-handler + "A ring handler that redirects a missing path if there is an + existing path that only differs in the ending slash. + + | key | description | + |---------|-------------| + | :method | :add - redirects slash-less to slashed | + | | :strip - redirects slashed to slash-less | + | | :both - works both ways (default) | + " + ([] (redirect-trailing-slash-handler {:method :both})) + ([{:keys [method]}] + (let [redirect-handler (fn redirect-handler [request] + (let [uri (:uri request) + status (if (= (:request-method request) :get) 301 308) + maybe-redirect (fn maybe-redirect [path] + (if (r/match-by-path (::r/router request) path) + {:status status + :headers {"Location" path} + :body ""}))] + (if (str/ends-with? uri "/") + (if (not= method :add) + (maybe-redirect (subs uri 0 (-> uri count dec)))) + (if (not= method :strip) + (maybe-redirect (str uri "/"))))))] + (fn + ([request] + (redirect-handler request)) + ([request respond _] + (respond (redirect-handler request))))))) + (defn create-default-handler "A default ring handler that can handle the following cases, configured via options: diff --git a/test/cljc/reitit/ring_test.cljc b/test/cljc/reitit/ring_test.cljc index 636d5028..4997ef63 100644 --- a/test/cljc/reitit/ring_test.cljc +++ b/test/cljc/reitit/ring_test.cljc @@ -239,6 +239,69 @@ (is (= response (app {:request-method :get, :uri "/any"}))) (is (= response (app {:request-method :options, :uri "/any"})))))))) +(deftest trailing-slash-handler-test + (let [ok {:status 200, :body "ok"} + routes [["/slash-less" {:get (constantly ok), + :post (constantly ok)}] + ["/with-slash/" {:get (constantly ok), + :post (constantly ok)}]]] + (testing "using :method :add" + (let [app (ring/ring-handler + (ring/router routes) + (ring/redirect-trailing-slash-handler {:method :add}))] + + (testing "exact matches work" + (is (= ok (app {:request-method :get, :uri "/slash-less"}))) + (is (= ok (app {:request-method :post, :uri "/slash-less"}))) + (is (= ok (app {:request-method :get, :uri "/with-slash/"}))) + (is (= ok (app {:request-method :post, :uri "/with-slash/"})))) + + (testing "adds slashes" + (is (= 301 (:status (app {:request-method :get, :uri "/with-slash"})))) + (is (= 308 (:status (app {:request-method :post, :uri "/with-slash"}))))) + + (testing "does not strip slashes" + (is (= nil (app {:request-method :get, :uri "/slash-less/"}))) + (is (= nil (app {:request-method :post, :uri "/slash-less/"})))))) + + (testing "using :method :strip" + (let [app (ring/ring-handler + (ring/router routes) + (ring/redirect-trailing-slash-handler {:method :strip}))] + + (testing "exact matches work" + (is (= ok (app {:request-method :get, :uri "/slash-less"}))) + (is (= ok (app {:request-method :post, :uri "/slash-less"}))) + (is (= ok (app {:request-method :get, :uri "/with-slash/"}))) + (is (= ok (app {:request-method :post, :uri "/with-slash/"})))) + + (testing "does not add slashes" + (is (= nil (app {:request-method :get, :uri "/with-slash"}))) + (is (= nil (app {:request-method :post, :uri "/with-slash"})))) + + (testing "strips slashes" + (is (= 301 (:status (app {:request-method :get, :uri "/slash-less/"})))) + (is (= 308 (:status (app {:request-method :post, :uri "/slash-less/"}))))))) + + (testing "without option (equivalent to using :method :both)" + (let [app (ring/ring-handler + (ring/router routes) + (ring/redirect-trailing-slash-handler))] + + (testing "exact matches work" + (is (= ok (app {:request-method :get, :uri "/slash-less"}))) + (is (= ok (app {:request-method :post, :uri "/slash-less"}))) + (is (= ok (app {:request-method :get, :uri "/with-slash/"}))) + (is (= ok (app {:request-method :post, :uri "/with-slash/"})))) + + (testing "adds slashes" + (is (= 301 (:status (app {:request-method :get, :uri "/with-slash"})))) + (is (= 308 (:status (app {:request-method :post, :uri "/with-slash"}))))) + + (testing "strips slashes" + (is (= 301 (:status (app {:request-method :get, :uri "/slash-less/"})))) + (is (= 308 (:status (app {:request-method :post, :uri "/slash-less/"}))))))))) + (deftest async-ring-test (let [promise #(let [value (atom ::nil)] (fn