From d4025f8ac13b368911099393b88c6faba9ce3805 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Sun, 17 Mar 2024 18:28:09 +0100 Subject: [PATCH 1/7] Add a serialized variant of the Trie-router Since this trie is contiguous memory, routing performance should improve. Note that --- .../BinaryTrie/BinaryTrie+resolve.swift | 184 ++++++++++++++++++ .../BinaryTrie/BinaryTrie+serialize.swift | 147 ++++++++++++++ .../Router/BinaryTrie/BinaryTrie.swift | 25 +++ Sources/Hummingbird/Router/Router.swift | 4 +- Sources/Hummingbird/Router/TrieRouter.swift | 42 ++++ Tests/HummingbirdTests/RouterTests.swift | 2 + 6 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift create mode 100644 Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift create mode 100644 Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift new file mode 100644 index 000000000..401ac650b --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -0,0 +1,184 @@ +import NIOCore + +extension BinaryTrie { + /// Resolve a path to a `Value` if available + func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { + var trie = trie + var pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) + var parameters = Parameters() + + if pathComponents.isEmpty { + return value(for: 0, parameters: parameters) + } + + return descendPath( + in: &trie, + index: 0, + parameters: ¶meters, + components: &pathComponents + ) + } + + /// If `index != nil`, resolves the `index` to a `Value` + /// This is used as a helper in `descendPath(in:parameters:components:)` + private func value(for index: UInt16?, parameters: Parameters) -> (value: Value, parameters: Parameters)? { + if let index, let value = self.values[Int(index)] { + return (value: value, parameters: parameters) + } + + return nil + } + + /// A function that takes a path component and descends the trie to find the value + private func descendPath( + in trie: inout ByteBuffer, + index: UInt16, + parameters: inout Parameters, + components: inout [Substring] + ) -> (value: Value, parameters: Parameters)? { + // If there are no more components in the path, return the value found + if components.isEmpty { + return value(for: index, parameters: parameters) + } + + // Take the next component from the path + var component = components.removeFirst() + + // Check the current node type through TokenKind + // And read the location of the _next_ node from the trie buffer + while + let index = trie.readInteger(as: UInt16.self), + let _token: Integer = trie.readInteger(), + let token = TokenKind(rawValue: _token), + let nextNodeIndex: UInt32 = trie.readInteger() + { + switch token { + case .path: + // The current node is a constant + guard + let length: Integer = trie.readInteger(), + trie.readAndCompareString(to: &component, length: length) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + continue + } + case .capture: + // The current node is a parameter + guard + let length: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(length)) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + return nil + } + + parameters[Substring(parameter)] = component + case .prefixCapture: + guard + let suffixLength: Integer = trie.readInteger(), + let suffix = trie.readString(length: Int(suffixLength)), + let parameterLength: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(parameterLength)), + component.hasSuffix(suffix) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + continue + } + + component.removeLast(suffix.count) + parameters[Substring(parameter)] = component + case .suffixCapture: + guard + let prefixLength: Integer = trie.readInteger(), + trie.readAndCompareString(to: &component, length: prefixLength), + let parameterLength: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(parameterLength)) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + continue + } + + component.removeFirst(Int(prefixLength)) + parameters[Substring(parameter)] = component + case .wildcard: + // Always matches, descend + () + case .prefixWildcard: + guard + let suffixLength: Integer = trie.readInteger(), + let suffix = trie.readString(length: Int(suffixLength)), + component.hasSuffix(suffix) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + continue + } + case .suffixWildcard: + guard + let prefixLength: Integer = trie.readInteger(), + trie.readAndCompareString(to: &component, length: prefixLength) + else { + // The constant's does not match the component's length + // So we can skip to the next sibling + trie.moveReaderIndex(to: Int(nextNodeIndex)) + continue + } + case .recursiveWildcard: + fatalError() + case .null: + continue + case .deadEnd: + return nil + } + + // This node matches! + return descendPath( + in: &trie, + index: index, + parameters: ¶meters, + components: &components + ) + } + + return nil + } +} + +#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 + +fileprivate extension ByteBuffer { + mutating func readAndCompareString(to string: inout Substring, length: Length) -> Bool { + let length = Int(length) + return string.withUTF8 { utf8 in + if utf8.count != length { + return false + } + + return withUnsafeReadableBytes { buffer in + if memcmp(utf8.baseAddress, buffer.baseAddress, length) == 0 { + moveReaderIndex(forwardBy: length) + return true + } else { + return false + } + } + } + } +} diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift new file mode 100644 index 000000000..bf04b8e2a --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift @@ -0,0 +1,147 @@ +import NIOCore + +extension BinaryTrie { + static func serialize( + _ node: RouterPathTrie.Node, + trie: inout ByteBuffer, + values: inout [Value?] + ) throws { + // Index where `value` is located + trie.writeInteger(UInt16(values.count)) + values.append(node.value) + + var nextNodeOffsetIndex: Int + + // Reserve an UInt32 in space for the next node offset + func reserveUInt32() -> Int { + let nextNodeOffsetIndex = trie.writerIndex + trie.writeInteger(UInt32(0)) + return nextNodeOffsetIndex + } + + // Serialize the node's component + switch node.key { + case .path(let path): + trie.writeInteger(TokenKind.path.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the path constant + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(path) + } + case .capture(let parameter): + trie.writeInteger(TokenKind.capture.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the parameter + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(parameter) + } + case .prefixCapture(suffix: let suffix, parameter: let parameter): + trie.writeInteger(TokenKind.prefixCapture.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the suffix and parameter + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(suffix) + } + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(parameter) + } + case .suffixCapture(prefix: let prefix, parameter: let parameter): + trie.writeInteger(TokenKind.suffixCapture.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the prefix and parameter + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(prefix) + } + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(parameter) + } + case .wildcard: + trie.writeInteger(TokenKind.wildcard.rawValue) + nextNodeOffsetIndex = reserveUInt32() + case .prefixWildcard(let suffix): + trie.writeInteger(TokenKind.prefixWildcard.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the suffix + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(suffix) + } + case .suffixWildcard(let prefix): + trie.writeInteger(TokenKind.suffixWildcard.rawValue) + nextNodeOffsetIndex = reserveUInt32() + + // Serialize the prefix + try trie.writeLengthPrefixed(as: Integer.self) { buffer in + buffer.writeSubstring(prefix) + } + case .recursiveWildcard: + trie.writeInteger(TokenKind.recursiveWildcard.rawValue) + nextNodeOffsetIndex = reserveUInt32() + case .null: + trie.writeInteger(TokenKind.null.rawValue) + nextNodeOffsetIndex = reserveUInt32() + } + + try serializeChildren( + of: node, + trie: &trie, + values: &values + ) + + // The last node in a trie is always a null token + // Since there is no next node to check anymores + trie.writeInteger(TokenKind.deadEnd.rawValue) + + // Write the offset of the next node, always immediately after this node + // Write a `deadEnd` at the end of this node, and update the current node in case + // The current node needs to be skipped + let nextNodeOffset = UInt32(trie.writerIndex + 4) + trie.writeInteger(nextNodeOffset) + trie.setInteger(nextNodeOffset, at: nextNodeOffsetIndex) + } + + static func serializeChildren( + of node: RouterPathTrie.Node, + trie: inout ByteBuffer, + values: inout [Value?] + ) throws { + // Serialize the child nodes in order of priority + // That's also the order of resolution + for child in node.children.sorted(by: highestPriorityFirst) { + try serialize(child, trie: &trie, values: &values) + } + } + + private static func highestPriorityFirst(lhs: RouterPathTrie.Node, rhs: RouterPathTrie.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..09070ae4b --- /dev/null +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -0,0 +1,25 @@ +internal final class BinaryTrie: Sendable { + typealias Integer = UInt8 + let trie: ByteBuffer + let values: [Value?] + + enum TokenKind: UInt8 { + case null = 0 + case path, capture, prefixCapture, suffixCapture, wildcard, prefixWildcard, suffixWildcard, recursiveWildcard + case deadEnd + } + + init(base: RouterPathTrie) throws { + var trie = ByteBufferAllocator().buffer(capacity: 1024) + var values = [base.root.value] + + try Self.serializeChildren( + of: base.root, + trie: &trie, + values: &values + ) + + self.trie = trie + self.values = values + } +} diff --git a/Sources/Hummingbird/Router/Router.swift b/Sources/Hummingbird/Router/Router.swift index a0eb58cd2..695db9f4e 100644 --- a/Sources/Hummingbird/Router/Router.swift +++ b/Sources/Hummingbird/Router/Router.swift @@ -68,13 +68,13 @@ public final class Router: RouterMethods, HTTPRespo } /// build responder from router - public func buildResponder() -> RouterResponder { + public func buildResponder() -> BinaryRouterResponder { if self.options.contains(.autoGenerateHeadEndpoints) { self.trie.forEach { node in node.value?.autoGenerateHeadEndpoint() } } - return RouterResponder( + return try! .init( context: Context.self, trie: self.trie.build(), options: self.options, diff --git a/Sources/Hummingbird/Router/TrieRouter.swift b/Sources/Hummingbird/Router/TrieRouter.swift index 99760cc23..282a9d16a 100644 --- a/Sources/Hummingbird/Router/TrieRouter.swift +++ b/Sources/Hummingbird/Router/TrieRouter.swift @@ -93,6 +93,48 @@ struct RouterPathTrieBuilder { } } +import NIOCore + +public struct BinaryRouterResponder: HTTPResponder { + let trie: BinaryTrie> + let notFoundResponder: any HTTPResponder + let options: RouterOptions + + init( + context: Context.Type, + trie: RouterPathTrie>, + options: RouterOptions, + notFoundResponder: any HTTPResponder + ) throws { + self.trie = try BinaryTrie(base: trie) + self.options = options + self.notFoundResponder = notFoundResponder + } + + /// Respond to request by calling correct handler + /// - Parameter request: HTTP request + /// - Returns: EventLoopFuture that will be fulfilled with the Response + public func respond(to request: Request, context: Context) async throws -> Response { + let path: String + if self.options.contains(.caseInsensitive) { + path = request.uri.path.lowercased() + } else { + path = request.uri.path + } + 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 + context.coreContext.parameters = parameters + // store endpoint path in request (mainly for metrics) + context.coreContext.endpointPath.value = responderChain.path + return try await responder.respond(to: request, context: context) + } +} + /// Trie used by Router responder struct RouterPathTrie: Sendable { let root: Node diff --git a/Tests/HummingbirdTests/RouterTests.swift b/Tests/HummingbirdTests/RouterTests.swift index ddd19d19b..9d1be8747 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -220,6 +220,8 @@ final class RouterTests: XCTestCase { } } + // TODO: No recursive wildcard test yet + /// Test adding middleware to group doesn't affect middleware in parent groups func testGroupGroupMiddleware2() async throws { struct TestGroupMiddleware: RouterMiddleware { From 1bb8d8c9cc5d1d02ee7fa2c2413787f5addf80a8 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Wed, 20 Mar 2024 14:47:27 +0100 Subject: [PATCH 2/7] fix benchmarks and add BinaryTrie benchmarks --- .../HTTP1/HTTP1ChannelBenchmarks.swift | 6 +- Benchmarks/Benchmarks/Router/Benchmarks.swift | 3 +- .../Router/BinaryTrieRouterBenchmarks.swift | 66 +++++++++++++++++++ .../Benchmarks/Router/RouterBenchmarks.swift | 37 +++++------ .../Router/TrieRouterBenchmarks.swift | 6 +- .../BinaryTrie/BinaryTrie+resolve.swift | 2 +- .../Router/BinaryTrie/BinaryTrie.swift | 4 +- Sources/Hummingbird/Router/TrieRouter.swift | 22 +++---- 8 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift diff --git a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift index 0871d71b3..1fd6fa3ce 100644 --- a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift +++ b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift @@ -51,7 +51,9 @@ extension Benchmark { try HTTP1Channel.Value(wrappingChannelSynchronously: channel) }.get() task = Task { - await http1.handle(value: asyncChannel, logger: Logger(label: "Testing")) + await withDiscardingTaskGroup { taskGroup in + http1.handle(value: asyncChannel, logger: Logger(label: "Testing"), onTaskGroup: &taskGroup) + } } } teardown: { try await channel.close() @@ -104,7 +106,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..377d1777e 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( @@ -25,5 +25,6 @@ let benchmarks = { warmupIterations: 10 ) trieRouterBenchmarks() + binaryTrieRouterBenchmarks() routerBenchmarks() } diff --git a/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift new file mode 100644 index 000000000..5ac2ca1b1 --- /dev/null +++ b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2021-2021 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 Benchmark +@_spi(Internal) import Hummingbird + +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.resolve($0) }) + } + } setup: { + let trieBuilder = RouterPathTrieBuilder() + trieBuilder.addEntry("/test/", value: "/test/") + trieBuilder.addEntry("/test/one", value: "/test/one") + 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 = try! BinaryTrie(base: trieBuilder.build()) + } + + 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.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/*/*") + trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") + trie2 = try! BinaryTrie(base: trieBuilder.build()) + } +} 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/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift index c8f341b85..dd9bf65c0 100644 --- a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Benchmark -@testable import Hummingbird +@_spi(Internal) import Hummingbird func trieRouterBenchmarks() { var trie: RouterPathTrie! @@ -23,6 +23,7 @@ func trieRouterBenchmarks() { "/test/one", "/test/one/two", "/doesntExist", + "/api/v1/users/1/profile", ] benchmark.startMeasurement() @@ -36,6 +37,7 @@ 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() } @@ -46,6 +48,7 @@ func trieRouterBenchmarks() { "/test/value", "/test/value1/value2", "/test2/one/two", + "/api/v1/users/1/profile", ] benchmark.startMeasurement() @@ -57,6 +60,7 @@ func trieRouterBenchmarks() { trieBuilder.addEntry("/test/:value", value: "/test/:value") trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2") trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*") + trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") trie2 = trieBuilder.build() } } diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift index 401ac650b..d15a1eef9 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -2,7 +2,7 @@ import NIOCore extension BinaryTrie { /// Resolve a path to a `Value` if available - func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { + @_spi(Internal) public func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { var trie = trie var pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) var parameters = Parameters() diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift index 09070ae4b..cc8548a67 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -1,4 +1,4 @@ -internal final class BinaryTrie: Sendable { +@_spi(Internal) public final class BinaryTrie: Sendable { typealias Integer = UInt8 let trie: ByteBuffer let values: [Value?] @@ -9,7 +9,7 @@ internal final class BinaryTrie: Sendable { case deadEnd } - init(base: RouterPathTrie) throws { + @_spi(Internal) public init(base: RouterPathTrie) throws { var trie = ByteBufferAllocator().buffer(capacity: 1024) var values = [base.root.value] diff --git a/Sources/Hummingbird/Router/TrieRouter.swift b/Sources/Hummingbird/Router/TrieRouter.swift index 282a9d16a..4fa8d7b46 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,7 +40,7 @@ struct RouterPathTrieBuilder { } } - func build() -> RouterPathTrie { + public func build() -> RouterPathTrie { .init(root: self.root.build()) } @@ -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? @@ -135,20 +135,20 @@ public struct BinaryRouterResponder: HTTPResponder } } -/// Trie used by Router responder -struct RouterPathTrie: Sendable { - let root: Node +/// Triea used by Router responder +@_spi(Internal) public struct RouterPathTrie: Sendable { + public let root: Node /// Initialise RouterPathTrie /// - Parameter root: Root node of trie - init(root: Node) { + public 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?)? { + @_spi(Internal) public func getValueAndParameters(_ path: String) -> (value: Value, parameters: Parameters?)? { let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) var parameters: Parameters? var node = self.root @@ -179,7 +179,7 @@ struct RouterPathTrie: Sendable { } /// Internally used Node to describe static trie - struct Node: Sendable { + @_spi(Internal) public struct Node: Sendable { let key: RouterPath.Element let children: [Node] let value: Value? From 6c2f4cbe4213c2d7f727a5825301c6c783d4dc92 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Wed, 20 Mar 2024 14:56:54 +0100 Subject: [PATCH 3/7] Add a benchmark for long paths --- .../Router/BinaryTrieRouterBenchmarks.swift | 18 ++++++++++++++++++ .../Router/TrieRouterBenchmarks.swift | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift index 5ac2ca1b1..9e6b43e58 100644 --- a/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift @@ -63,4 +63,22 @@ func binaryTrieRouterBenchmarks() { trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") trie2 = try! BinaryTrie(base: trieBuilder.build()) } + + 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 = try! BinaryTrie(base: trieBuilder.build()) + } } diff --git a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift index dd9bf65c0..89c832b96 100644 --- a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift @@ -63,4 +63,22 @@ func trieRouterBenchmarks() { trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") trie2 = trieBuilder.build() } + + var trie3: RouterPathTrie! + Benchmark("Trie: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.getValueAndParameters($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 = trieBuilder.build() + } } From b5e722559692be4f6bf803e1cbb8020154a5dc03 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Fri, 22 Mar 2024 20:59:17 +0100 Subject: [PATCH 4/7] Fix memcmp and the tests --- .../BinaryTrie/BinaryTrie+resolve.swift | 22 ++++++++++++++++++- .../BinaryTrie/BinaryTrie+serialize.swift | 14 ++++++++++++ .../Router/BinaryTrie/BinaryTrie.swift | 14 ++++++++++++ Tests/HummingbirdTests/TrieRouterTests.swift | 2 +- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift index d15a1eef9..84b289eeb 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// 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 { @@ -171,8 +185,14 @@ fileprivate extension ByteBuffer { 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 { + if memcmp(utf8.baseAddress!, buffer.baseAddress!, length) == 0 { moveReaderIndex(forwardBy: length) return true } else { diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift index bf04b8e2a..728b9ce09 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// 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 { diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift index cc8548a67..7a0a8f56b 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + @_spi(Internal) public final class BinaryTrie: Sendable { typealias Integer = UInt8 let trie: ByteBuffer diff --git a/Tests/HummingbirdTests/TrieRouterTests.swift b/Tests/HummingbirdTests/TrieRouterTests.swift index f13c33715..bfe31e578 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 { From b2bf10e801c0c6274813c375cf6906721c945a15 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Fri, 22 Mar 2024 21:33:00 +0100 Subject: [PATCH 5/7] Implement recursiveWildcard --- .../BinaryTrie/BinaryTrie+resolve.swift | 232 +++++++++++------- Tests/HummingbirdTests/RouterTests.swift | 15 +- 2 files changed, 150 insertions(+), 97 deletions(-) diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift index 84b289eeb..7f8f1863e 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -29,7 +29,8 @@ extension BinaryTrie { in: &trie, index: 0, parameters: ¶meters, - components: &pathComponents + components: &pathComponents, + isInRecursiveWildcard: false ) } @@ -43,12 +44,105 @@ extension BinaryTrie { return nil } + private enum MatchResult { + case match, mismatch, recursivelyDiscarded, ignore, deadEnd + } + + private func matchComponent( + _ component: inout Substring, + withToken token: TokenKind, + in trie: inout ByteBuffer, + parameters: inout Parameters + ) -> MatchResult { + switch token { + case .path: + // The current node is a constant + guard + let length: Integer = trie.readInteger(), + trie.readAndCompareString(to: &component, length: length) + else { + return .mismatch + } + + return .match + case .capture: + // The current node is a parameter + guard + let length: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(length)) + else { + return .mismatch + } + + parameters[Substring(parameter)] = component + return .match + case .prefixCapture: + guard + let suffixLength: Integer = trie.readInteger(), + let suffix = trie.readString(length: Int(suffixLength)), + let parameterLength: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(parameterLength)), + component.hasSuffix(suffix) + else { + return .mismatch + } + + component.removeLast(suffix.count) + parameters[Substring(parameter)] = component + return .match + case .suffixCapture: + guard + let prefixLength: Integer = trie.readInteger(), + let prefix = trie.readString(length: Int(prefixLength)), + let parameterLength: Integer = trie.readInteger(), + let parameter = trie.readString(length: Int(parameterLength)), + component.hasPrefix(prefix) + else { + return .mismatch + } + + component.removeFirst(Int(prefixLength)) + parameters[Substring(parameter)] = component + return .match + case .wildcard: + // Always matches, descend + return .match + case .prefixWildcard: + guard + let suffixLength: Integer = trie.readInteger(), + let suffix = trie.readString(length: Int(suffixLength)), + component.hasSuffix(suffix) + else { + return .mismatch + } + + return .match + case .suffixWildcard: + guard + let prefixLength: Integer = trie.readInteger(), + let prefix = trie.readString(length: Int(prefixLength)), + component.hasPrefix(prefix) + else { + return .mismatch + } + + return .match + case .recursiveWildcard: + return .recursivelyDiscarded + case .null: + return .ignore + case .deadEnd: + return .deadEnd + } + } + /// A function that takes a path component and descends the trie to find the value private func descendPath( in trie: inout ByteBuffer, index: UInt16, parameters: inout Parameters, - components: inout [Substring] + components: inout [Substring], + isInRecursiveWildcard: Bool ) -> (value: Value, parameters: Parameters)? { // If there are no more components in the path, return the value found if components.isEmpty { @@ -64,103 +158,51 @@ extension BinaryTrie { let index = trie.readInteger(as: UInt16.self), let _token: Integer = trie.readInteger(), let token = TokenKind(rawValue: _token), - let nextNodeIndex: UInt32 = trie.readInteger() + let nextSiblingNodeIndex: UInt32 = trie.readInteger() { - switch token { - case .path: - // The current node is a constant - guard - let length: Integer = trie.readInteger(), - trie.readAndCompareString(to: &component, length: length) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) - continue - } - case .capture: - // The current node is a parameter - guard - let length: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(length)) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) - return nil - } - - parameters[Substring(parameter)] = component - case .prefixCapture: - guard - let suffixLength: Integer = trie.readInteger(), - let suffix = trie.readString(length: Int(suffixLength)), - let parameterLength: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(parameterLength)), - component.hasSuffix(suffix) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) - continue - } - - component.removeLast(suffix.count) - parameters[Substring(parameter)] = component - case .suffixCapture: - guard - let prefixLength: Integer = trie.readInteger(), - trie.readAndCompareString(to: &component, length: prefixLength), - let parameterLength: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(parameterLength)) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) - continue - } - - component.removeFirst(Int(prefixLength)) - parameters[Substring(parameter)] = component - case .wildcard: - // Always matches, descend - () - case .prefixWildcard: - guard - let suffixLength: Integer = trie.readInteger(), - let suffix = trie.readString(length: Int(suffixLength)), - component.hasSuffix(suffix) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) + repeat { + // Record the current readerIndex + // ``matchComponent`` moves the reader index forward, so we'll need to reset it + // If we're in a recursiveWildcard and this component does not match + let readerIndex = trie.readerIndex + let result = matchComponent(&component, withToken: token, in: &trie, parameters: ¶meters) + + switch result { + case .match: + return descendPath( + in: &trie, + index: index, + parameters: ¶meters, + components: &components, + isInRecursiveWildcard: false + ) + case .mismatch where isInRecursiveWildcard: + if components.isEmpty { + return nil + } + + component = components.removeFirst() + // Move back he readerIndex, so that we can retry this step again with + // the next component + trie.moveReaderIndex(to: readerIndex) + case .mismatch: + // Move to the next sibling-node, not descending a level + trie.moveReaderIndex(to: Int(nextSiblingNodeIndex)) continue - } - case .suffixWildcard: - guard - let prefixLength: Integer = trie.readInteger(), - trie.readAndCompareString(to: &component, length: prefixLength) - else { - // The constant's does not match the component's length - // So we can skip to the next sibling - trie.moveReaderIndex(to: Int(nextNodeIndex)) + case .recursivelyDiscarded: + return descendPath( + in: &trie, + index: index, + parameters: ¶meters, + components: &components, + isInRecursiveWildcard: true + ) + case .ignore: continue + case .deadEnd: + return nil } - case .recursiveWildcard: - fatalError() - case .null: - continue - case .deadEnd: - return nil - } - - // This node matches! - return descendPath( - in: &trie, - index: index, - parameters: ¶meters, - components: &components - ) + } while isInRecursiveWildcard } return nil diff --git a/Tests/HummingbirdTests/RouterTests.swift b/Tests/HummingbirdTests/RouterTests.swift index 9d1be8747..e1e45fb15 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -220,8 +220,6 @@ final class RouterTests: XCTestCase { } } - // TODO: No recursive wildcard test yet - /// Test adding middleware to group doesn't affect middleware in parent groups func testGroupGroupMiddleware2() async throws { struct TestGroupMiddleware: RouterMiddleware { @@ -453,6 +451,19 @@ final class RouterTests: XCTestCase { } } + func testRecursiveWildcard() async throws { + let router = Router() + router.get("/api/v1/**/greet") { _, _ in + return HTTPResponse.Status.ok + } + 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/greet", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } + } + } + // Test auto generation of HEAD endpoints works func testAutoGenerateHeadEndpoints() async throws { let router = Router(options: .autoGenerateHeadEndpoints) From a75ae59def2051e7900d285aed1791cbeb27a902 Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Thu, 11 Apr 2024 19:06:40 +0200 Subject: [PATCH 6/7] Apply some of Adam's suggestions to make BinaryTrie code more readable --- .../HTTP1/HTTP1ChannelBenchmarks.swift | 4 +- .../BinaryTrie/BinaryTrie+resolve.swift | 116 +++++++++++++----- .../BinaryTrie/BinaryTrie+serialize.swift | 26 ++-- .../Router/BinaryTrie/BinaryTrie.swift | 12 +- 4 files changed, 107 insertions(+), 51 deletions(-) diff --git a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift index 1fd6fa3ce..4c7dec8ba 100644 --- a/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift +++ b/Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift @@ -51,9 +51,7 @@ extension Benchmark { try HTTP1Channel.Value(wrappingChannelSynchronously: channel) }.get() task = Task { - await withDiscardingTaskGroup { taskGroup in - http1.handle(value: asyncChannel, logger: Logger(label: "Testing"), onTaskGroup: &taskGroup) - } + await http1.handle(value: asyncChannel, logger: Logger(label: "Testing")) } } teardown: { try await channel.close() diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift index 7f8f1863e..44b1e2063 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -18,7 +18,7 @@ extension BinaryTrie { /// Resolve a path to a `Value` if available @_spi(Internal) public func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { var trie = trie - var pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) + var pathComponents = path.split(separator: "/", omittingEmptySubsequences: true)[...] var parameters = Parameters() if pathComponents.isEmpty { @@ -49,8 +49,8 @@ extension BinaryTrie { } private func matchComponent( - _ component: inout Substring, - withToken token: TokenKind, + _ component: Substring, + withToken token: BinaryTrieTokenKind, in trie: inout ByteBuffer, parameters: inout Parameters ) -> MatchResult { @@ -58,8 +58,10 @@ extension BinaryTrie { case .path: // The current node is a constant guard - let length: Integer = trie.readInteger(), - trie.readAndCompareString(to: &component, length: length) + trie.readAndCompareString( + to: component, + length: Integer.self + ) else { return .mismatch } @@ -68,8 +70,7 @@ extension BinaryTrie { case .capture: // The current node is a parameter guard - let length: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(length)) + let parameter = trie.readLengthPrefixedString(as: Integer.self) else { return .mismatch } @@ -78,39 +79,32 @@ extension BinaryTrie { return .match case .prefixCapture: guard - let suffixLength: Integer = trie.readInteger(), - let suffix = trie.readString(length: Int(suffixLength)), - let parameterLength: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(parameterLength)), + let suffix = trie.readLengthPrefixedString(as: Integer.self), + let parameter = trie.readLengthPrefixedString(as: Integer.self), component.hasSuffix(suffix) else { return .mismatch } - component.removeLast(suffix.count) - parameters[Substring(parameter)] = component + parameters[Substring(parameter)] = component.dropLast(suffix.count) return .match case .suffixCapture: guard - let prefixLength: Integer = trie.readInteger(), - let prefix = trie.readString(length: Int(prefixLength)), - let parameterLength: Integer = trie.readInteger(), - let parameter = trie.readString(length: Int(parameterLength)), + let prefix = trie.readLengthPrefixedString(as: Integer.self), + let parameter = trie.readLengthPrefixedString(as: Integer.self), component.hasPrefix(prefix) else { return .mismatch } - component.removeFirst(Int(prefixLength)) - parameters[Substring(parameter)] = component + parameters[Substring(parameter)] = component.dropFirst(prefix.count) return .match case .wildcard: // Always matches, descend return .match case .prefixWildcard: guard - let suffixLength: Integer = trie.readInteger(), - let suffix = trie.readString(length: Int(suffixLength)), + let suffix = trie.readLengthPrefixedString(as: Integer.self), component.hasSuffix(suffix) else { return .mismatch @@ -119,8 +113,7 @@ extension BinaryTrie { return .match case .suffixWildcard: guard - let prefixLength: Integer = trie.readInteger(), - let prefix = trie.readString(length: Int(prefixLength)), + let prefix = trie.readLengthPrefixedString(as: Integer.self), component.hasPrefix(prefix) else { return .mismatch @@ -141,7 +134,7 @@ extension BinaryTrie { in trie: inout ByteBuffer, index: UInt16, parameters: inout Parameters, - components: inout [Substring], + components: inout ArraySlice, isInRecursiveWildcard: Bool ) -> (value: Value, parameters: Parameters)? { // If there are no more components in the path, return the value found @@ -154,10 +147,9 @@ extension BinaryTrie { // Check the current node type through TokenKind // And read the location of the _next_ node from the trie buffer - while + while let index = trie.readInteger(as: UInt16.self), - let _token: Integer = trie.readInteger(), - let token = TokenKind(rawValue: _token), + let token = trie.readToken(), let nextSiblingNodeIndex: UInt32 = trie.readInteger() { repeat { @@ -165,7 +157,7 @@ extension BinaryTrie { // ``matchComponent`` moves the reader index forward, so we'll need to reset it // If we're in a recursiveWildcard and this component does not match let readerIndex = trie.readerIndex - let result = matchComponent(&component, withToken: token, in: &trie, parameters: ¶meters) + let result = matchComponent(component, withToken: token, in: &trie, parameters: ¶meters) switch result { case .match: @@ -181,7 +173,9 @@ extension BinaryTrie { return nil } - component = components.removeFirst() + component = components[components.startIndex] + components = components.dropFirst() + // Move back he readerIndex, so that we can retry this step again with // the next component trie.moveReaderIndex(to: readerIndex) @@ -220,9 +214,19 @@ import Glibc #endif fileprivate extension ByteBuffer { - mutating func readAndCompareString(to string: inout Substring, length: Length) -> Bool { - let length = Int(length) - return string.withUTF8 { utf8 in + 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 } @@ -242,5 +246,53 @@ fileprivate extension ByteBuffer { } } } + + 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 + } + + mutating func readLengthPrefixedString(as integer: F.Type) -> String? { + guard let buffer = readLengthPrefixedSlice(as: F.self) else { + return nil + } + + return String(buffer: buffer) + } + + mutating func readToken() -> BinaryTrieTokenKind? { + guard + let _token: BinaryTrieTokenKind.RawValue = readInteger(), + let token = BinaryTrieTokenKind(rawValue: _token) + else { + return nil + } + + return token + } + + mutating func readBinaryTrieNode() -> BinaryTrieNode? { + guard + let index = readInteger(as: UInt16.self), + let token = readToken(), + let nextSiblingNodeIndex: UInt32 = readInteger() + else { + return nil + } + + return BinaryTrieNode( + index: index, + kind: token, + nextSiblingNodeIndex: nextSiblingNodeIndex + ) } } diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift index 728b9ce09..a6971c520 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift @@ -36,7 +36,7 @@ extension BinaryTrie { // Serialize the node's component switch node.key { case .path(let path): - trie.writeInteger(TokenKind.path.rawValue) + trie.writeToken(.path) nextNodeOffsetIndex = reserveUInt32() // Serialize the path constant @@ -44,7 +44,7 @@ extension BinaryTrie { buffer.writeSubstring(path) } case .capture(let parameter): - trie.writeInteger(TokenKind.capture.rawValue) + trie.writeToken(.capture) nextNodeOffsetIndex = reserveUInt32() // Serialize the parameter @@ -52,7 +52,7 @@ extension BinaryTrie { buffer.writeSubstring(parameter) } case .prefixCapture(suffix: let suffix, parameter: let parameter): - trie.writeInteger(TokenKind.prefixCapture.rawValue) + trie.writeToken(.prefixCapture) nextNodeOffsetIndex = reserveUInt32() // Serialize the suffix and parameter @@ -63,7 +63,7 @@ extension BinaryTrie { buffer.writeSubstring(parameter) } case .suffixCapture(prefix: let prefix, parameter: let parameter): - trie.writeInteger(TokenKind.suffixCapture.rawValue) + trie.writeToken(.suffixCapture) nextNodeOffsetIndex = reserveUInt32() // Serialize the prefix and parameter @@ -74,10 +74,10 @@ extension BinaryTrie { buffer.writeSubstring(parameter) } case .wildcard: - trie.writeInteger(TokenKind.wildcard.rawValue) + trie.writeToken(.wildcard) nextNodeOffsetIndex = reserveUInt32() case .prefixWildcard(let suffix): - trie.writeInteger(TokenKind.prefixWildcard.rawValue) + trie.writeToken(.prefixWildcard) nextNodeOffsetIndex = reserveUInt32() // Serialize the suffix @@ -85,7 +85,7 @@ extension BinaryTrie { buffer.writeSubstring(suffix) } case .suffixWildcard(let prefix): - trie.writeInteger(TokenKind.suffixWildcard.rawValue) + trie.writeToken(.suffixWildcard) nextNodeOffsetIndex = reserveUInt32() // Serialize the prefix @@ -93,10 +93,10 @@ extension BinaryTrie { buffer.writeSubstring(prefix) } case .recursiveWildcard: - trie.writeInteger(TokenKind.recursiveWildcard.rawValue) + trie.writeToken(.recursiveWildcard) nextNodeOffsetIndex = reserveUInt32() case .null: - trie.writeInteger(TokenKind.null.rawValue) + trie.writeToken(.null) nextNodeOffsetIndex = reserveUInt32() } @@ -108,7 +108,7 @@ extension BinaryTrie { // The last node in a trie is always a null token // Since there is no next node to check anymores - trie.writeInteger(TokenKind.deadEnd.rawValue) + trie.writeToken(.deadEnd) // Write the offset of the next node, always immediately after this node // Write a `deadEnd` at the end of this node, and update the current node in case @@ -159,3 +159,9 @@ extension RouterPath.Element { } } } + +fileprivate extension ByteBuffer { + mutating func writeToken(_ token: BinaryTrieTokenKind) { + writeInteger(token.rawValue) + } +} diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift index 7a0a8f56b..9e6c8eb68 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -12,17 +12,17 @@ // //===----------------------------------------------------------------------===// +enum BinaryTrieTokenKind: UInt8 { + case null = 0 + case path, capture, prefixCapture, suffixCapture, wildcard, prefixWildcard, suffixWildcard, recursiveWildcard + case deadEnd +} + @_spi(Internal) public final class BinaryTrie: Sendable { typealias Integer = UInt8 let trie: ByteBuffer let values: [Value?] - enum TokenKind: UInt8 { - case null = 0 - case path, capture, prefixCapture, suffixCapture, wildcard, prefixWildcard, suffixWildcard, recursiveWildcard - case deadEnd - } - @_spi(Internal) public init(base: RouterPathTrie) throws { var trie = ByteBufferAllocator().buffer(capacity: 1024) var values = [base.root.value] From 1c46b01544c459f9d590d9f07039cadb1cf7fff4 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 6 May 2024 08:33:43 +0100 Subject: [PATCH 7/7] Serialized trie router updates (#436) * Added ByteBuffer.writeLengthPrefixedString. Moved ByteBuffer extensions into own file * Move some code about, delete RouterResponder * Use RouterPathTrieBuilder to build BinaryTrie * Test BinaryTrie * Fix bug where deadend doesn't have a index * Add ByteBuffer read/write of BinaryTrieNode * Remove trieRouterBenchmarks * Remove inout variables * Simplified resolve * Fixed matching paths after catchall * Renamed back to RouterResponder * Delete commented out code --- .swiftformat | 2 +- Benchmarks/Benchmarks/Router/Benchmarks.swift | 1 - .../Router/BinaryTrieRouterBenchmarks.swift | 6 +- .../Router/TrieRouterBenchmarks.swift | 84 ------- Sources/Hummingbird/Deprecations.swift | 2 - .../BinaryTrie/BinaryTrie+resolve.swift | 227 ++++-------------- .../BinaryTrie/BinaryTrie+serialize.swift | 119 +++------ .../Router/BinaryTrie/BinaryTrie.swift | 17 +- .../BinaryTrie/ByteBuffer+BinaryTrie.swift | 135 +++++++++++ Sources/Hummingbird/Router/Router.swift | 6 +- .../Hummingbird/Router/RouterResponder.swift | 26 +- Sources/Hummingbird/Router/TrieRouter.swift | 140 +---------- Tests/HummingbirdTests/RouterTests.swift | 14 +- Tests/HummingbirdTests/TrieRouterTests.swift | 90 +++---- 14 files changed, 305 insertions(+), 564 deletions(-) delete mode 100644 Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift create mode 100644 Sources/Hummingbird/Router/BinaryTrie/ByteBuffer+BinaryTrie.swift 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/Router/Benchmarks.swift b/Benchmarks/Benchmarks/Router/Benchmarks.swift index 377d1777e..16875464c 100644 --- a/Benchmarks/Benchmarks/Router/Benchmarks.swift +++ b/Benchmarks/Benchmarks/Router/Benchmarks.swift @@ -24,7 +24,6 @@ let benchmarks = { ], warmupIterations: 10 ) - trieRouterBenchmarks() binaryTrieRouterBenchmarks() routerBenchmarks() } diff --git a/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift index 9e6b43e58..8b6a2199d 100644 --- a/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift +++ b/Benchmarks/Benchmarks/Router/BinaryTrieRouterBenchmarks.swift @@ -39,7 +39,7 @@ func binaryTrieRouterBenchmarks() { 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 = try! BinaryTrie(base: trieBuilder.build()) + trie = BinaryTrie(base: trieBuilder) } var trie2: BinaryTrie! @@ -61,7 +61,7 @@ func binaryTrieRouterBenchmarks() { trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2") trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*") trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") - trie2 = try! BinaryTrie(base: trieBuilder.build()) + trie2 = BinaryTrie(base: trieBuilder) } var trie3: BinaryTrie! @@ -79,6 +79,6 @@ func binaryTrieRouterBenchmarks() { 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 = try! BinaryTrie(base: trieBuilder.build()) + trie3 = BinaryTrie(base: trieBuilder) } } diff --git a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift b/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift deleted file mode 100644 index 89c832b96..000000000 --- a/Benchmarks/Benchmarks/Router/TrieRouterBenchmarks.swift +++ /dev/null @@ -1,84 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 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 Benchmark -@_spi(Internal) import Hummingbird - -func trieRouterBenchmarks() { - var trie: RouterPathTrie! - Benchmark("TrieRouter", 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) }) - } - } setup: { - let trieBuilder = RouterPathTrieBuilder() - trieBuilder.addEntry("/test/", value: "/test/") - trieBuilder.addEntry("/test/one", value: "/test/one") - 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() - } - - var trie2: RouterPathTrie! - Benchmark("TrieRouterParameters", 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) }) - } - } 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/*/*") - trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile") - trie2 = trieBuilder.build() - } - - var trie3: RouterPathTrie! - Benchmark("Trie: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.getValueAndParameters($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 = trieBuilder.build() - } -} 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 index 44b1e2063..10933cef0 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift @@ -18,20 +18,33 @@ extension BinaryTrie { /// Resolve a path to a `Value` if available @_spi(Internal) public func resolve(_ path: String) -> (value: Value, parameters: Parameters)? { var trie = trie - var pathComponents = path.split(separator: "/", omittingEmptySubsequences: true)[...] + let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true) + var pathComponentsIterator = pathComponents.makeIterator() var parameters = Parameters() - - if pathComponents.isEmpty { - return value(for: 0, 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.. 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, recursivelyDiscarded, ignore, deadEnd + case match, mismatch, ignore, deadEnd } private func matchComponent( @@ -121,178 +153,11 @@ extension BinaryTrie { return .match case .recursiveWildcard: - return .recursivelyDiscarded + return .match case .null: return .ignore case .deadEnd: return .deadEnd } } - - /// A function that takes a path component and descends the trie to find the value - private func descendPath( - in trie: inout ByteBuffer, - index: UInt16, - parameters: inout Parameters, - components: inout ArraySlice, - isInRecursiveWildcard: Bool - ) -> (value: Value, parameters: Parameters)? { - // If there are no more components in the path, return the value found - if components.isEmpty { - return value(for: index, parameters: parameters) - } - - // Take the next component from the path - var component = components.removeFirst() - - // Check the current node type through TokenKind - // And read the location of the _next_ node from the trie buffer - while - let index = trie.readInteger(as: UInt16.self), - let token = trie.readToken(), - let nextSiblingNodeIndex: UInt32 = trie.readInteger() - { - repeat { - // Record the current readerIndex - // ``matchComponent`` moves the reader index forward, so we'll need to reset it - // If we're in a recursiveWildcard and this component does not match - let readerIndex = trie.readerIndex - let result = matchComponent(component, withToken: token, in: &trie, parameters: ¶meters) - - switch result { - case .match: - return descendPath( - in: &trie, - index: index, - parameters: ¶meters, - components: &components, - isInRecursiveWildcard: false - ) - case .mismatch where isInRecursiveWildcard: - if components.isEmpty { - return nil - } - - component = components[components.startIndex] - components = components.dropFirst() - - // Move back he readerIndex, so that we can retry this step again with - // the next component - trie.moveReaderIndex(to: readerIndex) - case .mismatch: - // Move to the next sibling-node, not descending a level - trie.moveReaderIndex(to: Int(nextSiblingNodeIndex)) - continue - case .recursivelyDiscarded: - return descendPath( - in: &trie, - index: index, - parameters: ¶meters, - components: &components, - isInRecursiveWildcard: true - ) - case .ignore: - continue - case .deadEnd: - return nil - } - } while isInRecursiveWildcard - } - - return nil - } -} - -#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 - -fileprivate extension ByteBuffer { - 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 - } - - mutating func readLengthPrefixedString(as integer: F.Type) -> String? { - guard let buffer = readLengthPrefixedSlice(as: F.self) else { - return nil - } - - return String(buffer: buffer) - } - - mutating func readToken() -> BinaryTrieTokenKind? { - guard - let _token: BinaryTrieTokenKind.RawValue = readInteger(), - let token = BinaryTrieTokenKind(rawValue: _token) - else { - return nil - } - - return token - } - - mutating func readBinaryTrieNode() -> BinaryTrieNode? { - guard - let index = readInteger(as: UInt16.self), - let token = readToken(), - let nextSiblingNodeIndex: UInt32 = readInteger() - else { - return nil - } - - return BinaryTrieNode( - index: index, - kind: token, - nextSiblingNodeIndex: nextSiblingNodeIndex - ) - } } diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift index a6971c520..7884da892 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+serialize.swift @@ -16,121 +16,80 @@ import NIOCore extension BinaryTrie { static func serialize( - _ node: RouterPathTrie.Node, + _ node: RouterPathTrieBuilder.Node, trie: inout ByteBuffer, values: inout [Value?] - ) throws { + ) { + let binaryTrieNodeIndex = trie.writerIndex + trie.reserveBinaryTrieNode() // Index where `value` is located - trie.writeInteger(UInt16(values.count)) + let index = UInt16(values.count) values.append(node.value) - var nextNodeOffsetIndex: Int - - // Reserve an UInt32 in space for the next node offset - func reserveUInt32() -> Int { - let nextNodeOffsetIndex = trie.writerIndex - trie.writeInteger(UInt32(0)) - return nextNodeOffsetIndex - } - - // Serialize the node's component + let token: BinaryTrieTokenKind switch node.key { case .path(let path): - trie.writeToken(.path) - nextNodeOffsetIndex = reserveUInt32() - + token = .path // Serialize the path constant - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(path) - } + trie.writeLengthPrefixedString(path, as: Integer.self) case .capture(let parameter): - trie.writeToken(.capture) - nextNodeOffsetIndex = reserveUInt32() - + token = .capture // Serialize the parameter - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(parameter) - } + trie.writeLengthPrefixedString(parameter, as: Integer.self) case .prefixCapture(suffix: let suffix, parameter: let parameter): - trie.writeToken(.prefixCapture) - nextNodeOffsetIndex = reserveUInt32() - + token = .prefixCapture // Serialize the suffix and parameter - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(suffix) - } - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(parameter) - } + trie.writeLengthPrefixedString(suffix, as: Integer.self) + trie.writeLengthPrefixedString(parameter, as: Integer.self) case .suffixCapture(prefix: let prefix, parameter: let parameter): - trie.writeToken(.suffixCapture) - nextNodeOffsetIndex = reserveUInt32() - + token = .suffixCapture // Serialize the prefix and parameter - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(prefix) - } - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(parameter) - } + trie.writeLengthPrefixedString(prefix, as: Integer.self) + trie.writeLengthPrefixedString(parameter, as: Integer.self) case .wildcard: - trie.writeToken(.wildcard) - nextNodeOffsetIndex = reserveUInt32() + token = .wildcard case .prefixWildcard(let suffix): - trie.writeToken(.prefixWildcard) - nextNodeOffsetIndex = reserveUInt32() - + token = .prefixWildcard // Serialize the suffix - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(suffix) - } + trie.writeLengthPrefixedString(suffix, as: Integer.self) case .suffixWildcard(let prefix): - trie.writeToken(.suffixWildcard) - nextNodeOffsetIndex = reserveUInt32() - + token = .suffixWildcard // Serialize the prefix - try trie.writeLengthPrefixed(as: Integer.self) { buffer in - buffer.writeSubstring(prefix) - } + trie.writeLengthPrefixedString(prefix, as: Integer.self) case .recursiveWildcard: - trie.writeToken(.recursiveWildcard) - nextNodeOffsetIndex = reserveUInt32() + token = .recursiveWildcard case .null: - trie.writeToken(.null) - nextNodeOffsetIndex = reserveUInt32() + token = .null } - try serializeChildren( + self.serializeChildren( of: node, trie: &trie, values: &values ) - // The last node in a trie is always a null token - // Since there is no next node to check anymores - trie.writeToken(.deadEnd) - - // Write the offset of the next node, always immediately after this node - // Write a `deadEnd` at the end of this node, and update the current node in case - // The current node needs to be skipped - let nextNodeOffset = UInt32(trie.writerIndex + 4) - trie.writeInteger(nextNodeOffset) - trie.setInteger(nextNodeOffset, at: nextNodeOffsetIndex) + 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: RouterPathTrie.Node, + of node: RouterPathTrieBuilder.Node, trie: inout ByteBuffer, values: inout [Value?] - ) throws { + ) { // Serialize the child nodes in order of priority // That's also the order of resolution - for child in node.children.sorted(by: highestPriorityFirst) { - try serialize(child, trie: &trie, values: &values) + for child in node.children.sorted(by: self.highestPriorityFirst) { + self.serialize(child, trie: &trie, values: &values) } } - private static func highestPriorityFirst(lhs: RouterPathTrie.Node, rhs: RouterPathTrie.Node) -> Bool { + private static func highestPriorityFirst(lhs: RouterPathTrieBuilder.Node, rhs: RouterPathTrieBuilder.Node) -> Bool { lhs.key.priority > rhs.key.priority } } @@ -159,9 +118,3 @@ extension RouterPath.Element { } } } - -fileprivate extension ByteBuffer { - mutating func writeToken(_ token: BinaryTrieTokenKind) { - writeInteger(token.rawValue) - } -} diff --git a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift index 9e6c8eb68..4afce5383 100644 --- a/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift +++ b/Sources/Hummingbird/Router/BinaryTrie/BinaryTrie.swift @@ -18,17 +18,26 @@ enum BinaryTrieTokenKind: UInt8 { 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: RouterPathTrie) throws { + @_spi(Internal) public init(base: RouterPathTrieBuilder) { var trie = ByteBufferAllocator().buffer(capacity: 1024) - var values = [base.root.value] + var values: [Value?] = [] - try Self.serializeChildren( - of: base.root, + Self.serialize( + base.root, trie: &trie, 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 695db9f4e..869cbc81b 100644 --- a/Sources/Hummingbird/Router/Router.swift +++ b/Sources/Hummingbird/Router/Router.swift @@ -68,15 +68,15 @@ public final class Router: RouterMethods, HTTPRespo } /// build responder from router - public func buildResponder() -> BinaryRouterResponder { + public func buildResponder() -> RouterResponder { if self.options.contains(.autoGenerateHeadEndpoints) { self.trie.forEach { node in node.value?.autoGenerateHeadEndpoint() } } - return try! .init( + 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 4fa8d7b46..d805bef12 100644 --- a/Sources/Hummingbird/Router/TrieRouter.swift +++ b/Sources/Hummingbird/Router/TrieRouter.swift @@ -40,8 +40,8 @@ import HummingbirdCore } } - public func build() -> RouterPathTrie { - .init(root: self.root.build()) + @_spi(Internal) public func build() -> BinaryTrie { + .init(base: self) } func forEach(_ process: (Node) throws -> Void) rethrows { @@ -80,10 +80,6 @@ import HummingbirdCore 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,135 +88,3 @@ import HummingbirdCore } } } - -import NIOCore - -public struct BinaryRouterResponder: HTTPResponder { - let trie: BinaryTrie> - let notFoundResponder: any HTTPResponder - let options: RouterOptions - - init( - context: Context.Type, - trie: RouterPathTrie>, - options: RouterOptions, - notFoundResponder: any HTTPResponder - ) throws { - self.trie = try BinaryTrie(base: trie) - self.options = options - self.notFoundResponder = notFoundResponder - } - - /// Respond to request by calling correct handler - /// - Parameter request: HTTP request - /// - Returns: EventLoopFuture that will be fulfilled with the Response - public func respond(to request: Request, context: Context) async throws -> Response { - let path: String - if self.options.contains(.caseInsensitive) { - path = request.uri.path.lowercased() - } else { - path = request.uri.path - } - 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 - context.coreContext.parameters = parameters - // store endpoint path in request (mainly for metrics) - context.coreContext.endpointPath.value = responderChain.path - return try await responder.respond(to: request, context: context) - } -} - -/// Triea used by Router responder -@_spi(Internal) public struct RouterPathTrie: Sendable { - public let root: Node - - /// Initialise RouterPathTrie - /// - Parameter root: Root node of trie - public 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 - @_spi(Internal) public 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 e1e45fb15..f1210d04f 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -453,13 +453,19 @@ final class RouterTests: XCTestCase { func testRecursiveWildcard() async throws { let router = Router() - router.get("/api/v1/**/greet") { _, _ in - return HTTPResponse.Status.ok + 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/greet", method: .get) { response in - XCTAssertEqual(response.status, .ok) + 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") } } } diff --git a/Tests/HummingbirdTests/TrieRouterTests.swift b/Tests/HummingbirdTests/TrieRouterTests.swift index bfe31e578..60b44515d 100644 --- a/Tests/HummingbirdTests/TrieRouterTests.swift +++ b/Tests/HummingbirdTests/TrieRouterTests.swift @@ -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")) } }