Skip to content

Commit

Permalink
Make Router generic over its route 🚏
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
p4checo committed Jul 25, 2021
1 parent 61bbc21 commit 120be87
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 59 deletions.
15 changes: 10 additions & 5 deletions Alicerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -551,6 +552,7 @@
0A9AF8BB1FC3242E0076458E /* SpecializedGenericTestView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SpecializedGenericTestView.xib; sourceTree = "<group>"; };
0A9AF8BF1FC336F60076458E /* ReusableViewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableViewTestCase.swift; sourceTree = "<group>"; };
0A9AF8C11FC33B070076458E /* ReusableViewCollectionViewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableViewCollectionViewTestCase.swift; sourceTree = "<group>"; };
0A9E25BA26ADE1030096D006 /* Routable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routable.swift; sourceTree = "<group>"; };
0AA4EBA6264598AE00616FB3 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
0AA4EBA72645E96900616FB3 /* Gemfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile; sourceTree = "<group>"; };
0AB34A052085385A001F2979 /* UnfairLockTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLockTestCase.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -829,6 +831,7 @@
isa = PBXGroup;
children = (
0A3C2C831EA7E18500EFB7D4 /* ApplicationRouter.swift */,
0A9E25BA26ADE1030096D006 /* Routable.swift */,
0A3C2C861EA7E18500EFB7D4 /* Route.swift */,
0A3C2C841EA7E18500EFB7D4 /* Route+Component.swift */,
0AE188C022E49DFF00153A36 /* Route+TrieNode.swift */,
Expand Down Expand Up @@ -1652,7 +1655,7 @@
0A7B505620B72192005A08E7 = {
CreatedOnToolsVersion = 9.3.1;
LastSwiftMigration = 1020;
ProvisioningStyle = Manual;
ProvisioningStyle = Automatic;
};
0A908667217767B200E76280 = {
LastSwiftMigration = 1020;
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions Sources/DeepLinking/Routable.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
22 changes: 11 additions & 11 deletions Sources/DeepLinking/Route+TrieRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>: Router {
public final class TrieRouter<R: Routable, T>: Router {

/// A type representing the router's trie tree node.
fileprivate typealias TrieNode = Route.TrieNode<AnyRouteHandler<T>>
fileprivate typealias TrieNode = Route.TrieNode<AnyRouteHandler<R, T>>

/// 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<T>)
private typealias Match = (parameters: Route.Parameters, handler: AnyRouteHandler<R, T>)

/// The router's trie tree.
fileprivate var trie: Atomic<TrieNode> = Atomic(TrieNode())
Expand All @@ -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<T>) throws {
public func register(_ route: R, handler: AnyRouteHandler<R, T>) throws {

let routeComponents = try parseAnnotatedRoute(route)
let routeComponents = try parseAnnotatedRoute(route.route)

try trie.modify { node in

Expand Down Expand Up @@ -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<T> {
public func unregister(_ route: R) throws -> AnyRouteHandler<R, T> {

let routeComponents = try parseAnnotatedRoute(route)
let routeComponents = try parseAnnotatedRoute(route.route)

return try trie.modify { node in

Expand All @@ -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

Expand Down Expand Up @@ -246,4 +246,4 @@ private extension Optional where Wrapped == String {
}

@available(*, unavailable, renamed: "Route.TrieRouter")
public typealias TreeRouter<Handler> = Route.TrieRouter<Handler>
public typealias TreeRouter<Handler> = Route.TrieRouter<URL, Handler>
36 changes: 21 additions & 15 deletions Sources/DeepLinking/Router.swift
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<R, T> { .init(self) }
}

/// A type-erased URL route handler.
public final class AnyRouteHandler<T>: RouteHandler {
/// A type-erased (URL-based) route handler.
public final class AnyRouteHandler<R, T>: 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
Expand All @@ -50,7 +61,7 @@ public final class AnyRouteHandler<T>: RouteHandler {
///
/// - Parameters:
/// - handler: The route handler instance to wrap.
public init<H: RouteHandler>(_ handler: H) where H.T == T {
public init<H: RouteHandler>(_ handler: H) where H.R == R, H.T == T {

_handle = handler.handle
_wrapped = handler
Expand All @@ -70,12 +81,7 @@ public final class AnyRouteHandler<T>: 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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import XCTest

class Route_TrieRouter_DescriptionTests: XCTestCase {

struct Payload {}

struct TestHandler: RouteHandler, CustomStringConvertible {

let tag: String
Expand All @@ -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<String>
typealias TestRouter = Route.TrieRouter<URL, Payload>

func testDescription_ShouldMatchValues() {

Expand Down Expand Up @@ -59,87 +61,87 @@ class Route_TrieRouter_DescriptionTests: XCTestCase {
"""
├──┬ schemeb
│ ├──┬ hosta
│ │ └──● AnyRouteHandler<String>(P)
│ │ └──● AnyRouteHandler<URL, Payload>(P)
│ │
│ ├──┬ hostb
│ │ └──┬ path
│ │ ├──┬ *
│ │ │ └──● AnyRouteHandler<String>(R)
│ │ │ └──● AnyRouteHandler<URL, Payload>(R)
│ │ │
│ │ └──● AnyRouteHandler<String>(Q)
│ │ └──● AnyRouteHandler<URL, Payload>(Q)
│ │
│ └──┬ *
│ └──● AnyRouteHandler<String>(O)
│ └──● AnyRouteHandler<URL, Payload>(O)
├──┬ schemea
│ ├──┬ hostc
│ │ └──┬ *
│ │ └──┬ yet
│ │ └──┬ another
│ │ ├──┬ path
│ │ │ └──● AnyRouteHandler<String>(K)
│ │ │ └──● AnyRouteHandler<URL, Payload>(K)
│ │ │
│ │ ├──┬ :parameterA
│ │ │ └──┬ *
│ │ │ └──● AnyRouteHandler<String>(L)
│ │ │ └──● AnyRouteHandler<URL, Payload>(L)
│ │ │
│ │ └──┬ **
│ │ └──● AnyRouteHandler<String>(M)
│ │ └──● AnyRouteHandler<URL, Payload>(M)
│ │
│ └──┬ *
│ └──┬ **catchAll
│ └──● AnyRouteHandler<String>(N)
│ └──● AnyRouteHandler<URL, Payload>(N)
└──┬ *
└──┬ *
├──┬ some
│ └──┬ path
│ ├──┬ *
│ │ └──┬ :parameterA
│ │ └──● AnyRouteHandler<String>(C)
│ │ └──● AnyRouteHandler<URL, Payload>(C)
│ │
│ ├──┬ **
│ │ └──● AnyRouteHandler<String>(B)
│ │ └──● AnyRouteHandler<URL, Payload>(B)
│ │
│ └──● AnyRouteHandler<String>(A)
│ └──● AnyRouteHandler<URL, Payload>(A)
├──┬ host
│ └──┬ another
│ └──┬ :parameterA
│ └──┬ :parameterB
│ └──┬ **parameterC
│ └──● AnyRouteHandler<String>(F)
│ └──● AnyRouteHandler<URL, Payload>(F)
├──┬ hostA
│ └──┬ another
│ ├──┬ path
│ │ └──● AnyRouteHandler<String>(D)
│ │ └──● AnyRouteHandler<URL, Payload>(D)
│ │
│ └──┬ :parameterA
│ └──┬ :parameterB
│ └──● AnyRouteHandler<String>(E)
│ └──● AnyRouteHandler<URL, Payload>(E)
└──┬ hostB
└──┬ :parameterA
├──┬ before
│ └──┬ path
│ └──● AnyRouteHandler<String>(G)
│ └──● AnyRouteHandler<URL, Payload>(G)
├──┬ :parameterB
│ ├──┬ path
│ │ └──┬ *
│ │ └──● AnyRouteHandler<String>(H)
│ │ └──● AnyRouteHandler<URL, Payload>(H)
│ │
│ └──┬ **
│ └──● AnyRouteHandler<String>(I)
│ └──● AnyRouteHandler<URL, Payload>(I)
└──┬ *
└──┬ path
└──┬ *
└──● AnyRouteHandler<String>(J)
└──● AnyRouteHandler<URL, Payload>(J)
"""
)
}

private func testHandler(_ tag: String) -> AnyRouteHandler<String> { return AnyRouteHandler(TestHandler(tag: tag)) }
private func testHandler(_ tag: String) -> AnyRouteHandler<URL, Payload> { AnyRouteHandler(TestHandler(tag: tag)) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ final class TestHandler: RouteHandler {

class Route_TrieRouter_RegisterTests: XCTestCase {

typealias TestRouter = Route.TrieRouter<HandledRoute>
typealias TestRouter = Route.TrieRouter<URL, HandledRoute>
typealias TestRouteTrieNode = Route.TrieNode<TestHandler>

var testHandler = AnyRouteHandler<HandledRoute>(TestHandler())
var testHandler = AnyRouteHandler(TestHandler())

// MARK: - failure

Expand Down
Loading

0 comments on commit 120be87

Please sign in to comment.