Skip to content

Commit 17d8de6

Browse files
authored
Router validation (#637)
1 parent 53c231f commit 17d8de6

File tree

3 files changed

+154
-24
lines changed

3 files changed

+154
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Hummingbird server framework project
4+
//
5+
// Copyright (c) 2024 the Hummingbird authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
17+
#if canImport(FoundationEssentials)
18+
import FoundationEssentials
19+
#else
20+
import Foundation
21+
#endif
22+
23+
extension Router {
24+
/// Route description
25+
public struct RouteDescription: CustomStringConvertible {
26+
/// Route path
27+
public let path: RouterPath
28+
/// Route method
29+
public let method: HTTPRequest.Method
30+
31+
public var description: String { "\(method) \(path)" }
32+
}
33+
34+
/// List of routes added to router
35+
public var routes: [RouteDescription] {
36+
let trieValues = self.trie.root.values()
37+
return trieValues.flatMap { endpoint in
38+
endpoint.value.methods.keys
39+
.sorted { $0.rawValue < $1.rawValue }
40+
.map { RouteDescription(path: endpoint.path, method: $0) }
41+
}
42+
}
43+
44+
/// Validate router
45+
///
46+
/// Verify that routes are not clashing
47+
public func validate() throws {
48+
try self.trie.root.validate()
49+
}
50+
}
51+
52+
extension RouterPathTrieBuilder.Node {
53+
func validate(_ root: String = "") throws {
54+
let sortedChildren = children.sorted { $0.key.priority > $1.key.priority }
55+
if sortedChildren.count > 1 {
56+
for index in 1..<sortedChildren.count {
57+
let exampleElement =
58+
switch sortedChildren[index].key.value {
59+
case .path(let path):
60+
String(path)
61+
case .capture:
62+
UUID().uuidString
63+
case .prefixCapture(let suffix, _):
64+
"\(UUID().uuidString)\(suffix)"
65+
case .suffixCapture(let prefix, _):
66+
"\(prefix)/\(UUID().uuidString)"
67+
case .wildcard:
68+
UUID().uuidString
69+
case .prefixWildcard(let suffix):
70+
"\(UUID().uuidString)\(suffix)"
71+
case .suffixWildcard(let prefix):
72+
"\(prefix)/\(UUID().uuidString)"
73+
case .recursiveWildcard:
74+
UUID().uuidString
75+
case .null:
76+
""
77+
}
78+
// test path element against all the previous trie entries in this node
79+
for trieEntry in sortedChildren[0..<index] {
80+
if case trieEntry.key = exampleElement {
81+
throw RouterValidationError(
82+
path: "\(root)/\(sortedChildren[index].key)",
83+
override: "\(root)/\(trieEntry.key)"
84+
)
85+
}
86+
}
87+
88+
}
89+
}
90+
91+
for child in self.children {
92+
try child.validate("\(root)/\(child.key)")
93+
}
94+
}
95+
}
96+
97+
/// Router validation error
98+
public struct RouterValidationError: Error, CustomStringConvertible {
99+
let path: RouterPath
100+
let override: RouterPath
101+
102+
public var description: String {
103+
"Route \(override) overrides \(path)"
104+
}
105+
}

Sources/Hummingbird/Router/Router.swift

+7-22
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ public final class Router<Context: RequestContext>: RouterMethods, HTTPResponder
5656

5757
/// build responder from router
5858
public func buildResponder() -> RouterResponder<Context> {
59+
#if DEBUG
60+
do {
61+
try self.validate()
62+
} catch {
63+
assertionFailure("\(error)")
64+
}
65+
#endif
5966
if self.options.contains(.autoGenerateHeadEndpoints) {
6067
// swift-format-ignore: ReplaceForEachWithForLoop
6168
self.trie.forEach { node in
@@ -128,25 +135,3 @@ public struct RouterOptions: OptionSet, Sendable {
128135
/// For every GET request that does not have a HEAD request, auto generate the HEAD request
129136
public static var autoGenerateHeadEndpoints: Self { .init(rawValue: 1 << 1) }
130137
}
131-
132-
extension Router {
133-
/// Route description
134-
public struct RouteDescription: CustomStringConvertible {
135-
/// Route path
136-
public let path: RouterPath
137-
/// Route method
138-
public let method: HTTPRequest.Method
139-
140-
public var description: String { "\(method) \(path)" }
141-
}
142-
143-
/// List of routes added to router
144-
public var routes: [RouteDescription] {
145-
let trieValues = self.trie.root.values()
146-
return trieValues.flatMap { endpoint in
147-
endpoint.value.methods.keys
148-
.sorted { $0.rawValue < $1.rawValue }
149-
.map { RouteDescription(path: endpoint.path, method: $0) }
150-
}
151-
}
152-
}

Tests/HummingbirdTests/RouterTests.swift

+42-2
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ final class RouterTests: XCTestCase {
735735
router.get("test/this") { _, _ in "" }
736736
router.put("test") { _, _ in "" }
737737
router.post("{test}/{what}") { _, _ in "" }
738-
router.get("wildcard/*") { _, _ in "" }
738+
router.get("wildcard/*/*") { _, _ in "" }
739739
router.get("recursive_wildcard/**") { _, _ in "" }
740740
router.patch("/test/longer/path/name") { _, _ in "" }
741741
let routes = router.routes
@@ -750,11 +750,51 @@ final class RouterTests: XCTestCase {
750750
XCTAssertEqual(routes[3].method, .patch)
751751
XCTAssertEqual(routes[4].path.description, "/{test}/{what}")
752752
XCTAssertEqual(routes[4].method, .post)
753-
XCTAssertEqual(routes[5].path.description, "/wildcard/*")
753+
XCTAssertEqual(routes[5].path.description, "/wildcard/*/*")
754754
XCTAssertEqual(routes[5].method, .get)
755755
XCTAssertEqual(routes[6].path.description, "/recursive_wildcard/**")
756756
XCTAssertEqual(routes[6].method, .get)
757757
}
758+
759+
func testValidateOrdering() throws {
760+
let router = Router()
761+
router.post("{test}/{what}") { _, _ in "" }
762+
router.get("test/this") { _, _ in "" }
763+
try router.validate()
764+
}
765+
766+
func testValidateParametersVsWildcards() throws {
767+
let router = Router()
768+
router.get("test/*") { _, _ in "" }
769+
router.get("test/{what}") { _, _ in "" }
770+
XCTAssertThrowsError(try router.validate()) { error in
771+
guard let error = error as? RouterValidationError else {
772+
XCTFail()
773+
return
774+
}
775+
XCTAssertEqual(error.description, "Route /test/{what} overrides /test/*")
776+
}
777+
}
778+
779+
func testValidateParametersVsRecursiveWildcard() throws {
780+
let router = Router()
781+
router.get("test/**") { _, _ in "" }
782+
router.get("test/{what}") { _, _ in "" }
783+
XCTAssertThrowsError(try router.validate()) { error in
784+
guard let error = error as? RouterValidationError else {
785+
XCTFail()
786+
return
787+
}
788+
XCTAssertEqual(error.description, "Route /test/{what} overrides /test/**")
789+
}
790+
}
791+
792+
func testValidateDifferentParameterNames() throws {
793+
let router = Router()
794+
router.get("test/{this}") { _, _ in "" }
795+
router.get("test/{what}") { _, _ in "" }
796+
XCTAssertThrowsError(try router.validate())
797+
}
758798
}
759799

760800
struct TestRouterContext2: RequestContext {

0 commit comments

Comments
 (0)