Skip to content

Commit c49a866

Browse files
ptoffygwynne
andauthored
Implement partial matching of routes (#142)
* Implement partial matching * Attempt at making it fast * Cleanup * Undo * Partial Matching 2 (#143) * Different approach to partial matching * Nits * Avoid a copy * Minor improvements * Nit * Update Sources/RoutingKit/PathComponent.swift Co-authored-by: Gwynne Raskind <[email protected]> * Update Sources/RoutingKit/TrieRouter/TrieRouterNode.swift Co-authored-by: Gwynne Raskind <[email protected]> * Nits * Nit --------- Co-authored-by: Gwynne Raskind <[email protected]>
1 parent 6d54556 commit c49a866

File tree

6 files changed

+160
-30
lines changed

6 files changed

+160
-30
lines changed

Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ let package = Package(
1313
.library(name: "RoutingKit", targets: ["RoutingKit"])
1414
],
1515
dependencies: [
16-
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.4")
16+
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.4"),
17+
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.1"),
1718
],
1819
targets: [
1920
.target(
2021
name: "RoutingKit",
2122
dependencies: [
22-
.product(name: "Logging", package: "swift-log")
23+
.product(name: "Logging", package: "swift-log"),
24+
.product(name: "Algorithms", package: "swift-algorithms"),
2325
],
2426
swiftSettings: swiftSettings
2527
),

Sources/RoutingKit/PathComponent.swift

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Algorithms
2+
13
/// A single path component of a `Route`. An array of these components describes
24
/// a route's path, including which parts are constant and which parts are dynamic.
35
public enum PathComponent: ExpressibleByStringInterpolation, CustomStringConvertible, Sendable, Hashable {
@@ -12,6 +14,7 @@ public enum PathComponent: ExpressibleByStringInterpolation, CustomStringConvert
1214
/// Represented as `:` followed by the identifier.
1315
case parameter(String)
1416

17+
case partialParameter(template: String, components: [Substring], parameters: [Substring])
1518
/// A dynamic parameter component with discarded value.
1619
///
1720
/// Represented as `*`
@@ -30,7 +33,31 @@ public enum PathComponent: ExpressibleByStringInterpolation, CustomStringConvert
3033

3134
// See `ExpressibleByStringLiteral.init(stringLiteral:)`.
3235
public init(stringLiteral value: String) {
33-
if value.hasPrefix(":") {
36+
if value.firstIndex(of: "{") != nil {
37+
var components: [Substring] = []
38+
var parameters: [Substring] = []
39+
40+
var inBraces = false
41+
42+
for (index, char) in value.dropFirst().indexed() {
43+
switch char {
44+
case "{":
45+
inBraces = true
46+
parameters.append("")
47+
components.append("")
48+
case "}":
49+
inBraces = false
50+
if index < value.index(before: value.endIndex) { components.append("") }
51+
default:
52+
if inBraces {
53+
parameters[parameters.index(before: parameters.endIndex)].append(char)
54+
} else {
55+
components[components.index(before: components.endIndex)].append(char)
56+
}
57+
}
58+
}
59+
self = .partialParameter(template: .init(value.dropFirst()), components: components, parameters: parameters)
60+
} else if value.starts(with: ":") {
3461
self = .parameter(.init(value.dropFirst()))
3562
} else if value == "*" {
3663
self = .anything
@@ -44,14 +71,11 @@ public enum PathComponent: ExpressibleByStringInterpolation, CustomStringConvert
4471
// See `CustomStringConvertible.description`.
4572
public var description: String {
4673
switch self {
47-
case .anything:
48-
return "*"
49-
case .catchall:
50-
return "**"
51-
case .parameter(let name):
52-
return ":" + name
53-
case .constant(let constant):
54-
return constant
74+
case .anything: "*"
75+
case .catchall: "**"
76+
case .parameter(let name): ":" + name
77+
case .constant(let constant): constant
78+
case .partialParameter(let template, _, _): template
5579
}
5680
}
5781
}

Sources/RoutingKit/TrieRouter/TrieRouter.swift

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
public import Algorithms
2+
import Foundation
13
import Logging
24

35
/// Generic ``TrieRouter`` built using the "trie" tree algorithm.
@@ -37,29 +39,22 @@ public final class TrieRouter<Output: Sendable>: Router, Sendable, CustomStringC
3739
/// - path: Raw path segments.
3840
/// - parameters: Will collect dynamic parameter values.
3941
/// - Returns: Output of matching route, if found.
40-
@inlinable public func route(path: [String], parameters: inout Parameters) -> Output? {
41-
// always start at the root node
42+
@inlinable
43+
public func route(path: [String], parameters: inout Parameters) -> Output? {
4244
var currentNode = self.root
43-
4445
let isCaseInsensitive = self.options.contains(.caseInsensitive)
45-
4646
var currentCatchall: (Node, [String])?
4747

48-
// traverse the string path supplied
49-
search: for (index, slice) in path.enumerated() {
50-
// store catchall in case search hits dead end
48+
search: for (index, slice) in path.indexed() {
5149
if let catchall = currentNode.catchall {
5250
currentCatchall = (catchall, [String](path.dropFirst(index)))
5351
}
5452

55-
// check the constants first
5653
if let constant = currentNode.constants[isCaseInsensitive ? slice.lowercased() : slice] {
5754
currentNode = constant
5855
continue search
5956
}
6057

61-
// no constants matched, check for dynamic members
62-
// including parameters or anythings
6358
if let wildcard = currentNode.wildcard {
6459
if let name = wildcard.parameter {
6560
parameters.set(name, to: slice)
@@ -69,9 +64,19 @@ public final class TrieRouter<Output: Sendable>: Router, Sendable, CustomStringC
6964
continue search
7065
}
7166

72-
// no matches, stop searching
67+
if let partials = currentNode.partials, !partials.isEmpty {
68+
for partial in partials {
69+
if let captures = isMatchForPartial(partial: partial, path: slice, parameters: parameters) {
70+
for (name, value) in captures {
71+
parameters.set(String(name), to: String(value))
72+
}
73+
currentNode = partial.node
74+
continue search
75+
}
76+
}
77+
}
78+
7379
if let (catchall, subpaths) = currentCatchall {
74-
// fallback to catchall output if we have one
7580
parameters.setCatchall(matched: subpaths)
7681
return catchall.output
7782
} else {
@@ -80,14 +85,11 @@ public final class TrieRouter<Output: Sendable>: Router, Sendable, CustomStringC
8085
}
8186

8287
if let output = currentNode.output {
83-
// return the currently resolved responder if there hasn't been an early exit.
8488
return output
8589
} else if let (catchall, subpaths) = currentCatchall {
86-
// fallback to catchall output if we have one
8790
parameters.setCatchall(matched: subpaths)
8891
return catchall.output
8992
} else {
90-
// current node has no output and there was not catchall
9193
return nil
9294
}
9395
}
@@ -96,4 +98,46 @@ public final class TrieRouter<Output: Sendable>: Router, Sendable, CustomStringC
9698
public var description: String {
9799
self.root.description
98100
}
101+
102+
@usableFromInline
103+
func isMatchForPartial(partial: Node.PartialMatch, path: String, parameters: Parameters) -> [Substring: Substring]? {
104+
var result: [Substring: Substring] = [:]
105+
var index = path.startIndex
106+
107+
var componentIndex = partial.components.startIndex
108+
let lastComponentIndex = partial.components.index(before: partial.components.endIndex)
109+
110+
while componentIndex <= lastComponentIndex {
111+
if index >= path.endIndex {
112+
// If we're at the end but there are more components, fail
113+
if componentIndex < lastComponentIndex { return nil }
114+
break
115+
}
116+
117+
let element = partial.components[componentIndex]
118+
119+
if element.isEmpty {
120+
let endIndex: String.Index
121+
if componentIndex < lastComponentIndex {
122+
let nextElement = partial.components[partial.components.index(after: componentIndex)]
123+
// greedy matching
124+
guard let range = path.range(of: nextElement, options: .backwards, range: index..<path.endIndex) else { return nil }
125+
endIndex = range.lowerBound
126+
} else {
127+
endIndex = path.endIndex
128+
}
129+
result[partial.parameters[result.count]] = path[index..<endIndex]
130+
index = endIndex
131+
} else {
132+
// Verify the literal matches at current position
133+
let substring = path[index...].prefix(element.count)
134+
guard substring == element else { return nil }
135+
index = substring.endIndex
136+
}
137+
138+
partial.components.formIndex(after: &componentIndex)
139+
}
140+
141+
return result
142+
}
99143
}

Sources/RoutingKit/TrieRouter/TrieRouterBuilder.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ public struct TrieRouterBuilder<Output: Sendable>: RouterBuilder {
125125
explicitlyIncludesAnything: true
126126
)
127127
return node.copyWith(wildcard: newWildcard)
128+
case .partialParameter(let template, let components, let parameters):
129+
var partials = node.partials ?? []
130+
let child = partials.first(where: { $0.template == template })?.node ?? Node()
131+
let updatedChild = insertRoute(node: child, path: path.dropFirst(), output: output)
132+
partials.append(.init(template: template, components: components, parameters: parameters, node: updatedChild))
133+
return node.copyWith(partials: partials)
128134
}
129135
}
130136
}

Sources/RoutingKit/TrieRouter/TrieRouterNode.swift

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,33 @@ final class TrieRouterNode<Output: Sendable>: Sendable, CustomStringConvertible
4040
}
4141
}
4242

43+
@usableFromInline
44+
struct PartialMatch: Sendable {
45+
@usableFromInline
46+
let template: String
47+
48+
@usableFromInline
49+
let components: [Substring]
50+
51+
@usableFromInline
52+
let parameters: [Substring]
53+
54+
@usableFromInline
55+
let node: TrieRouterNode
56+
}
57+
4358
/// All constant child nodes.
4459
@usableFromInline
4560
let constants: [String: TrieRouterNode]
4661

47-
/// Wildcard child node that may be a named parameter or an anything
62+
/// Wildcard child node that may be a named parameter or an anything.
4863
@usableFromInline
4964
let wildcard: Wildcard?
5065

66+
/// Partial match child nodes.
67+
@usableFromInline
68+
let partials: [PartialMatch]?
69+
5170
/// Catchall node, if one exists.
5271
/// This node should not have any child nodes.
5372
@usableFromInline
@@ -58,11 +77,18 @@ final class TrieRouterNode<Output: Sendable>: Sendable, CustomStringConvertible
5877
let output: Output?
5978

6079
/// Creates a new ``TrieRouterNode``.
61-
init(output: Output? = nil, constants: [String: TrieRouterNode] = [:], wildcard: Wildcard? = nil, catchall: TrieRouterNode? = nil) {
80+
init(
81+
output: Output? = nil,
82+
constants: [String: TrieRouterNode] = [:],
83+
wildcard: Wildcard? = nil,
84+
catchall: TrieRouterNode? = nil,
85+
partials: [PartialMatch]? = nil
86+
) {
6287
self.output = output
6388
self.constants = constants
6489
self.wildcard = wildcard
6590
self.catchall = catchall
91+
self.partials = partials
6692
}
6793

6894
@usableFromInline
@@ -89,6 +115,11 @@ final class TrieRouterNode<Output: Sendable>: Sendable, CustomStringConvertible
89115
}
90116
}
91117

118+
for partial in self.partials ?? [] {
119+
desc.append("\(partial.template)")
120+
desc += partial.node.subpathDescriptions.indented()
121+
}
122+
92123
if self.catchall != nil {
93124
desc.append("→ **")
94125
}
@@ -99,13 +130,15 @@ final class TrieRouterNode<Output: Sendable>: Sendable, CustomStringConvertible
99130
output: Output? = nil,
100131
constants: [String: TrieRouterNode]? = nil,
101132
wildcard: Wildcard? = nil,
102-
catchall: TrieRouterNode? = nil
133+
catchall: TrieRouterNode? = nil,
134+
partials: [PartialMatch]? = nil
103135
) -> TrieRouterNode {
104136
TrieRouterNode(
105137
output: output ?? self.output,
106138
constants: constants ?? self.constants,
107139
wildcard: wildcard ?? self.wildcard,
108-
catchall: catchall ?? self.catchall
140+
catchall: catchall ?? self.catchall,
141+
partials: partials ?? self.partials
109142
)
110143
}
111144
}

Tests/RoutingKitTests/RouterTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,25 @@ struct RouterTests {
309309
#expect(Set(params3.allNames) == ["bar"])
310310
#expect(params3.getCatchall() == ["bam"])
311311
}
312+
313+
@Test func testPartial() throws {
314+
var routerBuilder = TrieRouterBuilder<Int>()
315+
routerBuilder.register(42, at: ["test", ":{my-file}.json"])
316+
routerBuilder.register(41, at: ["test", ":{my}-test-{file}.{extension}"])
317+
318+
let router = routerBuilder.build()
319+
320+
var params = Parameters()
321+
#expect(router.route(path: ["test", "report.json"], parameters: &params) == 42)
322+
#expect(params.get("my-file") == "report")
323+
324+
#expect(router.route(path: ["test", ".json.json"], parameters: &params) == 42)
325+
#expect(params.get("my-file") == ".json")
326+
327+
params = Parameters()
328+
#expect(router.route(path: ["test", "foo-test-bar.txt"], parameters: &params) == 41)
329+
#expect(params.get("my") == "foo")
330+
#expect(params.get("file") == "bar")
331+
#expect(params.get("extension") == "txt")
332+
}
312333
}

0 commit comments

Comments
 (0)