This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 10% discount for team members. Become a Subscriber

We implement a type safe and Swift-like routing infrastructure that's pretty different from the common approach of most web frameworks.

0:06 In the previous episode, we looked at setting up the infrastructure of our Swift server-side project. Today we'll continue with routing. Every web app needs to somehow translate a request's path to the correct piece of code. Most web frameworks implement routing in roughly the same way, but we think we can do a bit better in Swift, so we'll try a slightly different solution and see how it goes.

Current Routing Solutions

1:24 Let's look at some sample code. Kitura works like this: a router object is used to specify a path, along with a closure. When that path is requested by the client, the closure gets called by the router. The closure receives a request object, a response object, and a next function. Inside the closure, we do whatever is necessary to handle the request, and at the end, we have to execute the next function Kitura gave us:

import Kitura

let router = Router()

router.get("/") { req, res, next in
    res.send("Homepage")
    next()
}

Kitura.addHTTPServer(onPort: 8090, with: router)
Kitura.run()

2:07 If we run this, we can open the root path, /, in a browser by visiting http://localhost:8090/, and we'll get "Homepage" as the page content. To add another page, we can add a /test route:

router.get("/test") { req, res, next in
    res.send("Test page")
    next()
}

2:39 For something more complicated, we can add a dynamic route that takes a user ID. This route is made up of a static part, /users, followed by a dynamic parameter, :id. We can then look for this ID in the request's parameters dictionary:

router.get("/users/:id") { req, res, next in
    let id = req.parameters["id"] ?? "Invalid id"
    res.send("User profile: \(id)")
    next()
}

3:51 With this path string, there's no real pattern matching, so we could even request the path /users/hello and it still works.

Our Approach to Routing

4:04 In the above example, the router object tries to map a matching string to a closure. The approach we're going to try is slightly different: we'll map the requested path to a Route enum.

4:33 The common approach, like the example above, has a couple of problems. For one, we have to run all the code to get a list of routes. In other words, the router gets constructed at runtime — there's no static list of all possible routes. Second, there's no type safety because the paths are matched with strings. That doesn't feel right for Swift, because we want to be able to specify the user ID is an integer. We also want to retrieve this integer from the route without having to parse a string each time.

5:30 Let's start by defining our routes as an enum. Later on, we'll map a path or URL to this enum. Our implementation doesn't depend on a web stack; we can start developing our routes without yet thinking about requests and responses:

enum Route {
    case home
    case test
    case users(id: Int)
}

6:29 The easiest way to get from a path to a route is a Route initializer that takes a path string. This initializer is failable, because we could receive an unknown or invalid path. We start with some simple if-statements and — not yet parsing an integer from the path — use a temporary constant, 1, for the user ID:

extension Route {
    init?(path: String) {
        if path == "/" {
            self = .home
        } else if path == "/test" {
            self = .test
        } else if path.hasPrefix("/users") {
            self = .users(id: 1)
        } else {
            return nil
        }
    }
}

8:12 Now we can construct routes in our Playground:

Route(path: "/") // .home
Route(path: "/test") // .test
Route(path: "/users/1") // .users(1)
Route(path: "/users/23") // .users(1)

Scanning Path Components

8:43 Let's replace the constant ID with a dynamic integer by parsing the path. We create a specific type of scanner for paths, which converts a path string into a URL and then stores the components of that URL. If the given string doesn't contain a valid URL at all, the initializer fails:

import Foundation

struct PathScanner {
    var components: [String]
    init?(path: String) {
        guard let url = URL(string: path) else {
            return nil
        }
        components = url.pathComponents
    }
}

10:48 PathScanner needs to offer two methods: one to scan constant parts of the path, e.g. "users" or "test", and one to scan dynamic parts, like an ID integer.

11:08 We'll only handle absolute paths, starting with /, so we can check for this in the initializer. If the first component equals the root part, "/", then we can remove it from components and continue. Otherwise, we let it fail:

struct PathScanner {
    var components: [String]
    init?(path: String) {
        // ...
        guard components.first == "/" else {
            return nil
        }
        components.removeFirst()
    }
}

Next we perform a quick check to see that this works:

