diff --git a/Sources/Hummingbird/Router/RouteCollection.swift b/Sources/Hummingbird/Router/RouteCollection.swift new file mode 100644 index 000000000..38de2633c --- /dev/null +++ b/Sources/Hummingbird/Router/RouteCollection.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2024 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +/// Collection of routes +public final class RouteCollection: RouterMethods { + /// Initialize RouteCollection + public init(context: Context.Type = BasicRequestContext.self) { + self.routes = .init() + self.middlewares = .init() + } + + /// Add responder to call when path and method are matched + /// + /// - Parameters: + /// - path: Path to match + /// - method: Request method to match + /// - responder: Responder to call if match is made + /// - Returns: self + public func on( + _ path: String, + method: HTTPRequest.Method, + responder: Responder + ) -> Self where Responder.Context == Context { + let route = RouteDefinition(path: path, method: method, responder: responder) + self.routes.append(route) + return self + } + + /// Return a group inside the route collection + /// - Parameter path: path prefix to add to routes inside this group + public func group(_ path: String = "") -> RouterGroup { + return .init(path: path, router: self) + } + + /// Add middleware to RouteCollection + @discardableResult public func add(middleware: any RouterMiddleware) -> Self { + self.middlewares.add(middleware) + return self + } + + fileprivate struct RouteDefinition { + let path: String + let method: HTTPRequest.Method + let responder: any HTTPResponder + } + + fileprivate var routes: [RouteDefinition] + let middlewares: MiddlewareGroup +} + +extension RouterMethods { + /// Add route collection to router + /// - Parameter collection: Route collection + public func addRoutes(_ collection: RouteCollection, atPath path: String = "") { + for route in collection.routes { + // ensure path starts with a "/" and doesn't end with a "/" + let path = self.combinePaths(path, route.path) + self.on(path, method: route.method, responder: collection.middlewares.constructResponder(finalResponder: route.responder)) + } + } +} diff --git a/Sources/Hummingbird/Router/Router.swift b/Sources/Hummingbird/Router/Router.swift index a0eb58cd2..c80e33ad3 100644 --- a/Sources/Hummingbird/Router/Router.swift +++ b/Sources/Hummingbird/Router/Router.swift @@ -54,19 +54,6 @@ public final class Router: RouterMethods, HTTPRespo self.options = options } - /// Add route to router - /// - Parameters: - /// - path: URI path - /// - method: http method - /// - responder: handler to call - public func add(_ path: String, method: HTTPRequest.Method, responder: any HTTPResponder) { - // ensure path starts with a "/" and doesn't end with a "/" - let path = "/\(path.dropSuffix("/").dropPrefix("/"))" - self.trie.addEntry(.init(path), value: EndpointResponders(path: path)) { node in - node.value!.addResponder(for: method, responder: self.middlewares.constructResponder(finalResponder: responder)) - } - } - /// build responder from router public func buildResponder() -> RouterResponder { if self.options.contains(.autoGenerateHeadEndpoints) { @@ -82,18 +69,25 @@ public final class Router: RouterMethods, HTTPRespo ) } - /// Add path for closure returning type conforming to ResponseGenerator - @discardableResult public func on( + /// Add responder to call when path and method are matched + /// + /// - Parameters: + /// - path: Path to match + /// - method: Request method to match + /// - responder: Responder to call if match is made + @discardableResult public func on( _ path: String, method: HTTPRequest.Method, - use closure: @escaping @Sendable (Request, Context) async throws -> some ResponseGenerator - ) -> Self { - let responder = constructResponder(use: closure) - var path = path + responder: Responder + ) -> Self where Responder.Context == Context { + // ensure path starts with a "/" and doesn't end with a "/" + var path = "/\(path.dropSuffix("/").dropPrefix("/"))" if self.options.contains(.caseInsensitive) { path = path.lowercased() } - self.add(path, method: method, responder: responder) + self.trie.addEntry(.init(path), value: EndpointResponders(path: path)) { node in + node.value!.addResponder(for: method, responder: self.middlewares.constructResponder(finalResponder: responder)) + } return self } diff --git a/Sources/Hummingbird/Router/RouterGroup.swift b/Sources/Hummingbird/Router/RouterGroup.swift index 62829c6ad..26b45c298 100644 --- a/Sources/Hummingbird/Router/RouterGroup.swift +++ b/Sources/Hummingbird/Router/RouterGroup.swift @@ -32,16 +32,16 @@ import NIOCore /// ``` public struct RouterGroup: RouterMethods { let path: String - let router: Router + let router: any RouterMethods let middlewares: MiddlewareGroup - init(path: String = "", middlewares: MiddlewareGroup = .init(), router: Router) { + init(path: String = "", middlewares: MiddlewareGroup = .init(), router: any RouterMethods) { self.path = path self.router = router self.middlewares = middlewares } - /// Add middleware to RouterEndpoint + /// Add middleware to RouterGroup @discardableResult public func add(middleware: any RouterMiddleware) -> RouterGroup { self.middlewares.add(middleware) return self @@ -57,24 +57,21 @@ public struct RouterGroup: RouterMethods { ) } - /// Add path for closure returning type using async/await - @discardableResult public func on( - _ path: String = "", + /// Add responder to call when path and method are matched + /// + /// - Parameters: + /// - path: Path to match + /// - method: Request method to match + /// - responder: Responder to call if match is made + /// - Returns: self + @discardableResult public func on( + _ path: String, method: HTTPRequest.Method, - use closure: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator - ) -> Self { - let responder = constructResponder(use: closure) - var path = self.combinePaths(self.path, path) - if self.router.options.contains(.caseInsensitive) { - path = path.lowercased() - } - self.router.add(path, method: method, responder: self.middlewares.constructResponder(finalResponder: responder)) + responder: Responder + ) -> Self where Responder.Context == Context { + // ensure path starts with a "/" and doesn't end with a "/" + let path = self.combinePaths(self.path, path) + self.router.on(path, method: method, responder: self.middlewares.constructResponder(finalResponder: responder)) return self } - - internal func combinePaths(_ path1: String, _ path2: String) -> String { - let path1 = path1.dropSuffix("/") - let path2 = path2.dropPrefix("/") - return "\(path1)/\(path2)" - } } diff --git a/Sources/Hummingbird/Router/RouterMethods.swift b/Sources/Hummingbird/Router/RouterMethods.swift index 89035183c..098187678 100644 --- a/Sources/Hummingbird/Router/RouterMethods.swift +++ b/Sources/Hummingbird/Router/RouterMethods.swift @@ -19,25 +19,41 @@ import NIOCore public protocol RouterMethods { associatedtype Context: BaseRequestContext - /// Add path for async closure - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - @discardableResult func on( + /// Add responder to call when path and method are matched + /// + /// - Parameters: + /// - path: Path to match + /// - method: Request method to match + /// - responder: Responder to call if match is made + /// - Returns: self + @discardableResult func on( _ path: String, method: HTTPRequest.Method, - use: @Sendable @escaping (Request, Context) async throws -> Output - ) -> Self + responder: Responder + ) -> Self where Responder.Context == Context /// add group func group(_ path: String) -> RouterGroup } extension RouterMethods { + /// Add path for async closure + @discardableResult func on( + _ path: String, + method: HTTPRequest.Method, + use closure: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator + ) -> Self { + let responder = self.constructResponder(use: closure) + self.on(path, method: method, responder: responder) + return self + } + /// GET path for async closure returning type conforming to ResponseGenerator @discardableResult public func get( _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .get, use: handler) + return self.on(path, method: .get, use: handler) } /// PUT path for async closure returning type conforming to ResponseGenerator @@ -45,7 +61,7 @@ extension RouterMethods { _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .put, use: handler) + return self.on(path, method: .put, use: handler) } /// DELETE path for async closure returning type conforming to ResponseGenerator @@ -53,7 +69,7 @@ extension RouterMethods { _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .delete, use: handler) + return self.on(path, method: .delete, use: handler) } /// HEAD path for async closure returning type conforming to ResponseGenerator @@ -61,7 +77,7 @@ extension RouterMethods { _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .head, use: handler) + return self.on(path, method: .head, use: handler) } /// POST path for async closure returning type conforming to ResponseGenerator @@ -69,7 +85,7 @@ extension RouterMethods { _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .post, use: handler) + return self.on(path, method: .post, use: handler) } /// PATCH path for async closure returning type conforming to ResponseGenerator @@ -77,10 +93,10 @@ extension RouterMethods { _ path: String = "", use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> Self { - return on(path, method: .patch, use: handler) + return self.on(path, method: .patch, use: handler) } - func constructResponder( + internal func constructResponder( use closure: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator ) -> CallbackResponder { return CallbackResponder { request, context in @@ -88,4 +104,10 @@ extension RouterMethods { return try output.response(from: request, context: context) } } + + internal func combinePaths(_ path1: String, _ path2: String) -> String { + let path1 = path1.dropSuffix("/") + let path2 = path2.dropPrefix("/") + return "\(path1)/\(path2)" + } } diff --git a/Tests/HummingbirdTests/RouterTests.swift b/Tests/HummingbirdTests/RouterTests.swift index ddd19d19b..68869c23f 100644 --- a/Tests/HummingbirdTests/RouterTests.swift +++ b/Tests/HummingbirdTests/RouterTests.swift @@ -431,6 +431,75 @@ final class RouterTests: XCTestCase { } } + // Test route collection added to Router + func testRouteCollection() async throws { + let router = Router() + let routes = RouteCollection() + routes.get("that") { _, _ in + return HTTPResponse.Status.ok + } + router.addRoutes(routes, atPath: "/this") + let app = Application(responder: router.buildResponder()) + try await app.test(.router) { client in + try await client.execute(uri: "/this/that", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } + } + } + + // Test route collection added to Router + func testRouteCollectionInGroup() async throws { + let router = Router() + let routes = RouteCollection() + .get("that") { _, _ in + return HTTPResponse.Status.ok + } + router.group("this").addRoutes(routes) + let app = Application(responder: router.buildResponder()) + try await app.test(.router) { client in + try await client.execute(uri: "/this/that", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } + } + } + + // Test middleware in route collection + func testMiddlewareInRouteCollection() async throws { + let router = Router() + let routes = RouteCollection() + .add(middleware: TestMiddleware("Hello")) + .get("that") { _, _ in + return HTTPResponse.Status.ok + } + router.addRoutes(routes, atPath: "/this") + let app = Application(responder: router.buildResponder()) + try await app.test(.router) { client in + try await client.execute(uri: "/this/that", method: .get) { response in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.headers[.test], "Hello") + } + } + } + + // Test group in route collection + func testGroupInRouteCollection() async throws { + let router = Router() + let routes = RouteCollection() + routes.group("2") + .add(middleware: TestMiddleware("Hello")) + .get("3") { _, _ in + return HTTPResponse.Status.ok + } + router.addRoutes(routes, atPath: "1") + let app = Application(responder: router.buildResponder()) + try await app.test(.router) { client in + try await client.execute(uri: "/1/2/3", method: .get) { response in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.headers[.test], "Hello") + } + } + } + // Test case insensitive router works func testCaseInsensitive() async throws { let router = Router(options: .caseInsensitive) @@ -440,6 +509,9 @@ final class RouterTests: XCTestCase { router.get("lowercased") { _, _ in return HTTPResponse.Status.ok } + router.group("group").get("Uppercased") { _, _ in + return HTTPResponse.Status.ok + } let app = Application(responder: router.buildResponder()) try await app.test(.router) { client in try await client.execute(uri: "/uppercased", method: .get) { response in @@ -448,6 +520,9 @@ final class RouterTests: XCTestCase { try await client.execute(uri: "/LOWERCASED", method: .get) { response in XCTAssertEqual(response.status, .ok) } + try await client.execute(uri: "/Group/uppercased", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } } }