Merge pull request #62 from metosin/frontend-routing

Frontend routing implementation (WIP)
This commit is contained in:
Juho Teperi 2018-07-12 13:13:57 +03:00 committed by GitHub
commit 93d911d60e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 855 additions and 46 deletions

3
.gitignore vendored
View file

@ -1,4 +1,4 @@
/target target/
/classes /classes
/checkouts /checkouts
pom.xml pom.xml
@ -11,3 +11,4 @@ pom.xml.asc
/gh-pages /gh-pages
/node_modules /node_modules
/_book /_book
figwheel_server.log

View file

@ -36,18 +36,18 @@ Bubblin' under:
All bundled: All bundled:
```clj ```clj
[metosin/reitit "0.1.3"] [metosin/reitit "0.1.4-SNAPSHOT"]
``` ```
Optionally, the parts can be required separately: Optionally, the parts can be required separately:
```clj ```clj
[metosin/reitit-core "0.1.3"] [metosin/reitit-core "0.1.4-SNAPSHOT"]
[metosin/reitit-ring "0.1.3"] [metosin/reitit-ring "0.1.4-SNAPSHOT"]
[metosin/reitit-spec "0.1.3"] [metosin/reitit-spec "0.1.4-SNAPSHOT"]
[metosin/reitit-schema "0.1.3"] [metosin/reitit-schema "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger "0.1.3"] [metosin/reitit-swagger "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger-ui "0.1.3"] [metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"]
``` ```
## Quick start ## Quick start

View file

@ -10,6 +10,7 @@
* Extendable * Extendable
* Modular * Modular
* [Fast](performance.md) * [Fast](performance.md)
* [Frontend routing](./frontend/README.md)
Modules: Modules:
@ -19,22 +20,24 @@ Modules:
* `reitit-schema` [Schema](https://github.com/plumatic/schema) coercion * `reitit-schema` [Schema](https://github.com/plumatic/schema) coercion
* `reitit-swagger` [Swagger2](https://swagger.io/) apidocs * `reitit-swagger` [Swagger2](https://swagger.io/) apidocs
* `reitit-swagger-ui` Integrated [Swagger UI](https://github.com/swagger-api/swagger-ui). * `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 ```clj
[metosin/reitit "0.1.3"] [metosin/reitit "0.1.4-SNAPSHOT"]
``` ```
Optionally, the parts can be required separately: Optionally, the parts can be required separately:
```clj ```clj
[metosin/reitit-core "0.1.3"] [metosin/reitit-core "0.1.4-SNAPSHOT"]
[metosin/reitit-ring "0.1.3"] [metosin/reitit-ring "0.1.4-SNAPSHOT"]
[metosin/reitit-spec "0.1.3"] [metosin/reitit-spec "0.1.4-SNAPSHOT"]
[metosin/reitit-schema "0.1.3"] [metosin/reitit-schema "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger "0.1.3"] [metosin/reitit-swagger "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger-ui "0.1.3"] [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/). For discussions, there is a [#reitit](https://clojurians.slack.com/messages/reitit/) channel in [Clojurians slack](http://clojurians.net/).

View file

@ -45,6 +45,11 @@
{:file "doc/ring/route_data_validation.md"}] {:file "doc/ring/route_data_validation.md"}]
["Compiling Middleware" {:file "doc/ring/compiling_middleware.md"}] ["Compiling Middleware" {:file "doc/ring/compiling_middleware.md"}]
["Swagger Support" {:file "doc/ring/swagger.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"}] ["Performance" {:file "doc/performance.md"}]
["Interceptors (WIP)" {:file "doc/interceptors.md"}] ["Interceptors (WIP)" {:file "doc/interceptors.md"}]
["Development Instructions" {:file "doc/development.md"}] ["Development Instructions" {:file "doc/development.md"}]

5
doc/frontend/README.md Normal file
View file

@ -0,0 +1,5 @@
# Frontend
* [Basics](basics.md)
* [Browser integration](browser.md)
* [Controllers](controllers.md)

3
doc/frontend/basics.md Normal file
View file

@ -0,0 +1,3 @@
# Frontend basics
TODO

3
doc/frontend/browser.md Normal file
View file

@ -0,0 +1,3 @@
# Frontend browser integration
TODO

View file

@ -0,0 +1,3 @@
# Controllers
TODO

View file

@ -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. [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 ```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. 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.

View file

@ -1,7 +1,7 @@
# Swagger Support # 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. 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. [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: `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:

View file

@ -0,0 +1 @@
../../../modules/reitit-core

View file

@ -0,0 +1 @@
../../../modules/reitit-frontend

View file

@ -0,0 +1 @@
../../../modules/reitit-schema

View file

@ -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})

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Reitit frontend example</title>
</head>
<body>
<div id="app"></div>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -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!)

View file

@ -0,0 +1 @@
../../../modules/reitit-core

View file

@ -0,0 +1 @@
../../../modules/reitit-frontend

View file

@ -0,0 +1 @@
../../../modules/reitit-schema

View file

@ -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})

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Reitit frontend example</title>
</head>
<body>
<div id="app"></div>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -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!)

