From 17d8de63af08ad6f9531ae0e0f0606dc92779360 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 28 Dec 2024 08:09:04 +0000 Subject: [PATCH] Router validation (#637) --- .../Router/Router+validation.swift | 105 ++++++++++++++++++ Sources/Hummingbird/Router/Router.swift | 29 ++--- Tests/HummingbirdTests/RouterTests.swift | 44 +++++++- 3 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 Sources/Hummingbird/Router/Router+validation.swift diff --git a/Sources/Hummingbird/Router/Router+validation.swift b/Sources/Hummingbird/Router/Router+validation.swift new file mode 100644 index 00000000..9417454f --- /dev/null +++ b/Sources/Hummingbird/Router/Router+validation.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2024 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +extension Router { + /// Route description + public struct RouteDescription: CustomStringConvertible { + /// Route path + public let path: RouterPath + /// Route method + public let method: HTTPRequest.Method + + public var description: String { "\(method) \(path)" } + } + + /// List of routes added to router + public var routes: [RouteDescription] { + let trieValues = self.trie.root.values() + return trieValues.flatMap { endpoint in + endpoint.value.methods.keys + .sorted { $0.rawValue < $1.rawValue } + .map { RouteDescription(path: endpoint.path, method: $0) } + } + } + + /// Validate router + /// + /// Verify that routes are not clashing + public func validate() throws { + try self.trie.root.validate() + } +} + +extension RouterPathTrieBuilder.Node { + func validate(_ root: String = "") throws { + let sortedChildren = children.sorted { $0.key.priority > $1.key.priority } + if sortedChildren.count > 1 { + for index in 1..: RouterMethods, HTTPResponder /// build responder from router public func buildResponder() -> RouterResponder { + #if DEBUG + do { + try self.validate() + } catch { + assertionFailure("\(error)") + } + #endif if self.options.contains(.autoGenerateHeadEndpoints) { // swift-format-ignore: ReplaceForEachWithForLoop self.trie.forEach { node in @@ -128,25 +135,3 @@ public struct RouterOptions: OptionSet, Sendable { /// For every GET request that does not have a HEAD request, auto generate the HEAD request public static var autoGenerateHeadEndpoints: Self { .init(rawValue: 1 << 1) } } - -extension Router { - /// Route description - public struct RouteDescription: CustomStringConvertible { - /// Route path - public let path: RouterPath - /// Route method - public let method: HTTPRequest.Method - - public var description: String { "\(method) \(path)" } - } - - /// List of routes added to router - public var routes: [RouteDescription] { - let trieValues = self.trie.root.values() - return trieValues.flatMap { endpoint in - endpoint.value.methods.keys - .sorted { $0.rawValue < $1.rawValue } - .map { RouteDescription(path: endpoint.path, method: $0) } - } - } -} diff --git a/Tests/HummingbirdTests/RouterTests.swift b/Tests/HummingbirdTests/RouterTests.swift index 0dfc2eb2..55b6820a 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -735,7 +735,7 @@ final class RouterTests: XCTestCase { router.get("test/this") { _, _ in "" } router.put("test") { _, _ in "" } router.post("{test}/{what}") { _, _ in "" } - router.get("wildcard/*") { _, _ in "" } + router.get("wildcard/*/*") { _, _ in "" } router.get("recursive_wildcard/**") { _, _ in "" } router.patch("/test/longer/path/name") { _, _ in "" } let routes = router.routes @@ -750,11 +750,51 @@ final class RouterTests: XCTestCase { XCTAssertEqual(routes[3].method, .patch) XCTAssertEqual(routes[4].path.description, "/{test}/{what}") XCTAssertEqual(routes[4].method, .post) - XCTAssertEqual(routes[5].path.description, "/wildcard/*") + XCTAssertEqual(routes[5].path.description, "/wildcard/*/*") XCTAssertEqual(routes[5].method, .get) XCTAssertEqual(routes[6].path.description, "/recursive_wildcard/**") XCTAssertEqual(routes[6].method, .get) } + + func testValidateOrdering() throws { + let router = Router() + router.post("{test}/{what}") { _, _ in "" } + router.get("test/this") { _, _ in "" } + try router.validate() + } + + func testValidateParametersVsWildcards() throws { + let router = Router() + router.get("test/*") { _, _ in "" } + router.get("test/{what}") { _, _ in "" } + XCTAssertThrowsError(try router.validate()) { error in + guard let error = error as? RouterValidationError else { + XCTFail() + return + } + XCTAssertEqual(error.description, "Route /test/{what} overrides /test/*") + } + } + + func testValidateParametersVsRecursiveWildcard() throws { + let router = Router() + router.get("test/**") { _, _ in "" } + router.get("test/{what}") { _, _ in "" } + XCTAssertThrowsError(try router.validate()) { error in + guard let error = error as? RouterValidationError else { + XCTFail() + return + } + XCTAssertEqual(error.description, "Route /test/{what} overrides /test/**") + } + } + + func testValidateDifferentParameterNames() throws { + let router = Router() + router.get("test/{this}") { _, _ in "" } + router.get("test/{what}") { _, _ in "" } + XCTAssertThrowsError(try router.validate()) + } } struct TestRouterContext2: RequestContext {