diff --git a/.swiftformat b/.swiftformat index d800f8ace..beb1d0545 100644 --- a/.swiftformat +++ b/.swiftformat @@ -8,7 +8,7 @@ --exclude .build # rules ---disable redundantReturn, extensionAccessControl, typeSugar +--disable redundantReturn, extensionAccessControl, typeSugar, conditionalAssignment # format options --ifdef no-indent diff --git a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift index 0871d71b3..4c7dec8ba 100644 --- a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift +++ b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift @@ -104,7 +104,7 @@ let benchmarks = { try await channel.writeInbound(HTTPRequestPart.body(buffer)) try await channel.writeInbound(HTTPRequestPart.end(nil)) } responder: { request, _ in - let buffer = try await request.body.collate(maxSize: .max) + let buffer = try await request.body.collect(upTo: .max) return .init(status: .ok, body: .init(byteBuffer: buffer)) } } diff --git a/Benchmarks/Benchmarks/Router/Benchmarks.swift b/Benchmarks/Benchmarks/Router/Benchmarks.swift index 2da694499..16875464c 100644 --- a/Benchmarks/Benchmarks/Router/Benchmarks.swift +++ b/Benchmarks/Benchmarks/Router/Benchmarks.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Benchmark -@testable import Hummingbird +import Hummingbird let benchmarks = { Benchmark.defaultConfiguration = .init( @@ -24,6 +24,6 @@ let benchmarks = { ], warmupIterations: 10 ) - trieRouterBenchmarks() + binaryTrieRouterBenchmarks() routerBenchmarks() } diff --git a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift similarity index 53% rename from Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift rename to Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift index c8f341b85..8b6a2199d 100644 --- a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift @@ -13,21 +13,22 @@ //===----------------------------------------------------------------------===// import Benchmark -@testable import Hummingbird +@_spi(Internal) import Hummingbird -func trieRouterBenchmarks() { - var trie: RouterPathTrie! - Benchmark("TrieRouter", configuration: .init(scalingFactor: .kilo)) { benchmark in +func binaryTrieRouterBenchmarks() { + var trie: BinaryTrie! + Benchmark("BinaryTrieRouter", configuration: .init(scalingFactor: .kilo)) { benchmark in let testValues = [ "/test/", "/test/one", "/test/one/two", "/doesntExist", + "/api/v1/users/1/profile", ] benchmark.startMeasurement() for _ in benchmark.scaledIterations { - blackHole(testValues.map { trie.getValueAndParameters($0) }) + blackHole(testValues.map { trie.resolve($0) }) } } setup: { let trieBuilder = RouterPathTrieBuilder() @@ -36,27 +37,48 @@ func trieRouterBenchmarks() { trieBuilder.addEntry("/test/one/two", value: "/test/one/two") trieBuilder.addEntry("/test/:value", value: "/test/:value") trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2") + trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*") - trie = trieBuilder.build() + trie = BinaryTrie(base: trieBuilder) } - var trie2: RouterPathTrie! - Benchmark("TrieRouterParameters", configuration: .init(scalingFactor: .kilo)) { benchmark in + var trie2: BinaryTrie! + Benchmark("BinaryTrieRouterParameters", configuration: .init(scalingFactor: .kilo)) { benchmark in let testValues = [ "/test/value", "/test/value1/value2", "/test2/one/two", + "/api/v1/users/1/profile", ] benchmark.startMeasurement() for _ in benchmark.scaledIterations { - blackHole(testValues.map { trie2.getValueAndParameters($0) }) + blackHole(testValues.map { trie2.resolve($0) }) } } setup: { let trieBuilder = RouterPathTrieBuilder() trieBuilder.addEntry("/test/:value", value: "/test/:value") trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2") trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*") - trie2 = trieBuilder.build() + trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") + trie2 = BinaryTrie(base: trieBuilder) + } + + var trie3: BinaryTrie! + Benchmark("BinaryTrie:LongPaths", configuration: .init(scalingFactor: .kilo)) { benchmark in + let testValues = [ + "/api/v1/users/1/profile", + "/api/v1/a/very/long/path/with/lots/of/segments", + ] + benchmark.startMeasurement() + + for _ in benchmark.scaledIterations { + blackHole(testValues.map { trie3.resolve($0) }) + } + } setup: { + let trieBuilder = RouterPathTrieBuilder() + trieBuilder.addEntry("/api/v1/a/very/long/path/with/lots/of/segments", value: "/api/v1/a/very/long/path/with/lots/of/segments") + trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") + trie3 = BinaryTrie(base: trieBuilder) } } diff --git a/Benchmarks/Benchmarks/Router/RouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/RouterBenchmarks.swift index a75c7306c..d01659877 100644 --- a/Benchmarks/Benchmarks/Router/RouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/RouterBenchmarks.swift @@ -15,6 +15,7 @@ import Benchmark import HTTPTypes import Hummingbird +import NIOEmbedded import NIOHTTPTypes @_spi(Internal) import HummingbirdCore import Logging @@ -35,6 +36,7 @@ struct BenchmarkBodyWriter: Sendable, ResponseBodyWriter { func write(_: ByteBuffer) async throws {} } +typealias ByteBufferWriter = (ByteBuffer) async throws -> Void extension Benchmark { @discardableResult convenience init?( @@ -42,7 +44,7 @@ extension Benchmark { context: Context.Type = BasicBenchmarkContext.self, configuration: Benchmark.Configuration = Benchmark.defaultConfiguration, request: HTTPRequest, - writeBody: @escaping @Sendable (StreamedRequestBody.InboundStream.TestSource) async throws -> Void = { _ in }, + writeBody: @escaping @Sendable (ByteBufferWriter) async throws -> Void = { _ in }, setupRouter: @escaping @Sendable (Router) async throws -> Void ) { let router = Router(context: Context.self) @@ -54,19 +56,16 @@ extension Benchmark { for _ in 0..<50 { try await withThrowingTaskGroup(of: Void.self) { group in let context = Context( - allocator: ByteBufferAllocator(), + channel: EmbeddedChannel(), logger: Logger(label: "Benchmark") ) - let (inbound, source) = NIOAsyncChannelInboundStream.makeTestingStream() - let streamer = StreamedRequestBody(iterator: inbound.makeAsyncIterator()) - let requestBody = RequestBody.stream(streamer) + let (requestBody, source) = RequestBody.makeStream() let Request = Request(head: request, body: requestBody) group.addTask { let response = try await responder.respond(to: Request, context: context) _ = try await response.body.write(BenchmarkBodyWriter()) } - try await writeBody(source) - source.yield(.end(nil)) + try await writeBody(source.yield) source.finish() } } @@ -98,14 +97,14 @@ func routerBenchmarks() { name: "Router:PUT", configuration: .init(warmupIterations: 10), request: .init(method: .put, scheme: "http", authority: "localhost", path: "/") - ) { bodyStream in - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) + ) { write in + try await write(buffer) + try await write(buffer) + try await write(buffer) + try await write(buffer) } setupRouter: { router in router.put { request, _ in - let body = try await request.body.collate(maxSize: .max) + let body = try await request.body.collect(upTo: .max) return body.readableBytes.description } } @@ -114,11 +113,11 @@ func routerBenchmarks() { name: "Router:Echo", configuration: .init(warmupIterations: 10), request: .init(method: .post, scheme: "http", authority: "localhost", path: "/") - ) { bodyStream in - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) - bodyStream.yield(.body(buffer)) + ) { write in + try await write(buffer) + try await write(buffer) + try await write(buffer) + try await write(buffer) } setupRouter: { router in router.post { request, _ in Response(status: .ok, headers: [:], body: .init { writer in @@ -134,7 +133,7 @@ func routerBenchmarks() { configuration: .init(warmupIterations: 10), request: .init(method: .get, scheme: "http", authority: "localhost", path: "/") ) { router in - struct EmptyMiddleware: MiddlewareProtocol { + struct EmptyMiddleware: RouterMiddleware { func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { return try await next(request, context) } diff --git a/Sources/Hummingbird/Deprecations.swift b/Sources/Hummingbird/Deprecations.swift index 81ef0cf7a..1c882c4ed 100644 --- a/Sources/Hummingbird/Deprecations.swift +++ b/Sources/Hummingbird/Deprecations.swift @@ -57,8 +57,6 @@ public typealias HBRouterMethods = RouterMethods public typealias HBRouterOptions = RouterOptions @_documentation(visibility: internal) @available(*, deprecated, renamed: "RouterPath") public typealias HBRouterPath = RouterPath -@_documentation(visibility: internal) @available(*, deprecated, renamed: "RouterResponder") -public typealias HBRouterResponder = RouterResponder @_documentation(visibility: internal) @available(*, deprecated, renamed: "CORSMiddleware") public typealias HBCORSMiddleware = CORSMiddleware diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift new file mode 100644 index 000000000..10933cef0 --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -0,0 +1,163 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOCore + +extension BinaryTrie { + /// Resolve a path to a `Value` if available + @_spi(Internal) public func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { + var trie = trie + let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) + var pathComponentsIterator = pathComponents.makeIterator() + var parameters = Parameters() + guard var node: BinaryTrieNode = trie.readBinaryTrieNode() else { return nil } + while let component = pathComponentsIterator.next() { + node = self.matchComponent(component, in: &trie, parameters: ¶meters) + if node.token == .recursiveWildcard { + // we have found a recursive wildcard. Go through all the path components until we match one of them + // or reach the end of the path component array + var range = component.startIndex.. (value: Value, parameters: Parameters)? { + if let index, let value = self.values[Int(index)] { + return (value: value, parameters: parameters) + } + + return nil + } + + /// Match sibling node for path component + private func matchComponent( + _ component: Substring, + in trie: inout ByteBuffer, + parameters: inout Parameters + ) -> BinaryTrieNode { + while let node = trie.readBinaryTrieNode() { + let result = self.matchComponent(component, withToken: node.token, in: &trie, parameters: ¶meters) + switch result { + case .match, .deadEnd: + return node + default: + trie.moveReaderIndex(to: Int(node.nextSiblingNodeIndex)) + } + } + // should never get here + return .init(index: 0, token: .deadEnd, nextSiblingNodeIndex: UInt32(trie.writerIndex)) + } + + private enum MatchResult { + case match, mismatch, ignore, deadEnd + } + + private func matchComponent( + _ component: Substring, + withToken token: BinaryTrieTokenKind, + in trie: inout ByteBuffer, + parameters: inout Parameters + ) -> MatchResult { + switch token { + case .path: + // The current node is a constant + guard + trie.readAndCompareString( + to: component, + length: Integer.self + ) + else { + return .mismatch + } + + return .match + case .capture: + // The current node is a parameter + guard + let parameter = trie.readLengthPrefixedString(as: Integer.self) + else { + return .mismatch + } + + parameters[Substring(parameter)] = component + return .match + case .prefixCapture: + guard + let suffix = trie.readLengthPrefixedString(as: Integer.self), + let parameter = trie.readLengthPrefixedString(as: Integer.self), + component.hasSuffix(suffix) + else { + return .mismatch + } + + parameters[Substring(parameter)] = component.dropLast(suffix.count) + return .match + case .suffixCapture: + guard + let prefix = trie.readLengthPrefixedString(as: Integer.self), + let parameter = trie.readLengthPrefixedString(as: Integer.self), + component.hasPrefix(prefix) + else { + return .mismatch + } + + parameters[Substring(parameter)] = component.dropFirst(prefix.count) + return .match + case .wildcard: + // Always matches, descend + return .match + case .prefixWildcard: + guard + let suffix = trie.readLengthPrefixedString(as: Integer.self), + component.hasSuffix(suffix) + else { + return .mismatch + } + + return .match + case .suffixWildcard: + guard + let prefix = trie.readLengthPrefixedString(as: Integer.self), + component.hasPrefix(prefix) + else { + return .mismatch + } + + return .match + case .recursiveWildcard: + return .match + case .null: + return .ignore + case .deadEnd: + return .deadEnd + } + } +} diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift new file mode 100644 index 000000000..7884da892 --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOCore + +extension BinaryTrie { + static func serialize( + _ node: RouterPathTrieBuilder.Node, + trie: inout ByteBuffer, + values: inout [Value?] + ) { + let binaryTrieNodeIndex = trie.writerIndex + trie.reserveBinaryTrieNode() + // Index where `value` is located + let index = UInt16(values.count) + values.append(node.value) + + let token: BinaryTrieTokenKind + switch node.key { + case .path(let path): + token = .path + // Serialize the path constant + trie.writeLengthPrefixedString(path, as: Integer.self) + case .capture(let parameter): + token = .capture + // Serialize the parameter + trie.writeLengthPrefixedString(parameter, as: Integer.self) + case .prefixCapture(suffix: let suffix, parameter: let parameter): + token = .prefixCapture + // Serialize the suffix and parameter + trie.writeLengthPrefixedString(suffix, as: Integer.self) + trie.writeLengthPrefixedString(parameter, as: Integer.self) + case .suffixCapture(prefix: let prefix, parameter: let parameter): + token = .suffixCapture + // Serialize the prefix and parameter + trie.writeLengthPrefixedString(prefix, as: Integer.self) + trie.writeLengthPrefixedString(parameter, as: Integer.self) + case .wildcard: + token = .wildcard + case .prefixWildcard(let suffix): + token = .prefixWildcard + // Serialize the suffix + trie.writeLengthPrefixedString(suffix, as: Integer.self) + case .suffixWildcard(let prefix): + token = .suffixWildcard + // Serialize the prefix + trie.writeLengthPrefixedString(prefix, as: Integer.self) + case .recursiveWildcard: + token = .recursiveWildcard + case .null: + token = .null + } + + self.serializeChildren( + of: node, + trie: &trie, + values: &values + ) + + let deadEndIndex = trie.writerIndex + // The last node in a trie is always a deadEnd token. We reserve space for it so we + // get the correct writer index for the next sibling + trie.reserveBinaryTrieNode() + trie.setBinaryTrieNode(.init(index: 0, token: .deadEnd, nextSiblingNodeIndex: UInt32(trie.writerIndex)), at: deadEndIndex) + // Write trie node + trie.setBinaryTrieNode(.init(index: index, token: token, nextSiblingNodeIndex: UInt32(trie.writerIndex)), at: binaryTrieNodeIndex) + } + + static func serializeChildren( + of node: RouterPathTrieBuilder.Node, + trie: inout ByteBuffer, + values: inout [Value?] + ) { + // Serialize the child nodes in order of priority + // That's also the order of resolution + for child in node.children.sorted(by: self.highestPriorityFirst) { + self.serialize(child, trie: &trie, values: &values) + } + } + + private static func highestPriorityFirst(lhs: RouterPathTrieBuilder.Node, rhs: RouterPathTrieBuilder.Node) -> Bool { + lhs.key.priority > rhs.key.priority + } +} + +extension RouterPath.Element { + fileprivate var priority: Int { + switch self { + case .prefixCapture, .suffixCapture: + // Most specific + return 1 + case .path, .null: + // Specific + return 0 + case .prefixWildcard, .suffixWildcard: + // Less specific + return -1 + case .capture: + // More important than wildcards + return -2 + case .wildcard: + // Not specific at all + return -3 + case .recursiveWildcard: + // Least specific + return -4 + } + } +} diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift new file mode 100644 index 000000000..4afce5383 --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +enum BinaryTrieTokenKind: UInt8 { + case null = 0 + case path, capture, prefixCapture, suffixCapture, wildcard, prefixWildcard, suffixWildcard, recursiveWildcard + case deadEnd +} + +struct BinaryTrieNode { + let index: UInt16 + let token: BinaryTrieTokenKind + let nextSiblingNodeIndex: UInt32 + + /// How many bytes a serialized BinaryTrieNode uses + static let serializedSize = 7 +} + +@_spi(Internal) public final class BinaryTrie: Sendable { + typealias Integer = UInt8 + let trie: ByteBuffer + let values: [Value?] + + @_spi(Internal) public init(base: RouterPathTrieBuilder) { + var trie = ByteBufferAllocator().buffer(capacity: 1024) + var values: [Value?] = [] + + Self.serialize( + base.root, + trie: &trie, + values: &values + ) + + self.trie = trie + self.values = values + } +} diff --git a/Sources/Hummingbird/Router/BinaryTrie/ByteBuffer+BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/ByteBuffer+BinaryTrie.swift new file mode 100644 index 000000000..aa29db18f --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/ByteBuffer+BinaryTrie.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin.C +#elseif canImport(Musl) +import Musl +#elseif os(Linux) || os(FreeBSD) || os(Android) +import Glibc +#else +#error("unsupported os") +#endif + +internal extension ByteBuffer { + /// Write length prefixed string to ByteBuffer + mutating func writeLengthPrefixedString(_ string: Substring, as integer: F.Type) { + do { + try self.writeLengthPrefixed(as: F.self) { buffer in + buffer.writeSubstring(string) + } + } catch { + preconditionFailure("Failed to write \"\(string)\" into BinaryTrie") + } + } + + /// Write BinaryTrieNode into ByteBuffer at position + @discardableResult mutating func setBinaryTrieNode(_ node: BinaryTrieNode, at index: Int) -> Int { + var offset = self.setInteger(node.index, at: index) + offset += self.setInteger(node.token.rawValue, at: index + offset) + offset += self.setInteger(node.nextSiblingNodeIndex, at: index + offset) + return offset + } + + /// Write BinaryTrieNode into ByteBuffer at position + mutating func writeBinaryTrieNode(_ node: BinaryTrieNode) { + let offset = self.setBinaryTrieNode(node, at: self.writerIndex) + self.moveWriterIndex(forwardBy: offset) + } + + /// Reserve space for a BinaryTrieNode + mutating func reserveBinaryTrieNode() { + self.moveWriterIndex(forwardBy: BinaryTrieNode.serializedSize) + } + + /// Read BinaryTrieNode from ByteBuffer + mutating func readBinaryTrieNode() -> BinaryTrieNode? { + guard let index = self.readInteger(as: UInt16.self), + let token = self.readToken(), + let nextSiblingNodeIndex: UInt32 = self.readInteger() + else { + return nil + } + return BinaryTrieNode(index: index, token: token, nextSiblingNodeIndex: nextSiblingNodeIndex) + } + + /// Read string from ByteBuffer and compare against another string + mutating func readAndCompareString( + to string: Substring, + length: Length.Type + ) -> Bool { + guard + let _length: Length = readInteger() + else { + return false + } + + let length = Int(_length) + + func compare(utf8: UnsafeBufferPointer) -> Bool { + if utf8.count != length { + return false + } + + if length == 0 { + // Needed, because `memcmp` wants a non-null pointer on Linux + // and a zero-length buffer has no baseAddress + return true + } + + return withUnsafeReadableBytes { buffer in + if memcmp(utf8.baseAddress!, buffer.baseAddress!, length) == 0 { + moveReaderIndex(forwardBy: length) + return true + } else { + return false + } + } + } + + guard let result = string.withContiguousStorageIfAvailable({ characters in + characters.withMemoryRebound(to: UInt8.self) { utf8 in + compare(utf8: utf8) + } + }) else { + var string = string + return string.withUTF8 { utf8 in + compare(utf8: utf8) + } + } + + return result + } + + /// Read length prefixed string from ByteBuffer + mutating func readLengthPrefixedString(as integer: F.Type) -> String? { + guard let buffer = readLengthPrefixedSlice(as: F.self) else { + return nil + } + + return String(buffer: buffer) + } + + /// Read BinaryTrieTokenKind from ByteBuffer + mutating func readToken() -> BinaryTrieTokenKind? { + guard + let _token: BinaryTrieTokenKind.RawValue = readInteger(), + let token = BinaryTrieTokenKind(rawValue: _token) + else { + return nil + } + + return token + } +} diff --git a/Sources/Hummingbird/Router/Router.swift b/Sources/Hummingbird/Router/Router.swift index a0eb58cd2..869cbc81b 100644 --- a/Sources/Hummingbird/Router/Router.swift +++ b/Sources/Hummingbird/Router/Router.swift @@ -74,9 +74,9 @@ public final class Router: RouterMethods, HTTPRespo node.value?.autoGenerateHeadEndpoint() } } - return RouterResponder( + return .init( context: Context.self, - trie: self.trie.build(), + trie: self.trie, options: self.options, notFoundResponder: self.middlewares.constructResponder(finalResponder: NotFoundResponder()) ) diff --git a/Sources/Hummingbird/Router/RouterResponder.swift b/Sources/Hummingbird/Router/RouterResponder.swift index c4abec9ae..745616d02 100644 --- a/Sources/Hummingbird/Router/RouterResponder.swift +++ b/Sources/Hummingbird/Router/RouterResponder.swift @@ -2,7 +2,7 @@ // // This source file is part of the Hummingbird server framework project // -// Copyright (c) 2021-2023 the Hummingbird authors +// Copyright (c) 2024 the Hummingbird authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,23 +12,20 @@ // //===----------------------------------------------------------------------===// -/// Directs requests to handlers based on the request uri and method. -/// -/// Conforms to `Responder` so need to provide its own implementation of -/// `func respond(to request: Request, context: Context) async throws -> Response`. -/// +import NIOCore + public struct RouterResponder: HTTPResponder { - let trie: RouterPathTrie> + let trie: BinaryTrie> let notFoundResponder: any HTTPResponder let options: RouterOptions init( context: Context.Type, - trie: RouterPathTrie>, + trie: RouterPathTrieBuilder>, options: RouterOptions, notFoundResponder: any HTTPResponder ) { - self.trie = trie + self.trie = BinaryTrie(base: trie) self.options = options self.notFoundResponder = notFoundResponder } @@ -43,17 +40,16 @@ public struct RouterResponder: HTTPResponder { } else { path = request.uri.path } - guard let result = trie.getValueAndParameters(path), - let responder = result.value.getResponder(for: request.method) + guard + let (responderChain, parameters) = trie.resolve(path), + let responder = responderChain.getResponder(for: request.method) else { return try await self.notFoundResponder.respond(to: request, context: context) } var context = context - if let parameters = result.parameters { - context.coreContext.parameters = parameters - } + context.coreContext.parameters = parameters // store endpoint path in request (mainly for metrics) - context.coreContext.endpointPath.value = result.value.path + context.coreContext.endpointPath.value = responderChain.path return try await responder.respond(to: request, context: context) } } diff --git a/Sources/Hummingbird/Router/TrieRouter.swift b/Sources/Hummingbird/Router/TrieRouter.swift index 99760cc23..d805bef12 100644 --- a/Sources/Hummingbird/Router/TrieRouter.swift +++ b/Sources/Hummingbird/Router/TrieRouter.swift @@ -15,10 +15,10 @@ import HummingbirdCore /// URI Path Trie Builder -struct RouterPathTrieBuilder { +@_spi(Internal) public struct RouterPathTrieBuilder { var root: Node - init() { + public init() { self.root = Node(key: .null, output: nil) } @@ -27,7 +27,7 @@ struct RouterPathTrieBuilder { /// - entry: Path for entry /// - value: Value to add to this path if one does not exist already /// - onAdd: How to edit the value at this path - func addEntry(_ entry: RouterPath, value: @autoclosure () -> Value, onAdd: (Node) -> Void = { _ in }) { + public func addEntry(_ entry: RouterPath, value: @autoclosure () -> Value, onAdd: (Node) -> Void = { _ in }) { var node = self.root for key in entry { node = node.addChild(key: key, output: nil) @@ -40,8 +40,8 @@ struct RouterPathTrieBuilder { } } - func build() -> RouterPathTrie { - .init(root: self.root.build()) + @_spi(Internal) public func build() -> BinaryTrie { + .init(base: self) } func forEach(_ process: (Node) throws -> Void) rethrows { @@ -49,7 +49,7 @@ struct RouterPathTrieBuilder { } /// Trie Node. Each node represents one component of a URI path - final class Node { + @_spi(Internal) public final class Node { let key: RouterPath.Element var children: [Node] var value: Value? @@ -80,10 +80,6 @@ struct RouterPathTrieBuilder { return self.children.first { $0.key ~= key } } - func build() -> RouterPathTrie.Node { - return .init(key: self.key, value: self.value, children: self.children.map { $0.build() }) - } - func forEach(_ process: (Node) throws -> Void) rethrows { try process(self) for node in self.children { @@ -92,93 +88,3 @@ struct RouterPathTrieBuilder { } } } - -/// Trie used by Router responder -struct RouterPathTrie: Sendable { - let root: Node - - /// Initialise RouterPathTrie - /// - Parameter root: Root node of trie - init(root: Node) { - self.root = root - } - - /// Get value from trie and any parameters from capture nodes - /// - Parameter path: Path to process - /// - Returns: value and parameters - func getValueAndParameters(_ path: String) -> (value: Value, parameters: Parameters?)? { - let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) - var parameters: Parameters? - var node = self.root - for component in pathComponents { - if let childNode = node.getChild(component) { - node = childNode - switch node.key { - case .capture(let key): - parameters.set(key, value: component) - case .prefixCapture(let suffix, let key): - parameters.set(key, value: component.dropLast(suffix.count)) - case .suffixCapture(let prefix, let key): - parameters.set(key, value: component.dropFirst(prefix.count)) - case .recursiveWildcard: - parameters.setCatchAll(path[component.startIndex.. Node? { - return self.children.first { $0.key == key } - } - - func getChild(_ key: Substring) -> Node? { - if let child = self.children.first(where: { $0.key == key }) { - return child - } - return self.children.first { $0.key ~= key } - } - } -} - -extension Optional { - fileprivate mutating func set(_ s: Substring, value: Substring) { - switch self { - case .some(var parameters): - parameters[s] = value - self = .some(parameters) - case .none: - self = .some(.init(.init([(s, value)]))) - } - } - - fileprivate mutating func setCatchAll(_ value: Substring) { - switch self { - case .some(var parameters): - parameters.setCatchAll(value) - self = .some(parameters) - case .none: - self = .some(.init(.init([(Parameters.recursiveCaptureKey, value)]))) - } - } -} diff --git a/Tests/HummingbirdTests/RouterTests.swift b/Tests/HummingbirdTests/RouterTests.swift index ddd19d19b..f1210d04f 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -451,6 +451,25 @@ final class RouterTests: XCTestCase { } } + func testRecursiveWildcard() async throws { + let router = Router() + router.get("/api/v1/**/john") { _, context in + return "John \(context.parameters.getCatchAll().joined(separator: "/"))" + } + router.get("/api/v1/**/jane") { _, context in + return "Jane \(context.parameters.getCatchAll().joined(separator: "/"))" + } + let app = Application(responder: router.buildResponder()) + try await app.test(.router) { client in + try await client.execute(uri: "/api/v1/a/b/c/d/e/f/john", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), "John a/b/c/d/e/f") + } + try await client.execute(uri: "/api/v1/a/b/d/e/f/jane", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), "Jane a/b/d/e/f") + } + } + } + // Test auto generation of HEAD endpoints works func testAutoGenerateHeadEndpoints() async throws { let router = Router(options: .autoGenerateHeadEndpoints) diff --git a/Tests/HummingbirdTests/TrieRouterTests.swift b/Tests/HummingbirdTests/TrieRouterTests.swift index f13c33715..60b44515d 100644 --- a/Tests/HummingbirdTests/TrieRouterTests.swift +++ b/Tests/HummingbirdTests/TrieRouterTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -@testable import Hummingbird +@_spi(Internal) import Hummingbird import XCTest class TrieRouterTests: XCTestCase { @@ -23,18 +23,18 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("/Users/*/bin", value: "test3") let trie = trieBuilder.build() - XCTAssertEqual(trie.getValueAndParameters("/usr/local/bin")?.value, "test1") - XCTAssertEqual(trie.getValueAndParameters("/usr/bin")?.value, "test2") - XCTAssertEqual(trie.getValueAndParameters("/Users/john/bin")?.value, "test3") - XCTAssertEqual(trie.getValueAndParameters("/Users/jane/bin")?.value, "test3") + XCTAssertEqual(trie.resolve("/usr/local/bin")?.value, "test1") + XCTAssertEqual(trie.resolve("/usr/bin")?.value, "test2") + XCTAssertEqual(trie.resolve("/Users/john/bin")?.value, "test3") + XCTAssertEqual(trie.resolve("/Users/jane/bin")?.value, "test3") } func testRootNode() { let trieBuilder = RouterPathTrieBuilder() trieBuilder.addEntry("", value: "test1") let trie = trieBuilder.build() - XCTAssertEqual(trie.getValueAndParameters("/")?.value, "test1") - XCTAssertEqual(trie.getValueAndParameters("")?.value, "test1") + XCTAssertEqual(trie.resolve("/")?.value, "test1") + XCTAssertEqual(trie.resolve("")?.value, "test1") } func testWildcard() { @@ -43,10 +43,10 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("users/*/fowler", value: "test2") trieBuilder.addEntry("users/*/*", value: "test3") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/users")) - XCTAssertEqual(trie.getValueAndParameters("/users/adam")?.value, "test1") - XCTAssertEqual(trie.getValueAndParameters("/users/adam/fowler")?.value, "test2") - XCTAssertEqual(trie.getValueAndParameters("/users/adam/1")?.value, "test3") + XCTAssertNil(trie.resolve("/users")) + XCTAssertEqual(trie.resolve("/users/adam")?.value, "test1") + XCTAssertEqual(trie.resolve("/users/adam/fowler")?.value, "test2") + XCTAssertEqual(trie.resolve("/users/adam/1")?.value, "test3") } func testGetParameters() { @@ -54,20 +54,20 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("users/:user", value: "test1") trieBuilder.addEntry("users/:user/name", value: "john smith") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/user/")) - XCTAssertEqual(trie.getValueAndParameters("/users/1234")?.parameters?.get("user"), "1234") - XCTAssertEqual(trie.getValueAndParameters("/users/1234/name")?.parameters?.get("user"), "1234") - XCTAssertEqual(trie.getValueAndParameters("/users/1234/name")?.value, "john smith") + XCTAssertNil(trie.resolve("/user/")) + XCTAssertEqual(trie.resolve("/users/1234")?.parameters.get("user"), "1234") + XCTAssertEqual(trie.resolve("/users/1234/name")?.parameters.get("user"), "1234") + XCTAssertEqual(trie.resolve("/users/1234/name")?.value, "john smith") } func testRecursiveWildcard() { let trieBuilder = RouterPathTrieBuilder() trieBuilder.addEntry("**", value: "**") let trie = trieBuilder.build() - XCTAssertEqual(trie.getValueAndParameters("/one")?.value, "**") - XCTAssertEqual(trie.getValueAndParameters("/one/two")?.value, "**") - XCTAssertEqual(trie.getValueAndParameters("/one/two/three")?.value, "**") - XCTAssertEqual(trie.getValueAndParameters("/one/two/three")?.parameters?.getCatchAll(), ["one", "two", "three"]) + XCTAssertEqual(trie.resolve("/one")?.value, "**") + XCTAssertEqual(trie.resolve("/one/two")?.value, "**") + XCTAssertEqual(trie.resolve("/one/two/three")?.value, "**") + XCTAssertEqual(trie.resolve("/one/two/three")?.parameters.getCatchAll(), ["one", "two", "three"]) } func testRecursiveWildcardWithPrefix() { @@ -75,14 +75,14 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("Test/**", value: "true") trieBuilder.addEntry("Test2/:test/**", value: "true") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/notTest/hello")) - XCTAssertNil(trie.getValueAndParameters("/Test/")?.value, "true") - XCTAssertEqual(trie.getValueAndParameters("/Test/one")?.value, "true") - XCTAssertEqual(trie.getValueAndParameters("/Test/one/two")?.value, "true") - XCTAssertEqual(trie.getValueAndParameters("/Test/one/two/three")?.value, "true") - XCTAssertEqual(trie.getValueAndParameters("/Test/")?.parameters?.getCatchAll(), nil) - XCTAssertEqual(trie.getValueAndParameters("/Test/one/two")?.parameters?.getCatchAll(), ["one", "two"]) - XCTAssertEqual(trie.getValueAndParameters("/Test2/one/two")?.parameters?.getCatchAll(), ["two"]) + XCTAssertNil(trie.resolve("/notTest/hello")) + XCTAssertNil(trie.resolve("/Test/")?.value, "true") + XCTAssertEqual(trie.resolve("/Test/one")?.value, "true") + XCTAssertEqual(trie.resolve("/Test/one/two")?.value, "true") + XCTAssertEqual(trie.resolve("/Test/one/two/three")?.value, "true") + XCTAssertEqual(trie.resolve("/Test/")?.parameters.getCatchAll(), nil) + XCTAssertEqual(trie.resolve("/Test/one/two")?.parameters.getCatchAll(), ["one", "two"]) + XCTAssertEqual(trie.resolve("/Test2/one/two")?.parameters.getCatchAll(), ["two"]) XCTAssertEqual(Parameters().getCatchAll(), []) } @@ -92,10 +92,10 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("test/*.jpg", value: "testjpg") trieBuilder.addEntry("*.app/config.json", value: "app") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/hello.png")) - XCTAssertEqual(trie.getValueAndParameters("/hello.jpg")?.value, "jpg") - XCTAssertEqual(trie.getValueAndParameters("/test/hello.jpg")?.value, "testjpg") - XCTAssertEqual(trie.getValueAndParameters("/hello.app/config.json")?.value, "app") + XCTAssertNil(trie.resolve("/hello.png")) + XCTAssertEqual(trie.resolve("/hello.jpg")?.value, "jpg") + XCTAssertEqual(trie.resolve("/test/hello.jpg")?.value, "testjpg") + XCTAssertEqual(trie.resolve("/hello.app/config.json")?.value, "app") } func testSuffixWildcard() { @@ -104,10 +104,10 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("test/file.*", value: "testfile") trieBuilder.addEntry("file.*/test", value: "filetest") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/file2.png")) - XCTAssertEqual(trie.getValueAndParameters("/file.jpg")?.value, "file") - XCTAssertEqual(trie.getValueAndParameters("/test/file.jpg")?.value, "testfile") - XCTAssertEqual(trie.getValueAndParameters("/file.png/test")?.value, "filetest") + XCTAssertNil(trie.resolve("/file2.png")) + XCTAssertEqual(trie.resolve("/file.jpg")?.value, "file") + XCTAssertEqual(trie.resolve("/test/file.jpg")?.value, "testfile") + XCTAssertEqual(trie.resolve("/file.png/test")?.value, "filetest") } func testPrefixCapture() { @@ -116,10 +116,10 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("test/{file}.jpg", value: "testjpg") trieBuilder.addEntry("{app}.app/config.json", value: "app") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/hello.png")) - XCTAssertEqual(trie.getValueAndParameters("/hello.jpg")?.parameters?.get("file"), "hello") - XCTAssertEqual(trie.getValueAndParameters("/test/hello.jpg")?.parameters?.get("file"), "hello") - XCTAssertEqual(trie.getValueAndParameters("/hello.app/config.json")?.parameters?.get("app"), "hello") + XCTAssertNil(trie.resolve("/hello.png")) + XCTAssertEqual(trie.resolve("/hello.jpg")?.parameters.get("file"), "hello") + XCTAssertEqual(trie.resolve("/test/hello.jpg")?.parameters.get("file"), "hello") + XCTAssertEqual(trie.resolve("/hello.app/config.json")?.parameters.get("app"), "hello") } func testSuffixCapture() { @@ -128,24 +128,24 @@ class TrieRouterTests: XCTestCase { trieBuilder.addEntry("test/file.{ext}", value: "testfile") trieBuilder.addEntry("file.{ext}/test", value: "filetest") let trie = trieBuilder.build() - XCTAssertNil(trie.getValueAndParameters("/file2.png")) - XCTAssertEqual(trie.getValueAndParameters("/file.jpg")?.parameters?.get("ext"), "jpg") - XCTAssertEqual(trie.getValueAndParameters("/test/file.jpg")?.parameters?.get("ext"), "jpg") - XCTAssertEqual(trie.getValueAndParameters("/file.png/test")?.parameters?.get("ext"), "png") + XCTAssertNil(trie.resolve("/file2.png")) + XCTAssertEqual(trie.resolve("/file.jpg")?.parameters.get("ext"), "jpg") + XCTAssertEqual(trie.resolve("/test/file.jpg")?.parameters.get("ext"), "jpg") + XCTAssertEqual(trie.resolve("/file.png/test")?.parameters.get("ext"), "png") } func testPrefixFullComponentCapture() { let trieBuilder = RouterPathTrieBuilder() trieBuilder.addEntry("{text}", value: "test") let trie = trieBuilder.build() - XCTAssertEqual(trie.getValueAndParameters("/file.jpg")?.parameters?.get("text"), "file.jpg") + XCTAssertEqual(trie.resolve("/file.jpg")?.parameters.get("text"), "file.jpg") } func testIncompletSuffixCapture() { let trieBuilder = RouterPathTrieBuilder() trieBuilder.addEntry("text}", value: "test") let trie = trieBuilder.build() - XCTAssertEqual(trie.getValueAndParameters("/text}")?.value, "test") - XCTAssertNil(trie.getValueAndParameters("/text")) + XCTAssertEqual(trie.resolve("/text}")?.value, "test") + XCTAssertNil(trie.resolve("/text")) } }