diff --git a/Crossroad.podspec b/Crossroad.podspec index d5c5a7e..1c205dd 100644 --- a/Crossroad.podspec +++ b/Crossroad.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Crossroad" - s.version = "2.0.0" + s.version = "2.1.0" s.summary = "Route URL schemes easily" s.description = <<-DESC Crossroad is an URL router focused on handling Custom URL Scheme. diff --git a/README.md b/README.md index ab0f4eb..49204f0 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,16 @@ let userInfo = UserInfo(userID: User.current.id) router.openIfPossible(url, userInfo: userInfo) ``` +## Universal Links + +You can make routers handle with Universal Links. + +Of course, you can also use [Firebase Dynamic Link](https://firebase.google.com/docs/dynamic-links) or other similar services. + +```swift +let router = DefaultRouter(url: URL(string: "https://my-awesome-pokedex.com")!) +``` + ## Supported version Latest version of Crossroad requires Swift 5.0 or above. diff --git a/Sources/Crossroad/PatternURL.swift b/Sources/Crossroad/PatternURL.swift index 84a0ea8..e603b01 100644 --- a/Sources/Crossroad/PatternURL.swift +++ b/Sources/Crossroad/PatternURL.swift @@ -8,6 +8,7 @@ internal struct PatternURL { let scheme: String let host: String let pathComponents: [String] + let patternString: String private static let schemeSeparator = "://" private static let pathSeparator = "/" @@ -24,6 +25,7 @@ internal struct PatternURL { } self.scheme = scheme self.host = host + self.patternString = string if components.count > 1 { let left = components[1 ..< components.count] // In URL, pathComponents includes the starting "/" so do the same. @@ -37,4 +39,8 @@ internal struct PatternURL { pathComponents = [] } } + + func hasPrefix(url: URL) -> Bool { + return patternString.hasPrefix(url.absoluteString) + } } diff --git a/Sources/Crossroad/Router.swift b/Sources/Crossroad/Router.swift index 5954d79..87e33d3 100644 --- a/Sources/Crossroad/Router.swift +++ b/Sources/Crossroad/Router.swift @@ -3,31 +3,57 @@ import Foundation public typealias SimpleRouter = Router public final class Router { - private let scheme: String + private enum Prefix { + case scheme(String) + case url(URL) + } + private let prefix: Prefix private var routes: [Route] = [] public init(scheme: String) { - self.scheme = scheme + prefix = .scheme(scheme) + } + + public init(url: URL) { + prefix = .url(url) + } + + private func isValidURLPattern(_ patternURL: PatternURL) -> Bool { + switch prefix { + case .scheme(let scheme): + return scheme == patternURL.scheme + case .url(let url): + return patternURL.hasPrefix(url: url) + } + } + + private func canRespond(to url: URL) -> Bool { + switch prefix { + case .scheme(let scheme): + return scheme == url.scheme + case .url(let prefixURL): + return url.absoluteString.hasPrefix(prefixURL.absoluteString) + } } internal func register(_ route: Route) { - if scheme != route.patternURL.scheme { - assertionFailure("Router and pattern must have the same schemes. expect: \(scheme), actual: \(route.patternURL.scheme)") - } else { + if isValidURLPattern(route.patternURL) { routes.append(route) + } else { + assertionFailure("Unexpected URL Pattern") } } @discardableResult public func openIfPossible(_ url: URL, userInfo: UserInfo) -> Bool { - if scheme != url.scheme { + if !canRespond(to: url) { return false } return routes.first { $0.openIfPossible(url, userInfo: userInfo) } != nil } public func responds(to url: URL, userInfo: UserInfo) -> Bool { - if scheme != url.scheme { + if !canRespond(to: url) { return false } return routes.first { $0.responds(to: url, userInfo: userInfo) } != nil @@ -36,10 +62,19 @@ public final class Router { public func register(_ routes: [(String, Route.Handler)]) { for (pattern, handler) in routes { let patternURLString: String - if pattern.hasPrefix("\(scheme)://") { - patternURLString = pattern - } else { - patternURLString = "\(scheme)://\(pattern)" + switch prefix { + case .scheme(let scheme): + if pattern.hasPrefix("\(scheme)://") { + patternURLString = pattern + } else { + patternURLString = "\(scheme)://\(pattern)" + } + case .url(let url): + if pattern.hasPrefix(url.absoluteString) { + patternURLString = pattern + } else { + patternURLString = url.appendingPathComponent(pattern).absoluteString + } } guard let patternURL = PatternURL(string: patternURLString) else { assertionFailure("\(pattern) is invalid") diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml index eda0e0b..2e07681 100644 --- a/Tests/.swiftlint.yml +++ b/Tests/.swiftlint.yml @@ -3,6 +3,7 @@ disabled_rules: - force_try - identifier_name - type_body_length + - file_length opt_in_rules: - trailing_comma trailing_comma: diff --git a/Tests/CrossroadTests/PatternURLTests.swift b/Tests/CrossroadTests/PatternURLTests.swift index ed05ac1..e3315ff 100644 --- a/Tests/CrossroadTests/PatternURLTests.swift +++ b/Tests/CrossroadTests/PatternURLTests.swift @@ -45,4 +45,15 @@ final class PatternURLTests: XCTestCase { assertShouldFailed("without_schema") assertShouldFailed("invalid_schema://////aaaaaaa") } + + func testHasPrefix() { + XCTAssertTrue(PatternURL(string: "https://example.com")!.hasPrefix(url: URL(string: "https://example.com")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/")!.hasPrefix(url: URL(string: "https://example.com/")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/")!.hasPrefix(url: URL(string: "https://example.com")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/users/:id")!.hasPrefix(url: URL(string: "https://example.com")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/users/:id")!.hasPrefix(url: URL(string: "https://example.com/")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/users/:id")!.hasPrefix(url: URL(string: "https://example.com/users")!)) + XCTAssertTrue(PatternURL(string: "https://example.com/users/:id")!.hasPrefix(url: URL(string: "https://example.com/users/")!)) + XCTAssertFalse(PatternURL(string: "https://example.com/users/:id")!.hasPrefix(url: URL(string: "https://example.com/users/10")!)) + } } diff --git a/Tests/CrossroadTests/RouterTests.swift b/Tests/CrossroadTests/RouterTests.swift index b0f3a35..519e6cc 100644 --- a/Tests/CrossroadTests/RouterTests.swift +++ b/Tests/CrossroadTests/RouterTests.swift @@ -29,7 +29,31 @@ final class RouterTest: XCTestCase { XCTAssertFalse(router.responds(to: URL(string: "spam/ham")!)) } - func testCanRespondWithoutScheme() { + func testCanRespondWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com")!) + router.register([ + ("https://example.com/static", { _ in true }), + ("https://example.com/foo/bar", { _ in true }), + ("https://example.com/spam/ham", { _ in false }), + ("https://example.com/:keyword", { _ in true }), + ("https://example.com/foo/:keyword", { _ in true }), + ]) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/static")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo/bar")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo/10000")!)) + XCTAssertFalse(router.responds(to: URL(string: "https://example.com/aaa/bbb")!)) + XCTAssertFalse(router.responds(to: URL(string: "nothttps://example.com/aaa/bbb")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/spam/ham")!)) + XCTAssertFalse(router.responds(to: URL(string: "static")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo/bar")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo/10000")!)) + XCTAssertFalse(router.responds(to: URL(string: "aaa/bbb")!)) + XCTAssertFalse(router.responds(to: URL(string: "spam/ham")!)) + } + + func testCanRespondWithoutPrefix() { let router = SimpleRouter(scheme: scheme) router.register([ ("static", { _ in true }), @@ -52,6 +76,29 @@ final class RouterTest: XCTestCase { XCTAssertFalse(router.responds(to: URL(string: "spam/ham")!)) } + func testCanRespondWithoutPrefixWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com/")!) + router.register([ + ("static", { _ in true }), + ("foo/bar", { _ in true }), + ("spam/ham", { _ in false }), + (":keyword", { _ in true }), + ("foo/:keyword", { _ in true }), + ]) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/static")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo/bar")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/foo/10000")!)) + XCTAssertFalse(router.responds(to: URL(string: "nothttps://example.com/aaa/bbb")!)) + XCTAssertTrue(router.responds(to: URL(string: "https://example.com/spam/ham")!)) + XCTAssertFalse(router.responds(to: URL(string: "static")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo/bar")!)) + XCTAssertFalse(router.responds(to: URL(string: "foo/10000")!)) + XCTAssertFalse(router.responds(to: URL(string: "aaa/bbb")!)) + XCTAssertFalse(router.responds(to: URL(string: "spam/ham")!)) + } + func testHandle() { let router = SimpleRouter(scheme: scheme) let expectation = self.expectation(description: "Should called handler four times") @@ -96,7 +143,51 @@ final class RouterTest: XCTestCase { wait(for: [expectation], timeout: 2.0) } - func testHandleWithoutScheme() { + func testHandleWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com")!) + let expectation = self.expectation(description: "Should called handler four times") + expectation.expectedFulfillmentCount = 4 + router.register([ + ("https://example.com/static", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/static")!) + expectation.fulfill() + return true + }), + ("https://example.com/foo/bar", { context in + XCTAssertEqual(context.parameter(for: "param0"), 123) + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/bar?param0=123")!) + expectation.fulfill() + return true + }), + ("https://example.com/:keyword", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/hoge")!) + XCTAssertEqual(try? context.argument(for: "keyword"), "hoge") + expectation.fulfill() + return true + }), + ("https://example.com/foo/:keyword/:keyword2", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/hoge/fuga")!) + XCTAssertEqual(try? context.argument(for: "keyword"), "hoge") + XCTAssertEqual(try? context.argument(for: "keyword2"), "fuga") + expectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/static")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar?param0=123")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/hoge")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/hoge/fuga")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "https://example.com/spam/ham")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "nothttps://example.com/static")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "static")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar?param0=123")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "hoge")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/hoge/fuga")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "spam/ham")!)) + wait(for: [expectation], timeout: 2.0) + } + + func testHandleWithoutPrefix() { let router = SimpleRouter(scheme: scheme) let expectation = self.expectation(description: "Should called handler four times") expectation.expectedFulfillmentCount = 4 @@ -140,6 +231,50 @@ final class RouterTest: XCTestCase { wait(for: [expectation], timeout: 2.0) } + func testHandleWithoutPrefixWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com")!) + let expectation = self.expectation(description: "Should called handler four times") + expectation.expectedFulfillmentCount = 4 + router.register([ + ("static", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/static")!) + expectation.fulfill() + return true + }), + ("foo/bar", { context in + XCTAssertEqual(context.parameter(for: "param0"), 123) + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/bar?param0=123")!) + expectation.fulfill() + return true + }), + (":keyword", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/hoge")!) + XCTAssertEqual(try? context.argument(for: "keyword"), "hoge") + expectation.fulfill() + return true + }), + ("foo/:keyword/:keyword2", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/hoge/fuga")!) + XCTAssertEqual(try? context.argument(for: "keyword"), "hoge") + XCTAssertEqual(try? context.argument(for: "keyword2"), "fuga") + expectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/static")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar?param0=123")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/hoge")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/hoge/fuga")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "https://example.com/spam/ham")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "nothttps://example.com/static")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "static")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar?param0=123")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "hoge")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/hoge/fuga")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "spam/ham")!)) + wait(for: [expectation], timeout: 2.0) + } + func testHandlerWithSamePatterns() { let router = SimpleRouter(scheme: scheme) let idExpectation = self.expectation(description: "Should called handler with ID") @@ -169,7 +304,36 @@ final class RouterTest: XCTestCase { wait(for: [idExpectation, keywordExpectation], timeout: 2.0) } - func testHandlerWithSamePatternsWithoutScheme() { + func testHandlerWithSamePatternsWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com/")!) + let idExpectation = self.expectation(description: "Should called handler with ID") + let keywordExpectation = self.expectation(description: "Should called handler with keyword") + router.register([ + ("https://example.com/foo/:id", { context in + guard let id: Int = try? context.argument(for: "id") else { + return false + } + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/42")!) + XCTAssertEqual(id, 42) + idExpectation.fulfill() + return true + }), + ("https://example.com/foo/:keyword", { context in + let keyword: String = try! context.argument(for: "keyword") + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/bar")!) + XCTAssertEqual(keyword, "bar") + keywordExpectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/42")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/42")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar")!)) + wait(for: [idExpectation, keywordExpectation], timeout: 2.0) + } + + func testHandlerWithSamePatternsWithoutPrefix() { let router = SimpleRouter(scheme: scheme) let idExpectation = self.expectation(description: "Should called handler with ID") let keywordExpectation = self.expectation(description: "Should called handler with keyword") @@ -198,6 +362,35 @@ final class RouterTest: XCTestCase { wait(for: [idExpectation, keywordExpectation], timeout: 2.0) } + func testHandlerWithSamePatternsWithoutPrefixWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com/")!) + let idExpectation = self.expectation(description: "Should called handler with ID") + let keywordExpectation = self.expectation(description: "Should called handler with keyword") + router.register([ + ("foo/:id", { context in + guard let id: Int = try? context.argument(for: "id") else { + return false + } + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/42")!) + XCTAssertEqual(id, 42) + idExpectation.fulfill() + return true + }), + ("foo/:keyword", { context in + let keyword: String = try! context.argument(for: "keyword") + XCTAssertEqual(context.url, URL(string: "https://example.com/foo/bar")!) + XCTAssertEqual(keyword, "bar") + keywordExpectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/42")!)) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/42")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar")!)) + wait(for: [idExpectation, keywordExpectation], timeout: 2.0) + } + func testHandleReturnsFalse() { let router = SimpleRouter(scheme: scheme) let expectation = self.expectation(description: "Should called handler twice") @@ -218,7 +411,27 @@ final class RouterTest: XCTestCase { wait(for: [expectation], timeout: 2.0) } - func testHandleReturnsFalseWithoutScheme() { + func testHandleReturnsFalseWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com/")!) + let expectation = self.expectation(description: "Should called handler twice") + expectation.expectedFulfillmentCount = 2 + router.register([ + ("https://example.com/foo/bar", { _ in + expectation.fulfill() + return false + }), + ("https://example.com/foo/:keyword", { context in + XCTAssertEqual(try? context.argument(for: "keyword"), "bar") + expectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar")!)) + wait(for: [expectation], timeout: 2.0) + } + + func testHandleReturnsFalseWithoutPrefix() { let router = SimpleRouter(scheme: scheme) let expectation = self.expectation(description: "Should called handler twice") expectation.expectedFulfillmentCount = 2 @@ -238,6 +451,26 @@ final class RouterTest: XCTestCase { wait(for: [expectation], timeout: 2.0) } + func testHandleReturnsFalseWithoutPrefixWithURLPrefix() { + let router = SimpleRouter(url: URL(string: "https://example.com/")!) + let expectation = self.expectation(description: "Should called handler twice") + expectation.expectedFulfillmentCount = 2 + router.register([ + ("foo/bar", { _ in + expectation.fulfill() + return false + }), + ("foo/:keyword", { context in + XCTAssertEqual(try? context.argument(for: "keyword"), "bar") + expectation.fulfill() + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/foo/bar")!)) + XCTAssertFalse(router.openIfPossible(URL(string: "foo/bar")!)) + wait(for: [expectation], timeout: 2.0) + } + func testWithUserInfo() { struct UserInfo { let value: Int @@ -256,7 +489,25 @@ final class RouterTest: XCTestCase { XCTAssertEqual(userInfo?.value, 42) } - func testWithUserInfoWithoutScheme() { + func testWithUserInfoWithURLPrefix() { + struct UserInfo { + let value: Int + } + let router = Router(url: URL(string: "https://example.com/")!) + var userInfo: UserInfo? + router.register([ + ("https://example.com/static", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/static")!) + userInfo = context.userInfo + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/static")!, userInfo: UserInfo(value: 42))) + XCTAssertFalse(router.openIfPossible(URL(string: "static")!, userInfo: UserInfo(value: 42))) + XCTAssertEqual(userInfo?.value, 42) + } + + func testWithUserInfoWithoutPrefix() { struct UserInfo { let value: Int } @@ -273,4 +524,22 @@ final class RouterTest: XCTestCase { XCTAssertFalse(router.openIfPossible(URL(string: "static")!, userInfo: UserInfo(value: 42))) XCTAssertEqual(userInfo?.value, 42) } + + func testWithUserInfoWithoutPrefixWithURLPrefix() { + struct UserInfo { + let value: Int + } + let router = Router(url: URL(string: "https://example.com/")!) + var userInfo: UserInfo? + router.register([ + ("static", { context in + XCTAssertEqual(context.url, URL(string: "https://example.com/static")!) + userInfo = context.userInfo + return true + }), + ]) + XCTAssertTrue(router.openIfPossible(URL(string: "https://example.com/static")!, userInfo: UserInfo(value: 42))) + XCTAssertFalse(router.openIfPossible(URL(string: "static")!, userInfo: UserInfo(value: 42))) + XCTAssertEqual(userInfo?.value, 42) + } }