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 30% discount for team members. Become a Subscriber

We show our new Swift Talk backend built on top of SwiftNIO by implementing a new team member signup feature.

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 Nodes 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.

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes