diff --git a/.gitignore b/.gitignore index 73f41df4..7b183f21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target +target/ /classes /checkouts pom.xml @@ -11,3 +11,4 @@ pom.xml.asc /gh-pages /node_modules /_book +figwheel_server.log diff --git a/README.md b/README.md index d5c5a4bc..aa540f0d 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,18 @@ Bubblin' under: All bundled: ```clj -[metosin/reitit "0.1.3"] +[metosin/reitit "0.1.4-SNAPSHOT"] ``` Optionally, the parts can be required separately: ```clj -[metosin/reitit-core "0.1.3"] -[metosin/reitit-ring "0.1.3"] -[metosin/reitit-spec "0.1.3"] -[metosin/reitit-schema "0.1.3"] -[metosin/reitit-swagger "0.1.3"] -[metosin/reitit-swagger-ui "0.1.3"] +[metosin/reitit-core "0.1.4-SNAPSHOT"] +[metosin/reitit-ring "0.1.4-SNAPSHOT"] +[metosin/reitit-spec "0.1.4-SNAPSHOT"] +[metosin/reitit-schema "0.1.4-SNAPSHOT"] +[metosin/reitit-swagger "0.1.4-SNAPSHOT"] +[metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"] ``` ## Quick start diff --git a/doc/README.md b/doc/README.md index 2d3079ff..d1fb5318 100644 --- a/doc/README.md +++ b/doc/README.md @@ -10,6 +10,7 @@ * Extendable * Modular * [Fast](performance.md) +* [Frontend routing](./frontend/README.md) Modules: @@ -19,22 +20,24 @@ Modules: * `reitit-schema` [Schema](https://github.com/plumatic/schema) coercion * `reitit-swagger` [Swagger2](https://swagger.io/) apidocs * `reitit-swagger-ui` Integrated [Swagger UI](https://github.com/swagger-api/swagger-ui). +* `reitit-frontend` Tools for frontend routing. -To use Reitit, add the following dependecy to your project: +To use Reitit, add the following dependency to your project: ```clj -[metosin/reitit "0.1.3"] +[metosin/reitit "0.1.4-SNAPSHOT"] ``` Optionally, the parts can be required separately: ```clj -[metosin/reitit-core "0.1.3"] -[metosin/reitit-ring "0.1.3"] -[metosin/reitit-spec "0.1.3"] -[metosin/reitit-schema "0.1.3"] -[metosin/reitit-swagger "0.1.3"] -[metosin/reitit-swagger-ui "0.1.3"] +[metosin/reitit-core "0.1.4-SNAPSHOT"] +[metosin/reitit-ring "0.1.4-SNAPSHOT"] +[metosin/reitit-spec "0.1.4-SNAPSHOT"] +[metosin/reitit-schema "0.1.4-SNAPSHOT"] +[metosin/reitit-swagger "0.1.4-SNAPSHOT"] +[metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"] +[metosin/frontend "0.1.4-SNAPSHOT"] ``` For discussions, there is a [#reitit](https://clojurians.slack.com/messages/reitit/) channel in [Clojurians slack](http://clojurians.net/). diff --git a/doc/cljdoc.edn b/doc/cljdoc.edn index 819e60a8..ffc68d25 100644 --- a/doc/cljdoc.edn +++ b/doc/cljdoc.edn @@ -45,6 +45,11 @@ {:file "doc/ring/route_data_validation.md"}] ["Compiling Middleware" {:file "doc/ring/compiling_middleware.md"}] ["Swagger Support" {:file "doc/ring/swagger.md"}]] + ["Frontend" + {:file "doc/frontend/README.md"} + ["Basics" {:file "doc/frontend/basics.md"}] + ["Browser integration" {:file "doc/frontend/browser.md"}] + ["Controllers" {:file "doc/frontend/controllers/.md"}]] ["Performance" {:file "doc/performance.md"}] ["Interceptors (WIP)" {:file "doc/interceptors.md"}] ["Development Instructions" {:file "doc/development.md"}] diff --git a/doc/frontend/README.md b/doc/frontend/README.md new file mode 100644 index 00000000..65b78197 --- /dev/null +++ b/doc/frontend/README.md @@ -0,0 +1,5 @@ +# Frontend + +* [Basics](basics.md) +* [Browser integration](browser.md) +* [Controllers](controllers.md) diff --git a/doc/frontend/basics.md b/doc/frontend/basics.md new file mode 100644 index 00000000..b0d632fb --- /dev/null +++ b/doc/frontend/basics.md @@ -0,0 +1,3 @@ +# Frontend basics + +TODO diff --git a/doc/frontend/browser.md b/doc/frontend/browser.md new file mode 100644 index 00000000..a7a8c613 --- /dev/null +++ b/doc/frontend/browser.md @@ -0,0 +1,3 @@ +# Frontend browser integration + +TODO diff --git a/doc/frontend/controllers.md b/doc/frontend/controllers.md new file mode 100644 index 00000000..11f9f263 --- /dev/null +++ b/doc/frontend/controllers.md @@ -0,0 +1,3 @@ +# Controllers + +TODO diff --git a/doc/ring/ring.md b/doc/ring/ring.md index b792c41c..1b916fd2 100644 --- a/doc/ring/ring.md +++ b/doc/ring/ring.md @@ -3,7 +3,7 @@ [Ring](https://github.com/ring-clojure/ring) is a Clojure web applications library inspired by Python's WSGI and Ruby's Rack. By abstracting the details of HTTP into a simple, unified API, Ring allows web applications to be constructed of modular components that can be shared among a variety of applications, web servers, and web frameworks. ```clj -[metosin/reitit-ring "0.1.3"] +[metosin/reitit-ring "0.1.4-SNAPSHOT"] ``` Ring-router adds support for [handlers](https://github.com/ring-clojure/ring/wiki/Concepts#handlers), [middleware](https://github.com/ring-clojure/ring/wiki/Concepts#middleware) and routing based on `:request-method`. Ring-router is created with `reitit.ring/router` function. It uses a custom route compiler, creating a optimized data structure for handling route matches, with compiled middleware chain & handlers for all request methods. It also ensures that all routes have a `:handler` defined. `reitit.ring/ring-handler` is used to create a Ring handler out of ring-router. diff --git a/doc/ring/swagger.md b/doc/ring/swagger.md index b3de46bf..243f4dc3 100644 --- a/doc/ring/swagger.md +++ b/doc/ring/swagger.md @@ -1,7 +1,7 @@ # Swagger Support ``` -[metosin/reitit-swagger "0.1.3"] +[metosin/reitit-swagger "0.1.4-SNAPSHOT"] ``` Reitit supports [Swagger2](https://swagger.io/) documentation, thanks to [schema-tools](https://github.com/metosin/schema-tools) and [spec-tools](https://github.com/metosin/spec-tools). Documentation is extracted from route definitions, coercion `:parameters` and `:responses` and from a set of new documentation keys. @@ -44,7 +44,7 @@ If you need to post-process the generated spec, just wrap the handler with a cus [Swagger-ui](https://github.com/swagger-api/swagger-ui) is a user interface to visualize and interact with the Swagger specification. To make things easy, there is a pre-integrated version of the swagger-ui as a separate module. ``` -[metosin/reitit-swagger-ui "0.1.3"] +[metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"] ``` `reitit.swagger-ui/create-swagger-ui-hander` can be used to create a ring-handler to serve the swagger-ui. It accepts the following options: diff --git a/examples/frontend-controllers/checkouts/reitit-core b/examples/frontend-controllers/checkouts/reitit-core new file mode 120000 index 00000000..a59d247e --- /dev/null +++ b/examples/frontend-controllers/checkouts/reitit-core @@ -0,0 +1 @@ +../../../modules/reitit-core \ No newline at end of file diff --git a/examples/frontend-controllers/checkouts/reitit-frontend b/examples/frontend-controllers/checkouts/reitit-frontend new file mode 120000 index 00000000..20cdd448 --- /dev/null +++ b/examples/frontend-controllers/checkouts/reitit-frontend @@ -0,0 +1 @@ +../../../modules/reitit-frontend \ No newline at end of file diff --git a/examples/frontend-controllers/checkouts/reitit-schema b/examples/frontend-controllers/checkouts/reitit-schema new file mode 120000 index 00000000..a68c7f05 --- /dev/null +++ b/examples/frontend-controllers/checkouts/reitit-schema @@ -0,0 +1 @@ +../../../modules/reitit-schema \ No newline at end of file diff --git a/examples/frontend-controllers/project.clj b/examples/frontend-controllers/project.clj new file mode 100644 index 00000000..c040a046 --- /dev/null +++ b/examples/frontend-controllers/project.clj @@ -0,0 +1,52 @@ +(defproject frontend "0.1.0-SNAPSHOT" + :description "FIXME: write description" + :url "http://example.com/FIXME" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + + :dependencies [[org.clojure/clojure "1.9.0"] + [ring-server "0.5.0"] + [reagent "0.8.1"] + [ring "1.6.3"] + [compojure "1.6.1"] + [hiccup "1.0.5"] + [org.clojure/clojurescript "1.10.339"] + [metosin/reitit "0.1.4-SNAPSHOT"] + [metosin/reitit-schema "0.1.4-SNAPSHOT"] + [metosin/reitit-frontend "0.1.4-SNAPSHOT"] + ;; Just for pretty printting the match + [fipp "0.6.12"]] + + :plugins [[lein-cljsbuild "1.1.7"] + [lein-figwheel "0.5.16"]] + + :source-paths [] + :resource-paths ["resources" "target/cljsbuild"] + + :profiles {:dev {:dependencies [[binaryage/devtools "0.9.10"]]}} + + :cljsbuild + {:builds + [{:id "app" + :figwheel true + :source-paths ["src"] + :watch-paths ["src" "checkouts/reitit-frontend/src"] + :compiler {:main "frontend.core" + :asset-path "/js/out" + :output-to "target/cljsbuild/public/js/app.js" + :output-dir "target/cljsbuild/public/js/out" + :source-map true + :optimizations :none + :pretty-print true + :preloads [devtools.preload]}} + {:id "min" + :source-paths ["src"] + :compiler {:output-to "target/cljsbuild/public/js/app.js" + :output-dir "target/cljsbuild/public/js" + :source-map "target/cljsbuild/public/js/app.js.map" + :optimizations :advanced + :pretty-print false}}]} + + :figwheel {:http-server-root "public" + :server-port 3449 + :nrepl-port 7002}) diff --git a/examples/frontend-controllers/resources/public/index.html b/examples/frontend-controllers/resources/public/index.html new file mode 100644 index 00000000..bbad514c --- /dev/null +++ b/examples/frontend-controllers/resources/public/index.html @@ -0,0 +1,10 @@ + + + + Reitit frontend example + + +
+ + + diff --git a/examples/frontend-controllers/src/frontend/core.cljs b/examples/frontend-controllers/src/frontend/core.cljs new file mode 100644 index 00000000..960867a5 --- /dev/null +++ b/examples/frontend-controllers/src/frontend/core.cljs @@ -0,0 +1,95 @@ +(ns frontend.core + (:require [reagent.core :as r] + [reitit.core :as re] + [reitit.frontend :as rf] + [reitit.frontend.history :as rfh] + [reitit.frontend.controllers :as rfc] + [reitit.coercion :as rc] + [reitit.coercion.schema :as rsc] + [schema.core :as s] + [fipp.edn :as fedn])) + +(defonce history (atom nil)) + +(defn home-page [] + [:div + [:h2 "Welcome to frontend"] + [:p "Look at console log for controller calls."]]) + +(defn item-page [match] + (let [{:keys [path query]} (:parameters match) + {:keys [id]} path] + [:div + [:ul + [:li [:a {:href (rfh/href @history ::item {:id 1})} "Item 1"]] + [:li [:a {:href (rfh/href @history ::item {:id 2} {:foo "bar"})} "Item 2"]]] + (if id + [:h2 "Selected item " id]) + (if (:foo query) + [:p "Optional foo query param: " (:foo query)])])) + +(defonce match (r/atom nil)) + +(defn current-page [] + [:div + [:ul + [:li [:a {:href (rfh/href @history ::frontpage)} "Frontpage"]] + [:li + [:a {:href (rfh/href @history ::item-list)} "Item list"] + ]] + (if @match + (let [view (:view (:data @match))] + [view @match])) + [:pre (with-out-str (fedn/pprint @match))]]) + +(defn log-fn [& params] + (fn [_] + (apply js/console.log params))) + +(def routes + (re/router + ["/" + ["" + {:name ::frontpage + :view home-page + :controllers [{:start (log-fn "start" "frontpage controller") + :stop (log-fn "stop" "frontpage controller")}]}] + ["items" + ;; Shared data for sub-routes + {:view item-page + :controllers [{:start (log-fn "start" "items controller") + :stop (log-fn "stop" "items controller")}]} + + ["" + {:name ::item-list + :controllers [{:start (log-fn "start" "item-list controller") + :stop (log-fn "stop" "item-list controller")}]}] + ["/:id" + {:name ::item + :parameters {:path {:id s/Int} + :query {(s/optional-key :foo) s/Keyword}} + :controllers [{:params (fn [match] + (:path (:parameters match))) + :start (fn [params] + (js/console.log "start" "item controller" (:id params))) + :stop (fn [params] + (js/console.log "stop" "item controller" (:id params)))}]}]]] + {:compile rc/compile-request-coercers + :data {:controllers [{:start (log-fn "start" "root-controller") + :stop (log-fn "stop" "root controller")}] + :coercion rsc/coercion}})) + +(defn init! [] + (swap! history (fn [old-history] + (rfh/stop! old-history) + (rfh/start! + routes + (fn [new-match] + (swap! match (fn [old-match] + (if new-match + (assoc new-match :controllers (rfc/apply-controllers (:controllers old-match) new-match)))))) + {:use-fragment true + :path-prefix "/"}))) + (r/render [current-page] (.getElementById js/document "app"))) + +(init!) diff --git a/examples/frontend/checkouts/reitit-core b/examples/frontend/checkouts/reitit-core new file mode 120000 index 00000000..a59d247e --- /dev/null +++ b/examples/frontend/checkouts/reitit-core @@ -0,0 +1 @@ +../../../modules/reitit-core \ No newline at end of file diff --git a/examples/frontend/checkouts/reitit-frontend b/examples/frontend/checkouts/reitit-frontend new file mode 120000 index 00000000..20cdd448 --- /dev/null +++ b/examples/frontend/checkouts/reitit-frontend @@ -0,0 +1 @@ +../../../modules/reitit-frontend \ No newline at end of file diff --git a/examples/frontend/checkouts/reitit-schema b/examples/frontend/checkouts/reitit-schema new file mode 120000 index 00000000..a68c7f05 --- /dev/null +++ b/examples/frontend/checkouts/reitit-schema @@ -0,0 +1 @@ +../../../modules/reitit-schema \ No newline at end of file diff --git a/examples/frontend/project.clj b/examples/frontend/project.clj new file mode 100644 index 00000000..c040a046 --- /dev/null +++ b/examples/frontend/project.clj @@ -0,0 +1,52 @@ +(defproject frontend "0.1.0-SNAPSHOT" + :description "FIXME: write description" + :url "http://example.com/FIXME" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + + :dependencies [[org.clojure/clojure "1.9.0"] + [ring-server "0.5.0"] + [reagent "0.8.1"] + [ring "1.6.3"] + [compojure "1.6.1"] + [hiccup "1.0.5"] + [org.clojure/clojurescript "1.10.339"] + [metosin/reitit "0.1.4-SNAPSHOT"] + [metosin/reitit-schema "0.1.4-SNAPSHOT"] + [metosin/reitit-frontend "0.1.4-SNAPSHOT"] + ;; Just for pretty printting the match + [fipp "0.6.12"]] + + :plugins [[lein-cljsbuild "1.1.7"] + [lein-figwheel "0.5.16"]] + + :source-paths [] + :resource-paths ["resources" "target/cljsbuild"] + + :profiles {:dev {:dependencies [[binaryage/devtools "0.9.10"]]}} + + :cljsbuild + {:builds + [{:id "app" + :figwheel true + :source-paths ["src"] + :watch-paths ["src" "checkouts/reitit-frontend/src"] + :compiler {:main "frontend.core" + :asset-path "/js/out" + :output-to "target/cljsbuild/public/js/app.js" + :output-dir "target/cljsbuild/public/js/out" + :source-map true + :optimizations :none + :pretty-print true + :preloads [devtools.preload]}} + {:id "min" + :source-paths ["src"] + :compiler {:output-to "target/cljsbuild/public/js/app.js" + :output-dir "target/cljsbuild/public/js" + :source-map "target/cljsbuild/public/js/app.js.map" + :optimizations :advanced + :pretty-print false}}]} + + :figwheel {:http-server-root "public" + :server-port 3449 + :nrepl-port 7002}) diff --git a/examples/frontend/resources/public/index.html b/examples/frontend/resources/public/index.html new file mode 100644 index 00000000..bbad514c --- /dev/null +++ b/examples/frontend/resources/public/index.html @@ -0,0 +1,10 @@ + + + + Reitit frontend example + + +
+ + + diff --git a/examples/frontend/src/frontend/core.cljs b/examples/frontend/src/frontend/core.cljs new file mode 100644 index 00000000..f8c4f890 --- /dev/null +++ b/examples/frontend/src/frontend/core.cljs @@ -0,0 +1,73 @@ +(ns frontend.core + (:require [reagent.core :as r] + [reitit.core :as re] + [reitit.frontend :as rf] + [reitit.frontend.history :as rfh] + [reitit.coercion :as rc] + [reitit.coercion.schema :as rsc] + [schema.core :as s] + [fipp.edn :as fedn])) + +(defonce history (atom nil)) + +(defn home-page [] + [:div + [:h2 "Welcome to frontend"]]) + +(defn about-page [] + [:div + [:h2 "About frontend"] + [:ul + [:li [:a {:href "http://google.com"} "external link"]] + [:li [:a {:href (rfh/href @history ::foobar)} "Missing route"]] + [:li [:a {:href (rfh/href @history ::item)} "Missing route params"]]]]) + +(defn item-page [match] + (let [{:keys [path query]} (:parameters match) + {:keys [id]} path] + [:div + [:h2 "Selected item " id] + (if (:foo query) + [:p "Optional foo query param: " (:foo query)])])) + +(defonce match (r/atom nil)) + +(defn current-page [] + [:div + [:ul + [:li [:a {:href (rfh/href @history ::frontpage)} "Frontpage"]] + [:li [:a {:href (rfh/href @history ::about)} "About"]] + [:li [:a {:href (rfh/href @history ::item {:id 1})} "Item 1"]] + [:li [:a {:href (rfh/href @history ::item {:id 2} {:foo "bar"})} "Item 2"]]] + (if @match + (let [view (:view (:data @match))] + [view @match])) + [:pre (with-out-str (fedn/pprint @match))]]) + +(def routes + (re/router + ["/" + ["" + {:name ::frontpage + :view home-page}] + ["about" + {:name ::about + :view about-page}] + ["item/:id" + {:name ::item + :view item-page + :parameters {:path {:id s/Int} + :query {(s/optional-key :foo) s/Keyword}}}]] + {:compile rc/compile-request-coercers + :data {:coercion rsc/coercion}})) + +(defn init! [] + (swap! history (fn [old-history] + (rfh/stop! old-history) + (rfh/start! routes + (fn [m] (reset! match m)) + {:use-fragment true + :path-prefix "/"}))) + (r/render [current-page] (.getElementById js/document "app"))) + +(init!) diff --git a/examples/just-coercion-with-ring/project.clj b/examples/just-coercion-with-ring/project.clj index 3801bb08..a1b18270 100644 --- a/examples/just-coercion-with-ring/project.clj +++ b/examples/just-coercion-with-ring/project.clj @@ -3,4 +3,4 @@ :dependencies [[org.clojure/clojure "1.9.0"] [ring "1.6.3"] [metosin/muuntaja "0.4.1"] - [metosin/reitit "0.1.3"]]) + [metosin/reitit "0.1.4-SNAPSHOT"]]) diff --git a/examples/ring-example/project.clj b/examples/ring-example/project.clj index 95b9975e..25091829 100644 --- a/examples/ring-example/project.clj +++ b/examples/ring-example/project.clj @@ -3,4 +3,4 @@ :dependencies [[org.clojure/clojure "1.9.0"] [ring "1.6.3"] [metosin/muuntaja "0.4.1"] - [metosin/reitit "0.1.3"]]) + [metosin/reitit "0.1.4-SNAPSHOT"]]) diff --git a/examples/ring-swagger/project.clj b/examples/ring-swagger/project.clj index bea72569..76887394 100644 --- a/examples/ring-swagger/project.clj +++ b/examples/ring-swagger/project.clj @@ -3,5 +3,5 @@ :dependencies [[org.clojure/clojure "1.9.0"] [ring "1.6.3"] [metosin/muuntaja "0.5.0"] - [metosin/reitit "0.1.3"]] + [metosin/reitit "0.1.4-SNAPSHOT"]] :repl-options {:init-ns example.server}) diff --git a/modules/reitit-core/project.clj b/modules/reitit-core/project.clj index f34eb15b..fe168dc8 100644 --- a/modules/reitit-core/project.clj +++ b/modules/reitit-core/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-core "0.1.3" +(defproject metosin/reitit-core "0.1.4-SNAPSHOT" :description "Snappy data-driven router for Clojure(Script)" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit-core/src/reitit/impl.cljc b/modules/reitit-core/src/reitit/impl.cljc index 003f2870..c5fca01e 100644 --- a/modules/reitit-core/src/reitit/impl.cljc +++ b/modules/reitit-core/src/reitit/impl.cljc @@ -131,7 +131,10 @@ (defn path-for [^Route route path-params] (if-let [required (:path-params route)] (if (every? #(contains? path-params %) required) - (str "/" (str/join \/ (map #(get (or path-params {}) % %) (:path-parts route))))) + (->> (:path-parts route) + (map #(get (or path-params {}) % %)) + (str/join \/) + (str "/"))) (:path route))) (defn throw-on-missing-path-params [template required path-params] @@ -214,5 +217,8 @@ "shallow transform of query parameters into query string" [params] (->> params - (map (fn [[k v]] (str (url-encode (into-string k)) "=" (url-encode (into-string v))))) + (map (fn [[k v]] + (str (url-encode (into-string k)) + "=" + (url-encode (into-string v))))) (str/join "&"))) diff --git a/modules/reitit-frontend/project.clj b/modules/reitit-frontend/project.clj new file mode 100644 index 00000000..2d5b0034 --- /dev/null +++ b/modules/reitit-frontend/project.clj @@ -0,0 +1,9 @@ +(defproject metosin/reitit-frontend "0.1.4-SNAPSHOT" + :description "Reitit: Clojurescript frontend routing core" + :url "https://github.com/metosin/reitit" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :plugins [[lein-parent "0.3.2"]] + :parent-project {:path "../../project.clj" + :inherit [:deploy-repositories :managed-dependencies]} + :dependencies [[metosin/reitit-core]]) diff --git a/modules/reitit-frontend/src/reitit/frontend.cljs b/modules/reitit-frontend/src/reitit/frontend.cljs new file mode 100644 index 00000000..6cf14ed7 --- /dev/null +++ b/modules/reitit-frontend/src/reitit/frontend.cljs @@ -0,0 +1,63 @@ +(ns reitit.frontend + "" + (:require [reitit.core :as reitit] + [clojure.string :as str] + [clojure.set :as set] + [reitit.coercion :as coercion] + [goog.events :as e] + [goog.dom :as dom]) + (:import goog.Uri)) + +(defn query-params + "Given goog.Uri, read query parameters into Clojure map." + [^goog.Uri uri] + (let [q (.getQueryData uri)] + (->> q + (.getKeys) + (map (juxt keyword #(.get q %))) + (into {})))) + +(defn match-by-path + "Given routing tree and current path, return match with possibly + coerced parameters. Return nil if no match found." + [router path] + (let [uri (.parse Uri path)] + (if-let [match (reitit/match-by-path router (.getPath uri))] + (let [q (query-params uri) + ;; Return uncoerced values if coercion is not enabled - so + ;; that tha parameters are always accessible from same property. + ;; FIXME: coerce! can't be used as it doesn't take query-params + parameters (if (:result match) + (coercion/coerce-request (:result match) {:query-params q + :path-params (:path-params match)}) + {:query q + :path (:path-params match)})] + (assoc match :parameters parameters))))) + +(defn match-by-name + ([router name] + (match-by-name router name {})) + ([router name path-params] + (reitit/match-by-name router name path-params))) + +(defn match-by-name! + "Logs problems using console.warn" + ([router name] + (match-by-name! router name {})) + ([router name path-params] + (if-let [match (match-by-name router name path-params)] + (if (reitit/partial-match? match) + (if (every? #(contains? path-params %) (:required match)) + match + (let [defined (-> path-params keys set) + missing (set/difference (:required match) defined)] + (js/console.warn + "missing path-params for route" name + {:template (:template match) + :missing missing + :path-params path-params + :required (:required match)}) + nil)) + match) + (do (js/console.warn "missing route" name) + nil)))) diff --git a/modules/reitit-frontend/src/reitit/frontend/controllers.cljs b/modules/reitit-frontend/src/reitit/frontend/controllers.cljs new file mode 100644 index 00000000..8556314d --- /dev/null +++ b/modules/reitit-frontend/src/reitit/frontend/controllers.cljs @@ -0,0 +1,40 @@ +(ns reitit.frontend.controllers) + +(defn- pad-same-length [a b] + (concat a (take (- (count b) (count a)) (repeat nil)))) + +(defn get-params + "Get controller parameters given match. If controller provides :params + function that will be called with the match. Default is nil." + [controller match] + (if-let [f (:params controller)] + (f match))) + +(defn apply-controller + "Run side-effects (:start or :stop) for controller. + The side-effect function is called with controller params." + [controller method] + (when-let [f (get controller method)] + (f (::params controller)))) + +(defn apply-controllers + "Applies changes between current controllers and + those previously enabled. Resets controllers whose + parameters have changed." + [old-controllers new-match] + (let [new-controllers (mapv (fn [controller] + (assoc controller ::params (get-params controller new-match))) + (:controllers (:data new-match))) + changed-controllers (->> (map (fn [old new] + ;; different controllers, or params changed + (if (not= old new) + {:old old, :new new})) + (pad-same-length old-controllers new-controllers) + (pad-same-length new-controllers old-controllers)) + (keep identity) + vec)] + (doseq [controller (map :old changed-controllers)] + (apply-controller controller :stop)) + (doseq [controller (map :new changed-controllers)] + (apply-controller controller :start)) + new-controllers)) diff --git a/modules/reitit-frontend/src/reitit/frontend/history.cljs b/modules/reitit-frontend/src/reitit/frontend/history.cljs new file mode 100644 index 00000000..7e178723 --- /dev/null +++ b/modules/reitit-frontend/src/reitit/frontend/history.cljs @@ -0,0 +1,127 @@ +(ns reitit.frontend.history + "" + (:require [reitit.core :as reitit] + [clojure.string :as string] + [goog.events :as e] + [goog.dom :as dom] + [reitit.core :as r] + [reitit.frontend :as rf]) + (:import goog.history.Html5History + goog.Uri)) + +;; Token is for Closure HtmlHistory +;; Path is for reitit + +(defn- token->path [history token] + (if (.-useFragment_ history) + ;; If no fragment at all, default to "/" + ;; If fragment is present, the token already is prefixed with "/" + (if (= "" token) + (.getPathPrefix history) + token) + (str (.getPathPrefix history) token))) + +(defn- path->token [history path] + (subs path (if (.-useFragment_ history) + 1 + (count (.getPathPrefix history))))) + +(defn- token->href [history token] + (if token + (str (if (.-useFragment_ history) + (str "#")) + (.getPathPrefix history) + token))) + +(def ^:private current-domain (if (exists? js/location) + (.getDomain (.parse Uri js/location)))) + +(defn ignore-anchor-click + "Ignore click events from a elements, if the href points to URL that is part + of the routing tree." + [router history e] + ;; Returns the next matching anchestor of event target + (when-let [el (.closest (.-target e) "a")] + (let [uri (.parse Uri (.-href el))] + (when (and (or (and (not (.hasScheme uri)) (not (.hasDomain uri))) + (= current-domain (.getDomain uri))) + (not (.-altKey e)) + (not (.-ctrlKey e)) + (not (.-metaKey e)) + (not (.-shiftKey e)) + (not (contains? #{"_blank" "self"} (.getAttribute el "target"))) + ;; Left button + (= 0 (.-button e)) + (reitit/match-by-path router (.getPath uri))) + (.preventDefault e) + (.replaceToken history (path->token history (.getPath uri))))))) + +(defn start! + "This registers event listeners on either haschange or HTML5 history. + When using with development workflow like Figwheel, rememeber to + remove listeners using stop! call before calling start! again. + + Parameters: + - router The reitit routing tree. + - on-navigate Function to be called when route changes. + + Options: + - :use-fragment (default true) If true, onhashchange and location hash are used to store the token. + - :path-prefix (default \"/\") If :use-fragment is false, this is prepended to all tokens, and is + removed from start of the token before matching the route." + [router + on-navigate + {:keys [path-prefix use-fragment] + :or {path-prefix "/" + use-fragment true}}] + (let [history + (doto (Html5History.) + (.setEnabled true) + (.setPathPrefix path-prefix) + (.setUseFragment use-fragment)) + + event-key + (e/listen history goog.history.EventType.NAVIGATE + (fn [e] + (on-navigate (rf/match-by-path router (token->path history (.getToken history)))))) + + click-listen-key + (if-not use-fragment + (e/listen js/document e/EventType.CLICK + (partial ignore-anchor-click router history)))] + + ;; Trigger navigate event for current route + (on-navigate (rf/match-by-path router (token->path history (.getToken history)))) + + {:router router + :history history + :close-fn (fn [] + (e/unlistenByKey event-key) + (e/unlistenByKey click-listen-key) + (.dispose history))})) + +(defn stop! [{:keys [close-fn]}] + (if close-fn + (close-fn))) + +(defn- match->token [history match k params query] + (some->> (r/match->path match query) + (path->token history))) + +(defn href + ([state k] + (href state k nil)) + ([state k params] + (href state k params nil)) + ([{:keys [router history]} k params query] + (let [match (rf/match-by-name! router k params) + token (match->token history match k params query)] + (token->href history token)))) + +(defn replace-token + ([state k params] + (replace-token state k params nil)) + ([{:keys [router history]} k params query] + (let [match (rf/match-by-name! router k params) + token (match->token history match k params query)] + (.replaceToken history token)))) diff --git a/modules/reitit-ring/project.clj b/modules/reitit-ring/project.clj index b933437c..ee10b9c1 100644 --- a/modules/reitit-ring/project.clj +++ b/modules/reitit-ring/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-ring "0.1.3" +(defproject metosin/reitit-ring "0.1.4-SNAPSHOT" :description "Reitit: Ring routing" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit-schema/project.clj b/modules/reitit-schema/project.clj index fe554e2d..61652645 100644 --- a/modules/reitit-schema/project.clj +++ b/modules/reitit-schema/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-schema "0.1.3" +(defproject metosin/reitit-schema "0.1.4-SNAPSHOT" :description "Reitit: Plumatic Schema coercion" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit-spec/project.clj b/modules/reitit-spec/project.clj index 0d1454b7..eeb7e735 100644 --- a/modules/reitit-spec/project.clj +++ b/modules/reitit-spec/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-spec "0.1.3" +(defproject metosin/reitit-spec "0.1.4-SNAPSHOT" :description "Reitit: clojure.spec coercion" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit-swagger-ui/project.clj b/modules/reitit-swagger-ui/project.clj index 39dffa03..b9903351 100644 --- a/modules/reitit-swagger-ui/project.clj +++ b/modules/reitit-swagger-ui/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-swagger-ui "0.1.3" +(defproject metosin/reitit-swagger-ui "0.1.4-SNAPSHOT" :description "Reitit: Swagger-ui support" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit-swagger/project.clj b/modules/reitit-swagger/project.clj index 3677678a..6522fac6 100644 --- a/modules/reitit-swagger/project.clj +++ b/modules/reitit-swagger/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-swagger "0.1.3" +(defproject metosin/reitit-swagger "0.1.4-SNAPSHOT" :description "Reitit: Swagger-support" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" diff --git a/modules/reitit/project.clj b/modules/reitit/project.clj index 391b1dd2..176b6f89 100644 --- a/modules/reitit/project.clj +++ b/modules/reitit/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit "0.1.3" +(defproject metosin/reitit "0.1.4-SNAPSHOT" :description "Snappy data-driven router for Clojure(Script)" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" @@ -11,4 +11,5 @@ [metosin/reitit-spec] [metosin/reitit-schema] [metosin/reitit-swagger] - [metosin/reitit-swagger-ui]]) + [metosin/reitit-swagger-ui] + [metosin/reitit-frontend]]) diff --git a/project.clj b/project.clj index 905bd954..783f9a81 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject metosin/reitit-parent "0.1.3" +(defproject metosin/reitit-parent "0.1.4-SNAPSHOT" :description "Snappy data-driven router for Clojure(Script)" :url "https://github.com/metosin/reitit" :license {:name "Eclipse Public License" @@ -9,13 +9,14 @@ :source-uri "https://github.com/metosin/reitit/{version}/{filepath}#L{line}" :metadata {:doc/format :markdown}} - :managed-dependencies [[metosin/reitit "0.1.3"] - [metosin/reitit-core "0.1.3"] - [metosin/reitit-ring "0.1.3"] - [metosin/reitit-spec "0.1.3"] - [metosin/reitit-schema "0.1.3"] - [metosin/reitit-swagger "0.1.3"] - [metosin/reitit-swagger-ui "0.1.3"] + :managed-dependencies [[metosin/reitit "0.1.4-SNAPSHOT"] + [metosin/reitit-core "0.1.4-SNAPSHOT"] + [metosin/reitit-ring "0.1.4-SNAPSHOT"] + [metosin/reitit-spec "0.1.4-SNAPSHOT"] + [metosin/reitit-schema "0.1.4-SNAPSHOT"] + [metosin/reitit-swagger "0.1.4-SNAPSHOT"] + [metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"] + [metosin/reitit-frontend "0.1.4-SNAPSHOT"] [meta-merge "1.0.0"] [ring/ring-core "1.6.3"] @@ -40,10 +41,11 @@ "modules/reitit-spec/src" "modules/reitit-schema/src" "modules/reitit-swagger/src" - "modules/reitit-swagger-ui/src"] + "modules/reitit-swagger-ui/src" + "modules/reitit-frontend/src"] :dependencies [[org.clojure/clojure "1.9.0"] - [org.clojure/clojurescript "1.10.238"] + [org.clojure/clojurescript "1.10.339"] ;; modules dependencies [metosin/reitit] @@ -60,7 +62,10 @@ [criterium "0.4.4"] [org.clojure/test.check "0.9.0"] [org.clojure/tools.namespace "0.2.11"] - [com.gfredericks/test.chuck "0.2.9"]]} + [com.gfredericks/test.chuck "0.2.9"] + + ;; https://github.com/bensu/doo/issues/180 + [fipp "0.6.12"]]} :perf {:jvm-opts ^:replace ["-server" "-Xmx4096m" "-Dclojure.compiler.direct-linking=true"] diff --git a/scripts/lein-modules b/scripts/lein-modules index daf815fe..fc3870d8 100755 --- a/scripts/lein-modules +++ b/scripts/lein-modules @@ -3,6 +3,6 @@ set -e # Modules -for ext in reitit-core reitit-ring reitit-spec reitit-schema reitit-swagger reitit-swagger-ui reitit; do +for ext in reitit-core reitit-ring reitit-spec reitit-schema reitit-swagger reitit-swagger-ui reitit-frontend reitit; do cd modules/$ext; lein "$@"; cd ../..; done diff --git a/test/cljs/reitit/doo_runner.cljs b/test/cljs/reitit/doo_runner.cljs index 57b7dc2b..3fbc392b 100644 --- a/test/cljs/reitit/doo_runner.cljs +++ b/test/cljs/reitit/doo_runner.cljs @@ -5,7 +5,10 @@ reitit.impl-test reitit.middleware-test reitit.ring-test - #_reitit.spec-test)) + #_reitit.spec-test + reitit.frontend.core-test + reitit.frontend.history-test + reitit.frontend.controllers-test)) (enable-console-print!) @@ -14,4 +17,7 @@ 'reitit.impl-test 'reitit.middleware-test 'reitit.ring-test - #_'reitit.spec-test) + #_'reitit.spec-test + 'reitit.frontend.core-test + 'reitit.frontend.history-test + 'reitit.frontend.controllers-test) diff --git a/test/cljs/reitit/frontend/controllers_test.cljs b/test/cljs/reitit/frontend/controllers_test.cljs new file mode 100644 index 00000000..b4d2238c --- /dev/null +++ b/test/cljs/reitit/frontend/controllers_test.cljs @@ -0,0 +1,68 @@ +(ns reitit.frontend.controllers-test + (:require [clojure.test :refer [deftest testing is are]] + [reitit.frontend.controllers :as rfc])) + +(deftest apply-controller-test + (is (= :ok (rfc/apply-controller {:stop (fn [_] :ok)} :stop))) + (is (= :ok (rfc/apply-controller {:start (fn [_] :ok)} :start)))) + +(deftest apply-controllers-test + (let [log (atom []) + controller-state (atom []) + controller-1 {:start (fn [_] (swap! log conj :start-1)) + :stop (fn [_] (swap! log conj :stop-1))} + controller-2 {:start (fn [_] (swap! log conj :start-2)) + :stop (fn [_] (swap! log conj :stop-2))} + controller-3 {:start (fn [{:keys [foo]}] (swap! log conj [:start-3 foo])) + :stop (fn [{:keys [foo]}] (swap! log conj [:stop-3 foo])) + :params (fn [match] + {:foo (-> match :parameters :path :foo)})}] + + (testing "single controller started" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [controller-1]}}) + + (is (= [:start-1] @log)) + (is (= [(assoc controller-1 ::rfc/params nil)] @controller-state)) + (reset! log [])) + + (testing "second controller started" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [controller-1 controller-2]}}) + + (is (= [:start-2] @log)) + (is (= [(assoc controller-1 ::rfc/params nil) + (assoc controller-2 ::rfc/params nil)] + @controller-state)) + (reset! log [])) + + (testing "second controller replaced" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [controller-1 controller-3]} + :parameters {:path {:foo 5}}}) + + (is (= [:stop-2 [:start-3 5]] @log)) + (is (= [(assoc controller-1 ::rfc/params nil) + (assoc controller-3 ::rfc/params {:foo 5})] + @controller-state)) + (reset! log [])) + + (testing "controller parameter changed" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [controller-1 controller-3]} + :parameters {:path {:foo 1}}}) + + (is (= [[:stop-3 5] [:start-3 1]] @log)) + (is (= [(assoc controller-1 ::rfc/params nil) + (assoc controller-3 ::rfc/params {:foo 1})] + @controller-state)) + (reset! log [])) + + (testing "all controllers stopped" + (swap! controller-state rfc/apply-controllers + {:data {:controllers []}}) + + (is (= [:stop-1 [:stop-3 1]] @log)) + (is (= [] @controller-state)) + (reset! log [])) + )) diff --git a/test/cljs/reitit/frontend/core_test.cljs b/test/cljs/reitit/frontend/core_test.cljs new file mode 100644 index 00000000..8a18f4d0 --- /dev/null +++ b/test/cljs/reitit/frontend/core_test.cljs @@ -0,0 +1,89 @@ +(ns reitit.frontend.core-test + (:require [clojure.test :refer [deftest testing is are]] + [reitit.core :as r] + [reitit.frontend :as rf] + [reitit.coercion :as rc] + [schema.core :as s] + [reitit.coercion.schema :as rsc] + [reitit.frontend.test-utils :refer [capture-console]])) + +(defn m [x] + (assoc x :data nil :result nil)) + +(deftest match-by-path-test + (testing "simple" + (let [router (r/router ["/" + ["" ::frontpage] + ["foo" ::foo] + ["bar" ::bar]])] + (is (= (r/map->Match + {:template "/" + :data {:name ::frontpage} + :path-params {} + :path "/" + :parameters {:query {} + :path {}}}) + (rf/match-by-path router "/"))) + + (is (= "/" + (r/match->path (rf/match-by-name router ::frontpage)))) + + (is (= (r/map->Match + {:template "/foo" + :data {:name ::foo} + :path-params {} + :path "/foo" + :parameters {:query {} + :path {}}}) + (rf/match-by-path router "/foo"))) + + (is (= "/foo" + (r/match->path (rf/match-by-name router ::foo)))) + + (is (= [{:type :warn + :message ["missing route" ::asd]}] + (:messages + (capture-console + (fn [] + (rf/match-by-name! router ::asd)))))))) + + (testing "schema coercion" + (let [router (r/router ["/" + [":id" {:name ::foo + :parameters {:path {:id s/Int} + :query {(s/optional-key :mode) s/Keyword}}}]] + {:compile rc/compile-request-coercers + :data {:coercion rsc/coercion}})] + (is (= (r/map->Match + {:template "/:id" + :path-params {:id "5"} + :path "/5" + :parameters {:query {} + :path {:id 5}}}) + (m (rf/match-by-path router "/5")))) + + (is (= "/5" + (r/match->path (rf/match-by-name router ::foo {:id 5})))) + + (is (= (r/map->Match + {:template "/:id" + :path-params {:id "5"} + ;; Note: query not included in path + :path "/5" + :parameters {:path {:id 5} + :query {:mode :foo}}}) + (m (rf/match-by-path router "/5?mode=foo")))) + + (is (= "/5?mode=foo" + (r/match->path (rf/match-by-name router ::foo {:id 5}) {:mode :foo}))) + + (is (= [{:type :warn + :message ["missing path-params for route" ::foo + {:template "/:id" + :missing #{:id} + :required #{:id} + :path-params {}}]}] + (:messages + (capture-console + (fn [] + (rf/match-by-name! router ::foo {}))))))))) diff --git a/test/cljs/reitit/frontend/history_test.cljs b/test/cljs/reitit/frontend/history_test.cljs new file mode 100644 index 00000000..3185b611 --- /dev/null +++ b/test/cljs/reitit/frontend/history_test.cljs @@ -0,0 +1,59 @@ +(ns reitit.frontend.history-test + (:require [clojure.test :refer [deftest testing is are]] + [reitit.core :as r] + [reitit.frontend.history :as rfh] + [reitit.frontend.test-utils :refer [capture-console]])) + +(def browser (exists? js/window)) + +(deftest fragment-history-test + (when browser + (let [router (r/router ["/" + ["" ::frontpage] + ["foo" ::foo] + ["bar/:id" ::bar]]) + history (rfh/start! router + (fn [_]) + {:use-fragment true + :path-prefix "/"})] + + (testing "creating urls" + (is (= "#/foo" + (rfh/href history ::foo))) + (is (= "#/bar/5" + (rfh/href history ::bar {:id 5}))) + (is (= "#/bar/5?q=x" + (rfh/href history ::bar {:id 5} {:q "x"}))) + (let [{:keys [value messages]} (capture-console + (fn [] + (rfh/href history ::asd)))] + (is (= nil value)) + (is (= [{:type :warn + :message ["missing route" ::asd]}] + messages))))))) + +(deftest html5-history-test + (when browser + (let [router (r/router ["/" + ["" ::frontpage] + ["foo" ::foo] + ["bar/:id" ::bar]]) + history (rfh/start! router + (fn [_]) + {:use-fragment false + :path-prefix "/"})] + + (testing "creating urls" + (is (= "/foo" + (rfh/href history ::foo))) + (is (= "/bar/5" + (rfh/href history ::bar {:id 5}))) + (is (= "/bar/5?q=x" + (rfh/href history ::bar {:id 5} {:q "x"}))) + (let [{:keys [value messages]} (capture-console + (fn [] + (rfh/href history ::asd)))] + (is (= nil value)) + (is (= [{:type :warn + :message ["missing route" ::asd]}] + messages))))))) diff --git a/test/cljs/reitit/frontend/test_utils.cljs b/test/cljs/reitit/frontend/test_utils.cljs new file mode 100644 index 00000000..b70c5f37 --- /dev/null +++ b/test/cljs/reitit/frontend/test_utils.cljs @@ -0,0 +1,15 @@ +(ns reitit.frontend.test-utils) + +(defn capture-console [f] + (let [messages (atom []) + original-console-warn js/console.warn + log (fn [t & message] + (swap! messages conj {:type t + :message message})) + value (try + (set! js/console.warn (partial log :warn)) + (f) + (finally + (set! js/console.warn original-console-warn)))] + {:value value + :messages @messages}))