Skip to content

Commit

Permalink
Support catch-all dynamic routes
Browse files Browse the repository at this point in the history
You can now terminate a route using {...rest} syntax.

The route "/a/b/c/{...rest}" will capture the value
"d/e/f" when the user visits "/a/b/c/d/e/f"

Fixes #637
  • Loading branch information
bitspittle committed Dec 30, 2024
1 parent ac87fbd commit 991450f
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 43 deletions.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,63 @@ fun PostPage() {
> conflict, then the dynamic route parameters will take precedence. (You can still access the query parameter value via
> `ctx.route.queryParams` in this case if necessary.)
#### Catch-all dynamic routes

As seen above, dynamic routes so far capture a single part of the entire route, e.g. `"/users/{user}/profile"` capturing
`"bitspittle"` in the URL `"/users/bitspittle/profile"`.

Kobweb also supports catch-all dynamic routes, which capture the remainder of the URL, at which point the page can parse
it and handle it as it sees fit.

To create a catch-all route, prepend your dynamic route name with an ellipsis.

For example, the catch-all route `"/a/b/c/{...rest}"` would capture `"x/y/z"` in the URL `"/a/b/c/x/y/z"`.

In practice, it looks like this:

```kotlin
// pages/com/mysite/store/products/ProductDetails.kt

@Page("{...product-details}")
@Composable
fun ProductDetailsPage() {
val ctx = rememberPageContext()
val productDetails = remember(ctx.route.path) { ctx.route.params.getValue("product-details").split("/") }
/* ... */
}
```

It's not expected that many sites will ever use a catch-all route, but in the above case, you could use the captured
value as a way to encode fluid details of a product, perhaps with sub-routes contextually depending on the root
category. For example, the above page could handle `/store/products/home-and-garden/hoses/19528` (depth = 3),
`/store/products/electronics/phones/google/pixel/4a` (depth = 5), and whatever other scheme each department demands.

Of course, it is better to provide a more structured solution if you can (e.g. declaring a page route like
`/store/products/{category}/{subcategory}/{product}`), but reality can be messy sometimes.

> [!CAUTION]
> Catch-all route parts MUST terminate the route. The following is not valid and will result in an exception being
> thrown: `"/a/b/c/{...middle}/x/y/z"`.
##### Optional catch-all routes

Note that `"a/b/c/{...rest}"` will NOT match `"/a/b/c/"`. If you want to additionally support the empty case, you can
add a question mark to the end of the name, e.g. `"/a/b/c/{...rest?}"`.

Using this feature, you could even discard Kobweb's routing logic entirely and handle everything yourself:

```kotlin
// com/mysite/pages/Index.kt

@Page("{...path?}")
@Composable
fun CatchAllPage() {
val ctx = rememberPageContext()
val url = ctx.route.params.getValue("path")
/* ... */
}
```

## Public resources

If you have a resource that you'd like to serve from your site, you handle this by placing it in your site's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,69 @@ fun List<ResolvedEntry<*>>.captureDynamicValues(): Map<String, String> {
}
}

fun List<ResolvedEntry<*>>.toRouteString() = "/" + joinToString("/") { it.capturedRoutePart }
fun List<ResolvedEntry<*>>.toRouteString() = joinToString("/") { it.capturedRoutePart }

