Skip to content

Commit

Permalink
Route collections (#421)
Browse files Browse the repository at this point in the history
* Change RouterMethods requirements slightly

Move all path manipulation in Router/RouterGroup to implementation of this function

* Added RouteCollection

Edited RouterGroup so it can be used with RouteCollection

* Add tests for RouteCollections

* Use flat array instead of dictionary to store routes

* Update Sources/Hummingbird/Router/RouteCollection.swift

Co-authored-by: Joannis Orlandos <[email protected]>

* Update tests

---------

Co-authored-by: Joannis Orlandos <[email protected]>
  • Loading branch information
adam-fowler and Joannis authored Apr 29, 2024
1 parent f1e6d1c commit ce5d492
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 52 deletions.
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

0 comments on commit ce5d492

Please sign in to comment.