DEV Community

Valentin Knabel
Valentin Knabel

Posted on • Originally published at vknabel.com on

Adopting Swift Async Await in Vapor

A few months ago Swift 5.5 has been released and made async/await available. And in 4.50.0 Vapor added support for it, too!

If you are still on Vapor 3, you first need to upgrade your server to Vapor 4.

Now we can migrate most usages of Swift NIO's EventLoopFuture with async. But we don't have to! This is not a breaking change. I recently performed this upgrade for the server of my app Puffery and as both, the client and the server are open source I will include links to the respective git commits.

_ Puffery is an app to send messages into channels using Shortcuts or HTTP. This will trigger a push notification to all clients that have subscribed. Within the app you can view your messages and channels._

I wouldn't recommend to directly replace all occurrences of EventLoopFuture. If you aren't going to touch specific code paths in a while, there is no need to migrate those. But we'll come back to that later.

Upgrading to Swift 5.5

If you haven't already, you need to upgrade your Swift Tools Version within your Package.swift-manifest:

// swift-tools-version:5.5
import PackageDescription
Enter fullscreen mode Exit fullscreen mode

Now a few lines later we need to upgrade to a newer macOS version, because async/await not only requires Swift 5.5, but also macOS 12 Monterey. Make sure you have upgraded accordingly. Otherwise you'd need to work on a linux machine or within a docker container.

let package = Package(
    name: "PufferyServer",
    platforms: [
        .macOS(.v12), // upgrade to .v12
    ],
Enter fullscreen mode Exit fullscreen mode

Next up, we need to bump our dependencies. As we want to rely on special features of the new Vapor, we explicitly go from: "4.50.0". Repeat this with other dependencies like Fluent.

        // ...
    dependencies: [
        // ...
        .package(url: "https://github.com/vapor/vapor.git", from: "4.50.0"), // upgrade to 4.50.0
        // ...
        ],
Enter fullscreen mode Exit fullscreen mode

Now, to silence a warning, we need to explicitly declare our Run target as executableTarget.

    targets: [
        // ...
        .executableTarget(name: "Run", dependencies: ["App"]),
        // ...
        ]
)
Enter fullscreen mode Exit fullscreen mode

If you use a Dockerfile, build FROM swift:5.5 as build. Also if present don't forget to update your .swift-version-file and your CI.

Now update your packages using swift package update. If you use Xcode, also update your dependencies using File > Packages > Update to Latest Package Versions to keep them in sync. In theory swift build and swift test should run without any errors. If it does, fix those and proceed.

git commit -am "Upgraded PufferyServer to Swift 5.5"

Adopting Async Await

Now that we upgraded our new Swift version and updated our dependencies, let's get started with our migration.

We will incrementally do tiny steps and migrate every function after another. But it doesn't make sense to migrate all functions immediately. If you haven't touched specific files in a while, there is no need to do so now. A great example are your database migrations. You won't touch them anyways. Just write new ones with async/await and you are fine.

In my opinion, controllers are the easiest place to get started. Later you can tackle migrate Jobs or ScheduledJobs. Then your services and your repositories.

The easiest places to upgrade will most likely be your Fluent queries: there are overloads for .find() and .all() to return EventLoopFuture and async throws.

Migrate the function signature

