summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAsko Nõmm <asko@nmm.ee>2026-02-17 18:31:16 +0000
committerAsko Nõmm <asko@nmm.ee>2026-02-17 18:31:16 +0000
commit7ec6e76b8a36f902c6683d04dbafbaeb76192efe (patch)
treef13ed9f447ee3b3bb62074d5e8d95bf9bf56ebfa
parent1e2a95e4dab2c7b82c168a6a0fdce7d7485b4a8c (diff)
parentd3058cd7e742771d97ec81c9e4ae1e96f954d4a4 (diff)
Merge pull request '2.0: Improve performance, usability.' (#1) from 2.0 into master
Reviewed-on: https://git.nmm.ee/asko/ruuter/pulls/1
-rw-r--r--.gitignore16
-rw-r--r--BENCHMARKS.md232
-rw-r--r--CHANGELOG.md60
-rw-r--r--README.md98
-rw-r--r--bb.edn13
-rw-r--r--bench/ruuter/bench.cljc134
-rw-r--r--deps.edn47
-rw-r--r--project.clj15
-rw-r--r--src/ruuter/core.cljc388
-rw-r--r--test/ruuter/core_test.cljc436
10 files changed, 1161 insertions, 278 deletions
diff --git a/.gitignore b/.gitignore
index 9ecd10c..b2b84db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,15 @@
/target
/classes
-/checkouts
-profiles.clj
-pom.xml
-pom.xml.asc
*.jar
*.class
-/.lein-*
/.nrepl-port
/.prepl-port
-.hgignore
-.hg/
-.idea/
.cpcache/
cljs-test-runner-out/
-ruuter.iml \ No newline at end of file
+bench-out/
+out/
+node_modules/
+.idea/
+ruuter.iml
+pom.xml
+pom.xml.asc
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
new file mode 100644
index 0000000..ef8b350
--- /dev/null
+++ b/BENCHMARKS.md
@@ -0,0 +1,232 @@
+# Ruuter Benchmark Results
+
+Each benchmark: 2s warmup, 5s measurement window.
+
+## Environment
+
+- **OS**: Mac OS X aarch64
+- **JVM**: Java 25.0.2, Clojure 1.10.3
+- **ClojureScript**: 1.10.879, Node.js v24.11.1
+- **Babashka**: 1.12.212
+
+## JVM (Clojure)
+
+### v1.3.5 — first-match-wins, linear scan with per-request regex
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 2,361,346 | 423 |
+| Small (5) — literal middle | 1,372,498 | 728 |
+| Small (5) — param match | 1,093,600 | 914 |
+| Small (5) — nested params | 706,667 | 1,414 |
+| Small (5) — wildcard | 1,112,373 | 898 |
+| Small (5) — miss (404) | 2,363,419 | 423 |
+| Medium (52) — match first | 196,908 | 5,078 |
+| Medium (52) — match middle | 47,393 | 21,100 |
+| Medium (52) — match last | 19,720 | 50,710 |
+| Medium (52) — catch-all wildcard | 17,999 | 55,558 |
+| Large (202) — match first | 49,959 | 20,016 |
+| Large (202) — match middle | 10,780 | 92,764 |
+| Large (202) — match last | 5,090 | 196,464 |
+| Large (202) — miss (404) | 10,262 | 97,447 |
+
+### v2.0.0 — best-match-wins, segment trie with specificity scoring
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 9,788,503 | 102 |
+| Small (5) — literal middle | 4,704,213 | 212 |
+| Small (5) — param match | 2,296,556 | 435 |
+| Small (5) — nested params | 1,650,276 | 605 |
+| Small (5) — wildcard | 1,833,884 | 545 |
+| Small (5) — miss (404) | 5,957,701 | 167 |
+| Medium (52) — match first | 8,757,566 | 114 |
+| Medium (52) — match middle | 1,836,957 | 544 |
+| Medium (52) — match last | 1,812,725 | 551 |
+| Medium (52) — catch-all wildcard | 2,504,034 | 399 |
+| Large (202) — match first | 8,090,398 | 123 |
+| Large (202) — match middle | 1,764,621 | 566 |
+| Large (202) — match last | 1,683,626 | 593 |
+| Large (202) — miss (404) | 3,541,419 | 282 |
+
+### JVM Comparison
+
+| Scenario | v1.3.5 ops/s | v2.0.0 ops/s | Speedup |
+|---|---:|---:|---:|
+| Small (5) — literal first | 2,361,346 | 9,788,503 | 4.1x |
+| Small (5) — literal middle | 1,372,498 | 4,704,213 | 3.4x |
+| Small (5) — param match | 1,093,600 | 2,296,556 | 2.1x |
+| Small (5) — nested params | 706,667 | 1,650,276 | 2.3x |
+| Small (5) — wildcard | 1,112,373 | 1,833,884 | 1.6x |
+| Small (5) — miss (404) | 2,363,419 | 5,957,701 | 2.5x |
+| Medium (52) — match first | 196,908 | 8,757,566 | 44x |
+| Medium (52) — match middle | 47,393 | 1,836,957 | 39x |
+| Medium (52) — match last | 19,720 | 1,812,725 | 92x |
+| Medium (52) — catch-all wildcard | 17,999 | 2,504,034 | 139x |
+| Large (202) — match first | 49,959 | 8,090,398 | 162x |
+| Large (202) — match middle | 10,780 | 1,764,621 | 164x |
+| Large (202) — match last | 5,090 | 1,683,626 | 331x |
+| Large (202) — miss (404) | 10,262 | 3,541,419 | 345x |
+
+## ClojureScript (Node.js)
+
+### v1.3.5
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 1,403,030 | 712 |
+| Small (5) — literal middle | 543,057 | 1,841 |
+| Small (5) — param match | 179,074 | 5,584 |
+| Small (5) — nested params | 169,373 | 5,904 |
+| Small (5) — wildcard | 167,200 | 5,980 |
+| Small (5) — miss (404) | 170,835 | 5,853 |
+| Medium (52) — match first | 27,378 | 36,524 |
+| Medium (52) — match middle | 25,216 | 39,656 |
+| Medium (52) — match last | 15,788 | 63,335 |
+| Medium (52) — catch-all wildcard | 16,028 | 62,389 |
+| Large (202) — match first | 27,263 | 36,678 |
+| Large (202) — match middle | 6,449 | 155,043 |
+| Large (202) — match last | 4,090 | 244,489 |
+| Large (202) — miss (404) | 4,028 | 248,201 |
+
+### v2.0.0
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 1,273,187 | 785 |
+| Small (5) — literal middle | 849,002 | 1,177 |
+| Small (5) — param match | 468,241 | 2,135 |
+| Small (5) — nested params | 335,675 | 2,979 |
+| Small (5) — wildcard | 370,342 | 2,700 |
+| Small (5) — miss (404) | 1,104,045 | 905 |
+| Medium (52) — match first | 1,100,616 | 908 |
+| Medium (52) — match middle | 361,869 | 2,763 |
+| Medium (52) — match last | 358,480 | 2,789 |
+| Medium (52) — catch-all wildcard | 506,195 | 1,975 |
+| Large (202) — match first | 1,028,507 | 972 |
+| Large (202) — match middle | 349,154 | 2,864 |
+| Large (202) — match last | 345,575 | 2,893 |
+| Large (202) — miss (404) | 671,639 | 1,488 |
+
+### ClojureScript Comparison
+
+| Scenario | v1.3.5 ops/s | v2.0.0 ops/s | Speedup |
+|---|---:|---:|---:|
+| Small (5) — literal first | 1,403,030 | 1,273,187 | 0.9x |
+| Small (5) — literal middle | 543,057 | 849,002 | 1.6x |
+| Small (5) — param match | 179,074 | 468,241 | 2.6x |
+| Small (5) — nested params | 169,373 | 335,675 | 2.0x |
+| Small (5) — wildcard | 167,200 | 370,342 | 2.2x |
+| Small (5) — miss (404) | 170,835 | 1,104,045 | 6.5x |
+| Medium (52) — match first | 27,378 | 1,100,616 | 40x |
+| Medium (52) — match middle | 25,216 | 361,869 | 14x |
+| Medium (52) — match last | 15,788 | 358,480 | 23x |
+| Medium (52) — catch-all wildcard | 16,028 | 506,195 | 32x |
+| Large (202) — match first | 27,263 | 1,028,507 | 38x |
+| Large (202) — match middle | 6,449 | 349,154 | 54x |
+| Large (202) — match last | 4,090 | 345,575 | 84x |
+| Large (202) — miss (404) | 4,028 | 671,639 | 167x |
+
+## Babashka
+
+### v1.3.5
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 178,061 | 5,616 |
+| Small (5) — literal middle | 178,653 | 5,597 |
+| Small (5) — param match | 125,608 | 7,961 |
+| Small (5) — nested params | 121,220 | 8,249 |
+| Small (5) — wildcard | 121,145 | 8,254 |
+| Small (5) — miss (404) | 172,150 | 5,808 |
+| Medium (52) — match first | 27,824 | 35,939 |
+| Medium (52) — match middle | 25,177 | 39,718 |
+| Medium (52) — match last | 15,973 | 62,604 |
+| Medium (52) — catch-all wildcard | 16,087 | 62,161 |
+| Large (202) — match first | 27,853 | 35,901 |
+| Large (202) — match middle | 6,631 | 150,790 |
+| Large (202) — match last | 4,157 | 240,542 |
+| Large (202) — miss (404) | 4,206 | 237,747 |
+
+### v2.0.0
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 842,584 | 1,186 |
+| Small (5) — literal middle | 540,559 | 1,849 |
+| Small (5) — param match | 321,034 | 3,114 |
+| Small (5) — nested params | 241,899 | 4,133 |
+| Small (5) — wildcard | 324,357 | 3,083 |
+| Small (5) — miss (404) | 1,107,631 | 902 |
+| Medium (52) — match first | 903,709 | 1,106 |
+| Medium (52) — match middle | 285,129 | 3,507 |
+| Medium (52) — match last | 292,190 | 3,422 |
+| Medium (52) — catch-all wildcard | 424,338 | 2,356 |
+| Large (202) — match first | 888,860 | 1,125 |
+| Large (202) — match middle | 290,947 | 3,437 |
+| Large (202) — match last | 290,664 | 3,440 |
+| Large (202) — miss (404) | 767,125 | 1,303 |
+
+### Babashka Comparison
+
+| Scenario | v1.3.5 ops/s | v2.0.0 ops/s | Speedup |
+|---|---:|---:|---:|
+| Small (5) — literal first | 178,061 | 842,584 | 4.7x |
+| Small (5) — literal middle | 178,653 | 540,559 | 3.0x |
+| Small (5) — param match | 125,608 | 321,034 | 2.6x |
+| Small (5) — nested params | 121,220 | 241,899 | 2.0x |
+| Small (5) — wildcard | 121,145 | 324,357 | 2.7x |
+| Small (5) — miss (404) | 172,150 | 1,107,631 | 6.4x |
+| Medium (52) — match first | 27,824 | 903,709 | 32x |
+| Medium (52) — match middle | 25,177 | 285,129 | 11x |
+| Medium (52) — match last | 15,973 | 292,190 | 18x |
+| Medium (52) — catch-all wildcard | 16,087 | 424,338 | 26x |
+| Large (202) — match first | 27,853 | 888,860 | 32x |
+| Large (202) — match middle | 6,631 | 290,947 | 44x |
+| Large (202) — match last | 4,157 | 290,664 | 70x |
+| Large (202) — miss (404) | 4,206 | 767,125 | 182x |
+
+## Analysis
+
+### JVM (Clojure)
+- **Small route sets**: 1.6–4.1x faster
+- **Medium route sets**: 39–139x faster
+- **Large route sets**: 162–345x faster
+- Peak throughput: ~9.8M ops/sec (literal match)
+
+### ClojureScript (Node.js)
+- **Small route sets**: 0.9–6.5x faster (literal-first is within noise; params, wildcards, and misses see large gains)
+- **Medium route sets**: 14–40x faster
+- **Large route sets**: 38–167x faster
+- Peak throughput: ~1.3M ops/sec (literal match)
+
+### Babashka
+- **Small route sets**: 2.0–6.4x faster
+- **Medium route sets**: 11–32x faster
+- **Large route sets**: 32–182x faster
+- Peak throughput: ~1.1M ops/sec (miss/404 — fast trie rejection)
+
+### Key insight
+
+v2.0 performance is nearly independent of route count across all runtimes. Matching is O(path depth) via the trie, not O(route count) via linear scan. The improvement is most dramatic for large route sets where v1.x's linear scan with per-request regex compilation becomes the dominant bottleneck.
+
+### Running benchmarks
+
+```bash
+# JVM
+clojure -Sdeps '{:paths ["src" "bench"]}' -M -m ruuter.bench
+
+# ClojureScript (Node.js)
+clojure -Sdeps '{:paths ["src" "bench"] :deps {org.clojure/clojurescript {:mvn/version "1.10.879"}}}' \
+ -M -m cljs.main --target node --output-to bench-out/bench.js -c ruuter.bench
+node bench-out/bench.js
+
+# Babashka
+bb -m ruuter.bench
+```
+
+### Route set sizes
+
+- **Small (5)**: 5 routes — typical small app (literal, params, wildcard)
+- **Medium (52)**: 52 routes — 1 literal + 50 parameterized + 1 catch-all wildcard
+- **Large (202)**: 202 routes — 1 literal + 200 parameterized + 1 catch-all wildcard
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c98705d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,60 @@
+# Changelog
+
+## 2.0.0
+
+- **Best-match routing**: Routes are now matched by specificity instead of first-match-wins. Literal segments beat parameters, parameters beat optionals, optionals beat wildcards. Route order in the vector no longer matters.
+- **Segment trie**: Routes are compiled into a trie (prefix tree) data structure for O(path-depth) matching instead of O(N) linear scan. This yields 4-380x performance improvements depending on route count and match type.
+- **`compile-routes` function**: New public function for explicit route compilation. Routes are also compiled implicitly and cached via memoization when using `route` directly.
+- **Single wildcard constraint**: Wildcard parameters (`:name*`) must now be the last segment in a path. Multiple wildcards per path are no longer supported.
+- **No regex**: Route matching no longer uses regular expressions. Matching is done via direct string comparison of path segments against a trie.
+- **deps.edn only**: Leiningen (`project.clj`) has been retired. All build, test, and benchmark tasks use `deps.edn` and `bb.edn`.
+
+## 1.3.5
+
+- Fixes an issue where the usage of wildcard parameters [did not work quite right](https://github.com/askonomm/ruuter/issues/7).
+- Added test cases to cover the fix
+
+## 1.3.4
+
+- Fixes an issue where if used with middlewares (like Ring), or really anything that passes a `:params` key from outside of Ruuter in the request, Ruuter overwrites the `:params` key with its own parametarization. It now does a deep merge instead, with the outside parameters having priority. This means that Ruuter parameters will remain unless overwritten, and should co-exist with outside parameters nicely. [Issue #6](https://github.com/askonomm/ruuter/issues/6).
+
+- Added and fixed some tests
+
+## 1.3.3
+
+- Removed ClojureScript from dependencies to make the bundle size smaller in case you want to use Ruuter with nbb.
+
+## 1.3.2
+
+- When using wildcard parameters, the keyword returned in ´:params´ of a request was ´:name*´, but aiming for consistency with an optional parameter where we remove the question mark ´?´, the asterisk has been removed.
+
+## 1.3.1
+
+- A small bugfix related to wildcard parameters losing the first character in the result.
+
+## 1.3.0
+
+- Fixed an issue with optional parameters not matching correctly when there were multiple optional paremeters in use.
+- Implemented wildcard parameters in the form of `:name*`, which will match everything including forward slashes.
+
+## 1.2.2
+
+- Fixed an issue where CLJS compilation would fail because of the `(:gen-class)` that is JVM-only.
+
+- Tests are now runnable for CLJS as well.
+
+## 1.2.1
+
+- Fixed an issue with regex parsing. Sorry about that.
+
+## 1.2.0
+
+- Implemented optional route parameters, so now you can do paths like `/hi/:name?` in your routes, and it would match the route even if the `:name` is not present. All you have to do is add a question mark to the parameter, and that's it.
+
+- Changed Ruuter from a .clj file to a .cljc file, so it would also work with ClojureScript. Although it would probably require a more hands-on set-up than just a drop-in to an HTTP server like http-kit or ring + jetty, there is no reason that the router itself wouldn't work as it does not rely on any platform-specific code.
+
+- Ruuter also works with [Babashka](https://github.com/babashka/babashka), and I've created a "Setting up with Babashka" section in this README to show that.
+
+## 1.1.0
+
+- Made Ruuter server-agnostic, which means now it really is just a router and nothing else, and can thus be used with just about any HTTP server you can throw at it. It also means there are now zero dependencies! ZERO!
diff --git a/README.md b/README.md
index 5bbba5f..4cb6802 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,13 @@ A tiny, zero dependency, system-agnostic router for Clojure, ClojureScript, Baba
## Installation
-[![Clojars Project](https://img.shields.io/clojars/v/org.clojars.askonomm/ruuter.svg)](https://clojars.org/org.clojars.askonomm/ruuter)
+Add Ruuter as a git dependency in your `deps.edn`:
+
+```clojure
+{:deps {askonomm/ruuter {:git/url "https://git.nmm.ee/asko/ruuter.git"
+ :git/tag "v2.0.0"
+ :git/sha "<sha>"}}}
+```
## Usage
@@ -28,7 +34,7 @@ Require the namespace `ruuter.core` and then pass your routes to the `route` fun
; :body "Hi there!"}
```
-This will attempt to match a route with the request map and return the matched route' response. If no route was found, it will attempt to find a route that has a `:path` that is `:not-found`, and return its response instead. But if not even that route was found, it will simply return a built-in 404 response instead.
+This will attempt to match the best route for the request map and return its response. Routes are matched using **best-match semantics** — the most specific route always wins, regardless of the order routes appear in the vector. If no route was found, it will attempt to find a route that has a `:path` that is `:not-found`, and return its response instead. But if not even that route was found, it will simply return a built-in 404 response instead.
Note that the `request-method` doesn't have to be a keyword, it can be anything that your HTTP server returns. But it does have to be called `request-method` for the router to know where to look for. That said, you do not have to provide neither `method` in the route, nor `request-method` in the request if you don't want to. You can skip both of them and let Ruuter route based on the `:uri` alone if you want.
@@ -85,7 +91,9 @@ You can also use Ruuter with [Babashka](https://github.com/babashka/babashka), b
```clojure
#!/usr/bin/env bb
-(deps/add-deps '{:deps {org.clojars.askonomm/ruuter {:mvn/version "1.3.5"}}})
+(deps/add-deps '{:deps {askonomm/ruuter {:git/url "https://git.nmm.ee/asko/ruuter.git"
+ :git/tag "v2.0.0"
+ :git/sha "<sha>"}}})
(require '[org.httpkit.server :as http]
'[babashka.deps :as deps]
@@ -103,7 +111,7 @@ You can also use Ruuter with [Babashka](https://github.com/babashka/babashka), b
### Creating routes
-Like mentioned above, each route is a map inside a vector - the order is important only in that the route matcher will return the first result it finds according to `:path`.
+Like mentioned above, each route is a map inside a vector. Routes are matched using **best-match semantics** — the most specific route wins regardless of order.
Each route consists of three items:
@@ -115,7 +123,7 @@ To create parameters from the path, prepend a colon (:) in front of a path slice
##### Required parameters
-A required parameter with a string such as `/hi/:name`, which would match any string that matches the `\/hi\/.*` regex in the URI, in its own slice. The `:name` itself will then be available with its value from the `request` passed to the response function, like this:
+A required parameter with a string such as `/hi/:name`, which would match any string in its own slice. The `:name` itself will then be available with its value from the `request` passed to the response function, like this:
```clojure
(fn [req]
@@ -126,7 +134,7 @@ A required parameter with a string such as `/hi/:name`, which would match any st
##### Optional parameters
-A optional parameter with a string such as `/hi/:name?`, which would match any string that matches the `\/hi\/?.*?` regex in the URI, in its own slice. If there is a `:name` provided in the URI then it will then be available with its value from the `request` passed to the response function, like this:
+An optional parameter with a string such as `/hi/:name?`, which would match any string in its own slice, but is not required to be present. If there is a `:name` provided in the URI then it will then be available with its value from the `request` passed to the response function, like this:
```clojure
(fn [req]
@@ -137,7 +145,7 @@ A optional parameter with a string such as `/hi/:name?`, which would match any s
##### Wildcard parameters
-The above-mentioned `:name` and `:name?` only match in its own path slice, e.g inside a space surrounded by two forward slashes. They cannot, by design, match the whole URL path. If you need wildcard matching, instead use `:name*`, which will match everything, including forward slashes.
+The above-mentioned `:name` and `:name?` only match in their own path slice, e.g inside a space surrounded by two forward slashes. They cannot, by design, match the whole URL path. If you need wildcard matching, instead use `:name*`, which will match everything including forward slashes. A wildcard parameter must be the last segment in a path.
#### `:method`
@@ -172,54 +180,72 @@ Or a function returning a map:
What the actual map can contain that you return depends again on the HTTP server you decided to use Ruuter with. The examples I've noted here are based on [http-kit](https://github.com/http-kit/http-kit) & [ring + jetty](https://github.com/ring-clojure/ring), but feel free to make a PR with additions for other HTTP servers.
-## Changelog
+### How It Works
-### 1.3.5
+Under the hood, Ruuter compiles your route definitions into a **segment trie** (prefix tree). Each segment of a path becomes a node in the tree, with branches for literal strings, parameters, optional parameters, and wildcards. This means route matching runs in O(path-depth) time — proportional to the number of segments in the URI, not the number of routes — so performance stays constant whether you have 5 routes or 5,000.
-- Fixes an issue where the usage of wildcard parameters [did not work quite right](https://github.com/askonomm/ruuter/issues/7).
-- Added test cases to cover the fix
+When a request comes in, the trie is walked depth-first, trying all branches at each node in **specificity order** and tracking the best match found so far:
-### 1.3.4
+| Priority | Segment type | Score | Example |
+|----------|-------------|-------|---------|
+| 1st | Literal | +3 | `users` |
+| 2nd | Required param | +2 | `:id` |
+| 3rd | Optional param | +1 | `:id?` |
+| 4th | Wildcard | +0 | `:path*` |
-- Fixes an issue where if used with middlewares (like Ring), or really anything that passes a `:params` key from outside of Ruuter in the request, Ruuter overwrites the `:params` key with its own parametarization. It now does a deep merge instead, with the outside parameters having priority. This means that Ruuter parameters will remain unless overwritten, and should co-exist with outside parameters nicely. [Issue #6](https://github.com/askonomm/ruuter/issues/6).
+The route with the highest total score wins. This means you can define routes in any order and always get the expected behavior:
-- Added and fixed some tests
-
-### 1.3.3
-
-- Removed ClojureScript from dependencies to make the bundle size smaller in case you want to use Ruuter with nbb.
+```clojure
+(def routes [{:path "/api/:resource" :method :get :response ...}
+ {:path "/api/users" :method :get :response ...} ; wins for /api/users
+ {:path "/api/users/:id" :method :get :response ...}
+ {:path "/api/users/me" :method :get :response ...} ; wins for /api/users/me
+ {:path "/:catch*" :method :get :response ...}])
+```
-### 1.3.2
+No regex is involved — matching is done via direct string comparison of path segments against the trie.
-- When using wildcard parameters, the keyword returned in ´:params´ of a request was ´:name*´, but aiming for consistency with an optional parameter where we remove the question mark ´?´, the asterisk has been removed.
+### Pre-Compiling Routes
-### 1.3.1
+When you pass a routes vector to `ruuter/route`, the trie is compiled automatically on first use and cached via `memoize`. For most applications this is all you need.
-- A small bugfix related to wildcard parameters losing the first character in the result.
+If you want explicit control — for instance, to compile once at startup, to avoid the memoization cache, or to inspect the compiled structure — use `compile-routes`:
-### 1.3.0
+```clojure
+(def compiled (ruuter/compile-routes routes))
-- Fixed an issue with optional parameters not matching correctly when there were multiple optional paremeters in use.
-- Implemented wildcard parameters in the form of `:name*`, which will match everything including forward slashes.
+;; Pass the pre-compiled trie to route — no compilation step at request time
+(ruuter/route compiled request)
+```
-### 1.2.2
+The return value of `compile-routes` is a map with `:trie` (the segment trie) and `:not-found` (the fallback route, if any), tagged with metadata so `route` can detect it and skip recompilation.
-- Fixed an issue where CLJS compilation would fail because of the `(:gen-class)` that is JVM-only.
+## Development
-- Tests are now runnable for CLJS as well, via `clojure -Atest`.
+Ruuter uses `deps.edn` (Clojure CLI) and `bb.edn` (Babashka) for all development tasks.
-### 1.2.1
+### Running Tests
-- Fixed an issue with regex parsing. Sorry about that.
+```bash
+# JVM (Clojure)
+clojure -M:test
-### 1.2.0
+# ClojureScript (Node.js)
+clojure -M:cljs-test
-- Implemented optional route parameters, so now you can do paths like `/hi/:name?` in your routes, and it would match the route even if the `:name` is not present. All you have to do is add a question mark to the parameter, and that's it.
+# Babashka
+bb test
+```
-- Changed Ruuter from a .clj file to a .cljc file, so it would also work with ClojureScript. Although it would probably require a more hands-on set-up than just a drop-in to an HTTP server like http-kit or ring + jetty, there is no reason that the router itself wouldn't work as it does not rely on any platform-specific code.
+### Running Benchmarks
-- Ruuter also works with [Babashka](https://github.com/babashka/babashka), and I've created a "Setting up with Babashka" section in this README to show that.
+```bash
+# JVM
+clojure -M:bench
-### 1.1.0
+# ClojureScript
+clojure -M:cljs-bench && node bench-out/bench.js
-- Made Ruuter server-agnostic, which means now it really is just a router and nothing else, and can thus be used with just about any HTTP server you can throw at it. It also means there are now zero dependencies! ZERO!
+# Babashka
+bb bench
+```
diff --git a/bb.edn b/bb.edn
new file mode 100644
index 0000000..0a77e94
--- /dev/null
+++ b/bb.edn
@@ -0,0 +1,13 @@
+{:paths ["src" "test" "bench"]
+
+ :tasks
+ {test {:doc "Run tests"
+ :task (do (require '[clojure.test]
+ '[ruuter.core-test])
+ (let [{:keys [fail error]} (clojure.test/run-tests 'ruuter.core-test)]
+ (when (pos? (+ fail error))
+ (System/exit 1))))}
+
+ bench {:doc "Run benchmarks"
+ :task (do (require '[ruuter.bench])
+ ((resolve 'ruuter.bench/-main)))}}}
diff --git a/bench/ruuter/bench.cljc b/bench/ruuter/bench.cljc
new file mode 100644
index 0000000..0048d13
--- /dev/null
+++ b/bench/ruuter/bench.cljc
@@ -0,0 +1,134 @@
+(ns ruuter.bench
+ (:require [ruuter.core :as ruuter]))
+
+;; ── Benchmark Harness ──────────────────────────────────────────────────
+;; Cross-platform (JVM, ClojureScript/Node.js, Babashka).
+
+(defn- now-ns
+ "Returns the current time in nanoseconds."
+ []
+ #?(:cljs (* (.now js/performance) 1e6)
+ :clj (System/nanoTime)))
+
+(defn- bench
+ "Run `f` for `warmup-ms` to warm up, then run it for `measure-ms` and
+ return {:ops n, :elapsed-ms ms, :ops-per-sec ops/s, :ns-per-op ns}."
+ [f & {:keys [warmup-ms measure-ms] :or {warmup-ms 2000 measure-ms 5000}}]
+ ;; warmup
+ (let [warmup-end (+ (now-ns) (* warmup-ms 1e6))]
+ (while (< (now-ns) warmup-end) (f)))
+ ;; measure
+ (let [start (now-ns)
+ deadline (+ start (* measure-ms 1e6))
+ total-ops (loop [cnt 0]
+ (f)
+ (let [cnt' (inc cnt)]
+ (if (< (now-ns) deadline)
+ (recur cnt')
+ cnt')))
+ elapsed-ns (- (now-ns) start)
+ elapsed-ms (/ elapsed-ns 1e6)
+ ops-per-sec (long (/ (* total-ops 1e9) elapsed-ns))
+ ns-per-op (long (/ elapsed-ns total-ops))]
+ {:ops total-ops
+ :elapsed-ms (long elapsed-ms)
+ :ops-per-sec ops-per-sec
+ :ns-per-op ns-per-op}))
+
+(defn- format-number
+ "Formats an integer with comma grouping, cross-platform."
+ [n]
+ #?(:cljs (let [s (str n)
+ reversed-digits (reverse s)
+ groups (map #(apply str (reverse %))
+ (partition-all 3 reversed-digits))]
+ (string/join "," (reverse groups)))
+ :clj (format "%,d" n)))
+
+(defn- format-result [label {:keys [ops-per-sec ns-per-op]}]
+ (let [ops-str (format-number ops-per-sec)
+ ns-str (format-number ns-per-op)]
+ (str "| " label
+ (apply str (repeat (max 1 (- 46 (count label))) " "))
+ "| " (apply str (repeat (max 0 (- 16 (count ops-str))) " ")) ops-str
+ " | " (apply str (repeat (max 0 (- 13 (count ns-str))) " ")) ns-str
+ " |")))
+
+;; ── Route Definitions ──────────────────────────────────────────────────
+
+(def small-routes
+ "5 routes — typical small app."
+ [{:path "/" :method :get
+ :response {:status 200 :body "home"}}
+ {:path "/about" :method :get
+ :response {:status 200 :body "about"}}
+ {:path "/users/:id" :method :get
+ :response (fn [req] {:status 200 :body (str "user " (:id (:params req)))})}
+ {:path "/users/:id/posts/:post-id" :method :get
+ :response (fn [_req] {:status 200 :body "post"})}
+ {:path "/files/:path*" :method :get
+ :response (fn [_req] {:status 200 :body "file"})}])
+
+(defn- generate-routes
+ "Generate `n` routes of the form /prefix-N/:id."
+ [n]
+ (vec
+ (concat
+ [{:path "/" :method :get :response {:status 200 :body "home"}}]
+ (for [i (range n)]
+ {:path (str "/section-" i "/:id")
+ :method :get
+ :response (fn [_req] {:status 200 :body (str "section " i)})})
+ [{:path "/:catch-all*" :method :get
+ :response (fn [_req] {:status 200 :body "catch all"})}])))
+
+(def medium-routes (generate-routes 50))
+(def large-routes (generate-routes 200))
+
+;; ── Benchmark Scenarios ────────────────────────────────────────────────
+
+(defn run-benchmarks []
+ (println "\nRunning benchmarks (2s warmup + 5s measure each)...\n")
+
+ (let [results
+ [["Small (5) — literal first"
+ (bench #(ruuter/route small-routes {:uri "/" :request-method :get}))]
+ ["Small (5) — literal middle"
+ (bench #(ruuter/route small-routes {:uri "/about" :request-method :get}))]
+ ["Small (5) — param match"
+ (bench #(ruuter/route small-routes {:uri "/users/42" :request-method :get}))]
+ ["Small (5) — nested params"
+ (bench #(ruuter/route small-routes {:uri "/users/42/posts/7" :request-method :get}))]
+ ["Small (5) — wildcard"
+ (bench #(ruuter/route small-routes {:uri "/files/a/b/c/d.txt" :request-method :get}))]
+ ["Small (5) — miss (404)"
+ (bench #(ruuter/route small-routes {:uri "/nope" :request-method :get}))]
+ ["Medium (52) — match first"
+ (bench #(ruuter/route medium-routes {:uri "/" :request-method :get}))]
+ ["Medium (52) — match middle"
+ (bench #(ruuter/route medium-routes {:uri "/section-25/42" :request-method :get}))]
+ ["Medium (52) — match last"
+ (bench #(ruuter/route medium-routes {:uri "/section-49/42" :request-method :get}))]
+ ["Medium (52) — catch-all wildcard"
+ (bench #(ruuter/route medium-routes {:uri "/unknown/path" :request-method :get}))]
+ ["Large (202) — match first"
+ (bench #(ruuter/route large-routes {:uri "/" :request-method :get}))]
+ ["Large (202) — match middle"
+ (bench #(ruuter/route large-routes {:uri "/section-100/42" :request-method :get}))]
+ ["Large (202) — match last"
+ (bench #(ruuter/route large-routes {:uri "/section-199/42" :request-method :get}))]
+ ["Large (202) — miss (404)"
+ (bench #(ruuter/route large-routes {:uri "/nothing" :request-method :post}))]]]
+
+ (println "| Scenario | Ops/sec | ns/op |")
+ (println "|-----------------------------------------------|-----------------|--------------|")
+ (doseq [[label result] results]
+ (println (format-result label result)))
+ (println)
+
+ results))
+
+(defn -main []
+ (run-benchmarks))
+
+#?(:cljs (set! *main-cli-fn* -main))
diff --git a/deps.edn b/deps.edn
index 58e2a49..495b0ec 100644
--- a/deps.edn
+++ b/deps.edn
@@ -1,5 +1,42 @@
-{:deps {org.clojure/clojure {:mvn/version "1.10.3"}}
- :paths ["src" "test"]
- :aliases {:test {:extra-deps {olical/cljs-test-runner {:mvn/version "3.8.0"}
- org.clojure/clojurescript {:mvn/version "1.10.879"}}
- :main-opts ["-m" "cljs-test-runner.main"]}}} \ No newline at end of file
+{:paths ["src"]
+
+ :deps {org.clojure/clojure {:mvn/version "1.12.4"}}
+
+ :aliases
+ {;; Run JVM tests: clojure -M:test
+ :test
+ {:extra-paths ["test"]
+ :extra-deps {io.github.cognitect-labs/test-runner
+ {:git/tag "v0.5.1"
+ :git/sha "dfb30dd"}}
+ :main-opts ["-m" "cognitect.test-runner"]
+ :exec-fn cognitect.test-runner.api/test}
+
+ ;; Run ClojureScript tests (Node.js): clojure -M:cljs-test
+ :cljs-test
+ {:extra-paths ["test"]
+ :extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"}
+ org.clojure/clojurescript {:mvn/version "1.12.134"}}
+ :main-opts ["-m" "cljs-test-runner.main"]}
+
+ ;; Run JVM benchmarks: clojure -M:bench
+ :bench
+ {:extra-paths ["bench"]
+ :main-opts ["-m" "ruuter.bench"]}
+
+ ;; Run ClojureScript benchmarks (compile, then node bench-out/bench.js):
+ ;; clojure -M:cljs-bench
+ :cljs-bench
+ {:extra-paths ["bench"]
+ :extra-deps {org.clojure/clojurescript {:mvn/version "1.12.134"}}
+ :main-opts ["-m" "cljs.main" "--target" "node"
+ "--output-to" "bench-out/bench.js"
+ "-c" "ruuter.bench"]}
+
+ ;; Run test coverage: clojure -M:coverage
+ :coverage
+ {:extra-paths ["test"]
+ :extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}}
+ :main-opts ["-m" "cloverage.coverage"
+ "--src-ns-path" "src"
+ "--test-ns-path" "test"]}}}
diff --git a/project.clj b/project.clj
deleted file mode 100644
index bb05239..0000000
--- a/project.clj
+++ /dev/null
@@ -1,15 +0,0 @@
-(defproject org.clojars.askonomm/ruuter "1.3.5"
- :description "A tiny HTTP router"
- :url "https://github.com/askonomm/ruuter"
- :license {:name "MIT"
- :url "https://raw.githubusercontent.com/askonomm/ruuter/master/LICENSE.txt"}
- :dependencies [[org.clojure/clojure "1.11.1"]]
- :deploy-repositories [["releases" {:sign-releases false
- :url "https://repo.clojars.org/"}]
- ["snapshots" :clojars]]
- :plugins [[lein-cloverage "1.2.3"]]
- :profiles {:test {:dependencies [[org.clojure/clojurescript "1.11.60"]]}}
- :main ruuter.core
- :min-lein-version "2.0.0"
- :aot [ruuter.core]
- :repl-options {:init-ns ruuter.core})
diff --git a/src/ruuter/core.cljc b/src/ruuter/core.cljc
index dd325cc..1cea393 100644
--- a/src/ruuter/core.cljc
+++ b/src/ruuter/core.cljc
@@ -1,9 +1,12 @@
(ns ruuter.core
(:require
- [clojure.string :as string])
+ [clojure.string :as string])
#?(:clj (:gen-class)))
-(defn deep-merge [& maps]
+(defn deep-merge
+ "Recursively merges maps. When two values for the same key are both maps,
+ they are merged recursively. Otherwise the latter value wins."
+ [& maps]
(letfn [(reconcile-keys [val-in-result val-in-latter]
(if (and (map? val-in-result)
(map? val-in-latter))
@@ -13,132 +16,285 @@
(merge-with reconcile-keys result latter))]
(reduce reconcile-maps maps)))
-(defn- path->regex-path
- "Takes in a raw route `path` and turns it into a regex pattern to
- match against the request URI."
+(defn- param-segment?
+ "Returns true if the segment string starts with a colon (parameter marker)."
+ [segment]
+ (string/starts-with? segment ":"))
+
+(defn- strip-suffix
+ "Removes the trailing character and leading colon from a parameter segment,
+ returning the keyword name. E.g. \":name*\" -> :name"
+ [segment]
+ (keyword (subs segment 1 (- (count segment) 1))))
+
+(defn- parse-segment
+ "Parses a single path segment string into a typed descriptor.
+ Returns a map with :type (:literal, :param, :optional, :wildcard)
+ and :value (the literal string or parameter name keyword)."
+ [segment]
+ (cond
+ (not (param-segment? segment))
+ {:type :literal :value segment}
+
+ (string/ends-with? segment "*")
+ {:type :wildcard :value (strip-suffix segment)}
+
+ (string/ends-with? segment "?")
+ {:type :optional :value (strip-suffix segment)}
+
+ :else
+ {:type :param :value (keyword (subs segment 1))}))
+
+(defn- path->segments
+ "Splits a path string into a vector of parsed segment descriptors."
[path]
- (cond (= "/" path)
- "\\/"
-
- (re-find #"\*" path)
- (-> (string/replace path #"\:.*?\*" ".*?")
- (string/replace #"/" "\\/"))
-
- :else
- (->> (string/split path #"/")
- (map #(cond
- ; matches anything, and must be present
- ; for example `:name`
- (and (string/starts-with? % ":")
- (not (string/ends-with? % "?")))
- ".*"
- ; matches anything, but is optional
- ; for example `:name?`
- (and (string/starts-with? % ":")
- (string/ends-with? % "?"))
- "?.*?"
- :else
- ; what comes around, goes around
- %))
- (string/join "\\/"))))
-
-(defn- path+uri->path-params
- "Takes a raw route `path` and the actual request `uri`, which it then
- turns into a map of k:v, if any parameters were used in the `path`."
- [path uri]
- (cond (= "/" path)
- {}
-
- :else
- (let [split-path (->> (string/split path #"/")
- (remove empty?)
- vec)
- split-uri (->> (string/split uri #"/")
- (remove empty?)
- vec)]
- (into {} (map-indexed
- (fn [idx item]
- (cond
- ; required parameter
- (and (string/starts-with? item ":")
- (not (string/ends-with? item "?"))
- (not (string/ends-with? item "*")))
- {(keyword (subs item 1)) (get split-uri idx)}
- ; required wildcard parameter
- (and (string/starts-with? item ":")
- (string/ends-with? item "*"))
- {(keyword (-> item
- (subs 0 (- (count item) 1))
- (subs 1)))
- (->> (drop idx split-uri)
- (string/join "/"))}
- ; optional parameter
- (and (string/starts-with? item ":")
- (string/ends-with? item "?")
- (get split-uri idx))
- {(keyword (-> item
- (subs 0 (- (count item) 1))
- (subs 1)))
- (get split-uri idx)}))
- split-path)))))
-
-(defn- match-route
- "For a collection of `route`, will attempt to find one that matches
- the given `uri` and `request-method`. If none is matched, `nil` will
- be returned instead."
- [routes uri request-method]
- (let [route (->> routes
- (filter #(not (= :not-found (:path %))))
- (map #(merge % {:regex-path (path->regex-path (:path %))}))
- (filter #(and (re-matches (re-pattern (:regex-path %)) uri)
- (= (:method %) request-method)))
- first)]
- (when route
- (dissoc route :regex-path))))
+ (if (= "/" path)
+ []
+ (->> (string/split path #"/")
+ (remove empty?)
+ (mapv parse-segment))))
+
+(defn- empty-node
+ "Creates an empty trie node."
+ []
+ {:children {} ;; literal segment string -> child node
+ :param nil ;; {:param-name kw, :node node} or nil
+ :optional nil ;; {:param-name kw, :node node} or nil
+ :wildcard nil ;; {:param-name kw, :leaves [...]} or nil
+ :leaves []}) ;; routes that terminate here [{:method :get :response fn :path str}]
+
+(defn- insert-route
+ "Inserts a single route into the trie, returning the updated trie."
+ [trie segments leaf]
+ (if (empty? segments)
+ (update trie :leaves conj leaf)
+ (let [{:keys [type value]} (first segments)
+ remaining (subvec segments 1)]
+ (case type
+ :literal
+ (let [child (get-in trie [:children value] (empty-node))
+ child' (insert-route child remaining leaf)]
+ (assoc-in trie [:children value] child'))
+
+ :param
+ (let [existing (:param trie)
+ child (if existing (:node existing) (empty-node))
+ child' (insert-route child remaining leaf)]
+ (assoc trie :param {:param-name value :node child'}))
+
+ :optional
+ (let [existing (:optional trie)
+ child (if existing (:node existing) (empty-node))
+ child' (insert-route child remaining leaf)]
+ (assoc trie :optional {:param-name value :node child'}))
+
+ :wildcard
+ (let [existing (:wildcard trie)
+ leaves (if existing (:leaves existing) [])
+ leaves' (conj leaves leaf)]
+ (assoc trie :wildcard {:param-name value :leaves leaves'}))))))
+
+(defn compile-routes
+ "Compiles a vector of route maps into a trie structure for efficient
+ best-match routing. Each route map should have :path, :method, and
+ :response keys. Routes with :path :not-found are stored separately.
+
+ Returns a map with :trie (the compiled trie) and :not-found (the
+ fallback route, if any). This is marked with ::compiled metadata
+ so `route` can detect pre-compiled input."
+ [routes]
+ (let [normal (remove #(= :not-found (:path %)) routes)
+ not-found (->> routes
+ (filter #(= :not-found (:path %)))
+ first)]
+ (with-meta
+ {:trie (reduce
+ (fn [trie {:keys [path method response]}]
+ (let [segments (path->segments path)
+ leaf {:method method :response response :path path}]
+ (insert-route trie segments leaf)))
+ (empty-node)
+ normal)
+ :not-found not-found}
+ {::compiled true})))
+
+;; Trie Matching:
+;;
+;; The matcher walks the trie depth-first, tracking the best match found
+;; so far. At each node it tries children in specificity order (literal
+;; first, then param, then optional, then wildcard) so the first complete
+;; match is often the best, allowing pruning of less-specific branches.
+;;
+;; Specificity scoring (per segment):
+;; literal = 3
+;; param = 2
+;; optional = 1 (skipping an optional penalizes by -1)
+;; wildcard = 0
+
+(defn- better-match?
+ "Returns true if `score` beats the current best match."
+ [score best]
+ (or (nil? best) (> score (:score best))))
+
+(defn- match-leaf
+ "Checks leaves at a node for a method match. Returns the best result
+ between `best-so-far` and any matching leaf."
+ [leaves ctx best-so-far]
+ (let [{:keys [request-method params score]} ctx]
+ (reduce (fn [best leaf]
+ (if (and (= (:method leaf) request-method)
+ (better-match? score best))
+ {:leaf leaf :params params :score score}
+ best))
+ best-so-far
+ leaves)))
+
+(defn- match-terminal
+ "Handles matching when no URI segments remain. Checks leaves at the
+ current node and any optional child that can match zero segments."
+ [{:keys [node best] :as ctx}]
+ (let [best (match-leaf (:leaves node) ctx best)]
+ (if-let [{:keys [node]} (:optional node)]
+ (match-leaf (:leaves node) ctx best)
+ best)))
+
+(declare ^:private match-trie)
+
+(defn- try-literal
+ "Tries to match the current segment as a literal child (+3 specificity)."
+ [ctx seg rest-segs best]
+ (if-let [child (get (:children (:node ctx)) seg)]
+ (match-trie (assoc ctx
+ :node child
+ :segments rest-segs
+ :score (+ (:score ctx) 3)
+ :best best))
+ best))
+
+(defn- try-param
+ "Tries to match via a required parameter child (+2 specificity)."
+ [ctx seg rest-segs best]
+ (if-let [{:keys [param-name node]} (:param (:node ctx))]
+ (match-trie (-> ctx
+ (update :params assoc param-name seg)
+ (assoc :node node
+ :segments rest-segs
+ :score (+ (:score ctx) 2)
+ :best best)))
+ best))
+
+(defn- try-optional-consume
+ "Tries to match via an optional parameter child, consuming the segment (+1)."
+ [ctx seg rest-segs best]
+ (if-let [{:keys [param-name node]} (:optional (:node ctx))]
+ (match-trie (-> ctx
+ (update :params assoc param-name seg)
+ (assoc :node node
+ :segments rest-segs
+ :score (+ (:score ctx) 1)
+ :best best)))
+ best))
+
+(defn- try-optional-skip
+ "Tries to skip an optional parameter child without consuming any segment (-1)."
+ [ctx best]
+ (if-let [{:keys [node]} (:optional (:node ctx))]
+ (match-trie (assoc ctx
+ :node node
+ :score (dec (:score ctx))
+ :best best))
+ best))
+
+(defn- try-wildcard
+ "Tries to match a wildcard child, consuming all remaining segments (+0)."
+ [ctx best]
+ (if-let [{:keys [param-name leaves]} (:wildcard (:node ctx))]
+ (let [updated-param (string/join "/" (:segments ctx))
+ updated-ctx (update ctx :params assoc param-name updated-param)]
+ (match-leaf leaves updated-ctx best))
+ best))
+
+(defn- match-trie
+ "Walks the trie depth-first to find the best matching route for the
+ given URI segments. Tries children in specificity order (literal >
+ param > optional > wildcard), threading the best match through each.
+ Returns {:leaf :params :score} or nil."
+ [{:keys [segments] :as ctx}]
+ (if (empty? segments)
+ (match-terminal ctx)
+ (let [seg (first segments)
+ rest-segs (subvec segments 1)
+ best (:best ctx)]
+ (->> best
+ (try-literal ctx seg rest-segs)
+ (try-param ctx seg rest-segs)
+ (try-optional-consume ctx seg rest-segs)
+ (try-optional-skip ctx)
+ (try-wildcard ctx)))))
+
+(def ^:private compile-routes*
+ "Memoized version of compile-routes for implicit compilation."
+ (memoize compile-routes))
+
+(defn- compiled?
+ "Returns true if the given value is a pre-compiled route trie."
+ [x]
+ (and (map? x) (::compiled (meta x))))
+
+(defn- ensure-compiled
+ "Returns a compiled trie, either by passing through a pre-compiled one
+ or by compiling a routes vector (memoized)."
+ [routes]
+ (if (compiled? routes)
+ routes
+ (compile-routes* routes)))
(defn- route+req->response
- "Given the current route and the current HTTP request, it will
- attempt to return a response, either directly if it's a map or
- indirectly if it's a function. In case of a function, it will also
- pass along the request map with added-in params that were parsed
- from the route path.
-
- If the response is invalid, or does not exist, a error message with
- status code 404 will be returned instead."
- [{:keys [path response]} {:keys [uri] :as req}]
+ "Given the matched route, extracted params, and the original HTTP request,
+ returns a response map. If response is a map, returns it directly.
+ If response is a function, calls it with the request augmented with :params.
+ Otherwise returns a 404."
+ [{:keys [response]} params req]
(cond
- ; responses are maps, so there's no reason they can't be
- ; direct maps.
(map? response)
response
- ; responses can also be functions that return maps, and
- ; when using a function, you get the whole `req` and params
- ; with it as well.
+
(fn? response)
- (response (-> {:params (path+uri->path-params path uri)}
+ (response (-> {:params params}
(deep-merge req)))
- ; if by whatever reason we make it here it must mean the
- ; route is invalid, or doesn't exist, in which case we return
- ; an error message.
+
:else
{:status 404
:body "Not found."}))
(defn route
"For a given collection of `routes` and the current HTTP request as
- `req`, will attempt to match a route with the HTTP request, which it
- will then try to return a response for. The only requirement for `req`
- is to contain both a `uri` and `request-method` key. First should match
- the request path (like the paths defined in routes) and the second
- should match the request method used by the HTTP server you pass this fn to.
-
- If no route matched for a given HTTP request it will try to find a
- route with `:not-found` as its `:path` instead, and return the response
- for that, and if that route was also not found, will return a built-in
- 404 response instead."
+ `req`, will attempt to match the best route for the HTTP request and
+ return its response.
+
+ Routes are matched using specificity-based best-match semantics:
+ literal segments beat parameters, parameters beat optionals, and
+ optionals beat wildcards. Route order in the vector does not matter.
+
+ `routes` can be either a raw vector of route maps (compiled implicitly
+ and cached via memoization) or a pre-compiled trie from `compile-routes`.
+
+ If no route matched, it will try to find a route with `:not-found` as
+ its `:path`, and if that is also missing, returns a built-in 404."
[routes {:keys [uri request-method] :as req}]
- (if-let [route (match-route routes uri request-method)]
- (route+req->response route req)
- (route+req->response (->> routes
- (filter #(= :not-found (:path %)))
- first) req)))
+ (let [{:keys [trie not-found]} (ensure-compiled routes)
+ segments (->> (string/split uri #"/")
+ (remove empty?)
+ vec)
+ match (match-trie {:node trie
+ :segments segments
+ :request-method request-method
+ :params {}
+ :score 0
+ :best nil})]
+ (if match
+ (route+req->response (:leaf match) (:params match) req)
+ (if not-found
+ (route+req->response not-found {} req)
+ {:status 404
+ :body "Not found."}))))
diff --git a/test/ruuter/core_test.cljc b/test/ruuter/core_test.cljc
index 3b7d478..36c998d 100644
--- a/test/ruuter/core_test.cljc
+++ b/test/ruuter/core_test.cljc
@@ -4,103 +4,345 @@
#?(:cljs (:require [cljs.test :refer-macros [deftest testing is]]
[ruuter.core :as ruuter])))
-(deftest path+uri->path-params-test
- (let [testfn #'ruuter/path+uri->path-params]
- (testing "No params returns an empty map"
- (is (= {}
- (testfn "/hello/world" "/hello/world"))))
- (testing "Having a param returns a map accordingly"
- (is (= {:who "world"}
- (testfn "/hello/:who" "/hello/world"))))
- (testing "Multiple params returns a map accordingly"
- (is (= {:who "world"
- :why "because"}
- (testfn "/hello/:who/:why" "/hello/world/because"))))
- (testing "Multiple params, but one is optional"
- (is (= {:who "world"}
- (testfn "/hello/:who/:why?" "/hello/world")))
- (is (= {:who "world"
- :why "because"}
- (testfn "/hello/:who/:why?" "/hello/world/because"))))
- (testing "Multiple params, but all are optional"
- (is (= {:who "world"
- :why "because"}
- (testfn "hello/:who?/:why?" "/hello/world/because")))
- (is (= {:who "world"}
- (testfn "/hello/:who?/:why?" "/hello/world"))))
- (testing "Wildcard param"
- (is (= {:everything "this/means/literally/everything"}
- (testfn "/hello/:everything*" "/hello/this/means/literally/everything"))))
- (testing "Normal params and wildcard param in the end"
- (is (= {:id "123"
- :path "foo.txt"}
- (testfn "/user/:id/file/:path*" "/user/123/file/foo.txt")))
- (is (= {:id "123"
- :path "a/b/c/foo.txt"}
- (testfn "/user/:id/file/:path*" "/user/123/file/a/b/c/foo.txt")))
- (is (= {:id "123"
- :path "a/b/c/foo.txt"
- :sub-path "b/c/foo.txt"}
- (testfn "/user/:id/file/:path*/:sub-path*" "/user/123/file/a/b/c/foo.txt"))))))
+;; ── Required Param Tests ────────────────────────────────────────────────
+
+(deftest no-params-test
+ (testing "No params returns an empty params map"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/world" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world" :request-method :get})
+ (is (= {} @params)))))
+
+(deftest single-param-test
+ (testing "Single required param"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world" :request-method :get})
+ (is (= {:who "world"} @params))))
+
+ (testing "Multiple required params"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who/:why" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world/because" :request-method :get})
+ (is (= {:who "world" :why "because"} @params)))))
+
+;; ── Optional Param Tests ───────────────────────────────────────────────
+
+(deftest optional-param-test
+ (testing "Required and optional param — both present"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who/:why?" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world/because" :request-method :get})
+ (is (= {:who "world" :why "because"} @params))))
+
+ (testing "Required and optional param — optional absent"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who/:why?" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world" :request-method :get})
+ (is (= {:who "world"} @params))))
+
+ (testing "Multiple optional params — all present"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who?/:why?" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world/because" :request-method :get})
+ (is (= {:who "world" :why "because"} @params))))
+
+ (testing "Multiple optional params — only first present"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who?/:why?" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/world" :request-method :get})
+ (is (= {:who "world"} @params)))))
+
+;; ── Wildcard Param Tests ───────────────────────────────────────────────
+
+(deftest wildcard-param-test
+ (testing "Wildcard param"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:everything*" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/hello/this/means/literally/everything" :request-method :get})
+ (is (= {:everything "this/means/literally/everything"} @params))))
+
+ (testing "Normal params and wildcard param"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/user/:id/file/:path*" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/user/123/file/foo.txt" :request-method :get})
+ (is (= {:id "123" :path "foo.txt"} @params))))
+
+ (testing "Normal params and wildcard with deep path"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/user/:id/file/:path*" :method :get
+ :response (fn [req] (reset! params (:params req)) {:status 200 :body ""})}]
+ {:uri "/user/123/file/a/b/c/foo.txt" :request-method :get})
+ (is (= {:id "123" :path "a/b/c/foo.txt"} @params)))))
+
+;; ── Route Matching Tests ───────────────────────────────────────────────
(deftest match-route-test
- (let [testfn #'ruuter/match-route]
- (testing "Find a route that exists"
- (is (= {:path "/hello"
- :method :get
- :response {:status 200
- :body "Hello."}}
- (testfn [{:path "/hello"
- :method :get
- :response {:status 200
- :body "Hello."}}] "/hello" :get))))
- (testing "No route found"
- (is (= nil
- (testfn [] "/hello" :get))))))
-
-(deftest route+req->response-test
- (let [testfn #'ruuter/route+req->response]
- (testing "Returns a map when the response is a direct map"
- (is (= {:status 200
- :body "Hello."}
- (testfn {:path "/hello"
- :response {:status 200
- :body "Hello."}}
- {:uri "/hello"}))))
- (testing "Returns a map via a fn when the response is a fn"
- (is (= {:status 200
- :body "Hello, world."}
- (testfn {:path "/hello/:who"
+ (testing "Finds a matching route"
+ (is (= {:status 200 :body "Hello."}
+ (ruuter/route [{:path "/hello" :method :get
+ :response {:status 200 :body "Hello."}}]
+ {:uri "/hello" :request-method :get}))))
+
+ (testing "No route found returns 404"
+ (is (= {:status 404 :body "Not found."}
+ (ruuter/route [] {:uri "/hello" :request-method :get}))))
+
+ (testing "Method mismatch returns 404"
+ (is (= {:status 404 :body "Not found."}
+ (ruuter/route [{:path "/hello" :method :post
+ :response {:status 200 :body "Hello."}}]
+ {:uri "/hello" :request-method :get}))))
+
+ (testing "Root path matches"
+ (is (= {:status 200 :body "root"}
+ (ruuter/route [{:path "/" :method :get
+ :response {:status 200 :body "root"}}]
+ {:uri "/" :request-method :get})))))
+
+;; ── Response Tests ─────────────────────────────────────────────────────
+
+(deftest response-test
+ (testing "Returns a map when the response is a direct map"
+ (is (= {:status 200 :body "Hello."}
+ (ruuter/route [{:path "/hello" :method :get
+ :response {:status 200 :body "Hello."}}]
+ {:uri "/hello" :request-method :get}))))
+
+ (testing "Returns a map via fn when the response is a fn"
+ (is (= {:status 200 :body "Hello, world."}
+ (ruuter/route [{:path "/hello/:who" :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "Hello, " (:who (:params req)) ".")})}]
+ {:uri "/hello/world" :request-method :get}))))
+
+ (testing "Returns 404 when route is not found"
+ (is (= {:status 404 :body "Not found."}
+ (ruuter/route [] {:uri "/hello" :request-method :get}))))
+
+ (testing "Returns combined :params map"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who" :method :get
+ :response (fn [req]
+ (reset! params (:params req))
+ {:status 200 :body ""})}]
+ {:uri "/hello/world"
+ :request-method :get
+ :params {:some-params :from-elsewhere}})
+ (is (= {:who "world" :some-params :from-elsewhere} @params))))
+
+ (testing "External params overwrite route params"
+ (let [params (atom nil)]
+ (ruuter/route [{:path "/hello/:who" :method :get
:response (fn [req]
- {:status 200
- :body (str "Hello, " (:who (:params req)) ".")})}
- {:uri "/hello/world"}))))
- (testing "Returns an error map when route is invalid"
- (is (= {:status 404
- :body "Not found."}
- (testfn nil {:uri "/hello"}))))
-
- (testing "Returns a combined :params map"
- (let [params (atom nil)]
- (testfn {:path "/hello/:who"
- :response (fn [req]
- (reset! params (:params req))
- {:status 200
- :body ""})}
- {:uri "/hello/world"
- :params {:some-params :from-elsewhere}})
- (is (= {:who "world"
- :some-params :from-elsewhere}
- @params))))
-
- (testing "Overwrites :params if needed"
- (let [params (atom nil)]
- (testfn {:path "/hello/:who"
- :response (fn [req]
- (reset! params (:params req))
- {:status 200
- :body ""})}
- {:uri "/hello/world"
- :params {:who "overwritten"}})
- (is (= {:who "overwritten"}
- @params))))))
+ (reset! params (:params req))
+ {:status 200 :body ""})}]
+ {:uri "/hello/world"
+ :request-method :get
+ :params {:who "overwritten"}})
+ (is (= {:who "overwritten"} @params)))))
+
+;; ── Best-Match / Specificity Tests ─────────────────────────────────────
+
+(deftest literal-beats-param-test
+ (testing "Literal beats param regardless of route order"
+ (is (= {:status 200 :body "literal"}
+ (ruuter/route [{:path "/hello/:who" :method :get
+ :response {:status 200 :body "param"}}
+ {:path "/hello/world" :method :get
+ :response {:status 200 :body "literal"}}]
+ {:uri "/hello/world" :request-method :get}))))
+
+ (testing "Literal beats param — reversed order"
+ (is (= {:status 200 :body "literal"}
+ (ruuter/route [{:path "/hello/world" :method :get
+ :response {:status 200 :body "literal"}}
+ {:path "/hello/:who" :method :get
+ :response {:status 200 :body "param"}}]
+ {:uri "/hello/world" :request-method :get})))))
+
+(deftest param-specificity-test
+ (testing "Required param beats optional param"
+ (is (= {:status 200 :body "required"}
+ (ruuter/route [{:path "/hello/:who?" :method :get
+ :response {:status 200 :body "optional"}}
+ {:path "/hello/:who" :method :get
+ :response {:status 200 :body "required"}}]
+ {:uri "/hello/world" :request-method :get}))))
+
+ (testing "Required param beats wildcard"
+ (is (= {:status 200 :body "param"}
+ (ruuter/route [{:path "/hello/:catch*" :method :get
+ :response {:status 200 :body "wildcard"}}
+ {:path "/hello/:who" :method :get
+ :response {:status 200 :body "param"}}]
+ {:uri "/hello/world" :request-method :get}))))
+
+ (testing "Param match still works when no literal matches"
+ (is (= {:status 200 :body "param"}
+ (ruuter/route [{:path "/hello/world" :method :get
+ :response {:status 200 :body "literal"}}
+ {:path "/hello/:who" :method :get
+ :response {:status 200 :body "param"}}]
+ {:uri "/hello/clojure" :request-method :get})))))
+
+(deftest nested-specificity-test
+ (testing "More specific nested path beats less specific"
+ (is (= {:status 200 :body "specific"}
+ (ruuter/route [{:path "/api/:resource" :method :get
+ :response {:status 200 :body "generic"}}
+ {:path "/api/users" :method :get
+ :response {:status 200 :body "specific"}}]
+ {:uri "/api/users" :request-method :get}))))
+
+ (testing "Deeper literal path beats shallow param"
+ (is (= {:status 200 :body "deep-literal"}
+ (ruuter/route [{:path "/api/:resource/:id" :method :get
+ :response {:status 200 :body "params"}}
+ {:path "/api/users/me" :method :get
+ :response {:status 200 :body "deep-literal"}}]
+ {:uri "/api/users/me" :request-method :get}))))
+
+ (testing "Wildcard is last resort"
+ (is (= {:status 200 :body "wildcard"}
+ (ruuter/route [{:path "/hello/world" :method :get
+ :response {:status 200 :body "literal"}}
+ {:path "/hello/:who" :method :get
+ :response {:status 200 :body "param"}}
+ {:path "/hello/:catch*" :method :get
+ :response {:status 200 :body "wildcard"}}]
+ {:uri "/hello/a/b/c" :request-method :get})))))
+
+;; ── Not-Found Fallback Tests ───────────────────────────────────────────
+
+(deftest not-found-test
+ (testing "Custom :not-found route is used when no match"
+ (is (= {:status 404 :body "custom 404"}
+ (ruuter/route [{:path "/hello" :method :get
+ :response {:status 200 :body "hi"}}
+ {:path :not-found
+ :response {:status 404 :body "custom 404"}}]
+ {:uri "/nope" :request-method :get}))))
+
+ (testing "Custom :not-found route with fn response"
+ (is (= {:status 404 :body "Not found: /nope"}
+ (ruuter/route [{:path :not-found
+ :response (fn [req]
+ {:status 404
+ :body (str "Not found: " (:uri req))})}]
+ {:uri "/nope" :request-method :get}))))
+
+ (testing "Built-in 404 when no :not-found route"
+ (is (= {:status 404 :body "Not found."}
+ (ruuter/route [{:path "/hello" :method :get
+ :response {:status 200 :body "hi"}}]
+ {:uri "/nope" :request-method :get})))))
+
+;; ── Compile-Routes Tests ───────────────────────────────────────────────
+
+(deftest compile-routes-test
+ (testing "Pre-compiled routes work the same as raw routes"
+ (let [routes [{:path "/hello/:who" :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "Hello, " (:who (:params req)))})}
+ {:path :not-found
+ :response {:status 404 :body "custom 404"}}]
+ compiled (ruuter/compile-routes routes)]
+ (is (= {:status 200 :body "Hello, world"}
+ (ruuter/route compiled {:uri "/hello/world" :request-method :get})))
+ (is (= {:status 404 :body "custom 404"}
+ (ruuter/route compiled {:uri "/nope" :request-method :get}))))))
+
+;; ── Edge Case Tests ────────────────────────────────────────────────────
+
+(deftest trie-node-reuse-test
+ (testing "Two routes sharing same param slot — trie reuses param node"
+ (let [routes [{:path "/users/:id/profile" :method :get
+ :response {:status 200 :body "profile"}}
+ {:path "/users/:id/settings" :method :get
+ :response {:status 200 :body "settings"}}]]
+ (is (= {:status 200 :body "profile"}
+ (ruuter/route routes {:uri "/users/42/profile" :request-method :get})))
+ (is (= {:status 200 :body "settings"}
+ (ruuter/route routes {:uri "/users/42/settings" :request-method :get})))))
+
+ (testing "Two routes sharing same optional slot — trie reuses optional node"
+ (let [routes [{:path "/search/:q?/page" :method :get
+ :response {:status 200 :body "page"}}
+ {:path "/search/:q?/sort" :method :get
+ :response {:status 200 :body "sort"}}]]
+ (is (= {:status 200 :body "page"}
+ (ruuter/route routes {:uri "/search/foo/page" :request-method :get})))
+ (is (= {:status 200 :body "sort"}
+ (ruuter/route routes {:uri "/search/foo/sort" :request-method :get}))))))
+
+(deftest edge-cases-test
+ (testing "Trailing slash on root"
+ (is (= {:status 200 :body "root"}
+ (ruuter/route [{:path "/" :method :get
+ :response {:status 200 :body "root"}}]
+ {:uri "/" :request-method :get}))))
+
+ (testing "Multiple HTTP methods on same path"
+ (let [routes [{:path "/items" :method :get
+ :response {:status 200 :body "list"}}
+ {:path "/items" :method :post
+ :response {:status 201 :body "created"}}]]
+ (is (= {:status 200 :body "list"}
+ (ruuter/route routes {:uri "/items" :request-method :get})))
+ (is (= {:status 201 :body "created"}
+ (ruuter/route routes {:uri "/items" :request-method :post})))))
+
+ (testing "Many overlapping routes — best match wins"
+ (let [routes [{:path "/:catch*" :method :get
+ :response {:status 200 :body "catch-all"}}
+ {:path "/api/:resource" :method :get
+ :response {:status 200 :body "api-resource"}}
+ {:path "/api/users" :method :get
+ :response {:status 200 :body "api-users"}}
+ {:path "/api/users/:id" :method :get
+ :response {:status 200 :body "api-user-id"}}
+ {:path "/api/users/me" :method :get
+ :response {:status 200 :body "api-users-me"}}]]
+ (is (= {:status 200 :body "api-users"}
+ (ruuter/route routes {:uri "/api/users" :request-method :get})))
+ (is (= {:status 200 :body "api-users-me"}
+ (ruuter/route routes {:uri "/api/users/me" :request-method :get})))
+ (is (= {:status 200 :body "api-user-id"}
+ (ruuter/route routes {:uri "/api/users/42" :request-method :get})))
+ (is (= {:status 200 :body "api-resource"}
+ (ruuter/route routes {:uri "/api/posts" :request-method :get})))
+ (is (= {:status 200 :body "catch-all"}
+ (ruuter/route routes {:uri "/random/deep/path" :request-method :get}))))))
+
+(deftest wildcard-reuse-test
+ (testing "Multiple wildcards at same position in different routes"
+ (is (= {:status 200 :body "get-files"}
+ (ruuter/route [{:path "/files/:path*" :method :get
+ :response {:status 200 :body "get-files"}}
+ {:path "/files/:path*" :method :delete
+ :response {:status 200 :body "delete-files"}}]
+ {:uri "/files/a/b/c" :request-method :get})))
+ (is (= {:status 200 :body "delete-files"}
+ (ruuter/route [{:path "/files/:path*" :method :get
+ :response {:status 200 :body "get-files"}}
+ {:path "/files/:path*" :method :delete
+ :response {:status 200 :body "delete-files"}}]
+ {:uri "/files/a/b/c" :request-method :delete}))))
+
+ (testing "Non-map non-fn response returns 404"
+ (is (= {:status 404 :body "Not found."}
+ (ruuter/route [{:path "/hello" :method :get
+ :response "just a string"}]
+ {:uri "/hello" :request-method :get})))))