From 120be87f191c8bb72bd4eccf23bb9661b5312a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pacheco=20Neves?= Date: Sun, 25 Jul 2021 22:53:58 +0100 Subject: [PATCH] =?UTF-8?q?Make=20`Router`=20generic=20over=20its=20route?= =?UTF-8?q?=20=F0=9F=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our `Router` and `RouteHandler` infrastructure was designed to work with plain `URL`'s on its main APIs. Despite URLs being the critical piece of information to route (e.g. in our `TrieRouter`), in certain situations the URL doesn't contain all the information, which limits the Routing infrastructure capabilities. One such example would be in a server middleware router, where the HTTP method would likely be required information to pass on to handlers to perform adequate routing. To remove this restriction, a new `Routable` protocol was created to represent a routable type, which only needs to provide a `url` and is forwarded by the router to the handler upon a successful match. To make it easier to unpack any additional information on the handlers and have more type safety, it was made an `associatedtype` of both `Router` and `RouteHandler`, effectively turning it into a new generic `R`. ## Changes - Add new `Routable` protocol, add default conformance to `URL`. - Add new `associatedtype R: Routable` to `Router` and `RouteHandler`. - Update `Router` and `RouterHandler` APIs to receive a route of type `R`. - Add new `RouteHandler.eraseToAnyRouteHandler` helper. --- Alicerce.xcodeproj/project.pbxproj | 15 ++++-- Sources/DeepLinking/Routable.swift | 12 +++++ Sources/DeepLinking/Route+TrieRouter.swift | 22 ++++----- Sources/DeepLinking/Router.swift | 36 ++++++++------ .../Route+TrieRouter_DescriptionTests.swift | 46 ++++++++--------- .../Route+TrieRouter_RegisterTests.swift | 4 +- .../Route+TrieRouter_RouteTests.swift | 49 ++++++++++++++++++- .../Route+TrieRouter_UnregisterTests.swift | 4 +- 8 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 Sources/DeepLinking/Routable.swift diff --git a/Alicerce.xcodeproj/project.pbxproj b/Alicerce.xcodeproj/project.pbxproj index b4d64a27..b7f24e2a 100644 --- a/Alicerce.xcodeproj/project.pbxproj +++ b/Alicerce.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ 0A9AF8BC1FC3242E0076458E /* SpecializedGenericTestView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0A9AF8BB1FC3242E0076458E /* SpecializedGenericTestView.xib */; }; 0A9AF8C01FC336F60076458E /* ReusableViewTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9AF8BF1FC336F60076458E /* ReusableViewTestCase.swift */; }; 0A9AF8C21FC33B070076458E /* ReusableViewCollectionViewTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9AF8C11FC33B070076458E /* ReusableViewCollectionViewTestCase.swift */; }; + 0A9E25BC26ADE3100096D006 /* Routable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9E25BA26ADE1030096D006 /* Routable.swift */; }; 0AA061002273B4F80052B4A1 /* TestNIBCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19A028822664D2A00650D93 /* TestNIBCollectionViewCell.swift */; }; 0AA061012273B4F80052B4A1 /* TestNIBTableHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D1F608226620D800CC46B3 /* TestNIBTableHeaderFooterView.swift */; }; 0AA061022273B4F80052B4A1 /* TestNIBCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19A028C22664EC300650D93 /* TestNIBCollectionReusableView.swift */; }; @@ -551,6 +552,7 @@ 0A9AF8BB1FC3242E0076458E /* SpecializedGenericTestView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SpecializedGenericTestView.xib; sourceTree = ""; }; 0A9AF8BF1FC336F60076458E /* ReusableViewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableViewTestCase.swift; sourceTree = ""; }; 0A9AF8C11FC33B070076458E /* ReusableViewCollectionViewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableViewCollectionViewTestCase.swift; sourceTree = ""; }; + 0A9E25BA26ADE1030096D006 /* Routable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routable.swift; sourceTree = ""; }; 0AA4EBA6264598AE00616FB3 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; 0AA4EBA72645E96900616FB3 /* Gemfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile; sourceTree = ""; }; 0AB34A052085385A001F2979 /* UnfairLockTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLockTestCase.swift; sourceTree = ""; }; @@ -829,6 +831,7 @@ isa = PBXGroup; children = ( 0A3C2C831EA7E18500EFB7D4 /* ApplicationRouter.swift */, + 0A9E25BA26ADE1030096D006 /* Routable.swift */, 0A3C2C861EA7E18500EFB7D4 /* Route.swift */, 0A3C2C841EA7E18500EFB7D4 /* Route+Component.swift */, 0AE188C022E49DFF00153A36 /* Route+TrieNode.swift */, @@ -1652,7 +1655,7 @@ 0A7B505620B72192005A08E7 = { CreatedOnToolsVersion = 9.3.1; LastSwiftMigration = 1020; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; }; 0A908667217767B200E76280 = { LastSwiftMigration = 1020; @@ -1961,6 +1964,7 @@ 0A708F6620E97B6E001784DA /* AnyAnalyticsTracker.swift in Sources */, 1B667A0A20127C1600A8CD5A /* StackOrchestrator+Store.swift in Sources */, 0A708F6220E96CD1001784DA /* Analytics.swift in Sources */, + 0A9E25BC26ADE3100096D006 /* Routable.swift in Sources */, 0AD7F24820A9D1D000CC927E /* ServerTrustEvaluator.swift in Sources */, 0A132EB724842EFE00FC108A /* StackOrchestratorStore.swift in Sources */, 0A3C2DBA1EA7E5DD00EFB7D4 /* TableViewHeaderFooterView.swift in Sources */, @@ -2342,8 +2346,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -2380,8 +2384,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; @@ -2393,6 +2397,7 @@ MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = com.mindera.alicerce.DummyHostApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; diff --git a/Sources/DeepLinking/Routable.swift b/Sources/DeepLinking/Routable.swift new file mode 100644 index 00000000..366e092b --- /dev/null +++ b/Sources/DeepLinking/Routable.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A type representing something that can be routed (via a URL). +public protocol Routable { + + var route: URL { get } +} + +extension URL: Routable { + + public var route: URL { self } +} diff --git a/Sources/DeepLinking/Route+TrieRouter.swift b/Sources/DeepLinking/Route+TrieRouter.swift index fac84dee..33fc4901 100644 --- a/Sources/DeepLinking/Route+TrieRouter.swift +++ b/Sources/DeepLinking/Route+TrieRouter.swift @@ -54,23 +54,23 @@ extension Route { /// A URL router that is backed by a **trie** tree and forwards route handling to registered handlers. /// /// Routes are registered with an associated handler, which on match handles the event and optionally invokes a - /// completion closure with an abitrary payload of type `T`. This allows handlers to perform asynchronous work even + /// completion closure with an arbitrary payload of type `T`. This allows handlers to perform asynchronous work even /// though route matching is made synchronously. /// /// - Remark: Access to the backing trie tree data structure *is* synchronized, so all operations can safely be /// called from different threads. /// /// - Note: https://en.wikipedia.org/wiki/Trie for more information. - public final class TrieRouter: Router { + public final class TrieRouter: Router { /// A type representing the router's trie tree node. - fileprivate typealias TrieNode = Route.TrieNode> + fileprivate typealias TrieNode = Route.TrieNode> /// A type representing a route to match. private typealias MatchRoute = (components: [String], queryItems : [URLQueryItem]) /// A type representing a matched route,. - private typealias Match = (parameters: Route.Parameters, handler: AnyRouteHandler) + private typealias Match = (parameters: Route.Parameters, handler: AnyRouteHandler) /// The router's trie tree. fileprivate var trie: Atomic = Atomic(TrieNode()) @@ -84,9 +84,9 @@ extension Route { /// - route: The route to register. /// - handler: The handler to associate with the route and handle it on match. /// - Throws: A `TrieRouterError` error if the route is invalid or a conflict exists. - public func register(_ route: URL, handler: AnyRouteHandler) throws { + public func register(_ route: R, handler: AnyRouteHandler) throws { - let routeComponents = try parseAnnotatedRoute(route) + let routeComponents = try parseAnnotatedRoute(route.route) try trie.modify { node in @@ -115,9 +115,9 @@ extension Route { /// - Throws: A `TrieRouterError` error if the route is invalid or wasn't found. /// - Returns: The unregistered handler associated with the route. @discardableResult - public func unregister(_ route: URL) throws -> AnyRouteHandler { + public func unregister(_ route: R) throws -> AnyRouteHandler { - let routeComponents = try parseAnnotatedRoute(route) + let routeComponents = try parseAnnotatedRoute(route.route) return try trie.modify { node in @@ -141,9 +141,9 @@ extension Route { /// - route: The route to route. /// - handleCompletion: The closure to notify routing success with custom payload from the route handler. /// - Throws: A `TrieRouterError` error if the route wasn't found. - public func route(_ route: URL, handleCompletion: ((T) -> Void)? = nil) throws { + public func route(_ route: R, handleCompletion: ((T) -> Void)? = nil) throws { - let (pathComponents, queryItems) = try parseMatchRoute(route) + let (pathComponents, queryItems) = try parseMatchRoute(route.route) let match: Match = try trie.withValue { node in @@ -246,4 +246,4 @@ private extension Optional where Wrapped == String { } @available(*, unavailable, renamed: "Route.TrieRouter") -public typealias TreeRouter = Route.TrieRouter +public typealias TreeRouter = Route.TrieRouter diff --git a/Sources/DeepLinking/Router.swift b/Sources/DeepLinking/Router.swift index 61b7fdfd..708c5170 100644 --- a/Sources/DeepLinking/Router.swift +++ b/Sources/DeepLinking/Router.swift @@ -1,23 +1,29 @@ import Foundation -/// A type representing a URL router. +/// A type representing a (URL-based) router. public protocol Router { + /// A router's route type. + associatedtype R: Routable + /// A router's custom payload type to be passed when a route is routed. associatedtype T - /// Routes the given route, and optionally notifies routing success with a custom payload. + /// Routes the given route and optionally notifies routing success with a custom payload. /// /// - Parameters: /// - route: The route to route. /// - handleCompletion: The closure to notify routing success with custom payload. /// - Throws: An error if the route couldn't be routed. - func route(_ route: URL, handleCompletion: ((T) -> Void)?) throws + func route(_ route: R, handleCompletion: ((T) -> Void)?) throws } -/// A type that handles URL routes form a router. +/// A type that handles (URL-based) routes from a router. public protocol RouteHandler { + /// A handler's (router) route type. + associatedtype R + /// A handler's custom payload type to be passed when a route is handled. associatedtype T @@ -34,14 +40,19 @@ public protocol RouteHandler { /// - parameters: The parameters captured by the router. /// - queryItems: The query items contained in the route. /// - completion: The closure to notify handle completion with custom payload. - func handle(route: URL, parameters: Route.Parameters, queryItems: [URLQueryItem], completion: ((T) -> Void)?) + func handle(route: R, parameters: Route.Parameters, queryItems: [URLQueryItem], completion: ((T) -> Void)?) +} + +extension RouteHandler { + + public func eraseToAnyRouteHandler() -> AnyRouteHandler { .init(self) } } -/// A type-erased URL route handler. -public final class AnyRouteHandler: RouteHandler { +/// A type-erased (URL-based) route handler. +public final class AnyRouteHandler: RouteHandler { /// The type-erased handler's wrapped instance `handle` method, stored as a closure. - private let _handle: (URL, Route.Parameters, [URLQueryItem], ((T) -> Void)?) -> Void + private let _handle: (R, Route.Parameters, [URLQueryItem], ((T) -> Void)?) -> Void /// The type-erased tracker's wrapped instance. private let _wrapped: Any @@ -50,7 +61,7 @@ public final class AnyRouteHandler: RouteHandler { /// /// - Parameters: /// - handler: The route handler instance to wrap. - public init(_ handler: H) where H.T == T { + public init(_ handler: H) where H.R == R, H.T == T { _handle = handler.handle _wrapped = handler @@ -70,12 +81,7 @@ public final class AnyRouteHandler: RouteHandler { /// - parameters: The parameters captured by the router. /// - queryItems: The query items contained in the route. /// - completion: The closure to notify handle completion with custom payload. - public func handle( - route: URL, - parameters: Route.Parameters, - queryItems: [URLQueryItem], - completion: ((T) -> Void)? - ) { + public func handle(route: R, parameters: Route.Parameters, queryItems: [URLQueryItem], completion: ((T) -> Void)?) { _handle(route, parameters, queryItems, completion) } diff --git a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_DescriptionTests.swift b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_DescriptionTests.swift index b35cf081..132e23ae 100644 --- a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_DescriptionTests.swift +++ b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_DescriptionTests.swift @@ -3,6 +3,8 @@ import XCTest class Route_TrieRouter_DescriptionTests: XCTestCase { + struct Payload {} + struct TestHandler: RouteHandler, CustomStringConvertible { let tag: String @@ -11,13 +13,13 @@ class Route_TrieRouter_DescriptionTests: XCTestCase { route: URL, parameters: [String : String], queryItems: [URLQueryItem], - completion: ((String) -> Void)? + completion: ((Payload) -> Void)? ) {} - public var description: String { return tag } + public var description: String { tag } } - typealias TestRouter = Route.TrieRouter + typealias TestRouter = Route.TrieRouter func testDescription_ShouldMatchValues() { @@ -59,17 +61,17 @@ class Route_TrieRouter_DescriptionTests: XCTestCase { """ ├──┬ schemeb │ ├──┬ hosta - │ │ └──● AnyRouteHandler(P) + │ │ └──● AnyRouteHandler(P) │ │ │ ├──┬ hostb │ │ └──┬ path │ │ ├──┬ * - │ │ │ └──● AnyRouteHandler(R) + │ │ │ └──● AnyRouteHandler(R) │ │ │ - │ │ └──● AnyRouteHandler(Q) + │ │ └──● AnyRouteHandler(Q) │ │ │ └──┬ * - │ └──● AnyRouteHandler(O) + │ └──● AnyRouteHandler(O) │ ├──┬ schemea │ ├──┬ hostc @@ -77,18 +79,18 @@ class Route_TrieRouter_DescriptionTests: XCTestCase { │ │ └──┬ yet │ │ └──┬ another │ │ ├──┬ path - │ │ │ └──● AnyRouteHandler(K) + │ │ │ └──● AnyRouteHandler(K) │ │ │ │ │ ├──┬ :parameterA │ │ │ └──┬ * - │ │ │ └──● AnyRouteHandler(L) + │ │ │ └──● AnyRouteHandler(L) │ │ │ │ │ └──┬ ** - │ │ └──● AnyRouteHandler(M) + │ │ └──● AnyRouteHandler(M) │ │ │ └──┬ * │ └──┬ **catchAll - │ └──● AnyRouteHandler(N) + │ └──● AnyRouteHandler(N) │ └──┬ * └──┬ * @@ -96,50 +98,50 @@ class Route_TrieRouter_DescriptionTests: XCTestCase { │ └──┬ path │ ├──┬ * │ │ └──┬ :parameterA - │ │ └──● AnyRouteHandler(C) + │ │ └──● AnyRouteHandler(C) │ │ │ ├──┬ ** - │ │ └──● AnyRouteHandler(B) + │ │ └──● AnyRouteHandler(B) │ │ - │ └──● AnyRouteHandler(A) + │ └──● AnyRouteHandler(A) │ ├──┬ host │ └──┬ another │ └──┬ :parameterA │ └──┬ :parameterB │ └──┬ **parameterC - │ └──● AnyRouteHandler(F) + │ └──● AnyRouteHandler(F) │ ├──┬ hostA │ └──┬ another │ ├──┬ path - │ │ └──● AnyRouteHandler(D) + │ │ └──● AnyRouteHandler(D) │ │ │ └──┬ :parameterA │ └──┬ :parameterB - │ └──● AnyRouteHandler(E) + │ └──● AnyRouteHandler(E) │ └──┬ hostB └──┬ :parameterA ├──┬ before │ └──┬ path - │ └──● AnyRouteHandler(G) + │ └──● AnyRouteHandler(G) │ ├──┬ :parameterB │ ├──┬ path │ │ └──┬ * - │ │ └──● AnyRouteHandler(H) + │ │ └──● AnyRouteHandler(H) │ │ │ └──┬ ** - │ └──● AnyRouteHandler(I) + │ └──● AnyRouteHandler(I) │ └──┬ * └──┬ path └──┬ * - └──● AnyRouteHandler(J) + └──● AnyRouteHandler(J) """ ) } - private func testHandler(_ tag: String) -> AnyRouteHandler { return AnyRouteHandler(TestHandler(tag: tag)) } + private func testHandler(_ tag: String) -> AnyRouteHandler { AnyRouteHandler(TestHandler(tag: tag)) } } diff --git a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RegisterTests.swift b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RegisterTests.swift index 2a35d240..a57b89e4 100644 --- a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RegisterTests.swift +++ b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RegisterTests.swift @@ -21,10 +21,10 @@ final class TestHandler: RouteHandler { class Route_TrieRouter_RegisterTests: XCTestCase { - typealias TestRouter = Route.TrieRouter + typealias TestRouter = Route.TrieRouter typealias TestRouteTrieNode = Route.TrieNode - var testHandler = AnyRouteHandler(TestHandler()) + var testHandler = AnyRouteHandler(TestHandler()) // MARK: - failure diff --git a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RouteTests.swift b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RouteTests.swift index fe970054..bb4cc2f0 100644 --- a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RouteTests.swift +++ b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_RouteTests.swift @@ -3,9 +3,9 @@ import XCTest class Route_TrieRouter_RouteTests: XCTestCase { - typealias TestRouter = Route.TrieRouter + typealias TestRouter = Route.TrieRouter typealias TestRouteTrieNode = Route.TrieNode - typealias AnyTestHandler = AnyRouteHandler + typealias AnyTestHandler = AnyRouteHandler var testHandler = AnyTestHandler(TestHandler()) @@ -518,6 +518,51 @@ class Route_TrieRouter_RouteTests: XCTestCase { assertQueryItems: expectedQueryItems ) } + + // MARK: route propagation + + func testRoute_WithMatchingRoute_ShouldPropagateRoute() { + + class TestRoute: Routable { + + var route: URL + + init(route: URL) { self.route = route } + } + + struct TestHandler: RouteHandler { + + var didHandle: ((TestRoute, [String : String], [URLQueryItem]) -> Void)? + + public func handle( + route: TestRoute, + parameters: [String : String], + queryItems: [URLQueryItem], + completion: ((String) -> Void)? + ) { + + didHandle?(route, parameters, queryItems) + } + } + + typealias TestRouter = Route.TrieRouter + + let handleExpectation = expectation(description: "handle") + defer { waitForExpectations(timeout: 1) } + + let route = TestRoute(route: "scheme://some/path".url()) + + let router = TestRouter() + var handler = TestHandler() + handler.didHandle = { _route, _, _ in + + XCTAssert(route === _route) + handleExpectation.fulfill() + } + + XCTAssertNoThrow(try router.register(route, handler: handler.eraseToAnyRouteHandler())) + XCTAssertNoThrow(try router.route(route)) + } } // MARK: - helpers diff --git a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_UnregisterTests.swift b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_UnregisterTests.swift index 453e830d..0283ee88 100644 --- a/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_UnregisterTests.swift +++ b/Tests/AlicerceTests/DeepLinking/Route+TrieRouter_UnregisterTests.swift @@ -3,10 +3,10 @@ import XCTest class Route_TrieRouter_UnregisterTests: XCTestCase { - typealias TestRouter = Route.TrieRouter + typealias TestRouter = Route.TrieRouter typealias TestRouteTrieNode = Route.TrieNode - var testHandler = AnyRouteHandler(TestHandler()) + var testHandler = AnyRouteHandler(TestHandler()) // MARK: - failure