├── .circleci
└── config.yml
├── .github
└── CODEOWNERS
├── .gitignore
├── ORIGINATOR
├── README.md
├── deps.edn
├── dev
├── user.clj
└── user.cljs
├── examples
└── example-01
│ ├── example.cljs
│ └── index.html
├── project.clj
├── src
└── secretary
│ ├── core.clj
│ └── core.cljs
└── test
└── secretary
└── test
└── core.cljs
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | # Clojure CircleCI 2.0 configuration file
2 | #
3 | # Check https://circleci.com/docs/2.0/language-clojure/ for more details
4 | #
5 | version: 2
6 | jobs:
7 | build:
8 | docker:
9 | # specify the version you desire here
10 | - image: circleci/clojure:lein-2.8.1-node
11 |
12 | # Specify service dependencies here if necessary
13 | # CircleCI maintains a library of pre-built images
14 | # documented at https://circleci.com/docs/2.0/circleci-images/
15 | # - image: circleci/postgres:9.4
16 |
17 | working_directory: ~/repo
18 |
19 | environment:
20 | LEIN_ROOT: "true"
21 | # Customize the JVM maximum heap limit
22 | JVM_OPTS: -Xmx3200m
23 |
24 | steps:
25 | - checkout
26 |
27 | # Download and cache dependencies
28 | - restore_cache:
29 | keys:
30 | - v1-dependencies-{{ checksum "project.clj" }}
31 | # fallback to using the latest cache if no exact match is found
32 | - v1-dependencies-
33 |
34 | - run: npm install phantomjs-prebuilt
35 |
36 | - run: lein deps
37 |
38 | - save_cache:
39 | paths:
40 | - ~/.m2
41 | key: v1-dependencies-{{ checksum "project.clj" }}
42 |
43 | # run tests!
44 | - run: PATH=~/repo/node_modules/phantomjs-prebuilt/lib/phantom/bin:$PATH lein run-tests | tee test-out.txt
45 | - run: grep '0 failures, 0 errors.' test-out.txt
46 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @slipset
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /lib
3 | /classes
4 | /checkouts
5 | pom.xml
6 | pom.xml.asc
7 | *.jar
8 | *.class
9 | .lein-deps-sum
10 | .lein-failures
11 | .lein-plugins
12 | .lein-repl-history
13 | *.swp
14 | \.\#*
15 | #*
16 | *\#
17 | .repl/*
18 | out/*
19 | /.cljs_node_repl
20 | /.cpcache
21 |
--------------------------------------------------------------------------------
/ORIGINATOR:
--------------------------------------------------------------------------------
1 | @noprompt
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # secretary
2 |
3 | A client-side router for ClojureScript.
4 |
5 | [](https://clojars.org/clj-commons/secretary)
6 | [](https://cljdoc.org/d/clj-commons/secretary/CURRENT)
7 | [](https://circleci.com/gh/clj-commons/secretary/tree/master)
8 |
9 |
10 | ## Contents
11 |
12 | - [Installation](#installation)
13 | - [Guide](#guide)
14 | * [Basic routing and dispatch](#basic-routing-and-dispatch)
15 | * [Route matchers](#route-matchers)
16 | * [Parameter destructuring](#parameter-destructuring)
17 | * [Query parameters](#query-parameters)
18 | * [Named routes](#named-routes)
19 | - [Example with history](#example-with-googhistory)
20 | - [Available protocols](#available-protocols)
21 | - [Contributors](#contributors)
22 | - [Committers](#committers)
23 |
24 |
25 | ## Installation
26 |
27 | Add secretary to your `project.clj` `:dependencies` vector:
28 |
29 | ```clojure
30 | [clj-commons/secretary "1.2.4"]
31 | ```
32 |
33 | For the current `SNAPSHOT` version use:
34 |
35 | ```clojure
36 | [clj-commons/secretary "1.2.5-SNAPSHOT"]
37 | ```
38 |
39 | ## Guide
40 |
41 | To get started `:require` secretary somewhere in your project.
42 |
43 | ```clojure
44 | (ns app.routes
45 | (:require [secretary.core :as secretary :refer-macros [defroute]]))
46 | ```
47 | **Note**: starting ClojureScript v0.0-2371, `:refer` cannot be used to import macros into your project anymore. The proper way to do it is by using `:refer-macros` as above. When using ClojureScript v0.0-2755 or above, if `(:require [secretary.core :as secretary])` is used, macros will be automatically aliased to `secretary`, e.g. `secretary/defroute`.
48 |
49 | ### Basic routing and dispatch
50 |
51 | Secretary is built around two main goals: creating route matchers
52 | and dispatching actions. Route matchers match and extract
53 | parameters from URI fragments and actions are functions which
54 | accept those parameters.
55 |
56 | `defroute` is Secretary's primary macro for defining a link between a
57 | route matcher and an action. The signature of this macro is
58 | `[name? route destruct & body]`. We will skip the `name?` part
59 | of the signature for now and return to it when we discuss
60 | [named routes](#named-routes). To get clearer picture of this
61 | let's define a route for users with an id.
62 |
63 | ```clojure
64 | (defroute "/users/:id" {:as params}
65 | (js/console.log (str "User: " (:id params))))
66 | ```
67 |
68 | In this example `"/users/:id"` is `route`, the route matcher,
69 | `{:as params}` is `destruct`, the destructured parameters extracted
70 | from the route matcher result, and the remaining
71 | `(js/console.log ...)` portion is `body`, the route action.
72 |
73 | Before going in to more detail let's try to dispatch our route.
74 |
75 | ```clojure
76 | (secretary/dispatch! "/users/gf3")
77 | ```
78 |
79 | With any luck, when we refresh the page and view the console we should
80 | see that `User: gf3` has been logged somewhere.
81 |
82 |
83 | #### Route matchers
84 |
85 | By default the route matcher may either be a string or regular
86 | expression. String route matchers have special syntax which may be
87 | familiar to you if you've worked with [Sinatra][sinatra] or
88 | [Ruby on Rails][rails]. When `secretary/dispatch!` is called with a
89 | URI it attempts to find a route match and it's corresponding
90 | action. If the match is successful, parameters will be extracted from
91 | the URI. For string route matchers these will be contained in a map;
92 | for regular expressions a vector.
93 |
94 | In the example above, the route matcher
95 | `"/users/:id"` successfully matched against `"/users/gf3"` and
96 | extracted `{:id "gf3}` as parameters. You can refer to the table below
97 | for more examples of route matchers and the parameters they return
98 | when matched successfully.
99 |
100 | Route matcher | URI | Parameters
101 | ---------------------|------------------|--------------------------
102 | `"/:x/:y"` | `"/foo/bar"` | `{:x "foo" :y "bar"}`
103 | `"/:x/:x"` | `"/foo/bar"` | `{:x ["foo" "bar"]}`
104 | `"/files/*.:format"` | `"/files/x.zip"` | `{:* "x" :format "zip"}`
105 | `"*"` | `"/any/thing"` | `{:* "/any/thing"}`
106 | `"/*/*"` | `"/n/e/thing"` | `{:* ["n" "e/thing"]}`
107 | `"/*x/*y"` | `"/n/e/thing"` | `{:x "n" :y "e/thing"}`
108 | `#"/[a-z]+/\d+"` | `"/foo/123"` | `["/foo/123"]`
109 | `#"/([a-z]+)/(\d+)"` | `"/foo/123"` | `["foo" "123"]`
110 |
111 |
112 | #### Parameter destructuring
113 |
114 | Now that we understand what happens during dispatch we can look at the
115 | `destruct` argument of `defroute`. This part is literally sugar
116 | around `let`. Basically whenever one of our route matches is
117 | successful and extracts parameters this is where we destructure
118 | them. Under the hood, for example with our users route, this looks
119 | something like the following.
120 |
121 | ```clojure
122 | (let [{:as params} {:id "gf3"}]
123 | ...)
124 | ```
125 |
126 | Given this, it should be fairly easy to see that we could have have
127 | written
128 |
129 | ```clojure
130 | (defroute "/users/:id" {id :id}
131 | (js/console.log (str "User: " id)))
132 | ```
133 |
134 | and seen the same result. With string route matchers we can go even
135 | further and write
136 |
137 | ```clojure
138 | (defroute "/users/:id" [id]
139 | (js/console.log (str "User: " id)))
140 | ```
141 |
142 | which is essentially the same as saying `{:keys [id]}`.
143 |
144 | For regular expression route matchers we can only use vectors for
145 | destructuring since they only ever return vectors.
146 |
147 | ```clojure
148 | (defroute #"/users/(\d+)" [id]
149 | (js/console.log (str "User: " id)))
150 | ```
151 |
152 |
153 | #### Query parameters
154 |
155 | If a URI contains a query string it will automatically be extracted to
156 | `:query-params` for string route matchers and to the last element for
157 | regular expression matchers.
158 |
159 | ```clojure
160 | (defroute "/users/:id" [id query-params]
161 | (js/console.log (str "User: " id))
162 | (js/console.log (pr-str query-params)))
163 |
164 | (defroute #"/users/(\d+)" [id {:keys [query-params]}]
165 | (js/console.log (str "User: " id))
166 | (js/console.log (pr-str query-params)))
167 |
168 | ;; In both instances...
169 | (secretary/dispatch! "/users/10?action=delete")
170 | ;; ... will log
171 | ;; User: 10
172 | ;; "{:action \"delete\"}"
173 | ```
174 |
175 |
176 | #### Named routes
177 |
178 | While route matching and dispatch is by itself useful, it is often
179 | necessary to have functions which take a map of parameters and return
180 | a URI. By passing an optional name to `defroute` Secretary will
181 | define this function for you.
182 |
183 | ```clojure
184 | (defroute users-path "/users" []
185 | (js/console.log "Users path"))
186 |
187 | (defroute user-path "/users/:id" [id]
188 | (js/console.log (str "User " id "'s path"))
189 |
190 | (users-path) ;; => "/users"
191 | (user-path {:id 1}) ;; => "/users/1"
192 | ```
193 |
194 | This also works with `:query-params`.
195 |
196 | ```clojure
197 | (user-path {:id 1 :query-params {:action "delete"}})
198 | ;; => "/users/1?action=delete"
199 | ```
200 |
201 | If the browser you're targeting does not support HTML5 history you can
202 | call
203 |
204 | ```clojure
205 | (secretary/set-config! :prefix "#")
206 | ```
207 |
208 | to prefix generated URIs with a "#".
209 |
210 | ```clojure
211 | (user-path {:id 1})
212 | ;; => "#/users/1"
213 | ```
214 |
215 | ##### This scheme doesn't comply with URI spec
216 |
217 | Beware that using `:prefix` that way will make resulting URIs no longer compliant with [standard URI syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Locator#Syntax) – the fragment must be the last part of the URI after the query). Indeed, the syntax of a URI is defined as:
218 |
219 | scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
220 |
221 | `secretary` adds a `#` after the path so it makes the fragment hide the query. For instance, the following URL is comprehended in different ways by `secretary` and the spec:
222 |
223 | ```
224 | https://www.example.com/path/of/app#path/inside/app?query=params&as=defined&by=secretary
225 | ```
226 |
227 | - the fragment is `"path/inside/app?query=params&as=defined&by=secretary"` for standard libraries,
228 | but is `"path/inside/app"` according to Secretary
229 |
230 | - the query is `""` for standard libraries, but is `"query=params&as=defined&by=secretary"` according to Secretary
231 |
232 |
233 | ### Available protocols
234 |
235 | You can extend Secretary's protocols to your own data types and
236 | records if you need special functionality.
237 |
238 | - [`IRenderRoute`](#irenderroute)
239 | - [`IRouteMatches`](#iroutematches)
240 |
241 |
242 | #### `IRenderRoute`
243 |
244 | Most of the time the defaults will be good enough but on occasion you
245 | may need custom route rendering. To do this implement `IRenderRoute`
246 | for your type or record.
247 |
248 | ```clojure
249 | (defrecord User [id]
250 | secretary/IRenderRoute
251 | (render-route [_]
252 | (str "/users/" id))
253 |
254 | (render-route [this params]
255 | (str (secretary/render-route this) "?"
256 | (secretary/encode-query-params params))))
257 |
258 | (secretary/render-route (User. 1))
259 | ;; => "/users/1"
260 | (secretary/render-route (User. 1) {:action :delete})
261 | ;; => "/users/1?action=delete"
262 | ```
263 |
264 |
265 | #### `IRouteMatches`
266 |
267 | It is seldom you will ever need to create your own route matching
268 | implementation as the built in `String` and `RegExp` routes matchers
269 | should be fine for most applications. Still, if you have a suitable
270 | use case then this protocol is available. If your intention is to is
271 | to use it with `defroute` your implementation must return a map or
272 | vector.
273 |
274 |
275 | ### Example with `goog.History`
276 |
277 | ```clojure
278 | (ns example
279 | (:require [secretary.core :as secretary :refer-macros [defroute]]
280 | [goog.events :as events])
281 | (:import [goog History]
282 | [goog.history EventType]))
283 |
284 | (def application
285 | (js/document.getElementById "application"))
286 |
287 | (defn set-html! [el content]
288 | (aset el "innerHTML" content))
289 |
290 | (secretary/set-config! :prefix "#")
291 |
292 | ;; /#/
293 | (defroute home-path "/" []
294 | (set-html! application "
OMG! YOU'RE HOME!
"))
295 |
296 | ;; /#/users
297 | (defroute users-path "/users" []
298 | (set-html! application "USERS!
"))
299 |
300 | ;; /#/users/:id
301 | (defroute user-path "/users/:id" [id]
302 | (let [message (str "HELLO USER " id "!
")]
303 | (set-html! application message)))
304 |
305 | ;; /#/777
306 | (defroute jackpot-path "/777" []
307 | (set-html! application "YOU HIT THE JACKPOT!
"))
308 |
309 | ;; Catch all
310 | (defroute "*" []
311 | (set-html! application "LOL! YOU LOST!
"))
312 |
313 | ;; Quick and dirty history configuration.
314 | (doto (History.)
315 | (events/listen EventType.NAVIGATE #(secretary/dispatch! (.-token %)))
316 | (.setEnabled true))
317 | ```
318 |
319 |
320 | ## Contributors
321 |
322 | * [@gf3](https://github.com/gf3) (Gianni Chiappetta)
323 | * [@noprompt](https://github.com/noprompt) (Joel Holdbrooks)
324 | * [@joelash](https://github.com/joelash) (Joel Friedman)
325 | * [@james-henderson](https://github.com/james-henderson) (James Henderson)
326 | * [@the-kenny](https://github.com/the-kenny) (Moritz Ulrich)
327 | * [@timgilbert](https://github.com/timgilbert) (Tim Gilbert)
328 | * [@bbbates](https://github.com/bbbates) (Brendan)
329 | * [@travis](https://github.com/travis) (Travis Vachon)
330 |
331 | ## Committers
332 |
333 | * [@gf3](https://github.com/gf3) (Gianni Chiappetta)
334 | * [@noprompt](https://github.com/noprompt) (Joel Holdbrooks)
335 | * [@joelash](https://github.com/joelash) (Joel Friedman)
336 |
337 | ## License
338 |
339 | Distributed under the Eclipse Public License, the same as Clojure.
340 |
341 | [sinatra]: http://www.sinatrarb.com/intro.html#Routes
342 | [rails]: http://guides.rubyonrails.org/routing.html
343 |
--------------------------------------------------------------------------------
/deps.edn:
--------------------------------------------------------------------------------
1 | {:deps {}
2 | :aliases {:test
3 | {:extra-deps {org.clojure/clojurescript {:mvn/version "RELEASE"}
4 | org.clojure/test.check {:mvn/version "RELEASE"}}
5 | :extra-paths ["test"]}}}
6 |
--------------------------------------------------------------------------------
/dev/user.clj:
--------------------------------------------------------------------------------
1 | (ns user
2 | (:require
3 | [weasel.repl.websocket]
4 | [cemerick.piggieback]
5 | [cljs.repl :as repl]
6 | [cljs.repl.node :as node]))
7 |
8 | (defn ws-repl []
9 | (cemerick.piggieback/cljs-repl
10 | :repl-env (weasel.repl.websocket/repl-env :ip "0.0.0.0" :port 9091)))
11 |
12 | (defn node-repl []
13 | (cemerick.piggieback/cljs-repl
14 | :repl-env (node/repl-env)
15 | :output-dir ".cljs_node_repl"
16 | :cache-analysis true
17 | :source-map true))
18 |
--------------------------------------------------------------------------------
/dev/user.cljs:
--------------------------------------------------------------------------------
1 | (ns user
2 | (:require
3 | [weasel.repl :as ws-repl]
4 | [secretary.core :as secretary :include-macros true]))
5 |
6 | (ws-repl/connect "ws://localhost:9091" :verbose true)
7 |
--------------------------------------------------------------------------------
/examples/example-01/example.cljs:
--------------------------------------------------------------------------------
1 | (ns example-01.example
2 | (:require [secretary.core :as secretary]
3 | [goog.events :as events])
4 | (:require-macros [secretary.core :refer [defroute]])
5 | (:import goog.History
6 | goog.history.EventType))
7 |
8 | (def application
9 | (js/document.getElementById "application"))
10 |
11 | (defn set-html! [el content]
12 | (aset el "innerHTML" content))
13 |
14 | (secretary/set-config! :prefix "#")
15 |
16 | ;; /#/
17 | (defroute home-path "/" []
18 | (set-html! application "OMG! YOU'RE HOME!
"))
19 |
20 | ;; /#/users
21 | (defroute user-path "/users" []
22 | (set-html! application "USERS!
"))
23 |
24 | ;; /#/users/:id
25 | (defroute user-path "/users/:id" [id]
26 | (let [message (str "HELLO USER " id "!
")]
27 | (set-html! application message)))
28 |
29 | ;; /#/777
30 | (defroute jackpot-path "/777" []
31 | (set-html! application "YOU HIT THE JACKPOT!
"))
32 |
33 | ;; Catch all
34 | (defroute "*" []
35 | (set-html! application "LOL! YOU LOST!
"))
36 |
37 | ;; Quick and dirty history configuration.
38 | (let [h (History.)]
39 | (goog.events/listen h EventType.NAVIGATE #(secretary/dispatch! (.-token %)))
40 | (doto h
41 | (.setEnabled true)))
42 |
43 | (secretary/dispatch! "/")
44 |
--------------------------------------------------------------------------------
/examples/example-01/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Example
5 |
6 |
7 |
8 |
9 | Users
10 |
17 | Click here to win!
18 | Click here to lose!
19 |
20 |
21 |
--------------------------------------------------------------------------------
/project.clj:
--------------------------------------------------------------------------------
1 | (defproject clj-commons/secretary "1.2.5-SNAPSHOT"
2 | :description "A client-side router for ClojureScript."
3 | :url "https://github.com/clj-commons/secretary"
4 | :license {:name "Eclipse Public License - v 1.0"
5 | :url "http://www.eclipse.org/legal/epl-v10.html"
6 | :distribution :repo
7 | :comments "same as Clojure"}
8 |
9 | :dependencies
10 | [[org.clojure/clojure "1.6.0"]
11 | [org.clojure/clojurescript "0.0-2913" :scope "provided"]]
12 |
13 | :plugins
14 | [[lein-cljsbuild "1.0.5"]]
15 |
16 | :profiles
17 | {:dev {:source-paths ["dev/" "src/"]
18 | :dependencies
19 | [[com.cemerick/piggieback "0.1.6-SNAPSHOT"]
20 | [weasel "0.6.0"]]
21 | :plugins
22 | [[com.cemerick/clojurescript.test "0.2.3-SNAPSHOT"]]
23 | :repl-options
24 | {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}}}
25 |
26 | :aliases
27 | {"run-tests" ["do" "clean," "cljsbuild" "once" "test"]
28 | "test-once" ["do" "clean," "cljsbuild" "once" "test"]
29 | "auto-test" ["do" "clean," "cljsbuild" "auto" "test"]}
30 |
31 | :cljsbuild
32 | {:builds [{:id "test"
33 | :source-paths ["src/" "test/"]
34 | :notify-command ["phantomjs" :cljs.test/runner "target/js/test.js"]
35 | :compiler {:output-to "target/js/test.js"
36 | :optimizations :whitespace
37 | :pretty-print true}}
38 | {:id "example-01"
39 | :source-paths ["src/" "examples/"]
40 | :compiler {:output-to "examples/example-01/example.js"
41 | :optimizations :whitespace
42 | :pretty-print true}}]})
43 |
--------------------------------------------------------------------------------
/src/secretary/core.clj:
--------------------------------------------------------------------------------
1 | (ns secretary.core)
2 |
3 | (defmacro ^{:arglists '([name? route destruct & body])}
4 | defroute
5 | "Add a route to the dispatcher."
6 | [route destruct & body]
7 | (let [[fn-name route destruct body] (if (symbol? route)
8 | [route destruct (first body) (rest body)]
9 | [nil route destruct body])
10 | fn-spec `([& args#]
11 | (apply secretary.core/render-route* ~route args#))
12 | fn-body (if fn-name
13 | (concat (list 'defn fn-name) fn-spec)
14 | (cons 'fn fn-spec))]
15 |
16 | (when-not ((some-fn map? vector?) destruct)
17 | (throw (IllegalArgumentException. (str "defroute bindings must be a map or vector, given " (pr-str destruct)))))
18 |
19 | `(let [action# (fn [params#]
20 | (cond
21 | (map? params#)
22 | (let [~(if (vector? destruct)
23 | {:keys destruct}
24 | destruct) params#]
25 | ~@body)
26 |
27 | (vector? params#)
28 | (let [~destruct params#]
29 | ~@body)))]
30 | (secretary.core/add-route! ~route action#)
31 | ~fn-body)))
32 |
--------------------------------------------------------------------------------
/src/secretary/core.cljs:
--------------------------------------------------------------------------------
1 | (ns secretary.core
2 | (:require [clojure.string :as string]
3 | [clojure.walk :refer [keywordize-keys]])
4 | (:require-macros [secretary.core :refer [defroute]]))
5 |
6 | ;;----------------------------------------------------------------------
7 | ;; Protocols
8 |
9 | (defprotocol IRouteMatches
10 | (route-matches [this route]))
11 |
12 | (defprotocol IRouteValue
13 | (route-value [this]))
14 |
15 | (defprotocol IRenderRoute
16 | (render-route
17 | [this]
18 | [this params]))
19 |
20 | ;;----------------------------------------------------------------------
21 | ;; Configuration
22 |
23 | (def ^:dynamic *config*
24 | (atom {:prefix ""}))
25 |
26 | (defn get-config
27 | "Gets a value for *config* at path."
28 | [path]
29 | (let [path (if (sequential? path) path [path])]
30 | (get-in @*config* path)))
31 |
32 | (defn set-config!
33 | "Associates a value val for *config* at path."
34 | [path val]
35 | (let [path (if (sequential? path) path [path])]
36 | (swap! *config* assoc-in path val)))
37 |
38 | ;;----------------------------------------------------------------------
39 | ;; Parameter encoding
40 |
41 | (def encode js/encodeURIComponent)
42 |
43 | (defmulti
44 | ^{:private true
45 | :doc "Given a key and a value return and encoded key-value pair."}
46 | encode-pair
47 | (fn [[k v]]
48 | (cond
49 | (or (sequential? v) (set? v))
50 | ::sequential
51 | (or (map? v) (satisfies? IRecord v))
52 | ::map)))
53 |
54 | (defn- key-index
55 | ([k] (str (name k) "[]"))
56 | ([k index]
57 | (str (name k) "[" index "]")))
58 |
59 | (defmethod encode-pair ::sequential [[k v]]
60 | (let [encoded (map-indexed
61 | (fn [i x]
62 | (let [pair (if (coll? x)
63 | [(key-index k i) x]
64 | [(key-index k) x])]
65 | (encode-pair pair)))
66 | v)]
67 | (string/join \& encoded)))
68 |
69 | (defmethod encode-pair ::map [[k v]]
70 | (let [encoded (map
71 | (fn [[ik iv]]
72 | (encode-pair [(key-index k (name ik)) iv]))
73 | v)]
74 | (string/join \& encoded)))
75 |
76 | (defmethod encode-pair :default [[k v]]
77 | (str (name k) \= (encode (str v))))
78 |
79 | (defn encode-query-params
80 | "Convert a map of query parameters into url encoded string."
81 | [query-params]
82 | (string/join \& (map encode-pair query-params)))
83 |
84 | (defn encode-uri
85 | "Like js/encodeURIComponent excepts ignore slashes."
86 | [uri]
87 | (->> (string/split uri #"/")
88 | (map encode)
89 | (string/join "/")))
90 |
91 | ;;----------------------------------------------------------------------
92 | ;; Parameter decoding
93 |
94 | (def decode js/decodeURIComponent)
95 |
96 | (defn- parse-path
97 | "Parse a value from a serialized query-string key index. If the
98 | index value is empty 0 is returned, if it's a digit it returns the
99 | js/parseInt value, otherwise it returns the extracted index."
100 | [path]
101 | (let [index-re #"\[([^\]]*)\]*" ;; Capture the index value.
102 | parts (re-seq index-re path)]
103 | (map
104 | (fn [[_ part]]
105 | (cond
106 | (empty? part) 0
107 | (re-matches #"\d+" part) (js/parseInt part)
108 | :else part))
109 | parts)))
110 |
111 | (defn- key-parse
112 | "Return a key path for a serialized query-string entry.
113 |
114 | Ex.
115 |
116 | (key-parse \"foo[][a][][b]\")
117 | ;; => (\"foo\" 0 \"a\" 0 \"b\")
118 | "
119 | [k]
120 | (let [re #"([^\[\]]+)((?:\[[^\]]*\])*)?"
121 | [_ key path] (re-matches re k)
122 | parsed-path (when path (parse-path path))]
123 | (cons key parsed-path)))
124 |
125 | (defn- assoc-in-query-params
126 | "Like assoc-in but numbers in path create vectors instead of maps.
127 |
128 | Ex.
129 |
130 | (assoc-in-query-params {} [\"foo\" 0] 1)
131 | ;; => {\"foo\" [1]}
132 |
133 | (assoc-in-query-params {} [\"foo\" 0 \"a\"] 1)
134 | ;; => {\"foo\" [{\"a\" 1}]}
135 | "
136 | [m path v]
137 | (let [heads (fn [xs]
138 | (map-indexed
139 | (fn [i _]
140 | (take (inc i) xs))
141 | xs))
142 | hs (heads path)
143 | m (reduce
144 | (fn [m h]
145 | (if (and (or (number? (last h)))
146 | (not (vector? (get-in m (butlast h)))))
147 | (assoc-in m (butlast h) [])
148 | m))
149 | m
150 | hs)]
151 | (if (zero? (last path))
152 | (update-in m (butlast path) conj v)
153 | (assoc-in m path v))))
154 |
155 | (defn decode-query-params
156 | "Extract a map of query parameters from a query string."
157 | [query-string]
158 | (let [parts (string/split query-string #"&")
159 | params (reduce
160 | (fn [m part]
161 | ;; We only want two parts since the part on the right hand side
162 | ;; could potentially contain an =.
163 | (let [[k v] (string/split part #"=" 2)]
164 | (assoc-in-query-params m (key-parse (decode k)) (decode v))))
165 | {}
166 | parts)
167 | params (keywordize-keys params)]
168 | params))
169 |
170 | ;;----------------------------------------------------------------------
171 | ;; Route compilation
172 |
173 | ;; The implementation for route compilation was inspired by Clout and
174 | ;; modified to suit JavaScript and Secretary.
175 | ;; SEE: https://github.com/weavejester/clout
176 |
177 | (defn- re-matches*
178 | "Like re-matches but result is a always vector. If re does not
179 | capture matches then it will return a vector of [m m] as if it had a
180 | single capture. Other wise it maintains consistent behavior with
181 | re-matches. "
182 | [re s]
183 | (let [ms (clojure.core/re-matches re s)]
184 | (when ms
185 | (if (sequential? ms) ms [ms ms]))))
186 |
187 | (def ^:private re-escape-chars
188 | (set "\\.*+|?()[]{}$^"))
189 |
190 | (defn- re-escape [s]
191 | (reduce
192 | (fn [s c]
193 | (if (re-escape-chars c)
194 | (str s \\ c)
195 | (str s c)))
196 | ""
197 | s))
198 |
199 | (defn- lex*
200 | "Attempt to lex a single token from s with clauses. Each clause is a
201 | pair of [regexp action] where action is a function. regexp is
202 | expected to begin with ^ and contain a single capture. If the
203 | attempt is successful a vector of [s-without-token (action capture)]
204 | is returned. Otherwise the result is nil."
205 | [s clauses]
206 | (some
207 | (fn [[re action]]
208 | (when-let [[m c] (re-find re s)]
209 | [(subs s (count m)) (action c)]))
210 | clauses))
211 |
212 | (defn- lex-route
213 | "Return a pair of [regex params]. regex is a compiled regular
214 | expression for matching routes. params is a list of route param
215 | names (:*, :id, etc.). "
216 | [s clauses]
217 | (loop [s s pattern "" params []]
218 | (if (seq s)
219 | (let [[s [r p]] (lex* s clauses)]
220 | (recur s (str pattern r) (conj params p)))
221 | [(re-pattern (str \^ pattern \$)) (remove nil? params)])))
222 |
223 | (defn- compile-route
224 | "Given a route return an instance of IRouteMatches."
225 | [orig-route]
226 | (let [clauses [[#"^\*([^\s.:*/]*)" ;; Splats, named splates
227 | (fn [v]
228 | (let [r "(.*?)"
229 | p (if (seq v)
230 | (keyword v)
231 | :*)]
232 | [r p]))]
233 | [#"^\:([^\s.:*/]+)" ;; Params
234 | (fn [v]
235 | (let [r "([^,;?/]+)"
236 | p (keyword v)]
237 | [r p]))]
238 | [#"^([^:*]+)" ;; Literals
239 | (fn [v]
240 | (let [r (re-escape v)]
241 | [r]))]]
242 | [re params] (lex-route orig-route clauses)]
243 | (reify
244 | IRouteValue
245 | (route-value [this] orig-route)
246 |
247 | IRouteMatches
248 | (route-matches [_ route]
249 | (when-let [[_ & ms] (re-matches* re route)]
250 | (->> (interleave params (map decode ms))
251 | (partition 2)
252 | (map (fn [[k v]] (first {k v})))
253 | (merge-with vector {})))))))
254 |
255 | ;;----------------------------------------------------------------------
256 | ;; Route rendering
257 |
258 | (defn ^:internal render-route* [obj & args]
259 | (when (satisfies? IRenderRoute obj)
260 | (apply render-route obj args)))
261 |
262 | ;;----------------------------------------------------------------------
263 | ;; Routes adding/removing
264 |
265 | (def ^:dynamic *routes*
266 | (atom []))
267 |
268 | (defn add-route! [obj action]
269 | (let [obj (if (string? obj)
270 | (compile-route obj)
271 | obj)]
272 | (swap! *routes* conj [obj action])))
273 |
274 | (defn remove-route! [obj]
275 | (swap! *routes*
276 | (fn [rs]
277 | (filterv
278 | (fn [[x _]]
279 | (not= x obj))
280 | rs))))
281 |
282 | (defn reset-routes! []
283 | (reset! *routes* []))
284 |
285 | ;;----------------------------------------------------------------------
286 | ;; Route lookup and dispatch
287 |
288 | (defn locate-route [route]
289 | (some
290 | (fn [[compiled-route action]]
291 | (when-let [params (route-matches compiled-route route)]
292 | {:action action :params params :route compiled-route}))
293 | @*routes*))
294 |
295 | (defn locate-route-value
296 | "Returns original route value as set in defroute when passed a URI path."
297 | [uri]
298 | (-> uri locate-route :route route-value))
299 |
300 | (defn- prefix
301 | []
302 | (str (get-config [:prefix])))
303 |
304 | (defn- uri-without-prefix
305 | [uri]
306 | (string/replace uri (re-pattern (str "^" (prefix))) ""))
307 |
308 | (defn- uri-with-leading-slash
309 | "Ensures that the uri has a leading slash"
310 | [uri]
311 | (if (= "/" (first uri))
312 | uri
313 | (str "/" uri)))
314 |
315 | (defn dispatch!
316 | "Dispatch an action for a given route if it matches the URI path."
317 | [uri]
318 | (let [[uri-path query-string] (string/split (uri-without-prefix uri) #"\?" 2)
319 | uri-path (uri-with-leading-slash uri-path)
320 | query-params (when query-string
321 | {:query-params (decode-query-params query-string)})
322 | {:keys [action params]} (locate-route uri-path)
323 | action (or action identity)
324 | params (merge params query-params)]
325 | (action params)))
326 |
327 | (defn invalid-params [params validations]
328 | (reduce (fn [m [key validation]]
329 | (let [value (get params key)]
330 | (if (re-matches validation value)
331 | m
332 | (assoc m key [value validation]))))
333 | {} (partition 2 validations)))
334 |
335 | (defn- params-valid? [params validations]
336 | (empty? (invalid-params params validations)))
337 |
338 | ;;----------------------------------------------------------------------
339 | ;; Protocol implementations
340 |
341 | (extend-protocol IRouteMatches
342 | string
343 | (route-matches [this route]
344 | (route-matches (compile-route this) route))
345 |
346 | js/RegExp
347 | (route-matches [this route]
348 | (when-let [[_ & ms] (re-matches* this route)]
349 | (vec ms)))
350 |
351 | cljs.core/PersistentVector
352 | (route-matches [[route-string & validations] route]
353 | (let [params (route-matches (compile-route route-string) route)]
354 | (when (params-valid? params validations)
355 | params))))
356 |
357 | (extend-protocol IRouteValue
358 | string
359 | (route-value [this]
360 | (route-value (compile-route this)))
361 |
362 | js/RegExp
363 | (route-value [this] this)
364 |
365 | cljs.core/PersistentVector
366 | (route-value [[route-string & validations]]
367 | (vec (cons (route-value route-string) validations))))
368 |
369 | (extend-protocol IRenderRoute
370 | string
371 | (render-route
372 | ([this]
373 | (render-route this {}))
374 | ([this params]
375 | (let [{:keys [query-params] :as m} params
376 | a (atom m)
377 | path (.replace this (js/RegExp. ":[^\\s.:*/]+|\\*[^\\s.:*/]*" "g")
378 | (fn [$1]
379 | (let [lookup (keyword (if (= $1 "*")
380 | $1
381 | (subs $1 1)))
382 | v (get @a lookup)
383 | replacement (if (sequential? v)
384 | (do
385 | (swap! a assoc lookup (next v))
386 | (encode-uri (first v)))
387 | (if v (encode-uri v) $1))]
388 | replacement)))
389 | path (str (get-config [:prefix]) path)]
390 | (if-let [query-string (and query-params
391 | (encode-query-params query-params))]
392 | (str path "?" query-string)
393 | path))))
394 |
395 | cljs.core/PersistentVector
396 | (render-route
397 | ([this]
398 | (render-route this {}))
399 | ([[route-string & validations] params]
400 | (let [invalid (invalid-params params validations)]
401 | (if (empty? invalid)
402 | (render-route route-string params)
403 | (throw (ex-info "Could not build route: invalid params" invalid)))))))
404 |
--------------------------------------------------------------------------------
/test/secretary/test/core.cljs:
--------------------------------------------------------------------------------
1 | (ns secretary.test.core
2 | (:require
3 | [cemerick.cljs.test :as t]
4 | [secretary.core :as s])
5 | (:require-macros
6 | [cemerick.cljs.test :refer [deftest is are testing]]))
7 |
8 |
9 | (deftest query-params-test
10 | (testing "encodes query params"
11 | (let [params {:id "kevin" :food "bacon"}
12 | encoded (s/encode-query-params params)]
13 | (is (= (s/decode-query-params encoded)
14 | params)))
15 |
16 | (are [x y] (= (s/encode-query-params x) y)
17 | {:x [1 2]} "x[]=1&x[]=2"
18 | {:a [{:b 1} {:b 2}]} "a[0][b]=1&a[1][b]=2"
19 | {:a [{:b [1 2]} {:b [3 4]}]} "a[0][b][]=1&a[0][b][]=2&a[1][b][]=3&a[1][b][]=4"))
20 |
21 | (testing "decodes query params"
22 | (let [query-string "id=kevin&food=bacong"
23 | decoded (s/decode-query-params query-string)
24 | encoded (s/encode-query-params decoded)]
25 | (is (re-find #"id=kevin" query-string))
26 | (is (re-find #"food=bacon" query-string)))
27 |
28 | (are [x y] (= (s/decode-query-params x) y)
29 | "x[]=1&x[]=2" {:x ["1" "2"]}
30 | "a[0][b]=1&a[1][b]=2" {:a [{:b "1"} {:b "2"}]}
31 | "a[0][b][]=1&a[0][b][]=2&a[1][b][]=?3&a[1][b][]=4" {:a [{:b ["1" "2"]} {:b ["?3" "4"]}]})))
32 |
33 | (deftest route-matches-test
34 | (testing "non-encoded-routes"
35 | (is (not (s/route-matches "/foo bar baz" "/foo%20bar%20baz")))
36 | (is (not (s/route-matches "/:x" "/,")))
37 | (is (not (s/route-matches "/:x" "/;")))
38 |
39 | (is (= (s/route-matches "/:x" "/%2C")
40 | {:x ","}))
41 |
42 | (is (= (s/route-matches "/:x" "/%3B")
43 | {:x ";"})))
44 |
45 | (testing "utf-8 routes"
46 | (is (= (s/route-matches "/:x" "/%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86")
47 | {:x "おはよう"})))
48 |
49 | (testing "regex-routes"
50 | (s/reset-routes!)
51 |
52 | (is (= (s/route-matches #"/([a-z]+)/(\d+)" "/lol/420")
53 | ["lol" "420"]))
54 | (is (not (s/route-matches #"/([a-z]+)/(\d+)" "/0x0A/0x0B"))))
55 |
56 | (testing "vector routes"
57 | (is (= (s/route-matches ["/:foo", :foo #"[0-9]+"] "/12345") {:foo "12345"}))
58 | (is (not (s/route-matches ["/:foo", :foo #"[0-9]+"] "/haiii"))))
59 |
60 | (testing "splats"
61 | (is (= (s/route-matches "*" "")
62 | {:* ""}))
63 | (is (= (s/route-matches "*" "/foo/bar")
64 | {:* "/foo/bar"}))
65 | (is (= (s/route-matches "*.*" "cat.bat")
66 | {:* ["cat" "bat"]}))
67 | (is (= (s/route-matches "*path/:file.:ext" "/loller/skates/xxx.zip")
68 | {:path "/loller/skates"
69 | :file "xxx"
70 | :ext "zip"}))
71 | (is (= (s/route-matches "/*a/*b/*c" "/lol/123/abc/look/at/me")
72 | {:a "lol"
73 | :b "123"
74 | :c "abc/look/at/me"}))))
75 |
76 |
77 | (deftest render-route-test
78 | (testing "it interpolates correctly"
79 | (is (= (s/render-route "/")
80 | "/"))
81 | (is (= (s/render-route "/users/:id" {:id 1})
82 | "/users/1"))
83 | (is (= (s/render-route "/users/:id/food/:food" {:id "kevin" :food "bacon"})
84 | "/users/kevin/food/bacon"))
85 | (is (= (s/render-route "/users/:id" {:id 123})
86 | "/users/123"))
87 | (is (= (s/render-route "/users/:id" {:id 123 :query-params {:page 2 :per-page 10}})
88 | "/users/123?page=2&per-page=10"))
89 | (is (= (s/render-route "/:id/:id" {:id 1})
90 | "/1/1"))
91 | (is (= (s/render-route "/:id/:id" {:id [1 2]})
92 | "/1/2"))
93 | (is (= (s/render-route "/*id/:id" {:id [1 2]})
94 | "/1/2"))
95 | (is (= (s/render-route "/*x/*y" {:x "lmao/rofl/gtfo"
96 | :y "k/thx/bai"})
97 | "/lmao/rofl/gtfo/k/thx/bai"))
98 | (is (= (s/render-route "/*.:format" {:* "blood"
99 | :format "tarzan"})
100 | "/blood.tarzan"))
101 | (is (= (s/render-route "/*.*" {:* ["stab" "wound"]})
102 | "/stab.wound"))
103 | (is (= (s/render-route ["/:foo", :foo #"[0-9]+"] {:foo "12345"}) "/12345"))
104 | (is (thrown? ExceptionInfo (s/render-route ["/:foo", :foo #"[0-9]+"] {:foo "haiii"}))))
105 |
106 | (testing "it encodes replacements"
107 | (is (= (s/render-route "/users/:path" {:path "yay/for/me"}))
108 | "/users/yay/for/me")
109 | (is (= (s/render-route "/users/:email" {:email "fake@example.com"}))
110 | "/users/fake%40example.com"))
111 |
112 | (testing "it adds prefixes"
113 | (binding [s/*config* (atom {:prefix "#"})]
114 | (is (= (s/render-route "/users/:id" {:id 1})
115 | "#/users/1"))))
116 |
117 | (testing "it leaves param in string if not in map"
118 | (is (= (s/render-route "/users/:id" {})
119 | "/users/:id"))))
120 |
121 |
122 | (deftest defroute-test
123 | (testing "dispatch! with basic routes"
124 | (s/reset-routes!)
125 |
126 | (s/defroute "/" [] "BAM!")
127 | (s/defroute "/users" [] "ZAP!")
128 | (s/defroute "/users/:id" {:as params} params)
129 | (s/defroute "/users/:id/food/:food" {:as params} params)
130 |
131 | (is (= (s/dispatch! "/")
132 | "BAM!"))
133 | (is (= (s/dispatch! "")
134 | "BAM!"))
135 | (is (= (s/dispatch! "/users")
136 | "ZAP!"))
137 | (is (= (s/dispatch! "/users/1")
138 | {:id "1"}))
139 | (is (= (s/dispatch! "/users/kevin/food/bacon")
140 | {:id "kevin", :food "bacon"})))
141 |
142 | (testing "dispatch! with query-params"
143 | (s/reset-routes!)
144 | (s/defroute "/search-1" {:as params} params)
145 |
146 | (is (not (contains? (s/dispatch! "/search-1")
147 | :query-params)))
148 |
149 | (is (contains? (s/dispatch! "/search-1?foo=bar")
150 | :query-params))
151 |
152 | (s/defroute "/search-2" [query-params] query-params)
153 |
154 | (let [s "abc123 !@#$%^&*"
155 | [p1 p2] (take 2 (iterate #(apply str (shuffle %)) s))
156 | r (str "/search-2?"
157 | "foo=" (js/encodeURIComponent p1)
158 | "&bar=" (js/encodeURIComponent p2))]
159 | (is (= (s/dispatch! r)
160 | {:foo p1 :bar p2})))
161 |
162 | (s/defroute #"/([a-z]+)/search" {:as params}
163 | (let [[letters {:keys [query-params]}] params]
164 | [letters query-params]))
165 |
166 | (is (= (s/dispatch! "/abc/search")
167 | ["abc" nil]))
168 |
169 | (is (= (s/dispatch! "/abc/search?flavor=pineapple&walnuts=true")
170 | ["abc" {:flavor "pineapple" :walnuts "true"}])))
171 |
172 | (testing "s/dispatch! with regex routes"
173 | (s/reset-routes!)
174 | (s/defroute #"/([a-z]+)/(\d+)" [letters digits] [letters digits])
175 |
176 | (is (= (s/dispatch! "/xyz/123")
177 | ["xyz" "123"])))
178 |
179 | (testing "s/dispatch! with vector routes"
180 | (s/reset-routes!)
181 | (s/defroute ["/:num/socks" :num #"[0-9]+"] {:keys [num]} (str num"socks"))
182 |
183 | (is (= (s/dispatch! "/bacon/socks") nil))
184 | (is (= (s/dispatch! "/123/socks") "123socks")))
185 |
186 | (testing "dispatch! with named-routes and configured prefix"
187 | (s/reset-routes!)
188 |
189 | (binding [s/*config* (atom {:prefix "#"})]
190 | (s/defroute root-route "/" [] "BAM!")
191 | (s/defroute users-route "/users" [] "ZAP!")
192 | (s/defroute user-route "/users/:id" {:as params} params)
193 |
194 | (is (= (s/dispatch! (root-route))
195 | "BAM!"))
196 | (is (= (s/dispatch! (users-route))
197 | "ZAP!"))
198 | (is (= (s/dispatch! (user-route {:id "2"}))
199 | {:id "2"}))))
200 |
201 | (testing "named routes"
202 | (s/reset-routes!)
203 |
204 | (s/defroute food-path "/food/:food" [food])
205 | (s/defroute search-path "/search" [query-params])
206 |
207 | (is (fn? food-path))
208 | (is (fn? (s/defroute "/pickles" {})))
209 | (is (= (food-path {:food "biscuits"})
210 | "/food/biscuits"))
211 |
212 | (let [url (search-path {:query-params {:burritos 10, :tacos 200}})]
213 | (is (re-find #"burritos=10" url))
214 | (is (re-find #"tacos=200" url))))
215 |
216 | (testing "dispatch! with splat and no home route"
217 | (s/reset-routes!)
218 |
219 | (s/defroute "/users/:id" {:as params} params)
220 | (s/defroute "*" [] "SPLAT")
221 |
222 | (is (= (s/dispatch! "/users/1")
223 | {:id "1"}))
224 | (is (= (s/dispatch! "")
225 | "SPLAT"))
226 | (is (= (s/dispatch! "/users")
227 | "SPLAT"))))
228 |
229 | (deftest locate-route
230 | (testing "locate-route includes original route as last value in return vector"
231 | (s/reset-routes!)
232 |
233 | (s/defroute "/my-route/:some-param" [params])
234 | (s/defroute #"my-regexp-route-[a-zA-Z]*" [params])
235 | (s/defroute ["/my-vector-route/:some-param", :some-param #"[0-9]+"] [params])
236 |
237 | (is (= "/my-route/:some-param" (s/locate-route-value "/my-route/100")))
238 | ;; is this right? shouldn't this just return nil?
239 | (is (thrown? js/Error (s/locate-route-value "/not-a-route")))
240 |
241 | (let [[route & validations] (s/locate-route-value "/my-vector-route/100")
242 | {:keys [some-param]} (apply hash-map validations)]
243 | (is (= "/my-vector-route/:some-param" route))
244 | (is (= (.-source #"[0-9]+")
245 | (.-source some-param))))
246 | (is (thrown? js/Error (s/locate-route-value "/my-vector-route/foo")))
247 |
248 | (is (= (.-source #"my-regexp-route-[a-zA-Z]*")
249 | (.-source (s/locate-route-value "my-regexp-route-test"))))))
250 |
--------------------------------------------------------------------------------