Introduction to “Swift on Server”

Vapor Code

Why Swift?

To quote the Swift on Server website:

The goal of the Swift project is to create the best available language for uses ranging from systems programming, to mobile and desktop apps, scaling up to cloud services.

Strict typing, async/await and actors are key features that make writing and maintaining correct programs easier. Some characteristics that make Swift especially suited for server applications are its small footprint in both CPU and memory, quick startup time and deterministic performance, thanks to ARC and the lack of JIT. Compared to node.js it can reduce system load up to 90%. It also enables iOS developers, who are already familiar with the Swift language, to easily double as backend developers.

Shared DTOs

In the context of Client-Server applications, we gain another advantage. Code can be shared between the server and the client application. By sharing e.g. the data transfer objects (DTOs), we make our API type-safe. The easiest way is to create a separate Swift package that can then be imported in both places.

Why Vapor?

There are a lot of frameworks for using serverside Swift. Vapor is the most popular. It is built on SwiftNIO, so profits from improvements coming from Apple or the Swift Server Group. It has a very swifty API and a focus on simplicity and composability. Vapor has great documentation and supports unit testing both in memory and live.

Example

Let’s look at an example of a basic Vapor app with a single endpoint.

import Vapor
 
let app = try Application(.detect())
defer { app.shutdown() }

app.get("hello") { req in
    return "Hello, world."
}

try app.run()

Command Line

To create a new project, we can use the vapor command line tool, which can be installed via Homebrew.

vapor new Demo

The tool guides us through the setup process and allows us to select additional frameworks like Fluent, to which we will come later.

Content

Vapor’s content API is based on Codable. Conforming a struct to Content allows it to be used when decoding requests or encoding responses.

struct User: Content {
    let username: String
    let email: String
}

In addition to JSON, Vapor supports Multipart, URL-Encoded Form, Plaintext and HTML. Speaking of HTML, Vapor even has its own templating language with a Swift-inspired syntax called Leaf and there are content management systems based on Vapor, but I won’t be covering that in this blog post.

Routes

Routes define how HTTP requests are handled. Like in the example at the top, we can add a route by prodiving a closure.

app.get("hello") { req in
    return "Hello, world."
}

Parameters are marked by a prefixed :.

app.get("hello", ":name") { req -> String in
    let name = req.parameters.get("name")!
    return "Hello, \(name)!"
}

In addition, routing supports anything (*) and catchall (**) path components.
We can access the body of a request using the content property.

let user = try req.content.decode(User.self)

The power of Vapor’s routing approach comes in its composability. For example, we can group routes under a common prefix.

let users = app.grouped("users")

users.get { req in /*...*/ }
users.post { req in /*...*/ }
users.delete(":id") { req in /*...*/ }

Grouping can also be used to insert middleware for rate limiting or authentification. More on that later.

Fluent

Fluent is an Object-relational mapping framework. It supports PostgreSQL, SQLite, MySQL, MongoDB and more.

Model

We describe our model using regular Swift classes, that conform to Model. Keys are defined using property wrappers.

final class User: Model {
    static let schema = "users"
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key: "username")
    var username: String
    
    @Field(key: "email")
    var email: String
}

We can even define parent/children or sibling relationships directly in the model class, which allows easier access.

final class Planet: Model {
    // ...
    
    @Parent(key: "star_id")
    var star: Star
}

final class Star: Model {
    // ...
    
    @Children(for: \.$star)
    var planets: [Planet]
}

We could conform the model to Content, to use it directly in our API, but since we have a shared package for the DTOs, this isn’t necessary.

Migration

Migrations act like version control for the database. Inside a migration, we can setup and modify one or multiple schemas. In addition, we can add a way to undo the changes.

struct Create: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("users")
            .id()
            .field("username", .string, .required)
            .field("email", .string, .required)
            .unique(on: "username")
            .create()
    }
    
    func revert(on database: Database) async throws {
        try await database.schema("users").delete()
    }
}

We then add that migration to the application’s migration list (app.migrations.add), which allows us to call the migration from the command line.

vapor run migrate

Remember to always do that when changing the schema. Alternatively, you can auto-migrate at the start via the --auto-migrate flag or by calling try await app.autoMigrate().

Working with models

Actually adding a model to the database is as easy as instantiating a class and calling the create(on:) function while providing a database.

let user = try User(
    username: dto.username,
    email: dto.email
)
try await user.create(on: req.db)

Query

Fluent’s query API is similar to Swift’s collection API. For example, to search for a user with a particular username, we can execute the following query.

let user = try await User.query(on: req.db)
    .filter(\.$username == "myuser")
    .first()

If we already know the ID of the model object, we can simply call the find function.

let user = try await User.find(id, on: req.db)

Fluent supports a variety of value operators for its filter function. We can e.g. filter for all users, whose username starts with an “m”.

let users = try await User.query(on: req.db)
    .filter(\.$username =~ "m")
    .all()

Fluent also supports joins, chunking, aggregates and more.

Middleware

Middleware can be inserted between the client and a Vapor route handler. A middleware can perform operations on incoming requests and on outgoing responses. Typical use cases for a middleware are rate limiting or user authentification.

Authentification

Fluent contains a built-in middleware to handle user authentification. To use it, we conform the User model to ModelAuthenticatable by creating key paths to the username and the password hash and by implementing the body of the verify(password:) function. For the cryptography we use Bcrypt, which comes bundled with Vapor.

final class User: Model {
    // ...
    
    @Field(key: "password_hash")
    var passwordHash: String
}

extension User: ModelAuthenticatable {
    static let usernameKey = \User.$username
    static let passwordHashKey = \User.$passwordHash
    
    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}

When creating a new user, we save the password hash in the user model. Bcrypt automatically handles salting, so we don’t have to worry about that.

let user = try User(
    username: dto.username,
    email: dto.email,
    passwordHash: Bcrypt.hash(dto.password)
)
try await user.create(on: req.db)

Now, we can insert the middleware into our route.

let passwordProtected = routes.grouped(User.authenticator())

passwordProtected.post("login") { req -> String in
    let user = try req.auth.require(User.self)
    // ...
}

When a client creates a request (and provides the correct credentials), we can access the User object via the auth property on the request.

let user = try req.auth.require(User.self)

Vapor also has built-in support for user tokens and JWTs.

In practice

To properly test Vapor, I created a demo project. I chose the idea of a seat reservation tool. It allows users to sign up and create or join locations, where they can then reserve a seat for one or more days or see the reservations of their peers. The server provides 19 API endpoints in total, uses user/password as well as user token authentification, includes rate limiting, has custom query validation, and stores the data in an SQLite database. The whole project contains only a little over 1000 lines of code.

In addition, I built a client app for iOS, iPadOS & macOS. Sharing the DTOs between the server and the client app really came in handy. Thanks to the power of extensions, I could easily adapt the DTOs depending on the situation, like conforming them to the Content protocol on the server.

extension DTO.User: Content {}

Since the DTOs are already Codable, I didn’t have to add anything else.

Conclusion

As an iOS developer I found Vapor to be surpisingly easy to learn. The first-class documentation with many examples is really helpful. The database framework and its built-in support for authentification make even rather complicated applications straight forward to build.

My recommendation therefore is to at least try out Vapor with us when creating a backend for a new mobile app. Vapor can even be a good choice for larger backend applications, thanks to its excellent performance characteristics.

App Platzda
Screenshot von App Platzda
Melvin Gundlach

Author

Melvin Gundlach
Advanced Developer