- func messagesForAllChannels(_ req: Request) throws -> EventLoopFuture<[MessageResponse]> {
+ func messagesForAllChannels(_ req: Request) async throws -> [MessageResponse] {
Enter fullscreen mode Exit fullscreen mode

Now fix all issues within the function. Then fix the errors of all callers.

If you temporarily converted invocations of this method from EventLoopFuture to an async function using .get(), it is now time to remove it.

Migrate Protocol Methods if directly affected

Most protocols need to be prefixed with Async like AsyncJob or AsyncScheduledJob. Then you can replace all function signatures.

I need async, but I have an EventLoopFuture

To convert a not yet converted EventLoopFuture, we call EventLoopFuture<V>.get() async throws -> V. You can migrate the function later.

try await theEventLoopFuture.get()
Enter fullscreen mode Exit fullscreen mode

I need an EventLoopFuture, but I have an async function

Sometimes I decided to keep some function signatures as they were and I did not migrate them. For those cases I created a small helper function to create an EventLoopFuture from an async task.

extension EventLoop {
    func from<T>(task: @escaping () async throws -> T) -> EventLoopFuture<T> {
        let promise = makePromise(of: T.self)
        promise.completeWithTask { try await task() }
        return promise.futureResult
    }
}
Enter fullscreen mode Exit fullscreen mode

For example executing multiple futures in parallel is easy with eventLoop.flatten, but it's much harder with async/await.

Migrate .flatMap

Migrate .flatMap({ messages in doSomething(messages) }) to let result = try await doSomething(messages).get().

Migrate .flatMapThrowing

Migrate .flatMapThrowing({ messages in doSomething(messages) }) to let result = try doSomething(messages)

Migrate eventLoop.flatten

Executing multiple futures in parallel is easy with eventLoop.flatten, but it's much harder with async/await.

I'd recommend to keep this part as is, and to keep this part as EventLoopFuture. See I need an EventLoopFuture, but I have async.

Migrate .transform(to:)

This is straight forward: use the value directly. Typically you'd return this.

Sometimes I used transform within a flatMap to keep the same return value. Now, just try await these side effects.

-   .flatMap({ user in
- user.update(on: req.db)
- .transform(to: user)
-   })
+   try await update(on: req.db)
Enter fullscreen mode Exit fullscreen mode

Migrate .always(_:)

.always will be executed when an EventLoopFuture fails and when it succeeds. This is the same behaviour of defer with async/await!

-   return computeSomething()
- .always { _ in
- doSomething()
- }
+ defer {
+ doSomething()
+ }
+   return try await computeSomething()
Enter fullscreen mode Exit fullscreen mode

_ Attention: you probably need to move your defer up. Using async/await will likely introduce more return and throw statements which will exit your functions early._

Returning constant futures

If you currently throw a failing future, just throw the error directly.

- return req.eventLoop.future(error: Abort(.notFound))
+ throw Abort(.notFound)
Enter fullscreen mode Exit fullscreen mode

To replace a succeeding future, return the value directly.

- return req.eventLoop.future(success: value)
+ return value
Enter fullscreen mode Exit fullscreen mode

If thee future(error:) was embedded within a do-catch to lift errors to an EventLoopFuture, you can probably remove the do-catch and mark the function as throws instead.

Test and Commit

Do not forget to regularly run your tests and to keep your project in a green state. From time to time, do some commits.

git commit -am "Use async/await for Vapor"

Real World Examples

In case you need guidance, here are typical examples for Vapor-endpoints. These examples should look familiar.

All code snippets are actual code from Puffery.

Example for a Fluent query

This function is part of the SubscriptionRepository. It is meant to be used from Controllers to consistently access, filter and sort the channel subscriptions of a user.

func all(of user: User) -> EventLoopFuture<[Subscription]> {
    do {
        return try Subscription.query(on: db)
            .filter(\Subscription.$user.$id == user.requireID())
            .sort(\.$createdAt, .descending)
            .all()
    } catch {
        return eventLoop.future(error: error)
    }
}
Enter fullscreen mode Exit fullscreen mode

We start by changing the type signature to async throws.

To fix the type errors, we could drop do-catch as the new variant is throwing. Previously it wasn't throwing as there is no overload of EventLoopFuture.flatMap that accepts throwing EventLoopFutures. Therefore all(of:) was required to lift thrown errors to futures.

As there is no distinction between directly throwing and a query failure with async/await we can get rid of the do-catch. And as Fluent has overloads for both EventLoopFuture and async throws we're done here.

func all(of user: User) async throws -> [Subscription] {
    try await Subscription.query(on: db)
      .filter(\Subscription.$user.$id == user.requireID())
    .sort(\.$createdAt, .descending)
    .all()
 }
Enter fullscreen mode Exit fullscreen mode

Example Migrations for simple read-only endpoints

My MessageController looked like this:

final class MessageController {
    func messagesForAllChannels(_ req: Request) throws -> EventLoopFuture<[MessageResponse]> {
        let user = try req.auth.require(User.self)

        return req.subscriptions.all(of: user)
        .flatMap(req.messages.latestSubscribed(for:))
      .flatMapThrowing { messages in
          try messages.map {
            try MessageResponse($0.message, subscription: $0.subscription)
                }
     }
    }

    // other endpoints ...
}
Enter fullscreen mode Exit fullscreen mode

This code should be familiar to any Vapor developer. I started migration with the function signature, replaced flatMap and flatMapThrowing and inserted the .get().

func messagesForAllChannels(_ req: Request) async throws -> [MessageResponse] {
    let user = try req.auth.require(User.self)

  let subs = try await req.subscriptions.all(of: user).get()
  let messages = try await req.messages.latestSubscribed(for: subs).get()
  return try messages.map {
      try MessageResponse($0.message, subscription: $0.subscription)
    }
}
Enter fullscreen mode Exit fullscreen mode

After I migrated my SubscriptionRepository, I could even get rid of the trailing .get().

Example Migration for simple write-endpoints

This function's migration path was more complex.

func confirmEmailIfNeeded(_ user: User) throws -> EventLoopFuture<Void> {
    guard let emailAddress = user.email else {
        return req.eventLoop.future()
    }

    let confirmation = try Confirmation(scope: "email", snapshot: emailAddress, user: user)
    return confirmation.create(on: req.db)
        .flatMapThrowing { _ in
            try Email(/*...*/)
        }
        .flatMap { email in
            self.req.queue.dispatch(SendEmailJob.self, email)
        }
}
Enter fullscreen mode Exit fullscreen mode

Here we could completely remove the empty req.eventLoop.future(). A simple, blank return statement is enough. And creating models doesn't force us anymore to nest everything one level deeper. We await the result, but we discard it.

func confirmEmailIfNeeded(_ user: User) async throws {
    guard let emailAddress = user.email else {
        return
    }

    let confirmation = try Confirmation(scope: "email", snapshot: emailAddress, user: user)
    try await confirmation.create(on: req.db)
    let email = try Email(/*...*/)
    try await req.queue.dispatch(SendEmailJob.self, email)
}
Enter fullscreen mode Exit fullscreen mode

Summary

Within this post we upgraded our Swift version, Package manifest, docker / CI Swift versions and our dependencies. Then we incrementally migrated portions of our codebase by following a set of rules. What was your migration like? Did you experience any problems?

If you wish, check out the open source repository of Puffery or check it out on the App Store. If you have any questions or feedback don't hesitate to ask me on twitter @vknabel or join the Puffery disussions.

Top comments (0)