Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Route collections #421

Merged
merged 6 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions Sources/Hummingbird/Router/RouteCollection.swift
Original file line number Diff line number Diff line change
@@ -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<Context: BaseRequestContext>: 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<Responder: HTTPResponder>(
_ 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<Context> {
return .init(path: path, router: self)
}

/// Add middleware to RouteCollection
@discardableResult public func add(middleware: any RouterMiddleware<Context>) -> Self {
self.middlewares.add(middleware)
return self
}

fileprivate struct RouteDefinition {
let path: String
let method: HTTPRequest.Method
let responder: any HTTPResponder<Context>
}

fileprivate var routes: [RouteDefinition]
let middlewares: MiddlewareGroup<Context>
}

extension RouterMethods {
/// Add route collection to router
/// - Parameter collection: Route collection
public func addRoutes(_ collection: RouteCollection<Context>, 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))
}
}
}
34 changes: 14 additions & 20 deletions Sources/Hummingbird/Router/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,6 @@ public final class Router<Context: BaseRequestContext>: 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<Context>) {
// 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<Context> {
if self.options.contains(.autoGenerateHeadEndpoints) {
Expand All @@ -82,18 +69,25 @@ public final class Router<Context: BaseRequestContext>: 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<Responder: HTTPResponder>(
_ 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
}

Expand Down
37 changes: 17 additions & 20 deletions Sources/Hummingbird/Router/RouterGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,16 @@ import NIOCore
/// ```
public struct RouterGroup<Context: BaseRequestContext>: RouterMethods {
let path: String
let router: Router<Context>
let router: any RouterMethods<Context>
let middlewares: MiddlewareGroup<Context>

init(path: String = "", middlewares: MiddlewareGroup<Context> = .init(), router: Router<Context>) {
init(path: String = "", middlewares: MiddlewareGroup<Context> = .init(), router: any RouterMethods<Context>) {
self.path = path
self.router = router
self.middlewares = middlewares
}

/// Add middleware to RouterEndpoint
/// Add middleware to RouterGroup
@discardableResult public func add(middleware: any RouterMiddleware<Context>) -> RouterGroup<Context> {
self.middlewares.add(middleware)
return self
Expand All @@ -57,24 +57,21 @@ public struct RouterGroup<Context: BaseRequestContext>: 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<Responder: HTTPResponder>(
_ 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)"
}
}
46 changes: 34 additions & 12 deletions Sources/Hummingbird/Router/RouterMethods.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,73 +19,95 @@ import NIOCore
public protocol RouterMethods<Context> {
associatedtype Context: BaseRequestContext

/// Add path for async closure
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@discardableResult func on<Output: ResponseGenerator>(
/// 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<Responder: HTTPResponder>(
_ 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<Context>
}

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
@discardableResult public func put(
_ 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
@discardableResult public func delete(
_ 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
@discardableResult public func head(
_ 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
@discardableResult public func post(
_ 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
@discardableResult public func patch(
_ 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<Context> {
return CallbackResponder { request, context in
let output = try await closure(request, context)
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)"
}
}
75 changes: 75 additions & 0 deletions Tests/HummingbirdTests/RouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
}
}
}

Expand Down
Loading