View file

@ -3,4 +3,4 @@
:dependencies [[org.clojure/clojure "1.9.0"] :dependencies [[org.clojure/clojure "1.9.0"]
[ring "1.6.3"] [ring "1.6.3"]
[metosin/muuntaja "0.4.1"] [metosin/muuntaja "0.4.1"]
[metosin/reitit "0.1.3"]]) [metosin/reitit "0.1.4-SNAPSHOT"]])

View file

@ -3,4 +3,4 @@
:dependencies [[org.clojure/clojure "1.9.0"] :dependencies [[org.clojure/clojure "1.9.0"]
[ring "1.6.3"] [ring "1.6.3"]
[metosin/muuntaja "0.4.1"] [metosin/muuntaja "0.4.1"]
[metosin/reitit "0.1.3"]]) [metosin/reitit "0.1.4-SNAPSHOT"]])

View file

@ -3,5 +3,5 @@
:dependencies [[org.clojure/clojure "1.9.0"] :dependencies [[org.clojure/clojure "1.9.0"]
[ring "1.6.3"] [ring "1.6.3"]
[metosin/muuntaja "0.5.0"] [metosin/muuntaja "0.5.0"]
[metosin/reitit "0.1.3"]] [metosin/reitit "0.1.4-SNAPSHOT"]]
:repl-options {:init-ns example.server}) :repl-options {:init-ns example.server})

View file

