Skip to content

Instantly share code, notes, and snippets.

@tikitu
Forked from chriseidhof/type-safe-routing.swift
Last active August 4, 2018 07:43
Show Gist options
  • Save tikitu/33c80b5686dcf4e80b4bb04225190b53 to your computer and use it in GitHub Desktop.
Save tikitu/33c80b5686dcf4e80b4bb04225190b53 to your computer and use it in GitHub Desktop.
//: 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