-
-
Save tikitu/33c80b5686dcf4e80b4bb04225190b53 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//: Playground - noun: a place where people can play | |
// | |
// main.swift | |
// RoutingApproaches | |
// | |
// Created by Chris Eidhof on 01.08.18. | |
// Copyright © 2018 objc.io. All rights reserved. | |
// | |
import Foundation | |
// User-defined type describing the destinations in the app. | |
// Desiderata: | |
// 1. Parsing: from a valid path-string get to a destination | |
// 2. Pretty-printing: from a destination get its canonical path-string | |
// (NB: the same destination might be reachable in multiple ways: we must choose one!) | |
// 3. Well-typed: a path leading to a destination must provide the destination's data | |
// 4. Exhaustivity: automated check that all destinations are reachable via some path-string | |
enum Page { | |
case home | |
case episodes | |
case contact | |
case deep | |
case episode(String) | |
case two(Int, Int) | |
} | |
// Some examples: | |
print(Page.home.pretty) | |
print(Page.episodes.pretty) | |
print(Page.contact.pretty) | |
print(Page.deep.pretty) | |
print(Page.episode("the-one-about-routing").pretty) | |
Page("home") | |
Page("episodes") | |
Page("about/contact") | |
Page("episode/the-cool-one") | |
Page("two/5/test/6") | |
Page("deep/deep/link") | |
// 1. Parsing (failable of course) | |
extension Routable where Routes.Value == Self { | |
init?(_ p: String) { | |
var c = p.split(separator: "/", omittingEmptySubsequences: false).map(String.init) | |
guard let x = Self.routes.parse(&c) else { return nil } | |
self = Self.Routes.value(x) | |
} | |
} | |
// 2. Pretty-printing the canonical path to a destination | |
extension Routable { | |
var pretty: String { | |
return Self.routes.pretty(canonical).joined(separator: "/") | |
} | |
} | |
extension Page: Routable { | |
// Here we define the routes; at least one per destination. More is also fine: a destination | |
// can be reachable by multiple routes without any extra boilerplate beyond the <|>-case here. | |
static var routes: Routes { | |
return | |
"home" ~ lift(Page.home) | |
<|> "episodes" ~ lift(Page.episodes) | |
<|> "episode" </> string ~ lift(Page.episode) | |
// 3. Well-typed: we cannot accidentally swap Page.episode and Page.episodes here: | |
// the type on the RHS of ~ must consume the parameters provided on the LHS and | |
// produce a value of type Self. | |
<|> "about" </> "contact" ~ lift(Page.contact) | |
<|> "deep" </> "deep" </> "link" ~ lift(Page.deep) | |
<|> "two" </> int </> "test" </> int ~ lift(Page.two) | |
} | |
// 4. Exhaustivity check. We are reminded when we add a case to Pages that we need a canonical | |
// path for it. Nothing in fact forces us to make the path *to that destination* (e.g. we could | |
// define | |
// case .home: return .left(.bind((), lift(Page.episodes))) | |
// The type-checking does ensure that the path we give is type-compatible with the Page case, and | |
// writing unit tests to show that the canonical path *does* go to the right destination is very | |
// easy: | |
// XCTAssert(Page(Page.home.pretty) == Page.home) | |
// The pattern for writing these is very regular (which is not to say simple): the prefix of | |
// .lefts and .rights finds the <|>-case, then the first argument to bind() gives the path-structure | |
// and the second (inside lift() to pull out only the interesting parts of the path type) gives the | |
// destination case. | |
var canonical: Routes.Path { | |
switch self { | |
case .home: return .left(.bind((), lift(Page.home))) | |
case .episodes: return .right(.left(.bind((), lift(Page.episodes)))) | |
case .episode(let slug): return .right(.right(.left(.bind(((), slug), lift(Page.episode))))) | |
case .contact: return .right(.right(.right(.left(.bind(((), ()), lift(Page.contact)))))) | |
case .deep: return .right(.right(.right(.right(.left(.bind(((), ((), ())), lift(Page.deep))))))) | |
case .two(let a, let b): return .right(.right(.right(.right(.right(.bind(((), (a, ((), b))), lift(Page.two))))))) | |
} | |
} | |
// This type can be inferred from `routes` and copy-pasted. It's a description of the routes. | |
typealias Routes = | |
Sum<Bind<Constant, (), Page>, Sum<Bind<Constant, (), Page>, Sum<Bind<Prod<Constant, P<String>>, ((), String), Page>, Sum<Bind<Prod<Constant, Constant>, ((), ()), Page>, Sum<Bind<Prod<Constant, Prod<Constant, Constant>>, ((), ((), ())), Page>, Bind<Prod<Constant, Prod<P<Int>, Prod<Constant, P<Int>>>>, ((), (Int, ((), Int))), Page>>>>>> | |
} | |
/* | |
Some funky stuff I haven't explored in much detail. Looks like it probably should work but | |
please don't bet the farm on it. | |
Path variations: | |
Constant("a") </> (Constant("b") <|> Constant("c")) </> Constant("d") ~ lift(destination) | |
This would match a/b/d and a/c/d, just as you would expect. | |
Unfortunately the binding fixes the type of the entire path, so these variations can change the | |
constant *values* but not the number of path elements. If you want different-length paths (or | |
different arrangements of constants-and-parameters) you'll need to add full <|>-cases. | |
Intermediate bindings: | |
Constant("user") </> (int ~ lift(Database.user(id:)) </> "profile" ~ lift(Page.profile) | |
should work if Page has a case profile(User). | |
*/ | |
/******************* Here starts the magic that makes it all possible **************************/ | |
// A *description* of a choice between two routes A and B | |
struct Sum<A, B> { | |
let l: A | |
let r: B | |
init(_ l: A, _ r: B) { | |
self.l = l | |
self.r = r | |
} | |
} | |
// A *description* of a two parts of a route in sequence | |
struct Prod<A, B> { | |
let a: A | |
let b: B | |
init(_ a: A, _ b: B) { | |
self.a = a | |
self.b = b | |
} | |
} | |
// A description of a constant label | |
struct Constant { | |
let name: String | |
init(_ name: String) { | |
self.name = name | |
} | |
} | |
extension Constant: ExpressibleByStringLiteral { | |
init(stringLiteral name: String) { | |
self.name = name | |
} | |
} | |
// A binding of a route to a destination value | |
struct Bind<R, P, V> { // Route, Params, Value | |
let r: R | |
let f: (P) -> V | |
} | |
// A description of a single parameter A | |
struct P<A> { } | |
// A route representing () | |
struct Unit {} | |
enum Either<A, B> { case left(A); case right(B) } | |
// All of Sum, Prod, Label, K and Unit conform to Endpoint | |
protocol Endpoint { | |
associatedtype Path | |
associatedtype Value | |
func parse(_: inout [String]) -> Path? | |
func pretty(_: Path) -> [String] | |
static func value(_: Path) -> Value | |
} | |
extension Sum: Endpoint where A: Endpoint, B: Endpoint, A.Value == B.Value { | |
typealias Path = Either<A.Path, B.Path> | |
typealias Value = A.Value | |
func parse(_ p: inout [String]) -> Either<A.Path, B.Path>? { | |
let saved = p | |
if let x = l.parse(&p), p.isEmpty { | |
return .left(x) | |
} else { | |
p = saved | |
return r.parse(&p).map(Either.right) | |
} | |
} | |
func pretty(_ x: Either<A.Path, B.Path>) -> [String] { | |
switch x { | |
case let .left(x): return l.pretty(x) | |
case let .right(x): return r.pretty(x) | |
} | |
} | |
static func value(_ result: Either<A.Path, B.Path>) -> Value { | |
switch result { | |
case .left(let a): | |
return A.value(a) | |
case .right(let b): | |
return B.value(b) | |
} | |
} | |
} | |
extension Constant: Endpoint { | |
typealias Path = Void | |
typealias Value = Void | |
func parse(_ p: inout [String]) -> Path? { | |
guard p.first == name else { return nil } | |
p.removeFirst() | |
return () | |
} | |
func pretty(_ x: Void) -> [String] { | |
return [name] | |
} | |
static func value(_ x: Path) -> Value { | |
return x | |
} | |
} | |
extension P: Endpoint where A: Param { | |
typealias Path = A | |
typealias Value = A | |
func parse(_ p: inout [String]) -> Path? { | |
return A.parse(&p) | |
} | |
func pretty(_ x: A) -> [String] { | |
return x.pretty() | |
} | |
static func value(_ x: Path) -> Value { | |
return x | |
} | |
} | |
extension Prod: Endpoint where A: Endpoint, B: Endpoint { | |
typealias Path = (A.Path, B.Path) | |
typealias Value = (A.Value, B.Value) | |
func parse(_ p: inout [String]) -> Path? { | |
let state = p | |
guard let x = a.parse(&p), let y = b.parse(&p) else { | |
p = state | |
return nil | |
} | |
return (x,y) | |
} | |
func pretty(_ x: Path) -> [String] { | |
return a.pretty(x.0) + b.pretty(x.1) | |
} | |
static func value(_ x: Path) -> Value { | |
return (A.value(x.0), B.value(x.1)) | |
} | |
} | |
extension Unit: Endpoint { | |
typealias Path = () | |
typealias Value = () | |
func parse(_: inout [String]) -> ()? { | |
return () | |
} | |
func pretty(_: ()) -> [String] { | |
return [] | |
} | |
static func value(_: ()) -> () { | |
return () | |
} | |
} | |
extension Bind: Endpoint where R: Endpoint, R.Value == P { | |
typealias Path = Bind<R.Path, R.Value, V> | |
typealias Value = V | |
func parse(_ s: inout [String]) -> Path? { | |
let rollback = s | |
guard let bound = r.parse(&s) else { | |
s = rollback | |
return nil | |
} | |
return Bind<R.Path, R.Value, V>(r: bound, f: f) | |
} | |
func pretty(_ from: Path) -> [String] { | |
return r.pretty(from.r) | |
} | |
static func value(_ route: Path) -> Value { | |
return route.f(R.value(route.r)) | |
} | |
} | |
// Parameters | |
protocol Param { | |
static func parse(_ p: inout [String]) -> Self? | |
func pretty() -> [String] | |
} | |
extension Int: Param { | |
static func parse(_ p: inout [String]) -> Int? { | |
guard let x = p.first, let value = Int(x) else { return nil } | |
p.removeFirst() | |
return value | |
} | |
func pretty() -> [String] { | |
return ["\(self)"] | |
} | |
} | |
extension String: Param { | |
static func parse(_ p: inout [String]) -> String? { | |
guard let x = p.first else { return nil } | |
p.removeFirst() | |
return x | |
} | |
func pretty() -> [String] { | |
return [self] // todo escape | |
} | |
} | |
// In order for a type (e.g. YourPages) to be Routable, it needs a static record of | |
// the routes available, and to be able to convert each of its own members to a | |
// corresponding route. | |
protocol Routable { | |
associatedtype Routes: Endpoint | |
static var routes: Routes { get } | |
var canonical: Routes.Path { get } | |
} | |
// Combinators: | |
precedencegroup Sum { | |
associativity: right | |
} | |
precedencegroup Bind { | |
higherThan: Sum | |
associativity: none | |
} | |
precedencegroup Prod { | |
higherThan: Bind | |
associativity: right | |
} | |
infix operator <|>: Sum | |
func <|><A,B>(lhs: A, rhs: B) -> Sum<A, B> | |
where A: Endpoint, B: Endpoint, A.Value == B.Value { | |
return Sum(lhs, rhs) | |
} | |
infix operator </>: Prod | |
func </><A,B>(lhs: A, rhs: B) -> Prod<A, B> | |
where A: Endpoint, B: Endpoint { | |
return Prod(lhs, rhs) | |
} | |
infix operator ~: Bind | |
func ~<A, B, C>(lhs: A, rhs: @escaping (B) -> C) -> Bind<A, B, C> | |
where A: Endpoint, A.Value == B { | |
return Bind(r: lhs, f: rhs) | |
} | |
let string = P<String>() | |
let int = P<Int>() | |
extension Bind { | |
static func bind(_ r: R, _ v: @escaping (P) -> V) -> Bind { return Bind(r: r, f: v) } | |
} | |
// Yes we need lift functions for every path-structure we support. That's the only | |
// way I can see to extract the parameter values from the path for type-checking with | |
// the destination function. At least these are sorta sensible-looking though. | |
// No, these aren't anything like enough lift functions. I got sick of typing them very quickly. | |
func lift<A>(_ a: A) -> (()) -> A { return { _ in a } } | |
func lift<A>(_ a: A) -> (((), ())) -> A { return { _ in a } } | |
func lift<A>(_ a: A) -> (((), ((), ()))) -> A { return { _ in a } } | |
func lift<A: Param, B>(_ f: @escaping (A) -> B) -> (A) -> B { return f } // hoho (let's *always* add lift()) | |
func lift<A: Param, B>(_ f: @escaping (A) -> B) -> (((), A)) -> B { return { f($0.1) } } | |
func lift<A: Param, B: Param, C>(_ f: @escaping (A, B) -> C) -> (((), (A, ((), B)))) -> C { | |
return { f($0.1.0, $0.1.1.1) } // autocomplete really helps with these... | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment