We build the basic routing infrastructure to map paths to the code that should handle a particular request. We take a different approach than most web frameworks in an attempt to make the code type safe and more at home in Swift.
0:06 In the previous
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:
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
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
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:
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:
10:48PathScanner 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
12:16 Now we should create the methods that scan for constants and
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:
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:
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.
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:
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.