Share
Embed
Type-safe URL Routing in Swift
Functional Swift Conference 2016 (Oct 1, 2016)
http://2016.funswiftconf.com/
Library: https://github.com/inamiy/FunRouter
Yasuhiro Inami
October 01, 2016
Transcript
48 slides
-
URL routings everywhere! • Client app • Universal Links (>=
iOS 9) • Custom URL Schemes (<= iOS 8) • Server-side • Perfect, Kitura, Zewo, Vapor, etc • Mostly, Node.js Express-like -
Client-side (example) enum Sitemap { case hello(city: String, year: Int)
// "/Hello/{city}/{year}" case notFound } struct Router { func match(path: String) -> Sitemap { if let captures: [String] = myRegex("/Hello/(.+)/(\\d+)", path), captures.count == 2, let year = Int(captures[1]) { return .hello(captures[0], year) } return .notFound } } -
func application( _ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping
([Any]?) -> () ) -> Bool { // iOS Universal Links only guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return false } let sitemap = self.router.match(url.path) switch sitemap { case let .hello(city, year): self.presentAwesomeOpeningMovie(city, year) return true case .notFound: print("Invalid URL.") return false } } -
Server-side (example) // Perfect routes.add(method: .get, uri: "/Hello/{city}/{year}", handler: requestHandler)
// Zewo route.get("/Hello/:city/:year") { request in guard let city: String = request.pathParameters["city"], let year: String = request.pathParameters["year"] else { ... } /* Warning: All pathComponents are captured in String! */ ... } // Vapor drop.get("Hello", String.self, Int.self) { (request, city: String, year: Int) in ... // Wow, type-safe!? } -
// Vapor routing APIs // ⚠ AUTOMATICALLY GENERATED CODE ///
GET /<path>/ public func get(_ p0: String = "", handler: (Request) throws -> ResponseRepresentable) /// GET /{wildcard}/ public func get<W0: StringInitializable>(_ w0: W0.Type, handler: (Request, W0) throws -> ResponseRepresentable) /// GET /<path>/{wildcard}/ public func get<W0: StringInitializable>(_ p0: String, _ w0: W0.Type, handler: (Request, W0) throws -> ResponseRepresentable) /// GET /<path>/<path>/ public func get(_ p0: String, _ p1: String, handler: (Request) throws -> ResponseRepresentable) /// GET /{wildcard}/<path>/{wildcard}/ public func get<W0: StringInitializable, W1: StringInitializable>(_ w0: W0.Type, _ p0: String, _ w1: W1.Type, handler: (Request, W0, W1) throws -> ResponseRepresentable) -
Route type /// Function-based URL parser. public struct Route<Value> {
let parse: ([String]) -> ([String], Value)? } • Container of state-transforming function • Input: pathComponents as [String] • Output: Optional tuple of "Value" & "remaining input" -
// MARK: Functor public func <^> <Value1, Value2>( f: @escaping
(Value1) -> Value2, route: Route<Value1> ) -> Route<Value2> { return Route { pathComponents in let reply = route.parse(pathComponents) switch reply { case let .some(pathComponents2, value): return (pathComponents2, f(value)) case .none: return nil } } } -
// MARK: Applicative public func pure<Value>(_ value: Value) -> Route<Value>
{ return Route { ($0, value) } } public func <*> <Value1, Value2>( route1: Route<(Value1) -> Value2>, route2: @autoclosure @escaping () -> Route<Value1> ) -> Route<Value2> { return Route { pathComponents in let reply = route1.parse(pathComponents) switch reply { case let .some(pathComponents2, f): return (f <^> route2()).parse(pathComponents2) case .none: return nil } } } -
// MARK: Applicative public func <**> <Value1, Value2>( route1: Route<Value1>,
route2: @autoclosure @escaping () -> Route<(Value1) -> Value2> ) -> Route<Value2> { return { a in { $0(a) } } <^> route1 <*> route2 } public func <* <Value1, Value2>( route1: Route<Value1>, route2: @autoclosure @escaping () -> Route<Value2> ) -> Route<Value1> { return const <^> route1 <*> route2 } public func *> <Value1, Value2>( route1: Route<Value1>, route2: @autoclosure @escaping () -> Route<Value2> ) -> Route<Value2> { return const(id) <^> route1 <*> route2 } -
// MARK: Alternative public func empty<Value>() -> Route<Value> { return
Route { _ in nil } } public func <|> <Value>( route1: Route<Value>, route2: @autoclosure @escaping () -> Route<Value> ) -> Route<Value> { return Route { pathComponents in let reply = route1.parse(pathComponents) switch reply { case .some: return reply case .none: return route2().parse(pathComponents) } } } -
// MARK: Monad // Comment-Out: // We don't need `Monad`
because when we make // a URL route chaining, previous route doesn't // affect the next route (context-free grammar). // // In other word, there is no need to create // a new `Route` at runtime, // and only applying existing routes // one by one sequentially is sufficient. -
public func capture<Value>(_ parse: @escaping (String) -> Value?) -> Route<Value>
{ return Route { pathComponents in if let (first, rest) = uncons(pathComponents), let parsed = parse(first) { return (Array(rest), parsed) } return nil } } public func int() -> Route<Int> { return capture { Int($0) } } public func match(_ string: String) -> Route<()> { return capture { $0 == string ? () : nil } } public func choice<Value>(_ routes: [Route<Value>]) -> Route<Value> { return routes.reduce(empty()) { $0 <|> $1 } } -
/// Parses "/Hello/{city}/{year}". let route: Route<(String, Int)> = curry {
($1, $2) } <^> match("Hello") <*> string() <*> int() // or, more fancy flipped way: // let route = // /"Hello" </> string() </> int() <&!> flipCurry { ($1, $2) } let value1 = route.run(["Hello", "Budapest", "2016"]) expect(value1?.0) == "Budapest" expect(value1?.1) == 2016 let value2 = route.run(["Ya", "tu", "sabes"]) expect(value2).to(beNil()) -
/// - "/R/foo/{int}" ==> `Sitemap.foo(int)` /// - "/R/bar/{double}" ==> `Sitemap.bar(double)`
/// - "/R/baz/{string}" ==> `Sitemap.baz(string)` /// - "/R/foo/bar" ==> `Sitemap.fooBar` /// - otherwise ==> `Sitemap.notFound` let route: Route<Sitemap> = match("R") *> choice([ match("foo") *> int() <&> Sitemap.foo, match("bar") *> double() <&> Sitemap.bar, match("baz") *> string() <&> Sitemap.baz, match("foo") *> match("bar") *> pure(Sitemap.fooBar), ]) <|> pure(Sitemap.notFound) // `<&>` = flipped map = `flip(<^>)` -
// (continued) let route: Route<Sitemap> = match("R") *> choice([ match("foo")
*> int() <&> Sitemap.foo, match("bar") *> double() <&> Sitemap.bar, match("baz") *> string() <&> Sitemap.baz, match("foo") *> match("bar") *> pure(Sitemap.fooBar), ]) <|> pure(Sitemap.notFound) expect(route.run(["R", "foo", "123"])) == .foo(123) expect(route.run(["R", "bar", "4.5"])) == .bar(4.5) expect(route.run(["R", "baz", "xyz"])) == .baz("xyz") expect(route.run(["R", "foo", "bar"])) == .fooBar expect(route.run(["R", "foo", "xxx"])) == .notFound -
// Naive choice. choice([ match("foo") *> int() <&> Sitemap.foo, match("bar")
*> double() <&> Sitemap.bar, match("baz") *> string() <&> Sitemap.baz, match("foo") *> match("bar") *> pure(Sitemap.fooBar), ]) 1. Check "foo" + int 2. Check "bar" + double 3. Check "baz" + string 4. Check "foo" + "bar" ... Checking "foo" twice! ! -
We rather want... // Optimized choice. choice([ match("foo") *> choice([
int() <&> Sitemap.foo, match("bar") *> pure(Sitemap.fooBar) ]), match("bar") *> double() <&> Sitemap.bar, match("baz") *> string() <&> Sitemap.baz ]) -
New Route type (Enum-based) /// Enum-based URL route. public indirect
enum Route<Value> { case match(String, Route<Value>) case capture((String) -> Any?, (Any) -> Route<Value>) case choice([Route<Value>]) case term(Value) case zero } • .match contains next Route • .capture contains "parsing" and "converting" functions -
extension Route { public func run(_ pathComponents: [String]) -> Value?
{ switch self { case let .match(string, route): ... case let .capture(parse, convert): ... case let .choice(routes): ... case let .term(value): return value case .zero: return nil } } } // Implement `run`, Functor, Applicative, Alternative... -
public func optimize<Value>(_ route: Route<Value>) -> Route<Value> { switch route
{ case let .match(string, route): return .match(string, optimize(route)) case let .capture(parse, convert): return .capture(parse, { optimize(convert($0)) }) case let .choice(routes): return _optimizeChoice(routes) case .term, .zero: return route } } -
private func _optimizeChoice<Value>( _ routes: [Route<Value>] ) -> Route<Value> {
if routes.isEmpty { return .zero } // Sort, group by the same route-head, and flatten. let sortedRoutes = routes.sorted(by: isAscending) let arrangedRoutes = groupBy(isSameRouteHead)(ArraySlice(sortedRoutes)) .map(flatten) return choice(arrangedRoutes) } -
// Before optimization (duplicated `match("foo")`) let naiveRoute: Route<Sitemap> = match("R")
*> choice([ match("foo") *> int() <&> Sitemap.foo, match("bar") *> double() <&> Sitemap.bar, match("baz") *> string() <&> Sitemap.baz, match("foo") *> match("bar") *> pure(Sitemap.fooBar) ]) // After optimization let optimizedRoute = optimize(naiveRoute) -
Result: naiveRoute = .match("R", .choice([ .match("foo", .capture(int)), .match("bar", .capture(double)), .match("baz",
.capture(string)), .match("foo", .match("bar", .term(fooBar))) ])) optimizedRoute = .match("R", .choice([ .match("bar", .capture(double)), .match("baz", .capture(string)), .match("foo", .choice([ .capture(int), .match("bar", .term(fooBar)) ])) ])) -
Summary • String-based routing is easy, but type-unsafe • Dive
into Monadic Applicative parser • Optimization is possible with interpretable enum pattern • Caveat • Too easy to get error: "Expression was too complex to be solved in reasonable time." • Swift 4 will surely solve this problem!