00: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
01: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()
02: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()
}
02: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()
}
03: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
04: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.
04: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.
05: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)
}
06: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
}
}
}
08:12 Now we can construct routes in our Playground:
Route(path: "/") Route(path: "/test") Route(path: "/users/1") Route(path: "/users/23")
Scanning Path Components
08: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 PathScanner(path: "/users/42")?.components
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")
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()
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()
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.