├── .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 | [![Clojars Project](https://img.shields.io/clojars/v/clj-commons/secretary.svg)](https://clojars.org/clj-commons/secretary) 6 | [![cljdoc badge](https://cljdoc.org/badge/clj-commons/secretary)](https://cljdoc.org/d/clj-commons/secretary/CURRENT) 7 | [![CircleCI](https://circleci.com/gh/clj-commons/secretary/tree/master.svg?style=svg)](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 | --------------------------------------------------------------------------------