PathScanner(path: "/")?.components // []
PathScanner(path: "/test")?.components // ["test"]
PathScanner(path: "/users/42")?.components // ["users", "42"]

12:16 Now we should create the methods that scan for constants and dynamic parts.

12:30 In our Route initializer, we use the scanner for the first case, which is the root path. Because the scanner's components property should be made private later, we add an isEmpty property to PathScanner:

struct PathScanner {
    // ...
    var isEmpty: Bool {
        return components.isEmpty
    }
}

// ...

extension Route {
    init?(path: String) {
        guard let scanner = PathScanner(path: path) else { return nil }
        if scanner.isEmpty {
            self = .home
        } // ...
    }
    // ...
}

14:05 To scan the path for a constant string, we add a mutating method that checks whether the first component is equal to the given string. If it is, it removes said component:

struct PathScanner {
    // ...
    mutating func constant(_ component: String) -> Bool {
        guard components.first == component else { return false }
        components.removeFirst()
        return true
    }
    // ...
}

15:41 Because we'll be mutating by scanning, we have to make the route's scanner variable by changing guard let scanner into guard var scanner. Now we can use the scan method to update the "test" case:

extension Route {
    init?(path: String) {
        guard let scanner = PathScanner(path: path) else { return nil }
        if scanner.isEmpty {
            self = .home
        } else if scanner.constant("test") {
            self = .test
        } // ...
    }
    // ...
}

16:09 For the "users" case, we need to scan the dynamic ID. We add a scan method that — if the conversion succeeds — takes the first component out of the array and returns its integer value:

struct PathScanner {
    // ...
    mutating func scan() -> Int? {
        guard let component = components.first, let int = Int(component) else {
            return nil
        }
        components.removeFirst()
        return int
    }
    // ...
}

The method name scan is generic on purpose; we intend to overload the method by adding variations of scan for all return types we need.

18:01 We can now scan the constant "users" and the dynamic ID. This completes the initializer:

extension Route {
    init?(path: String) {
        guard var scanner = PathScanner(path: path) else { return nil }
        if scanner.isEmpty {
            self = .home
        } else if scanner.constant("test") {
            self = .test
        } else if scanner.constant("users"), let id = scanner.scan() {
            self = .users(id: id)
        } else {
            return nil
        }
    }
}

Route(path: "/users/23") // .users(23)

18:13 We should probably check that our path is empty after we're done scanning and before we decide on the route. But for these test cases, it works.

Using Routes

18:23 When using this approach in a big app, we have to somehow prevent the Route enum from massively growing. Also, from looking at our if-statements in the Route initializer, it's apparent it could become rather complicated to handle additional cases. A possible solution would be to nest routes in each other. For example, the enum case .users could have as its associated value another enum that models all the subroutes under "/users/". Having a dedicated enum for each set of subroutes is a way to compartmentalize our app.

19:18 Finally, let's look at using our routes to execute some code in our app. This might happen in a completely different part of the app than where we define and initialize the routes. We add a separate extension that decides what to do with each route. Route gets a method called interpret, which generates a response. We'll use a String as our response type for now:

typealias Response = String

extension Route {
    func interpret() -> Response {
        switch self {
        case .home: return "Homepage"
        case .test: return "Test page"
        case .users(let id): return "User profile: \(id)"
        }
    }
}

20:25 In the switch, we can simply use the associated id integer of the users case. We no longer need to look it up in a parameters dictionary or cast its type because the Route already handled this.

20:50 We can now simply use a route, constructed from a string, to get a response:

Route(path: "/users/23")?.interpret() // "User profile: 23"

21:08 Even cooler: we can stop using strings entirely by simply creating a specific route case. And we don't need a web server for testing this:

Route.users(id: 42).interpret() // "User profile: 42"

Looking Forward

21:34 Obviously, we don't want to put all the code in a switch statement. In a real app, the interpret method will probably delegate each route to other pieces of code. This is the place where we map enum cases to functions that should be called. The advantage of the switch statement is the compiler will help us cover all cases — for example, when we introduce a new route, we'll get a warning to handle this route in the switch.

22:29 We've now added another piece of infrastructure to our server-side app. In a future episode, we'll continue to build functionality on top of this.