Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Type-safe URL Routing in Swift

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
Tweet

More Decks by Yasuhiro Inami

Other Decks in Programming

Transcript

  1. 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
  2. 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 } }
  3. 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 } }
  4. 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!? }
  5. // 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)
  6. !

  7. 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"
  8. // 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 } } }
  9. // 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 } } }
  10. // 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 }
  11. // 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) } } }
  12. // 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.
  13. 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 } }
  14. /// 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())
  15. /// - "/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(<^>)`
  16. // (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
  17. // 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! !
  18. 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 ])
  19. 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
  20. 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...
  21. 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 } }
  22. 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) }
  23. // 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)
  24. 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)) ])) ]))
  25. 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!