00:06 Today we'll have a first look at the implementation of our Swift
Talk backend in Swift! We started rewriting it two years ago, and this version
has been online for a while already.
00:28 We want to show how the backend works, but building it from scratch
would be a bit boring. So instead, we'll start implementing a new feature, and
along the way, we'll explain the different aspects of the backend.
Adding Team Members
00:58 Let's look at the team members page in the account section of the
site. When you want to add a person to your team, you have to enter their GitHub
username:
This isn't ideal because the team manager might not know the username, which
means they'd have to ask the invitee before being able to invite them. We want
to reverse this: we want to show a signup link that can be shared with the
people who may join your team, which will allow the invitees to sign up with
their own GitHub accounts.
01:38 Our first task is to replace the invite form on the team members
page with a signup link. When we dive into the code, we find the
teamMembersView
function that returns the view to be rendered as a Node
— a
recursive enum that represents an HTML node, which can be anything, like an HTML
element, a text, or a comment:
func teamMembersView(addForm: Node, teamMembers: [Row<UserData>]) -> Node {
}
02:23 Inside this function, we find the content definition that's
included in the result. We remove the form element and replace it with a
paragraph node, Node.p
, with a string as its single child. We also add another
paragraph node with a placeholder for the signup link, and we nest the two
paragraphs in a div
for styling:
func teamMembersView(addForm: Node, teamMembers: [Row<UserData>]) -> Node {
let content: [Node] = [
Node.div(classes: "stack++", [
Node.div([
heading("Add Team Member"),
Node.div(classes: "stack", [
Node.p(["To add team members, send them the following signup link:"]),
Node.p(["TODO link"])
])
]),
Node.div([
heading("Current Team Members"),
currentTeamMembers
])
])
]
}
04:06 When we rebuild the project, we see the changed page:
04:21 We can remove the team members form we used to pass into the
teamMembersView
function, as well as the helper that created the form. After
doing this, we get a compiler error about the call site in another part of the
codebase.
05:00 When the server receives a request from the browser, we turn that
request into a Route
— an enum with cases such as the homepage, an episode
page, and the team members page. The interpreter then interprets this enum.
We can think of the interpreter as the controller, while the Node
s are
comparable to views of an iOS app. Having this separation allows us to swap out
the server interpreter with a test interpreter, the latter of which skips all of
the server infrastructure.
05:50 In the interpret code, we have a helper function that creates the
old team member form, but we no longer need this:
extension Route.Account {
private func interpret2<I: Interp>(session sess: Session) throws -> I {
func teamMembersResponse(_ data: TeamMemberFormData? = nil, errors: [ValidationError] = []) throws -> I {
let renderedForm = addTeamMemberForm().render(data ?? TeamMemberFormData(githubUsername: ""), errors)
return I.query(sess.user.teamMembers) { members in
I.write(teamMembersView(addForm: renderedForm, teamMembers: members))
}
}
}
06:23 We remove the helper function, except for its return statement,
which we move inline to where we called the helper:
extension Route.Account {
private func interpret2<I: Interp>(session sess: Session) throws -> I {
switch self {
case .teamMembers:
let url = Route.teamMemberSignup(token: sess.user.data.teamToken).url
return I.query(sess.user.teamMembers) { members in
I.write(teamMembersView(signupURL: url, teamMembers: members))
}
}
}
07:04 We were also using the helper function in the route that deletes a
team member. Instead of calling the helper to create the response, we redirect
back to the team members route:
extension Route.Account {
private func interpret2<I: Interp>(session sess: Session) throws -> I {
switch self {
case .deleteTeamMember(let id):
return I.verifiedPost { _ in
I.query(sess.user.deleteTeamMember(id)) {
let task = Task.syncTeamMembersWithRecurly(userId: sess.user.id).schedule(at: globals.currentDate().addingTimeInterval(5*60))
return I.query(task) {
return I.redirect(to: .account(.teamMembers))
}
}
}
}
}
}
07:20 The object we're returning from, I
, is the response type, and
one of its helper methods is redirect
. We redirect to another route using the
same enum as the one that gets interpreted as a request from the browser. By
only representing internal links with an enum, it's impossible to create an
incorrect internal link; the compiler simply won't let us.
Generating a Signup Token
08:39 The next step is generating a token for the signup link and saving
this token to the database.
We've chosen to use PostgreSQL for our database, and we write our SQL queries
manually (with the exception of a few helpers we use to perform some simple
queries). We prefer writing a few queries over adding a large abstraction layer
that may hide some of the many useful features of SQL.
09:09 An array of queries makes up our database migrations, and we add a
migration that adds a column for team tokens to the users table:
fileprivate let migrations: [String] = [
"""
ALTER TABLE users ADD COLUMN IF NOT EXISTS team_token uuid DEFAULT public.uuid_generate_v4();
"""
]
10:04 Since we'll be looking up tokens from the database later on, we
also add a token index:
fileprivate let migrations: [String] = [
"""
CREATE INDEX IF NOT EXISTS team_token_index ON users (team_token);
"""
]
10:33 Each time the server starts, all of the migrations are run. This
requires us to pay attention and write our queries in such a way that they can
be safely executed many times — note the IF NOT EXISTS
conditions in the two
examples above.
11:38 We run the server and, not having received any errors, we conclude
that the migrations have been performed successfully. So we can now also add the
team token to our user model.
Updating the Model
11:59 We use Codable
to automatically generate queries from a struct
(something we covered in a previous
episode),
and also to parse query results back into this struct. Each table is represented
by a struct, and we also have some structs for specific queries.
12:35 With all that in place, we now just have to add a teamToken
to
the user struct in order to access the token stored in the database:
struct UserData: Codable, Insertable {
var email: String
var githubUID: Int?
var teamToken: UUID
init(email: String, githubUID: Int? = nil, , teamToken: UUID = UUID()) {
self.email = email
self.githubUID = githubUID
self.teamToken = teamToken
}
static let tableName = "users"
}
13:06 When we run the server and reload the page in the browser, a team
token should have been loaded from the database into our user data. But we have
no way of knowing, because we're not yet using the token.
13:37 In order to show the signup link, we have to first create a route
for it, so we take a look at the Route
enum and its nested enums:
indirect enum Route: Equatable {
case home
case episodes
case sitemap
case subscribe
case collections
case login(continue: Route?)
case account(Account)
enum Account: Equatable {
case register(couponCode: String?)
case profile
case teamMembers
}
}
14:15 The new route we're creating will be similar to the .subscribe
route, with the addition of using a team token in the signup process. We add a
new case, called .teamMemberSignup
, with a token as its associated value:
indirect enum Route: Equatable {
case subscribe,
case teamMemberSignup(token: UUID),
}
14:39 We simply store the parameters of a Route
in the correct type,
like UUID
here, as long as we're able to convert the type from and to a
request. By the time we're in one of the interpreting functions, we already have
all the parameters we need to process the request.
15:15 We wrote a (slightly complicated) library to support the Route
enum, and we won't go into much detail, but adding a new Route
essentially
comes down to specifying how to convert a request to that Route
and how to
convert the Route
back to a URL
.
16:10 We do so by providing the router with these two transformations.
We first use the constant helper, c
, to tell the router that the URL for this
route begins with the string "join_team"
. Then, for the token parameter, we
use the /
operator, followed by the Router.uuid
helper, which takes two
functions. The first function receives a parsed UUID
and has to return the
Route
, and the second one receives a Route
and has to return the UUID
value if it's actually the route we expect:
private let otherRoutes: [Router<Route>] = [
.c("join_team") / Router.uuid.transform({ .teamMemberSignup(token: $0) }, { route in
guard case let .teamMemberSignup(token) = route else { return nil }
return token
})
]
18:27 Because the library does most of the work of parsing requests
(including the parameters) and generating URLs, the main focus has shifted to
the conversions between the UUID
parameter and the Route
.
19:00 After adding a new Route
, we have to handle it in the
interpreter. The compiler reminds us of this fact because the switch statement
in the interpret
function is no longer exhaustive. We add the case and, for
now, simply write a string to the response:
extension Route {
func interpret<I: Interp>() throws -> I {
switch self {
case let .teamMemberSignup(token: token):
return I.write("team signup \(token)")
}
}
}
19:56 Before we can get to the route, we have to show the signup URL on
the team members page, so we add a URL parameter to the teamMembersView
helper:
func teamMembersView(signupURL: URL, teamMembers: [Row<UserData>]) -> Node {
}
20:17 We remove our placeholder and insert the URL. Before, we used a
string literal for a paragraph's child node, which is allowed because the node
type implements StringLiteralConvertible
. But now we want to use a string
property by wrapping it in a .text
node. We also specify a CSS class to give
the link a monospaced font:
func teamMembersView(signupURL: URL, teamMembers: [Row<UserData>]) -> Node {
let content: [Node] = [
Node.div(classes: "stack++", [
Node.div([
heading("Add Team Member"),
Node.div(classes: "stack", [
Node.p(["To add team members, send them the following signup link:"]),
Node.p(classes: "type-mono", [.text(signupURL.absoluteString)])
])
]),
])
]
}
21:19 When we try to run the server, the view helper complains about
the fact that we're not yet passing in a signup URL, so we take the URL from the
route we just added:
extension Route.Account {
private func interpret2<I: Interp>(session sess: Session) throws -> I {
switch self {
case .teamMembers:
let url = Route.teamMemberSignup(token: sess.user.data.teamToken).url
return I.query(sess.user.teamMembers) { members in
I.write(teamMembersView(signupURL: url, teamMembers: members))
}
}
}
}
22:05 When we run the server again and refresh, we see the signup link
on the team members page:
22:11 We copy the URL and open it in the browser to see the response we
wrote earlier:
22:24 We can try messing with the URL and removing a character from the
token; this results in a "page not found" error. That's because the router tries
to parse the string "join_team"
and a UUID, and if it can't, there's no route
that matches with the URL.
It's a good first check that the route only works with a valid UUID. However,
we're not yet checking whether the requested UUID is in fact a valid token in
the database.
Discussion
23:22 So far, we've already seen a few different parts of our backend's
infrastructure: we modified a view, we added a database migration and updated
our database model, and we added a new route and a minimal response for it.
23:39 Everything is built directly on top of
SwiftNIO. Not using any other frameworks
in between makes some parts, like driving the database, pretty bare bones. But
this also helps us remain efficient: we can write exactly the queries we need.
And SQL itself is a high-level language, which we couldn't write better
ourselves.
24:42 In the upcoming episodes, we'll finish the team token signup
flow, for which we'll have to query the database. We'll also add a button to
invalidate the signup link by generating a new token, and we'll write a few
tests at some point.