/**
* A tree data structure that represents a parsed route, such as `/example/path` or `/{dynamic}/path`
*/
class RouteTree<T> {
sealed class Node<T>(val parent: Node<T>? = null, val name: String, var data: T?) {
sealed class Node<T>(val parent: Node<T>? = null, val sourceRoutePart: String, var data: T?) {
private val _children = mutableListOf<Node<T>>()
val children: List<Node<T>> = _children

protected open fun matches(name: String): Boolean {
return this.name == name
}
/**
* The raw name for this node.
*
* For most nodes, it will just match the route part from which it is associated with, but for dynamic routes,
* this will be the value undecorated from things like curly braces.
*/
open val name: String = sourceRoutePart

protected open val isRouteTerminator: Boolean = false

/**
* Given a list of route parts, consume any matching this node type, and return the text consumed.
*
* For example, the route "/a/b/c" is converted to ["", "a", "b", "c"]. The root node should always consume the
* first empty string, leaving remaining children nodes to consume ["a", "b", "c"].
*
* At this point...
* - a static node "a" would leave ["b", "c"] and return the string "a"
* - a static node "z" would leave ["a", "b", "c"] untouched and return null
* - a dynamic node "{dynamic}" would leave ["b", "c"] and return the string "a"
* - a dynamic node "{...dynamic}" would consume everything and return "a/b/c"
*
* IMPORTANT: [remainingRouteParts] will always contain at least one element (so there is no need to waste time
* checking if it is empty).
*/
abstract fun consume(remainingRouteParts: MutableList<String>): String?

fun createChild(routePart: String, data: T?): Node<T> {
val node = if (routePart.startsWith('{') && routePart.endsWith('}')) {
DynamicNode(this, routePart.substring(1, routePart.length - 1), data)
} else {
StaticNode(this, routePart, data)
if (isRouteTerminator) {
error("User attempted to register an invalid route. \"$sourceRoutePart\" must be the last part of the route, but it was followed by \"$routePart\".")
}

run {
val existingDynamicNode = _children.firstOrNull { it is DynamicNode }
if (existingDynamicNode != null) {
error("User attempted to register a new route when a dynamic route is already set up that will capture it. \"$routePart\" is being registered but \"${existingDynamicNode.sourceRoutePart}\" already exists.")
}
}

val node = DynamicNode.tryCreate(this, routePart, data) ?: StaticNode(this, routePart, data)

if (node is DynamicNode && _children.isNotEmpty()) {
error("User attempted to register a dynamic route that conflicts with one or more routes already set up. \"$routePart\" is being registered but [${_children.joinToString { "\"${it.sourceRoutePart}\"" }}] route(s) already exist.")
}

_children.add(node)
return node
}

fun findChild(routePart: String): Node<T>? = _children.find { it.matches(routePart) }

fun resolve(remainingRouteParts: MutableList<String>): ResolvedEntry<T>? {
return consume(remainingRouteParts)?.let { capturedRoutePart -> ResolvedEntry(this, capturedRoutePart) }
}

/**
* A sequence of all nodes from this node (including itself) in a breadth first order
Expand All @@ -69,16 +107,109 @@ class RouteTree<T> {
}
}

class RootNode<T> : Node<T>(parent = null, name = "", data = null)
class RootNode<T> : Node<T>(parent = null, sourceRoutePart = "", data = null) {
override fun consume(remainingRouteParts: MutableList<String>): String {
check(remainingRouteParts.first() == "")
remainingRouteParts.removeFirst()
return ""
}
}

sealed class ChildNode<T>(parent: Node<T>, name: String, data: T?) : Node<T>(parent, name, data)
sealed class ChildNode<T>(parent: Node<T>, sourceRoutePart: String, data: T?) :
Node<T>(parent, sourceRoutePart, data)

/** A node representing a normal part of the route, such as "example" in "/example/path" */
class StaticNode<T>(parent: Node<T>, name: String, data: T?) : ChildNode<T>(parent, name, data)
class StaticNode<T>(parent: Node<T>, sourceRoutePart: String, data: T?) : ChildNode<T>(parent, sourceRoutePart, data) {
override fun consume(remainingRouteParts: MutableList<String>): String? {
if (remainingRouteParts.first() == sourceRoutePart) {
return remainingRouteParts.removeFirst()
}
return null
}
}

/** A node representing a dynamic part of the route, such as "{dynamic}" in "/{dynamic}/path" */
class DynamicNode<T>(parent: Node<T>, name: String, data: T?) : ChildNode<T>(parent, name, data) {
override fun matches(name: String) = true // Dynamic nodes eat all possible inputs
class DynamicNode<T>(parent: Node<T>, sourceRoutePart: String, data: T?) :
ChildNode<T>(parent, sourceRoutePart, data) {

private enum class Match {
/**
* This node matches a single part of the route.
*
* For example: "/users/{user}/posts/{post}"
*
* This would match a URL like "/users/bitspittle/posts/123"
*/
SINGLE,

/**
* This node consumes the rest of the route.
*
* For example: "/games/{...game-details}"
*
* This would match a URL like "/games/frogger", "/games/space-invaders/easy", etc. This would NOT match
* "/games" by itself, as the node expects at least one more part of the route.
*/
REST,

/**
* Like [REST] but also match if there are no more parts of the route.
*/
REST_OPTIONAL,
}

private val match: Match
private val _name: String
init {
var name = sourceRoutePart.removeSurrounding("{", "}")
match = if (name.startsWith("...")) {
name = name.removePrefix("...")
if (name.endsWith("?")) {
name = name.removeSuffix("?")
Match.REST_OPTIONAL
} else {
Match.REST
}
} else {
Match.SINGLE
}

_name = name
}

override val name = _name

companion object {
fun <T> tryCreate(parent: Node<T>, routePart: String, data: T?): DynamicNode<T>? {
if (routePart.startsWith('{') && routePart.endsWith('}')) {
return DynamicNode(parent, routePart, data)
}
return null
}
}

// REST match types consume the rest of the route so therefore can't have any following route parts
override val isRouteTerminator = match != Match.SINGLE


override fun consume(remainingRouteParts: MutableList<String>): String? {
when (match) {
Match.SINGLE -> {
return remainingRouteParts.removeFirst()
}

Match.REST,
Match.REST_OPTIONAL -> {
if (match == Match.REST_OPTIONAL || remainingRouteParts.first() != "") {
return remainingRouteParts.joinToString("/").also {
remainingRouteParts.clear()
}
}
}
}

return null
}
}

/**
Expand All @@ -97,16 +228,19 @@ class RouteTree<T> {
private val redirects = mutableListOf<PatternMapper>()

private fun resolveWithoutRedirects(route: String): List<ResolvedEntry<T>>? {
val routeParts = route.split('/')
val routeParts = route.split('/').toMutableList()

val resolved = mutableListOf<ResolvedEntry<T>>()
var currNode: Node<T> = root
require(routeParts[0] == root.name) // Will be true as long as incoming route starts with '/'

for (i in 1 until routeParts.size) {
val routePart = routeParts[i]
currNode = currNode.findChild(routePart) ?: return null
resolved.add(ResolvedEntry(currNode, routePart))
require(routeParts[0] == root.sourceRoutePart) // Will be true as long as incoming route starts with '/'
val resolved = mutableListOf(root.resolve(routeParts)!!)

while (routeParts.isNotEmpty()) {
val resolvedEntry = currNode.children.asSequence()
.mapNotNull { child -> child.resolve(routeParts) }
.firstOrNull() ?: return null
currNode = resolvedEntry.node
resolved.add(resolvedEntry)
}

return resolved.takeIf { it.isEmpty() || it.last().node.data != null }
Expand Down Expand Up @@ -170,14 +304,20 @@ class RouteTree<T> {
// Avoid considering redirects here; they should only be used at query time.
if (resolveWithoutRedirects(route) != null) return false

val routeParts = route.split('/')
val routeParts = route.split('/').toMutableList()
var currNode: Node<T> = root
require(routeParts[0] == root.name) // Will be true if incoming route starts with '/'
for (i in 1 until routeParts.size) {
val routePart = routeParts[i]
currNode = currNode.findChild(routePart) ?: currNode.createChild(
routePart,
data.takeIf { i == routeParts.lastIndex })
require(routeParts[0] == root.sourceRoutePart) // Will be true if incoming route starts with '/'
root.resolve(routeParts)!!
while (routeParts.isNotEmpty()) {
val resolvedEntry = currNode.children
// Don't let "{dynamic}" aggressively match here if we're registering a "static" route
.firstOrNull { it.sourceRoutePart == routeParts.first() }
?.resolve(routeParts)
if (resolvedEntry != null) {
currNode = resolvedEntry.node
} else {
currNode = currNode.createChild(routeParts.removeFirst(), data.takeIf { routeParts.isEmpty() })
}
}

return true
Expand Down
Loading

0 comments on commit 991450f

Please sign in to comment.