@ -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)" :description "Snappy data-driven router for Clojure(Script)"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -131,7 +131,10 @@
(defn path-for [^Route route path-params] (defn path-for [^Route route path-params]
(if-let [required (:path-params route)] (if-let [required (:path-params route)]
(if (every? #(contains? path-params %) required) (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))) (:path route)))
(defn throw-on-missing-path-params [template required path-params] (defn throw-on-missing-path-params [template required path-params]
@ -214,5 +217,8 @@
"shallow transform of query parameters into query string" "shallow transform of query parameters into query string"
[params] [params]
(->> 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 "&"))) (str/join "&")))

View file

@ -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]])

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
(defproject metosin/reitit-ring "0.1.3" (defproject metosin/reitit-ring "0.1.4-SNAPSHOT"
:description "Reitit: Ring routing" :description "Reitit: Ring routing"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -1,4 +1,4 @@
(defproject metosin/reitit-schema "0.1.3" (defproject metosin/reitit-schema "0.1.4-SNAPSHOT"
:description "Reitit: Plumatic Schema coercion" :description "Reitit: Plumatic Schema coercion"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -1,4 +1,4 @@
(defproject metosin/reitit-spec "0.1.3" (defproject metosin/reitit-spec "0.1.4-SNAPSHOT"
:description "Reitit: clojure.spec coercion" :description "Reitit: clojure.spec coercion"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -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" :description "Reitit: Swagger-ui support"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -1,4 +1,4 @@
(defproject metosin/reitit-swagger "0.1.3" (defproject metosin/reitit-swagger "0.1.4-SNAPSHOT"
:description "Reitit: Swagger-support" :description "Reitit: Swagger-support"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"

View file

@ -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)" :description "Snappy data-driven router for Clojure(Script)"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"
@ -11,4 +11,5 @@
[metosin/reitit-spec] [metosin/reitit-spec]
[metosin/reitit-schema] [metosin/reitit-schema]
[metosin/reitit-swagger] [metosin/reitit-swagger]
[metosin/reitit-swagger-ui]]) [metosin/reitit-swagger-ui]
[metosin/reitit-frontend]])

View file

@ -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)" :description "Snappy data-driven router for Clojure(Script)"
:url "https://github.com/metosin/reitit" :url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License" :license {:name "Eclipse Public License"
@ -9,13 +9,14 @@
:source-uri "https://github.com/metosin/reitit/{version}/{filepath}#L{line}" :source-uri "https://github.com/metosin/reitit/{version}/{filepath}#L{line}"
:metadata {:doc/format :markdown}} :metadata {:doc/format :markdown}}
:managed-dependencies [[metosin/reitit "0.1.3"] :managed-dependencies [[metosin/reitit "0.1.4-SNAPSHOT"]
[metosin/reitit-core "0.1.3"] [metosin/reitit-core "0.1.4-SNAPSHOT"]
[metosin/reitit-ring "0.1.3"] [metosin/reitit-ring "0.1.4-SNAPSHOT"]
[metosin/reitit-spec "0.1.3"] [metosin/reitit-spec "0.1.4-SNAPSHOT"]
[metosin/reitit-schema "0.1.3"] [metosin/reitit-schema "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger "0.1.3"] [metosin/reitit-swagger "0.1.4-SNAPSHOT"]
[metosin/reitit-swagger-ui "0.1.3"] [metosin/reitit-swagger-ui "0.1.4-SNAPSHOT"]
[metosin/reitit-frontend "0.1.4-SNAPSHOT"]
[meta-merge "1.0.0"] [meta-merge "1.0.0"]
[ring/ring-core "1.6.3"] [ring/ring-core "1.6.3"]
@ -40,10 +41,11 @@
"modules/reitit-spec/src" "modules/reitit-spec/src"
"modules/reitit-schema/src" "modules/reitit-schema/src"
"modules/reitit-swagger/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"] :dependencies [[org.clojure/clojure "1.9.0"]
[org.clojure/clojurescript "1.10.238"] [org.clojure/clojurescript "1.10.339"]
;; modules dependencies ;; modules dependencies
[metosin/reitit] [metosin/reitit]
@ -60,7 +62,10 @@
[criterium "0.4.4"] [criterium "0.4.4"]
[org.clojure/test.check "0.9.0"] [org.clojure/test.check "0.9.0"]
[org.clojure/tools.namespace "0.2.11"] [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" :perf {:jvm-opts ^:replace ["-server"
"-Xmx4096m" "-Xmx4096m"
"-Dclojure.compiler.direct-linking=true"] "-Dclojure.compiler.direct-linking=true"]

View file

@ -3,6 +3,6 @@
set -e set -e
# Modules # 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 ../..; cd modules/$ext; lein "$@"; cd ../..;
done done

View file

@ -5,7 +5,10 @@
reitit.impl-test reitit.impl-test
reitit.middleware-test reitit.middleware-test
reitit.ring-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!) (enable-console-print!)
@ -14,4 +17,7 @@
'reitit.impl-test 'reitit.impl-test
'reitit.middleware-test 'reitit.middleware-test
'reitit.ring-test 'reitit.ring-test
#_'reitit.spec-test) #_'reitit.spec-test
'reitit.frontend.core-test
'reitit.frontend.history-test
'reitit.frontend.controllers-test)

View file

@ -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 []))
))

View file

@ -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 {})))))))))

View file

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

View file

@ -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}))